The only site whose contents are mostly SOFT, and that one material change rewrites the
level. `produce` is a new Smashable material that throws no shards at all — Splat.gd
bursts it instead: coloured pulp, a puddle that STAYS, a squelch, and litres counted,
because "how much juice did you make" is a far better score for a greengrocer than "how
many objects did you destroy".
Two knock-ons make the level play itself, both falling out of existing systems rather
than needing new ones:
* produce has a low brittle_speed, so anything heavy landing on it squashes it — which
means a pyramid crushes its OWN bottom layer as it collapses, with no special code.
Knock one off the top and the pile does the rest. Shove test: 257 items -> 45, and
42 litres of juice out of the collapse.
* puddles are slippery, cutting ACCELERATION rather than top speed so it reads as
sliding rather than as being slowed down. The more mess you make, the less the floor
cooperates.
Stacks are built bottom-up by Main._build_stack and nothing is glued or frozen — they
stand because they are stacked, so pulling one out of the bottom row does what you'd
hope. 258 produce items spawn and settle to 0.00 m/s with no spawn crush. The glass
bottles behind the juice bar are the deliberate exception: one shelf in the room that
still rewards a proper swing, and the contrast between litres and shards is the joke.
Round produce is authored CENTRED, not floor-centred like every other prop in this repo,
because a rolling body whose origin sits on the floor plane wobbles like a loaded die.
_glb_piece grew a `centred` flag for it.
REAL BUG FOUND, and it was costing hits in every level: _strike took the NEAREST collider
of any kind, so a static surface could eat a swing. Leaning over a display table, the
table edge is a few centimetres nearer than the fruit piled on it, so every swing hit the
table and nothing broke — 0/306 for the whole first take. It now prefers a Smashable and
only falls back to loose bodies if there isn't one. The offices were quietly losing hits
on desks and shelves the same way.
tools/gen_produce.py: apple, orange, tomato, watermelon, cabbage, banana hand, glass
juice bottle, display crate. The melon's stripes were radial boxes first time and it came
out looking like a sea mine; they're thin lenses through the middle now.
dev/probe_grocer.gd verifies stacks settle, that produce yields litres and NO debris,
that a collapse crushes, and that none of it leaks to the next site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
354 lines
12 KiB
Python
354 lines
12 KiB
Python
"""The greengrocer's stock.
|
|
|
|
/Applications/Blender.app/Contents/MacOS/Blender --background \
|
|
--python tools/gen_produce.py -- [--render]
|
|
|
|
ORIGIN RULE, and it differs from every other prop in this repo: round produce is
|
|
CENTRED, not floor-centred. These things are meant to roll, and a rolling body whose
|
|
origin sits on the floor plane wobbles like a loaded die. Everything else here (crates,
|
|
bottles, the banana bunch) keeps the usual floor-centre origin, and Main knows which is
|
|
which from the level spec.
|
|
|
|
Deliberately a bit under-detailed: these spawn in pyramids of thirty, so the budget goes
|
|
on silhouette and colour rather than on a nice stem.
|
|
"""
|
|
|
|
import bpy
|
|
import math
|
|
import os
|
|
import sys
|
|
from mathutils import Vector, Matrix
|
|
|
|
OUT_DIR = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
"game", "assets", "store")
|
|
PREVIEW_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "previews")
|
|
RENDER = "--render" in sys.argv
|
|
|
|
|
|
def reset():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
|
|
|
|
def mat(name, color, rough=0.55, metal=0.0, alpha=1.0):
|
|
m = bpy.data.materials.new(name)
|
|
m.use_nodes = True
|
|
b = m.node_tree.nodes["Principled BSDF"]
|
|
b.inputs["Base Color"].default_value = (color[0], color[1], color[2], alpha)
|
|
b.inputs["Roughness"].default_value = rough
|
|
b.inputs["Metallic"].default_value = metal
|
|
if alpha < 1.0:
|
|
b.inputs["Alpha"].default_value = alpha
|
|
m.blend_method = 'BLEND'
|
|
return m
|
|
|
|
|
|
def assign(o, m):
|
|
o.data.materials.clear()
|
|
o.data.materials.append(m)
|
|
return o
|
|
|
|
|
|
def box(size, loc=(0, 0, 0), rot=(0, 0, 0)):
|
|
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc, rotation=rot)
|
|
o = bpy.context.active_object
|
|
o.scale = (size[0], size[1], size[2])
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
return o
|
|
|
|
|
|
def cyl(r, depth, loc=(0, 0, 0), rot=(0, 0, 0), verts=16, r2=None):
|
|
if r2 is None:
|
|
bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=depth, vertices=verts,
|
|
location=loc, rotation=rot)
|
|
else:
|
|
bpy.ops.mesh.primitive_cone_add(radius1=r, radius2=r2, depth=depth,
|
|
vertices=verts, location=loc, rotation=rot)
|
|
return bpy.context.active_object
|
|
|
|
|
|
def ball(r, loc=(0, 0, 0), segs=20, rings=12):
|
|
bpy.ops.mesh.primitive_uv_sphere_add(radius=r, segments=segs, ring_count=rings,
|
|
location=loc)
|
|
return bpy.context.active_object
|
|
|
|
|
|
def bevel(o, width=0.005, segments=2):
|
|
m = o.modifiers.new("bevel", "BEVEL")
|
|
m.width = width
|
|
m.segments = segments
|
|
m.limit_method = 'ANGLE'
|
|
m.angle_limit = math.radians(40)
|
|
bpy.context.view_layer.objects.active = o
|
|
try:
|
|
bpy.ops.object.modifier_apply(modifier=m.name)
|
|
except Exception:
|
|
o.modifiers.remove(m)
|
|
return o
|
|
|
|
|
|
def smooth(o, angle=45.0):
|
|
bpy.context.view_layer.objects.active = o
|
|
o.select_set(True)
|
|
try:
|
|
bpy.ops.object.shade_auto_smooth(angle=math.radians(angle))
|
|
except Exception:
|
|
bpy.ops.object.shade_smooth()
|
|
o.select_set(False)
|
|
|
|
|
|
def span(r0, r1, p0, p1, verts=8):
|
|
p0, p1 = Vector(p0), Vector(p1)
|
|
d = p1 - p0
|
|
n = d.length
|
|
if n < 1e-6:
|
|
return None
|
|
o = cyl(r0, n, verts=verts, r2=r1)
|
|
q = Vector((0, 0, 1)).rotation_difference(d.normalized())
|
|
o.matrix_world = Matrix.Translation(p0 + d * 0.5) @ q.to_matrix().to_4x4()
|
|
bpy.ops.object.transform_apply(location=True, rotation=True)
|
|
return o
|
|
|
|
|
|
# ---------------------------------------------------------------- produce
|
|
# All the round ones are CENTRED on the origin so they roll properly.
|
|
|
|
def p_apple(M):
|
|
parts = []
|
|
b = ball(0.040, segs=18, rings=12)
|
|
b.scale = (1.0, 1.0, 0.92)
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
parts.append(assign(b, M["apple"]))
|
|
# the dimple: a dark disc SUNK into the top, not a bobble sitting on it
|
|
d = ball(0.017, loc=(0, 0, 0.029), segs=12, rings=8)
|
|
d.scale = (1.0, 1.0, 0.34)
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
parts.append(assign(d, M["apple_dark"]))
|
|
parts.append(assign(span(0.004, 0.003, Vector((0, 0, 0.032)),
|
|
Vector((0.006, 0.004, 0.062)), verts=6), M["stem"]))
|
|
return parts
|
|
|
|
|
|
def p_orange(M):
|
|
parts = []
|
|
b = ball(0.037, segs=18, rings=12)
|
|
b.scale = (1.0, 1.0, 0.95)
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
parts.append(assign(b, M["orange"]))
|
|
parts.append(assign(cyl(0.006, 0.004, loc=(0, 0, 0.034), verts=8), M["stem"]))
|
|
return parts
|
|
|
|
|
|
def p_tomato(M):
|
|
parts = []
|
|
b = ball(0.033, segs=18, rings=12)
|
|
b.scale = (1.0, 1.0, 0.82)
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
parts.append(assign(b, M["tomato"]))
|
|
# calyx: five little leaves
|
|
for i in range(5):
|
|
a = i * (math.tau / 5.0)
|
|
parts.append(assign(span(0.006, 0.002,
|
|
Vector((0, 0, 0.026)),
|
|
Vector((math.cos(a) * 0.020, math.sin(a) * 0.020, 0.030)),
|
|
verts=5), M["leaf"]))
|
|
return parts
|
|
|
|
|
|
def p_melon(M):
|
|
"""Watermelon. Big, heavy, and the best thing in the shop to drop on other produce."""
|
|
parts = []
|
|
b = ball(0.145, segs=26, rings=16)
|
|
b.scale = (1.0, 1.28, 1.0)
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
parts.append(assign(b, M["melon"]))
|
|
# Stripes as thin lenses through the middle, each a hair proud of the skin, so they
|
|
# read as bands wrapping the fruit. Boxes poking out radially made it look like a
|
|
# sea mine.
|
|
for i in range(7):
|
|
a = i * (math.pi / 7.0)
|
|
st = ball(0.1465, segs=26, rings=16)
|
|
st.scale = (0.115, 1.283, 1.004)
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
st.rotation_euler = (0.0, 0.0, a)
|
|
bpy.ops.object.transform_apply(rotation=True)
|
|
parts.append(assign(st, M["melon_dark"]))
|
|
return parts
|
|
|
|
|
|
def p_cabbage(M):
|
|
parts = []
|
|
b = ball(0.082, segs=20, rings=14)
|
|
b.scale = (1.0, 1.0, 0.88)
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
parts.append(assign(b, M["cabbage"]))
|
|
# a few outer leaves, so it isn't just a green ball
|
|
for i in range(6):
|
|
a = i * (math.tau / 6.0)
|
|
lf = ball(0.052, loc=(math.cos(a) * 0.058, math.sin(a) * 0.058, -0.016),
|
|
segs=12, rings=8)
|
|
lf.scale = (1.25, 1.25, 0.42)
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
parts.append(assign(lf, M["cabbage_out"]))
|
|
return parts
|
|
|
|
|
|
def p_banana(M):
|
|
"""A hand of bananas. Floor-centred — these don't roll, they flop."""
|
|
parts = []
|
|
for i in range(5):
|
|
off = (i - 2) * 0.018
|
|
pos = Vector((off, 0.0, 0.030))
|
|
prev = pos
|
|
for seg in range(4):
|
|
a = math.radians(-38 + seg * 26)
|
|
nxt = prev + Vector((0.0, math.cos(a) * 0.042, math.sin(a) * 0.042 * 0.5))
|
|
r = 0.016 - seg * 0.0022
|
|
parts.append(assign(span(r, r * 0.86, prev, nxt, verts=8), M["banana"]))
|
|
prev = nxt
|
|
crown = box((0.10, 0.030, 0.026), loc=(0, -0.012, 0.030))
|
|
parts.append(assign(bevel(crown, 0.008), M["stem"]))
|
|
return parts
|
|
|
|
|
|
# ---------------------------------------------------------------- fittings
|
|
|
|
def p_bottle(M):
|
|
"""Glass juice bottle. Floor-centred. Breaks like glass, not like fruit."""
|
|
parts = []
|
|
body = cyl(0.037, 0.185, loc=(0, 0, 0.093), verts=20)
|
|
parts.append(assign(body, M["glass"]))
|
|
juice = cyl(0.031, 0.140, loc=(0, 0, 0.076), verts=18)
|
|
parts.append(assign(juice, M["juice"]))
|
|
shoulder = cyl(0.037, 0.055, loc=(0, 0, 0.212), r2=0.017, verts=20)
|
|
parts.append(assign(shoulder, M["glass"]))
|
|
neck = cyl(0.017, 0.048, loc=(0, 0, 0.262), verts=14)
|
|
parts.append(assign(neck, M["glass"]))
|
|
cap = cyl(0.020, 0.020, loc=(0, 0, 0.294), verts=14)
|
|
parts.append(assign(cap, M["cap"]))
|
|
label = cyl(0.0385, 0.070, loc=(0, 0, 0.085), verts=20)
|
|
parts.append(assign(label, M["label"]))
|
|
return parts
|
|
|
|
|
|
def p_crate(M):
|
|
"""Angled wooden display crate. Produce piles into it."""
|
|
parts = []
|
|
W, D, H, T = 0.52, 0.38, 0.15, 0.014
|
|
parts.append(assign(box((W, T, H), loc=(0, -D / 2, H / 2)), M["crate"]))
|
|
parts.append(assign(box((W, T, H * 1.5), loc=(0, D / 2, H * 0.75)), M["crate"]))
|
|
for s in (-1, 1):
|
|
parts.append(assign(box((T, D, H * 1.2), loc=(s * W / 2, 0, H * 0.6)), M["crate"]))
|
|
for i in range(5):
|
|
parts.append(assign(box((W, D / 5 - 0.01, T),
|
|
loc=(0, -D / 2 + (i + 0.5) * D / 5, T / 2)), M["crate"]))
|
|
return parts
|
|
|
|
|
|
PROPS = {
|
|
"produce-apple": p_apple,
|
|
"produce-orange": p_orange,
|
|
"produce-tomato": p_tomato,
|
|
"produce-melon": p_melon,
|
|
"produce-cabbage": p_cabbage,
|
|
"produce-banana": p_banana,
|
|
"juice-bottle": p_bottle,
|
|
"produce-crate": p_crate,
|
|
}
|
|
|
|
|
|
def materials():
|
|
return {
|
|
"apple": mat("apple", (0.62, 0.06, 0.07), rough=0.32),
|
|
"apple_dark": mat("apple_dark", (0.30, 0.04, 0.05), rough=0.5),
|
|
"orange": mat("orange", (0.88, 0.42, 0.05), rough=0.62),
|
|
"tomato": mat("tomato", (0.72, 0.07, 0.05), rough=0.28),
|
|
"melon": mat("melon", (0.16, 0.36, 0.12), rough=0.45),
|
|
"melon_dark": mat("melon_dark", (0.08, 0.20, 0.07), rough=0.45),
|
|
"cabbage": mat("cabbage", (0.68, 0.78, 0.48), rough=0.55),
|
|
"cabbage_out": mat("cabbage_out", (0.38, 0.58, 0.26), rough=0.62),
|
|
"banana": mat("banana", (0.90, 0.78, 0.18), rough=0.5),
|
|
"leaf": mat("leaf", (0.24, 0.44, 0.16), rough=0.7),
|
|
"stem": mat("stem", (0.34, 0.26, 0.13), rough=0.85),
|
|
"glass": mat("glass", (0.74, 0.82, 0.78), rough=0.06, alpha=0.34),
|
|
"juice": mat("juice", (0.86, 0.44, 0.06), rough=0.22),
|
|
"cap": mat("cap", (0.72, 0.70, 0.66), rough=0.3, metal=0.8),
|
|
"label": mat("label", (0.94, 0.92, 0.86), rough=0.85),
|
|
"crate": mat("crate", (0.56, 0.40, 0.22), rough=0.85),
|
|
}
|
|
|
|
|
|
def export(objs, name):
|
|
objs = [o for o in objs if o is not None]
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for o in objs:
|
|
o.select_set(True)
|
|
bpy.context.view_layer.objects.active = objs[0]
|
|
path = os.path.join(OUT_DIR, name + ".glb")
|
|
bpy.ops.export_scene.gltf(filepath=path, export_format='GLB',
|
|
use_selection=True, export_apply=True, export_yup=True)
|
|
print("[produce] %-18s -> %s" % (name, os.path.basename(path)))
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
|
|
|
|
def render_preview(name):
|
|
scene = bpy.context.scene
|
|
engines = scene.render.bl_rna.properties['engine'].enum_items.keys()
|
|
for want in ('BLENDER_EEVEE_NEXT', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
|
|
if want in engines:
|
|
scene.render.engine = want
|
|
break
|
|
scene.render.resolution_x = 480
|
|
scene.render.resolution_y = 480
|
|
if scene.world is None:
|
|
scene.world = bpy.data.worlds.new("preview")
|
|
scene.world.use_nodes = True
|
|
bg = scene.world.node_tree.nodes.get("Background")
|
|
if bg:
|
|
bg.inputs[0].default_value = (0.10, 0.10, 0.11, 1.0)
|
|
lo = Vector((1e9,) * 3)
|
|
hi = Vector((-1e9,) * 3)
|
|
for o in scene.objects:
|
|
if o.type != 'MESH':
|
|
continue
|
|
for c in o.bound_box:
|
|
w = o.matrix_world @ Vector(c)
|
|
lo = Vector((min(lo.x, w.x), min(lo.y, w.y), min(lo.z, w.z)))
|
|
hi = Vector((max(hi.x, w.x), max(hi.y, w.y), max(hi.z, w.z)))
|
|
centre = (lo + hi) * 0.5
|
|
radius = max((hi - lo).length * 0.5, 0.02)
|
|
dist = radius * 3.0
|
|
d = Vector((0.62, -0.74, 0.34)).normalized()
|
|
bpy.ops.object.camera_add(location=tuple(centre + d * dist))
|
|
cam = bpy.context.active_object
|
|
cam.data.lens = 58
|
|
cam.rotation_mode = 'QUATERNION'
|
|
cam.rotation_quaternion = (-d).to_track_quat('-Z', 'Y')
|
|
scene.camera = cam
|
|
for off, e, s in ((Vector((1.0, -1.0, 1.1)), 60.0, 1.2),
|
|
(Vector((-1.1, -0.4, 0.3)), 18.0, 1.8)):
|
|
bpy.ops.object.light_add(type='AREA', location=tuple(centre + off * dist))
|
|
L = bpy.context.active_object
|
|
L.data.energy = e * (dist ** 2)
|
|
L.data.size = s * radius
|
|
scene.render.filepath = os.path.join(PREVIEW_DIR, name + ".png")
|
|
bpy.ops.render.render(write_still=True)
|
|
|
|
|
|
def main():
|
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
os.makedirs(PREVIEW_DIR, exist_ok=True)
|
|
for name, builder in PROPS.items():
|
|
reset()
|
|
M = materials()
|
|
parts = builder(M)
|
|
for p in parts:
|
|
smooth(p, 45.0)
|
|
export(parts, name)
|
|
if RENDER:
|
|
render_preview(name)
|
|
print("[produce] done -> %s" % OUT_DIR)
|
|
|
|
|
|
main()
|