"""Boss grotto for Champions of Luneraria: a rock island in the river with a crescent of cliffs, a cave mouth and violet crystals. Symmetric across the map diagonal, so both entrances (towards Blau and towards Rot) are equal. Stone uses ENV_* material names. Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_grotto.py Local Godot frame: +x/+z are the map axes; the map diagonal (x = z) is the mirror line. """ from pathlib import Path import bpy, bmesh, json, hashlib, struct from math import sin, cos, pi, sqrt from mathutils import Vector, noise P = Path(__file__).resolve().parents[2] SRC = Path(__file__).resolve().parent OUT = P / 'assets/luneraria' OUT.mkdir(parents=True, exist_ok=True) bpy.ops.wm.read_factory_settings(use_empty=True) bpy.context.preferences.filepaths.save_version = 0 parts = [] def G(x, y, z): return Vector((x, -z, y)) def material(name, color, rough=.9, metal=0.0, emission=0.0): m = bpy.data.materials.new(name) m.diffuse_color = (*color, 1) m.use_nodes = True bs = m.node_tree.nodes.get('Principled BSDF') bs.inputs['Base Color'].default_value = (*color, 1) bs.inputs['Roughness'].default_value = rough bs.inputs['Metallic'].default_value = metal if emission: bs.inputs['Emission Color'].default_value = (*color, 1) bs.inputs['Emission Strength'].default_value = emission return m M = { 'cliff': material('ENV_cliff', (.133, .153, .138)), 'soil': material('ENV_soil', (.10, .15, .065), .97), 'limestone': material('ENV_limestone', (.283, .246, .178)), 'dark': material('GR_Shadow', (.02, .02, .03), 1.0), 'crystal': material('GR_Crystal', (.62, .38, 1.0), .15, 0, 3.0), } def obj(name, vs, fs, kind, smooth=False): me = bpy.data.meshes.new(name) me.from_pydata([tuple(v) for v in vs], [], fs) me.update() me.materials.append(M[kind]) for p in me.polygons: p.use_smooth = smooth ob = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(ob) parts.append(ob) return ob def island(name, radius, top, bottom, seed, kind='soil'): sides, levels = 64, 6 vs, fs = [], [] for li in range(levels): y = top - (top - bottom) * li / (levels - 1) for k in range(sides): a = 2 * pi * k / sides # Radius depends on |angle - 45 deg| so the island is symmetric across x = z. m = abs(((a - pi / 4) + pi) % (2 * pi) - pi) r = radius * (1 + .08 * noise.noise(Vector((cos(m) * 2, li * .5, seed)))) * (1 - .06 * li / levels) vs.append(G(cos(a) * r, y, sin(a) * r)) for li in range(levels - 1): for k in range(sides): a, b = li * sides + k, li * sides + (k + 1) % sides fs.append((a, a + sides, b + sides, b)) fs += [tuple(range(sides)), tuple(reversed(range((levels - 1) * sides, levels * sides)))] return obj(name, vs, fs, kind) def boulder(name, center, size, seed, kind='cliff'): bm = bmesh.new() bmesh.ops.create_icosphere(bm, subdivisions=2, radius=1.0) c = G(*center) for v in bm.verts: d = v.co.normalized() r = 1 + .32 * noise.noise(d * 1.8 + Vector((seed, seed * .5, seed * .3))) gx, gy, gz = d.x * r * size[0], d.z * r * size[1], -d.y * r * size[2] if gy < -.25 * size[1]: gy = -.25 * size[1] v.co = c + Vector((gx, -gz, gy)) me = bpy.data.meshes.new(name) bm.to_mesh(me) bm.free() me.materials.append(M[kind]) ob = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(ob) parts.append(ob) return ob def gem(name, center, height, radius, tilt, kind='crystal'): cx, cy, cz = center tip = G(cx + tilt[0], cy + height, cz + tilt[1]) base = [G(cx + cos(2 * pi * k / 6) * radius, cy + height * .25, cz + sin(2 * pi * k / 6) * radius) for k in range(6)] root = G(cx, cy - .1, cz) vs = [tip] + base + [root] fs = [(0, 1 + (k + 1) % 6, 1 + k) for k in range(6)] + [(7, 1 + k, 1 + (k + 1) % 6) for k in range(6)] return obj(name, vs, fs, kind) def mirror(p): return (p[2], p[1], p[0]) island('Island', 7.2, .35, -1.2, 3) # Crescent of cliffs on the far side from Mid (towards -x-z), open towards both entrances. ring = [] for k in range(9): a = pi + pi / 4 + (k - 4) * .30 ring.append((cos(a) * 5.6, 0.0, sin(a) * 5.6, 1.5 + (1.2 if abs(k - 4) <= 1 else .5 * (4 - abs(k - 4)) / 4))) for i, (x, y, z, h) in enumerate(ring): boulder('Cliff_%d' % i, (x, y + h * .45, z), (1.6, h, 1.4), 10 + min(i, 8 - i)) # Cave mouth: dark recess under an arch in the middle of the crescent. a = pi + pi / 4 boulder('Cave_Shadow', (cos(a) * 4.6, .8, sin(a) * 4.6), (1.4, 1.1, .7), 31, 'dark') boulder('Arch_Top', (cos(a) * 4.8, 3.1, sin(a) * 4.8), (2.2, .8, 1.3), 32) # Violet crystals, mirrored pairs so neither entrance looks different. for i, (x, z, h, r) in enumerate([(-2.2, -3.6, 1.8, .32), (-3.9, -1.2, 1.4, .28), (-1.0, -4.3, 1.1, .22), (-3.2, -3.2, 2.3, .4)]): for p in {(x, 0.3, z), mirror((x, 0.3, z))}: gem('Crystal_%d_%d' % (i, int(p[0] * 10)), p, h, r, (.12 * (1 if p[0] < p[2] else -1), .08)) # Entrance stones: symmetric pair of limestone steps facing Blau (+x) and Rot (+z)... see manifest. for p in [(4.6, .18, 1.2), mirror((4.6, .18, 1.2))]: boulder('Step_%d' % int(p[0] * 10), p, (1.1, .25, .8), 40, 'limestone') bpy.ops.object.select_all(action='DESELECT') for ob in parts: ob.select_set(True) bpy.context.view_layer.objects.active = parts[0] bpy.ops.object.join() grotto = bpy.context.object grotto.name = 'BossGrotto' bm = bmesh.new() bm.from_mesh(grotto.data) bmesh.ops.triangulate(bm, faces=list(bm.faces)) bm.to_mesh(grotto.data) bm.free() bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'grotto.blend')) bpy.ops.export_scene.gltf(filepath=str(OUT / 'grotto.glb'), export_format='GLB', use_selection=True, export_materials='EXPORT', export_yup=True, export_animations=False) raw = (OUT / 'grotto.glb').read_bytes() doc = json.loads(raw[20:20 + struct.unpack_from('