mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 21:33:42 +00:00
1eb5a3188d
Ship gets a greebled hull, tapered nose, swept canopy, twin engine nacelles, and tail fin (built via the vendored Blender MCP, generator committed at tools/blender/gen_ship.py) in place of the 5 flat primitives. The ball is fully remodeled as a smooth round sphere with a crossed emissive accent pattern (gen_ball.py), replacing the old flat-shaded gold_ball rather than just tweaking its material. Node names (Nose/TailFin) are preserved for Ship._apply_team_color(), and the RigidBody3D/CollisionShape3D physics on both ship.tscn and ball.tscn are untouched so RL-trained bots and flight feel stay valid. Each part's mesh is extracted to a standalone .res (tools/blender/extract_meshes.gd) rather than referenced via glb::ArrayMesh_xxx sub-paths, which don't reliably resolve across scene files and were silently rendering both models invisible.
342 lines
13 KiB
Python
342 lines
13 KiB
Python
"""Procedurally generates the Cosmic Clash ship hull model.
|
|
|
|
Run inside Blender (e.g. via the blender-mcp `execute_blender_code` tool, or
|
|
Blender's own script editor / `blender --background --python gen_ship.py`).
|
|
Builds six named mesh objects — Hull, Nose, Canopy, TailFin, EngineGlowL,
|
|
EngineGlowR — greebled from bmesh primitives (bevel + inset/extrude), saves
|
|
ship.blend, then exports each part as its OWN glb file (ship_hull.glb,
|
|
ship_nose.glb, etc.) rather than one combined file.
|
|
|
|
Why per-part files: Godot cannot reliably resolve a single named sub-mesh
|
|
out of a multi-object glTF via `path.glb::ArrayMesh_xxx` addressing from a
|
|
*different* .tscn — that syntax only resolves once the whole glTF scene has
|
|
already been instanced elsewhere in the same session; a fresh load of just
|
|
that ext_resource reference silently returns a null mesh. Exporting one
|
|
object per glb file avoids that (each file's root is a clean, individually
|
|
loadable node), but each still imports as a synthetic Node3D wrapper with
|
|
ONE MeshInstance3D child — not the MeshInstance3D itself. `ship.tscn` needs
|
|
the mesh nodes as *direct* children of the Ship root (see node-name note
|
|
below), so after running this script, run `extract_meshes.gd` (in this same
|
|
directory) inside Godot to pull each part's ArrayMesh out into its own
|
|
`.res` file — that's what `ship.tscn` actually references.
|
|
|
|
Node names matter: `scripts/ship.gd`'s `_apply_team_color()` recolors direct
|
|
children of the Ship node named exactly "Nose" and "TailFin" at runtime, so
|
|
those two object names must be preserved.
|
|
|
|
Orientation: empirically, Blender's -Y axis (this script builds the nose at
|
|
-Y) maps to Godot's **+Z** on glTF export (not -Z as the naive Blender-Z-up
|
|
vs. Godot-Y-up mapping might suggest) — verified in-engine, not assumed.
|
|
Since ship forward is Godot -Z (see `ship.gd`), `ship.tscn` applies a
|
|
180-degree rotation (`Transform3D(-1,0,0, 0,1,0, 0,0,-1, ...)`) to every
|
|
part's instance node to correct this. If you regenerate the parts, that
|
|
correction stays in `ship.tscn` — don't rebuild it into the Blender geometry
|
|
too, or the two fixes will cancel out and the ship will face backwards again.
|
|
|
|
Silhouette budget: the ship's RigidBody3D collision shape (`ship.tscn`,
|
|
BoxShape3D) is (1, 1, 4) in Godot space — X width, Y height, Z length. In
|
|
this script's local (pre-export) space X=width, Y=length (nose at -Y),
|
|
Z=height, and the budget is X +-0.5, Y(length) +-2.0, Z(height) +-0.5. The
|
|
built silhouette is kept reasonably close to that budget, not an exact fit
|
|
(matching the original primitive ship, which was also slightly over at a
|
|
few extremities).
|
|
"""
|
|
|
|
import math
|
|
import random
|
|
|
|
import bmesh
|
|
import bpy
|
|
from mathutils import Matrix, Vector
|
|
|
|
BLEND_PATH = "Game/assets/blender_models/ship.blend"
|
|
PART_GLB_PATHS = {
|
|
"Hull": "Game/assets/models/ship_hull.glb",
|
|
"Nose": "Game/assets/models/ship_nose.glb",
|
|
"Canopy": "Game/assets/models/ship_canopy.glb",
|
|
"TailFin": "Game/assets/models/ship_tailfin.glb",
|
|
"EngineGlowL": "Game/assets/models/ship_engine_l.glb",
|
|
"EngineGlowR": "Game/assets/models/ship_engine_r.glb",
|
|
}
|
|
|
|
|
|
def new_mesh_object(name, bm):
|
|
mesh = bpy.data.meshes.new(name)
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
obj = bpy.data.objects.new(name, mesh)
|
|
bpy.context.collection.objects.link(obj)
|
|
return obj
|
|
|
|
|
|
def remove_object(name):
|
|
obj = bpy.data.objects.get(name)
|
|
if obj:
|
|
mesh = obj.data
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
if mesh.users == 0:
|
|
bpy.data.meshes.remove(mesh)
|
|
|
|
|
|
def clear_scene():
|
|
for obj in list(bpy.data.objects):
|
|
if obj.type in {"MESH", "EMPTY"}:
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
for block in list(bpy.data.meshes):
|
|
if block.users == 0:
|
|
bpy.data.meshes.remove(block)
|
|
for block in list(bpy.data.materials):
|
|
if block.users == 0:
|
|
bpy.data.materials.remove(block)
|
|
|
|
|
|
def build_hull():
|
|
random.seed(7)
|
|
bm = bmesh.new()
|
|
bmesh.ops.create_cube(bm, size=1.0)
|
|
# Full dims: width 0.9, length 2.6, height 0.6 (collision budget is 1 x 4 x 1 full)
|
|
bmesh.ops.scale(bm, vec=(0.9, 2.6, 0.6), verts=bm.verts)
|
|
|
|
bm.edges.ensure_lookup_table()
|
|
long_edges = [e for e in bm.edges if (e.verts[0].co - e.verts[1].co).length > 2.0]
|
|
bmesh.ops.bevel(bm, geom=long_edges, offset=0.1, segments=3, affect="EDGES")
|
|
|
|
bm.faces.ensure_lookup_table()
|
|
panel_target_faces = [f for f in bm.faces if abs(f.normal.z) > 0.6 or abs(f.normal.x) > 0.6]
|
|
edges_to_cut = list({e for f in panel_target_faces for e in f.edges})
|
|
bmesh.ops.subdivide_edges(bm, edges=edges_to_cut, cuts=3, use_grid_fill=True)
|
|
|
|
def greeble_pass(seed, fraction, thickness, depth_range):
|
|
random.seed(seed)
|
|
bm.faces.ensure_lookup_table()
|
|
candidates = [
|
|
f
|
|
for f in bm.faces
|
|
if (abs(f.normal.z) > 0.55 or abs(f.normal.x) > 0.55) and 0.01 < f.calc_area() < 0.35
|
|
]
|
|
random.shuffle(candidates)
|
|
chosen = candidates[: max(1, int(len(candidates) * fraction))]
|
|
for f in chosen:
|
|
if not f.is_valid:
|
|
continue
|
|
res = bmesh.ops.inset_individual(bm, faces=[f], thickness=thickness)
|
|
new_faces = res["faces"]
|
|
if new_faces:
|
|
nf = new_faces[0]
|
|
depth = random.uniform(*depth_range)
|
|
bmesh.ops.translate(bm, verts=nf.verts, vec=nf.normal * depth)
|
|
|
|
greeble_pass(seed=7, fraction=1 / 3, thickness=0.02, depth_range=(-0.03, 0.022))
|
|
greeble_pass(seed=42, fraction=0.25, thickness=0.018, depth_range=(-0.028, 0.018))
|
|
|
|
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
|
return new_mesh_object("Hull", bm)
|
|
|
|
|
|
def build_nose():
|
|
random.seed(11)
|
|
bm = bmesh.new()
|
|
bmesh.ops.create_cube(bm, size=1.0)
|
|
bmesh.ops.scale(bm, vec=(0.82, 0.25, 0.5), verts=bm.verts)
|
|
bmesh.ops.translate(bm, vec=(0.0, -1.425, 0.0), verts=bm.verts)
|
|
|
|
def front_face():
|
|
bm.faces.ensure_lookup_table()
|
|
return min(bm.faces, key=lambda f: f.calc_center_median().y)
|
|
|
|
# Stage 1 taper: extrude forward, narrow to ~half width/height.
|
|
f = front_face()
|
|
r = bmesh.ops.extrude_face_region(bm, geom=[f])
|
|
new_verts = [v for v in r["geom"] if isinstance(v, bmesh.types.BMVert)]
|
|
bmesh.ops.translate(bm, verts=new_verts, vec=(0, -0.32, 0.02))
|
|
pivot = sum((v.co for v in new_verts), Vector()) / len(new_verts)
|
|
bmesh.ops.scale(bm, verts=new_verts, vec=(0.5, 1.0, 0.55), space=Matrix.Translation(-pivot))
|
|
|
|
# Stage 2 taper: extrude further to a near-point tip.
|
|
bm.faces.ensure_lookup_table()
|
|
f2 = front_face()
|
|
r2 = bmesh.ops.extrude_face_region(bm, geom=[f2])
|
|
new_verts2 = [v for v in r2["geom"] if isinstance(v, bmesh.types.BMVert)]
|
|
bmesh.ops.translate(bm, verts=new_verts2, vec=(0, -0.28, 0.0))
|
|
pivot2 = sum((v.co for v in new_verts2), Vector()) / len(new_verts2)
|
|
bmesh.ops.scale(bm, verts=new_verts2, vec=(0.08, 1.0, 0.08), space=Matrix.Translation(-pivot2))
|
|
|
|
bm.edges.ensure_lookup_table()
|
|
sharp_edges = [e for e in bm.edges if e.calc_face_angle(1.0) > 0.35]
|
|
bmesh.ops.bevel(bm, geom=sharp_edges, offset=0.02, segments=2, affect="EDGES")
|
|
|
|
bm.faces.ensure_lookup_table()
|
|
side_faces = [f for f in bm.faces if abs(f.normal.x) > 0.5 and f.calc_area() > 0.01][:2]
|
|
for f in side_faces:
|
|
if not f.is_valid:
|
|
continue
|
|
res = bmesh.ops.inset_individual(bm, faces=[f], thickness=0.02)
|
|
nf = res["faces"][0] if res["faces"] else None
|
|
if nf:
|
|
bmesh.ops.translate(bm, verts=nf.verts, vec=nf.normal * -0.015)
|
|
|
|
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
|
return new_mesh_object("Nose", bm)
|
|
|
|
|
|
def build_canopy():
|
|
bm = bmesh.new()
|
|
bmesh.ops.create_icosphere(bm, subdivisions=2, radius=0.3)
|
|
bm.faces.ensure_lookup_table()
|
|
faces_to_del = [f for f in bm.faces if f.calc_center_median().z < -0.02]
|
|
bmesh.ops.delete(bm, geom=faces_to_del, context="FACES")
|
|
bm.edges.ensure_lookup_table()
|
|
boundary_edges = [e for e in bm.edges if len(e.link_faces) == 1]
|
|
if boundary_edges:
|
|
bmesh.ops.holes_fill(bm, edges=boundary_edges)
|
|
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
|
|
|
# Swept teardrop canopy: wider, stretched fore-aft, low profile, tapered at the rear.
|
|
for v in bm.verts:
|
|
v.co.x *= 1.5
|
|
v.co.y *= 2.2
|
|
v.co.z *= 0.82
|
|
if v.co.y > 0:
|
|
t = min(v.co.y / 0.62, 1.0)
|
|
v.co.z *= 1.0 - 0.35 * t
|
|
v.co.x *= 1.0 - 0.25 * t
|
|
|
|
obj = new_mesh_object("Canopy", bm)
|
|
# Front-upper on the hull; kept low so the top stays close to the +-0.5 height budget.
|
|
obj.location = (0.0, -0.55, 0.31)
|
|
return obj
|
|
|
|
|
|
def build_nacelle(name, x_offset):
|
|
bm = bmesh.new()
|
|
bmesh.ops.create_cone(
|
|
bm, cap_ends=True, cap_tris=False, segments=12, radius1=0.11, radius2=0.095, depth=0.85
|
|
)
|
|
# Cone is built along Z by default; rotate -90 deg about X to align its axis with Y (length).
|
|
rot = Matrix.Rotation(-math.pi / 2, 4, "X")
|
|
bmesh.ops.transform(bm, matrix=rot, verts=bm.verts)
|
|
|
|
bm.edges.ensure_lookup_table()
|
|
ring_edges = [
|
|
e
|
|
for e in bm.edges
|
|
if len(e.link_faces) == 2 and abs(e.verts[0].co.y - e.verts[1].co.y) < 0.01
|
|
]
|
|
if ring_edges:
|
|
bmesh.ops.bevel(bm, geom=ring_edges, offset=0.012, segments=2, affect="EDGES")
|
|
|
|
obj = new_mesh_object(name, bm)
|
|
# Rear of the ship, flanking the hull, slightly below centerline.
|
|
obj.location = (x_offset, 0.95, -0.05)
|
|
return obj
|
|
|
|
|
|
def build_tailfin():
|
|
bm = bmesh.new()
|
|
bmesh.ops.create_cube(bm, size=1.0)
|
|
bmesh.ops.scale(bm, vec=(0.06, 0.65, 0.42), verts=bm.verts)
|
|
# Sweep the top-rear edge back, taper the top edge thin.
|
|
for v in bm.verts:
|
|
if v.co.z > 0:
|
|
v.co.y += 0.16 if v.co.y > 0 else 0.04
|
|
v.co.x *= 0.35
|
|
|
|
bm.edges.ensure_lookup_table()
|
|
sharp_edges = [e for e in bm.edges if e.calc_face_angle(1.0) > 0.3]
|
|
bmesh.ops.bevel(bm, geom=sharp_edges, offset=0.012, segments=2, affect="EDGES")
|
|
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
|
|
|
obj = new_mesh_object("TailFin", bm)
|
|
obj.location = (0.0, 0.72, 0.24)
|
|
return obj
|
|
|
|
|
|
def make_material(name, base_rgb, metallic, roughness, emission_rgb=None, emission_strength=0.0):
|
|
existing = bpy.data.materials.get(name)
|
|
if existing:
|
|
bpy.data.materials.remove(existing)
|
|
mat = bpy.data.materials.new(name)
|
|
mat.use_nodes = True
|
|
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
|
bsdf.inputs["Base Color"].default_value = (*base_rgb, 1.0)
|
|
bsdf.inputs["Metallic"].default_value = metallic
|
|
bsdf.inputs["Roughness"].default_value = roughness
|
|
if emission_rgb is not None:
|
|
bsdf.inputs["Emission Color"].default_value = (*emission_rgb, 1.0)
|
|
bsdf.inputs["Emission Strength"].default_value = emission_strength
|
|
return mat
|
|
|
|
|
|
def assign_materials():
|
|
mat_hull = make_material("Mat_Hull", (0.35, 0.37, 0.42), 0.6, 0.4)
|
|
# Accent starts team-blue; Ship._apply_team_color() overrides this per-team at runtime.
|
|
mat_accent = make_material("Mat_Accent", (0.25, 0.55, 1.0), 0.3, 0.5, (0.25, 0.55, 1.0), 0.9)
|
|
mat_canopy = make_material("Mat_Canopy", (0.15, 0.85, 1.0), 0.8, 0.1, (0.15, 0.85, 1.0), 1.4)
|
|
mat_nacelle_body = make_material("Mat_NacelleBody", (0.22, 0.24, 0.28), 0.7, 0.35)
|
|
mat_engine_glow = make_material(
|
|
"Mat_EngineGlow", (1.0, 0.55, 0.15), 0.1, 0.4, (1.0, 0.55, 0.15), 4.0
|
|
)
|
|
|
|
def assign_single(obj_name, mat):
|
|
obj = bpy.data.objects[obj_name]
|
|
obj.data.materials.clear()
|
|
obj.data.materials.append(mat)
|
|
|
|
assign_single("Hull", mat_hull)
|
|
assign_single("Nose", mat_accent)
|
|
assign_single("TailFin", mat_accent)
|
|
assign_single("Canopy", mat_canopy)
|
|
|
|
for name in ["EngineGlowL", "EngineGlowR"]:
|
|
obj = bpy.data.objects[name]
|
|
obj.data.materials.clear()
|
|
obj.data.materials.append(mat_nacelle_body) # slot 0: body
|
|
obj.data.materials.append(mat_engine_glow) # slot 1: rear exhaust glow cap
|
|
polys = obj.data.polygons
|
|
max_y = max(p.center.y for p in polys)
|
|
for p in polys:
|
|
p.material_index = 1 if p.center.y > max_y - 0.01 else 0
|
|
|
|
|
|
def build_ship():
|
|
clear_scene()
|
|
build_hull()
|
|
build_nose()
|
|
build_canopy()
|
|
build_nacelle("EngineGlowL", -0.42)
|
|
build_nacelle("EngineGlowR", 0.42)
|
|
build_tailfin()
|
|
assign_materials()
|
|
|
|
|
|
def export(repo_root):
|
|
import os
|
|
|
|
blend_path = os.path.join(repo_root, BLEND_PATH)
|
|
bpy.ops.wm.save_as_mainfile(filepath=blend_path)
|
|
|
|
for obj_name, rel_path in PART_GLB_PATHS.items():
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
obj = bpy.data.objects[obj_name]
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = obj
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=os.path.join(repo_root, rel_path),
|
|
export_format="GLB",
|
|
use_selection=True,
|
|
export_apply=True,
|
|
export_materials="EXPORT",
|
|
)
|
|
|
|
print("Exported per-part glbs. Now run extract_meshes.gd inside Godot")
|
|
print("to produce the ship_*.res files that ship.tscn references.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import os
|
|
|
|
# Assumes this script lives at <repo_root>/tools/blender/gen_ship.py.
|
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
build_ship()
|
|
export(repo_root)
|