mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 06:42:03 +00:00
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.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user