"""Lane tower for Champions of Luneraria: stone plinth, tapered shaft with brass bands, crenellated platform and a hovering crystal. The crystal material (TW_Crystal) is tinted per team at runtime; stone surfaces use the Kluft material names (ENV_*). Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_tower.py Godot coordinates are written through G(x, y, z) (x right, y up, z towards the viewer). """ from pathlib import Path import bpy, bmesh, json, hashlib, struct from math import sin, cos, pi 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 = { 'stone': material('ENV_limestone', (.283, .246, .178)), 'dark': material('ENV_cliff', (.133, .153, .138)), 'paving': material('ENV_paving', (.162, .171, .136), .92), 'brass': material('ENV_brass', (.515, .366, .153), .45, .7), 'crystal': material('TW_Crystal', (.55, .8, 1.0), .15, 0, 2.5), } 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 lathe(name, profile, sides, kind, center=(0, 0, 0), rotation=0.0, jitter=0.0, seed=0): cx, cy, cz = center vs, fs = [], [] for j, (r, y) in enumerate(profile): for k in range(sides): a = 2 * pi * k / sides + rotation rr = r * (1 + jitter * noise.noise(Vector((cos(a) * 3, y * 2, sin(a) * 3 + seed)))) vs.append(G(cx + cos(a) * rr, cy + y, cz + sin(a) * rr)) n = len(profile) for j in range(n - 1): for k in range(sides): a, b = j * sides + k, j * sides + (k + 1) % sides fs.append((a, b, b + sides, a + sides)) fs += [tuple(reversed(range(sides))), tuple(range((n - 1) * sides, n * sides))] return obj(name, vs, fs, kind) def box(name, center, size, yaw, kind): cx, cy, cz = center hx, hy, hz = size[0] / 2, size[1] / 2, size[2] / 2 vs = [] for dy in (-1, 1): for dz in (-1, 1): for dx in (-1, 1): x, z = dx * hx, dz * hz vs.append(G(cx + x * cos(yaw) - z * sin(yaw), cy + dy * hy, cz + x * sin(yaw) + z * cos(yaw))) return obj(name, vs, [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)], kind) # Plinth: two stepped octagons of dark stone with a paving top. lathe('Plinth', [(1.65, 0.0), (1.65, .28), (1.45, .34), (1.45, .52), (1.3, .58)], 8, 'dark', rotation=pi / 8, jitter=.03, seed=1) lathe('Plinth_Top', [(1.28, .57), (1.28, .62)], 8, 'paving', rotation=pi / 8) # Shaft: tapered, slightly irregular limestone blocks. shaft = [(1.02, .6), (.98, 1.4), (.9, 2.4), (.84, 3.2), (.9, 3.4)] lathe('Shaft', shaft, 10, 'stone', rotation=pi / 10, jitter=.025, seed=2) for h in (1.05, 2.25, 3.25): r = .99 - (h - .6) * .05 lathe('Band_%d' % int(h * 10), [(r + .06, h - .07), (r + .06, h + .07)], 10, 'brass', rotation=pi / 10) # Platform with crenellations. lathe('Platform', [(.95, 3.35), (1.25, 3.6), (1.25, 3.85), (1.1, 3.9)], 10, 'stone', rotation=pi / 10, jitter=.02, seed=3) for k in range(10): a = 2 * pi * (k + .5) / 10 box('Merlon_%d' % k, (cos(a) * 1.08, 4.08, sin(a) * 1.08), (.42, .42, .26), -a + pi / 2, 'stone') # Crystal cradle: four brass prongs and a hovering bipyramid crystal. for k in range(4): a = pi / 4 + k * pi / 2 pts = [(cos(a) * .55, 3.9, sin(a) * .55), (cos(a) * .42, 4.55, sin(a) * .42), (cos(a) * .22, 5.0, sin(a) * .22)] for i in range(2): p0, p1 = Vector(pts[i]), Vector(pts[i + 1]) mid = (p0 + p1) / 2 d = p1 - p0 box('Prong_%d_%d' % (k, i), tuple(mid), (.09, d.length + .05, .09), 0, 'brass') crystal = [G(0, 5.95, 0)] + [G(cos(2 * pi * k / 6) * .36, 5.05, sin(2 * pi * k / 6) * .36) for k in range(6)] + [G(0, 4.45, 0)] cf = [(0, 1 + k, 1 + (k + 1) % 6) for k in range(6)] + [(7, 1 + (k + 1) % 6, 1 + k) for k in range(6)] obj('Crystal', crystal, cf, 'crystal') # A small door and window slits keep the scale readable from the game camera. box('Door', (0, .95, 1.0), (.5, .75, .12), 0, 'dark') for k, h in enumerate((1.7, 2.7)): box('Slit_%d' % k, (0, h, .93 - (h - 1.7) * .05), (.14, .42, .1), 0, 'dark') 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() tower = bpy.context.object tower.name = 'LaneTower' bm = bmesh.new() bm.from_mesh(tower.data) bmesh.ops.triangulate(bm, faces=list(bm.faces)) bm.to_mesh(tower.data) bm.free() bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'tower.blend')) bpy.ops.export_scene.gltf(filepath=str(OUT / 'tower.glb'), export_format='GLB', use_selection=True, export_materials='EXPORT', export_yup=True, export_animations=False) raw = (OUT / 'tower.glb').read_bytes() doc = json.loads(raw[20:20 + struct.unpack_from('