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.
207 lines
6.8 KiB
JavaScript
207 lines
6.8 KiB
JavaScript
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 };
|
|
}
|