Outline: the inverted-hull width was tuned when the look was heavier and now reads as a thick marker stroke. Cut every call site to roughly a third (characters 0.012 -> 0.004, level geometry 0.015 -> 0.005, Neon Alley's size-scaled clamp 0.02..0.05 -> 0.007..0.018) plus the shader default, so the ink line stays readable without fattening every silhouette. Third-person weapon on HumanoidModel: the gun was parented to root_pivot at a fixed (-0.15, 1.0, 0.4) — floating 40 cm off the chest while both arms posed at it from a distance. Give the rig a real right-hand node at the end of the forearm and hang the weapon there, so it travels with the arm. Its aim is re-derived from the body each frame (hand_r_pivot's global basis is copied from root_pivot), otherwise the barrel would swing around with the arm animation. The hold pose is retuned to match: right elbow tucked at the ribs with a level forearm, left arm crossing to the handguard. _seat_weapon_in_hand() slides each weapon along its own barrel axis so a plausible grip lands in the fist regardless of where the artist left the model origin. It measures with MeshInstance3D.get_aabb(), not mesh.get_aabb() — the FBX gun parts are skinned, so the Mesh resource still carries bind-pose bounds tens of metres across. Co-Authored-By: Claude Opus 4.8 <[email protected]>
894 lines
40 KiB
GDScript
894 lines
40 KiB
GDScript
extends TestLevelBuilder
|
|
|
|
## Akiba Crossing — a 10x10-block Akihabara-styled city (100 blocks).
|
|
##
|
|
## The map is a street grid: 48m buildable cells on a 64m pitch with 16m
|
|
## streets. A hand-authored layout assigns each cell a block type so the
|
|
## city stays varied ("S" retail is the fabric; landmarks break it up):
|
|
## S standard retail T landmark tower J shrine courtyard
|
|
## M market street P plaza (2x2 centre = the Crossing) C construction
|
|
## A brick rail viaduct runs east-west across the whole city, and a brick
|
|
## wall rings the district.
|
|
##
|
|
## Everything is parametric: buildings derive parapets, storefronts,
|
|
## canopies, per-floor sign bands, window bands, corner sign columns, roof
|
|
## AC/water tanks/antennas from their footprint; per-block variation comes
|
|
## from a seeded RNG so no two blocks repeat exactly.
|
|
##
|
|
## Collision & acoustics: bodies/canopies/columns/furniture are static and
|
|
## tagged (concrete/glass/wood/brick/metal); pure decoration (signs,
|
|
## windows, wires) is render-only to keep physics light.
|
|
|
|
const BLOCK := 48.0
|
|
const STREET := 16.0
|
|
const PITCH := BLOCK + STREET
|
|
const GRID := 10
|
|
const CITY_HALF := GRID * PITCH * 0.5 # 320
|
|
const FLOOR_H := 4.0
|
|
const RAIL_ROW := 2 # viaduct runs along the street south of row 2
|
|
const RAIL_TOP := 9.0
|
|
|
|
const LAYOUT: Array[String] = [
|
|
"SMSTSSMSTS",
|
|
"STSSJSSCSS",
|
|
"SSMSPSSTSM",
|
|
"MSTSSSTSSS",
|
|
"SSSTPPTSMS",
|
|
"SJSTPPTSSS",
|
|
"SSSSTPTSJS",
|
|
"TSMSSSSSTS",
|
|
"SSJSSTSSMS",
|
|
"SMSSTSMSST",
|
|
]
|
|
|
|
# Palette
|
|
const ASPHALT := Color(0.38, 0.38, 0.45)
|
|
const PAVING := Color(0.56, 0.54, 0.6)
|
|
const STRIPE := Color(0.72, 0.72, 0.75)
|
|
const FACADES: Array[Color] = [
|
|
Color(0.74, 0.71, 0.66), Color(0.62, 0.63, 0.67), Color(0.68, 0.6, 0.5),
|
|
Color(0.55, 0.6, 0.66), Color(0.71, 0.66, 0.6), Color(0.6, 0.55, 0.58),
|
|
]
|
|
const SEGA_RED := Color(0.78, 0.16, 0.14)
|
|
const ELEC_BLUE := Color(0.2, 0.38, 0.62)
|
|
const GLASS := Color(0.62, 0.8, 0.84)
|
|
const WINDOW := Color(0.16, 0.2, 0.28)
|
|
const CANOPY := Color(0.5, 0.34, 0.22)
|
|
const METAL := Color(0.44, 0.48, 0.54)
|
|
const BRICK := Color(0.5, 0.34, 0.3)
|
|
const WOOD := Color(0.52, 0.34, 0.2)
|
|
const VERMILION := Color(0.88, 0.26, 0.16)
|
|
const LEAF := Color(0.3, 0.55, 0.32)
|
|
const TRUNK := Color(0.4, 0.28, 0.18)
|
|
|
|
const SIGN_COLORS: Array[Color] = [
|
|
Color(1.0, 0.3, 0.5), Color(0.25, 0.9, 0.95), Color(1.0, 0.8, 0.2),
|
|
Color(0.6, 0.45, 1.0), Color(0.35, 1.0, 0.55), Color(0.95, 0.95, 0.98),
|
|
]
|
|
|
|
var _spawn_points: Array[Vector3] = []
|
|
var _rng := RandomNumberGenerator.new()
|
|
|
|
# ── Kenney City Kit catalog (CC0) — base sizes measured at import scale ─────
|
|
const KIT_DIR := "res://assets/props/citykit/"
|
|
const KIT_SCALE := 13.0
|
|
## name -> Vector3(facade_width, height, depth) at import scale
|
|
const KIT_BUILDINGS := {
|
|
"building-a": Vector3(0.884, 1.293, 0.94), "building-b": Vector3(0.97, 1.293, 0.94),
|
|
"building-c": Vector3(0.884, 0.893, 1.09), "building-d": Vector3(0.84, 1.293, 0.9),
|
|
"building-e": Vector3(1.64, 0.893, 1.008), "building-f": Vector3(0.84, 1.693, 1.03),
|
|
"building-g": Vector3(0.97, 1.693, 0.922), "building-h": Vector3(0.884, 1.293, 1.008),
|
|
"building-i": Vector3(1.24, 1.68, 1.302), "building-j": Vector3(2.084, 1.693, 1.34),
|
|
"building-k": Vector3(2.084, 1.47, 0.942), "building-l": Vector3(1.37, 2.27, 1.402),
|
|
"building-n": Vector3(2.32, 2.48, 1.82),
|
|
}
|
|
const KIT_SKYSCRAPERS := {
|
|
"building-skyscraper-a": Vector3(1.36, 2.88, 1.36), "building-skyscraper-b": Vector3(1.36, 4.48, 1.36),
|
|
"building-skyscraper-c": Vector3(1.28, 4.08, 1.388), "building-skyscraper-d": Vector3(1.28, 5.47, 1.388),
|
|
"building-skyscraper-e": Vector3(1.295, 4.08, 1.242),
|
|
}
|
|
var _kit_cache: Dictionary = {}
|
|
|
|
|
|
func _kit_scene(key: String) -> PackedScene:
|
|
if not _kit_cache.has(key):
|
|
_kit_cache[key] = load(KIT_DIR + key + ".glb")
|
|
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.005)
|
|
|
|
|
|
## Small visual-only kit prop (parasols, awnings): no collider needed.
|
|
func _kit_prop(key: String, pos: Vector3, yaw_deg: float, scale_f: float) -> void:
|
|
var inst: Node3D = _kit_scene(key).instantiate()
|
|
inst.scale = Vector3.ONE * scale_f
|
|
add_child(inst)
|
|
inst.global_position = pos
|
|
inst.rotation_degrees.y = yaw_deg
|
|
LevelMaterials.apply_toon_recursive(inst, 0.007)
|
|
|
|
|
|
## Place a kit model with a box collider matching its scaled bounds.
|
|
## front_pos: centre of the facade at ground level. yaw_deg: 0 faces +Z.
|
|
func _kit_building(key: String, front_pos: Vector3, yaw_deg: float, node_name: String,
|
|
scale_f: float = KIT_SCALE) -> float:
|
|
var dims: Vector3 = KIT_BUILDINGS.get(key, KIT_SKYSCRAPERS.get(key, Vector3.ONE))
|
|
var w := dims.x * scale_f
|
|
var h := dims.y * scale_f
|
|
var d := dims.z * scale_f
|
|
var fwd := Vector3(sin(deg_to_rad(yaw_deg)), 0, cos(deg_to_rad(yaw_deg)))
|
|
var center := front_pos - fwd * d * 0.5
|
|
|
|
var body := StaticBody3D.new()
|
|
body.name = node_name
|
|
body.set_meta("acoustic_material", "concrete")
|
|
add_child(body)
|
|
body.global_position = center
|
|
body.rotation_degrees.y = yaw_deg
|
|
var shape := CollisionShape3D.new()
|
|
var bs := BoxShape3D.new()
|
|
bs.size = Vector3(w, h, d)
|
|
shape.shape = bs
|
|
shape.position = Vector3(0, h * 0.5, 0)
|
|
body.add_child(shape)
|
|
|
|
var inst: Node3D = _kit_scene(key).instantiate()
|
|
inst.scale = Vector3.ONE * scale_f
|
|
body.add_child(inst)
|
|
LevelMaterials.apply_toon_recursive(inst, 0.0)
|
|
# Subtle per-building tint so repeated kit models read as different shops
|
|
_tint_recursive(inst, Color(1, 1, 1).lerp(Color.from_hsv(_rng.randf(), 0.3, 1.0), 0.16))
|
|
return w
|
|
|
|
|
|
func _tint_recursive(node: Node, tint: Color) -> void:
|
|
if node is MeshInstance3D:
|
|
var mi := node as MeshInstance3D
|
|
var sc: int = mi.mesh.get_surface_count() if mi.mesh else 0
|
|
for s in sc:
|
|
var m := mi.get_surface_override_material(s)
|
|
if m is ShaderMaterial:
|
|
var c = m.get_shader_parameter("albedo_color")
|
|
if c is Color:
|
|
m.set_shader_parameter("albedo_color", c * tint)
|
|
for child in node.get_children():
|
|
_tint_recursive(child, tint)
|
|
|
|
|
|
## Fill one street-facing edge of a lot with kit buildings + Akiba signage.
|
|
func _kit_fill_side(front_x: float, dir: int, z0: float, z1: float, seed_id: String) -> void:
|
|
var yaw := -90.0 if dir > 0 else 90.0 # face the street (-x for east row)
|
|
var cursor := z0
|
|
var idx := 0
|
|
var keys := KIT_BUILDINGS.keys()
|
|
while z1 - cursor > 10.0:
|
|
var key: String = keys[_rng.randi() % keys.size()]
|
|
var w: float = KIT_BUILDINGS[key].x * KIT_SCALE
|
|
if w > z1 - cursor:
|
|
# find any model that still fits, else stop
|
|
var fits := false
|
|
for k2 in keys:
|
|
if KIT_BUILDINGS[k2].x * KIT_SCALE <= z1 - cursor:
|
|
key = k2
|
|
w = KIT_BUILDINGS[k2].x * KIT_SCALE
|
|
fits = true
|
|
break
|
|
if not fits:
|
|
break
|
|
var zc := cursor + w * 0.5
|
|
var h: float = KIT_BUILDINGS[key].y * KIT_SCALE
|
|
_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
|
|
# Kitbashed konbini storefront module on wide buildings
|
|
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",
|
|
Vector3(front_x, 3.1, zc), face_yaw, minf(w * 0.9, 11.0))
|
|
# Street-level posters plastered on the facade (graphic density)
|
|
var posters := 1 + _rng.randi() % 3
|
|
for p in posters:
|
|
var pz := cursor + _rng.randf_range(1.5, maxf(w - 1.5, 2.0))
|
|
_ad_quad(Vector3(front_x - float(dir) * 0.15, _rng.randf_range(1.7, 2.4), pz),
|
|
Vector2(1.3, 2.0) * _rng.randf_range(0.8, 1.1), face_yaw, _poster_tex())
|
|
# Protruding double-sided shop signs over the sidewalk (2nd floor)
|
|
if h >= 8.0:
|
|
var signs := 1 + _rng.randi() % 2
|
|
for sp in signs:
|
|
var sz := cursor + w * (float(sp) + 0.7) / (float(signs) + 0.7)
|
|
var spos := Vector3(front_x - float(dir) * 0.9, 5.6 + _rng.randf_range(-0.4, 0.6), sz)
|
|
var stx := _poster_tex()
|
|
_deco_box(spos + Vector3(float(dir) * 0.75, 0, 0), Vector3(1.6, 0.12, 0.12), METAL.darkened(0.2))
|
|
_ad_quad(spos + Vector3(0.06, 0, 0), Vector2(1.2, 1.7), 90.0, stx)
|
|
_ad_quad(spos + Vector3(-0.06, 0, 0), Vector2(1.2, 1.7), -90.0, stx)
|
|
# Akiba signage layer, aligned to the model's measured bounds
|
|
if h >= 14.0:
|
|
var zs := cursor + 1.2
|
|
var col_h := h * 0.55
|
|
_box_static(Vector3(front_x - float(dir) * 0.45, h * 0.35, zs), Vector3(0.7, col_h, 1.4),
|
|
METAL, "K%s_%d_sc" % [seed_id, idx], "metal")
|
|
_ad_quad(Vector3(front_x - float(dir) * 0.84, h * 0.35, zs),
|
|
Vector2(1.25, col_h * 0.9), face_yaw, _column_tex())
|
|
if h >= 20.0:
|
|
var bw := minf(w - 4.0, 10.0)
|
|
_box_static(Vector3(front_x + float(dir) * 2.0, h + 1.9, zc), Vector3(0.5, 3.0, bw),
|
|
METAL, "K%s_%d_bf" % [seed_id, idx], "metal")
|
|
_ad_quad(Vector3(front_x + float(dir) * 1.7, h + 1.9, zc),
|
|
Vector2(bw - 0.6, 2.5), face_yaw, _board_tex())
|
|
cursor += w
|
|
idx += 1
|
|
|
|
|
|
# ── Cel-styled statics + render-only decoration ──────────────────────────────
|
|
|
|
func _box_static(pos: Vector3, size: Vector3, color: Color, node_name: String = "",
|
|
acoustic: String = "") -> StaticBody3D:
|
|
var body := super._box_static(pos, size, color, node_name, acoustic)
|
|
for mi in body.find_children("*", "MeshInstance3D", false, false):
|
|
mi.mesh.surface_set_material(0, LevelMaterials.flat(color))
|
|
if maxf(size.x, maxf(size.y, size.z)) < 10.0:
|
|
var w := clampf(maxf(size.x, maxf(size.y, size.z)) * 0.003, 0.007, 0.018)
|
|
mi.material_overlay = LevelMaterials.outline(w)
|
|
return body
|
|
|
|
|
|
# ── Ad graphics (generated library: tools/generate_ads.py) ──────────────────
|
|
|
|
var _ad_mats: Dictionary = {}
|
|
|
|
func _ad_material(path: String) -> StandardMaterial3D:
|
|
if _ad_mats.has(path):
|
|
return _ad_mats[path]
|
|
var m := StandardMaterial3D.new()
|
|
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
var tex: Texture2D = load(path)
|
|
m.albedo_texture = tex
|
|
m.emission_enabled = true
|
|
m.emission_texture = tex
|
|
m.emission_energy_multiplier = 0.9
|
|
_ad_mats[path] = m
|
|
return m
|
|
|
|
|
|
## A lit ad panel (poster/billboard/sign face). Quad faces +Z before yaw.
|
|
func _ad_quad(pos: Vector3, size: Vector2, yaw_deg: float, tex_path: String) -> void:
|
|
var mi := MeshInstance3D.new()
|
|
var qm := QuadMesh.new()
|
|
qm.size = size
|
|
qm.material = _ad_material(tex_path)
|
|
mi.mesh = qm
|
|
add_child(mi)
|
|
mi.global_position = pos
|
|
mi.rotation_degrees.y = yaw_deg
|
|
|
|
|
|
func _poster_tex() -> String:
|
|
return "res://assets/textures/ads/ad_%02d.png" % (_rng.randi() % 10)
|
|
|
|
func _board_tex() -> String:
|
|
return "res://assets/textures/ads/board_%02d.png" % (_rng.randi() % 8)
|
|
|
|
func _column_tex() -> String:
|
|
return "res://assets/textures/ads/column_%02d.png" % (_rng.randi() % 6)
|
|
|
|
|
|
func _deco_box(pos: Vector3, size: Vector3, color: Color, emissive: bool = false) -> void:
|
|
var mi := MeshInstance3D.new()
|
|
var bm := BoxMesh.new()
|
|
bm.size = size
|
|
if emissive:
|
|
var m := StandardMaterial3D.new()
|
|
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
m.albedo_color = color
|
|
m.emission_enabled = true
|
|
m.emission = color
|
|
m.emission_energy_multiplier = 2.2
|
|
bm.material = m
|
|
else:
|
|
bm.material = LevelMaterials.flat(color)
|
|
mi.mesh = bm
|
|
mi.position = pos
|
|
add_child(mi)
|
|
|
|
|
|
# ── Environment ──────────────────────────────────────────────────────────────
|
|
|
|
func _build_environment() -> void:
|
|
var env := LevelEnvironment.add_to(self, "sunset")
|
|
# Calibrated for the kit's bright albedo textures (they clip to white at
|
|
# the energies the old dark flat palette needed).
|
|
env.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
|
env.environment.ambient_light_color = Color(0.58, 0.54, 0.66)
|
|
# Under the filmic shoulder the ambient's lavender tint actually shows
|
|
# (LINEAR used to clip it to white) — keep it lower so the sun shapes
|
|
# surfaces and lit faces stay bright.
|
|
env.environment.ambient_light_energy = 1.35
|
|
env.environment.fog_density = 0.0007 # light city haze for depth over 600m
|
|
# Ambient occlusion grounds the modeled facade detail (ZZZ-style depth)
|
|
env.environment.ssao_enabled = true
|
|
env.environment.ssao_intensity = 2.0
|
|
var sun := get_node_or_null("Sun")
|
|
if sun:
|
|
sun.light_energy = 2.5
|
|
sun.rotation_degrees = Vector3(-38, 55, 0)
|
|
sun.directional_shadow_max_distance = 220.0
|
|
|
|
|
|
func _build_geometry() -> void:
|
|
_build_environment()
|
|
_build_lighting()
|
|
# Ground: one asphalt slab for the whole district
|
|
_box_static(Vector3(0, -0.5, 0), Vector3(CITY_HALF * 2.0 + 16.0, 1.0, CITY_HALF * 2.0 + 16.0), ASPHALT, "Ground")
|
|
# Perimeter wall
|
|
for i in 4:
|
|
var horiz := i < 2
|
|
var s := 1.0 if i % 2 == 0 else -1.0
|
|
var p := (CITY_HALF + 6.0) * s
|
|
_box_static(Vector3(0.0 if horiz else p, 8.0, p if horiz else 0.0),
|
|
Vector3(CITY_HALF * 2.0 + 14.0 if horiz else 1.0, 16.0, 1.0 if horiz else CITY_HALF * 2.0 + 14.0),
|
|
BRICK.darkened(0.1), "CityWall_%d" % i, "brick")
|
|
|
|
for bz in GRID:
|
|
for bx in GRID:
|
|
_build_block(bx, bz)
|
|
|
|
_build_rail_line()
|
|
_build_streets()
|
|
_build_avenues()
|
|
_build_parked_cars()
|
|
|
|
if _spawn_points.is_empty():
|
|
_spawn_points.append(Vector3(0, 2, 0))
|
|
|
|
|
|
func _spawn_player(pid: int) -> CharacterBody3D:
|
|
var player := super._spawn_player(pid)
|
|
var s: Vector3 = _spawn_points[randi() % _spawn_points.size()]
|
|
player.position = s + Vector3(randf_range(-1.0, 1.0), 0, randf_range(-1.0, 1.0))
|
|
player.rotation.y = atan2(player.position.x, player.position.z)
|
|
return player
|
|
|
|
|
|
# ── Grid plumbing ────────────────────────────────────────────────────────────
|
|
|
|
func _cell_center(bx: int, bz: int) -> Vector3:
|
|
return Vector3((float(bx) - float(GRID - 1) * 0.5) * PITCH, 0.0, (float(bz) - float(GRID - 1) * 0.5) * PITCH)
|
|
|
|
|
|
func _build_block(bx: int, bz: int) -> void:
|
|
var c := _cell_center(bx, bz)
|
|
_rng.seed = hash(Vector2i(bx, bz)) + 77
|
|
var t := LAYOUT[bz][bx]
|
|
# Sidewalk apron under the whole cell
|
|
_box_static(c + Vector3(0, 0.08, 0), Vector3(BLOCK + 4.0, 0.16, BLOCK + 4.0), PAVING, "Walk_%d_%d" % [bx, bz])
|
|
match t:
|
|
"S": _standard_block(c, bx, bz)
|
|
"T": _tower_block(c, bx, bz)
|
|
"J": _shrine_block(c, bx, bz)
|
|
"M": _market_block(c, bx, bz)
|
|
"P": _plaza_block(c, bx, bz)
|
|
"C": _construction_block(c, bx, bz)
|
|
|
|
|
|
# ── Buildings (shared parametric unit) ───────────────────────────────────────
|
|
|
|
## front_x: world x of the facade plane. dir: +1 body extends +x, -1 extends -x.
|
|
func _building(front_x: float, dir: int, z0: float, z1: float, depth: float,
|
|
floors: int, facade: Color, id: String) -> void:
|
|
var w := z1 - z0
|
|
var zc := (z0 + z1) * 0.5
|
|
var h := float(floors) * FLOOR_H
|
|
var cx := front_x + float(dir) * depth * 0.5
|
|
var n := "B" + id
|
|
|
|
_box_static(Vector3(cx, h * 0.5, zc), Vector3(depth, h, w), facade, n, "concrete")
|
|
|
|
# Parapet ring
|
|
var pt := 0.5
|
|
_box_static(Vector3(front_x + float(dir) * 0.2, h + pt * 0.5, zc), Vector3(0.4, pt, w), facade.darkened(0.15), n + "pf")
|
|
_box_static(Vector3(front_x + float(dir) * (depth - 0.2), h + pt * 0.5, zc), Vector3(0.4, pt, w), facade.darkened(0.15), n + "pb")
|
|
for e in 2:
|
|
_box_static(Vector3(cx, h + pt * 0.5, (z0 + 0.2) if e == 0 else (z1 - 0.2)), Vector3(depth, pt, 0.4), facade.darkened(0.15), n + "ps%d" % e)
|
|
|
|
# Ground floor: glass storefront + canopy
|
|
_box_static(Vector3(front_x - float(dir) * 0.12, 2.0, zc), Vector3(0.25, 3.0, w - 1.6), GLASS, n + "g", "glass")
|
|
_box_static(Vector3(front_x - float(dir) * 1.1, 3.7, zc), Vector3(2.2, 0.2, w - 1.0), CANOPY, n + "c", "wood")
|
|
|
|
# Upper floors: sign band + window band per floor (render-only)
|
|
for f in range(1, floors):
|
|
var y := float(f) * FLOOR_H
|
|
_deco_box(Vector3(front_x - float(dir) * 0.1, y + 0.9, zc), Vector3(0.16, 1.3, w - 2.6),
|
|
SIGN_COLORS[(f + id.hash()) % SIGN_COLORS.size()], true)
|
|
_deco_box(Vector3(front_x - float(dir) * 0.08, y + 2.6, zc), Vector3(0.12, 1.4, w - 2.0), WINDOW)
|
|
|
|
# Corner sign column with glowing segments
|
|
if floors >= 3:
|
|
var zs := z0 + 1.3
|
|
var col_h := h - FLOOR_H
|
|
_box_static(Vector3(front_x - float(dir) * 0.45, FLOOR_H + col_h * 0.5 - 1.0, zs),
|
|
Vector3(0.7, col_h, 1.4), METAL, n + "sc", "metal")
|
|
for s2 in floors - 1:
|
|
var sy := FLOOR_H - 1.0 + 0.6 + (col_h - 1.2) * (float(s2) + 0.5) / float(floors - 1)
|
|
_deco_box(Vector3(front_x - float(dir) * 0.85, sy, zs), Vector3(0.12, 2.2, 1.2),
|
|
SIGN_COLORS[(s2 + 1 + id.hash()) % SIGN_COLORS.size()], true)
|
|
|
|
# Roof furniture: AC units always; water tank or antenna by seed
|
|
var count := maxi(1, int(w / 8.0))
|
|
for a in count:
|
|
var za := z0 + w * (float(a) + 0.5) / float(count)
|
|
_box_static(Vector3(cx + float(dir) * 1.5, h + 0.8, za), Vector3(1.7, 1.6, 1.7), METAL, n + "ac%d" % a, "metal")
|
|
match _rng.randi() % 3:
|
|
0: # water tank on legs
|
|
_box_static(Vector3(cx - float(dir) * 2.5, h + 1.9, zc), Vector3(2.4, 2.4, 2.4), METAL.lightened(0.15), n + "wt", "metal")
|
|
1: # antenna mast (grapple pole)
|
|
_box_static(Vector3(cx - float(dir) * 2.5, h + 2.6, zc), Vector3(0.3, 5.2, 0.3), METAL, n + "an", "metal")
|
|
_:
|
|
pass
|
|
|
|
# Rooftop billboard on tall buildings
|
|
if floors >= 4:
|
|
var bw := minf(w - 4.0, 10.0)
|
|
_box_static(Vector3(front_x + float(dir) * 2.0, h + 2.1, zc), Vector3(0.5, 3.2, bw), METAL, n + "bf", "metal")
|
|
_deco_box(Vector3(front_x + float(dir) * 1.7, h + 2.1, zc), Vector3(0.12, 2.6, bw - 0.6),
|
|
SIGN_COLORS[id.hash() % SIGN_COLORS.size()], true)
|
|
|
|
|
|
# ── Block types ──────────────────────────────────────────────────────────────
|
|
|
|
## Retail fabric: two building pairs back-to-back facing the E/W streets,
|
|
## seeded heights/colors; ~half the blocks get a mid-block alley with fire
|
|
## escapes (wallrun + climb route).
|
|
func _standard_block(c: Vector3, bx: int, bz: int) -> void:
|
|
var half := BLOCK * 0.5
|
|
# Detailed kit buildings fill both street-facing edges
|
|
_kit_fill_side(c.x - half, 1, c.z - half, c.z + half, "%d_%dW" % [bx, bz])
|
|
_kit_fill_side(c.x + half, -1, c.z - half, c.z + half, "%d_%dE" % [bx, bz])
|
|
# Fire escapes into the mid-block gap on some blocks
|
|
if _rng.randi() % 2 == 0:
|
|
for f in range(1, 3):
|
|
var y := float(f) * FLOOR_H - 0.4
|
|
_box_static(c + Vector3(-2.0 + float(f) * 1.3, y, 4.0), Vector3(2.2, 0.16, 2.2), METAL,
|
|
"Esc_%d_%d_%d" % [bx, bz, f], "metal")
|
|
# Sidewalk life: phone booth on some corners, bike rows by the fronts
|
|
if _rng.randi() % 4 == 0:
|
|
_hero("phonebooth", c + Vector3(-half - 0.6, 0.1, -half - 0.6), _rng.randf_range(0.0, 360.0),
|
|
Vector3(1.2, 2.7, 1.2), 1.35, "glass")
|
|
if _rng.randi() % 2 == 0:
|
|
var bikes := 2 + _rng.randi() % 3
|
|
var bside := 1.0 if _rng.randi() % 2 == 0 else -1.0
|
|
for b in bikes:
|
|
_kit_prop_path("res://assets/props/hero/bicycle.glb",
|
|
c + Vector3(bside * (half + 1.0), 0.1, -6.0 + float(b) * 1.5),
|
|
90.0 * bside + _rng.randf_range(-14.0, 14.0), 1.0)
|
|
if _rng.randi() % 3 == 0:
|
|
_spawn_points.append(c + Vector3(0, 1.5, 0))
|
|
|
|
|
|
## Landmark: an 8-10 floor tower with a retail podium and mega-billboards.
|
|
func _tower_block(c: Vector3, bx: int, bz: int) -> void:
|
|
var n := "Tower_%d_%d" % [bx, bz]
|
|
var col := SEGA_RED if _rng.randi() % 3 == 0 else (ELEC_BLUE if _rng.randi() % 2 == 0 else FACADES[_rng.randi() % FACADES.size()])
|
|
# Podium (1 floor, walkable roof ring) with glass front
|
|
_box_static(c + Vector3(0, FLOOR_H * 0.5, 0), Vector3(BLOCK - 4.0, FLOOR_H, BLOCK - 4.0), col.darkened(0.15), n + "_Podium", "concrete")
|
|
_box_static(c + Vector3(0, 2.0, -(BLOCK - 4.0) * 0.5 + 0.1), Vector3(BLOCK - 8.0, 3.0, 0.25), GLASS, n + "_Glass", "glass")
|
|
# Detailed kit skyscraper on the podium
|
|
var keys := KIT_SKYSCRAPERS.keys()
|
|
var key: String = keys[_rng.randi() % keys.size()]
|
|
var dims: Vector3 = KIT_SKYSCRAPERS[key]
|
|
var s := (BLOCK - 22.0) / dims.x # shaft footprint ~26m
|
|
var h := dims.y * s + FLOOR_H
|
|
var yaw := float(90 * (_rng.randi() % 4))
|
|
var body := StaticBody3D.new()
|
|
body.name = n
|
|
body.set_meta("acoustic_material", "concrete")
|
|
add_child(body)
|
|
body.global_position = c + Vector3(0, FLOOR_H, 0)
|
|
body.rotation_degrees.y = yaw
|
|
var shape := CollisionShape3D.new()
|
|
var bs := BoxShape3D.new()
|
|
bs.size = Vector3(dims.x * s, dims.y * s, dims.z * s)
|
|
shape.shape = bs
|
|
shape.position = Vector3(0, dims.y * s * 0.5, 0)
|
|
body.add_child(shape)
|
|
var inst: Node3D = _kit_scene(key).instantiate()
|
|
inst.scale = Vector3.ONE * s
|
|
body.add_child(inst)
|
|
LevelMaterials.apply_toon_recursive(inst, 0.0)
|
|
# Rooftop crown billboard both directions (skyline wayfinding)
|
|
var tw := dims.x * s
|
|
_box_static(c + Vector3(0, h + 2.4, 0), Vector3(0.6, 4.0, tw - 4.0), METAL, n + "_Crown", "metal")
|
|
_ad_quad(c + Vector3(0.42, h + 2.4, 0), Vector2(tw - 4.6, 3.3), 90.0, _board_tex())
|
|
_ad_quad(c + Vector3(-0.42, h + 2.4, 0), Vector2(tw - 4.6, 3.3), -90.0, _board_tex())
|
|
# Giant mid-height wall ad on the tower's street faces (ZZZ-style)
|
|
var wall_off := dims.z * s * 0.5 + 0.15
|
|
_ad_quad(c + Vector3(0, FLOOR_H + dims.y * s * 0.45, -wall_off).rotated(Vector3.UP, deg_to_rad(yaw)),
|
|
Vector2(tw * 0.55, tw * 0.8), yaw + 180.0, _poster_tex())
|
|
_spawn_points.append(c + Vector3(0, FLOOR_H + 1.5, (BLOCK - 4.0) * 0.4))
|
|
|
|
|
|
## Shrine courtyard: torii, hall with sloped roof, lanterns, trees — the
|
|
## quiet green break in the city fabric.
|
|
func _shrine_block(c: Vector3, bx: int, bz: int) -> void:
|
|
var n := "Shrine_%d_%d" % [bx, bz]
|
|
# Courtyard paving + low wall ring
|
|
_box_static(c + Vector3(0, 0.12, 0), Vector3(BLOCK - 6.0, 0.1, BLOCK - 6.0), PAVING.lightened(0.1), n + "_Court")
|
|
for i in 4:
|
|
var horiz := i < 2
|
|
var s := 1.0 if i % 2 == 0 else -1.0
|
|
var off := (BLOCK - 6.0) * 0.5 * s
|
|
_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),
|
|
Color(0.55, 0.55, 0.52), n + "_Wall%d" % i, "brick")
|
|
# Kitbashed torii (curved kasagi) at the south entry; pillar colliders
|
|
var tz := c.z + (BLOCK - 6.0) * 0.5
|
|
_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.9, 7.0, 0.9), VERMILION, n + "_PillarW", "wood").visible = false
|
|
_box_static(c + Vector3(4.0, 3.5, tz), Vector3(0.9, 7.0, 0.9), VERMILION, n + "_PillarE", "wood").visible = false
|
|
# 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")
|
|
_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")
|
|
# Stone + hanging lanterns, wooden gate fence, real modeled trees
|
|
for i in 2:
|
|
var lx := -8.0 + 16.0 * float(i)
|
|
_box_static(c + Vector3(lx, 1.0, 8.0), Vector3(1.0, 2.0, 1.0), Color(0.55, 0.55, 0.52), n + "_Lant%d" % i, "brick")
|
|
_kit_prop_path("res://assets/props/town/lantern.glb", c + Vector3(lx, 2.0, 8.0), 0.0, 2.6)
|
|
_box_static(c + Vector3(lx, 1.6, -2.0), Vector3(0.8, 3.2, 0.8), TRUNK, n + "_Trunk%d" % i, "wood")
|
|
_kit_prop_path("res://assets/props/nature/" + ["tree_detailed.glb", "tree_default.glb"][i % 2],
|
|
c + Vector3(lx, 0, -2.0), _rng.randf_range(0.0, 360.0), 7.0)
|
|
_kit_prop_path("res://assets/props/nature/tree_oak.glb", c + Vector3(0, 0, 2.0), _rng.randf_range(0.0, 360.0), 6.0)
|
|
# Fence gate flanking the torii approach
|
|
_kit_prop_path("res://assets/props/town/fence.glb", c + Vector3(-8.0, 0.15, tz - 0.2), 0.0, 3.0)
|
|
_kit_prop_path("res://assets/props/town/fence.glb", c + Vector3(8.0, 0.15, tz - 0.2), 0.0, 3.0)
|
|
_spawn_points.append(c + Vector3(0, 1.5, 12))
|
|
|
|
|
|
## Market street: two dense stall rows with lantern strings — low cover maze.
|
|
func _market_block(c: Vector3, bx: int, bz: int) -> void:
|
|
var n := "Mkt_%d_%d" % [bx, bz]
|
|
# Back walls: detailed kit buildings on east/west faces
|
|
_kit_fill_side(c.x - BLOCK * 0.5, 1, c.z - BLOCK * 0.5, c.z + BLOCK * 0.5, "%d_%dmw" % [bx, bz])
|
|
_kit_fill_side(c.x + BLOCK * 0.5, -1, c.z - BLOCK * 0.5, c.z + BLOCK * 0.5, "%d_%dme" % [bx, bz])
|
|
# Cafe parasols scattered among the stalls
|
|
for pi in 3:
|
|
var pp := c + Vector3(_rng.randf_range(-9.0, 9.0), 0.1, _rng.randf_range(-12.0, 12.0))
|
|
_kit_prop(["detail-parasol-a", "detail-parasol-b"][pi % 2], pp, _rng.randf_range(0.0, 360.0), 6.0)
|
|
# Modeled stall rows down the middle (kit stalls + carts, box colliders)
|
|
var stall_keys := ["stall", "stall-green", "stall-red", "stall-bench"]
|
|
for r in 2:
|
|
var x := c.x + (-5.0 if r == 0 else 5.0)
|
|
for s in 4:
|
|
var z := c.z - 15.0 + float(s) * 10.0
|
|
var body := _box_static(Vector3(x, 1.3, z), Vector3(3.4, 2.6, 3.0), WOOD, "%s_S%d_%d" % [n, r, s], "wood")
|
|
for mi in body.find_children("*", "MeshInstance3D", false, false):
|
|
mi.visible = false # collider only; the kit stall is the visual
|
|
_kit_prop_path("res://assets/props/town/%s.glb" % stall_keys[(r * 4 + s + bx) % stall_keys.size()],
|
|
Vector3(x, 0.02, z), 90.0 if r == 0 else -90.0, 3.2)
|
|
if s % 2 == 0:
|
|
_kit_prop_path("res://assets/props/town/lantern.glb",
|
|
Vector3(x + 2.2, 0.02, z + 1.8), 0.0, 3.0)
|
|
# Lantern string over each row
|
|
for l in 6:
|
|
_deco_box(Vector3(x, 3.6, c.z - 17.5 + float(l) * 7.0), Vector3(0.32, 0.45, 0.32), Color(1.0, 0.62, 0.25), true)
|
|
# Hand cart + kitbashed yatai ramen stand at the market mouths
|
|
_kit_prop_path("res://assets/props/town/cart.glb", c + Vector3(0, 0.02, 19.0), _rng.randf_range(-30, 30), 3.0)
|
|
_hero("yatai", c + Vector3(2.0, 0.02, -19.0), _rng.randf_range(150.0, 210.0),
|
|
Vector3(3.0, 2.8, 1.8), 1.4, "wood")
|
|
# Vending pair at the north entry
|
|
for v in 2:
|
|
_box_static(c + Vector3(-1.5 + 3.0 * float(v), 1.0, -BLOCK * 0.5 + 3.0), Vector3(1.1, 2.0, 0.9),
|
|
[Color(0.85, 0.3, 0.3), Color(0.3, 0.55, 0.85)][v], "%s_V%d" % [n, v], "metal")
|
|
_spawn_points.append(c + Vector3(0, 1.5, 0))
|
|
|
|
|
|
## Plaza: open ground; the exact centre cell hosts the giant screen tower.
|
|
func _plaza_block(c: Vector3, bx: int, bz: int) -> void:
|
|
var n := "Plaza_%d_%d" % [bx, bz]
|
|
# Planter cover ring with real trees
|
|
for i in 4:
|
|
var a := TAU * float(i) / 4.0 + PI / 4.0
|
|
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")
|
|
_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")
|
|
# Koban police box watching the central crossing
|
|
if bx == 5 and bz == 5:
|
|
_hero("koban", c + Vector3(-BLOCK * 0.5 + 4.0, 0.02, -BLOCK * 0.5 + 4.0), 225.0,
|
|
Vector3(3.7, 3.4, 3.5), 1.7, "concrete")
|
|
# The Crossing screen tower on the single centre-most plaza cell
|
|
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")
|
|
for face in 4:
|
|
var a2 := TAU * float(face) / 4.0
|
|
var off := Vector3(cos(a2), 0, sin(a2)) * 3.15
|
|
_ad_quad(c + off + Vector3(0, 11.0, 0), Vector2(4.6, 6.0),
|
|
rad_to_deg(a2) + 90.0, _board_tex())
|
|
_spawn_points.append(c + Vector3(BLOCK * 0.2, 1.5, -BLOCK * 0.2))
|
|
|
|
|
|
## Construction site: crane (grapple mast + jib), container stacks, ramps
|
|
## and scaffold platforms — the vertical playground block.
|
|
func _construction_block(c: Vector3, bx: int, bz: int) -> void:
|
|
var n := "Site_%d_%d" % [bx, bz]
|
|
# Unfinished frame: slab stack with open floors (wallrun edges)
|
|
for lvl in 3:
|
|
_box_static(c + Vector3(-8, float(lvl) * 6.0 + 5.7, -8), Vector3(24.0, 0.6, 24.0), Color(0.6, 0.58, 0.56), "%s_Slab%d" % [n, lvl], "concrete")
|
|
for px in 2:
|
|
for pz in 2:
|
|
_box_static(c + Vector3(-8.0 + (-10.0 + 20.0 * float(px)), 9.0, -8.0 + (-10.0 + 20.0 * float(pz))),
|
|
Vector3(1.0, 18.0, 1.0), Color(0.6, 0.58, 0.56), "%s_Col%d%d" % [n, px, pz], "concrete")
|
|
# Access ramp to first slab
|
|
_ramp_static(c + Vector3(6.0, 3.0, -8.0), Vector3(4.0, 0.4, 13.5), Vector3(0, 0, -26), METAL, n + "_Ramp", "metal")
|
|
# Crane: mast + jib over the street (THE grapple anchor)
|
|
_box_static(c + Vector3(14, 14.0, 14), Vector3(1.6, 28.0, 1.6), Color(0.9, 0.6, 0.15), n + "_CraneMast", "metal")
|
|
_box_static(c + Vector3(4, 27.5, 14), Vector3(22.0, 1.2, 1.2), Color(0.9, 0.6, 0.15), n + "_CraneJib", "metal")
|
|
_deco_box(c + Vector3(-6.0, 24.0, 14), Vector3(0.12, 6.5, 0.12), INK_COLOR)
|
|
# Container stacks (jump steps + cover)
|
|
var cc := [Color(0.75, 0.35, 0.25), Color(0.3, 0.55, 0.85), Color(0.35, 0.65, 0.4)]
|
|
for i in 3:
|
|
_box_static(c + Vector3(10, 1.3 + 2.6 * float(mini(i, 1)), -12.0 + float(i) * 3.2), Vector3(6.2, 2.6, 2.6),
|
|
cc[i], "%s_Cont%d" % [n, i], "metal")
|
|
# Cones + barriers scattered around the site entrance
|
|
for i in 4:
|
|
_kit_prop_path("res://assets/props/roads/construction-cone.glb",
|
|
c + Vector3(-16.0 + float(i) * 4.0, 0.1, 18.0), 0.0, 8.0)
|
|
for i in 2:
|
|
_kit_prop_path("res://assets/props/roads/construction-barrier.glb",
|
|
c + Vector3(-14.0 + float(i) * 12.0, 0.1, 14.0), float(i * 30), 8.0)
|
|
_spawn_points.append(c + Vector3(14, 1.5, -14))
|
|
|
|
const INK_COLOR := Color(0.08, 0.07, 0.1)
|
|
|
|
|
|
# ── Rail viaduct across the whole city ───────────────────────────────────────
|
|
|
|
func _build_rail_line() -> void:
|
|
var z := (float(RAIL_ROW) - float(GRID - 1) * 0.5) * PITCH + PITCH * 0.5 # street south of RAIL_ROW
|
|
var full := CITY_HALF * 2.0 + 12.0
|
|
_box_static(Vector3(0, RAIL_TOP - 0.5, z), Vector3(full, 1.0, 7.0), BRICK, "Rail_Deck", "brick")
|
|
_box_static(Vector3(0, RAIL_TOP + 0.55, z - 3.2), Vector3(full, 1.1, 0.5), BRICK.darkened(0.1), "Rail_WallN", "brick")
|
|
_box_static(Vector3(0, RAIL_TOP + 0.55, z + 3.2), Vector3(full, 1.1, 0.5), BRICK.darkened(0.1), "Rail_WallS", "brick")
|
|
# Piers at every block line (streets cross beneath between them)
|
|
var i := 0
|
|
var x := -CITY_HALF + PITCH * 0.5
|
|
while x <= CITY_HALF:
|
|
_box_static(Vector3(x, (RAIL_TOP - 1.0) * 0.5, z), Vector3(2.2, RAIL_TOP - 1.0, 5.4), BRICK, "Rail_Pier%d" % i, "brick")
|
|
# vending pair + posters under alternating arches
|
|
if i % 2 == 0:
|
|
_box_static(Vector3(x + 4.0, 1.0, z + 1.8), Vector3(1.1, 2.0, 0.9), Color(0.85, 0.3, 0.3), "Rail_V%da" % i, "metal")
|
|
_box_static(Vector3(x + 5.6, 1.0, z + 1.8), Vector3(1.1, 2.0, 0.9), Color(0.3, 0.55, 0.85), "Rail_V%db" % i, "metal")
|
|
_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
|
|
x += PITCH
|
|
# Kitbashed station entrance beside the central avenue crossing, with a
|
|
# platform strip (canopy + benches + signs) up on the deck above it
|
|
_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")
|
|
_hero("platform", Vector3(10.0, RAIL_TOP, z), 0.0, Vector3(14.0, 2.8, 2.4), 1.4, "metal")
|
|
_hero("platform", Vector3(-24.0, RAIL_TOP, z), 0.0, Vector3(14.0, 2.8, 2.4), 1.4, "metal")
|
|
|
|
# Pedestrian footbridge over the central avenue (deck + stair colliders
|
|
# are walkable: a third crossing route between the viaduct and plaza)
|
|
var fb_z := z + PITCH
|
|
var fb := StaticBody3D.new()
|
|
fb.name = "Footbridge"
|
|
fb.set_meta("acoustic_material", "metal")
|
|
add_child(fb)
|
|
fb.global_position = Vector3(0, 0, fb_z) # span runs along x, across the avenue
|
|
var fb_shape := CollisionShape3D.new()
|
|
var fb_box := BoxShape3D.new()
|
|
fb_box.size = Vector3(18.0, 0.4, 3.0)
|
|
fb_shape.shape = fb_box
|
|
fb_shape.position = Vector3(0, 5.5, 0)
|
|
fb.add_child(fb_shape)
|
|
var fb_inst: Node3D = _kit_scene_at("res://assets/props/hero/footbridge.glb").instantiate()
|
|
fb.add_child(fb_inst)
|
|
LevelMaterials.apply_toon_recursive(fb_inst, 0.005)
|
|
# Walkable stair ramps at both ends (over the stair visuals)
|
|
for e in [-1.0, 1.0]:
|
|
var ang := rad_to_deg(atan2(5.5, 6.4))
|
|
_ramp_static(Vector3(e * (9.0 + 3.2), 2.65, fb_z), Vector3(2.8, 0.3, 8.6),
|
|
Vector3(0, 0, -ang * e), PAVING, "FootbridgeStair_%d" % int(e), "metal").visible = false
|
|
|
|
# Slide ramps to the deck at the two main-avenue crossings
|
|
for sx in [-PITCH * 0.5 - 4.0, PITCH * 0.5 + 4.0]:
|
|
var ang := rad_to_deg(atan2(RAIL_TOP, 16.0))
|
|
_ramp_static(Vector3(sx, RAIL_TOP * 0.5, z + 3.5 + 8.0), Vector3(3.0, 0.4, sqrt(256.0 + RAIL_TOP * RAIL_TOP)),
|
|
Vector3(-ang, 0, 0), PAVING.darkened(0.1), "Rail_Ramp_%d" % int(sx), "concrete")
|
|
_spawn_points.append(Vector3(0, RAIL_TOP + 1.5, z))
|
|
|
|
|
|
# ── Streets: real road meshes with markings ──────────────────────────────────
|
|
|
|
const ROAD_W := 12.0 # visual road width between sidewalk aprons
|
|
|
|
## Street lines run between block columns/rows at multiples of PITCH.
|
|
func _street_lines() -> Array[float]:
|
|
var lines: Array[float] = []
|
|
for i in GRID - 1:
|
|
lines.append((float(i) - float(GRID) * 0.5 + 1.0) * PITCH)
|
|
return lines
|
|
|
|
|
|
func _road_piece(key: String, pos: Vector3, yaw_deg: float, sc: Vector3) -> void:
|
|
var inst: Node3D = _kit_scene_at("res://assets/props/roads/" + key + ".glb").instantiate()
|
|
inst.scale = sc
|
|
add_child(inst)
|
|
inst.global_position = pos
|
|
inst.rotation_degrees.y = yaw_deg
|
|
LevelMaterials.apply_toon_recursive(inst, 0.0)
|
|
# The kit's pale concrete clips to white under the map lighting
|
|
_tint_recursive(inst, Color(0.55, 0.55, 0.6))
|
|
|
|
|
|
var _path_cache: Dictionary = {}
|
|
func _kit_scene_at(path: String) -> PackedScene:
|
|
if not _path_cache.has(path):
|
|
_path_cache[path] = load(path)
|
|
return _path_cache[path]
|
|
|
|
|
|
func _build_streets() -> void:
|
|
var lines := _street_lines()
|
|
var seg := PITCH - ROAD_W # straight run between intersections
|
|
var y := 0.02
|
|
# Intersections with line markings
|
|
for x in lines:
|
|
for z in lines:
|
|
_road_piece("road-crossroad-line", Vector3(x, y, z), 0.0, Vector3(ROAD_W, 2.0, ROAD_W))
|
|
# Straight segments (stretched single meshes) + edge stubs to the wall
|
|
for x in lines:
|
|
for i in lines.size() + 1:
|
|
var z0 := -CITY_HALF if i == 0 else lines[i - 1] + ROAD_W * 0.5
|
|
var z1 := CITY_HALF if i == lines.size() else lines[i] - ROAD_W * 0.5
|
|
if z1 - z0 < 2.0:
|
|
continue
|
|
_road_piece("road-straight", Vector3(x, y, (z0 + z1) * 0.5), 0.0, Vector3(ROAD_W, 2.0, z1 - z0))
|
|
for z in lines:
|
|
for i in lines.size() + 1:
|
|
var x0 := -CITY_HALF if i == 0 else lines[i - 1] + ROAD_W * 0.5
|
|
var x1 := CITY_HALF if i == lines.size() else lines[i] - ROAD_W * 0.5
|
|
if x1 - x0 < 2.0:
|
|
continue
|
|
_road_piece("road-straight", Vector3((x0 + x1) * 0.5, y, z), 90.0, Vector3(ROAD_W, 2.0, x1 - x0))
|
|
|
|
# Overhead cable bundles across east-west streets (urban-Japan clutter)
|
|
for z in lines:
|
|
var x := -CITY_HALF + 24.0
|
|
while x < CITY_HALF - 24.0:
|
|
if absf(x) > 14.0: # keep the central avenue sky clear
|
|
var h := 9.5 + fmod(absf(x), 3.0) * 0.4
|
|
for s3 in 3:
|
|
var t0 := float(s3) / 3.0
|
|
var t1 := float(s3 + 1) / 3.0
|
|
var sag0 := sin(t0 * PI) * 0.7
|
|
var sag1 := sin(t1 * PI) * 0.7
|
|
var za := z - STREET * 0.5 + STREET * t0
|
|
var zb := z - STREET * 0.5 + STREET * t1
|
|
var mid := Vector3(x, h - (sag0 + sag1) * 0.5, (za + zb) * 0.5)
|
|
_deco_box(mid, Vector3(0.07, 0.07, zb - za + 0.1), INK_COLOR)
|
|
x += 48.0
|
|
else:
|
|
x += 48.0
|
|
|
|
|
|
# ── Main avenues: kit streetlights ───────────────────────────────────────────
|
|
|
|
func _build_avenues() -> void:
|
|
var lines := _street_lines()
|
|
var lamp_scale := 10.0
|
|
for z in lines:
|
|
for side: float in [-1.0, 1.0]:
|
|
# Double-arm lights along the central N-S avenue at every block line
|
|
var x := side * (ROAD_W * 0.5 + 0.4)
|
|
_box_static(Vector3(x, 3.4, z), Vector3(0.26, 6.8, 0.26), METAL, "LampP_%d_%d" % [int(z), int(side)], "metal")
|
|
_kit_prop_path("res://assets/props/roads/light-curved.glb",
|
|
Vector3(x, 0, z), 90.0 if side > 0 else -90.0, lamp_scale)
|
|
# Traffic signal on the opposite diagonal corner of each crossing
|
|
_kit_prop_path("res://assets/props/roads/light-square-cross.glb",
|
|
Vector3(x, 0, z - side * (ROAD_W * 0.5 + 0.8)), 90.0 if side > 0 else -90.0, lamp_scale * 0.8)
|
|
# Bus stop shelters on alternating blocks along the avenue
|
|
if int(roundf(z / PITCH)) % 2 == 0:
|
|
_hero("busstop", Vector3(ROAD_W * 0.5 + 2.0, 0.02, z + 18.0), -90.0,
|
|
Vector3(3.8, 2.6, 1.6), 1.3, "metal")
|
|
_hero("busstop", Vector3(-ROAD_W * 0.5 - 2.0, 0.02, z - 18.0), 90.0,
|
|
Vector3(3.8, 2.6, 1.6), 1.3, "metal")
|
|
for x in lines:
|
|
if absf(x) < 1.0:
|
|
continue
|
|
# Single lights down the central E-W avenue
|
|
for side: float in [-1.0, 1.0]:
|
|
var z2 := side * (ROAD_W * 0.5 + 0.4)
|
|
_kit_prop_path("res://assets/props/roads/light-curved.glb",
|
|
Vector3(x, 0, z2), 0.0 if side > 0 else 180.0, lamp_scale)
|
|
|
|
|
|
# ── Parked cars (Kenney Car Kit) ─────────────────────────────────────────────
|
|
|
|
const CAR_KEYS := ["sedan", "sedan", "taxi", "van", "suv", "hatchback-sports",
|
|
"delivery", "sedan-sports", "police"]
|
|
const CAR_SCALE := 1.8
|
|
|
|
## A parked car: metal-tagged collider, kit body, wheels attached to the
|
|
## model's wheel sockets, slight per-car tint.
|
|
func _parked_car(pos: Vector3, yaw_deg: float) -> void:
|
|
var key: String = CAR_KEYS[_rng.randi() % CAR_KEYS.size()]
|
|
var body := StaticBody3D.new()
|
|
body.name = "Car_%d" % _rng.randi()
|
|
body.set_meta("acoustic_material", "metal")
|
|
add_child(body)
|
|
body.global_position = pos
|
|
body.rotation_degrees.y = yaw_deg + _rng.randf_range(-3.0, 3.0)
|
|
var shape := CollisionShape3D.new()
|
|
var bs := BoxShape3D.new()
|
|
bs.size = Vector3(2.8, 2.6, 5.0)
|
|
shape.shape = bs
|
|
shape.position = Vector3(0, 1.3, 0)
|
|
body.add_child(shape)
|
|
|
|
var inst: Node3D = _kit_scene_at("res://assets/props/cars/" + key + ".glb").instantiate()
|
|
inst.scale = Vector3.ONE * CAR_SCALE
|
|
inst.position.y = 0.55 # wheel contact at ground after scaling
|
|
body.add_child(inst)
|
|
var wheel_scene := _kit_scene_at("res://assets/props/cars/wheel-default.glb")
|
|
for n in inst.find_children("wheel*", "Node3D", true, false):
|
|
if n.get_child_count() == 0 and not (n is MeshInstance3D):
|
|
var wheel: Node3D = wheel_scene.instantiate()
|
|
n.add_child(wheel)
|
|
LevelMaterials.apply_toon_recursive(inst, 0.0)
|
|
# Darken under the map lighting (kit albedo clips) + per-car hue
|
|
_tint_recursive(inst, Color(0.62, 0.62, 0.66) * Color(1, 1, 1).lerp(Color.from_hsv(_rng.randf(), 0.6, 1.0), 0.35))
|
|
|
|
|
|
## Seeded parking lanes hugging the curbs of the N-S streets.
|
|
func _build_parked_cars() -> void:
|
|
for x in _street_lines():
|
|
for row in GRID:
|
|
_rng.seed = hash(Vector2i(int(x), row)) + 913
|
|
if _rng.randf() < 0.45:
|
|
continue
|
|
var z_base := (float(row) - float(GRID - 1) * 0.5) * PITCH
|
|
var cars := 1 + _rng.randi() % 3
|
|
for i in cars:
|
|
var side := 1.0 if _rng.randi() % 2 == 0 else -1.0
|
|
var cx := x + side * (ROAD_W * 0.5 - 1.8)
|
|
var cz := z_base + _rng.randf_range(-18.0, 18.0)
|
|
_parked_car(Vector3(cx, 0.02, cz), 0.0 if _rng.randi() % 2 == 0 else 180.0)
|
|
|
|
|
|
func _kit_prop_path(path: String, pos: Vector3, yaw_deg: float, scale_f: float) -> void:
|
|
var inst: Node3D = _kit_scene_at(path).instantiate()
|
|
inst.scale = Vector3.ONE * scale_f
|
|
add_child(inst)
|
|
inst.global_position = pos
|
|
inst.rotation_degrees.y = yaw_deg
|
|
# Nature-kit foliage uses vertex-colored materials that the toon swap
|
|
# mangles (cyan trees) — keep original materials; the faceted low-poly
|
|
# look already reads cel under this lighting.
|
|
if not path.contains("/nature/"):
|
|
LevelMaterials.apply_toon_recursive(inst, 0.0)
|