saumwacht-godot/assets-source/phase10/build_sanctuary.py
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

274 lines
14 KiB
Python

"""Saumwacht: the Verdant Rift, an editable Blender environment in real metres.
The two 6x8 gameplay grids remain unobstructed. All vegetation, bridges,
architectural details and rock strata are actual meshes, not a backdrop.
Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python this_file.py
"""
from pathlib import Path
import sys, math, random, json, hashlib
import bpy
from mathutils import Vector, noise
PROJECT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT/'assets-source/phase4/blender'))
from common import mesh_object, ellipsoid, cylinder, cone, srgb_to_linear
from reference_high_detail import hd_box, hd_curve, hd_ring, hd_rod, hd_finish
R = random.Random(10092026)
scene = bpy.data.scenes.new('The_Verdant_Rift')
bpy.context.window.scene = scene
scene.unit_settings.system = 'METRIC'
scene['description'] = 'Two intact 12 x 40 metre paths; forest sanctuary above a river gorge.'
M = {}
for key, color, rough, metal in [
('limestone','918875',.89,0),('cliff','666D68',.92,0),
('soil','404F39',.97,0),('bark','534332',.93,0),
('leaf_dark','314B38',.89,0),('leaf_mid','526A42',.85,0),
('leaf_light','8B9655',.79,0),('brass','BEA36D',.53,.65),
('teal','69CEB9',.36,.2),('coral','DF9A7A',.36,.2),
('flower','C4BB93',.72,0),('paving','707367',.92,0)]:
mat=bpy.data.materials.new('ENV_'+key); mat.use_nodes=True
c=tuple(srgb_to_linear(int(color[i:i+2],16)/255) for i in (0,2,4))
mat.diffuse_color=(*c,1)
bs=mat.node_tree.nodes.get('Principled BSDF')
bs.inputs['Base Color'].default_value=(*c,1)
bs.inputs['Roughness'].default_value=rough
bs.inputs['Metallic'].default_value=metal
if key in ['teal','coral']:
bs.inputs['Emission Color'].default_value=(*c,1)
bs.inputs['Emission Strength'].default_value=.65
M[key]=mat
def p(x,z,h=0): return (x,-z,h)
def box(name,x,z,h,sx,sz,sh,key='limestone',radius=.035,angle=0):
return hd_box(name,p(x,z,h),(sx,sz,sh),M[key],radius,(0,0,angle))
def rock(name,x,z,h,sx,sz,sh,seed=0):
# Angular, eroded stratified rock, authored rings rather than round spheres.
rng=random.Random(seed+917);verts=[];faces=[];sides=14;levels=13
phase=rng.uniform(0,6.28)
for row in range(levels):
t=row/(levels-1)
shoulder=.55+.45*math.sin(math.pi*t)**.3
ledge=[1.0,.93,1.05,.96][row%4]
for col in range(sides):
angle=math.tau*col/sides+phase
n=noise.noise(Vector((math.cos(angle)*2,math.sin(angle)*2,t*3+seed)))
radius=shoulder*ledge*(1+n*.16)
px=math.cos(angle)*sx*radius
py=math.sin(angle)*sz*radius
pz=(t*2-1)*sh+n*.07*sh
verts.append((px,py,pz))
for row in range(levels-1):
for col in range(sides):
a=row*sides+col;b=row*sides+(col+1)%sides
faces.append((a,b,b+sides,a+sides))
faces.append(tuple(reversed(range(sides))))
faces.append(tuple((levels-1)*sides+k for k in range(sides)))
obj=mesh_object(name,verts,faces,M['cliff']);obj.location=p(x,z,h)
hd_finish(obj,.035,2,False)
return obj
def leaf_mesh(name,center,radius,seed):
"""An airy spray of individually curved, closed six-sided leaves."""
rng=random.Random(seed); verts=[]; faces=[]
for i in range(90):
direction=Vector((rng.uniform(-1,1),rng.uniform(-1,1),rng.uniform(-.6,.7))).normalized()
point=Vector(center)+direction*radius*rng.random()**.38
tangent=Vector((rng.uniform(-1,1),rng.uniform(-1,1),rng.uniform(-.4,.4))).normalized()
side=tangent.cross(Vector((0,0,1))).normalized()
length=rng.uniform(.18,.35); width=length*.47; base=len(verts)
verts.extend([tuple(point-tangent*length),tuple(point-side*width),tuple(point+tangent*length),
tuple(point+side*width),tuple(point+Vector((0,0,length*.22))),tuple(point-Vector((0,0,.018)))])
faces.extend([tuple(base+j for j in f) for f in [(0,1,4),(1,2,4),(2,3,4),(3,0,4),(1,0,5),(2,1,5),(3,2,5),(0,3,5)]])
return mesh_object(name,verts,faces,M[['leaf_dark','leaf_mid','leaf_light'][seed%3]])
def tree(x,z,size,seed):
rng=random.Random(seed); base=Vector(p(x,z,-.3))
lean=Vector((rng.uniform(-.5,.5),rng.uniform(-.5,.5),0))*size
height=size*3.8
trunk=[base,base+Vector((0,0,height*.28)),base+lean*.7+Vector((0,0,height*.68)),base+lean+Vector((0,0,height))]
hd_curve('Tree_%d_Trunk'%seed,trunk,[size*.38,size*.25,size*.15,.035],M['bark'],18,9)
for j in range(7):
a=j*math.tau/7
root=base+Vector((math.cos(a),math.sin(a),0))*size*1.3
root.z=-.21
hd_curve('Tree_%d_Root_%d'%(seed,j),[root,base+Vector((math.cos(a)*.3,math.sin(a)*.3,.3))*size,trunk[1]],[.03,size*.14,.12*size],M['bark'],10,6)
for j in range(11):
a=j*2.399+seed
branch=base+lean*.5+Vector((0,0,height*(.4+j*.035)))
length=size*(1.7-j*.065)
end=branch+Vector((math.cos(a)*length,math.sin(a)*length,size*(.9+j*.055)))
hd_curve('Tree_%d_Branch_%d'%(seed,j),[branch,(branch+end)*.5+Vector((0,0,.18)),end],[size*.11,size*.065,.012],M['bark'],10,6)
leaf_mesh('Tree_%d_Canopy_%d'%(seed,j),end,size*.68,seed*100+j)
leaf_mesh('Tree_%d_Spray_%d'%(seed,j),end*.55+branch*.45,size*.5,seed*200+j)
def fern(x,z,scale,seed):
rng=random.Random(seed); verts=[]; faces=[]
base=Vector(p(x,z,-.08))
for j in range(7):
a=math.tau*j/7+seed
direction=Vector((math.cos(a),math.sin(a),0))
side=direction.cross(Vector((0,0,1)))
length=scale*rng.uniform(.65,1.05)
for k in range(1,8):
t=k/8
center=base+direction*length*t+Vector((0,0,math.sin(t*math.pi)*length*.6))
for sign in [-1,1]:
end=center+side*sign*length*.25*math.sin(t*math.pi)+direction*length*.09
i=len(verts)
verts.extend([tuple(center-direction*.025),tuple(end),tuple(center+direction*.08),tuple(center+Vector((0,0,.015)))])
faces.extend([(i,i+1,i+3),(i+1,i+2,i+3),(i+2,i,i+3),(i+2,i+1,i)])
mesh_object('Fern_'+str(seed),verts,faces,M['leaf_mid' if seed%2 else 'leaf_light'])
def ruin_pillar(x,z,height,team,seed):
box('Pillar_Plinth',x,z,.10,1.30,1.30,.38)
box('Pillar_Foot',x,z,.36,1.04,1.04,.16,'brass',.025)
for level in range(int(height/.5)):
box('Pillar_Block',x,z,.63+level*.48,.8,.8,.46,angle=(seed%3-1)*.018)
box('Pillar_Capital',x,z,height+.2,1.06,1.06,.24)
box('Pillar_Cap',x,z,height+.38,.94,.94,.12,'brass',.018)
# Deep vertical grooves and inset illuminated rune bars on all four faces.
for sx,sz in [(1,0),(-1,0),(0,1),(0,-1)]:
for off in [-.21,.21]:
box('Pillar_Groove',x+sx*.408+sz*off,z+sz*.408+sx*off,height*.52+.25,.034 if sx else .065,.065 if sx else .034,height*.63,'brass',.008)
box('Pillar_Inscription',x+sx*.425,z+sz*.425,height*.67,.038 if sx else .095,.095 if sx else .038,.4,'teal' if team==0 else 'coral',.009)
# A sculpted, continuous ravine. The causeways are flat and above the terrain.
nx,nz=224,256
verts=[]; faces=[]
for j in range(nz+1):
z=-52+j*104/nz
for i in range(nx+1):
x=-48+i*96/nx
v=noise.noise_vector(Vector((x*.16,z*.12,3)))
river=math.exp(-(abs(x)/3.4)**4)
h=-.42-5.1*river + v.x*.34
outer=max(0,abs(x)-29)/11
h+=outer*(1.7+v.y*2.7)
if 3 < abs(x) < 16 and abs(z)<21: h=min(h,-.24)
shore=max(0,min(1,(math.sqrt((x/48)**2+(z/52)**2)-.70)/.27))
shore=shore*shore*(3-2*shore)
h=h*(1-shore)-5.5*shore
verts.append(p(x,z,h))
for j in range(nz):
for i in range(nx):
k=j*(nx+1)+i
faces.append((k,k+nx+1,k+nx+2,k+1))
terrain=mesh_object('Sculpted_River_Gorge',verts,faces,M['soil'])
for face in terrain.data.polygons: face.use_smooth=True
solid=terrain.modifiers.new('Closed bedrock','SOLIDIFY'); solid.thickness=2
bpy.context.view_layer.objects.active=terrain
bpy.ops.object.modifier_apply(modifier=solid.name)
for team,c in enumerate([-9,9]):
# Granite retaining wall: jointed masonry above real rock walls.
box('Causeway_Foundation',c,0,-.65,12.6,41,1.1,'cliff',.13)
for side in [-1,1]:
for j in range(21):
z=-20+j*2
box('Edge_Coping',c+side*6.2,z,-.04,.48,1.94,.26,'limestone',.06)
if j%3==0:
box('Boundary_Socket',c+side*6.18,z,.12,.58,.7,.25,'limestone',.05)
box('Boundary_Inlay',c+side*6.18,z,.25,.12,.40,.02,'teal' if team==0 else 'coral',.008)
# Side buttresses with stepped lower construction.
if j%4==0:
box('Wall_Buttress',c+side*6.4,z,-1.0,.75,1.15,2.4,'limestone',.09)
box('Wall_Buttress_Foot',c+side*6.4,z,-2.15,1,1.4,.3,'limestone',.06)
for j in range(11):
rock('Stratified_Cliff',c+side*6.7,-21+j*4,-2.55,1.05,2.45,2.45,100+team*30+j)
# Quiet broad paving, recessed bevels and minor rotation keep it natural.
for row in range(20):
for col in range(6):
box('Paving',c-5+col*2,-19+row*2,-.082,1.975,1.975,.15,'paving',.018)
# Portals at the arrival end, clear 10m passage under a ruined arch.
for s in [-1,1]:
ruin_pillar(c+s*5.25,-20.7,3.1,team,team*2+s)
for t in range(3):
box('Portal_Corbel',c+s*(5.1-t*.32),-20.7,3.42+t*.26,1.2,1.15,.32,'limestone',.045)
# Tall spire and layered ring patio around the crystal, outside workers.
for radius,height,depth in [(3.8,-.06,.12),(3.5,.025,.055)]:
obj=cylinder('Sanctum_Patio',p(c,18.2,height),radius,depth,M['limestone'],96)
hd_finish(obj,.025,2)
for rad in [3.25,3.5]: hd_ring('Sanctum_Inlay',p(c,18.2,.067),rad,.025,M['brass'],segments=128)
for s in [-1,1]:
ruin_pillar(c+s*4.45,20.6,2.6,team,40+s)
for i in range(8):
x=c-4.2+(i%4)*2.8; z=13.7+(i//4)*2.2
box('Gatherer_Station',x,z+.55,.05,1.6,1.0,.12,'limestone',.05)
box('Gatherer_Channel',x,z+.55,.12,1.16,.06,.025,'teal' if team==0 else 'coral',.01)
# Rear stairs communicate an entrance without covering the arena.
for step in range(6): box('Sanctum_Stairs',c,21.3+step*.35,-.08-step*.08,5.6,.45,.2,'limestone',.035)
# Ruined footbridges connect the courtyards over the gorge.
for z in [-22.5,23.6]:
for j in range(13):
x=-5.1+j*.85
h=.25+math.sin((j/12)*math.pi)*.5
box('Bridge_Voussoir',x,z,h-.52,.86,2.3,1,'limestone',.075)
box('Bridge_Paving',x,z,h,.83,2.28,.12,'paving',.018)
for side in [-1,1]:
box('Bridge_Parapet',x,z+side*1.2,h+.25,.81,.18,.50,'limestone',.035)
# Vegetation frames the board; no trees or grass on either gameplay grid.
for i in range(30):
side=-1 if i%2 else 1
x=side*R.uniform(19.5,29.5); z=R.uniform(-29,29)
tree(x,z,R.uniform(.78,1.35),i+1)
for i in range(100):
x=R.choice([-1,1])*R.uniform(17.0,30); z=R.uniform(-31,31)
rock('Forest_Boulder',x,z,-.1,R.uniform(.25,1.3),R.uniform(.35,1.5),R.uniform(.3,1.1),i)
for i in range(370):
x=R.choice([-1,1])*R.uniform(16.3,29); z=R.uniform(-31,31)
fern(x,z,R.uniform(.30,.85),i)
for i in range(28):
z=R.uniform(-29,29); x=R.choice([-1,1])*R.uniform(2.1,2.9)
rock('River_Bank',x,z,-3.2,R.uniform(.6,1),R.uniform(1,2),R.uniform(1.2,2.8),300+i)
for i in range(14):
x=R.choice([-1,1])*R.uniform(17,22); z=R.uniform(-28,28)
for j in range(3):
box('Fallen_Ruin',x+j*.32,z+j*.42,.12+j*.1,1.5,.8,.55,'limestone',.07,angle=i*.4)
# Meadow tussocks on the outer banks, absent from all gameplay cells.
verts=[];faces=[]
for i in range(2200):
x=R.choice([-1,1])*R.uniform(16.6,30.5);z=R.uniform(-32,32)
for j in range(5):
h=R.uniform(.13,.38);w=R.uniform(.012,.03);a=R.uniform(0,math.tau)
base=Vector(p(x+R.uniform(-.11,.11),z+R.uniform(-.11,.11),-.24))
side=Vector((math.cos(a),math.sin(a),0))*w
bend=Vector((math.sin(a),math.cos(a),0))*h*.36
mid=base+Vector((0,0,h*.56))+bend*.3
end=base+Vector((0,0,h))+bend
n=len(verts)
verts.extend([tuple(base-side),tuple(base+side),tuple(mid+side*.7),tuple(mid-side*.7),tuple(end),tuple(mid+Vector((0,0,.012)))])
faces.extend([(n,n+1,n+2,n+3),(n+3,n+2,n+4),(n+1,n,n+5),(n+2,n+1,n+5),(n+4,n+2,n+5),(n+3,n+4,n+5),(n,n+3,n+5)])
mesh_object('Meadow_11000_Curved_Blades',verts,faces,M['leaf_mid'])
# Editable source retains every object. Runtime merges by material and chunk
# for culling, greatly reducing the number of draws compared with source parts.
source=PROJECT/'assets-source/phase10/verdant-rift.blend'
bpy.data.libraries.write(str(source),{scene},fake_user=True,compress=True)
objects=[o for o in scene.objects if o.type=='MESH']
source_count=len(objects)
groups={}
for obj in objects:
center=obj.matrix_world.translation
key=(int(math.floor(center.x/16)),int(math.floor(center.y/16)),obj.data.materials[0].name)
groups.setdefault(key,[]).append(obj)
for key,parts in groups.items():
bpy.ops.object.select_all(action='DESELECT')
for obj in parts: obj.select_set(True)
bpy.context.view_layer.objects.active=parts[0]
bpy.ops.object.join()
parts[0].name='Chunk_%d_%d_%s'%key
bpy.ops.object.select_all(action='SELECT')
out=PROJECT/'assets/environment/verdant-rift.glb'
bpy.ops.export_scene.gltf(filepath=str(out),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_cameras=False,export_lights=False,export_animations=False)
tris=0
for obj in scene.objects:
if obj.type=='MESH':
obj.data.calc_loop_triangles();tris+=len(obj.data.loop_triangles)
record={'name':'Die grüne Kluft','source_objects':source_count,'runtime_chunks':len(groups),'triangles':tris,'seed':10092026,'blender':bpy.app.version_string,'sha256':hashlib.sha256(out.read_bytes()).hexdigest(),'playable_grids':2,'cell_size_m':2,'grid_cells':[6,8]}
(PROJECT/'assets-source/phase10/environment-manifest.json').write_text(json.dumps(record,indent=2)+'\n')
print('ENVIRONMENT_EXPORTED',json.dumps(record),flush=True)