Complete local voxel MVP with server, client, MCP, packages and world interchange

This commit is contained in:
Emil
2026-09-14 17:35:17 +03:00
parent cf8d1aae2e
commit b6ba064339
98 changed files with 58116 additions and 194 deletions
+1151
View File
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Shacraft · Мир</title>
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 32 32%27%3E%3Cpath fill=%27%2394ae81%27 d=%27M16 1 30 9v15L16 32 2 24V9z%27/%3E%3C/svg%3E"
/>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<canvas
id="world-canvas"
aria-label="Трёхмерный мир Shacraft. WASD — движение, пробел — прыжок, мышь — обзор."
></canvas>
<div id="labels" aria-hidden="true"></div>
<header class="topbar">
<div class="identity">
<svg viewBox="0 0 28 32" width="27" height="30" aria-hidden="true">
<path d="M14 1 27 8.5 14 16 1 8.5Z" fill="#c0d3a9" />
<path d="M1 8.5 14 16v15L1 23.5Z" fill="#6d8560" />
<path d="m14 16 13-7.5v15L14 31Z" fill="#94ae81" />
</svg>
<div>
<strong>Shacraft</strong
><button id="world-button" class="world-name" title="Выбрать мир">
Подключение… ▾
</button>
</div>
<span id="connection" role="status">Загрузка</span>
</div>
<nav aria-label="Игровое меню">
<button id="library-button">Блоки <kbd>E</kbd></button
><button id="diagnostics-button" title="Открыть диагностику">
Диагностика <kbd>F3</kbd></button
><button id="menu-button" aria-label="Меню игры">☰</button>
</nav>
</header>
<div id="crosshair" aria-hidden="true"></div>
<div id="target-label"></div>
<div id="match" hidden></div>
<div id="toast" role="status" hidden></div>
<div class="bottom-left">
<div id="chat-log" aria-live="polite"></div>
<form id="chat-form" hidden>
<input
id="chat-input"
autocomplete="off"
maxlength="240"
aria-label="Сообщение в чат"
placeholder="Сообщение · Enter — отправить, Esc — закрыть"
/>
</form>
<div id="hints">
<span><kbd>W A S D</kbd> движение</span
><span><kbd>Пробел</kbd> прыжок</span
><span><kbd>ЛКМ / ПКМ</kbd> убрать / поставить</span
><span><kbd>T</kbd> чат</span>
</div>
</div>
<footer>
<div id="selected-name"></div>
<div id="hotbar" role="toolbar" aria-label="Быстрый выбор блоков"></div>
<div id="play-hint">
Нажмите на мир для управления · без захвата мыши: удерживайте и двигайте
</div>
</footer>
<div id="fatal" hidden role="alert"></div>
<aside id="menu" class="panel" hidden aria-label="Меню игры">
<div class="panel-head">
<h1>Меню игры</h1>
<button data-close="menu" aria-label="Закрыть меню">×</button>
</div>
<label for="player-name">Имя игрока</label>
<div class="inline">
<input id="player-name" maxlength="24" autocomplete="off" /><button
id="rename-button"
>
Применить
</button>
</div>
<button id="resume-button" class="primary wide">Вернуться в мир</button
><button id="respawn-button" class="wide">
Вернуться к точке появления</button
><button id="spleef-button" class="wide">Начать Spleef</button>
<p class="muted">
В Spleef убирайте снег под соперником. Последний игрок на арене
побеждает.
</p>
<div class="rule"></div>
<label for="sensitivity">Чувствительность мыши</label
><input
type="range"
id="sensitivity"
min="0.0008"
max="0.006"
step="0.0002"
value="0.0024"
/>
<p class="muted">
Esc освобождает мышь. Перетаскивание работает и без захвата. Средняя
кнопка выбирает блок под прицелом.
</p>
<button id="reconnect-button" class="wide">Переподключиться</button>
</aside>
<aside id="worlds" class="panel" hidden aria-label="Выбор мира">
<div class="panel-head">
<h1>Миры</h1>
<button data-close="worlds" aria-label="Закрыть миры">×</button>
</div>
<p class="muted">Изменения каждого экземпляра сохраняются независимо.</p>
<div id="world-list"></div>
</aside>
<aside
id="library"
class="panel library"
hidden
aria-label="Каталог блоков"
>
<div class="panel-head">
<div>
<h1>Библиотека блоков</h1>
<p id="catalog-count" class="muted">Каталог сервера</p>
</div>
<button data-close="library" aria-label="Закрыть библиотеку">×</button>
</div>
<input
id="catalog-search"
type="search"
placeholder="Поиск: oak, stairs, glass…"
aria-label="Поиск по каталогу"
/>
<p class="muted">
Выберите блок для текущей ячейки. Свойства состояний показаны под
названием.
</p>
<div id="catalog-list"></div>
<button id="catalog-more" class="wide">Показать ещё</button>
</aside>
<aside
id="diagnostics"
class="panel diagnostics"
hidden
aria-label="Диагностика"
>
<div class="panel-head">
<h1>Диагностика</h1>
<button data-close="diagnostics" aria-label="Закрыть диагностику">
×
</button>
</div>
<dl id="debug-values"></dl>
<div id="package-status" class="muted">Пакеты ещё не загружены</div>
<button id="resync-button" class="wide">Сверить снимок мира</button>
<p class="muted">
Сервер подтверждает движение и правки. RAM процесса и полезные данные
кэша — разные измерения.
</p>
</aside>
<script type="module" src="/app.js"></script>
</body>
</html>
+172
View File
@@ -0,0 +1,172 @@
export const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
export const key = (p) => `${p[0]},${p[1]},${p[2]}`;
export const direction = (yaw, pitch) => [
Math.sin(yaw) * Math.cos(pitch),
Math.sin(pitch),
-Math.cos(yaw) * Math.cos(pitch),
];
export function multiply(a, b) {
const o = new Float32Array(16);
for (let c = 0; c < 4; c++)
for (let r = 0; r < 4; r++)
for (let k = 0; k < 4; k++) o[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
return o;
}
export function perspective(fovy, aspect, near, far) {
const f = 1 / Math.tan(fovy / 2),
nf = 1 / (near - far);
return new Float32Array([
f / aspect,
0,
0,
0,
0,
f,
0,
0,
0,
0,
(far + near) * nf,
-1,
0,
0,
2 * far * near * nf,
0,
]);
}
export function viewMatrix(eye, yaw, pitch) {
const d = direction(yaw, pitch),
z = d.map((v) => -v),
x = [Math.cos(yaw), 0, Math.sin(yaw)],
y = [
-Math.sin(yaw) * Math.sin(pitch),
Math.cos(pitch),
Math.cos(yaw) * Math.sin(pitch),
];
return new Float32Array([
x[0],
y[0],
z[0],
0,
x[1],
y[1],
z[1],
0,
x[2],
y[2],
z[2],
0,
-dot(x, eye),
-dot(y, eye),
-dot(z, eye),
1,
]);
}
export function dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
export function project(p, m, w, h) {
const x = m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
y = m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
z = m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
q = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15];
return q > 0 && z / q < 1
? [((x / q + 1) * w) / 2, ((1 - y / q) * h) / 2]
: null;
}
export const unitBox = { min: [0, 0, 0], max: [1, 1, 1] };
export function normalizeBoxes(boxes) {
return (boxes || [])
.map((b) =>
Array.isArray(b) ? { min: b.slice(0, 3), max: b.slice(3, 6) } : b,
)
.filter((b) => b.min?.length === 3 && b.max?.length === 3);
}
export function rayBox(origin, dir, min, max, reach) {
let tmin = -Infinity,
tmax = Infinity,
normal = [0, 0, 0];
for (let i = 0; i < 3; i++) {
if (Math.abs(dir[i]) < 1e-9) {
if (origin[i] < min[i] || origin[i] > max[i]) return null;
continue;
}
let a = (min[i] - origin[i]) / dir[i],
b = (max[i] - origin[i]) / dir[i],
sign = -Math.sign(dir[i]);
if (a > b) [a, b] = [b, a];
if (a > tmin) {
tmin = a;
normal = [0, 0, 0];
normal[i] = sign;
}
tmax = Math.min(tmax, b);
if (tmin > tmax) return null;
}
if (tmax < 0 || tmin > reach) return null;
return { distance: Math.max(0, tmin), normal };
}
// A bounded DDA finds candidate cells; exact material boxes determine selection and face.
export function raycast(origin, dir, blocks, materials, reach = 6) {
const cell = origin.map(Math.floor),
step = dir.map(Math.sign);
const delta = dir.map((v) => (v === 0 ? Infinity : Math.abs(1 / v)));
const next = dir.map((v, i) =>
v === 0 ? Infinity : ((v > 0 ? cell[i] + 1 : cell[i]) - origin[i]) / v,
);
const visited = new Set();
let best = null;
for (let n = 0; n < 128; n++) {
// Official shapes can extend outside their owning cell (e.g. a fence is 1.5 high).
// Inspect neighboring owners once, but intersect their exact boxes in world space.
for (let x = -3; x <= 3; x++)
for (let y = -3; y <= 3; y++)
for (let z = -3; z <= 3; z++) {
const owner = [cell[0] + x, cell[1] + y, cell[2] + z],
idKey = key(owner);
if (visited.has(idKey)) continue;
visited.add(idKey);
const id = blocks.get(idKey);
if (!id) continue;
const mat = materials.get(id),
boxes = mat ? mat.render : [unitBox];
for (const box of boxes || [unitBox]) {
const hit = rayBox(
origin,
dir,
box.min.map((v, i) => v + owner[i]),
box.max.map((v, i) => v + owner[i]),
reach,
);
if (hit && (!best || hit.distance < best.distance))
best = { ...hit, pos: owner, block: id };
}
}
const axis =
next[0] < next[1]
? next[0] < next[2]
? 0
: 2
: next[1] < next[2]
? 1
: 2;
if (
next[axis] > reach ||
!Number.isFinite(next[axis]) ||
(best && next[axis] > best.distance)
)
break;
cell[axis] += step[axis];
next[axis] += delta[axis];
}
return best;
}
export function isFullCube(mat) {
const b = mat?.render;
return (
b?.length === 1 &&
b[0].min.every((v) => v === 0) &&
b[0].max.every((v) => v === 1) &&
!mat.transparent
);
}
+1
View File
@@ -0,0 +1 @@
{"name":"shacraft-client","version":"0.1.0","private":true,"type":"module","scripts":{"test":"node --test tests/*.test.js"}}
+569
View File
@@ -0,0 +1,569 @@
import {
multiply,
perspective,
viewMatrix,
key,
isFullCube,
unitBox,
project,
} from "./math.js";
const vertex = `#version 300 es
precision highp float;layout(location=0) in vec3 aPos;layout(location=1) in vec3 aColor;layout(location=2) in vec3 aNormal;layout(location=3) in float aMaterial;layout(location=4) in float aOpacity;uniform mat4 uVP;out vec3 vPos;out vec3 vColor;out vec3 vNormal;flat out float vMaterial;out float vOpacity;void main(){vOpacity=aOpacity;vPos=aPos;vColor=aColor;vNormal=aNormal;vMaterial=aMaterial;gl_Position=uVP*vec4(aPos,1.);}`;
const fragment = `#version 300 es
precision highp float;in vec3 vPos;in vec3 vColor;in vec3 vNormal;flat in float vMaterial;in float vOpacity;uniform vec3 uEye;uniform sampler2D uTexture;uniform bool uTextured;uniform sampler2D uEffectTexture;uniform bool uEffectTextured;uniform float uPulse;out vec4 outColor;
float hash(vec3 p){return fract(sin(dot(p,vec3(127.1,311.7,74.7)))*43758.5453);}
vec3 shacraftBounceTint(vec3 color,float pulse){return color;}
void main(){vec3 n=normalize(vNormal);vec2 uv=abs(n.y)>.5?vPos.xz:(abs(n.x)>.5?vPos.zy:vPos.xy);vec3 p=floor((vPos+n*.002)*16.);float noise=hash(p)*.14-.07;float pattern=1.;
if(vMaterial>1.5&&vMaterial<2.5){pattern=.89+.11*step(.07,fract(uv.y*4.));pattern*=.9+.1*step(.055,fract(uv.x*.5+floor(uv.y*4.)*.5));}
if(vMaterial>2.5&&vMaterial<3.5){pattern=.87+.13*step(.06,fract(uv.y*4.));pattern*=.84+.16*step(.045,fract(uv.x*2.+floor(uv.y*4.)*.5));}
if(vMaterial>3.5&&vMaterial<4.5){pattern=.91+.09*sin(floor(uv.x*16.)*1.73+sin(floor(uv.y*16.)*.22));}
float tex=uTextured?mix(.84,1.12,texture(uTexture,uv).r):1.;if(vMaterial>4.5&&uEffectTextured)tex=mix(.75,1.2,texture(uEffectTexture,uv).g);float diffuse=max(0.,dot(n,normalize(vec3(-.45,.86,.3))));float light=.69+diffuse*.34;vec3 color=clamp((vColor+noise)*pattern*tex*light,0.,1.);if(vMaterial>.5&&vMaterial<1.5&&n.y<.5)color=mix(color,vec3(.38,.29,.17),.65);
if(vMaterial>4.5)color=shacraftBounceTint(color,uPulse);float fog=1.-exp(-pow(distance(vPos,uEye)/74.,2.));color=mix(color,vec3(.70,.80,.79),fog*.91);outColor=vec4(color,vOpacity);}`;
const skyVert = `#version 300 es
precision highp float;out vec2 vUV;void main(){vec2 p=vec2((gl_VertexID<<1)&2,gl_VertexID&2);vUV=p;gl_Position=vec4(p*2.-1.,1.,1.);}`;
const skyFrag = `#version 300 es
precision highp float;in vec2 vUV;uniform float uPitch;uniform float uYaw;uniform float uAspect;out vec4 outColor;void main(){float h=vUV.y+uPitch*.72;vec3 color=mix(vec3(.77,.83,.77),vec3(.38,.65,.78),smoothstep(.14,1.2,h));vec2 sun=vec2(.77-uYaw*.3,.82-uPitch*.72);float d=length((vUV-sun)*vec2(uAspect,1.));color=mix(color,vec3(1.,.98,.8),smoothstep(.048,.039,d)*.98);color+=vec3(.045,.025,0.)*exp(-d*7.);outColor=vec4(color,1.);}`;
const lineVertex = `#version 300 es
precision highp float;layout(location=0)in vec3 aPos;uniform mat4 uVP;void main(){gl_Position=uVP*vec4(aPos,1.);}`;
const lineFragment = `#version 300 es
precision highp float;out vec4 outColor;void main(){outColor=vec4(.13,.21,.12,1.);}`;
const faces = [
{
n: [1, 0, 0],
v: [
[1, 0, 1],
[1, 0, 0],
[1, 1, 0],
[1, 1, 1],
],
},
{
n: [-1, 0, 0],
v: [
[0, 0, 0],
[0, 0, 1],
[0, 1, 1],
[0, 1, 0],
],
},
{
n: [0, 1, 0],
v: [
[0, 1, 1],
[1, 1, 1],
[1, 1, 0],
[0, 1, 0],
],
},
{
n: [0, -1, 0],
v: [
[0, 0, 0],
[1, 0, 0],
[1, 0, 1],
[0, 0, 1],
],
},
{
n: [0, 0, 1],
v: [
[0, 0, 1],
[1, 0, 1],
[1, 1, 1],
[0, 1, 1],
],
},
{
n: [0, 0, -1],
v: [
[1, 0, 0],
[0, 0, 0],
[0, 1, 0],
[1, 1, 0],
],
},
];
function program(gl, vs, fs) {
const p = gl.createProgram();
for (const [type, source] of [
[gl.VERTEX_SHADER, vs],
[gl.FRAGMENT_SHADER, fs],
]) {
const s = gl.createShader(type);
gl.shaderSource(s, source);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
throw Error(gl.getShaderInfoLog(s));
gl.attachShader(p, s);
gl.deleteShader(s);
}
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS))
throw Error(gl.getProgramInfoLog(p));
return p;
}
function boxVertices(
out,
pos,
box,
color,
material = 0,
skip = () => false,
yaw = 0,
opacity = 1,
) {
for (let f = 0; f < 6; f++) {
if (skip(faces[f].n)) continue;
const face = faces[f],
c = Math.cos(yaw),
s = Math.sin(yaw),
n = [
face.n[0] * c - face.n[2] * s,
face.n[1],
face.n[0] * s + face.n[2] * c,
];
for (const i of [0, 1, 2, 0, 2, 3]) {
const v = face.v[i].map(
(v, k) => box.min[k] + v * (box.max[k] - box.min[k]),
);
out.push(
pos[0] + v[0] * c - v[2] * s,
pos[1] + v[1],
pos[2] + v[0] * s + v[2] * c,
...color,
...n,
material,
opacity,
);
}
}
}
function materialType(state = "", effect) {
if (effect === "bounce") return 5;
if (state.includes("grass_block")) return 1;
if (state.includes("planks")) return 2;
if (state.includes("brick")) return 3;
if (/log|stem|wood/.test(state)) return 4;
return 0;
}
const section = (p) => p.map((v) => Math.floor(v / 16)).join(",");
export class Renderer {
constructor(canvas) {
this.canvas = canvas;
const gl = (this.gl = canvas.getContext("webgl2", {
antialias: true,
alpha: false,
preserveDrawingBuffer: true,
powerPreference: "high-performance",
}));
if (!gl)
throw Error(
"WebGL2 недоступен. Включите аппаратное ускорение или откройте клиент в браузере с поддержкой WebGL2.",
);
this.program = program(gl, vertex, fragment);
this.sky = program(gl, skyVert, skyFrag);
this.line = program(gl, lineVertex, lineFragment);
this.sections = new Map();
this.dirty = new Set();
this.blocks = new Map();
this.materials = new Map();
this.texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([180, 180, 180, 255]),
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
this.textured = false;
this.effectTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([180, 180, 180, 255]),
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
this.effectTextured = false;
this.bounceAt = -10000;
this.dynamic = this.mesh([]);
this.lines = this.mesh([], 3);
this.triangles = 0;
this.vp = new Float32Array(16);
this.lost = false;
canvas.addEventListener("webglcontextlost", (e) => {
e.preventDefault();
this.lost = true;
});
canvas.addEventListener("webglcontextrestored", () => location.reload());
}
mesh(data, stride = 11) {
const gl = this.gl,
vao = gl.createVertexArray(),
buffer = gl.createBuffer();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW);
for (const [loc, size, offset] of stride === 3
? [[0, 3, 0]]
: [
[0, 3, 0],
[1, 3, 3],
[2, 3, 6],
[3, 1, 9],
[4, 1, 10],
]) {
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(
loc,
size,
gl.FLOAT,
false,
stride * 4,
offset * 4,
);
}
return { vao, buffer, count: data.length / stride };
}
disposeMesh(mesh) {
if (mesh.transparent) this.disposeMesh(mesh.transparent);
this.gl.deleteBuffer(mesh.buffer);
this.gl.deleteVertexArray(mesh.vao);
}
upload(mesh, data, stride = 11) {
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW);
mesh.count = data.length / stride;
}
setBounceShader(source) {
if (source.length > 32768) throw Error("Шейдер пакета слишком велик");
const next = program(
this.gl,
vertex,
fragment.replace(
"vec3 shacraftBounceTint(vec3 color,float pulse){return color;}",
source,
),
);
this.gl.deleteProgram(this.program);
this.program = next;
}
async setTexture(blob, effect = false) {
const bitmap = await createImageBitmap(blob);
this.gl.bindTexture(
this.gl.TEXTURE_2D,
effect ? this.effectTexture : this.texture,
);
this.gl.texImage2D(
this.gl.TEXTURE_2D,
0,
this.gl.RGBA,
this.gl.RGBA,
this.gl.UNSIGNED_BYTE,
bitmap,
);
this.gl.texParameteri(
this.gl.TEXTURE_2D,
this.gl.TEXTURE_WRAP_S,
this.gl.REPEAT,
);
this.gl.texParameteri(
this.gl.TEXTURE_2D,
this.gl.TEXTURE_WRAP_T,
this.gl.REPEAT,
);
bitmap.close();
if (effect) this.effectTextured = true;
else this.textured = true;
}
replace(blocks, materials) {
for (const m of this.sections.values()) this.disposeMesh(m);
this.sections.clear();
this.dirty.clear();
this.blocks = blocks;
this.materials = materials;
for (const k of blocks.keys())
this.dirty.add(section(k.split(",").map(Number)));
}
change(changes) {
for (const { pos } of changes) {
this.dirty.add(section(pos));
for (const f of faces)
this.dirty.add(section(pos.map((v, i) => v + f.n[i])));
}
}
invalidate() {
for (const k of this.blocks.keys())
this.dirty.add(section(k.split(",").map(Number)));
}
rebuild(id) {
const base = id.split(",").map((v) => Number(v) * 16),
vertices = [],
transparent = [];
for (let x = base[0]; x < base[0] + 16; x++)
for (let y = base[1]; y < base[1] + 16; y++)
for (let z = base[2]; z < base[2] + 16; z++) {
const pos = [x, y, z],
block = this.blocks.get(key(pos));
if (!block) continue;
const mat = this.materials.get(block) || {
color: [150, 152, 142],
render: [unitBox],
},
color = mat.color.map((v) => v / 255),
full = isFullCube(mat);
for (const box of mat.render || [unitBox]) {
boxVertices(
mat.opacity < 1 ? transparent : vertices,
pos,
box,
color,
materialType(mat.state, mat.effect),
(n) =>
full &&
isFullCube(
this.materials.get(
this.blocks.get(key(pos.map((v, i) => v + n[i]))),
),
),
0,
mat.opacity ?? 1,
);
}
}
let mesh = this.sections.get(id);
if (mesh) this.upload(mesh, vertices);
else if (vertices.length || transparent.length) {
mesh = this.mesh(vertices);
this.sections.set(id, mesh);
}
if (mesh) {
if (mesh.transparent) this.upload(mesh.transparent, transparent);
else if (transparent.length) mesh.transparent = this.mesh(transparent);
}
if (mesh && vertices.length === 0 && transparent.length === 0) {
this.disposeMesh(mesh);
this.sections.delete(id);
}
}
updateSelection(hit) {
const a = [];
if (hit) {
const mat = this.materials.get(hit.block),
boxes = mat?.render?.length ? mat.render : [unitBox];
for (const box of boxes) {
const points = [];
for (let i = 0; i < 8; i++)
points.push(
[0, 1, 2].map(
(k) =>
hit.pos[k] +
((i >> k) & 1 ? box.max[k] + 0.003 : box.min[k] - 0.003),
),
);
for (let i = 0; i < 8; i++)
for (let axis = 0; axis < 3; axis++)
if (!(i & (1 << axis)))
a.push(...points[i], ...points[i | (1 << axis)]);
}
}
this.upload(this.lines, a, 3);
}
draw(camera, players, entities, entityDefinitions) {
if (this.lost) return;
const gl = this.gl,
ratio = Math.min(devicePixelRatio || 1, 1.75),
w = Math.floor(this.canvas.clientWidth * ratio),
h = Math.floor(this.canvas.clientHeight * ratio);
if (this.canvas.width !== w || this.canvas.height !== h) {
this.canvas.width = w;
this.canvas.height = h;
}
gl.viewport(0, 0, w, h);
gl.clearColor(0.65, 0.79, 0.8, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.useProgram(this.sky);
gl.uniform1f(gl.getUniformLocation(this.sky, "uPitch"), camera.pitch);
gl.uniform1f(gl.getUniformLocation(this.sky, "uYaw"), camera.yaw);
gl.uniform1f(gl.getUniformLocation(this.sky, "uAspect"), w / h);
gl.bindVertexArray(null);
gl.drawArrays(gl.TRIANGLES, 0, 3);
this.vp = multiply(
perspective(Math.PI / 2.7, w / h, 0.06, 190),
viewMatrix(camera.eye, camera.yaw, camera.pitch),
);
const start = performance.now();
let rebuilt = 0;
for (const id of this.dirty) {
this.rebuild(id);
this.dirty.delete(id);
rebuilt++;
if (rebuilt >= 3 || performance.now() - start > 7) break;
}
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE);
gl.cullFace(gl.BACK);
gl.frontFace(gl.CCW);
gl.useProgram(this.program);
gl.uniformMatrix4fv(
gl.getUniformLocation(this.program, "uVP"),
false,
this.vp,
);
gl.uniform3fv(gl.getUniformLocation(this.program, "uEye"), camera.eye);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.uniform1i(gl.getUniformLocation(this.program, "uTexture"), 0);
gl.uniform1i(
gl.getUniformLocation(this.program, "uTextured"),
this.textured ? 1 : 0,
);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.effectTexture);
gl.uniform1i(gl.getUniformLocation(this.program, "uEffectTexture"), 1);
gl.uniform1i(
gl.getUniformLocation(this.program, "uEffectTextured"),
this.effectTextured ? 1 : 0,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uPulse"),
Math.max(0, 1 - (performance.now() - this.bounceAt) / 600),
);
this.triangles = 0;
for (const [id, m] of this.sections) {
const center = id.split(",").map((v) => Number(v) * 16 + 8);
if (
Math.hypot(center[0] - camera.eye[0], center[2] - camera.eye[2]) > 135
)
continue;
gl.bindVertexArray(m.vao);
gl.drawArrays(gl.TRIANGLES, 0, m.count);
this.triangles += m.count / 3;
}
const dynamic = [];
for (const p of players) {
const c = p.color || [0.29, 0.46, 0.36],
yaw = p.yaw || 0;
boxVertices(
dynamic,
p.position,
{ min: [-0.21, 0.63, -0.13], max: [0.21, 1.27, 0.13] },
c,
2,
() => false,
yaw,
);
boxVertices(
dynamic,
p.position,
{ min: [-0.23, 1.27, -0.23], max: [0.23, 1.73, 0.23] },
[0.77, 0.61, 0.43],
0,
() => false,
yaw,
);
for (const side of [-1, 1]) {
boxVertices(
dynamic,
p.position,
{
min: [side * 0.13 - 0.085, 0.02, -0.09],
max: [side * 0.13 + 0.085, 0.63, 0.09],
},
[0.25, 0.3, 0.32],
0,
() => false,
yaw,
);
boxVertices(
dynamic,
p.position,
{
min: [side * 0.31 - 0.085, 0.63, -0.1],
max: [side * 0.31 + 0.085, 1.24, 0.1],
},
c,
0,
() => false,
yaw,
);
}
boxVertices(
dynamic,
p.position,
{ min: [-0.18, 1.56, -0.234], max: [0.18, 1.64, -0.226] },
[0.13, 0.19, 0.15],
0,
() => false,
yaw,
);
}
for (const e of entities) {
const def = entityDefinitions.get(e.kind || e.name) || {},
color = (e.color || def.color || [192, 161, 123]).map((v) => v / 255),
boxes = e.render ||
def.render || [{ min: [-0.3, 0, -0.3], max: [0.3, 0.8, 0.3] }];
for (const b of boxes)
boxVertices(dynamic, e.position, b, color, 0, () => false, e.yaw || 0);
}
this.upload(this.dynamic, dynamic);
gl.bindVertexArray(this.dynamic.vao);
gl.drawArrays(gl.TRIANGLES, 0, this.dynamic.count);
this.triangles += this.dynamic.count / 3;
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.depthMask(false);
const translucent = [...this.sections]
.filter(([, m]) => m.transparent?.count)
.sort((a, b) => {
const dist = (id) =>
Math.hypot(
...id.split(",").map((v, i) => Number(v) * 16 + 8 - camera.eye[i]),
);
return dist(b[0]) - dist(a[0]);
});
for (const [, m] of translucent) {
gl.bindVertexArray(m.transparent.vao);
gl.drawArrays(gl.TRIANGLES, 0, m.transparent.count);
this.triangles += m.transparent.count / 3;
}
gl.depthMask(true);
gl.disable(gl.BLEND);
if (this.lines.count) {
gl.useProgram(this.line);
gl.uniformMatrix4fv(
gl.getUniformLocation(this.line, "uVP"),
false,
this.vp,
);
gl.bindVertexArray(this.lines.vao);
gl.drawArrays(gl.LINES, 0, this.lines.count);
}
gl.bindVertexArray(null);
}
project(p) {
return project(
p,
this.vp,
this.canvas.clientWidth,
this.canvas.clientHeight,
);
}
}
+597
View File
@@ -0,0 +1,597 @@
:root {
font-family: Inter, "Segoe UI", system-ui, sans-serif;
color: #f0f2ed;
background: #a8c6d0;
font-size: 14px;
--panel: rgba(28, 35, 31, 0.97);
--line: #4a544b;
--muted: #b2beb0;
--accent: #c0d3a9;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
}
button,
input {
font: inherit;
}
button {
color: inherit;
background: #39453b;
border: 1px solid #5d6a5d;
border-radius: 4px;
padding: 9px 12px;
cursor: pointer;
}
button:hover {
background: #4c5e4a;
}
button:focus-visible,
input:focus-visible {
outline: 2px solid #dfefba;
outline-offset: 3px;
}
button:disabled {
opacity: 0.5;
cursor: wait;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #253021;
font-weight: 650;
}
input {
background: #202821;
color: #f4f4ef;
border: 1px solid #576255;
border-radius: 4px;
padding: 10px;
width: 100%;
}
input[type="range"] {
padding: 0;
accent-color: #b9ce9e;
}
kbd {
font-size: 10px;
font-family: inherit;
color: #d7dfd3;
border: 1px solid #82907d75;
border-radius: 3px;
padding: 2px 4px;
background: #17231c55;
white-space: nowrap;
}
button kbd {
margin-left: 6px;
}
#world-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
cursor: crosshair;
}
.topbar {
position: absolute;
inset: 22px 24px auto;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
pointer-events: none;
text-shadow: 0 1px 4px #213a4380;
}
.identity {
display: flex;
align-items: center;
gap: 12px;
}
.identity > div {
display: flex;
flex-direction: column;
gap: 2px;
}
.identity strong {
font-size: 19px;
letter-spacing: -0.4px;
line-height: 1.15;
}
.world-name {
border: 0;
background: none;
padding: 0;
text-align: left;
font-size: 12px;
color: #e2ece0;
text-shadow: 0 1px 3px #18241d;
pointer-events: auto;
}
.world-name:hover {
background: none;
color: white;
}
#connection {
align-self: center;
font-size: 11px;
color: #edf4e9;
background: #273b2cb5;
padding: 5px 8px;
border-radius: 3px;
margin-left: 10px;
}
#connection.bad {
background: #713e30;
}
nav {
display: flex;
gap: 7px;
pointer-events: auto;
}
nav button {
background: #26362ed6;
border-color: #e0efd333;
box-shadow: 0 2px 8px #1d342615;
font-size: 12px;
}
#menu-button {
padding-inline: 13px;
}
#crosshair {
position: absolute;
left: 50%;
top: 50%;
width: 13px;
height: 13px;
transform: translate(-50%, -50%);
pointer-events: none;
filter: drop-shadow(0 1px 1px #112019);
}
#crosshair:before,
#crosshair:after {
content: "";
position: absolute;
background: #fffdf5b3;
}
#crosshair:before {
left: 6px;
top: 0;
width: 1px;
height: 13px;
}
#crosshair:after {
left: 0;
top: 6px;
width: 13px;
height: 1px;
}
#target-label {
position: absolute;
left: 50%;
top: calc(50% + 28px);
transform: translateX(-50%);
font-size: 11px;
text-shadow: 0 1px 3px #172f21;
pointer-events: none;
opacity: 0.9;
}
footer {
position: absolute;
bottom: 23px;
left: 50%;
transform: translateX(-50%);
text-align: center;
pointer-events: none;
}
#selected-name {
font-size: 12px;
color: #fffdf6;
text-shadow: 0 1px 4px #163622;
margin-bottom: 9px;
min-height: 17px;
}
#hotbar {
display: flex;
gap: 3px;
background: #1b2a22b8;
border: 1px solid #e0e9d633;
padding: 5px;
border-radius: 5px;
pointer-events: auto;
box-shadow: 0 5px 20px #20352730;
}
.slot {
width: 49px;
height: 49px;
position: relative;
padding: 8px;
border: 1px solid #9cac9340;
background: #46513b8c;
border-radius: 2px;
display: grid;
place-items: center;
}
.slot.active {
background: #dce7b82e;
border: 2px solid #e6edbc;
padding: 7px;
}
.slot small {
position: absolute;
left: 3px;
top: 2px;
font-size: 9px;
color: #e8edde;
}
.swatch {
display: block;
width: 26px;
height: 26px;
image-rendering: pixelated;
background: var(--block-color, #aaaaaa);
box-shadow:
inset -6px -5px 0 #0003,
inset 4px 4px 0 #fff2;
transform: rotate(-2deg);
}
#play-hint {
font-size: 11px;
text-shadow: 0 1px 4px #15271c;
margin-top: 9px;
color: #e3e8d7;
white-space: nowrap;
}
.bottom-left {
position: absolute;
left: 24px;
bottom: 25px;
max-width: min(355px, 30vw);
pointer-events: none;
}
#hints {
display: flex;
flex-wrap: wrap;
gap: 7px 11px;
color: #e1e9db;
font-size: 10px;
line-height: 1.7;
text-shadow: 0 1px 3px #25392b;
}
#chat-log {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 14px;
font-size: 12px;
text-shadow: 0 1px 3px #000;
max-height: 180px;
overflow: hidden;
word-break: break-word;
}
#chat-log div {
background: #20332ba6;
padding: 5px 8px;
border-radius: 3px;
width: fit-content;
}
#chat-log .system {
color: #e3dfb5;
}
#chat-form {
pointer-events: auto;
margin-bottom: 10px;
width: min(400px, 80vw);
}
#chat-input {
font-size: 12px;
background: #20352bec;
}
#labels {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.player-label {
position: absolute;
white-space: nowrap;
font-size: 11px;
background: #203629a8;
padding: 3px 6px;
border-radius: 3px;
transform: translate(-50%, -100%);
}
.panel {
position: absolute;
top: 86px;
right: 24px;
width: 315px;
max-height: calc(100vh - 116px);
overflow: auto;
background: var(--panel);
border: 1px solid #93a38a66;
box-shadow: 0 10px 30px #10281933;
border-radius: 6px;
padding: 20px;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 18px;
}
.panel-head h1 {
font-size: 18px;
font-weight: 650;
margin: 2px 0 0;
letter-spacing: -0.3px;
}
.panel-head button {
background: none;
border: 0;
font-size: 24px;
line-height: 20px;
padding: 0 2px;
color: #b6c4b1;
}
.panel-head p {
margin: 5px 0 0;
}
.panel label {
display: block;
font-size: 12px;
color: #c8d3c2;
margin-bottom: 7px;
}
.muted {
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.wide {
display: block;
width: 100%;
margin-top: 10px;
}
.inline {
display: flex;
gap: 6px;
margin-bottom: 20px;
}
.inline input {
min-width: 0;
}
.inline button {
font-size: 11px;
padding: 8px;
}
.rule {
border-top: 1px solid var(--line);
margin: 20px 0;
}
.world-row {
display: block;
width: 100%;
text-align: left;
margin-top: 8px;
background: #2c382f;
}
.world-row small {
display: block;
color: var(--muted);
font-size: 11px;
margin-top: 4px;
}
.world-row.current {
border-color: #bacda4;
}
.library {
width: min(610px, calc(100vw - 48px));
padding-bottom: 16px;
}
.library #catalog-search {
position: sticky;
top: 0;
z-index: 1;
}
#catalog-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
max-height: 55vh;
overflow: auto;
}
.catalog-item {
display: flex;
gap: 9px;
align-items: center;
min-width: 0;
text-align: left;
background: #2f3a30;
padding: 10px;
font-size: 11px;
}
.catalog-item .swatch {
flex-shrink: 0;
width: 22px;
height: 22px;
}
.catalog-item > span:last-child {
min-width: 0;
overflow-wrap: anywhere;
}
.catalog-item small {
display: block;
font-size: 9px;
color: #aebca6;
margin-top: 5px;
overflow-wrap: anywhere;
}
.catalog-empty {
grid-column: 1/-1;
color: var(--muted);
padding: 20px 0;
}
.diagnostics dl {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 9px 10px;
font-size: 12px;
margin: 0 0 20px;
}
.diagnostics dt {
color: #aebeaa;
}
.diagnostics dd {
margin: 0;
text-align: right;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
#toast {
position: absolute;
top: 90px;
left: 50%;
transform: translateX(-50%);
background: #263a2cf2;
border: 1px solid #b7cba380;
border-radius: 4px;
padding: 11px 16px;
font-size: 13px;
max-width: min(580px, 90vw);
text-align: center;
box-shadow: 0 2px 10px #0002;
}
#toast.error {
background: #633c2ff5;
border-color: #d0a68f;
}
#match {
position: absolute;
top: 92px;
left: 24px;
background: #26392cd6;
border: 1px solid #e2e3c24a;
padding: 12px 16px;
border-radius: 4px;
font-size: 13px;
max-width: 360px;
}
#fatal {
position: absolute;
inset: 30% 20% auto;
background: #312e25ed;
border: 1px solid #d4b696;
padding: 30px;
line-height: 1.6;
border-radius: 5px;
}
body.locked .topbar,
body.locked #hints,
body.locked #play-hint {
opacity: 0.35;
}
body.locked .topbar:hover {
opacity: 1;
}
[hidden] {
display: none !important;
}
@media (max-width: 850px) {
.topbar {
inset: 14px 14px auto;
}
.identity {
gap: 8px;
}
#connection {
margin-left: 1px;
font-size: 10px;
}
nav button {
font-size: 11px;
padding: 9px 8px;
}
nav kbd {
display: none;
}
.bottom-left {
left: 14px;
bottom: 115px;
max-width: 75vw;
}
#hints {
display: none;
}
footer {
bottom: 14px;
}
.panel {
right: 14px;
top: 77px;
max-height: calc(100vh - 94px);
}
.slot {
width: 40px;
height: 44px;
}
#play-hint {
white-space: normal;
width: 360px;
font-size: 10px;
}
#catalog-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 520px) {
.identity strong {
font-size: 15px;
}
.identity svg {
width: 22px;
}
.topbar {
gap: 6px;
}
#connection {
display: none;
}
#diagnostics-button {
font-size: 0;
}
#diagnostics-button:before {
content: "F3";
font-size: 12px;
}
.slot {
width: 34px;
height: 40px;
padding: 6px;
}
.swatch {
width: 23px;
height: 23px;
}
.library {
width: calc(100vw - 28px);
}
#target-label {
max-width: 90vw;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#fatal {
inset: 25% 14px auto;
}
}
+72
View File
@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
direction,
raycast,
rayBox,
multiply,
perspective,
viewMatrix,
project,
unitBox,
} from "../math.js";
test("camera axes and world projection match the server coordinate convention", () => {
assert.deepEqual(direction(0, 0), [0, 0, -1]);
const east = direction(Math.PI / 2, 0);
assert.ok(Math.abs(east[0] - 1) < 1e-8);
const vp = multiply(
perspective(Math.PI / 2, 1, 0.1, 100),
viewMatrix([0, 2, 8], 0, 0),
);
const center = project([0, 2, 0], vp, 100, 100);
assert.ok(Math.abs(center[0] - 50) < 1e-5 && Math.abs(center[1] - 50) < 1e-5);
assert.equal(project([0, 2, 20], vp, 100, 100), null);
});
test("DDA resolves negative world coordinates and nearest entry face", () => {
const blocks = new Map([
["-1,0,-2", 1],
["-1,0,-4", 1],
]),
materials = new Map([[1, { render: [unitBox] }]]);
const hit = raycast([-0.5, 0.5, 0], [0, 0, -1], blocks, materials);
assert.deepEqual(hit.pos, [-1, 0, -2]);
assert.deepEqual(hit.normal, [0, 0, 1]);
assert.equal(hit.distance, 1);
});
test("ray selection respects slab geometry and reach instead of selecting empty half", () => {
const blocks = new Map([["0,0,-2", 1]]),
materials = new Map([
[1, { render: [{ min: [0, 0, 0], max: [1, 0.5, 1] }] }],
]);
assert.equal(raycast([0.5, 0.75, 0], [0, 0, -1], blocks, materials), null);
assert.ok(raycast([0.5, 0.25, 0], [0, 0, -1], blocks, materials));
assert.equal(
raycast([0.5, 0.25, 10], [0, 0, -1], blocks, materials, 6),
null,
);
});
test("axis aligned ray slabs reject parallel paths outside a box", () => {
assert.equal(rayBox([2, 0.5, 3], [0, 0, -1], [0, 0, 0], [1, 1, 1], 6), null);
assert.equal(
rayBox([0.5, 0.5, 3], [0, 0, -1], [0, 0, 0], [1, 1, 1], 6).distance,
2,
);
});
test("selection intersects a fence extension owned by the cell below", () => {
const blocks = new Map([["0,0,-2", 1]]),
materials = new Map([
[1, { render: [{ min: [0.375, 0, 0.375], max: [0.625, 1.5, 0.625] }] }],
]);
const hit = raycast([0.5, 1.25, 0], [0, 0, -1], blocks, materials);
assert.deepEqual(hit.pos, [0, 0, -2]);
assert.deepEqual(hit.normal, [0, 0, 1]);
assert.equal(hit.distance, 1.375);
});
test("package geometry is selectable across its documented three-block extent", () => {
const blocks = new Map([["0,0,0", 1]]);
const materials = new Map([[1, { render: [{min:[0,0,0],max:[1,3,1]}] }]]);
const hit = raycast([0.5,2.5,4], [0,0,-1], blocks, materials, 6);
assert.deepEqual(hit.pos,[0,0,0]);
assert.deepEqual(hit.normal,[0,0,1]);
});
+64
View File
@@ -0,0 +1,64 @@
<!doctype html><meta charset="utf-8" /><title>Shacraft renderer fixture</title
><style>
body {
margin: 0;
}
canvas {
width: 100vw;
height: 100vh;
display: block;
}</style
><canvas id="c"></canvas>
<script type="module">
import { Renderer } from "../renderer.js";
const canvas = document.querySelector("canvas");
try {
const r = new Renderer(canvas),
blocks = new Map(),
mats = new Map([
[
1,
{
state: "minecraft:grass_block",
color: [107, 151, 65],
opacity: 1,
render: [{ min: [0, 0, 0], max: [1, 1, 1] }],
},
],
[
2,
{
state: "minecraft:bricks",
color: [169, 88, 68],
opacity: 1,
render: [{ min: [0, 0, 0], max: [1, 1, 1] }],
},
],
[
3,
{
state: "minecraft:glass",
color: [169, 220, 219],
opacity: 0.35,
render: [{ min: [0, 0, 0], max: [1, 1, 1] }],
},
],
]);
for (let x = -16; x <= 16; x++)
for (let z = -16; z <= 16; z++) blocks.set(`${x},0,${z}`, 1);
for (let x = -3; x < 3; x++)
for (let y = 1; y < 5; y++)
blocks.set(
`${x},${y},-7`,
(x === 0 || x === 1) && y > 1 && y < 4 ? 3 : 2,
);
r.replace(blocks, mats);
for (let n = 0; n < 24; n++) {
r.draw({ eye: [7, 5, 12], yaw: -0.38, pitch: -0.2 }, [], [], new Map());
}
document.body.dataset.status = `ready:${r.triangles}`;
} catch (error) {
document.body.dataset.status = "error:" + error.message;
document.body.textContent = error.stack;
}
</script>