mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 22:23:44 +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.
133 lines
4.6 KiB
Python
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)
|