Files
Josh Creek 1eb5a3188d feat: replace ship and ball placeholder meshes with Blender-modeled assets
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.
2026-08-03 22:39:46 +01:00

133 lines
4.6 KiB
Python

"""Procedurally generates the Cosmic Clash match ball model.
Run inside Blender (e.g. via the blender-mcp `execute_blender_code` tool, or
`blender --background --python gen_ball.py`). Builds a single "Ball" mesh
object: a smooth-shaded, undeformed high-resolution icosphere (every vertex
stays exactly on the radius-1.0 sphere, so the silhouette is perfectly round
and matches the untouched SphereShape3D collision) with a metallic gold body
and a pair of crossed emissive "energy seam" rings applied purely via
per-face material assignment — no geometry is pushed off the sphere, unlike
an earlier beveled/faceted revision that read as an angular gem rather than
a round ball.
Built at native radius 1.0 to match `ball.tscn`'s existing MeshInstance3D
scale of 0.5 (world radius 0.5). Keep this convention if regenerating, so
`ball.tscn` doesn't need its scale transform changed.
After running this script, run `extract_meshes.gd` (in this same directory)
inside Godot to produce `ball.res`, which is what `ball.tscn` actually
references — not `ball.glb` directly. A glTF import's PackedScene wraps its
mesh object in a synthetic Node3D root, and referencing a named sub-mesh of
that scene via `path.glb::ArrayMesh_xxx` from a *different* .tscn doesn't
reliably resolve (it silently returns a null mesh unless that exact glTF
scene has already been instanced elsewhere first) — extracting a standalone
`.res` avoids that entirely.
"""
import bpy
import bmesh
BLEND_PATH = "Game/assets/blender_models/ball.blend"
GLB_PATH = "Game/assets/models/ball.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 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_ball_mesh():
bm = bmesh.new()
# No bevel/inset — geometry stays an undeformed sphere so the silhouette
# is perfectly round; all surface detail below is material-only.
bmesh.ops.create_icosphere(bm, subdivisions=4, radius=1.0)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
ball = new_mesh_object("Ball", bm)
for p in ball.data.polygons:
p.use_smooth = True
return ball
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(ball):
mat_body = make_material("Mat_GoldBody", (0.85, 0.65, 0.15), 0.92, 0.18)
mat_accent = make_material(
"Mat_GoldAccent", (1.0, 0.85, 0.4), 0.7, 0.2, (1.0, 0.8, 0.35), 3.0
)
ball.data.materials.clear()
ball.data.materials.append(mat_body) # 0
ball.data.materials.append(mat_accent) # 1
# Two crossed great-circle "energy seam" rings, selected purely by face
# center position — a deliberate accent pattern rather than random speckle,
# with zero effect on vertex positions.
for p in ball.data.polygons:
cx, _, cz = p.center
on_equator_ring = abs(cz) < 0.09
on_meridian_ring = abs(cx) < 0.09
p.material_index = 1 if (on_equator_ring or on_meridian_ring) else 0
def build_ball():
clear_scene()
ball = build_ball_mesh()
assign_materials(ball)
def export(repo_root):
import os
blend_path = os.path.join(repo_root, BLEND_PATH)
glb_path = os.path.join(repo_root, GLB_PATH)
bpy.ops.wm.save_as_mainfile(filepath=blend_path)
bpy.ops.object.select_all(action="SELECT")
bpy.ops.export_scene.gltf(
filepath=glb_path,
export_format="GLB",
use_selection=True,
export_apply=True,
export_materials="EXPORT",
)
if __name__ == "__main__":
import os
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
build_ball()
export(repo_root)