253 lines
7.7 KiB
TypeScript
253 lines
7.7 KiB
TypeScript
import {
|
|
entity,
|
|
uid,
|
|
type Vec3,
|
|
type Geometry,
|
|
validateGeometry,
|
|
} from "./schema.ts";
|
|
const cross = (a: number[], b: number[], c: number[]) =>
|
|
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
export function extrude(profile: number[][], depth = 1): Geometry {
|
|
if (
|
|
!Array.isArray(profile) ||
|
|
profile.length < 3 ||
|
|
profile.length > 256 ||
|
|
!Number.isFinite(depth) ||
|
|
depth <= 0 ||
|
|
!profile.every((p) => p.length === 2 && p.every(Number.isFinite))
|
|
)
|
|
throw Error("Нужен простой замкнутый профиль из 3–256 точек");
|
|
const points = profile.map((p) => [...p]);
|
|
let area = points.reduce(
|
|
(s, p, i) =>
|
|
s +
|
|
p[0] * points[(i + 1) % points.length][1] -
|
|
points[(i + 1) % points.length][0] * p[1],
|
|
0,
|
|
);
|
|
if (Math.abs(area) < 1e-8) throw Error("Нулевая площадь");
|
|
if (area < 0) points.reverse();
|
|
const n = points.length;
|
|
for (let i = 0; i < n; i++)
|
|
for (let j = i + 2; j < n; j++) {
|
|
if (i === 0 && j === n - 1) continue;
|
|
const a = points[i],
|
|
b = points[(i + 1) % n],
|
|
c = points[j],
|
|
d = points[(j + 1) % n];
|
|
if (
|
|
cross(a, b, c) * cross(a, b, d) < 0 &&
|
|
cross(c, d, a) * cross(c, d, b) < 0
|
|
)
|
|
throw Error("Профиль пересекает себя");
|
|
}
|
|
const indices: number[] = [],
|
|
left = points.map((_, i) => i);
|
|
let guard = 0;
|
|
while (left.length > 3) {
|
|
let clipped = false;
|
|
for (let j = 0; j < left.length; j++) {
|
|
const a = left[(j + left.length - 1) % left.length],
|
|
b = left[j],
|
|
c = left[(j + 1) % left.length];
|
|
if (cross(points[a], points[b], points[c]) <= 1e-8) continue;
|
|
if (
|
|
left.some(
|
|
(k) =>
|
|
k !== a &&
|
|
k !== b &&
|
|
k !== c &&
|
|
cross(points[a], points[b], points[k]) >= 0 &&
|
|
cross(points[b], points[c], points[k]) >= 0 &&
|
|
cross(points[c], points[a], points[k]) >= 0,
|
|
)
|
|
)
|
|
continue;
|
|
indices.push(a, b, c, a + n, c + n, b + n);
|
|
left.splice(j, 1);
|
|
clipped = true;
|
|
break;
|
|
}
|
|
if (!clipped || guard++ > 256)
|
|
throw Error("Невозможно триангулировать профиль");
|
|
}
|
|
indices.push(
|
|
left[0],
|
|
left[1],
|
|
left[2],
|
|
left[0] + n,
|
|
left[2] + n,
|
|
left[1] + n,
|
|
);
|
|
const positions: number[] = [];
|
|
for (const y of [0, depth])
|
|
for (const [x, z] of points) positions.push(x, y, z);
|
|
for (let i = 0; i < n; i++) {
|
|
const j = (i + 1) % n;
|
|
indices.push(i, i + n, j + n, i, j + n, j);
|
|
}
|
|
const g = { positions, indices };
|
|
validateGeometry(g);
|
|
return g;
|
|
}
|
|
export function lathe(profile: number[][], segments = 24): Geometry {
|
|
if (
|
|
!Number.isInteger(segments) ||
|
|
segments < 3 ||
|
|
segments > 128 ||
|
|
!Array.isArray(profile) ||
|
|
profile.length < 2 ||
|
|
profile.length > 256 ||
|
|
!profile.every(
|
|
(p) => p.length === 2 && p.every(Number.isFinite) && p[0] >= 0,
|
|
)
|
|
)
|
|
throw Error("Нужен профиль [радиус, высота] и 3–128 сегментов");
|
|
const positions: number[] = [],
|
|
indices: number[] = [];
|
|
for (const [r, y] of profile)
|
|
for (let j = 0; j < segments; j++) {
|
|
const a = (j / segments) * Math.PI * 2;
|
|
positions.push(Math.cos(a) * r, y, Math.sin(a) * r);
|
|
}
|
|
for (let i = 0; i < profile.length - 1; i++)
|
|
for (let j = 0; j < segments; j++) {
|
|
const a = i * segments + j,
|
|
b = i * segments + ((j + 1) % segments),
|
|
c = a + segments,
|
|
d = b + segments;
|
|
indices.push(a, c, b, b, c, d);
|
|
}
|
|
const g = { positions, indices };
|
|
validateGeometry(g);
|
|
return g;
|
|
}
|
|
export function transformed(
|
|
g: Geometry,
|
|
offset: Vec3 = [0, 0, 0],
|
|
scale: Vec3 = [1, 1, 1],
|
|
): Geometry {
|
|
const positions = g.positions.map((v, i) => v * scale[i % 3] + offset[i % 3]),
|
|
indices = [...g.indices];
|
|
if (scale[0] * scale[1] * scale[2] < 0)
|
|
for (let i = 0; i < indices.length; i += 3)
|
|
[indices[i + 1], indices[i + 2]] = [indices[i + 2], indices[i + 1]];
|
|
return { positions, indices };
|
|
}
|
|
export function arena({
|
|
width = 22,
|
|
depth = 18,
|
|
seed = 42,
|
|
obstacles = 8,
|
|
}: any = {}) {
|
|
width = Math.max(8, Math.min(80, width));
|
|
depth = Math.max(8, Math.min(80, depth));
|
|
obstacles = Math.max(0, Math.min(80, Math.round(obstacles)));
|
|
let v = seed | 0;
|
|
const random = () => {
|
|
v = (Math.imul(v, 1664525) + 1013904223) | 0;
|
|
return (v >>> 0) / 4294967296;
|
|
};
|
|
const root = entity("Сад · уровень"),
|
|
nodes = [root];
|
|
const add = (
|
|
name: string,
|
|
pos: Vec3,
|
|
size: Vec3,
|
|
color: string,
|
|
type = "box",
|
|
collision = true,
|
|
) => {
|
|
const n = entity(
|
|
name,
|
|
{
|
|
mesh: { type, size },
|
|
material: { color, roughness: 0.92 },
|
|
...(collision
|
|
? { collider: { shape: "box", size }, rigidbody: { type: "fixed" } }
|
|
: {}),
|
|
},
|
|
pos,
|
|
);
|
|
n.parentId = root.id;
|
|
nodes.push(n);
|
|
return n;
|
|
};
|
|
add("Каменное основание", [0, -0.5, 0], [width, 1, depth], "#b6aa91");
|
|
add(
|
|
"Светлый песок",
|
|
[0, 0.025, 0],
|
|
[width - 0.5, 0.05, depth - 0.5],
|
|
"#d1c9ae",
|
|
"box",
|
|
false,
|
|
);
|
|
add("Северная стена", [0, 0.6, depth / 2], [width, 1.2, 0.45], "#b0a48a");
|
|
add("Южная стена", [0, 0.6, -depth / 2], [width, 1.2, 0.45], "#b0a48a");
|
|
add("Западная стена", [-width / 2, 0.6, 0], [0.45, 1.2, depth], "#b0a48a");
|
|
add("Восточная стена", [width / 2, 0.6, 0], [0.45, 1.2, depth], "#b0a48a");
|
|
for (let z = -Math.floor(depth / 2) + 1; z < depth / 2; z += 1.1)
|
|
add(
|
|
"Плитка тропы",
|
|
[0, 0.07, z],
|
|
[1.7, 0.09, 0.9],
|
|
"#b6b49c",
|
|
"box",
|
|
false,
|
|
);
|
|
for (const x of [-width / 2 + 1.2, width / 2 - 1.2])
|
|
for (const z of [-depth / 2 + 1.2, depth / 2 - 1.2]) {
|
|
add("Основание колонны", [x, 0.15, z], [1.4, 0.3, 1.4], "#a9a187");
|
|
add("Колонна", [x, 1.6, z], [0.7, 2.7, 0.7], "#c5bca2", "cylinder");
|
|
add("Капитель", [x, 3, z], [1.2, 0.3, 1.2], "#cfc5ab");
|
|
}
|
|
for (let i = 0; i < obstacles; i++) {
|
|
const x = (i % 2 ? -1 : 1) * (2 + random() * (width / 2 - 4)),
|
|
z = (random() - 0.5) * (depth - 5);
|
|
if (i % 3 === 0) {
|
|
add("Ствол", [x, 0.7, z], [0.25, 1.4, 0.25], "#80765c", "cylinder");
|
|
add("Крона", [x, 1.7, z], [2, 1.7, 2], "#859887", "icosphere", false);
|
|
} else {
|
|
const n = add(
|
|
"Обломок",
|
|
[x, 0.35, z],
|
|
[0.9 + random(), 0.7, 0.8 + random()],
|
|
"#999b84",
|
|
);
|
|
n.transform.rotation[1] = random() * 2;
|
|
}
|
|
}
|
|
return nodes;
|
|
}
|
|
export function character({
|
|
height = 1.8,
|
|
color = "#7d9b8a",
|
|
name = "Персонаж · модель",
|
|
}: any = {}) {
|
|
const root = entity(name),
|
|
nodes = [root],
|
|
scale = height / 1.8;
|
|
const add = (name: string, p: Vec3, size: Vec3, c: string, type = "box") => {
|
|
const n = entity(
|
|
name,
|
|
{
|
|
mesh: { type, size: size.map((v) => v * scale) },
|
|
material: { color: c, roughness: 0.8 },
|
|
},
|
|
p.map((v) => v * scale) as Vec3,
|
|
);
|
|
n.parentId = root.id;
|
|
nodes.push(n);
|
|
};
|
|
add("Торс", [0, 1.1, 0], [0.6, 0.65, 0.35], color);
|
|
add("Голова", [0, 1.65, 0], [0.4, 0.4, 0.4], "#d5b994", "sphere");
|
|
add("Капюшон", [0, 1.8, -0.04], [0.5, 0.25, 0.48], color, "icosphere");
|
|
for (const sign of [-1, 1]) {
|
|
add("Нога", [sign * 0.16, 0.37, 0], [0.24, 0.7, 0.25], "#61685f");
|
|
add("Сапог", [sign * 0.16, 0.09, 0.09], [0.28, 0.18, 0.42], "#55594f");
|
|
add("Рука", [sign * 0.42, 1.05, 0], [0.18, 0.65, 0.2], color);
|
|
}
|
|
add("Клинок", [0.53, 0.98, 0.28], [0.08, 0.85, 0.12], "#c4cac0");
|
|
return nodes;
|
|
}
|