Hauptszene scenes/luneraria.tscn, Saumwacht bleibt über --legacy und die alten QA-Schalter erreichbar. Karte aus Bestandsassets plus neuem Turm, HUD mit Planung, Lanes und Minimap, Menü umbenannt, Einstellungen werden aus dem alten Benutzerordner übernommen. Simulation 6,6x schneller (Raster, Caches, faire Staffelung) und exakt spiegelsymmetrisch. Regeln 91/91, echte Anwendung 38/38, Legacy unverändert grün. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
6.3 KiB
Python
144 lines
6.3 KiB
Python
"""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('<I', raw, 12)[0]])
|
|
triangles = sum(doc['accessors'][q['indices']]['count'] // 3 for m in doc['meshes'] for q in m['primitives'])
|
|
(P / 'assets/luneraria/tower_manifest.json').write_text(json.dumps({'assets': [{
|
|
'id': 'lane_tower', 'resource_path': 'res://assets/luneraria/tower.glb', 'source': 'assets-source/luneraria/tower.blend',
|
|
'triangles': triangles, 'height': 5.95, 'sha256': hashlib.sha256(raw).hexdigest(),
|
|
'materials': [m['name'] for m in doc['materials']]}]}, indent=2) + '\n')
|
|
print('TOWER_BUILD', triangles, 'triangles', flush=True)
|