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>
274 lines
14 KiB
Python
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)
|