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; }