- footbridge.glb: 18m truss footbridge over the central avenue one block south of the rail line — X-pattern teal trusses, mesh railings, real stair flights at both ends. Deck and stairs are WALKABLE (box deck collider + invisible ramp colliders over the stair visuals): a third elevated crossing route between the viaduct and the plaza, and a new grapple anchor over the avenue - platform.glb: station platform strips on the viaduct deck flanking the crossing — canopy on posts, benches, line signs; dresses the deck route players actually run - Fixed a parse error from an undefined color constant caught by the capture loop (blank render -> error -> fix -> verified) Movement 11/11, smoke 0 failures; full-city aerial verified. Co-Authored-By: Claude Fable 5 <[email protected]>
360 lines
14 KiB
Python
360 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Kitbash original hero assets for Akiba Crossing in headless Blender.
|
|
|
|
Builds detailed modular pieces from beveled primitives with flat cel
|
|
palette materials (the game's toonify keeps base colors), origin at
|
|
ground center, sized in meters:
|
|
|
|
konbini.glb - 8m convenience-store front: fascia, sign band, framed
|
|
windows, recessed door, awning, drink machine, planter
|
|
station.glb - viaduct station entrance: canopy, pillars, real stairs,
|
|
handrails, sign totem
|
|
clock.glb - plaza clock tower: tiered base, column, 4 faces, ring
|
|
torii.glb - proper torii with curved kasagi beam and plaque
|
|
|
|
Run: blender --background --python tools/build_hero_assets.py
|
|
Outputs to assets/props/hero/.
|
|
"""
|
|
import math
|
|
import os
|
|
import sys
|
|
|
|
import bpy
|
|
|
|
OUT = os.path.join(os.getcwd(), "assets", "props", "hero")
|
|
os.makedirs(OUT, exist_ok=True)
|
|
|
|
_mats = {}
|
|
|
|
|
|
def mat(name, rgba):
|
|
if name in _mats:
|
|
return _mats[name]
|
|
m = bpy.data.materials.new(name)
|
|
m.use_nodes = True
|
|
bsdf = m.node_tree.nodes.get("Principled BSDF")
|
|
bsdf.inputs["Base Color"].default_value = (*rgba, 1.0)
|
|
bsdf.inputs["Roughness"].default_value = 0.9
|
|
_mats[name] = m
|
|
return m
|
|
|
|
|
|
INK = mat("ink", (0.07, 0.065, 0.09))
|
|
WHITE = mat("white", (0.85, 0.84, 0.82))
|
|
CREAM = mat("cream", (0.78, 0.74, 0.66))
|
|
RED = mat("red", (0.8, 0.2, 0.16))
|
|
VERMILION = mat("vermilion", (0.82, 0.24, 0.14))
|
|
TEAL = mat("teal", (0.16, 0.65, 0.65))
|
|
GLASS = mat("glass", (0.45, 0.68, 0.75))
|
|
METAL = mat("metal", (0.4, 0.44, 0.5))
|
|
WOOD = mat("wood", (0.42, 0.28, 0.16))
|
|
YELLOW = mat("yellow", (0.95, 0.75, 0.2))
|
|
GRAY = mat("gray", (0.55, 0.55, 0.58))
|
|
|
|
|
|
def clear():
|
|
bpy.ops.object.select_all(action="SELECT")
|
|
bpy.ops.object.delete()
|
|
|
|
|
|
def box(name, size, loc, material, bevel=0.02, rot=(0, 0, 0)):
|
|
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc, rotation=rot)
|
|
o = bpy.context.active_object
|
|
o.name = name
|
|
o.scale = (size[0] / 2.0, size[1] / 2.0, size[2] / 2.0)
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
if bevel > 0:
|
|
b = o.modifiers.new("bevel", "BEVEL")
|
|
b.width = bevel
|
|
b.segments = 2
|
|
b.limit_method = "ANGLE"
|
|
o.data.materials.append(material)
|
|
return o
|
|
|
|
|
|
def cyl(name, r, depth, loc, material, rot=(0, 0, 0), verts=24):
|
|
bpy.ops.mesh.primitive_cylinder_add(vertices=verts, radius=r, depth=depth,
|
|
location=loc, rotation=rot)
|
|
o = bpy.context.active_object
|
|
o.name = name
|
|
o.data.materials.append(material)
|
|
return o
|
|
|
|
|
|
def export(path):
|
|
bpy.ops.object.select_all(action="SELECT")
|
|
bpy.ops.export_scene.gltf(filepath=path, use_selection=True)
|
|
print("hero: exported", path)
|
|
|
|
|
|
# ── Konbini storefront (8 x 4.2 x 1.4, front faces +Y) ───────────────────────
|
|
|
|
def konbini():
|
|
clear()
|
|
W, H, D = 8.0, 4.2, 1.2
|
|
# back panel + side returns
|
|
box("back", (W, 0.15, H), (0, -D / 2, H / 2), CREAM)
|
|
for sx in (-1, 1):
|
|
box("side", (0.3, D, H), (sx * (W / 2 - 0.15), 0, H / 2), CREAM)
|
|
# fascia + colored sign band with ink trim
|
|
box("fascia", (W, D, 0.5), (0, 0, H - 0.25), WHITE)
|
|
box("signband", (W - 0.3, D + 0.06, 0.7), (0, 0, H - 0.85), TEAL, 0.03)
|
|
box("trim_top", (W, D + 0.1, 0.08), (0, 0, H - 0.5), INK, 0.01)
|
|
box("trim_bot", (W, D + 0.1, 0.08), (0, 0, H - 1.2), INK, 0.01)
|
|
# framed windows left+right of the recessed door
|
|
for sx in (-1, 1):
|
|
cx = sx * (W / 4 + 0.35)
|
|
box("winframe", (2.6, 0.18, 2.2), (cx, D / 2 - 0.2, 1.35), INK, 0.015)
|
|
box("winglass", (2.3, 0.1, 1.9), (cx, D / 2 - 0.16, 1.35), GLASS, 0.0)
|
|
box("sill", (2.7, 0.35, 0.12), (cx, D / 2 - 0.25, 0.28), GRAY)
|
|
# window ad strip
|
|
box("winad", (1.0, 0.06, 0.5), (cx - 0.5, D / 2 - 0.08, 1.9), RED, 0.01)
|
|
# recessed doorway
|
|
box("doorback", (1.8, 0.1, 2.4), (0, -0.1, 1.2), INK, 0.0)
|
|
box("doorglassL", (0.8, 0.08, 2.1), (-0.45, 0.05, 1.15), GLASS, 0.0)
|
|
box("doorglassR", (0.8, 0.08, 2.1), (0.45, 0.05, 1.15), GLASS, 0.0)
|
|
box("doorframe", (2.0, 0.12, 0.15), (0, D / 2 - 0.35, 2.42), INK, 0.01)
|
|
# awning over the door
|
|
box("awning", (2.6, 0.9, 0.1), (0, D / 2 + 0.35, 2.6), RED, 0.02,
|
|
rot=(math.radians(12), 0, 0))
|
|
# drink machine + planter dressing
|
|
box("vending", (0.9, 0.7, 1.9), (W / 2 - 1.0, D / 2 - 0.3, 0.95), RED, 0.03)
|
|
box("vendglass", (0.7, 0.06, 1.0), (W / 2 - 1.0, D / 2 + 0.06, 1.3), GLASS, 0.0)
|
|
box("planter", (1.4, 0.5, 0.45), (-W / 2 + 1.1, D / 2 + 0.1, 0.225), GRAY, 0.03)
|
|
box("hedge", (1.25, 0.4, 0.35), (-W / 2 + 1.1, D / 2 + 0.1, 0.62), mat("leaf", (0.3, 0.55, 0.32)), 0.05)
|
|
export(os.path.join(OUT, "konbini.glb"))
|
|
|
|
|
|
# ── Station entrance (6 x 5 x 3.6, opening faces +Y) ─────────────────────────
|
|
|
|
def station():
|
|
clear()
|
|
W, D, H = 6.0, 5.0, 3.6
|
|
# canopy + ink edge
|
|
box("canopy", (W, D, 0.25), (0, 0, H), WHITE, 0.04)
|
|
box("canopy_edge", (W + 0.15, D + 0.15, 0.1), (0, 0, H - 0.16), INK, 0.01)
|
|
# pillars
|
|
for sx in (-1, 1):
|
|
for sy in (-1, 1):
|
|
cyl("pillar", 0.14, H, (sx * (W / 2 - 0.3), sy * (D / 2 - 0.3), H / 2), METAL)
|
|
# side walls (half height)
|
|
for sx in (-1, 1):
|
|
box("wall", (0.25, D, 1.2), (sx * (W / 2 - 0.125), 0, 0.6), CREAM)
|
|
# stairs descending toward -Y (6 steps down 1.8m)
|
|
steps = 6
|
|
for i in range(steps):
|
|
z = -0.15 - i * 0.3
|
|
y = -D / 2 + 0.55 + i * 0.7
|
|
box("step", (W - 1.2, 0.7, 0.3), (0, y, z), GRAY, 0.01)
|
|
# handrails along the stairwell
|
|
for sx in (-1, 1):
|
|
box("rail", (0.08, D - 0.8, 0.08),
|
|
(sx * (W / 2 - 0.7), 0.0, 0.95), INK, 0.0,
|
|
rot=(math.radians(-18), 0, 0))
|
|
for i in range(4):
|
|
box("baluster", (0.06, 0.06, 0.9),
|
|
(sx * (W / 2 - 0.7), -D / 2 + 0.8 + i * 1.1, 0.45 - i * 0.28), INK, 0.0)
|
|
# sign totem
|
|
box("totem", (0.5, 0.35, 4.4), (W / 2 + 0.5, D / 2 - 0.2, 2.2), METAL, 0.03)
|
|
box("totem_sign", (0.42, 0.1, 1.6), (W / 2 + 0.5, D / 2 - 0.01, 3.4), YELLOW, 0.01)
|
|
export(os.path.join(OUT, "station.glb"))
|
|
|
|
|
|
# ── Plaza clock tower (10m) ──────────────────────────────────────────────────
|
|
|
|
def clock():
|
|
clear()
|
|
box("base1", (3.2, 3.2, 0.5), (0, 0, 0.25), GRAY, 0.04)
|
|
box("base2", (2.4, 2.4, 0.5), (0, 0, 0.75), WHITE, 0.04)
|
|
cyl("column", 0.5, 6.5, (0, 0, 4.25), CREAM, verts=12)
|
|
for i in range(3):
|
|
cyl("colring", 0.58, 0.18, (0, 0, 1.6 + i * 2.4), INK, verts=12)
|
|
# head
|
|
box("head", (1.9, 1.9, 1.9), (0, 0, 8.4), WHITE, 0.06)
|
|
box("head_trim", (2.1, 2.1, 0.2), (0, 0, 7.45), INK, 0.02)
|
|
for i in range(4):
|
|
a = math.pi / 2 * i
|
|
dx, dy = math.cos(a), math.sin(a)
|
|
cyl("face", 0.68, 0.06, (dx * 0.98, dy * 0.98, 8.4), INK,
|
|
rot=(0, math.pi / 2, a))
|
|
cyl("face_in", 0.6, 0.08, (dx * 0.99, dy * 0.99, 8.4), WHITE,
|
|
rot=(0, math.pi / 2, a))
|
|
box("hand_h", (0.08, 0.08, 0.4), (dx * 1.05, dy * 1.05, 8.55), INK, 0.0)
|
|
box("hand_m", (0.08, 0.08, 0.28), (dx * 1.05 + dy * 0.12, dy * 1.05 + dx * 0.12, 8.32), INK, 0.0)
|
|
# crown
|
|
box("crown", (1.4, 1.4, 0.3), (0, 0, 9.5), TEAL, 0.03)
|
|
cyl("spire", 0.08, 1.2, (0, 0, 10.2), INK, verts=8)
|
|
export(os.path.join(OUT, "clock.glb"))
|
|
|
|
|
|
# ── Torii with curved kasagi (10 wide x 8 tall) ──────────────────────────────
|
|
|
|
def torii():
|
|
clear()
|
|
for sx in (-1, 1):
|
|
cyl("pillar", 0.42, 7.0, (sx * 4.0, 0, 3.5), VERMILION, verts=16)
|
|
cyl("pillar_base", 0.55, 0.5, (sx * 4.0, 0, 0.25), INK, verts=16)
|
|
# curved kasagi: segmented arc of boxes
|
|
segs = 9
|
|
for i in range(segs):
|
|
t = i / (segs - 1) - 0.5 # -0.5..0.5
|
|
x = t * 10.0
|
|
z = 7.3 + (1.0 - (2 * t) ** 2) * 0.45 # gentle arch
|
|
rot = (0, -t * 0.28, 0)
|
|
box("kasagi", (10.0 / segs + 0.15, 1.0, 0.55), (x, 0, z), INK, 0.02, rot=rot)
|
|
box("shimaki", (10.0 / segs + 0.15, 0.8, 0.35), (x, 0, z - 0.45), VERMILION, 0.02, rot=rot)
|
|
# nuki (lower tie beam) + plaque
|
|
box("nuki", (9.4, 0.55, 0.55), (0, 0, 5.6), VERMILION, 0.03)
|
|
box("gakuzuka", (0.5, 0.3, 1.1), (0, 0, 6.5), VERMILION, 0.02)
|
|
box("plaque", (0.9, 0.18, 1.3), (0, 0.2, 6.5), mat("plaque", (0.9, 0.86, 0.75)), 0.02)
|
|
export(os.path.join(OUT, "torii.glb"))
|
|
|
|
|
|
konbini()
|
|
station()
|
|
clock()
|
|
torii()
|
|
print("hero: all assets built")
|
|
|
|
|
|
# ── Batch 2: street furniture ────────────────────────────────────────────────
|
|
|
|
def yatai():
|
|
"""Ramen cart: body, roof, noren curtain strips, stools, lantern."""
|
|
clear()
|
|
box("body", (2.6, 1.2, 1.1), (0, 0, 0.75), WOOD, 0.03)
|
|
box("counter", (2.8, 1.35, 0.08), (0, 0, 1.34), mat("counter", (0.62, 0.45, 0.28)), 0.02)
|
|
for sx in (-1, 1):
|
|
box("post", (0.08, 0.08, 1.3), (sx * 1.2, -0.45, 2.0), INK, 0.0)
|
|
cyl("wheel", 0.28, 0.1, (sx * 0.9, 0.62, 0.28), INK, rot=(math.radians(90), 0, 0))
|
|
box("roof", (3.0, 1.5, 0.12), (0, 0, 2.7), RED, 0.02)
|
|
box("roof_trim", (3.1, 1.6, 0.06), (0, 0, 2.62), INK, 0.0)
|
|
# noren curtain strips across the front
|
|
for i in range(5):
|
|
x = -1.1 + i * 0.55
|
|
box("noren", (0.42, 0.04, 0.6), (x, 0.72, 2.28), mat("noren", (0.92, 0.9, 0.85)), 0.0)
|
|
box("noren_bar", (2.7, 0.05, 0.05), (0, 0.72, 2.6), INK, 0.0)
|
|
for i, x in enumerate((-0.9, 0.0, 0.9)):
|
|
cyl("stool", 0.18, 0.5, (x, 1.05, 0.25), RED, verts=12)
|
|
cyl("lantern", 0.16, 0.42, (1.35, 0.7, 2.3), mat("lamp", (0.98, 0.62, 0.25)), verts=12)
|
|
export(os.path.join(OUT, "yatai.glb"))
|
|
|
|
|
|
def koban():
|
|
"""Police box: small building, sign board, red lamp."""
|
|
clear()
|
|
W, D, H = 3.2, 3.0, 3.2
|
|
box("body", (W, D, H), (0, 0, H / 2), CREAM, 0.03)
|
|
box("roof", (W + 0.5, D + 0.5, 0.3), (0, 0, H + 0.15), TEAL, 0.03)
|
|
box("door", (0.95, 0.1, 2.1), (-0.6, D / 2, 1.05), INK, 0.01)
|
|
box("window", (1.1, 0.1, 1.0), (0.7, D / 2, 1.7), GLASS, 0.01)
|
|
box("winframe", (1.25, 0.06, 1.15), (0.7, D / 2 - 0.02, 1.7), INK, 0.01)
|
|
box("sign", (1.6, 0.12, 0.5), (0, D / 2 + 0.1, 2.75), WHITE, 0.01)
|
|
cyl("lamp", 0.12, 0.24, (0, D / 2 + 0.2, 3.05), RED, verts=10)
|
|
export(os.path.join(OUT, "koban.glb"))
|
|
|
|
|
|
def phonebooth():
|
|
clear()
|
|
box("frame", (1.1, 1.1, 2.5), (0, 0, 1.25), mat("booth", (0.2, 0.45, 0.3)), 0.03)
|
|
for a in range(4):
|
|
ang = math.pi / 2 * a
|
|
dx, dy = math.cos(ang), math.sin(ang)
|
|
box("glass", (0.06 if a % 2 == 0 else 0.85, 0.85 if a % 2 == 0 else 0.06, 1.6),
|
|
(dx * 0.53, dy * 0.53, 1.35), GLASS, 0.0)
|
|
box("cap", (1.25, 1.25, 0.18), (0, 0, 2.6), INK, 0.02)
|
|
export(os.path.join(OUT, "phonebooth.glb"))
|
|
|
|
|
|
def busstop():
|
|
clear()
|
|
box("bench", (2.6, 0.5, 0.1), (0, 0.2, 0.55), WOOD, 0.02)
|
|
for sx in (-1, 1):
|
|
box("leg", (0.1, 0.45, 0.55), (sx * 1.1, 0.2, 0.27), INK, 0.0)
|
|
box("cpost", (0.1, 0.1, 2.4), (sx * 1.5, -0.25, 1.2), METAL, 0.0)
|
|
box("croof", (3.6, 1.3, 0.1), (0, 0.15, 2.45), GLASS, 0.02)
|
|
box("croof_trim", (3.7, 1.4, 0.05), (0, 0.15, 2.38), INK, 0.0)
|
|
box("signpole", (0.08, 0.08, 3.0), (2.1, -0.3, 1.5), METAL, 0.0)
|
|
cyl("signdisc", 0.35, 0.06, (2.1, -0.3, 2.85), YELLOW, rot=(math.radians(90), 0, 0), verts=16)
|
|
export(os.path.join(OUT, "busstop.glb"))
|
|
|
|
|
|
def bicycle():
|
|
clear()
|
|
for wy in (-0.55, 0.55):
|
|
bpy.ops.mesh.primitive_torus_add(major_radius=0.32, minor_radius=0.035,
|
|
location=(0, wy, 0.32),
|
|
rotation=(math.radians(90), 0, math.radians(90)))
|
|
o = bpy.context.active_object
|
|
o.data.materials.append(INK)
|
|
box("frame1", (0.05, 0.7, 0.05), (0, 0.1, 0.55), RED, 0.0, rot=(math.radians(25), 0, 0))
|
|
box("frame2", (0.05, 0.6, 0.05), (0, -0.2, 0.5), RED, 0.0, rot=(math.radians(-35), 0, 0))
|
|
box("bar", (0.4, 0.05, 0.05), (0, -0.52, 0.85), INK, 0.0)
|
|
box("seat", (0.14, 0.3, 0.06), (0, 0.28, 0.85), INK, 0.01)
|
|
box("basket", (0.32, 0.3, 0.25), (0, -0.72, 0.72), METAL, 0.01)
|
|
export(os.path.join(OUT, "bicycle.glb"))
|
|
|
|
|
|
yatai()
|
|
koban()
|
|
phonebooth()
|
|
busstop()
|
|
bicycle()
|
|
print("hero: batch 2 built")
|
|
|
|
|
|
# ── Batch 3: footbridge + station platform ───────────────────────────────────
|
|
|
|
def footbridge():
|
|
"""Pedestrian bridge: 18m deck, X-trusses, railings, stairs both ends."""
|
|
clear()
|
|
SPAN, DH = 18.0, 5.5
|
|
box("deck", (SPAN, 3.0, 0.35), (0, 0, DH), GRAY, 0.03)
|
|
box("deck_edge", (SPAN + 0.2, 3.2, 0.12), (0, 0, DH - 0.22), INK, 0.01)
|
|
# side trusses: X pattern of rotated bars + top chord
|
|
for sy in (-1, 1):
|
|
y = sy * 1.45
|
|
box("chord", (SPAN, 0.12, 0.12), (0, y, DH + 1.25), TEAL, 0.01)
|
|
n = 6
|
|
for i in range(n):
|
|
x = -SPAN / 2 + SPAN / n * (i + 0.5)
|
|
for ang in (0.6, -0.6):
|
|
box("truss", (0.09, 0.09, 1.55), (x, y, DH + 0.62), TEAL, 0.0,
|
|
rot=(0, ang, 0))
|
|
# rail mesh
|
|
box("rail", (SPAN, 0.05, 0.5), (0, y, DH + 0.55), mat("railmesh", (0.3, 0.32, 0.38)), 0.0)
|
|
# stairs at both ends, descending outward along the bridge axis
|
|
steps = 10
|
|
for end in (-1, 1):
|
|
for i in range(steps):
|
|
z = DH - 0.3 - i * (DH - 0.3) / steps
|
|
x = end * (SPAN / 2 + 0.5 + i * 0.62)
|
|
box("step", (0.62, 2.6, 0.22), (x, 0, z), GRAY, 0.01)
|
|
# stair rails
|
|
box("stairrail", (steps * 0.64, 0.08, 0.08),
|
|
(end * (SPAN / 2 + 0.4 + steps * 0.31), 1.25, DH * 0.55), INK, 0.0,
|
|
rot=(0, end * -0.68, 0))
|
|
export(os.path.join(OUT, "footbridge.glb"))
|
|
|
|
|
|
def platform():
|
|
"""Station platform strip for the viaduct deck: canopy, benches, signs."""
|
|
clear()
|
|
L = 14.0
|
|
for i in range(4):
|
|
x = -L / 2 + L / 4 * (i + 0.5)
|
|
cyl("post", 0.1, 2.6, (x, 0, 1.3), METAL)
|
|
box("proof", (L, 2.2, 0.12), (0, 0, 2.7), WHITE, 0.02)
|
|
box("proof_edge", (L + 0.2, 2.4, 0.06), (0, 0, 2.6), TEAL, 0.01)
|
|
for x in (-L / 4, L / 4):
|
|
box("bench", (1.8, 0.45, 0.08), (x, 0.4, 0.5), WOOD, 0.01)
|
|
for sx in (-1, 1):
|
|
box("bleg", (0.08, 0.4, 0.5), (x + sx * 0.75, 0.4, 0.25), INK, 0.0)
|
|
box("psign", (2.2, 0.1, 0.55), (0, 0.9, 2.15), mat("psign", (0.15, 0.35, 0.6)), 0.01)
|
|
export(os.path.join(OUT, "platform.glb"))
|
|
|
|
|
|
footbridge()
|
|
platform()
|
|
print("hero: batch 3 built")
|