feat: original kitbashed hero assets built in Blender

First bespoke art authored for the project (tools/build_hero_assets.py,
headless Blender 5.1 -> assets/props/hero/):

- konbini.glb: 8m convenience-store front — fascia + teal sign band with
  ink trim, framed windows with sills and window ads, recessed double
  glass door, angled awning, drink machine, hedge planter. Placed on
  ~1/3 of wide buildings (glass-tagged collider)
- station.glb: viaduct station entrance — canopy on pillars, real stair
  steps with handrails and balusters, sign totem; placed at the central
  avenue crossing of the rail line
- clock.glb: 10m plaza clock tower — tiered base, ringed column, 4 clock
  faces with hands, teal crown; anchors the south-west plaza cell
- torii.glb: proper torii with segmented curved kasagi, shimaki, nuki
  beam and plaque — replaces the box torii in every shrine courtyard
  (invisible pillar colliders keep gameplay identical)

All hero pieces run through the toon pipeline with fine ink outlines.
Every part beveled; flat cel palette matches the city.

Verified: city renders clean with hero pieces placed; movement 11/11,
smoke 0 failures, acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-19 03:00:17 -04:00
co-authored by Claude Fable 5
parent 6b3e363dfd
commit 7f9a579b63
6 changed files with 256 additions and 7 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+39 -7
View File
@@ -96,6 +96,28 @@ func _kit_scene(key: String) -> PackedScene:
return _kit_cache[key] return _kit_cache[key]
# ── Hero assets (original kitbash, tools/build_hero_assets.py) ──────────────
## Hero piece with a box collider. Front faces -Z before yaw (glTF Y-up).
func _hero(key: String, pos: Vector3, yaw_deg: float, col_size: Vector3,
col_center_y: float, acoustic: String = "concrete") -> void:
var body := StaticBody3D.new()
body.name = "Hero_" + key + "_%d" % _rng.randi()
body.set_meta("acoustic_material", acoustic)
add_child(body)
body.global_position = pos
body.rotation_degrees.y = yaw_deg
var shape := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = col_size
shape.shape = bs
shape.position = Vector3(0, col_center_y, 0)
body.add_child(shape)
var inst: Node3D = _kit_scene_at("res://assets/props/hero/" + key + ".glb").instantiate()
body.add_child(inst)
LevelMaterials.apply_toon_recursive(inst, 0.015)
## Small visual-only kit prop (parasols, awnings): no collider needed. ## Small visual-only kit prop (parasols, awnings): no collider needed.
func _kit_prop(key: String, pos: Vector3, yaw_deg: float, scale_f: float) -> void: func _kit_prop(key: String, pos: Vector3, yaw_deg: float, scale_f: float) -> void:
var inst: Node3D = _kit_scene(key).instantiate() var inst: Node3D = _kit_scene(key).instantiate()
@@ -177,8 +199,12 @@ func _kit_fill_side(front_x: float, dir: int, z0: float, z1: float, seed_id: Str
var h: float = KIT_BUILDINGS[key].y * KIT_SCALE var h: float = KIT_BUILDINGS[key].y * KIT_SCALE
_kit_building(key, Vector3(front_x, 0, zc), yaw, "K%s_%d" % [seed_id, idx]) _kit_building(key, Vector3(front_x, 0, zc), yaw, "K%s_%d" % [seed_id, idx])
var face_yaw := -90.0 if dir > 0 else 90.0 # ad quads face the street var face_yaw := -90.0 if dir > 0 else 90.0 # ad quads face the street
# Storefront awning on some buildings (kit detail piece) # Kitbashed konbini storefront module on wide buildings
if _rng.randi() % 5 < 2: if w >= 12.0 and _rng.randi() % 3 == 0:
_hero("konbini", Vector3(front_x - float(dir) * 0.75, 0.02, zc), 90.0 * float(dir),
Vector3(8.2, 4.2, 1.4), 2.1, "glass")
# Storefront awning on some other buildings (kit detail piece)
elif _rng.randi() % 5 < 2:
_kit_prop_path(KIT_DIR + "detail-awning-wide.glb", _kit_prop_path(KIT_DIR + "detail-awning-wide.glb",
Vector3(front_x, 3.1, zc), face_yaw, minf(w * 0.9, 11.0)) Vector3(front_x, 3.1, zc), face_yaw, minf(w * 0.9, 11.0))
# Street-level posters plastered on the facade (graphic density) # Street-level posters plastered on the facade (graphic density)
@@ -501,12 +527,11 @@ func _shrine_block(c: Vector3, bx: int, bz: int) -> void:
_box_static(c + Vector3(0.0 if horiz else off, 0.7, off if horiz else 0.0), _box_static(c + Vector3(0.0 if horiz else off, 0.7, off if horiz else 0.0),
Vector3(BLOCK - 6.0 if horiz else 0.6, 1.4, 0.6 if horiz else BLOCK - 6.0), Vector3(BLOCK - 6.0 if horiz else 0.6, 1.4, 0.6 if horiz else BLOCK - 6.0),
Color(0.55, 0.55, 0.52), n + "_Wall%d" % i, "brick") Color(0.55, 0.55, 0.52), n + "_Wall%d" % i, "brick")
# Torii gate at the south entry # Kitbashed torii (curved kasagi) at the south entry; pillar colliders
var tz := c.z + (BLOCK - 6.0) * 0.5 var tz := c.z + (BLOCK - 6.0) * 0.5
_box_static(c + Vector3(-4.0, 3.5, tz), Vector3(0.8, 7.0, 0.8), VERMILION, n + "_PillarW", "wood") _hero("torii", c + Vector3(0, 0.1, tz), 0.0, Vector3(0.1, 0.1, 0.1), 0.05, "wood")
_box_static(c + Vector3(4.0, 3.5, tz), Vector3(0.8, 7.0, 0.8), VERMILION, n + "_PillarE", "wood") _box_static(c + Vector3(-4.0, 3.5, tz), Vector3(0.9, 7.0, 0.9), VERMILION, n + "_PillarW", "wood").visible = false
_box_static(c + Vector3(0, 7.0, tz), Vector3(11.0, 0.7, 1.1), VERMILION, n + "_BeamTop", "wood") _box_static(c + Vector3(4.0, 3.5, tz), Vector3(0.9, 7.0, 0.9), VERMILION, n + "_PillarE", "wood").visible = false
_box_static(c + Vector3(0, 5.8, tz), Vector3(9.4, 0.5, 0.9), VERMILION.darkened(0.15), n + "_BeamLow", "wood")
# Hall with slide roof # Hall with slide roof
_box_static(c + Vector3(0, 2.0, -10.0), Vector3(12.0, 4.0, 8.0), WOOD.darkened(0.2), n + "_Hall", "wood") _box_static(c + Vector3(0, 2.0, -10.0), Vector3(12.0, 4.0, 8.0), WOOD.darkened(0.2), n + "_Hall", "wood")
_ramp_static(c + Vector3(0, 4.9, -8.2), Vector3(13.0, 0.3, 5.6), Vector3(-24, 0, 0), VERMILION.darkened(0.35), n + "_Roof", "wood") _ramp_static(c + Vector3(0, 4.9, -8.2), Vector3(13.0, 0.3, 5.6), Vector3(-24, 0, 0), VERMILION.darkened(0.35), n + "_Roof", "wood")
@@ -571,6 +596,9 @@ func _plaza_block(c: Vector3, bx: int, bz: int) -> void:
var p := c + Vector3(cos(a), 0, sin(a)) * (BLOCK * 0.32) var p := c + Vector3(cos(a), 0, sin(a)) * (BLOCK * 0.32)
_box_static(Vector3(p.x, 0.55, p.z), Vector3(4.0, 1.1, 4.0), Color(0.5, 0.48, 0.52), "%s_Pl%d" % [n, i], "brick") _box_static(Vector3(p.x, 0.55, p.z), Vector3(4.0, 1.1, 4.0), Color(0.5, 0.48, 0.52), "%s_Pl%d" % [n, i], "brick")
_kit_prop_path("res://assets/props/nature/tree_oak.glb", Vector3(p.x, 1.0, p.z), float(i) * 90.0, 4.5) _kit_prop_path("res://assets/props/nature/tree_oak.glb", Vector3(p.x, 1.0, p.z), float(i) * 90.0, 4.5)
# Kitbashed clock tower on the south-west plaza cell
if bx == 4 and bz == 5:
_hero("clock", c + Vector3(0, 0.1, 0), 0.0, Vector3(3.2, 10.5, 3.2), 5.25, "concrete")
# The Crossing screen tower on the single centre-most plaza cell # The Crossing screen tower on the single centre-most plaza cell
if bx == 5 and bz == 4: if bx == 5 and bz == 4:
_box_static(c + Vector3(0, 9.0, 0), Vector3(6.0, 18.0, 6.0), METAL.darkened(0.2), n + "_ScreenTower", "metal") _box_static(c + Vector3(0, 9.0, 0), Vector3(6.0, 18.0, 6.0), METAL.darkened(0.2), n + "_ScreenTower", "metal")
@@ -637,6 +665,10 @@ func _build_rail_line() -> void:
_ad_quad(Vector3(x - 1.35, 2.0, z), Vector2(1.2, 1.8), -90.0, _poster_tex()) _ad_quad(Vector3(x - 1.35, 2.0, z), Vector2(1.2, 1.8), -90.0, _poster_tex())
i += 1 i += 1
x += PITCH x += PITCH
# Kitbashed station entrance beside the central avenue crossing
_hero("station", Vector3(ROAD_W * 0.5 + 8.0, 0.02, z + 7.5), 90.0,
Vector3(6.0, 3.6, 5.0), 1.8, "concrete")
# Slide ramps to the deck at the two main-avenue crossings # Slide ramps to the deck at the two main-avenue crossings
for sx in [-PITCH * 0.5 - 4.0, PITCH * 0.5 + 4.0]: for sx in [-PITCH * 0.5 - 4.0, PITCH * 0.5 + 4.0]:
var ang := rad_to_deg(atan2(RAIL_TOP, 16.0)) var ang := rad_to_deg(atan2(RAIL_TOP, 16.0))
+217
View File
@@ -0,0 +1,217 @@
#!/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")