Expand voxel gameplay, lighting, full-height streaming and world imports
MVP checks / mvp (push) Canceled after 0s
MVP checks / mvp (push) Canceled after 0s
Add shared Rust/WASM physics, worker meshing and diagnostics, 64-chunk full-height streaming, atlas texture support, and baseline world import. Document the current implementation and include the supplied in-game lobby screenshot.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { WORLD_MIN_Y, WORLD_MAX_Y } from './view-distance.js';
|
||||
const positionKey = (pos) => pos.join(",");
|
||||
const integerPosition = (pos) =>
|
||||
Array.isArray(pos) && pos.length === 3 && pos.every(Number.isSafeInteger);
|
||||
const samePosition = (a, b) =>
|
||||
integerPosition(a) &&
|
||||
integerPosition(b) &&
|
||||
a.every((value, i) => value === b[i]);
|
||||
const sectionOf = (pos) => pos.map((value) => Math.floor(value / 16));
|
||||
|
||||
export function getViewBounds(message) {
|
||||
const center =
|
||||
message.view_center ||
|
||||
message.spawn?.map((value) => Math.floor(value / 16) * 16);
|
||||
if (!integerPosition(center)) return null;
|
||||
if (integerPosition(message.view_min) && integerPosition(message.view_max))
|
||||
return { min: [...message.view_min], max: [...message.view_max],
|
||||
...(message.full_height === true && message.view_min[1] === WORLD_MIN_Y && message.view_max[1] === WORLD_MAX_Y ? {fullHeight:true} : {}) };
|
||||
// Protocol 1 servers without chunk_stream_v1 use an unaligned vertical view.
|
||||
return {
|
||||
min: center.map((value, i) => value - (i === 1 ? 8 : 32)),
|
||||
max: center.map((value) => value + 31),
|
||||
};
|
||||
}
|
||||
|
||||
export function positionInView(pos, bounds) {
|
||||
return (
|
||||
!bounds ||
|
||||
pos.every((value, i) => value >= bounds.min[i] && value <= bounds.max[i])
|
||||
);
|
||||
}
|
||||
|
||||
function recordMap(records) {
|
||||
const result = new Map();
|
||||
for (const record of records) {
|
||||
if (
|
||||
!integerPosition(record?.pos) ||
|
||||
!Number.isSafeInteger(record.block) ||
|
||||
record.block < 0
|
||||
)
|
||||
throw Error("Invalid block record");
|
||||
if (record.block) result.set(positionKey(record.pos), record.block);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Update the existing map so unchanged blocks never trigger mesh rebuilds. */
|
||||
export function mergeSnapshot(blocks, records) {
|
||||
const next = recordMap(records),
|
||||
changes = [];
|
||||
for (const [id] of blocks) {
|
||||
if (!next.has(id)) {
|
||||
blocks.delete(id);
|
||||
changes.push({ pos: id.split(",").map(Number), block: 0 });
|
||||
}
|
||||
}
|
||||
for (const [id, block] of next) {
|
||||
if (blocks.get(id) !== block) {
|
||||
blocks.set(id, block);
|
||||
changes.push({ pos: id.split(",").map(Number), block });
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function sectionWindow(center) {
|
||||
const c = sectionOf(center),
|
||||
sections = new Set();
|
||||
for (let x = c[0] - 2; x <= c[0] + 1; x++)
|
||||
for (let y = c[1] - 1; y <= c[1] + 1; y++)
|
||||
for (let z = c[2] - 2; z <= c[2] + 1; z++)
|
||||
sections.add(positionKey([x, y, z]));
|
||||
return sections;
|
||||
}
|
||||
|
||||
function alignedCenter(center) {
|
||||
return integerPosition(center) && center.every((value) => value % 16 === 0);
|
||||
}
|
||||
|
||||
function* entriesInSections(blocks, sections) {
|
||||
if (typeof blocks.keysInSection === "function") {
|
||||
for (const section of sections)
|
||||
for (const key of blocks.keysInSection(section))
|
||||
yield [key, blocks.get(key), section];
|
||||
} else {
|
||||
for (const [key, block] of blocks) {
|
||||
const section = positionKey(sectionOf(key.split(",").map(Number)));
|
||||
if (sections.has(section)) yield [key, block, section];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the complete transition before mutating blocks or view metadata.
|
||||
* Consumers handling section unloads separately can omit their block-zero
|
||||
* records; the departing blocks are still removed from the map.
|
||||
*/
|
||||
export function mergeChunkUpdate(
|
||||
view,
|
||||
message,
|
||||
{ includeUnloadedChanges = true } = {},
|
||||
) {
|
||||
const invalid = { status: "resync", changes: [] };
|
||||
if (message.world !== view.world) return { status: "ignored", changes: [] };
|
||||
if (
|
||||
!Number.isSafeInteger(message.revision) ||
|
||||
message.revision !== view.revision ||
|
||||
!alignedCenter(view.viewCenter) ||
|
||||
!samePosition(message.from_center, view.viewCenter) ||
|
||||
!alignedCenter(message.view_center) ||
|
||||
!samePosition(
|
||||
message.view_min,
|
||||
message.view_center.map((value, i) => value - (i === 1 ? 16 : 32)),
|
||||
) ||
|
||||
!samePosition(
|
||||
message.view_max,
|
||||
message.view_center.map((value) => value + 31),
|
||||
) ||
|
||||
!Array.isArray(message.unload) ||
|
||||
!Array.isArray(message.sections)
|
||||
)
|
||||
return invalid;
|
||||
const previous = sectionWindow(view.viewCenter),
|
||||
next = sectionWindow(message.view_center),
|
||||
departing = new Set([...previous].filter((id) => !next.has(id))),
|
||||
entering = new Set([...next].filter((id) => !previous.has(id))),
|
||||
unloaded = new Set(),
|
||||
received = new Set(),
|
||||
replacement = new Map();
|
||||
for (const pos of message.unload) {
|
||||
if (!integerPosition(pos)) return invalid;
|
||||
const id = positionKey(pos);
|
||||
if (!departing.has(id) || unloaded.has(id)) return invalid;
|
||||
unloaded.add(id);
|
||||
}
|
||||
for (const section of message.sections) {
|
||||
if (!integerPosition(section?.section) || !Array.isArray(section.blocks))
|
||||
return invalid;
|
||||
const id = positionKey(section.section),
|
||||
[sx, sy, sz] = section.section;
|
||||
if (!entering.has(id) || received.has(id)) return invalid;
|
||||
received.add(id);
|
||||
for (const record of section.blocks) {
|
||||
if (
|
||||
!integerPosition(record?.pos) ||
|
||||
Math.floor(record.pos[0] / 16) !== sx ||
|
||||
Math.floor(record.pos[1] / 16) !== sy ||
|
||||
Math.floor(record.pos[2] / 16) !== sz ||
|
||||
!Number.isSafeInteger(record.block) ||
|
||||
record.block < 0
|
||||
)
|
||||
return invalid;
|
||||
const key = positionKey(record.pos);
|
||||
if (replacement.has(key)) return invalid;
|
||||
replacement.set(key, {
|
||||
pos: record.pos,
|
||||
block: record.block,
|
||||
section: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (unloaded.size !== departing.size || received.size !== entering.size)
|
||||
return invalid;
|
||||
|
||||
const changes = [],
|
||||
blocks = view.blocks,
|
||||
bulkUnload =
|
||||
typeof blocks.keysInSection === "function" &&
|
||||
typeof blocks.deleteSection === "function";
|
||||
if (bulkUnload) {
|
||||
for (const section of departing) {
|
||||
if (includeUnloadedChanges)
|
||||
for (const key of blocks.keysInSection(section))
|
||||
if (blocks.get(key) !== 0)
|
||||
changes.push({ pos: key.split(",").map(Number), block: 0 });
|
||||
blocks.deleteSection(section);
|
||||
}
|
||||
}
|
||||
for (const [id, block, section] of entriesInSections(
|
||||
blocks,
|
||||
bulkUnload ? entering : new Set([...departing, ...entering]),
|
||||
)) {
|
||||
const record = replacement.get(id),
|
||||
newBlock = record?.block || 0;
|
||||
if (newBlock !== block) {
|
||||
if (newBlock) blocks.set(id, newBlock);
|
||||
else blocks.delete(id);
|
||||
if (includeUnloadedChanges || !departing.has(section))
|
||||
changes.push({
|
||||
pos: record?.pos || id.split(",").map(Number),
|
||||
block: newBlock,
|
||||
});
|
||||
}
|
||||
replacement.delete(id);
|
||||
}
|
||||
for (const [id, { pos, block, section }] of replacement) {
|
||||
if (!block) continue;
|
||||
if (typeof blocks.setInSection === "function")
|
||||
blocks.setInSection(id, block, section);
|
||||
else blocks.set(id, block);
|
||||
changes.push({ pos, block });
|
||||
}
|
||||
view.viewCenter = [...message.view_center];
|
||||
view.viewBounds = getViewBounds(message);
|
||||
return { status: "applied", changes };
|
||||
}
|
||||
Reference in New Issue
Block a user