feat: Blender-authored roadside districts that rise per chapter
This commit is contained in:
239
assets/blender/districts/author-districts.py
Normal file
239
assets/blender/districts/author-districts.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
"""Author the roadside district kit in its own scene and library.
|
||||||
|
|
||||||
|
Run headless only, so no open session is touched:
|
||||||
|
blender -b --factory-startup --python assets/blender/districts/author-districts.py
|
||||||
|
It builds the "Roadside Districts" scene from scratch, drops the factory scene and
|
||||||
|
saves nightdrive-districts.blend. Convention: Blender +X faces the road (three.js +X), +Y runs
|
||||||
|
down the road (three.js -Z), Z is up; every piece stands on z=0 at its origin.
|
||||||
|
Each piece is two meshes: <piece>_body (graphite) and <piece>_light (emissive).
|
||||||
|
"""
|
||||||
|
import bpy, bmesh, math
|
||||||
|
from mathutils import Matrix, Euler, Vector
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if not bpy.app.background:
|
||||||
|
raise SystemExit('Run author-districts.py headless (blender -b); it saves a new file.')
|
||||||
|
SCENE = 'Roadside Districts'
|
||||||
|
LIB = Path(__file__).resolve().parent / 'nightdrive-districts.blend'
|
||||||
|
|
||||||
|
if SCENE in bpy.data.scenes:
|
||||||
|
old = bpy.data.scenes[SCENE]
|
||||||
|
for obj in list(old.objects):
|
||||||
|
mesh = obj.data
|
||||||
|
bpy.data.objects.remove(obj)
|
||||||
|
if mesh and mesh.users == 0:
|
||||||
|
bpy.data.meshes.remove(mesh)
|
||||||
|
bpy.data.scenes.remove(old)
|
||||||
|
scene = bpy.data.scenes.new(SCENE)
|
||||||
|
|
||||||
|
|
||||||
|
class Part:
|
||||||
|
def __init__(self):
|
||||||
|
self.bm = bmesh.new()
|
||||||
|
|
||||||
|
def box(self, c, s, r=(0, 0, 0)):
|
||||||
|
geom = bmesh.ops.create_cube(self.bm, size=1.0)
|
||||||
|
m = Matrix.Translation(Vector(c)) @ Euler([math.radians(a) for a in r]).to_matrix().to_4x4() @ Matrix.Diagonal(Vector((*s, 1.0)))
|
||||||
|
bmesh.ops.transform(self.bm, matrix=m, verts=geom['verts'])
|
||||||
|
|
||||||
|
def beam(self, a, b, t):
|
||||||
|
"""Square beam of thickness t between points a and b."""
|
||||||
|
a, b = Vector(a), Vector(b)
|
||||||
|
d = b - a
|
||||||
|
geom = bmesh.ops.create_cube(self.bm, size=1.0)
|
||||||
|
rot = d.to_track_quat('Z', 'Y').to_matrix().to_4x4()
|
||||||
|
m = Matrix.Translation((a + b) / 2) @ rot @ Matrix.Diagonal(Vector((t, t, d.length, 1.0)))
|
||||||
|
bmesh.ops.transform(self.bm, matrix=m, verts=geom['verts'])
|
||||||
|
|
||||||
|
|
||||||
|
def piece(name, body, light):
|
||||||
|
for suffix, part in (('body', body), ('light', light)):
|
||||||
|
bmesh.ops.remove_doubles(part.bm, verts=part.bm.verts, dist=1e-4)
|
||||||
|
mesh = bpy.data.meshes.new(f'{name}_{suffix}')
|
||||||
|
part.bm.to_mesh(mesh)
|
||||||
|
part.bm.free()
|
||||||
|
obj = bpy.data.objects.new(f'{name}_{suffix}', mesh)
|
||||||
|
scene.collection.objects.link(obj)
|
||||||
|
|
||||||
|
|
||||||
|
# Overhead gantry spanning the carriageway (x -11..11 in three.js units).
|
||||||
|
b, l = Part(), Part()
|
||||||
|
for x in (-11.2, 11.2):
|
||||||
|
b.box((x, 0, 0.2), (1.2, 1.6, 0.4))
|
||||||
|
b.box((x, -0.45, 3.9), (0.34, 0.34, 7.4))
|
||||||
|
b.box((x, 0.45, 3.9), (0.34, 0.34, 7.4))
|
||||||
|
for z in (1.6, 3.4, 5.2):
|
||||||
|
b.beam((x, -0.45, z), (x, 0.45, z + 1.6), 0.12)
|
||||||
|
for y in (-0.45, 0.45):
|
||||||
|
for z in (6.3, 7.5):
|
||||||
|
b.box((0, y, z), (22.8, 0.22, 0.22))
|
||||||
|
for i in range(12):
|
||||||
|
x = -10.4 + i * 1.9
|
||||||
|
b.beam((x, -0.45, 6.3), (x + 1.9, -0.45, 7.5), 0.1)
|
||||||
|
b.beam((x, 0.45, 6.3), (x + 1.9, 0.45, 7.5), 0.1)
|
||||||
|
b.box((x, 0, 6.3), (0.1, 0.9, 0.1))
|
||||||
|
for cx, w in ((-5.2, 7.0), (4.6, 6.0)):
|
||||||
|
b.box((cx, -0.62, 5.0), (w, 0.14, 2.6))
|
||||||
|
l.box((cx, -0.71, 6.32), (w, 0.04, 0.07))
|
||||||
|
l.box((cx, -0.71, 3.68), (w, 0.04, 0.07))
|
||||||
|
for k, f in enumerate((0.62, 0.4, 0.5)):
|
||||||
|
l.box((cx - w * (1 - f) / 2 + 0.4, -0.71, 5.6 - k * 0.55), (w * f - 0.8, 0.04, 0.16))
|
||||||
|
l.box((-1.4, -0.71, 4.4), (0.9, 0.04, 0.16), (0, 45, 0))
|
||||||
|
l.box((-1.65, -0.71, 4.6), (0.16, 0.04, 0.7))
|
||||||
|
for x in range(-10, 11, 4):
|
||||||
|
l.box((x, 0, 6.15), (0.6, 0.12, 0.05))
|
||||||
|
piece('gantry', b, l)
|
||||||
|
|
||||||
|
# Open-air server row with an overhead cable tray (Infrastructure).
|
||||||
|
b, l = Part(), Part()
|
||||||
|
for i in range(6):
|
||||||
|
y = -2.75 + i * 1.1
|
||||||
|
b.box((0, y, 1.15), (1.2, 1.0, 2.3))
|
||||||
|
b.box((0, y, 2.36), (1.0, 0.9, 0.12))
|
||||||
|
for k in range(9):
|
||||||
|
width = (0.82, 0.62, 0.74, 0.4, 0.82, 0.55, 0.7, 0.3, 0.6)[(k + i * 2) % 9]
|
||||||
|
l.box((0.61, y - (0.82 - width) / 2, 0.45 + k * 0.2), (0.03, width, 0.045))
|
||||||
|
l.box((0.61, y + 0.38, 2.12), (0.03, 0.08, 0.08))
|
||||||
|
for y in (-3.4, 3.4):
|
||||||
|
b.box((-0.3, y, 1.6), (0.18, 0.18, 3.2))
|
||||||
|
b.box((0.2, y, 3.15), (1.2, 0.16, 0.12))
|
||||||
|
b.box((0.35, 0, 3.05), (0.04, 7.0, 0.16))
|
||||||
|
b.box((-0.35, 0, 3.05), (0.04, 7.0, 0.16))
|
||||||
|
for k in range(12):
|
||||||
|
b.box((0, -3.3 + k * 0.6, 3.0), (0.7, 0.05, 0.04))
|
||||||
|
l.box((0, 0, 2.94), (0.5, 6.8, 0.03))
|
||||||
|
piece('rack-row', b, l)
|
||||||
|
|
||||||
|
# Lattice radio mast with dishes (Toolkit / Infrastructure horizon).
|
||||||
|
b, l = Part(), Part()
|
||||||
|
H = 26.0
|
||||||
|
corners = [(-1, -1), (1, -1), (1, 1), (-1, 1)]
|
||||||
|
def half(z):
|
||||||
|
return 1.6 - z * 0.045
|
||||||
|
for cx, cy in corners:
|
||||||
|
b.beam((cx * half(0), cy * half(0), 0), (cx * half(H), cy * half(H), H), 0.16)
|
||||||
|
for lv in range(9):
|
||||||
|
z0, z1 = lv * H / 9, (lv + 1) * H / 9
|
||||||
|
for j in range(4):
|
||||||
|
(ax, ay), (bx, by) = corners[j], corners[(j + 1) % 4]
|
||||||
|
b.beam((ax * half(z1), ay * half(z1), z1), (bx * half(z1), by * half(z1), z1), 0.09)
|
||||||
|
b.beam((ax * half(z0), ay * half(z0), z0), (bx * half(z1), by * half(z1), z1), 0.07)
|
||||||
|
b.box((0, 0, H + 3), (0.12, 0.12, 6))
|
||||||
|
for z, ang in ((18, 30), (13, -40), (21, 160)):
|
||||||
|
d = Vector((math.cos(math.radians(ang)), math.sin(math.radians(ang)), 0))
|
||||||
|
b.box(tuple(d * 1.4 + Vector((0, 0, z))), (0.18, 1.4, 1.4), (0, 0, ang))
|
||||||
|
for z in (H + 6, H, 17, 9):
|
||||||
|
l.box((0, 0, z), (0.34, 0.34, 0.34))
|
||||||
|
piece('mast', b, l)
|
||||||
|
|
||||||
|
# Workshop with half-open roller door and a lift (Selected builds).
|
||||||
|
b, l = Part(), Part()
|
||||||
|
W, D, Hh = 8.0, 6.0, 4.2
|
||||||
|
b.box((-D, 0, Hh / 2), (0.2, W, Hh))
|
||||||
|
b.box((-D / 2, -W / 2, Hh / 2), (D, 0.2, Hh))
|
||||||
|
b.box((-D / 2, W / 2, Hh / 2), (D, 0.2, Hh))
|
||||||
|
for s in (-1, 1):
|
||||||
|
b.box((-D / 2, s * W / 4, Hh + 0.75), (D + 0.4, W / 2 + 0.25, 0.14), (s * 20.5, 0, 0))
|
||||||
|
b.box((0, -W / 2 + 0.3, Hh / 2), (0.3, 0.6, Hh))
|
||||||
|
b.box((0, W / 2 - 0.3, Hh / 2), (0.3, 0.6, Hh))
|
||||||
|
for k in range(5):
|
||||||
|
b.box((0.05, 0, Hh - 0.25 - k * 0.32), (0.1, W - 1.2, 0.26))
|
||||||
|
b.box((-4.8, -2.2, 0.45), (1.6, 1.0, 0.9))
|
||||||
|
for y in (0.4, 3.0):
|
||||||
|
b.box((-2.6, y, 1.1), (0.25, 0.25, 2.2))
|
||||||
|
b.box((-2.6, 1.7, 1.15), (2.4, 2.8, 0.08))
|
||||||
|
l.box((-D / 2, 0, Hh + 0.1), (D - 0.4, 0.06, 0.05))
|
||||||
|
l.box((0.25, 0, Hh + 0.35), (0.04, W - 1.4, 0.32))
|
||||||
|
l.box((0.22, -W / 2 + 0.62, Hh / 2), (0.04, 0.05, Hh))
|
||||||
|
l.box((0.22, W / 2 - 0.62, Hh / 2), (0.04, 0.05, Hh))
|
||||||
|
l.box((-4.0, -2.2, 0.96), (0.9, 0.5, 0.03))
|
||||||
|
piece('workshop', b, l)
|
||||||
|
|
||||||
|
# Tower crane (Selected builds / Additional work horizon).
|
||||||
|
b, l = Part(), Part()
|
||||||
|
Hc = 30.0
|
||||||
|
for cx, cy in corners:
|
||||||
|
b.box((cx * 0.75, cy * 0.75, Hc / 2), (0.18, 0.18, Hc))
|
||||||
|
for lv in range(10):
|
||||||
|
z = (lv + 1) * Hc / 10
|
||||||
|
for j in range(4):
|
||||||
|
(ax, ay), (bx, by) = corners[j], corners[(j + 1) % 4]
|
||||||
|
b.beam((ax * 0.75, ay * 0.75, z), (bx * 0.75, by * 0.75, z), 0.08)
|
||||||
|
b.beam((ax * 0.75, ay * 0.75, z - Hc / 10), (bx * 0.75, by * 0.75, z), 0.06)
|
||||||
|
b.box((0, 0, Hc + 0.8), (2.2, 2.2, 1.6))
|
||||||
|
b.box((0, 0, Hc + 4), (0.3, 0.3, 4.8))
|
||||||
|
J = 26.0
|
||||||
|
for z in (Hc + 1.6, Hc + 2.8):
|
||||||
|
b.box((J / 2 - 3, 0.5 if z < Hc + 2 else 0, z), (J, 0.18, 0.18))
|
||||||
|
b.box((J / 2 - 3, -0.5, Hc + 1.6), (J, 0.18, 0.18))
|
||||||
|
for k in range(9):
|
||||||
|
x = -3 + k * J / 9
|
||||||
|
b.beam((x, 0, Hc + 2.8), (x + J / 9, 0.5, Hc + 1.6), 0.07)
|
||||||
|
b.beam((x, 0, Hc + 2.8), (x + J / 9, -0.5, Hc + 1.6), 0.07)
|
||||||
|
b.box((-7, 0, Hc + 1.7), (8, 0.8, 0.3))
|
||||||
|
b.box((-9.5, 0, Hc + 1.0), (2.2, 1.6, 1.4))
|
||||||
|
b.beam((J - 3, 0, Hc + 6.4), (-9, 0, Hc + 2.0), 0.05)
|
||||||
|
b.box((15, 0, Hc - 6), (0.05, 0.05, 12))
|
||||||
|
b.box((15, 0, Hc - 12.2), (0.8, 0.6, 0.5))
|
||||||
|
l.box((0, 0, Hc + 6.5), (0.4, 0.4, 0.4))
|
||||||
|
l.box((J - 3, 0, Hc + 2.0), (0.36, 0.36, 0.36))
|
||||||
|
l.box((15, 0, Hc - 12.6), (0.5, 0.3, 0.06))
|
||||||
|
piece('crane', b, l)
|
||||||
|
|
||||||
|
# Shipping container, stackable (Additional work).
|
||||||
|
b, l = Part(), Part()
|
||||||
|
b.box((0, 0, 1.3), (2.44, 6.06, 2.6))
|
||||||
|
for k in range(14):
|
||||||
|
y = -2.7 + k * 0.415
|
||||||
|
b.box((1.24, y, 1.3), (0.06, 0.18, 2.36))
|
||||||
|
b.box((-1.24, y, 1.3), (0.06, 0.18, 2.36))
|
||||||
|
for x in (-1.15, 1.15):
|
||||||
|
for y in (-3.0, 3.0):
|
||||||
|
b.box((x, y, 1.3), (0.16, 0.16, 2.62))
|
||||||
|
l.box((1.28, 0, 2.56), (0.03, 5.9, 0.05))
|
||||||
|
l.box((0.0, -3.06, 1.3), (1.8, 0.03, 0.05))
|
||||||
|
piece('container', b, l)
|
||||||
|
|
||||||
|
# Roadside billboard (Profile / Experience).
|
||||||
|
b, l = Part(), Part()
|
||||||
|
for y in (-2.5, 2.5):
|
||||||
|
b.box((0, y, 3.0), (0.3, 0.3, 6.0))
|
||||||
|
b.box((0.1, 0, 7.0), (0.24, 8.4, 3.6))
|
||||||
|
b.box((0.6, 0, 5.05), (1.0, 8.2, 0.08))
|
||||||
|
l.box((0.24, 0, 8.86), (0.04, 8.4, 0.08))
|
||||||
|
l.box((0.24, 0, 5.14), (0.04, 8.4, 0.08))
|
||||||
|
l.box((0.24, -4.2, 7.0), (0.04, 0.08, 3.8))
|
||||||
|
l.box((0.24, 4.2, 7.0), (0.04, 0.08, 3.8))
|
||||||
|
for k, f in enumerate((0.7, 0.45, 0.55)):
|
||||||
|
l.box((0.24, -4.2 + 8.4 * f / 2 + 0.6, 8.0 - k * 0.75), (0.04, 8.4 * f - 1.2, 0.22))
|
||||||
|
for y in (-3, 0, 3):
|
||||||
|
l.box((1.05, y, 5.15), (0.2, 0.5, 0.08))
|
||||||
|
piece('billboard', b, l)
|
||||||
|
|
||||||
|
# Destination arch over the road (Contact).
|
||||||
|
b, l = Part(), Part()
|
||||||
|
for s in (-1, 1):
|
||||||
|
b.box((s * 12.5, 0, 7.0), (1.4, 1.4, 14.0))
|
||||||
|
b.box((s * 12.5, 0, 0.3), (2.4, 2.4, 0.6))
|
||||||
|
l.box((s * 11.78, 0, 7.0), (0.05, 0.12, 12.4))
|
||||||
|
b.box((0, 0, 14.6), (27.4, 1.6, 1.4))
|
||||||
|
b.box((0, 0, 16.0), (18.0, 1.0, 1.4))
|
||||||
|
l.box((0, -0.82, 14.2), (25.0, 0.04, 0.12))
|
||||||
|
l.box((0, -0.82, 15.0), (25.0, 0.04, 0.05))
|
||||||
|
l.box((0, -0.52, 16.5), (16.0, 0.04, 0.1))
|
||||||
|
for k in range(9):
|
||||||
|
l.box((-8 + k * 2, 0, 13.88), (0.9, 0.5, 0.04))
|
||||||
|
piece('arch', b, l)
|
||||||
|
|
||||||
|
tris = 0
|
||||||
|
for obj in scene.objects:
|
||||||
|
obj.data.calc_loop_triangles()
|
||||||
|
tris += len(obj.data.loop_triangles)
|
||||||
|
for other in [s for s in bpy.data.scenes if s != scene]:
|
||||||
|
bpy.data.scenes.remove(other)
|
||||||
|
for block in (bpy.data.objects, bpy.data.meshes, bpy.data.cameras, bpy.data.lights, bpy.data.materials):
|
||||||
|
for item in list(block):
|
||||||
|
if item.users == 0:
|
||||||
|
block.remove(item)
|
||||||
|
bpy.ops.wm.save_as_mainfile(filepath=str(LIB), compress=True)
|
||||||
|
print('pieces', len(scene.objects) // 2, 'triangles', tris, 'library', LIB)
|
||||||
67
assets/blender/districts/export-districts.py
Normal file
67
assets/blender/districts/export-districts.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Export the district kit to the runtime GLB (positions + indices only).
|
||||||
|
|
||||||
|
blender -b assets/blender/districts/nightdrive-districts.blend --python assets/blender/districts/export-districts.py [-- --preview out.png]
|
||||||
|
|
||||||
|
Normals are derived per fragment at runtime (flat shading from screen-space
|
||||||
|
derivatives), so the GLB carries no normals, UVs or materials and shared box
|
||||||
|
corners stay welded. Writes provenance next to this script.
|
||||||
|
"""
|
||||||
|
import bpy, json, hashlib, sys, math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
here = Path(__file__).resolve().parent
|
||||||
|
root = here.parents[2]
|
||||||
|
scene = bpy.data.scenes['Roadside Districts']
|
||||||
|
output = root / 'src/assets/nightdrive-districts.glb'
|
||||||
|
|
||||||
|
for obj in scene.objects:
|
||||||
|
obj.select_set(True)
|
||||||
|
bpy.ops.export_scene.gltf(
|
||||||
|
filepath=str(output), export_format='GLB', use_selection=True, use_active_scene=True,
|
||||||
|
export_apply=True, export_normals=False, export_texcoords=False, export_materials='NONE',
|
||||||
|
export_animations=False, export_cameras=False, export_lights=False, export_yup=True,
|
||||||
|
)
|
||||||
|
triangles = 0
|
||||||
|
for obj in scene.objects:
|
||||||
|
obj.data.calc_loop_triangles()
|
||||||
|
triangles += len(obj.data.loop_triangles)
|
||||||
|
provenance = {
|
||||||
|
'asset': 'Nightdrive roadside districts',
|
||||||
|
'source': 'nightdrive-districts.blend',
|
||||||
|
'authorship': 'Original procedural modelling in Blender for this portfolio (author-districts.py); no external assets.',
|
||||||
|
'editable_scene': 'Roadside Districts',
|
||||||
|
'pieces': sorted({o.name.rsplit('_', 1)[0] for o in scene.objects}),
|
||||||
|
'triangles': triangles,
|
||||||
|
'attributes': 'POSITION + indices; flat normals derived at runtime',
|
||||||
|
'export': '../../../src/assets/nightdrive-districts.glb',
|
||||||
|
'sha256': hashlib.sha256(output.read_bytes()).hexdigest(),
|
||||||
|
}
|
||||||
|
(here / 'nightdrive-districts.asset.json').write_text(json.dumps(provenance, indent=2) + '\n')
|
||||||
|
print(json.dumps(provenance))
|
||||||
|
|
||||||
|
if '--preview' in sys.argv:
|
||||||
|
target = sys.argv[sys.argv.index('--preview') + 1]
|
||||||
|
names = provenance['pieces']
|
||||||
|
for i, name in enumerate(names):
|
||||||
|
for suffix in ('body', 'light'):
|
||||||
|
obj = scene.objects[f'{name}_{suffix}']
|
||||||
|
obj.location = ((i % 4) * 34 - 50, (i // 4) * 40, 0)
|
||||||
|
mat = bpy.data.materials.new(f'preview-{suffix}')
|
||||||
|
mat.diffuse_color = (0.05, 0.05, 0.08, 1) if suffix == 'body' else (0.1, 0.9, 1.0, 1)
|
||||||
|
obj.data.materials.append(mat)
|
||||||
|
cam = bpy.data.objects.new('preview-cam', bpy.data.cameras.new('preview-cam'))
|
||||||
|
scene.collection.objects.link(cam)
|
||||||
|
cam.location = (95, -95, 70)
|
||||||
|
cam.rotation_euler = (math.radians(58), 0, math.radians(45))
|
||||||
|
cam.data.lens = 30
|
||||||
|
scene.camera = cam
|
||||||
|
try:
|
||||||
|
scene.render.engine = 'BLENDER_WORKBENCH'
|
||||||
|
except TypeError as e:
|
||||||
|
print(e)
|
||||||
|
scene.display.shading.color_type = 'MATERIAL'
|
||||||
|
scene.display.shading.light = 'STUDIO'
|
||||||
|
scene.display.shading.show_cavity = True
|
||||||
|
scene.render.resolution_x, scene.render.resolution_y = 1600, 1000
|
||||||
|
scene.render.filepath = target
|
||||||
|
bpy.ops.render.render(write_still=True)
|
||||||
20
assets/blender/districts/nightdrive-districts.asset.json
Normal file
20
assets/blender/districts/nightdrive-districts.asset.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"asset": "Nightdrive roadside districts",
|
||||||
|
"source": "nightdrive-districts.blend",
|
||||||
|
"authorship": "Original procedural modelling in Blender for this portfolio (author-districts.py); no external assets.",
|
||||||
|
"editable_scene": "Roadside Districts",
|
||||||
|
"pieces": [
|
||||||
|
"arch",
|
||||||
|
"billboard",
|
||||||
|
"container",
|
||||||
|
"crane",
|
||||||
|
"gantry",
|
||||||
|
"mast",
|
||||||
|
"rack-row",
|
||||||
|
"workshop"
|
||||||
|
],
|
||||||
|
"triangles": 5424,
|
||||||
|
"attributes": "POSITION + indices; flat normals derived at runtime",
|
||||||
|
"export": "../../../src/assets/nightdrive-districts.glb",
|
||||||
|
"sha256": "428a212792ac4d8dbf86b9224e9788f49cfc22b3d0dd1b72dfaf33c89f08f189"
|
||||||
|
}
|
||||||
BIN
assets/blender/districts/nightdrive-districts.blend
Normal file
BIN
assets/blender/districts/nightdrive-districts.blend
Normal file
Binary file not shown.
@@ -38,7 +38,7 @@ export const classifyArtifact = (path, source = '') => {
|
|||||||
if (/^assets\/index-[^/]+\.js$/u.test(path) && /import\(/u.test(source)) return 'initial-application-js';
|
if (/^assets\/index-[^/]+\.js$/u.test(path) && /import\(/u.test(source)) return 'initial-application-js';
|
||||||
if (/^assets\/(?:environment-high|high-tier)-[^/]+\.(?:js|avif|webp|png|jpe?g)$/u.test(path)) return 'high-tier-extras';
|
if (/^assets\/(?:environment-high|high-tier)-[^/]+\.(?:js|avif|webp|png|jpe?g)$/u.test(path)) return 'high-tier-extras';
|
||||||
if (/^assets\/environment-[^/]+\.js$/u.test(path)) return 'first-live-scene';
|
if (/^assets\/environment-[^/]+\.js$/u.test(path)) return 'first-live-scene';
|
||||||
if (/^assets\/nightdrive-grand-tourer-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
|
if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene';
|
||||||
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
|
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
|
||||||
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';
|
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';
|
||||||
if (/^assets\/project-[^/]+\.(?:webm|mp4)$/u.test(path)) return 'project-video';
|
if (/^assets\/project-[^/]+\.(?:webm|mp4)$/u.test(path)) return 'project-video';
|
||||||
|
|||||||
BIN
src/assets/nightdrive-districts.glb
Normal file
BIN
src/assets/nightdrive-districts.glb
Normal file
Binary file not shown.
323
src/scene/world/districts.ts
Normal file
323
src/scene/world/districts.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/* three ships untyped here (see src/scene/three.d.ts). */
|
||||||
|
type Mesh = { visible: boolean; geometry?: Geometry };
|
||||||
|
type Geometry = { dispose(): void };
|
||||||
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||||
|
import districtsUrl from '@/assets/nightdrive-districts.glb?url';
|
||||||
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
import type { RenderQualitySettings } from '@/scene/render-quality';
|
||||||
|
import type { SectionId } from '@/content/types';
|
||||||
|
import { signAccentFor } from './sign';
|
||||||
|
import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
|
||||||
|
|
||||||
|
/** Roadside districts: each chapter raises its own landscape out of the ground
|
||||||
|
* (server rows for Infrastructure, a workshop and cranes for Builds, a container
|
||||||
|
* yard, radio masts, a destination arch). The kit is authored in Blender
|
||||||
|
* (assets/blender/districts) and drawn as instanced, script-wrapped geometry,
|
||||||
|
* like the expressway furniture. Districts at rest are hidden, so only the
|
||||||
|
* current chapter's pieces cost draw calls. */
|
||||||
|
export type DistrictPiece =
|
||||||
|
| 'gantry'
|
||||||
|
| 'billboard'
|
||||||
|
| 'rack-row'
|
||||||
|
| 'mast'
|
||||||
|
| 'workshop'
|
||||||
|
| 'crane'
|
||||||
|
| 'container'
|
||||||
|
| 'arch';
|
||||||
|
|
||||||
|
interface Placement {
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
y?: number;
|
||||||
|
/** Yaw in radians, applied before mirroring. */
|
||||||
|
ry?: number;
|
||||||
|
s?: number;
|
||||||
|
/** Extra vertical stretch (gantries clear the chase camera). */
|
||||||
|
sy?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How strongly each chapter raises a piece (0 = flat and hidden). */
|
||||||
|
export const DISTRICT_PRESENCE: Readonly<
|
||||||
|
Record<DistrictPiece, Partial<Record<SectionId, number>>>
|
||||||
|
> = {
|
||||||
|
gantry: {
|
||||||
|
profile: 1,
|
||||||
|
experience: 1,
|
||||||
|
infrastructure: 1,
|
||||||
|
'selected-builds': 1,
|
||||||
|
'additional-work': 1,
|
||||||
|
toolkit: 1,
|
||||||
|
},
|
||||||
|
billboard: { profile: 1, experience: 1 },
|
||||||
|
'rack-row': { infrastructure: 1 },
|
||||||
|
mast: { infrastructure: 0.75, toolkit: 1 },
|
||||||
|
workshop: { 'selected-builds': 1, 'additional-work': 0.5 },
|
||||||
|
crane: { 'selected-builds': 1, 'additional-work': 1 },
|
||||||
|
container: { 'additional-work': 1 },
|
||||||
|
arch: { contact: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const span = (count: number, from: number, to: number) =>
|
||||||
|
Array.from({ length: count }, (_, i) => from + ((to - from) * i) / count);
|
||||||
|
|
||||||
|
/** Placements in three.js units: negative x is the left verge. Pieces face +x;
|
||||||
|
* right-side copies are mirrored so they face the carriageway too. */
|
||||||
|
function placementsFor(piece: DistrictPiece, dense: boolean): Placement[] {
|
||||||
|
const n = (full: number) => (dense ? full : Math.ceil(full / 2));
|
||||||
|
switch (piece) {
|
||||||
|
case 'gantry':
|
||||||
|
return span(n(3), -70, -70 - WORLD_DEPTH).map((z) => ({ x: 0, z, sy: 1.42 }));
|
||||||
|
case 'billboard':
|
||||||
|
return span(n(10), -40, -40 - WORLD_DEPTH).map((z, i) => ({
|
||||||
|
x: (i % 2 ? 1 : -1) * 16,
|
||||||
|
z,
|
||||||
|
ry: (i % 2 ? 1 : -1) * 0.28,
|
||||||
|
}));
|
||||||
|
case 'rack-row':
|
||||||
|
return span(n(18), -26, -26 - WORLD_DEPTH).flatMap((z, i) => {
|
||||||
|
const side = i % 2 ? 1 : -1;
|
||||||
|
return [
|
||||||
|
{ x: side * 14.6, z, s: 1.5 },
|
||||||
|
{ x: side * 19.2, z: z - 3, s: 1.5 },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
case 'mast':
|
||||||
|
return span(n(6), -90, -90 - WORLD_DEPTH).map((z, i) => ({
|
||||||
|
x: (i % 2 ? 1 : -1) * (30 + (i % 3) * 7),
|
||||||
|
z,
|
||||||
|
ry: i * 0.6,
|
||||||
|
}));
|
||||||
|
case 'workshop':
|
||||||
|
return span(n(6), -48, -48 - WORLD_DEPTH).map((z, i) => ({
|
||||||
|
x: (i % 2 ? 1 : -1) * 17,
|
||||||
|
z,
|
||||||
|
s: 1.3,
|
||||||
|
}));
|
||||||
|
case 'crane':
|
||||||
|
return span(n(4), -120, -120 - WORLD_DEPTH).map((z, i) => ({
|
||||||
|
x: (i % 2 ? 1 : -1) * 34,
|
||||||
|
z,
|
||||||
|
ry: (i % 2 ? 1 : -1) * 0.9 + i * 0.4,
|
||||||
|
}));
|
||||||
|
case 'container':
|
||||||
|
return span(n(12), -30, -30 - WORLD_DEPTH).flatMap((z, i) => {
|
||||||
|
const side = i % 2 ? 1 : -1;
|
||||||
|
const stack: Placement[] = [
|
||||||
|
{ x: side * 16, z, ry: Math.PI / 2, s: 1.2 },
|
||||||
|
{ x: side * 16.3, z: z - 3.2, ry: Math.PI / 2, s: 1.2 },
|
||||||
|
];
|
||||||
|
if (i % 3 !== 1) stack.push({ x: side * 16.1, y: 3.14, z: z - 1.5, ry: Math.PI / 2 + 0.06, s: 1.2 });
|
||||||
|
return stack;
|
||||||
|
});
|
||||||
|
case 'arch':
|
||||||
|
return span(2, -60, -60 - WORLD_DEPTH).map((z) => ({ x: 0, z, sy: 1.15 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERTEX = `
|
||||||
|
uniform float uDistance;
|
||||||
|
uniform float uRise;
|
||||||
|
varying vec3 vWorld;
|
||||||
|
varying float vDepth;
|
||||||
|
varying float vRise;
|
||||||
|
varying float vHeight;
|
||||||
|
void main() {
|
||||||
|
vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
||||||
|
float stagger = fract(sin(dot(origin.xz, vec2(12.9898, 78.233))) * 43758.5453);
|
||||||
|
float rise = clamp(uRise * 1.45 - stagger * 0.45, 0.0, 1.0);
|
||||||
|
rise = rise * rise * (3.0 - 2.0 * rise);
|
||||||
|
vRise = rise;
|
||||||
|
vHeight = position.y;
|
||||||
|
vec4 p = instanceMatrix * vec4(position.x, position.y * rise - (1.0 - rise) * 0.4, position.z, 1.0);
|
||||||
|
p.z += mod(origin.z + uDistance - ${WORLD_FAR_Z.toFixed(1)}, ${WORLD_DEPTH.toFixed(1)}) + ${WORLD_FAR_Z.toFixed(1)} - origin.z;
|
||||||
|
vWorld = p.xyz;
|
||||||
|
vec4 view = modelViewMatrix * p;
|
||||||
|
vDepth = -view.z;
|
||||||
|
gl_Position = projectionMatrix * view;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FADES = `
|
||||||
|
float farFade = 1.0 - smoothstep(360.0, 640.0, vDepth);
|
||||||
|
float nearFade = smoothstep(1.5, 10.0, vDepth);
|
||||||
|
float fade = farFade * nearFade;
|
||||||
|
if (fade < 0.02) discard;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface DistrictsWorld {
|
||||||
|
group: { name: string; add(...objects: unknown[]): void };
|
||||||
|
update(
|
||||||
|
distance: number,
|
||||||
|
energy: number,
|
||||||
|
section: SectionId,
|
||||||
|
elapsed: number,
|
||||||
|
dt: number,
|
||||||
|
): void;
|
||||||
|
/** Pieces currently raised (for tests and diagnostics). */
|
||||||
|
raised(): DistrictPiece[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDistricts(
|
||||||
|
resources: SceneResourceBag,
|
||||||
|
quality: RenderQualitySettings,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): DistrictsWorld {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = 'roadside-districts';
|
||||||
|
const shared = {
|
||||||
|
uDistance: { value: 0 },
|
||||||
|
uEnergy: { value: 0 },
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uAccent: { value: new THREE.Color(signAccentFor('start')) },
|
||||||
|
};
|
||||||
|
const targetAccent = new THREE.Color(signAccentFor('start'));
|
||||||
|
const state = new Map<
|
||||||
|
DistrictPiece,
|
||||||
|
{ rise: { value: number }; meshes: Mesh[] }
|
||||||
|
>();
|
||||||
|
const pieces = Object.keys(DISTRICT_PRESENCE) as DistrictPiece[];
|
||||||
|
for (const piece of pieces) state.set(piece, { rise: { value: 0 }, meshes: [] });
|
||||||
|
|
||||||
|
const bodyMaterial = (rise: { value: number }) =>
|
||||||
|
resources.own(
|
||||||
|
'material',
|
||||||
|
new THREE.ShaderMaterial({
|
||||||
|
uniforms: { ...shared, uRise: rise },
|
||||||
|
vertexShader: VERTEX,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform vec3 uAccent;
|
||||||
|
varying vec3 vWorld;
|
||||||
|
varying float vDepth;
|
||||||
|
varying float vRise;
|
||||||
|
varying float vHeight;
|
||||||
|
void main() {
|
||||||
|
${FADES}
|
||||||
|
vec3 n = normalize(cross(dFdx(vWorld), dFdy(vWorld)));
|
||||||
|
float key = max(0.0, dot(n, normalize(vec3(-0.45, 0.85, 0.35))));
|
||||||
|
float rim = pow(1.0 - abs(n.y), 3.0) * 0.5;
|
||||||
|
vec3 color = vec3(0.03, 0.016, 0.05) + vec3(0.085, 0.06, 0.14) * key;
|
||||||
|
color += uAccent * rim * 0.06 * (0.4 + 0.6 * smoothstep(0.0, 12.0, vHeight));
|
||||||
|
gl_FragColor = vec4(color, fade * smoothstep(0.0, 0.25, vRise));
|
||||||
|
#include <tonemapping_fragment>
|
||||||
|
#include <colorspace_fragment>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: true,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const lightMaterial = (rise: { value: number }, accentMix: number) =>
|
||||||
|
resources.own(
|
||||||
|
'material',
|
||||||
|
new THREE.ShaderMaterial({
|
||||||
|
uniforms: { ...shared, uRise: rise },
|
||||||
|
vertexShader: VERTEX,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform vec3 uAccent;
|
||||||
|
uniform float uEnergy;
|
||||||
|
uniform float uTime;
|
||||||
|
varying vec3 vWorld;
|
||||||
|
varying float vDepth;
|
||||||
|
varying float vRise;
|
||||||
|
void main() {
|
||||||
|
${FADES}
|
||||||
|
// Neon tubes catch as the district finishes rising: a brief
|
||||||
|
// stutter, then steady (no per-instance CPU work).
|
||||||
|
float on = smoothstep(0.8, 1.0, vRise);
|
||||||
|
float stutter = step(0.35, fract(sin(floor(uTime * 22.0) + vWorld.z * 0.07) * 43758.5));
|
||||||
|
on *= mix(stutter, 1.0, step(0.985, vRise));
|
||||||
|
vec3 tone = mix(vec3(0.55, 0.95, 1.0), uAccent, ${accentMix.toFixed(2)});
|
||||||
|
// Close passes dim, so overhead neon never floods the bloom pass.
|
||||||
|
float near = mix(0.3, 1.0, smoothstep(5.0, 32.0, vDepth));
|
||||||
|
vec3 color = tone * (1.1 + uEnergy * 0.4) * near;
|
||||||
|
gl_FragColor = vec4(color * on * fade, 1.0);
|
||||||
|
#include <tonemapping_fragment>
|
||||||
|
#include <colorspace_fragment>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dummy = new THREE.Object3D();
|
||||||
|
const install = (piece: DistrictPiece, body: Geometry, light: Geometry) => {
|
||||||
|
const entry = state.get(piece);
|
||||||
|
if (!entry) return;
|
||||||
|
const placements = placementsFor(piece, quality.postEffects);
|
||||||
|
const accentMix = piece === 'gantry' || piece === 'arch' || piece === 'billboard' ? 1 : 0.35;
|
||||||
|
for (const [geometry, material] of [
|
||||||
|
[body, bodyMaterial(entry.rise)],
|
||||||
|
[light, lightMaterial(entry.rise, accentMix)],
|
||||||
|
] as const) {
|
||||||
|
resources.own('geometry', geometry);
|
||||||
|
const mesh = resources.own(
|
||||||
|
'other',
|
||||||
|
new THREE.InstancedMesh(geometry, material, placements.length),
|
||||||
|
);
|
||||||
|
mesh.name = `district-${piece}`;
|
||||||
|
mesh.frustumCulled = false;
|
||||||
|
mesh.visible = false;
|
||||||
|
placements.forEach((placement, i) => {
|
||||||
|
const mirror = placement.x > 0 ? -1 : 1;
|
||||||
|
dummy.position.set(placement.x, placement.y ?? 0, placement.z);
|
||||||
|
dummy.rotation.set(0, (placement.ry ?? 0) * mirror, 0);
|
||||||
|
const s = placement.s ?? 1;
|
||||||
|
dummy.scale.set(s * mirror, s * (placement.sy ?? 1), s);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
mesh.setMatrixAt(i, dummy.matrix);
|
||||||
|
});
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
entry.meshes.push(mesh);
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The kit streams in beside the first frame; a failed fetch leaves the road
|
||||||
|
// exactly as it was before districts existed.
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(districtsUrl, { signal });
|
||||||
|
if (!response.ok) return;
|
||||||
|
const bytes = await response.arrayBuffer();
|
||||||
|
if (signal.aborted) return;
|
||||||
|
const gltf = await new GLTFLoader().parseAsync(bytes, districtsUrl);
|
||||||
|
if (signal.aborted) return;
|
||||||
|
for (const piece of pieces) {
|
||||||
|
const body = gltf.scene.getObjectByName(`${piece}_body`) as Mesh | undefined;
|
||||||
|
const light = gltf.scene.getObjectByName(`${piece}_light`) as Mesh | undefined;
|
||||||
|
if (body?.geometry && light?.geometry) install(piece, body.geometry, light.geometry);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Districts are optional scenery. */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
let current: SectionId = 'start';
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
update(distance, energy, section, elapsed, dt) {
|
||||||
|
current = section;
|
||||||
|
shared.uDistance.value = distance;
|
||||||
|
shared.uEnergy.value = energy;
|
||||||
|
shared.uTime.value = elapsed;
|
||||||
|
targetAccent.set(signAccentFor(section));
|
||||||
|
shared.uAccent.value.lerp(targetAccent, 1 - Math.exp(-dt * 2.5));
|
||||||
|
for (const [piece, entry] of state) {
|
||||||
|
const target = DISTRICT_PRESENCE[piece][section] ?? 0;
|
||||||
|
const rate = target > entry.rise.value ? 0.9 : 1.4;
|
||||||
|
entry.rise.value += (target - entry.rise.value) * (1 - Math.exp(-dt * rate));
|
||||||
|
if (Math.abs(target - entry.rise.value) < 0.002) entry.rise.value = target;
|
||||||
|
const visible = entry.rise.value > 0.002;
|
||||||
|
for (const mesh of entry.meshes) mesh.visible = visible;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
raised: () =>
|
||||||
|
pieces.filter((piece) => (DISTRICT_PRESENCE[piece][current] ?? 0) > 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
|
|||||||
import { renderQualityFor } from '@/scene/render-quality';
|
import { renderQualityFor } from '@/scene/render-quality';
|
||||||
import { createHorizonAir } from '@/scene/world/horizon-air';
|
import { createHorizonAir } from '@/scene/world/horizon-air';
|
||||||
import { createExpressway } from '@/scene/world/expressway';
|
import { createExpressway } from '@/scene/world/expressway';
|
||||||
|
import { createDistricts } from '@/scene/world/districts';
|
||||||
import { createSignStage } from '@/scene/world/sign-stage';
|
import { createSignStage } from '@/scene/world/sign-stage';
|
||||||
import { usesRoadsideSign } from '@/scene/world/sign-layout';
|
import { usesRoadsideSign } from '@/scene/world/sign-layout';
|
||||||
import { createSpeedWake } from '@/scene/world/speed-wake';
|
import { createSpeedWake } from '@/scene/world/speed-wake';
|
||||||
@@ -116,6 +117,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
const terrain = createTerrain(context.resources, quality);
|
const terrain = createTerrain(context.resources, quality);
|
||||||
const speedWake = createSpeedWake(context.resources);
|
const speedWake = createSpeedWake(context.resources);
|
||||||
const expressway = createExpressway(context.resources, quality);
|
const expressway = createExpressway(context.resources, quality);
|
||||||
|
const districts = createDistricts(context.resources, quality, context.signal);
|
||||||
const signStage = createSignStage(context.resources);
|
const signStage = createSignStage(context.resources);
|
||||||
const sun = createSun(context.resources);
|
const sun = createSun(context.resources);
|
||||||
const horizonAir = createHorizonAir(context.resources);
|
const horizonAir = createHorizonAir(context.resources);
|
||||||
@@ -132,6 +134,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
atmosphere.group,
|
atmosphere.group,
|
||||||
road.group,
|
road.group,
|
||||||
expressway.group,
|
expressway.group,
|
||||||
|
districts.group,
|
||||||
terrain.group,
|
terrain.group,
|
||||||
sun.group,
|
sun.group,
|
||||||
horizonAir.group,
|
horizonAir.group,
|
||||||
@@ -306,6 +309,13 @@ export async function createProceduralSceneGeneration(
|
|||||||
snapshot.activeSectionId,
|
snapshot.activeSectionId,
|
||||||
deltaSeconds,
|
deltaSeconds,
|
||||||
);
|
);
|
||||||
|
districts.update(
|
||||||
|
driveDistance,
|
||||||
|
response.roadEnergy,
|
||||||
|
snapshot.activeSectionId,
|
||||||
|
atmosphericTime,
|
||||||
|
deltaSeconds,
|
||||||
|
);
|
||||||
terrain.update(
|
terrain.update(
|
||||||
driveDistance,
|
driveDistance,
|
||||||
visualSpeed,
|
visualSpeed,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ describe('release budgets', () => {
|
|||||||
expect(classifyArtifact('assets/index-a.js', 'import("./environment-b.js")')).toBe('initial-application-js');
|
expect(classifyArtifact('assets/index-a.js', 'import("./environment-b.js")')).toBe('initial-application-js');
|
||||||
expect(classifyArtifact('assets/environment-b.js', 'three scene')).toBe('first-live-scene');
|
expect(classifyArtifact('assets/environment-b.js', 'three scene')).toBe('first-live-scene');
|
||||||
expect(classifyArtifact('assets/nightdrive-grand-tourer-a.glb', '')).toBe('first-live-scene');
|
expect(classifyArtifact('assets/nightdrive-grand-tourer-a.glb', '')).toBe('first-live-scene');
|
||||||
|
expect(classifyArtifact('assets/nightdrive-districts-a.glb', '')).toBe('first-live-scene');
|
||||||
expect(classifyArtifact('assets/nightdrive-main.opus', '')).toBe('soundtrack-audio');
|
expect(classifyArtifact('assets/nightdrive-main.opus', '')).toBe('soundtrack-audio');
|
||||||
expect(classifyArtifact('assets/nightdrive-intro.mp3', '')).toBe('soundtrack-audio');
|
expect(classifyArtifact('assets/nightdrive-intro.mp3', '')).toBe('soundtrack-audio');
|
||||||
expect(classifyArtifact('assets/nightdrive-afterglow.opus', '')).toBe('soundtrack-audio');
|
expect(classifyArtifact('assets/nightdrive-afterglow.opus', '')).toBe('soundtrack-audio');
|
||||||
|
|||||||
32
tests/unit/districts.test.ts
Normal file
32
tests/unit/districts.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { DISTRICT_PRESENCE } from "@/scene/world/districts";
|
||||||
|
|
||||||
|
const provenance = JSON.parse(
|
||||||
|
readFileSync("assets/blender/districts/nightdrive-districts.asset.json", "utf8"),
|
||||||
|
) as { pieces: string[]; triangles: number };
|
||||||
|
|
||||||
|
describe("roadside districts", () => {
|
||||||
|
it("raises every authored piece in at least one chapter and nothing else", () => {
|
||||||
|
expect(Object.keys(DISTRICT_PRESENCE).sort()).toEqual(provenance.pieces);
|
||||||
|
for (const presence of Object.values(DISTRICT_PRESENCE)) {
|
||||||
|
const strengths = Object.values(presence);
|
||||||
|
expect(strengths.length).toBeGreaterThan(0);
|
||||||
|
for (const strength of strengths) {
|
||||||
|
expect(strength).toBeGreaterThan(0);
|
||||||
|
expect(strength).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the opening and the destination clear of mid-route districts", () => {
|
||||||
|
for (const [piece, presence] of Object.entries(DISTRICT_PRESENCE)) {
|
||||||
|
expect(presence.start, piece).toBeUndefined();
|
||||||
|
if (piece !== "arch") expect(presence.contact, piece).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays a light kit", () => {
|
||||||
|
expect(provenance.triangles).toBeLessThan(8000);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user