4482 lines
210 KiB
GDScript
4482 lines
210 KiB
GDScript
extends TestLevelBuilder
|
|
|
|
## 桜踏切 — Sakura Crossing.
|
|
##
|
|
## A Japanese suburban level crossing in blossom season, built to the aesthetic
|
|
## of the Sakura Crossing reference: flat cel colour on real geometry, a narrow
|
|
## pale palette, hue-shifted violet shadow, and screen-space ink taken from the
|
|
## curvature of the depth buffer.
|
|
##
|
|
## ── The layout is three lanes and three crossings ────────────────────────────
|
|
##
|
|
## Everything is arranged east-west, because the railway is, and the railway is
|
|
## what makes this place a place. North is -Z.
|
|
##
|
|
## Z = -62 ░ boundary wall, cedar treeline, distant hills ░
|
|
## LANE 1 Z = -47..-10 the shopping street (商店街): two rows of shophouses
|
|
## facing a 9 m street, with a service alley behind the
|
|
## south row. Tight, roofed, full of stalls and vending
|
|
## machines. This is the CQC lane and its rooftops are
|
|
## the north half's high ground.
|
|
## LANE 2 Z = -7..7 THE RAILWAY. Open, dead flat, fenced both sides. A
|
|
## 160 m sightline with almost no cover — crossing it
|
|
## anywhere but at a marked point is a decision.
|
|
## LANE 3 Z = 9..60 the south side: a verge of cherry trees, a street, a
|
|
## house row behind garden walls, then the park and the
|
|
## shrine. Open, soft cover, long angles.
|
|
## Z = 62 ░ boundary ░
|
|
##
|
|
## The lanes connect at exactly three places, and each plays differently, which
|
|
## is the whole design:
|
|
##
|
|
## X = -40 THE FOOTBRIDGE. Deck at 6.2 m spanning the tracks — the only high
|
|
## ground that sees the full length of the railway. Its two stairs
|
|
## run ALONG the lanes rather than across them, so the climb is
|
|
## exposed to the alley on one side and the verge on the other.
|
|
## X = 0 THE CROSSING. The signature image and the map's hot zone. At
|
|
## grade, so it is the fastest connection and the most contested; the
|
|
## barrier booms and the signal cabinets are the only hard cover in
|
|
## fourteen metres of open track.
|
|
## X = +42 THE STATION. A 1.1 m platform under a canopy, with a ramp up
|
|
## from the south street and a stair off the far end. Half cover, a
|
|
## roof worth fighting over, and the only crossing that is a room —
|
|
## so it plays as a hold where the crossing plays as a dash.
|
|
##
|
|
## ── Why the railway is at grade and not on an embankment ─────────────────────
|
|
##
|
|
## An embankment would be easier to build and would give free verticality, but a
|
|
## 踏切 IS the level crossing — barriers, an alarm, an X-shaped sign and a road
|
|
## running straight over the rails. Raise the track and none of that can exist,
|
|
## and the map stops being this place. Verticality comes from the footbridge,
|
|
## the shop roofs and the station canopy instead, which is where it comes from
|
|
## in the reference too.
|
|
|
|
const HALF_X := 118.0 # playable half-extent, east-west
|
|
const HALF_Z := 88.0 # playable half-extent, north-south
|
|
|
|
# ── The railway turns ────────────────────────────────────────────────────────
|
|
#
|
|
# It runs dead straight through the crossing, the footbridge and the station —
|
|
# it has to, those are all built square to it — and then swings north-east once
|
|
# it is clear of them. The turn is what stops the east half of the map being the
|
|
# west half again: it opens a large wedge of land south of the line, which is
|
|
# where the housing district goes, and it gives the whole east end a different
|
|
# geometry to fight in. It also means the 160 m sightline down the track now
|
|
# ENDS somewhere, instead of running out of the map in both directions.
|
|
const CURVE_START_X := 62.0 # clear of the station's east ramp
|
|
const CURVE_RADIUS := 60.0
|
|
const CURVE_SWEEP := 55.0 # degrees of turn, toward -Z
|
|
|
|
const RAIL_HALF := 7.0 # railway corridor half-width
|
|
const RAIL_GAUGE := 1.435 # metres between rail heads, because it is
|
|
const TRACK_OFFSET := 2.6 # each track's centre, either side of Z = 0
|
|
const BALLAST_H := 0.16
|
|
const CROSS_Y := 0.36 # the crossing deck, flush with the rail heads
|
|
|
|
const ROAD_HALF := 4.5 # the main street, north-south through X = 0
|
|
const PAVE_W := 2.6
|
|
|
|
# ── Surface heights ──────────────────────────────────────────────────────────
|
|
#
|
|
# Every ground plane in the map gets an explicit height, and they are all
|
|
# different on purpose. Two coplanar slabs z-fight, and the fight is worst
|
|
# exactly where two roads cross — which here is the middle of the map. Ordering
|
|
# them also encodes which surface wins: the main street is drawn over the
|
|
# east-west streets because it is the through route, and grass is drawn under
|
|
# both because a lawn does not run across a road.
|
|
const Y_ROAD_EW := 0.060
|
|
const Y_ROAD_NS := 0.075
|
|
const Y_GRASS := 0.100
|
|
const Y_PAVE := 0.200
|
|
|
|
const SHOP_ST_Z := -30.0 # centreline of the shopping street
|
|
const SHOP_ST_HALF := 4.5
|
|
const SOUTH_ST_Z := 20.0 # centreline of the south residential street
|
|
const SOUTH_ST_HALF := 4.5
|
|
const ALLEY_Z := -11.2 # service alley behind the south shop row
|
|
const ALLEY_W := 3.0
|
|
|
|
# ── The east district ────────────────────────────────────────────────────────
|
|
# The residential block in the wedge the railway's turn opens up, with its own
|
|
# north-south and east-west streets.
|
|
const EAST_DIST_X0 := 10.0
|
|
const EAST_DIST_X1 := 108.0
|
|
const EAST_DIST_Z1 := 80.0
|
|
const EAST_ST_X := 64.0 # the north-south street through it
|
|
const EAST_ST_Z := 54.0 # the east-west street it feeds
|
|
const EAST_ST_HALF := 4.5
|
|
|
|
## The shopping street stops short of the curve — past this the railway is
|
|
## swinging north across where the street would have run.
|
|
const SHOP_ST_X1 := 52.0
|
|
|
|
const BRIDGE_X := -40.0
|
|
const BRIDGE_SPAN := 22.0
|
|
const BRIDGE_DECK_Y := 6.2
|
|
const STATION_X := 42.0
|
|
const PARK_Z := 50.0
|
|
const SHRINE_X := -22.0 # the shrine axis, deliberately NOT the main street
|
|
|
|
const FLOOR_H := 3.4 # a shophouse storey
|
|
|
|
## How far the ground reaches beyond the boundary walls.
|
|
##
|
|
## A player who gets over a wall — and this game has a grapple and a dash —
|
|
## used to run out of collision twenty metres later and fall out of the world.
|
|
## The combat volume and its five-second timer are the RULE that says they have
|
|
## left; the ground out here is what stops leaving from being a bug. They land
|
|
## on open country, see the warning, and have five seconds to get back.
|
|
const OUTFIELD := 300.0
|
|
|
|
## ── The outfield is three rings, in this order ───────────────────────────────
|
|
##
|
|
## town → suburb → hills, and nothing from one ring is allowed into another.
|
|
##
|
|
## They used to be laid on top of each other: the hills were scattered across
|
|
## the whole margin with no idea where the city was, so buildings stood
|
|
## half-buried in hillsides and roads climbed into them and stopped. A road that
|
|
## disappears into a hill is the clearest possible way to tell a player the
|
|
## world is fake.
|
|
const CITY_REACH := 150.0 # how far the built-up area runs past the wall
|
|
const HILL_START := 178.0 # hills begin beyond it, never inside it
|
|
|
|
var _rng := RandomNumberGenerator.new()
|
|
var _spawn_points: Array[Vector3] = []
|
|
var _mesh_cache: Dictionary = {}
|
|
var _decor: Node3D # render-only children, kept out of physics
|
|
var M := {}
|
|
|
|
## When set, everything built goes under this node instead of the level root.
|
|
##
|
|
## It exists so a whole assembly can be built in LOCAL coordinates and then
|
|
## placed and turned as one — see `_house`, which is written entirely around a
|
|
## street to its north and gets its mirror image for free by being built under a
|
|
## pivot rotated 180°. Hand-mirroring sixty offsets and half a dozen rotations
|
|
## is the kind of edit that looks right and is wrong in one place.
|
|
var _group: Node3D = null
|
|
|
|
|
|
func _attach(n: Node) -> void:
|
|
if _group != null:
|
|
_group.add_child(n)
|
|
else:
|
|
add_child(n)
|
|
|
|
|
|
func _build_geometry() -> void:
|
|
_rng.seed = 0x5AC124 # deterministic: the map is the same every match
|
|
_decor = Node3D.new()
|
|
_decor.name = "Decor"
|
|
add_child(_decor)
|
|
|
|
_build_environment()
|
|
_build_ground()
|
|
_build_railway()
|
|
_build_crossing()
|
|
_build_shopping_street()
|
|
_build_south_side()
|
|
_build_footbridge()
|
|
_build_station()
|
|
_build_park()
|
|
_build_street_furniture()
|
|
_build_cover()
|
|
_build_boundary()
|
|
_build_petals()
|
|
|
|
if _spawn_points.is_empty():
|
|
_spawn_points.append(Vector3(0, 2, 0))
|
|
_publish_spawn_markers()
|
|
|
|
# The play volume follows the visible wall centreline exactly. The old eight
|
|
# metre allowance let a player stand outside the wall without being warned.
|
|
# genuinely left — over a wall with the grapple, or off the edge of the
|
|
# world. Below it there is nothing to land on, which is exactly the case
|
|
# this is here to catch.
|
|
CombatArea.add_to(self,
|
|
Vector3(HALF_X * 2.0, 60.0, HALF_Z * 2.0),
|
|
Vector3(0, 22.0, 0))
|
|
|
|
|
|
## Mirror the spawn list into real nodes in the `spawn_points` group.
|
|
##
|
|
## The list itself is a private array, which is all _spawn_player needs — but it
|
|
## means nothing outside this script can see where a player will appear.
|
|
## map_preview_capture photographs a map from its spawns for the level-select
|
|
## card, and with no markers it fell back to a raised corner view scaled to the
|
|
## map's bounds. The markers cost nothing and make the spawn layout inspectable
|
|
## in the remote scene tree as well.
|
|
func _publish_spawn_markers() -> void:
|
|
var holder := Node3D.new()
|
|
holder.name = "SpawnPoints"
|
|
add_child(holder)
|
|
for i in range(_spawn_points.size()):
|
|
var m := Marker3D.new()
|
|
m.name = "Spawn%02d" % i
|
|
m.position = _spawn_points[i]
|
|
m.add_to_group("spawn_points")
|
|
holder.add_child(m)
|
|
|
|
|
|
func _build_environment() -> void:
|
|
# "sakura" carries the pale hand-painted sky, the four-light anime rig and
|
|
# the reduced ambient the cel ramp needs. See LevelEnvironment.
|
|
LevelEnvironment.add_to(self, "sakura")
|
|
|
|
|
|
# ── Primitives ───────────────────────────────────────────────────────────────
|
|
#
|
|
# Meshes are cached by shape and the material goes on the INSTANCE, so a
|
|
# thousand sleepers or fence posts share one mesh resource. Anything that does
|
|
# not need to be collided with goes under Decor and never becomes a body — a
|
|
# level crossing has a great many small objects, and turning all of them into
|
|
# physics is how a stylised map ends up with a worse frame time than a realistic
|
|
# one.
|
|
|
|
func _box_mesh(size: Vector3) -> BoxMesh:
|
|
var key := "b%.3f,%.3f,%.3f" % [size.x, size.y, size.z]
|
|
if _mesh_cache.has(key):
|
|
return _mesh_cache[key]
|
|
var m := BoxMesh.new()
|
|
m.size = size
|
|
_mesh_cache[key] = m
|
|
return m
|
|
|
|
|
|
func _cyl_mesh(bottom_r: float, top_r: float, height: float, segments: int = 8) -> CylinderMesh:
|
|
var key := "c%.3f,%.3f,%.3f,%d" % [bottom_r, top_r, height, segments]
|
|
if _mesh_cache.has(key):
|
|
return _mesh_cache[key]
|
|
var m := CylinderMesh.new()
|
|
m.bottom_radius = bottom_r
|
|
m.top_radius = top_r
|
|
m.height = height
|
|
m.radial_segments = segments
|
|
m.rings = 1
|
|
_mesh_cache[key] = m
|
|
return m
|
|
|
|
|
|
func _sphere_mesh(radius: float, segments: int = 7, rings: int = 4) -> SphereMesh:
|
|
var key := "s%.3f,%d,%d" % [radius, segments, rings]
|
|
if _mesh_cache.has(key):
|
|
return _mesh_cache[key]
|
|
var m := SphereMesh.new()
|
|
m.radius = radius
|
|
m.height = radius * 2.0
|
|
m.radial_segments = segments
|
|
m.rings = rings
|
|
_mesh_cache[key] = m
|
|
return m
|
|
|
|
|
|
func _prism_mesh(size: Vector3) -> PrismMesh:
|
|
var key := "p%.3f,%.3f,%.3f" % [size.x, size.y, size.z]
|
|
if _mesh_cache.has(key):
|
|
return _mesh_cache[key]
|
|
var m := PrismMesh.new()
|
|
m.size = size
|
|
_mesh_cache[key] = m
|
|
return m
|
|
|
|
|
|
## Render-only mesh. Goes under Decor, has no body, costs one draw call.
|
|
func _deco(mesh: Mesh, mat: Material, pos: Vector3, rot: Vector3 = Vector3.ZERO,
|
|
parent: Node = null) -> MeshInstance3D:
|
|
var mi := MeshInstance3D.new()
|
|
mi.mesh = mesh
|
|
mi.material_override = mat
|
|
mi.position = pos
|
|
if rot != Vector3.ZERO:
|
|
mi.rotation_degrees = rot
|
|
var host: Node = parent
|
|
if host == null:
|
|
host = _group if _group != null else _decor
|
|
host.add_child(mi)
|
|
return mi
|
|
|
|
|
|
## A solid box: mesh plus a matching box collider.
|
|
func _solid(pos: Vector3, size: Vector3, mat: Material, acoustic: String = "concrete",
|
|
rot: Vector3 = Vector3.ZERO, node_name: String = "") -> StaticBody3D:
|
|
var body := StaticBody3D.new()
|
|
body.name = node_name if node_name != "" else "S"
|
|
body.position = pos
|
|
if rot != Vector3.ZERO:
|
|
body.rotation_degrees = rot
|
|
if acoustic != "":
|
|
body.set_meta("acoustic_material", acoustic)
|
|
_attach(body)
|
|
var shape := CollisionShape3D.new()
|
|
var box := BoxShape3D.new()
|
|
box.size = size
|
|
shape.shape = box
|
|
body.add_child(shape)
|
|
var mi := MeshInstance3D.new()
|
|
mi.mesh = _box_mesh(size)
|
|
mi.material_override = mat
|
|
body.add_child(mi)
|
|
return body
|
|
|
|
|
|
## A collider with no mesh. For barriers whose VISIBLE form is a set of thin
|
|
## members — a fence, a railing — where one box the size of the whole barrier is
|
|
## the right physics and completely the wrong picture.
|
|
func _blocker(pos: Vector3, size: Vector3, acoustic: String = "metal") -> StaticBody3D:
|
|
var body := StaticBody3D.new()
|
|
body.name = "B"
|
|
body.position = pos
|
|
body.set_meta("acoustic_material", acoustic)
|
|
_attach(body)
|
|
var shape := CollisionShape3D.new()
|
|
var box := BoxShape3D.new()
|
|
box.size = size
|
|
shape.shape = box
|
|
body.add_child(shape)
|
|
return body
|
|
|
|
|
|
## A solid cylinder — posts, poles, trunks, bollards.
|
|
func _post(pos: Vector3, radius: float, height: float, mat: Material,
|
|
acoustic: String = "metal", segments: int = 8) -> StaticBody3D:
|
|
var body := StaticBody3D.new()
|
|
body.name = "P"
|
|
body.position = pos
|
|
body.set_meta("acoustic_material", acoustic)
|
|
_attach(body)
|
|
var shape := CollisionShape3D.new()
|
|
var cyl := CylinderShape3D.new()
|
|
cyl.radius = radius
|
|
cyl.height = height
|
|
shape.shape = cyl
|
|
body.add_child(shape)
|
|
var mi := MeshInstance3D.new()
|
|
mi.mesh = _cyl_mesh(radius, radius, height, segments)
|
|
mi.material_override = mat
|
|
body.add_child(mi)
|
|
return body
|
|
|
|
|
|
## Flat ground slab. Collides (you walk on it) but is only ever a fifth of a
|
|
## metre thick, so its sides are never seen.
|
|
func _slab(centre: Vector2, size: Vector2, y: float, mat: Material,
|
|
acoustic: String = "concrete", thickness: float = 0.2) -> StaticBody3D:
|
|
return _solid(
|
|
Vector3(centre.x, y - thickness * 0.5, centre.y),
|
|
Vector3(size.x, thickness, size.y), mat, acoustic)
|
|
|
|
|
|
## A painted line, a tactile strip, a drift of fallen blossom — anything laid
|
|
## flat on the ground. Render-only, and lifted a centimetre so it never
|
|
## z-fights the slab under it.
|
|
func _paint(centre: Vector2, size: Vector2, y: float, mat: Material) -> void:
|
|
_deco(_box_mesh(Vector3(size.x, 0.02, size.y)), mat,
|
|
Vector3(centre.x, y + 0.012, centre.y))
|
|
|
|
|
|
## Split the run [a, b] around a list of [lo, hi] gaps. Used for every kerb,
|
|
## pavement and fence in the map: they all have to stop where a road, a crossing
|
|
## or a platform cuts through them, and a kerb that runs across a road is the
|
|
## sort of mistake that makes a whole street stop reading.
|
|
func _segments(a: float, b: float, gaps: Array) -> Array[Vector2]:
|
|
var sorted: Array = gaps.duplicate()
|
|
sorted.sort_custom(func(p, q): return p.x < q.x)
|
|
var out: Array[Vector2] = []
|
|
var cursor := a
|
|
for g in sorted:
|
|
if g.x > cursor:
|
|
out.append(Vector2(cursor, minf(g.x, b)))
|
|
cursor = maxf(cursor, g.y)
|
|
if cursor >= b:
|
|
break
|
|
if cursor < b:
|
|
out.append(Vector2(cursor, b))
|
|
var clean: Array[Vector2] = []
|
|
for s in out:
|
|
if s.y - s.x > 0.5:
|
|
clean.append(s)
|
|
return clean
|
|
|
|
|
|
# ── Materials ────────────────────────────────────────────────────────────────
|
|
#
|
|
# Named once here rather than inline, because the palette discipline only works
|
|
# if the same surface is the same colour everywhere. The ramp and shadow-tint
|
|
# choices ARE the art direction — see the note on each group.
|
|
|
|
func _build_materials() -> void:
|
|
var P := SakuraPalette
|
|
M = {
|
|
# Ground. Four bands rather than three: the road and the pavement are the
|
|
# largest continuous surfaces in the map, and a three-step ramp puts a
|
|
# visible hard bar across them wherever the sun rakes.
|
|
"road": LevelMaterials.cel(P.ROAD, LevelMaterials.RAMP_4, P.TINT),
|
|
"road_dark": LevelMaterials.cel(P.ROAD_DARK, LevelMaterials.RAMP_4, P.TINT),
|
|
"pave": LevelMaterials.cel(P.SIDEWALK, LevelMaterials.RAMP_4, P.TINT),
|
|
"pave_alt": LevelMaterials.cel(P.SIDEWALK_ALT, LevelMaterials.RAMP_4, P.TINT),
|
|
"kerb": LevelMaterials.cel(P.CURB, LevelMaterials.RAMP_3, P.TINT, "trim"),
|
|
"line_white": LevelMaterials.unlit(P.LINE_WHITE),
|
|
"line_yellow": LevelMaterials.unlit(P.LINE_YELLOW),
|
|
"tactile": LevelMaterials.cel(P.TACTILE, LevelMaterials.RAMP_2, P.TINT_WARM),
|
|
# The dots sit ON the yellow strip, so they are a shade deeper than it —
|
|
# at this size the shading alone cannot separate them from their ground.
|
|
"tactile_dot": LevelMaterials.cel(P.YELLOW_DEEP, LevelMaterials.RAMP_2, P.TINT_WARM),
|
|
"drain_dark": LevelMaterials.cel(P.DRAIN, LevelMaterials.RAMP_2, P.TINT_COOL),
|
|
"gravel": LevelMaterials.cel(P.GRAVEL, LevelMaterials.RAMP_3, P.TINT),
|
|
"ballast": LevelMaterials.cel(P.BALLAST, LevelMaterials.RAMP_3, P.TINT_COOL),
|
|
"dirt": LevelMaterials.cel(P.DIRT, LevelMaterials.RAMP_3, P.TINT_WARM),
|
|
"dirt_dark": LevelMaterials.unlit(P.STONE_WARM.darkened(0.16)),
|
|
"grass": LevelMaterials.cel(P.GRASS, LevelMaterials.RAMP_3, P.TINT_FOLIAGE),
|
|
|
|
# Concrete and stone.
|
|
"concrete": LevelMaterials.cel(P.CONCRETE, LevelMaterials.RAMP_3, P.TINT, "wall"),
|
|
"concrete_mid": LevelMaterials.cel(P.CONCRETE_MID, LevelMaterials.RAMP_3, P.TINT),
|
|
"concrete_dark": LevelMaterials.cel(P.CONCRETE_DARK, LevelMaterials.RAMP_3, P.TINT),
|
|
"trim_mat": LevelMaterials.cel(P.TRIM, LevelMaterials.RAMP_3, P.TINT),
|
|
"stone": LevelMaterials.cel(P.STONE, LevelMaterials.RAMP_3, P.TINT),
|
|
"stone_dark": LevelMaterials.cel(P.STONE_DARK, LevelMaterials.RAMP_3, P.TINT),
|
|
# Water is DRAWN, not lit. A cel painter puts a flat block in a pond;
|
|
# a lit surface at this scale reads as ice.
|
|
"water": LevelMaterials.unlit(P.WATER),
|
|
"wall_cream": LevelMaterials.cel(P.WALL_CREAM, LevelMaterials.RAMP_3,
|
|
P.TINT, "wall"),
|
|
"stone_warm": LevelMaterials.cel(P.STONE_WARM, LevelMaterials.RAMP_3, P.TINT_WARM),
|
|
|
|
# Metal. The coolest shadow tint in the map — this is what makes rail,
|
|
# fence and shutter read as metal in a renderer with no specular at all.
|
|
"metal": LevelMaterials.cel(P.METAL, LevelMaterials.RAMP_3, P.TINT_COOL),
|
|
"metal_dark": LevelMaterials.cel(P.METAL_DARK, LevelMaterials.RAMP_3, P.TINT_COOL),
|
|
"metal_warm": LevelMaterials.cel(P.METAL_WARM, LevelMaterials.RAMP_3, P.TINT_WARM),
|
|
"rail": LevelMaterials.cel(P.RAIL_METAL, LevelMaterials.RAMP_2, P.TINT_COOL),
|
|
"rail_head": LevelMaterials.cel(P.RAIL_HEAD, LevelMaterials.RAMP_2, P.TINT_COOL),
|
|
"sleeper": LevelMaterials.cel(P.SLEEPER, LevelMaterials.RAMP_2, P.TINT),
|
|
"shutter": LevelMaterials.cel(P.SHUTTER, LevelMaterials.RAMP_3, P.TINT_COOL, "panel"),
|
|
"cabinet": LevelMaterials.cel(P.CABINET, LevelMaterials.RAMP_3, P.TINT, "panel"),
|
|
"cabinet_top": LevelMaterials.cel(P.CABINET_TOP, LevelMaterials.RAMP_3, P.TINT),
|
|
"mirror_back": LevelMaterials.cel(P.MIRROR_BACK, LevelMaterials.RAMP_2, P.TINT_WARM),
|
|
|
|
# Timber. Warm tint: a cedar post pushed the full way to violet goes
|
|
# grey, and the shrine and the shopfronts are most of what says "here"
|
|
# rather than just "a town".
|
|
"wood": LevelMaterials.cel(P.SHRINE_WOOD, LevelMaterials.RAMP_3, P.TINT_WARM),
|
|
"wood_dark": LevelMaterials.cel(P.SHRINE_WOOD_DARK, LevelMaterials.RAMP_3, P.TINT_WARM),
|
|
"rope": LevelMaterials.cel(P.ROPE, LevelMaterials.RAMP_2, P.TINT_WARM),
|
|
|
|
# Roofs.
|
|
"roof_slate": LevelMaterials.cel(P.ROOF_SLATE, LevelMaterials.RAMP_3, P.TINT_COOL),
|
|
"roof_shrine": LevelMaterials.cel(P.SHRINE_ROOF, LevelMaterials.RAMP_3, P.TINT_COOL),
|
|
|
|
# Blossom. The high-key ramp, and it is not optional: on the standard
|
|
# ramp the shadow side of a canopy drops to 0.36 and a cherry tree turns
|
|
# into a storm cloud. SOFT3's darkest stop is 0.67, so the tree stays a
|
|
# pale pink mass whichever way it faces — which is the single most
|
|
# recognisable thing in the reference.
|
|
"blossom": LevelMaterials.cel(P.BLOSSOM, LevelMaterials.RAMP_SOFT3, P.TINT_WARM),
|
|
"blossom_light": LevelMaterials.cel(P.BLOSSOM_LIGHT, LevelMaterials.RAMP_SOFT, P.TINT_WARM),
|
|
"blossom_deep": LevelMaterials.cel(P.BLOSSOM_DEEP, LevelMaterials.RAMP_SOFT3, P.TINT_WARM),
|
|
"petal_ground": LevelMaterials.unlit(P.PETAL_DEEP),
|
|
"trunk": LevelMaterials.cel(P.TRUNK, LevelMaterials.RAMP_3, P.TINT_WARM),
|
|
"trunk_dark": LevelMaterials.cel(P.TRUNK_DARK, LevelMaterials.RAMP_3, P.TINT_WARM),
|
|
"leaf": LevelMaterials.cel(P.LEAF, LevelMaterials.RAMP_3, P.TINT_FOLIAGE),
|
|
"leaf_deep": LevelMaterials.cel(P.LEAF_DEEP, LevelMaterials.RAMP_3, P.TINT_FOLIAGE),
|
|
"cedar": LevelMaterials.cel(P.CEDAR, LevelMaterials.RAMP_3, P.TINT_FOLIAGE),
|
|
|
|
# Accents. Two bands, because these are small saturated objects whose
|
|
# silhouette does the work, and extra steps on them only muddy the hue.
|
|
"red": LevelMaterials.cel(P.RED, LevelMaterials.RAMP_2, P.TINT_WARM),
|
|
"torii": LevelMaterials.cel(P.TORII, LevelMaterials.RAMP_2, P.TINT_WARM),
|
|
"yellow": LevelMaterials.cel(P.GATE_YELLOW, LevelMaterials.RAMP_2, P.TINT_WARM),
|
|
"black": LevelMaterials.cel(P.GATE_BLACK, LevelMaterials.RAMP_2, P.TINT_COOL),
|
|
"teal": LevelMaterials.cel(P.TEAL, LevelMaterials.RAMP_2, P.TINT_COOL),
|
|
"blue": LevelMaterials.cel(P.BLUE, LevelMaterials.RAMP_2, P.TINT_COOL),
|
|
"vend_white": LevelMaterials.cel(P.VEND_WHITE, LevelMaterials.RAMP_3, P.TINT, "panel"),
|
|
"vend_red": LevelMaterials.cel(P.VEND_RED, LevelMaterials.RAMP_2, P.TINT_WARM),
|
|
"vend_teal": LevelMaterials.cel(P.VEND_TEAL, LevelMaterials.RAMP_2, P.TINT_COOL),
|
|
|
|
# Glass and lit panels are DRAWN, not lit. A cel painter puts a flat
|
|
# block of colour in a window; shading it is what makes stylised
|
|
# architecture read as a 3D model of a building.
|
|
"glass": LevelMaterials.unlit(P.GLASS),
|
|
"glass_dark": LevelMaterials.unlit(P.GLASS_DARK),
|
|
"lantern": LevelMaterials.unlit(P.LANTERN_LIT),
|
|
"signal_off": LevelMaterials.unlit(P.SIGNAL_OFF),
|
|
"signal_red": LevelMaterials.unlit(P.SIGNAL_RED),
|
|
"ink": LevelMaterials.unlit(P.INK),
|
|
"white_board": LevelMaterials.unlit(P.WALL_WHITE),
|
|
|
|
# Distant landscape. Unlit and pale: a hill two hundred metres out is
|
|
# atmosphere, and lighting it would put a band edge across the horizon.
|
|
"hill": LevelMaterials.unlit(P.HILL),
|
|
"hill_far": LevelMaterials.unlit(P.HILL_FAR),
|
|
}
|
|
|
|
|
|
func _wall_mat(i: int) -> Material:
|
|
return LevelMaterials.cel(
|
|
SakuraPalette.WALLS[i % SakuraPalette.WALLS.size()],
|
|
LevelMaterials.RAMP_3, SakuraPalette.TINT, "wall")
|
|
|
|
|
|
func _roof_mat(i: int) -> Material:
|
|
return LevelMaterials.cel(
|
|
SakuraPalette.ROOFS[i % SakuraPalette.ROOFS.size()],
|
|
LevelMaterials.RAMP_3, SakuraPalette.TINT_COOL)
|
|
|
|
|
|
# ── Trim and lettering ───────────────────────────────────────────────────────
|
|
#
|
|
# The second pass over this map, and the one that made it stop reading as a
|
|
# massing study. The first version had correct forms and correct colour and
|
|
# almost no SMALL structure: no sills, no downpipes, no meters, no manholes, and
|
|
# not one character of type anywhere in a Japanese suburb, which is a place made
|
|
# largely of lettering.
|
|
#
|
|
# Everything in this section is render-only and most of it is a box. That is the
|
|
# point — none of it needs to be clever, it needs to be THERE, because the third
|
|
# tier of visual structure is what the eye uses to judge whether a place is real.
|
|
|
|
|
|
## A sign board with lettering on it: the coloured panel, sized to its text, and
|
|
## the text a centimetre proud so it never z-fights the panel.
|
|
func _sign_board(centre: Vector3, facing: float, text: String, text_h: float,
|
|
board: Color, ink: Color, pad: float = 0.32) -> void:
|
|
var tw: float = SakuraSignage.text_width(text, text_h)
|
|
var yaw := 0.0 if facing > 0.0 else 180.0
|
|
_deco(_box_mesh(Vector3(tw + pad * 2.0, text_h + pad, 0.09)),
|
|
LevelMaterials.unlit(board), centre)
|
|
SakuraSignage.label(_decor, text, text_h, ink,
|
|
centre + Vector3(0, 0, facing * 0.06), Vector3(0, yaw, 0))
|
|
|
|
|
|
## A projecting side sign (袖看板): the box that stands off the wall, and the
|
|
## same vertical line of characters on both of its faces. These are what give a
|
|
## Japanese shopping street its vertical rhythm, and a row without them reads as
|
|
## a terrace of flat shopfronts.
|
|
func _vertical_sign(x: float, top_y: float, z: float, facing: float,
|
|
text: String, board: Color, ink: Color) -> void:
|
|
var ch := 0.30
|
|
var height: float = float(text.length()) * ch * 1.34 + 0.28
|
|
var depth := 0.9
|
|
var centre := Vector3(x, top_y - height * 0.5, z + facing * depth * 0.5)
|
|
_deco(_box_mesh(Vector3(0.1, height, depth)), LevelMaterials.unlit(board), centre)
|
|
# Both faces: the sign exists to be read from up and down the street.
|
|
for s in [-1.0, 1.0]:
|
|
SakuraSignage.vertical_label(_decor, text, ch, ink,
|
|
Vector3(x + s * 0.06, top_y - 0.24, centre.z),
|
|
Vector3(0, 90.0 * s, 0))
|
|
|
|
|
|
## A window sill and its lintel. Two boxes, and between them they are most of
|
|
## what stops a punched window reading as a sticker.
|
|
func _window_trim(centre: Vector3, w: float, h: float, facing: float,
|
|
mat: Material) -> void:
|
|
_deco(_box_mesh(Vector3(w + 0.22, 0.10, 0.20)), mat,
|
|
centre + Vector3(0, -h * 0.5 - 0.05, facing * 0.06))
|
|
_deco(_box_mesh(Vector3(w + 0.18, 0.08, 0.14)), mat,
|
|
centre + Vector3(0, h * 0.5 + 0.04, facing * 0.04))
|
|
|
|
|
|
## A rainwater downpipe with its brackets and a shoe at the bottom. Runs the full
|
|
## height of the building at a corner — a long vertical line on a broad flat
|
|
## facade, which is exactly the kind of small structure the first pass had none
|
|
## of.
|
|
func _downpipe(x: float, z: float, top: float, facing: float, mat: Material) -> void:
|
|
_deco(_cyl_mesh(0.055, 0.055, top - 0.3, 6), mat,
|
|
Vector3(x, (top - 0.3) * 0.5 + 0.2, z + facing * 0.08))
|
|
var brackets := maxi(2, int(top / 2.2))
|
|
for i in range(brackets):
|
|
_deco(_box_mesh(Vector3(0.16, 0.05, 0.10)), mat,
|
|
Vector3(x, 0.8 + float(i) * (top - 1.2) / float(brackets), z + facing * 0.03))
|
|
# The shoe kicks out at the bottom, which is the bit you actually notice.
|
|
_deco(_box_mesh(Vector3(0.11, 0.30, 0.11)), mat,
|
|
Vector3(x, 0.24, z + facing * 0.16), Vector3(facing * 22.0, 0, 0))
|
|
|
|
|
|
## A wall-mounted air-conditioning condenser and its pipe run. Ubiquitous, and
|
|
## it breaks up an upper storey the way nothing authored ever quite does.
|
|
func _ac_unit(pos: Vector3, facing: float) -> void:
|
|
_deco(_box_mesh(Vector3(0.78, 0.56, 0.30)), M["metal"],
|
|
pos + Vector3(0, 0, facing * 0.18))
|
|
_deco(_cyl_mesh(0.20, 0.20, 0.06, 10), M["metal_dark"],
|
|
pos + Vector3(0, 0, facing * 0.34), Vector3(90, 0, 0))
|
|
# Bracket under it, and the pipe run going back into the wall.
|
|
_deco(_box_mesh(Vector3(0.86, 0.06, 0.34)), M["metal_dark"],
|
|
pos + Vector3(0, -0.31, facing * 0.18))
|
|
_deco(_box_mesh(Vector3(0.09, 0.9, 0.09)), M["concrete_dark"],
|
|
pos + Vector3(0.46, -0.6, facing * 0.06))
|
|
|
|
|
|
## The meter cupboard by the door. Small, boxy, always slightly the wrong colour
|
|
## from the wall it is on.
|
|
func _meter_box(pos: Vector3, facing: float) -> void:
|
|
_deco(_box_mesh(Vector3(0.42, 0.56, 0.18)), M["cabinet"],
|
|
pos + Vector3(0, 0, facing * 0.11))
|
|
_deco(_box_mesh(Vector3(0.46, 0.06, 0.22)), M["cabinet_top"],
|
|
pos + Vector3(0, 0.31, facing * 0.11))
|
|
|
|
|
|
## An upper-storey balcony: a slab, a solid front panel and a thin capping rail.
|
|
func _balcony(cx: float, y: float, z: float, w: float, facing: float,
|
|
mat: Material) -> void:
|
|
var d := 1.05
|
|
_deco(_box_mesh(Vector3(w, 0.12, d)), mat, Vector3(cx, y, z + facing * d * 0.5))
|
|
_deco(_box_mesh(Vector3(w, 0.86, 0.08)), mat, Vector3(cx, y + 0.49, z + facing * d))
|
|
_deco(_box_mesh(Vector3(w + 0.06, 0.07, 0.16)), M["metal"],
|
|
Vector3(cx, y + 0.94, z + facing * d))
|
|
for s in [-1.0, 1.0]:
|
|
_deco(_box_mesh(Vector3(0.08, 0.86, d)), mat,
|
|
Vector3(cx + s * w * 0.5, y + 0.49, z + facing * d * 0.5))
|
|
|
|
|
|
## A manhole cover: a dark disc with a raised rim. Godot has no ring primitive,
|
|
## so it is two discs — the trick is that the rim is a hair PROUD of the road,
|
|
## because a manhole that sits flush reads as a painted circle.
|
|
func _manhole(x: float, z: float, y: float) -> void:
|
|
_deco(_cyl_mesh(0.36, 0.36, 0.04, 12), M["metal_dark"], Vector3(x, y + 0.02, z))
|
|
_deco(_cyl_mesh(0.30, 0.30, 0.05, 12), M["drain_dark"], Vector3(x, y + 0.035, z))
|
|
|
|
|
|
## A gully grate at the kerb line, drawn as a dark recess with bars across it.
|
|
func _drain_grate(x: float, z: float, y: float, along_x: bool) -> void:
|
|
var size := Vector3(0.7, 0.05, 0.34) if along_x else Vector3(0.34, 0.05, 0.7)
|
|
_deco(_box_mesh(size), M["drain_dark"], Vector3(x, y + 0.015, z))
|
|
for i in range(5):
|
|
var t := (float(i) - 2.0) * 0.11
|
|
var bar := Vector3(0.66, 0.03, 0.035) if along_x else Vector3(0.035, 0.03, 0.66)
|
|
var off := Vector3(0, 0, t) if along_x else Vector3(t, 0, 0)
|
|
_deco(_box_mesh(bar), M["metal_dark"], Vector3(x, y + 0.035, z) + off)
|
|
|
|
|
|
## Tactile paving (点字ブロック), as actual DOTS.
|
|
##
|
|
## The first pass drew these as a flat yellow rectangle, which is the one place
|
|
## in the map where a real, strongly patterned surface was replaced by a block of
|
|
## colour — and it is a surface every player walks over at the crossing and on
|
|
## the platform. A MultiMesh of a few hundred truncated cones costs one draw call
|
|
## and is the single highest detail-per-vertex thing in the level.
|
|
func _tactile_dots(centre: Vector2, size: Vector2, y: float) -> void:
|
|
var pitch := 0.15
|
|
var nx := maxi(1, int(size.x / pitch))
|
|
var nz := maxi(1, int(size.y / pitch))
|
|
var mm := MultiMesh.new()
|
|
mm.transform_format = MultiMesh.TRANSFORM_3D
|
|
mm.mesh = _cyl_mesh(0.052, 0.040, 0.035, 6)
|
|
mm.instance_count = nx * nz
|
|
var i := 0
|
|
for ix in range(nx):
|
|
for iz in range(nz):
|
|
var p := Vector3(
|
|
centre.x + (float(ix) - float(nx - 1) * 0.5) * pitch,
|
|
y + 0.018,
|
|
centre.y + (float(iz) - float(nz - 1) * 0.5) * pitch)
|
|
mm.set_instance_transform(i, Transform3D(Basis.IDENTITY, p))
|
|
i += 1
|
|
var mmi := MultiMeshInstance3D.new()
|
|
mmi.multimesh = mm
|
|
mmi.material_override = M["tactile_dot"]
|
|
_decor.add_child(mmi)
|
|
|
|
|
|
## Lettering painted on the road. Laid flat, and always read from the direction
|
|
## of travel, so `yaw` points it at the driver rather than at the map's north.
|
|
func _road_text(text: String, x: float, z: float, y: float, height: float,
|
|
colour: Color, yaw: float) -> void:
|
|
# `inked: false` — this is paint on tarmac, not an object. See
|
|
# SakuraSignage.label for what inking it did to the letterforms.
|
|
SakuraSignage.label(_decor, text, height, colour,
|
|
Vector3(x, y + 0.014, z), Vector3(-90, yaw, 0), false)
|
|
|
|
|
|
## A zebra crossing: bars across the carriageway, with a gap between each.
|
|
func _zebra(centre: Vector2, road_w: float, depth: float, along_x: bool,
|
|
y: float) -> void:
|
|
var bar := 0.55
|
|
var gap := 0.45
|
|
var n := int(road_w / (bar + gap))
|
|
for i in range(n):
|
|
var t := (float(i) - float(n - 1) * 0.5) * (bar + gap)
|
|
if along_x:
|
|
_paint(Vector2(centre.x + t, centre.y), Vector2(bar, depth), y, M["line_white"])
|
|
else:
|
|
_paint(Vector2(centre.x, centre.y + t), Vector2(depth, bar), y, M["line_white"])
|
|
|
|
|
|
## A large painted advertisement on the exposed flank wall of a terrace.
|
|
##
|
|
## Set vertically, because that is how a tall narrow gable is used and because
|
|
## 縦書き on a wall is one of the most immediately Japanese things in the map. A
|
|
## painted panel behind the type, a hairline border, and the whole thing sitting
|
|
## a few centimetres proud so the ink pass draws it as signage rather than as a
|
|
## stain on the render.
|
|
func _wall_sign(x: float, z: float, h: float, d: float, facing_x: float,
|
|
idx: int, front_facing: float = -1.0) -> void:
|
|
var text: String = SakuraSignage.WALL_ADS[idx % SakuraSignage.WALL_ADS.size()]
|
|
var ch: float = minf(0.95, (h - 2.4) / float(text.length()) / 1.34)
|
|
var panel_h: float = float(text.length()) * ch * 1.34 + 0.7
|
|
var panel_w := ch + 0.7
|
|
var yaw := 90.0 * facing_x
|
|
var col: Color = SakuraPalette.DRINKS[(idx * 7) % SakuraPalette.DRINKS.size()]
|
|
var ink: Color = SakuraPalette.INK if col.get_luminance() > 0.42 \
|
|
else SakuraPalette.WALL_WHITE
|
|
var top := h - 0.9
|
|
# Toward the FRONT of the building, following its shopfront. Fixed at
|
|
# `z - d*0.22` the far row's signs sat on the back half of each gable, out
|
|
# of sight of the street they were advertising to.
|
|
var cz := z + front_facing * d * 0.22
|
|
_deco(_box_mesh(Vector3(0.08, panel_h, panel_w)), LevelMaterials.unlit(col),
|
|
Vector3(x + facing_x * 0.05, top - panel_h * 0.5, cz))
|
|
_deco(_box_mesh(Vector3(0.05, panel_h + 0.22, panel_w + 0.22)),
|
|
LevelMaterials.unlit(SakuraPalette.WALL_WHITE),
|
|
Vector3(x + facing_x * 0.03, top - panel_h * 0.5, cz))
|
|
SakuraSignage.vertical_label(_decor, text, ch, ink,
|
|
Vector3(x + facing_x * 0.10, top - 0.55, cz), Vector3(0, yaw, 0))
|
|
|
|
|
|
## The street name plate bolted to a pole, lettered on both faces.
|
|
func _street_plate(pos: Vector3, text: String, yaw: float) -> void:
|
|
var h := 0.17
|
|
var tw: float = SakuraSignage.text_width(text, h)
|
|
_deco(_box_mesh(Vector3(tw + 0.24, h + 0.16, 0.05)), LevelMaterials.unlit(
|
|
SakuraPalette.WALL_WHITE), pos, Vector3(0, yaw, 0))
|
|
for s in [-1.0, 1.0]:
|
|
SakuraSignage.label(_decor, text, h, SakuraPalette.INK,
|
|
pos + Vector3(sin(deg_to_rad(yaw)) * 0.04 * s, 0, cos(deg_to_rad(yaw)) * 0.04 * s),
|
|
Vector3(0, yaw + (0.0 if s > 0.0 else 180.0), 0))
|
|
|
|
|
|
# ── Ground ───────────────────────────────────────────────────────────────────
|
|
|
|
func _build_ground() -> void:
|
|
# The base plate. Everything else is laid on top, so it is the one surface
|
|
# that has to be continuous. It reaches well past the boundary walls — see
|
|
# _build_outfield for why the edge of the world is not the edge of the
|
|
# collision.
|
|
# Grass, not the warm `dirt` it used to be. Once the map grew, the plate was
|
|
# the largest visible surface in every aerial and a pale ochre read as sand
|
|
# — the town looked like it had been dropped on a beach. Everything actually
|
|
# built sits above it, so this is only ever seen in the gaps, and turf is
|
|
# what is in the gaps of a Japanese suburb.
|
|
_slab(Vector2(0, 0), Vector2(HALF_X * 2 + OUTFIELD * 2, HALF_Z * 2 + OUTFIELD * 2),
|
|
0.0, M["grass"], "grass", 1.2)
|
|
|
|
# Roads: one north-south main street through the crossing, two east-west
|
|
# streets. The main street is drawn last and highest because it is the
|
|
# through route — see the note on the Y_ constants.
|
|
# The shopping street stops at SHOP_ST_X1: past that the railway is swinging
|
|
# north across where it would have run.
|
|
_slab(Vector2((-HALF_X + SHOP_ST_X1) * 0.5, SHOP_ST_Z),
|
|
Vector2(SHOP_ST_X1 + HALF_X, SHOP_ST_HALF * 2), Y_ROAD_EW,
|
|
M["road"], "concrete")
|
|
_slab(Vector2(0, SOUTH_ST_Z), Vector2(HALF_X * 2, SOUTH_ST_HALF * 2), Y_ROAD_EW,
|
|
M["road"], "concrete")
|
|
_slab(Vector2(0, 0), Vector2(ROAD_HALF * 2, HALF_Z * 2), Y_ROAD_NS,
|
|
M["road"], "concrete")
|
|
|
|
# Kerb and pavement either side of every road, gapped wherever another road,
|
|
# the railway, or the crossing cuts through. The kerb is a real 12 cm step
|
|
# you can hear and feel, which is most of what makes a street feel like a
|
|
# street rather than like a floor.
|
|
var ns_gaps := [
|
|
Vector2(-RAIL_HALF - 1.0, RAIL_HALF + 1.0),
|
|
Vector2(SHOP_ST_Z - SHOP_ST_HALF - PAVE_W - 0.3, SHOP_ST_Z + SHOP_ST_HALF + PAVE_W + 0.3),
|
|
Vector2(SOUTH_ST_Z - SOUTH_ST_HALF - PAVE_W - 0.3, SOUTH_ST_Z + SOUTH_ST_HALF + PAVE_W + 0.3),
|
|
# The east-district street is a T-junction into the main road. Leaving
|
|
# this out made the main-road kerb and pavement run straight across its
|
|
# mouth, so the neighbourhood road stopped behind a raised white strip.
|
|
Vector2(EAST_ST_Z - EAST_ST_HALF - PAVE_W - 0.3,
|
|
EAST_ST_Z + EAST_ST_HALF + PAVE_W + 0.3),
|
|
]
|
|
var ew_gaps := [Vector2(-ROAD_HALF - PAVE_W - 0.3, ROAD_HALF + PAVE_W + 0.3)]
|
|
_street_edges(false, 0.0, ROAD_HALF, -HALF_Z, HALF_Z, ns_gaps)
|
|
_street_edges(true, SHOP_ST_Z, SHOP_ST_HALF, -HALF_X, SHOP_ST_X1, ew_gaps)
|
|
_street_edges(true, SOUTH_ST_Z, SOUTH_ST_HALF, -HALF_X, HALF_X, ew_gaps)
|
|
|
|
# Markings. Broken centre lines on the through streets, a pair of stop bars
|
|
# short of the rails, and a zebra on the main street south of the crossing.
|
|
var z := -HALF_Z
|
|
while z < HALF_Z:
|
|
if absf(z) > RAIL_HALF + 2.5:
|
|
_paint(Vector2(0, z), Vector2(0.16, 3.0), Y_ROAD_NS, M["line_white"])
|
|
z += 6.0
|
|
var x := -HALF_X
|
|
while x < HALF_X:
|
|
if absf(x) > ROAD_HALF + 1.0:
|
|
if x < SHOP_ST_X1 - 2.0:
|
|
_paint(Vector2(x, SHOP_ST_Z), Vector2(3.0, 0.16), Y_ROAD_EW, M["line_white"])
|
|
_paint(Vector2(x, SOUTH_ST_Z), Vector2(3.0, 0.16), Y_ROAD_EW, M["line_white"])
|
|
x += 6.0
|
|
for s in [-1.0, 1.0]:
|
|
_paint(Vector2(ROAD_HALF * 0.5 * s, (RAIL_HALF + 2.0) * s),
|
|
Vector2(ROAD_HALF - 0.4, 0.4), Y_ROAD_NS, M["line_white"])
|
|
_build_road_markings()
|
|
|
|
|
|
## Everything painted on or set into the road surface.
|
|
##
|
|
## This is the tier of detail the first pass had none of, and the ground is half
|
|
## of every frame in a first-person game — a road with two dashed lines on it and
|
|
## nothing else is a corridor floor with a stripe.
|
|
func _build_road_markings() -> void:
|
|
# 止まれ on both approaches to the crossing, read from the direction of
|
|
# travel: northbound traffic reads it from the south side and vice versa.
|
|
for s in [-1.0, 1.0]:
|
|
_road_text(SakuraSignage.ROAD_STOP, -ROAD_HALF * 0.45 * s,
|
|
s * (RAIL_HALF + 6.2), Y_ROAD_NS, 1.5, SakuraPalette.LINE_WHITE,
|
|
0.0 if s > 0.0 else 180.0)
|
|
_road_text(SakuraSignage.ROAD_SLOW, -ROAD_HALF * 0.45 * s,
|
|
s * (RAIL_HALF + 13.0), Y_ROAD_NS, 1.1, SakuraPalette.LINE_WHITE,
|
|
0.0 if s > 0.0 else 180.0)
|
|
# Zebra crossings on the far side of each junction.
|
|
_zebra(Vector2(0, s * (SHOP_ST_Z if s < 0.0 else SOUTH_ST_Z) + s * 7.4),
|
|
ROAD_HALF * 2.0 - 0.8, 2.4, true, Y_ROAD_NS)
|
|
|
|
# Zebras across the two east-west streets at the main junction.
|
|
for z in [SHOP_ST_Z, SOUTH_ST_Z]:
|
|
for s in [-1.0, 1.0]:
|
|
_zebra(Vector2(s * (ROAD_HALF + 3.2), z), SHOP_ST_HALF * 2.0 - 0.8,
|
|
2.4, false, Y_ROAD_EW)
|
|
|
|
# Manholes and gullies. Manholes sit in the running lane; gullies sit tight
|
|
# against the kerb, which is the only place water goes.
|
|
var z := -HALF_Z + 14.0
|
|
while z < HALF_Z - 8.0:
|
|
if absf(z) > RAIL_HALF + 3.0:
|
|
_manhole(ROAD_HALF * -0.42, z, Y_ROAD_NS)
|
|
_drain_grate(ROAD_HALF - 0.42, z + 5.0, Y_ROAD_NS, false)
|
|
_drain_grate(-ROAD_HALF + 0.42, z + 5.0, Y_ROAD_NS, false)
|
|
z += 17.0
|
|
var x := -HALF_X + 12.0
|
|
while x < HALF_X - 8.0:
|
|
if absf(x) > ROAD_HALF + 3.0:
|
|
for street_z in [SHOP_ST_Z, SOUTH_ST_Z]:
|
|
_manhole(x, street_z + 1.4, Y_ROAD_EW)
|
|
_drain_grate(x + 6.0, street_z - SHOP_ST_HALF + 0.42, Y_ROAD_EW, true)
|
|
x += 21.0
|
|
|
|
# Kerb ramps at every corner of the main junction, plus their tactile pad —
|
|
# a dropped kerb with no 点字ブロック on it is the one detail a Japanese
|
|
# street never omits.
|
|
for sx in [-1.0, 1.0]:
|
|
for street_z in [SHOP_ST_Z, SOUTH_ST_Z]:
|
|
for sz in [-1.0, 1.0]:
|
|
var cx: float = sx * (ROAD_HALF + 1.3)
|
|
var cz: float = street_z + sz * (SHOP_ST_HALF + 1.3)
|
|
# Fill the complete corner quadrant. The old 2.35 m square ended
|
|
# 0.425 m before both long pavements, leaving every tactile pad on
|
|
# a visually isolated concrete island.
|
|
var apron_span := PAVE_W + 0.60
|
|
var apron_cx: float = sx * (ROAD_HALF + apron_span * 0.5)
|
|
var apron_cz: float = street_z + sz * (SHOP_ST_HALF + apron_span * 0.5)
|
|
var connector := _slab(Vector2(apron_cx, apron_cz),
|
|
Vector2(apron_span, apron_span), Y_PAVE, M["pave"], "concrete")
|
|
connector.name = "SidewalkConnector"
|
|
connector.set_meta("sidewalk_connector", true)
|
|
_tactile_dots(Vector2(cx, cz), Vector2(1.5, 1.5), Y_PAVE)
|
|
|
|
|
|
## Kerb + pavement along a road. `ew` picks the axis; `gaps` are runs to skip.
|
|
func _street_edges(ew: bool, centre: float, road_half: float,
|
|
a: float, b: float, gaps: Array) -> void:
|
|
for s in [-1.0, 1.0]:
|
|
var edge: float = centre + road_half * s
|
|
for seg in _segments(a, b, gaps):
|
|
var mid := (seg.x + seg.y) * 0.5
|
|
var length := seg.y - seg.x
|
|
var pave_c: float = edge + (PAVE_W * 0.5 + 0.12) * s
|
|
if ew:
|
|
_solid(Vector3(mid, 0.10, edge + 0.06 * s),
|
|
Vector3(length, 0.20, 0.12), M["kerb"], "concrete")
|
|
_slab(Vector2(mid, pave_c), Vector2(length, PAVE_W), Y_PAVE,
|
|
M["pave"], "concrete")
|
|
else:
|
|
_solid(Vector3(edge + 0.06 * s, 0.10, mid),
|
|
Vector3(0.12, 0.20, length), M["kerb"], "concrete")
|
|
_slab(Vector2(pave_c, mid), Vector2(PAVE_W, length), Y_PAVE,
|
|
M["pave"], "concrete")
|
|
|
|
|
|
# ── Railway ──────────────────────────────────────────────────────────────────
|
|
#
|
|
# The line is a POLYLINE, not an axis. Everything below walks that centreline
|
|
# and places itself relative to the local tangent and normal, so the ballast,
|
|
# the sleepers, the rails, the fence, the contact wire and the catenary all
|
|
# follow the turn without any of them knowing where the turn is.
|
|
#
|
|
# That rewrite is also what fixed the catenary. It used to be a single mast with
|
|
# a cantilever arm reaching HALF way across the corridor, and two insulators
|
|
# hung at the track centres — so one insulator sat under the arm, the other
|
|
# floated in clear air two metres past its end, and neither of them actually
|
|
# touched it (the arm sat at 6.86-6.94, the insulators at 6.49-6.83). It is now
|
|
# a portal: a mast each side, a beam over both tracks, droppers down from the
|
|
# beam, and a contact wire running the length of the line that the droppers
|
|
# land on. Nothing is left hanging.
|
|
|
|
var _rail_pts: Array = []
|
|
|
|
|
|
## The centreline, sampled at sleeper pitch: [Vector2 position, Vector2 tangent].
|
|
##
|
|
## Straight in from the west, an arc of CURVE_SWEEP degrees starting at
|
|
## CURVE_START_X, then straight out to the edge of the world on the new heading.
|
|
func _rail_path() -> Array:
|
|
if not _rail_pts.is_empty():
|
|
return _rail_pts
|
|
var step := 0.62
|
|
var pts: Array = []
|
|
var x := -HALF_X - OUTFIELD
|
|
while x < CURVE_START_X:
|
|
pts.append([Vector2(x, 0.0), Vector2(1.0, 0.0)])
|
|
x += step
|
|
# The arc. Centre is one radius to the north of where the turn begins, so
|
|
# the tangent at the start is still due east and the line bends toward -Z.
|
|
var c := Vector2(CURVE_START_X, -CURVE_RADIUS)
|
|
var sweep := deg_to_rad(CURVE_SWEEP)
|
|
var n := maxi(2, int(CURVE_RADIUS * sweep / step))
|
|
for i in range(n + 1):
|
|
var th := sweep * float(i) / float(n)
|
|
pts.append([c + Vector2(sin(th), cos(th)) * CURVE_RADIUS,
|
|
Vector2(cos(th), -sin(th))])
|
|
var ep: Vector2 = pts[pts.size() - 1][0]
|
|
var et: Vector2 = pts[pts.size() - 1][1]
|
|
var d := 0.0
|
|
while d < OUTFIELD + 70.0:
|
|
d += step
|
|
pts.append([ep + et * d, et])
|
|
_rail_pts = pts
|
|
return pts
|
|
|
|
|
|
## Yaw that turns a box's local +X onto the given tangent.
|
|
func _yaw_of(t: Vector2) -> float:
|
|
return rad_to_deg(atan2(-t.y, t.x))
|
|
|
|
|
|
## A solid box spanning exactly from `a` to `b`, `width` across and `h` tall,
|
|
## centred vertically on `y`. Length and heading come from the chord, so a run
|
|
## of these butts end to end however the line curves.
|
|
func _chord_solid(a: Vector2, b: Vector2, y: float, h: float, width: float,
|
|
mat: Material, acoustic: String) -> StaticBody3D:
|
|
var mid := (a + b) * 0.5
|
|
var d := b - a
|
|
return _solid(Vector3(mid.x, y, mid.y), Vector3(d.length(), h, width),
|
|
mat, acoustic, Vector3(0, _yaw_of(d.normalized()), 0))
|
|
|
|
|
|
## Collision-only twin of _chord_solid, for barriers whose visible form is a set
|
|
## of thin members. See _blocker.
|
|
func _chord_blocker(a: Vector2, b: Vector2, y: float, h: float, width: float,
|
|
acoustic: String = "metal") -> void:
|
|
var mid := (a + b) * 0.5
|
|
var d := b - a
|
|
_blocker(Vector3(mid.x, y, mid.y), Vector3(d.length(), h, width),
|
|
acoustic).rotation_degrees = Vector3(0, _yaw_of(d.normalized()), 0)
|
|
|
|
|
|
## Render-only twin of _chord_solid.
|
|
func _chord_deco(a: Vector2, b: Vector2, y: float, h: float, width: float,
|
|
mat: Material) -> void:
|
|
var mid := (a + b) * 0.5
|
|
var d := b - a
|
|
_deco(_box_mesh(Vector3(d.length(), h, width)), mat,
|
|
Vector3(mid.x, y, mid.y), Vector3(0, _yaw_of(d.normalized()), 0))
|
|
|
|
|
|
## Does this chord stay clear of the level crossing's road deck?
|
|
func _chord_clear_of_crossing(a: Vector2, b: Vector2) -> bool:
|
|
var mid := (a + b) * 0.5
|
|
return absf(mid.x) > ROAD_HALF + 0.9 or absf(mid.y) > 3.0
|
|
|
|
|
|
## Left-hand normal of a tangent, in the XZ plane.
|
|
func _normal_of(t: Vector2) -> Vector2:
|
|
return Vector2(-t.y, t.x)
|
|
|
|
|
|
## Is this stretch of lineside left open? The crossing for the road, the station
|
|
## for its platform, the footbridge for its piers.
|
|
func _rail_open(p: Vector2, side: float) -> bool:
|
|
if absf(p.y) < 3.0 and absf(p.x) < ROAD_HALF + 1.2:
|
|
return true
|
|
if absf(p.y) < 3.0 and absf(p.x - BRIDGE_X) < 2.2:
|
|
return true
|
|
if side > 0.0 and absf(p.y) < 3.0 and absf(p.x - STATION_X) < 17.0:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _build_railway() -> void:
|
|
var path := _rail_path()
|
|
var step := 0.62
|
|
|
|
# ── Ballast ──────────────────────────────────────────────────────────────
|
|
# Solid, because players walk on it, so it is one rotated body every few
|
|
# metres rather than a MultiMesh. Skipped through the crossing, where the
|
|
# road deck takes over.
|
|
# ── Segments are CHORDS, not fixed-length boxes ──────────────────────────
|
|
#
|
|
# A 6.2 m box dropped every 6.0 m along a centreline is fine on the
|
|
# straight and wrong on the curve: the box follows the TANGENT while the
|
|
# line follows the arc, so consecutive segments splay apart on the outside
|
|
# and drive into each other on the inside. Spanning each segment between two
|
|
# actual path samples makes its length and heading the chord itself, so they
|
|
# meet end to end all the way round the turn.
|
|
var bal_every := int(6.0 / step)
|
|
var i := 0
|
|
while i + bal_every < path.size():
|
|
var a: Vector2 = path[i][0]
|
|
var b: Vector2 = path[i + bal_every][0]
|
|
if _chord_clear_of_crossing(a, b):
|
|
_chord_solid(a, b, BALLAST_H - 0.25, 0.5, RAIL_HALF * 2.0 - 2.0,
|
|
M["ballast"], "gravel")
|
|
i += bal_every
|
|
|
|
# ── Sleepers ─────────────────────────────────────────────────────────────
|
|
_path_multimesh(_box_mesh(Vector3(0.22, 0.16, 2.3)),
|
|
M["sleeper"], path, 1, BALLAST_H + 0.04, [-TRACK_OFFSET, TRACK_OFFSET], 0.0)
|
|
|
|
# ── Rails ────────────────────────────────────────────────────────────────
|
|
# Two boxes per rail: a dark web and a bright head. At any distance a player
|
|
# sees them from, a rail IS a dark line with a light line on top of it.
|
|
var rail_every := 3 # ~1.9 m segments: smooth enough around a 60 m radius
|
|
var offs: Array = []
|
|
for tr in [-TRACK_OFFSET, TRACK_OFFSET]:
|
|
offs.append(tr - RAIL_GAUGE * 0.5)
|
|
offs.append(tr + RAIL_GAUGE * 0.5)
|
|
_path_multimesh(_box_mesh(Vector3(step * float(rail_every) + 0.05, 0.10, 0.07)),
|
|
M["rail"], path, rail_every, BALLAST_H + 0.13, offs, 0.0)
|
|
_path_multimesh(_box_mesh(Vector3(step * float(rail_every) + 0.05, 0.05, 0.09)),
|
|
M["rail_head"], path, rail_every, BALLAST_H + 0.20, offs, 0.0)
|
|
|
|
# ── Contact wire ─────────────────────────────────────────────────────────
|
|
# Continuous, above each track, at the height the portal droppers reach down
|
|
# to. This is the thing the old insulators were hanging next to nothing for
|
|
# want of.
|
|
_path_multimesh(_box_mesh(Vector3(step * float(rail_every) + 0.05, 0.05, 0.05)),
|
|
M["ink"], path, rail_every, 6.20, [-TRACK_OFFSET, TRACK_OFFSET], 0.0)
|
|
|
|
# ── Lineside fence ───────────────────────────────────────────────────────
|
|
_rail_fence(path, step)
|
|
|
|
# ── Catenary portals ─────────────────────────────────────────────────────
|
|
var portal_every := int(22.0 / step)
|
|
var j := portal_every
|
|
while j < path.size():
|
|
var p: Vector2 = path[j][0]
|
|
var t: Vector2 = path[j][1]
|
|
if absf(p.x) > ROAD_HALF + 4.0 and absf(p.x - BRIDGE_X) > 4.0 \
|
|
and absf(p.x - STATION_X) > 17.0:
|
|
_catenary_portal(p, t)
|
|
j += portal_every
|
|
|
|
|
|
## Lay one mesh repeatedly along the path, at a set of lateral offsets.
|
|
## Returns the instance count placed.
|
|
func _path_multimesh(mesh: Mesh, mat: Material, path: Array, every: int,
|
|
y: float, offsets: Array, _pad: float) -> int:
|
|
var xforms: Array = []
|
|
var i := 0
|
|
while i < path.size():
|
|
var p: Vector2 = path[i][0]
|
|
var t: Vector2 = path[i][1]
|
|
var nrm := _normal_of(t)
|
|
var basis := Basis(Vector3.UP, deg_to_rad(_yaw_of(t)))
|
|
for o in offsets:
|
|
var q: Vector2 = p + nrm * float(o)
|
|
xforms.append(Transform3D(basis, Vector3(q.x, y, q.y)))
|
|
i += every
|
|
var mm := MultiMesh.new()
|
|
mm.transform_format = MultiMesh.TRANSFORM_3D
|
|
mm.mesh = mesh
|
|
mm.instance_count = xforms.size()
|
|
for k in range(xforms.size()):
|
|
mm.set_instance_transform(k, xforms[k])
|
|
var mmi := MultiMeshInstance3D.new()
|
|
mmi.multimesh = mm
|
|
mmi.material_override = mat
|
|
_decor.add_child(mmi)
|
|
return xforms.size()
|
|
|
|
|
|
## Posts and two bars, with an invisible barrier doing the blocking.
|
|
##
|
|
## The physics and the picture are separated on purpose: one solid box the size
|
|
## of the whole fence is the right shape to stop a player and completely the
|
|
## wrong picture — an early build drew exactly that and walled the railway off
|
|
## behind a hundred and sixty metres of blank grey parapet.
|
|
func _rail_fence(path: Array, step: float) -> void:
|
|
var post_every := int(2.4 / step)
|
|
var block_every := int(6.0 / step)
|
|
for side in [-1.0, 1.0]:
|
|
var off: float = side * (RAIL_HALF - 0.4)
|
|
# Posts.
|
|
var posts: Array = []
|
|
var i := post_every / 2
|
|
while i < path.size():
|
|
var p: Vector2 = path[i][0]
|
|
var t: Vector2 = path[i][1]
|
|
if not _rail_open(p, side):
|
|
var q: Vector2 = p + _normal_of(t) * off
|
|
posts.append(Transform3D(Basis.IDENTITY, Vector3(q.x, 0.75, q.y)))
|
|
i += post_every
|
|
var mm := MultiMesh.new()
|
|
mm.transform_format = MultiMesh.TRANSFORM_3D
|
|
mm.mesh = _cyl_mesh(0.05, 0.05, 1.5, 6)
|
|
mm.instance_count = posts.size()
|
|
for k in range(posts.size()):
|
|
mm.set_instance_transform(k, posts[k])
|
|
var mmi := MultiMeshInstance3D.new()
|
|
mmi.multimesh = mm
|
|
mmi.material_override = M["metal_dark"]
|
|
_decor.add_child(mmi)
|
|
|
|
# Bars, and the barrier behind them — chord-spanned for the same reason
|
|
# the ballast is. Fixed 6.25 m bars stepped every 6.0 m along the arc
|
|
# were the fence "cutting into itself" on the curve: each bar ran along
|
|
# its own tangent, so on the inside of the turn they overlapped and
|
|
# speared through one another.
|
|
var j := 0
|
|
while j + block_every < path.size():
|
|
var pa: Vector2 = path[j][0]
|
|
var pb: Vector2 = path[j + block_every][0]
|
|
var qa: Vector2 = pa + _normal_of(path[j][1]) * off
|
|
var qb: Vector2 = pb + _normal_of(path[j + block_every][1]) * off
|
|
if not _rail_open((pa + pb) * 0.5, side):
|
|
_chord_blocker(qa, qb, 0.75, 1.5, 0.10)
|
|
for bar_y in [0.62, 1.42]:
|
|
_chord_deco(qa, qb, bar_y, 0.07, 0.07, M["metal_dark"])
|
|
j += block_every
|
|
|
|
|
|
## A catenary portal: a mast each side, a beam across both tracks, and a dropper
|
|
## from the beam down to the contact wire above each track.
|
|
##
|
|
## Everything here TOUCHES something. The previous version's insulators were
|
|
## placed at the track centres while the arm that was supposed to carry them
|
|
## reached only half way across the corridor, so one of them hung unattached in
|
|
## mid-air — which is exactly what it looked like.
|
|
func _catenary_portal(p: Vector2, t: Vector2) -> void:
|
|
var nrm := _normal_of(t)
|
|
var yaw := _yaw_of(t)
|
|
var reach := RAIL_HALF - 1.2
|
|
var beam_y := 7.10
|
|
var wire_y := 6.20
|
|
|
|
for side in [-1.0, 1.0]:
|
|
var q: Vector2 = p + nrm * (reach * side)
|
|
_post(Vector3(q.x, 3.6, q.y), 0.11, 7.2, M["metal_dark"], "metal", 6)
|
|
|
|
# The beam, spanning mast to mast. Its length is the full corridor, not half
|
|
# of it, so both tracks are actually under it.
|
|
_deco(_box_mesh(Vector3(0.14, 0.16, reach * 2.0 + 0.3)), M["metal_dark"],
|
|
Vector3(p.x, beam_y, p.y), Vector3(0, yaw, 0))
|
|
|
|
# Droppers, from the beam down to the wire, with the registration insulator
|
|
# at the bottom sitting ON the wire rather than beside it.
|
|
for tr in [-TRACK_OFFSET, TRACK_OFFSET]:
|
|
var q: Vector2 = p + nrm * tr
|
|
var drop := beam_y - wire_y
|
|
_deco(_box_mesh(Vector3(0.06, drop, 0.06)), M["metal_dark"],
|
|
Vector3(q.x, wire_y + drop * 0.5, q.y), Vector3(0, yaw, 0))
|
|
_deco(_box_mesh(Vector3(0.13, 0.22, 0.13)), M["metal"],
|
|
Vector3(q.x, wire_y + 0.13, q.y), Vector3(0, yaw, 0))
|
|
|
|
|
|
|
|
# ── The crossing ─────────────────────────────────────────────────────────────
|
|
|
|
func _build_crossing() -> void:
|
|
# The road surface over the rails: a concrete panel deck flush with the rail
|
|
# heads, with the flangeway gaps drawn as dark slots. This is the map's
|
|
# centre and the thing the place is named for, so it is the most detailed
|
|
# square metre in it.
|
|
var deck_w := ROAD_HALF * 2 + 1.2
|
|
_slab(Vector2(0, 0), Vector2(deck_w, RAIL_HALF * 2 - 1.6), CROSS_Y,
|
|
M["concrete_mid"], "concrete", 0.5)
|
|
|
|
# Approach ramps. Without these the deck is a 28 cm wall across the road:
|
|
# real crossings rise to meet the rails and so does this one, over three and
|
|
# a half metres, which is about five degrees and invisible.
|
|
var rise := CROSS_Y - Y_ROAD_NS
|
|
var run := 3.5
|
|
var ramp_len := sqrt(rise * rise + run * run)
|
|
var ramp_a := rad_to_deg(atan2(rise, run))
|
|
for s in [-1.0, 1.0]:
|
|
var zc: float = s * (RAIL_HALF - 0.8 + run * 0.5)
|
|
_solid(Vector3(0, Y_ROAD_NS + rise * 0.5 - 0.1, zc),
|
|
Vector3(deck_w, 0.2, ramp_len), M["concrete_mid"], "concrete",
|
|
Vector3(ramp_a * s, 0, 0))
|
|
|
|
for t in [-TRACK_OFFSET, TRACK_OFFSET]:
|
|
for r in [-RAIL_GAUGE * 0.5, RAIL_GAUGE * 0.5]:
|
|
_paint(Vector2(0, t + r), Vector2(deck_w, 0.10), CROSS_Y, M["ink"])
|
|
_deco(_box_mesh(Vector3(deck_w, 0.05, 0.09)), M["rail_head"],
|
|
Vector3(0, CROSS_Y - 0.01, t + r))
|
|
|
|
# Tactile paving where the pavement meets the track — the one saturated
|
|
# yellow at ground level, and it reads from right across the map. The strip
|
|
# is the ground colour; the DOTS on it are what make it 点字ブロック rather
|
|
# than a painted band.
|
|
for s in [-1.0, 1.0]:
|
|
_paint(Vector2(0, s * (RAIL_HALF + 1.0)), Vector2(ROAD_HALF * 2, 0.7),
|
|
Y_ROAD_NS, M["tactile"])
|
|
_tactile_dots(Vector2(0, s * (RAIL_HALF + 1.0)), Vector2(ROAD_HALF * 2 - 0.2, 0.6),
|
|
Y_ROAD_NS + 0.02)
|
|
# とまれ, right at the rails, which is where it is actually painted.
|
|
_road_text(SakuraSignage.CROSSING_STOP, ROAD_HALF * 0.45 * s,
|
|
s * (RAIL_HALF + 3.0), Y_ROAD_NS, 1.05, SakuraPalette.LINE_WHITE,
|
|
0.0 if s > 0.0 else 180.0)
|
|
|
|
# Crossing gear on all four corners: mast, barrier boom, alarm head, X sign.
|
|
for sx in [-1.0, 1.0]:
|
|
for sz in [-1.0, 1.0]:
|
|
_crossing_gear(
|
|
Vector3(sx * (ROAD_HALF + 0.9), Y_ROAD_NS, sz * (RAIL_HALF + 0.9)), sx, sz)
|
|
|
|
# Signal relay cabinets. Chest high, hard, and placed so they give the only
|
|
# real cover inside the crossing — the map's most important piece of
|
|
# gameplay furniture, and also a thing that is genuinely there.
|
|
for sx in [-1.0, 1.0]:
|
|
for sz in [-1.0, 1.0]:
|
|
var c := Vector3(sx * (ROAD_HALF + 3.6), Y_ROAD_NS, sz * (RAIL_HALF + 2.0))
|
|
_solid(c + Vector3(0, 0.62, 0), Vector3(1.5, 1.24, 0.75), M["cabinet"], "metal")
|
|
_deco(_box_mesh(Vector3(1.62, 0.09, 0.87)), M["cabinet_top"],
|
|
c + Vector3(0, 1.28, 0))
|
|
|
|
_spawn_points.append(Vector3(0, 1.6, -RAIL_HALF - 7.0))
|
|
_spawn_points.append(Vector3(0, 1.6, RAIL_HALF + 7.0))
|
|
|
|
|
|
func _crossing_gear(base: Vector3, sx: float, sz: float) -> void:
|
|
_post(base + Vector3(0, 1.6, 0), 0.10, 3.2, M["black"], "metal", 6)
|
|
|
|
# The boom, down across the road, striped. Alternating boxes rather than a
|
|
# texture, because the whole map is untextured and a painted stripe would be
|
|
# the only thing in it that was not geometry.
|
|
var boom_len := ROAD_HALF + 1.4
|
|
var stripes := 7
|
|
var seg := boom_len / float(stripes)
|
|
for i in range(stripes):
|
|
var mat: Material = M["yellow"] if i % 2 == 0 else M["black"]
|
|
_deco(_box_mesh(Vector3(seg, 0.14, 0.10)), mat,
|
|
Vector3(base.x - sx * (seg * (float(i) + 0.5)), base.y + 1.05, base.z))
|
|
_deco(_box_mesh(Vector3(0.36, 0.36, 0.30)), M["black"],
|
|
base + Vector3(sx * 0.24, 1.05, 0))
|
|
|
|
# The alarm head: two lamps and the X. One lamp lit and one dark — a
|
|
# crossing alarm alternates, and freezing it mid-alternation is what makes a
|
|
# still frame of it look like a frame of animation rather than like a model.
|
|
var head := base + Vector3(0, 3.0, 0)
|
|
_deco(_box_mesh(Vector3(0.9, 0.22, 0.14)), M["black"], head)
|
|
_deco(_cyl_mesh(0.16, 0.16, 0.10, 8), M["signal_red"],
|
|
head + Vector3(-0.3, 0, -sz * 0.1), Vector3(90, 0, 0))
|
|
_deco(_cyl_mesh(0.16, 0.16, 0.10, 8), M["signal_off"],
|
|
head + Vector3(0.3, 0, -sz * 0.1), Vector3(90, 0, 0))
|
|
for a in [42.0, -42.0]:
|
|
_deco(_box_mesh(Vector3(1.15, 0.13, 0.06)), M["yellow"],
|
|
head + Vector3(0, 0.62, -sz * 0.12), Vector3(0, 0, a))
|
|
# 踏切注意 on a plate under the alarm head, facing the road it warns.
|
|
_sign_board(base + Vector3(0, 2.34, -sz * 0.13), -sz,
|
|
SakuraSignage.CROSSING_WARN, 0.20, SakuraPalette.GATE_YELLOW,
|
|
SakuraPalette.INK, 0.12)
|
|
# The reflective bands every crossing mast carries, low down where a car's
|
|
# lights catch them.
|
|
for i in range(3):
|
|
_deco(_box_mesh(Vector3(0.23, 0.13, 0.23)), M["white_board"],
|
|
base + Vector3(0, 0.42 + float(i) * 0.42, 0))
|
|
|
|
|
|
# ── The shopping street ──────────────────────────────────────────────────────
|
|
|
|
func _build_shopping_street() -> void:
|
|
# Two rows of shophouses facing each other across a 9 m street. The railway
|
|
# row also fronts the alley behind it, so it is the only building line in
|
|
# the map read from both sides — which is why it carries the fire escapes,
|
|
# and why its roofline is worth taking.
|
|
# The railway row is DOUBLE-FRONTED: shopfronts on the shopping street AND
|
|
# on the railway side.
|
|
#
|
|
# It was single-fronted first, and the render showed why that could not
|
|
# stand: the view north from the crossing — the map's signature shot, the
|
|
# one the whole place is arranged around — was a hundred and sixty metres of
|
|
# blank rear wall. A block between a station and a shopping street trades on
|
|
# both faces in real life for exactly the reason it has to here, which is
|
|
# that both sides are where the people are.
|
|
var pave_edge := SHOP_ST_HALF + PAVE_W + 0.12
|
|
_shop_row(SHOP_ST_Z + pave_edge, -1.0, true) # railway block
|
|
_shop_row(SHOP_ST_Z - pave_edge, 1.0, false) # far side
|
|
|
|
# The service alley between the railway row and the line. Split around the
|
|
# main street, which crosses it.
|
|
# ── The alley ends with the shops it serves ──────────────────────────────
|
|
#
|
|
# It used to run the full width of the map, and the railway's turn crosses
|
|
# z = -11.2 at x = 96.9 — so the far end of the alley ran out under the
|
|
# tracks. It serves the shop row, the shop row stops at SHOP_ST_X1, and so
|
|
# does this: no walkway, nothing to bridge.
|
|
for seg in _segments(-HALF_X, SHOP_ST_X1 + 4.0,
|
|
[Vector2(-ROAD_HALF - 0.2, ROAD_HALF + 0.2)]):
|
|
_slab(Vector2((seg.x + seg.y) * 0.5, ALLEY_Z), Vector2(seg.y - seg.x, ALLEY_W),
|
|
Y_GRASS, M["concrete_mid"], "concrete")
|
|
|
|
# ── The bridge approach ──────────────────────────────────────────────────
|
|
#
|
|
# The gap the two rows leave at BRIDGE_X is made into a real side street,
|
|
# running from the shopping street through to the alley and the footbridge
|
|
# stair. Without it that gap is just a hole in a terrace; with it the bridge
|
|
# has an honest way on and off from the north, and the shopping street gains
|
|
# a second connection to the line.
|
|
# North end first: the shopping street sits at a MORE negative Z than the
|
|
# alley, so taking them in the other order gave the slab a negative length.
|
|
var app_z0 := SHOP_ST_Z + SHOP_ST_HALF + PAVE_W
|
|
var app_z1 := ALLEY_Z + ALLEY_W * 0.5
|
|
_slab(Vector2(BRIDGE_X, (app_z0 + app_z1) * 0.5), Vector2(7.0, app_z1 - app_z0),
|
|
Y_GRASS, M["pave_alt"], "concrete")
|
|
# A kerb each side, so it reads as a street rather than as a missing tooth.
|
|
for s in [-1.0, 1.0]:
|
|
_solid(Vector3(BRIDGE_X + s * 3.55, Y_GRASS + 0.06,
|
|
(app_z0 + app_z1) * 0.5),
|
|
Vector3(0.12, 0.24, app_z1 - app_z0), M["kerb"], "concrete")
|
|
_spawn_points.append(Vector3(BRIDGE_X, 1.6, ALLEY_Z - 4.0))
|
|
|
|
_spawn_points.append(Vector3(-26, 1.6, ALLEY_Z))
|
|
_spawn_points.append(Vector3(30, 1.6, ALLEY_Z))
|
|
_spawn_points.append(Vector3(-10, 1.6, SHOP_ST_Z))
|
|
_spawn_points.append(Vector3(28, 1.6, SHOP_ST_Z))
|
|
|
|
|
|
## `facing` is +1 if the shopfronts look toward +Z, -1 if they look toward -Z.
|
|
##
|
|
## ── Unit width is the whole character of the street ─────────────────────────
|
|
##
|
|
## The first version divided the row into EIGHT units, which at this map's
|
|
## length made each shop 19.5 m wide — and a 19.5 m shop is not a shop, it is a
|
|
## department store. The render showed the consequence plainly: one awning, one
|
|
## sign and one noren stretched across sixty feet of frontage, so the street read
|
|
## as four enormous buildings rather than as a 商店街, and the noren in
|
|
## particular spanned the entire elevation like a stage curtain.
|
|
##
|
|
## A real shopping street is made of SMALL businesses — six to ten metres of
|
|
## frontage each — and the rhythm of many narrow units is most of what the place
|
|
## is. Twenty units a side instead of eight means twenty awning colours, twenty
|
|
## names, twenty projecting signs and twenty doorways, which is where all the
|
|
## detail in this kind of street actually lives.
|
|
func _shop_row(front_z: float, facing: float, double_fronted: bool) -> void:
|
|
var depth := 10.0
|
|
|
|
# Lay the units out first, so each one can be told whether its neighbours
|
|
# exist. A unit with a missing neighbour turns a blank flank wall to the
|
|
# street, and those walls need a painted sign — see _wall_sign.
|
|
var units: Array = [] # [cx, w, present]
|
|
var x := -HALF_X + 4.0
|
|
while x < SHOP_ST_X1 - 8.0:
|
|
var unit := _rng.randf_range(6.0, 10.0)
|
|
var cx := x + unit * 0.5
|
|
# ── Two gaps in the terrace ──────────────────────────────────────────
|
|
#
|
|
# The crossing approach, and — new — the FOOTBRIDGE approach. The
|
|
# railway row used to run straight past the bridge, and its fire escapes
|
|
# land in the same service alley the bridge's north stair descends into:
|
|
# the two intersected, so the way off the bridge ran through somebody's
|
|
# staircase. Twelve metres of clearance is set by reach, not by looks —
|
|
# a fire escape extends about six metres back from its unit and the
|
|
# stair run is seven, so anything closer than that overlaps again.
|
|
var clear := absf(cx) > ROAD_HALF + 4.0
|
|
if double_fronted and absf(cx - BRIDGE_X) < 12.0:
|
|
clear = false
|
|
elif not double_fronted and absf(cx - BRIDGE_X) < 8.0:
|
|
clear = false
|
|
units.append([cx, unit - 0.35, clear])
|
|
x += unit
|
|
|
|
for i in range(units.size()):
|
|
if not units[i][2]:
|
|
continue
|
|
var prev_gone: bool = i == 0 or not units[i - 1][2]
|
|
var next_gone: bool = i == units.size() - 1 or not units[i + 1][2]
|
|
# The body sits BEHIND its shopfront, i.e. against -facing. The 0.35 m
|
|
# taken off the width is the party-wall joint between neighbours, and
|
|
# the ink pass draws it as the line that separates one business from
|
|
# the next.
|
|
_shop(Vector3(units[i][0], 0.0, front_z - facing * depth * 0.5),
|
|
units[i][1], depth, facing, i, double_fronted, prev_gone, next_gone)
|
|
|
|
|
|
## The shop generator. One function, and the variety comes out of a handful of
|
|
## numbers the way the reference's `makeShop` does: storeys, wall tone, awning
|
|
## colour, whether it has a noren, a roof tank, a fire escape.
|
|
func _shop(centre: Vector3, w: float, d: float, facing: float, idx: int,
|
|
double_fronted: bool = false, expose_minus_x: bool = false,
|
|
expose_plus_x: bool = false) -> void:
|
|
var storeys := 2 if _rng.randf() < 0.68 else 3
|
|
var h := float(storeys) * FLOOR_H
|
|
|
|
# Flank walls that face the street get a painted sign. These are the biggest
|
|
# blank surfaces in the map without one.
|
|
if expose_minus_x:
|
|
_wall_sign(centre.x - w * 0.5, centre.z, h, d, -1.0, idx, facing)
|
|
if expose_plus_x:
|
|
_wall_sign(centre.x + w * 0.5, centre.z, h, d, 1.0, idx + 3, facing)
|
|
|
|
# The body. Its top face IS the roof deck — nothing extra to stand on.
|
|
_solid(centre + Vector3(0, h * 0.5, 0), Vector3(w, h, d), _wall_mat(idx), "concrete")
|
|
|
|
# Parapet on all four edges. This is what makes a roof a place you can fight
|
|
# from rather than a plane you slide off, so it is a real collider.
|
|
for s in [-1.0, 1.0]:
|
|
_solid(centre + Vector3(0, h + 0.35, s * (d * 0.5 - 0.12)),
|
|
Vector3(w + 0.3, 0.7, 0.25), _roof_mat(idx), "concrete")
|
|
_solid(centre + Vector3(s * (w * 0.5 - 0.12), h + 0.35, 0),
|
|
Vector3(0.25, 0.7, d), _roof_mat(idx), "concrete")
|
|
|
|
# Every third unit is a SERVICE unit: no rear shopfront, a fire escape
|
|
# instead. That is what gives the row its way onto the roof, and it is also
|
|
# why the rear elevation is not simply the front one mirrored — a terrace
|
|
# where every single unit trades on both faces has no back of house at all,
|
|
# which is the sort of too-tidy that reads as generated.
|
|
# Every third unit, AND every three-storey unit whatever its index.
|
|
#
|
|
# The frequency used to be index-only, and the walkability probe showed what
|
|
# that cost: the tall roofs came out as the biggest unreachable islands in
|
|
# the map, because a 10.2 m roof is three and a half metres above its
|
|
# two-storey neighbours and nothing else in the row can climb that. A block
|
|
# that stands a storey over the street should be the best position on it,
|
|
# not scenery.
|
|
var rear_service := idx % 3 == 1 or storeys == 3
|
|
_shop_facade(centre.x, centre.z + facing * d * 0.5, facing, w, h, storeys, idx)
|
|
if double_fronted and not rear_service:
|
|
# Offset the index so the two faces are not the same shop drawn twice.
|
|
_shop_facade(centre.x, centre.z - facing * d * 0.5, -facing, w, h, storeys,
|
|
idx + 4)
|
|
|
|
# Roof clutter — a water tank, an AC unit, a vent stack. Half the reason a
|
|
# roofline is worth being on is that there is something up there to stand
|
|
# behind.
|
|
if _rng.randf() < 0.7:
|
|
_solid(centre + Vector3(w * 0.2, h + 1.1, facing * d * 0.15),
|
|
Vector3(1.8, 1.4, 1.6), M["metal"], "metal")
|
|
_deco(_cyl_mesh(0.14, 0.14, 1.2, 6), M["metal_dark"],
|
|
centre + Vector3(w * 0.2 - 0.7, h + 1.9, facing * d * 0.15))
|
|
_solid(centre + Vector3(-w * 0.25, h + 0.5, -facing * d * 0.2),
|
|
Vector3(1.2, 0.9, 0.9), M["metal_dark"], "metal")
|
|
|
|
# The way up, on the service unit's back wall — the alley for the railway
|
|
# row, the rear lane for the far one — so the climb onto the roofline is
|
|
# never made in view of the street that roofline overlooks.
|
|
if rear_service:
|
|
_fire_escape(Vector3(centre.x + w * 0.5 - 1.0, Y_GRASS,
|
|
centre.z - facing * d * 0.5), h, -facing)
|
|
# A roof spawn every few units, not on every one. With forty shops a row a
|
|
# spawn per roof would make the rooflines four fifths of the spawn table and
|
|
# nearly every round would open with everyone above the street.
|
|
if idx % 5 == 2:
|
|
_spawn_points.append(Vector3(centre.x, h + 1.4, centre.z))
|
|
|
|
|
|
## One elevation of a shop: the glazed bay, the awning and its valance, an
|
|
## optional noren, a corner signboard, and a fascia band plus window band per
|
|
## upper storey.
|
|
##
|
|
## `facing` is OUTWARD — away from the building, toward the street — and every
|
|
## offset in here is `front + facing * k`. That one convention is what lets the
|
|
## same function draw the shopping-street elevation and the railway elevation of
|
|
## the same block without a second code path.
|
|
func _shop_facade(cx: float, front: float, facing: float, w: float, h: float,
|
|
storeys: int, idx: int) -> void:
|
|
var bay_w := w - 1.4
|
|
_deco(_box_mesh(Vector3(bay_w, 2.5, 0.1)), M["glass"],
|
|
Vector3(cx, 1.45, front + facing * 0.06))
|
|
# The shopfront frame: a head member, a stallriser under the glass, and two
|
|
# mullions. A glazed bay with no frame is a hole in a wall.
|
|
_deco(_box_mesh(Vector3(bay_w + 0.3, 0.3, 0.2)), M["wood_dark"],
|
|
Vector3(cx, 2.85, front + facing * 0.08))
|
|
_deco(_box_mesh(Vector3(bay_w + 0.24, 0.44, 0.16)), M["wood_dark"],
|
|
Vector3(cx, 0.30, front + facing * 0.07))
|
|
for s in [-1.0, 1.0]:
|
|
_deco(_box_mesh(Vector3(0.12, 2.5, 0.14)), M["wood_dark"],
|
|
Vector3(cx + s * bay_w * 0.5, 1.45, front + facing * 0.07))
|
|
_deco(_box_mesh(Vector3(0.10, 2.5, 0.11)), M["wood"],
|
|
Vector3(cx + s * bay_w * 0.22, 1.45, front + facing * 0.05))
|
|
# The shutter box over the bay — every one of these shops has one, and it is
|
|
# the deepest horizontal shadow line on the ground floor.
|
|
_deco(_box_mesh(Vector3(bay_w + 0.42, 0.34, 0.3)), M["shutter"],
|
|
Vector3(cx, 3.18, front + facing * 0.13))
|
|
|
|
# The awning is the row's colour rhythm and the thing that makes the street
|
|
# read as 商店街 rather than as a terrace. Sloped — a flat one reads as a
|
|
# shelf — with a valance hanging off its front edge.
|
|
var awn: Color = SakuraPalette.AWNINGS[(idx * 3 + 1) % SakuraPalette.AWNINGS.size()]
|
|
var awn_mat := LevelMaterials.cel(awn, LevelMaterials.RAMP_2, SakuraPalette.TINT_WARM)
|
|
# Rotating about X by +θ drops the +Z end, so the slope has to follow the
|
|
# facing or half the awnings in the map tilt back into their own shopfronts.
|
|
_deco(_box_mesh(Vector3(bay_w + 0.6, 0.12, 2.0)), awn_mat,
|
|
Vector3(cx, 3.15, front + facing * 0.9), Vector3(facing * 14.0, 0, 0))
|
|
_deco(_box_mesh(Vector3(bay_w + 0.6, 0.42, 0.06)), awn_mat,
|
|
Vector3(cx, 2.86, front + facing * 1.86))
|
|
if idx % 5 != 2:
|
|
var noren: Color = [SakuraPalette.NOREN, SakuraPalette.NOREN_RED,
|
|
SakuraPalette.NOREN_CREAM][idx % 3]
|
|
var noren_ink: Color = SakuraPalette.NOREN_CREAM if noren != SakuraPalette.NOREN_CREAM \
|
|
else SakuraPalette.INK
|
|
# A noren hangs over the DOORWAY and is about 1.5 m across — it is not
|
|
# sized to the shopfront. Scaling it to the bay made it a stage curtain
|
|
# spanning the whole elevation, which is the single most conspicuous
|
|
# thing wrong with the first render of this street.
|
|
var nor_x := cx - bay_w * 0.5 + 1.1
|
|
for j in range(3):
|
|
_deco(_box_mesh(Vector3(0.46, 0.95, 0.03)),
|
|
LevelMaterials.unlit(noren, true),
|
|
Vector3(nor_x + (float(j) - 1.0) * 0.5, 2.28, front + facing * 0.12))
|
|
# A noren carries one or two characters, never a whole shop name.
|
|
var nt: String = SakuraSignage.NOREN_TEXT[idx % SakuraSignage.NOREN_TEXT.size()]
|
|
SakuraSignage.label(_decor, nt, 0.20, noren_ink,
|
|
Vector3(nor_x, 2.34, front + facing * 0.15),
|
|
Vector3(0, 0.0 if facing > 0.0 else 180.0, 0))
|
|
|
|
# ── The lettering ────────────────────────────────────────────────────────
|
|
#
|
|
# The fascia name board and the projecting side sign. Between them these are
|
|
# the loudest thing on a Japanese shopping street and the single largest
|
|
# missing piece from the first pass of this map — a 商店街 without type on it
|
|
# is a terrace, and the row read as one.
|
|
var name: String = SakuraSignage.SHOP_NAMES[idx % SakuraSignage.SHOP_NAMES.size()]
|
|
var sign_col: Color = SakuraPalette.DRINKS[(idx * 5) % SakuraPalette.DRINKS.size()]
|
|
var sign_ink: Color = SakuraPalette.INK if sign_col.get_luminance() > 0.42 \
|
|
else SakuraPalette.WALL_WHITE
|
|
# Tight padding and large type: on a real fascia the lettering fills the
|
|
# board almost edge to edge, and a small line floating in a big coloured
|
|
# rectangle reads as a placeholder.
|
|
_sign_board(Vector3(cx, 3.74, front + facing * 0.12), facing, name, 0.46,
|
|
sign_col, sign_ink, 0.16)
|
|
|
|
# The 袖看板, standing off the corner of the facade.
|
|
var vert: String = SakuraSignage.SHOP_VERTICAL[idx % SakuraSignage.SHOP_VERTICAL.size()]
|
|
_vertical_sign(cx + w * 0.5 - 0.55, h - 0.5, front + facing * 0.12,
|
|
facing, vert, sign_col, sign_ink)
|
|
|
|
# The entrance: a recessed door under the noren, with a frame and a step.
|
|
var door_x := cx - bay_w * 0.5 + 1.1
|
|
_deco(_box_mesh(Vector3(1.05, 2.1, 0.06)), M["glass_dark"],
|
|
Vector3(door_x, 1.05, front + facing * 0.09))
|
|
_deco(_box_mesh(Vector3(1.24, 2.26, 0.11)), M["wood_dark"],
|
|
Vector3(door_x, 1.13, front + facing * 0.07))
|
|
_deco(_box_mesh(Vector3(1.4, 0.12, 0.5)), M["stone"],
|
|
Vector3(door_x, 0.06, front + facing * 0.28))
|
|
|
|
for s_i in range(1, storeys):
|
|
var band_y := float(s_i) * FLOOR_H + 0.55
|
|
_deco(_box_mesh(Vector3(w - 0.6, 0.5, 0.1)), M["white_board"],
|
|
Vector3(cx, band_y, front + facing * 0.08))
|
|
# A string course under the band, which is what gives an upper storey a
|
|
# horizontal line to sit on.
|
|
_deco(_box_mesh(Vector3(w - 0.3, 0.11, 0.17)), M["concrete_dark"],
|
|
Vector3(cx, band_y - 0.34, front + facing * 0.1))
|
|
var wins := maxi(2, int(w / 2.6))
|
|
var win_w := (w - 1.0) / float(wins) - 0.5
|
|
for k in range(wins):
|
|
var wx := cx + (float(k) - float(wins - 1) * 0.5) * (w - 1.0) / float(wins)
|
|
var wc := Vector3(wx, float(s_i) * FLOOR_H + 1.9, front + facing * 0.05)
|
|
_deco(_box_mesh(Vector3(win_w, 1.5, 0.08)), M["glass_dark"], wc)
|
|
# Sill, lintel, and a centre mullion in the opening.
|
|
_window_trim(wc, win_w, 1.5, facing, M["concrete_dark"])
|
|
_deco(_box_mesh(Vector3(0.07, 1.5, 0.10)), M["trim_mat"], wc + Vector3(0, 0, facing * 0.02))
|
|
# One balcony per upper storey, on alternating units.
|
|
if (idx + s_i) % 2 == 0:
|
|
_balcony(cx, float(s_i) * FLOOR_H + 0.95, front, w * 0.52, facing,
|
|
M["concrete_mid"])
|
|
# Condenser units on the storeys without one.
|
|
else:
|
|
_ac_unit(Vector3(cx + w * 0.28, float(s_i) * FLOOR_H + 1.1, front), facing)
|
|
|
|
# Ground-floor service kit and the pipe that drains the whole elevation.
|
|
_meter_box(Vector3(cx + w * 0.5 - 0.55, 1.15, front), facing)
|
|
_downpipe(cx - (w * 0.5 - 0.28), front + facing * 0.02, h, facing,
|
|
M["metal_dark"])
|
|
# The parapet coping throws the roofline's own shadow line down the facade.
|
|
_deco(_box_mesh(Vector3(w + 0.5, 0.16, 0.34)), M["concrete_dark"],
|
|
Vector3(cx, h + 0.72, front + facing * 0.1))
|
|
|
|
|
|
## ── A flight of stairs ───────────────────────────────────────────────────────
|
|
##
|
|
## `top` is a point on the LANDING the flight serves, at that landing's walking
|
|
## height. The flight descends OUTWARD from there — along Z for `axis` 0, along
|
|
## X for `axis` 1, in the direction of `out_dir` — until it reaches `bottom_y`.
|
|
## Returns the foot of the flight so the caller can put a landing, a ramp or a
|
|
## second flight where it lands.
|
|
##
|
|
## ── Why it is written top-down ───────────────────────────────────────────────
|
|
##
|
|
## The version this replaces took the BOTTOM and a climb direction, and every
|
|
## one of its three call sites got the relationship backwards: the base was put
|
|
## directly under the landing and the flight then rose *away* from it. The
|
|
## footbridge's stairs climbed six metres into open air beside the deck they
|
|
## were supposed to reach, with their lowest step tucked underneath it — which
|
|
## is the thing that reads, correctly, as "broken and disconnected".
|
|
##
|
|
## Anchoring on the landing removes the whole class of error. A flight cannot be
|
|
## built without naming the thing it connects to, and the arithmetic that used
|
|
## to live (wrongly) in every caller lives here once.
|
|
##
|
|
## The step geometry follows from that: with N risers between the two levels
|
|
## there are N-1 treads, the first sitting one rise below the landing and the
|
|
## last one rise above the ground. Getting that off by one is what leaves a
|
|
## flight ending in a step to nowhere.
|
|
func _stair_flight(top: Vector3, bottom_y: float, out_dir: float, width: float,
|
|
axis: int, going: float = 0.40) -> Vector3:
|
|
var axis_vec := Vector3(0, 0, out_dir) if axis == 0 else Vector3(out_dir, 0, 0)
|
|
var height := top.y - bottom_y
|
|
if height <= 0.05:
|
|
return Vector3(top.x, bottom_y, top.z)
|
|
|
|
# Even risers. The controller steps up to 0.95 m (state_ground), so 0.32 is
|
|
# comfortably inside its budget with room for the rounding below.
|
|
var risers := maxi(1, int(ceil(height / 0.32)))
|
|
var rise := height / float(risers)
|
|
var run := float(risers - 1) * going
|
|
var foot := Vector3(top.x, bottom_y, top.z) + axis_vec * run
|
|
|
|
for i in range(1, risers):
|
|
var tread_top := top.y - float(i) * rise
|
|
var travel := (float(i) - 0.5) * going
|
|
var c := Vector3(top.x, tread_top - rise * 0.5, top.z) + axis_vec * travel
|
|
var size := Vector3(width, rise, going + 0.02) if axis == 0 \
|
|
else Vector3(going + 0.02, rise, width)
|
|
_solid(c, size, M["concrete_mid"], "concrete")
|
|
|
|
if run <= 0.01:
|
|
return foot
|
|
|
|
# The soffit: one sloped slab closing the underside. Without it a flight is
|
|
# a floating staircase you can see the sky through, which no stair in this
|
|
# map is meant to be.
|
|
var mid := (Vector3(top.x, top.y, top.z) + foot) * 0.5
|
|
var slope := atan2(height, run)
|
|
var length := sqrt(height * height + run * run)
|
|
var rot := Vector3(out_dir * rad_to_deg(slope), 0, 0) if axis == 0 \
|
|
else Vector3(0, 0, -out_dir * rad_to_deg(slope))
|
|
var soffit_size := Vector3(width, 0.26, length) if axis == 0 \
|
|
else Vector3(length, 0.26, width)
|
|
_solid(mid - Vector3(0, 0.30, 0), soffit_size, M["concrete_mid"], "concrete", rot)
|
|
|
|
# Handrails: a rail parallel to the flight on each side, with balusters.
|
|
for s in [-1.0, 1.0]:
|
|
var side := Vector3(s * width * 0.5, 0, 0) if axis == 0 \
|
|
else Vector3(0, 0, s * width * 0.5)
|
|
var rail_size := Vector3(0.08, 0.08, length) if axis == 0 \
|
|
else Vector3(length, 0.08, 0.08)
|
|
_deco(_box_mesh(rail_size), M["metal"], mid + side + Vector3(0, 1.02, 0), rot)
|
|
var posts := maxi(2, int(run / 1.5))
|
|
for i in range(posts + 1):
|
|
var t := float(i) / float(posts)
|
|
var p := Vector3(top.x, top.y, top.z).lerp(foot, t) + side
|
|
_deco(_box_mesh(Vector3(0.08, 1.02, 0.08)), M["metal"], p + Vector3(0, 0.51, 0))
|
|
|
|
return foot
|
|
|
|
|
|
## ── A fire escape ────────────────────────────────────────────────────────────
|
|
##
|
|
## A switchback, not a single flight, and that is forced by arithmetic rather
|
|
## than by taste: a two-storey shop is 6.8 m up, which is 24 risers, which at a
|
|
## walkable going is nine metres of run — longer than most of the units in the
|
|
## row and far longer than the three-metre alley behind them. Folding it in half
|
|
## puts the whole thing inside a five-metre footprint against the back wall.
|
|
##
|
|
## The top landing deliberately sits ABOVE the parapet and reaches back over it,
|
|
## so a player walks off the landing and steps DOWN onto the roof. The previous
|
|
## version stopped at a platform 3.4 m out from the wall at roof height, with
|
|
## nothing between it and the building.
|
|
## The two flights are also offset in Z rather than stacked. Doubling straight
|
|
## back underneath left about 1.2 m of headroom on the lower flight once the
|
|
## upper one's soffit was accounted for — enough to trap a 1.8 m player under
|
|
## his own staircase. Side by side, the whole switchback still fits the three
|
|
## metre alley.
|
|
func _fire_escape(base: Vector3, roof_y: float, out_dir: float) -> void:
|
|
var land_y := roof_y + 0.78 # parapet top is roof_y + 0.7
|
|
var z_up := base.z + out_dir * 1.15 # upper flight, nearest the wall
|
|
var z_down := base.z + out_dir * 2.55 # lower flight, one flight further out
|
|
var mid_y := (land_y + base.y) * 0.5
|
|
|
|
# The top landing, bridging the parapet: it starts a metre inside the roof
|
|
# and runs out clear of the wall, so a player walks off it and steps DOWN
|
|
# onto the roof deck.
|
|
_solid(Vector3(base.x, land_y - 0.1, base.z + out_dir * 0.35),
|
|
Vector3(1.8, 0.2, 2.7), M["metal_dark"], "metal")
|
|
|
|
# Upper flight: off the landing, running -X down to the half-way level.
|
|
var mid_foot := _stair_flight(Vector3(base.x - 0.9, land_y, z_up), mid_y,
|
|
-1.0, 1.3, 1, 0.34)
|
|
# The half landing turns the corner between the two flights.
|
|
_solid(Vector3(mid_foot.x, mid_y - 0.1, base.z + out_dir * 1.85),
|
|
Vector3(1.5, 0.2, 2.9), M["metal_dark"], "metal")
|
|
# Lower flight: doubling back +X, one flight further from the wall.
|
|
_stair_flight(Vector3(mid_foot.x, mid_y, z_down), base.y, 1.0, 1.3, 1, 0.34)
|
|
|
|
|
|
# ── The south side ───────────────────────────────────────────────────────────
|
|
|
|
func _build_south_side() -> void:
|
|
# The verge between the railway fence and the south street: a row of cherry
|
|
# trees, so the south approach to the crossing is soft cover rather than
|
|
# open ground. It stops where the line turns away north — past that the
|
|
# verge is not beside anything.
|
|
var tx := -HALF_X + 10.0
|
|
while tx < CURVE_START_X - 4.0:
|
|
if absf(tx) > ROAD_HALF + 4.0 and absf(tx - STATION_X) > 18.0 \
|
|
and absf(tx - BRIDGE_X) > 9.0:
|
|
_cherry_tree(Vector3(tx, 0.0, 11.4), _rng.randf_range(0.85, 1.25))
|
|
tx += _rng.randf_range(9.0, 13.0)
|
|
|
|
# A row of detached houses behind garden walls, fronting the south street.
|
|
# Lower and further apart than the shophouses, so the south lane plays long
|
|
# where the north lane plays close.
|
|
# Stop where the east district begins. That district's north row backs onto
|
|
# this street; continuing the old row behind it placed two full houses in the
|
|
# same five metres of depth.
|
|
_house_row(-HALF_X + 8.0, EAST_DIST_X0 - 4.0, 35.5, -1.0, 0)
|
|
_build_east_district()
|
|
_build_north_park()
|
|
_build_park_road()
|
|
_build_shrine_precinct()
|
|
|
|
|
|
## A run of detached houses along one side of a street, skipping anything that
|
|
## would block the shrine axis, the crossing, or the station forecourt.
|
|
func _house_row(x0: float, x1: float, z: float, facing: float, seed_i: int) -> void:
|
|
var x := x0
|
|
var i := seed_i
|
|
while x < x1:
|
|
var w := _rng.randf_range(9.0, 12.0)
|
|
# The plot, not the building, advances the row. Garden walls used to
|
|
# extend past a house whose next centre was spaced from the body width,
|
|
# which let neighbouring walls and even eaves overlap.
|
|
var plot_w := w + _rng.randf_range(4.0, 5.5)
|
|
var cx := x + plot_w * 0.5
|
|
# Nothing is built across the shrine's axis: the 参道 used to dead-end
|
|
# into somebody's garden wall, with the torii pointing at the back of a
|
|
# house. Ten metres, not seven — a plot is up to twelve wide and its
|
|
# boundary wall reaches most of the way to its neighbour.
|
|
if absf(cx) > ROAD_HALF + 7.0 and absf(cx - STATION_X) > 16.0 \
|
|
and absf(cx - SHRINE_X) > 10.0 \
|
|
and absf(cx - EAST_ST_X) > plot_w * 0.5 + EAST_ST_HALF + PAVE_W + 0.4:
|
|
_house(Vector3(cx, 0, z), w, i, facing, plot_w)
|
|
x += plot_w
|
|
i += 1
|
|
|
|
|
|
## ── The north park ───────────────────────────────────────────────────────────
|
|
##
|
|
## This was market gardens: furrowed plots and sheds, put there to stop the
|
|
## strip behind the shopping street being dead ground. It is a public park now,
|
|
## which does the same job and does it better — a park has paths, so it reads as
|
|
## somewhere people go rather than somewhere they work, and a pond and a stand
|
|
## of trees give it interior structure that a grid of plots never had.
|
|
##
|
|
## It is also what the shopping street now runs around. See _park_road_path.
|
|
const NPARK_X0 := -96.0
|
|
const NPARK_X1 := 92.0
|
|
const NPARK_Z0 := -84.0
|
|
const NPARK_Z1 := -46.0
|
|
|
|
|
|
func _build_north_park() -> void:
|
|
var cz := (NPARK_Z0 + NPARK_Z1) * 0.5
|
|
# Lawn, split around the main street.
|
|
for seg in _segments(NPARK_X0, NPARK_X1,
|
|
[Vector2(-ROAD_HALF - PAVE_W - 0.3, ROAD_HALF + PAVE_W + 0.3)]):
|
|
_slab(Vector2((seg.x + seg.y) * 0.5, cz),
|
|
Vector2(seg.y - seg.x, NPARK_Z1 - NPARK_Z0), Y_GRASS, M["grass"], "grass")
|
|
|
|
# The main path: a gravel spine down the middle, and two spurs to the
|
|
# shopping street so the park is entered rather than merely bordered.
|
|
for seg in _segments(NPARK_X0 + 4.0, NPARK_X1 - 4.0,
|
|
[Vector2(-ROAD_HALF - 0.3, ROAD_HALF + 0.3)]):
|
|
_slab(Vector2((seg.x + seg.y) * 0.5, cz), Vector2(seg.y - seg.x, 3.6),
|
|
Y_GRASS + 0.02, M["gravel"], "gravel")
|
|
for sx in [-62.0, 26.0]:
|
|
_slab(Vector2(sx, (cz + NPARK_Z1) * 0.5), Vector2(3.2, NPARK_Z1 - cz),
|
|
Y_GRASS + 0.02, M["gravel"], "gravel")
|
|
# A second route loops around the pond instead of making every visit an
|
|
# out-and-back along the central spine. It joins the two park entrances via
|
|
# the water, pavilion and open east lawn.
|
|
_slab(Vector2(-34.0, NPARK_Z0 + 2.2), Vector2(40.0, 2.4),
|
|
Y_GRASS + 0.025, M["gravel"], "gravel")
|
|
for lx in [-54.0, -14.0]:
|
|
_slab(Vector2(lx, (NPARK_Z0 + 2.2 + cz) * 0.5),
|
|
Vector2(2.4, cz - (NPARK_Z0 + 2.2)),
|
|
Y_GRASS + 0.025, M["gravel"], "gravel")
|
|
_slab(Vector2(24.0, (cz + cz - 9.0) * 0.5), Vector2(2.4, 9.0),
|
|
Y_GRASS + 0.025, M["gravel"], "gravel")
|
|
|
|
# ── The pond ─────────────────────────────────────────────────────────────
|
|
# Sunk a little, with a stone rim. Water is drawn flat and unlit, the way
|
|
# the reference draws it — a lit surface at this scale reads as ice.
|
|
var pond := Vector2(-34.0, cz - 8.0)
|
|
_slab(pond, Vector2(30.0, 15.0), Y_GRASS + 0.01, M["stone"], "concrete")
|
|
_slab(pond, Vector2(27.0, 12.4), Y_GRASS - 0.06, M["water"], "concrete")
|
|
for s in [-1.0, 1.0]:
|
|
_solid(Vector3(pond.x, Y_GRASS + 0.18, pond.y + s * 7.6),
|
|
Vector3(30.4, 0.36, 0.7), M["stone_dark"], "concrete")
|
|
_solid(Vector3(pond.x + s * 15.2, Y_GRASS + 0.18, pond.y),
|
|
Vector3(0.7, 0.36, 15.4), M["stone_dark"], "concrete")
|
|
# Trees frame the side banks, away from the cedar boundary strip. The old
|
|
# north-bank row occupied z=-83.5 while the cedars occupied z=-81, so the two
|
|
# species visibly grew through one another.
|
|
for p in [Vector2(pond.x - 18.2, pond.y), Vector2(pond.x + 18.2, pond.y)]:
|
|
_cherry_tree(Vector3(p.x, Y_GRASS, p.y), 1.08)
|
|
for p in [Vector3(pond.x - 13.8, Y_GRASS, pond.y + 8.5),
|
|
Vector3(pond.x + 13.8, Y_GRASS, pond.y + 8.5)]:
|
|
_shrub(p)
|
|
# A low wooden footbridge closes the walking loop and makes the pond an
|
|
# interactive route rather than scenery seen from one side.
|
|
var pond_bridge := _solid(Vector3(pond.x, Y_GRASS + 0.34, pond.y),
|
|
Vector3(2.2, 0.26, 16.8), M["wood"], "wood")
|
|
pond_bridge.name = "PondBridge"
|
|
for sx in [-1.0, 1.0]:
|
|
_solid(Vector3(pond.x + sx * 1.0, Y_GRASS + 0.90, pond.y),
|
|
Vector3(0.10, 0.75, 16.8), M["wood_dark"], "wood")
|
|
for z in [-6.8, -3.4, 0.0, 3.4, 6.8]:
|
|
for sx in [-1.0, 1.0]:
|
|
_post(Vector3(pond.x + sx, Y_GRASS + 0.72, pond.y + z), 0.07, 1.1,
|
|
M["wood_dark"], "wood", 6)
|
|
|
|
# ── The pavilion (四阿) ───────────────────────────────────────────────────
|
|
# A roof on posts in the middle of a lawn: the park's one piece of hard
|
|
# cover and the thing that gives the space a centre.
|
|
var pav := Vector2(24.0, cz - 9.0)
|
|
_slab(pav, Vector2(7.0, 7.0), Y_GRASS + 0.12, M["stone"], "concrete")
|
|
for sx in [-1.0, 1.0]:
|
|
for sz in [-1.0, 1.0]:
|
|
_post(Vector3(pav.x + sx * 2.7, 1.4, pav.y + sz * 2.7), 0.16, 2.8,
|
|
M["wood_dark"], "wood", 6)
|
|
_solid(Vector3(pav.x, 2.92, pav.y), Vector3(8.0, 0.24, 8.0),
|
|
M["roof_shrine"], "wood")
|
|
_deco(_prism_mesh(Vector3(8.4, 1.5, 8.4)), M["roof_shrine"],
|
|
Vector3(pav.x, 3.75, pav.y))
|
|
_solid(Vector3(pav.x, 0.55, pav.y - 2.6), Vector3(5.0, 0.14, 0.55),
|
|
M["wood"], "wood")
|
|
|
|
# Trees: cherry along the paths, cedar massed at the back against the wall.
|
|
var tx := NPARK_X0 + 8.0
|
|
while tx < NPARK_X1 - 8.0:
|
|
if absf(tx) > ROAD_HALF + 7.0 and absf(tx - pond.x) > 17.0 \
|
|
and absf(tx - pav.x) > 8.0:
|
|
_cherry_tree(Vector3(tx, Y_GRASS, cz + 7.0), _rng.randf_range(0.9, 1.35))
|
|
tx += _rng.randf_range(11.0, 17.0)
|
|
var bx := NPARK_X0 + 5.0
|
|
while bx < NPARK_X1 - 5.0:
|
|
# Keep the pond loop open and push the evergreen mass back toward the
|
|
# boundary wall, clearly separate from the ornamental cherries.
|
|
if not _on_any_road(Vector2(bx, NPARK_Z0 - 1.2)) \
|
|
and absf(bx - pond.x) > 24.0:
|
|
_cedar(Vector3(bx, 0.0, NPARK_Z0 - 1.2), _rng.randf_range(0.85, 1.25))
|
|
bx += _rng.randf_range(7.0, 12.0)
|
|
|
|
# Benches along the spine, and hedges for cover across the open lawn.
|
|
for i in range(6):
|
|
var bxx := NPARK_X0 + 18.0 + float(i) * 26.0
|
|
if absf(bxx) < ROAD_HALF + 7.0:
|
|
continue
|
|
_solid(Vector3(bxx, 0.55, cz + 2.6), Vector3(2.4, 0.12, 0.5), M["wood"], "wood")
|
|
for s in [-0.95, 0.95]:
|
|
_deco(_box_mesh(Vector3(0.14, 0.5, 0.5)), M["metal_dark"],
|
|
Vector3(bxx + s, 0.35, cz + 2.6))
|
|
var hx := NPARK_X0 + 12.0
|
|
while hx < NPARK_X1 - 14.0:
|
|
if absf(hx) > ROAD_HALF + 9.0 and absf(hx - pond.x) > 18.0:
|
|
_hedge(Vector2(hx, cz - 14.0), Vector2(hx + 11.0, cz - 14.0))
|
|
hx += _rng.randf_range(24.0, 34.0)
|
|
|
|
# Social and service assets are grouped at path nodes, which makes the park
|
|
# read as a sequence of usable places rather than props scattered on lawn.
|
|
_picnic_table(Vector3(43.0, Y_GRASS, cz - 7.0), 90.0)
|
|
_picnic_table(Vector3(57.0, Y_GRASS, cz + 7.0), 0.0)
|
|
_park_bin(Vector3(28.5, Y_GRASS, cz - 4.2))
|
|
_park_bin(Vector3(-59.0, Y_GRASS, cz + 2.8))
|
|
for p in [Vector3(-58.0, Y_GRASS, cz - 2.8),
|
|
Vector3(-10.0, Y_GRASS, cz - 2.8),
|
|
Vector3(31.0, Y_GRASS, cz + 2.8),
|
|
Vector3(70.0, Y_GRASS, cz - 2.8)]:
|
|
_park_lamp(p)
|
|
# Simple calisthenics bars on the east lawn, common in neighbourhood parks.
|
|
for x in [67.0, 70.0, 73.0]:
|
|
_post(Vector3(x, 1.25, cz + 8.0), 0.08, 2.5, M["metal_dark"], "metal", 8)
|
|
for x in [68.5, 71.5]:
|
|
_solid(Vector3(x, 2.25, cz + 8.0), Vector3(3.0, 0.10, 0.10),
|
|
M["red"], "metal")
|
|
|
|
_spawn_points.append(Vector3(-40, 1.6, cz))
|
|
_spawn_points.append(Vector3(46, 1.6, cz))
|
|
_spawn_points.append(Vector3(pav.x, 1.6, pav.y))
|
|
|
|
|
|
## ── The road round the park ──────────────────────────────────────────────────
|
|
##
|
|
## The shopping street used to stop dead at x = 52 because the railway's turn
|
|
## occupies everything east of it — a carriageway ending in mid-air, which is
|
|
## the same "the world is fake" tell as a road running into a hill.
|
|
##
|
|
## It now carries on east, turns north around the park's eastern end, and leaves
|
|
## through the north wall. That keeps it clear of the line (which is outside the
|
|
## map for every z north of -35) and gives the park a proper edge on two sides.
|
|
const PARK_RD_TURN_X := 76.0
|
|
const PARK_RD_R := 26.0
|
|
const PARK_RD_EXIT_X := PARK_RD_TURN_X + PARK_RD_R # 102
|
|
|
|
|
|
## Centreline of that road: straight, a quarter circle, then straight north.
|
|
func _park_road_path() -> Array:
|
|
var pts: Array = []
|
|
var step := 2.0
|
|
var x := SHOP_ST_X1 - 6.0
|
|
while x < PARK_RD_TURN_X:
|
|
pts.append([Vector2(x, SHOP_ST_Z), Vector2(1.0, 0.0)])
|
|
x += step
|
|
# Turning left, toward -Z. Centre one radius north of where the turn starts.
|
|
var c := Vector2(PARK_RD_TURN_X, SHOP_ST_Z - PARK_RD_R)
|
|
var n := int(PARK_RD_R * PI * 0.5 / step)
|
|
for i in range(n + 1):
|
|
var th := (PI * 0.5) * float(i) / float(n)
|
|
pts.append([c + Vector2(sin(th), cos(th)) * PARK_RD_R,
|
|
Vector2(cos(th), -sin(th))])
|
|
var z := SHOP_ST_Z - PARK_RD_R
|
|
while z > -HALF_Z - OUTFIELD * 0.4:
|
|
z -= step
|
|
pts.append([Vector2(PARK_RD_EXIT_X, z), Vector2(0.0, -1.0)])
|
|
return pts
|
|
|
|
|
|
func _build_park_road() -> void:
|
|
var path := _park_road_path()
|
|
var half := SHOP_ST_HALF
|
|
for i in range(path.size() - 1):
|
|
var a: Vector2 = path[i][0]
|
|
var b: Vector2 = path[i + 1][0]
|
|
_chord_solid(a, b, Y_ROAD_EW - 0.1, 0.2, half * 2.0, M["road"], "concrete")
|
|
# Kerbs, offset along the local normal so they follow the turn.
|
|
var na := _normal_of(path[i][1])
|
|
var nb := _normal_of(path[i + 1][1])
|
|
for s in [-1.0, 1.0]:
|
|
_chord_solid(a + na * (half * s), b + nb * (half * s), 0.10, 0.20,
|
|
0.12, M["kerb"], "concrete")
|
|
# Centre line.
|
|
for i in range(0, path.size() - 2, 3):
|
|
var a: Vector2 = path[i][0]
|
|
var b: Vector2 = path[i + 1][0]
|
|
_chord_deco(a, b, Y_ROAD_EW + 0.012, 0.02, 0.16, M["line_white"])
|
|
|
|
|
|
## Is this point on the park road? Scenery has to know about it like any other.
|
|
func _near_park_road(p: Vector2, margin: float = SHOP_ST_HALF + 3.0) -> bool:
|
|
for e in _park_road_path():
|
|
var q: Vector2 = e[0]
|
|
if absf(q.x - p.x) > margin or absf(q.y - p.y) > margin:
|
|
continue
|
|
if q.distance_to(p) < margin:
|
|
return true
|
|
return false
|
|
|
|
|
|
## ── The shrine precinct ──────────────────────────────────────────────────────
|
|
##
|
|
## Behind the shrine hall there was thirty metres of empty grass to the wall.
|
|
## It is the rest of the precinct now: an inner court behind a second torii,
|
|
## with the buildings a working shrine actually has — a 手水舎 to rinse at, a
|
|
## 社務所 that sells the charms, a rack for the 絵馬 — and a cedar grove closing
|
|
## it off. It gives the south-west a destination with a shape instead of a lawn.
|
|
const PRECINCT_Z0 := 62.0
|
|
const PRECINCT_Z1 := 84.0
|
|
|
|
|
|
func _build_shrine_precinct() -> void:
|
|
var cz := (PRECINCT_Z0 + PRECINCT_Z1) * 0.5
|
|
var x0 := -HALF_X + 16.0
|
|
# West of the main street, which runs the full length of the map at x = 0.
|
|
# Taken to +2 the gravel court was laid straight across the carriageway.
|
|
var x1 := -ROAD_HALF - PAVE_W - 2.0
|
|
|
|
# The court: gravel, with a stone-flagged path on the shrine's own axis.
|
|
_slab(Vector2((x0 + x1) * 0.5, cz), Vector2(x1 - x0, PRECINCT_Z1 - PRECINCT_Z0),
|
|
Y_GRASS, M["gravel"], "gravel")
|
|
_slab(Vector2(SHRINE_X, cz), Vector2(6.0, PRECINCT_Z1 - PRECINCT_Z0),
|
|
Y_GRASS + 0.02, M["stone"], "concrete")
|
|
# A cross-axis connects the office, purification pavilion, ema and fortune
|
|
# rack to the processional path. These used to sit as islands in open gravel.
|
|
_slab(Vector2(SHRINE_X - 14.0, PRECINCT_Z0 + 8.0), Vector2(28.0, 2.6),
|
|
Y_GRASS + 0.025, M["stone"], "concrete")
|
|
_slab(Vector2(SHRINE_X + 7.0, PRECINCT_Z0 + 8.0), Vector2(14.0, 2.6),
|
|
Y_GRASS + 0.025, M["stone"], "concrete")
|
|
|
|
# The inner torii, on the same axis as the outer one.
|
|
_torii(Vector3(SHRINE_X, Y_GRASS, PRECINCT_Z0 + 2.0), 0.95)
|
|
# The hall belongs at the end of the processional axis. It used to stand in
|
|
# front of this court, leaving the detailed precinct hidden behind a blank
|
|
# rear wall and making the second torii point at nothing.
|
|
_build_shrine_hall(Vector3(SHRINE_X, Y_GRASS, PRECINCT_Z1 - 8.0))
|
|
|
|
# 手水舎 — the water pavilion: a roof on four posts over a stone basin.
|
|
var chz := Vector2(SHRINE_X - 13.0, cz - 4.0)
|
|
_slab(chz, Vector2(5.0, 5.0), Y_GRASS + 0.1, M["stone"], "concrete")
|
|
for sx in [-1.0, 1.0]:
|
|
for sz in [-1.0, 1.0]:
|
|
_post(Vector3(chz.x + sx * 1.9, 1.25, chz.y + sz * 1.9), 0.13, 2.5,
|
|
M["wood_dark"], "wood", 6)
|
|
_solid(Vector3(chz.x, 2.62, chz.y), Vector3(5.6, 0.2, 5.6), M["roof_shrine"], "wood")
|
|
_deco(_prism_mesh(Vector3(6.0, 1.2, 6.0)), M["roof_shrine"],
|
|
Vector3(chz.x, 3.3, chz.y))
|
|
_solid(Vector3(chz.x, 0.55, chz.y), Vector3(2.6, 0.9, 1.5), M["stone_dark"], "concrete")
|
|
_deco(_box_mesh(Vector3(2.3, 0.12, 1.2)), M["water"], Vector3(chz.x, 1.02, chz.y))
|
|
# Bamboo ladles rest across the basin instead of leaving it as an empty tub.
|
|
for z in [-0.34, 0.34]:
|
|
_deco(_cyl_mesh(0.035, 0.035, 1.65, 6), M["wood"],
|
|
Vector3(chz.x, 1.18, chz.y + z), Vector3(0, 0, 90))
|
|
_deco(_cyl_mesh(0.16, 0.13, 0.16, 8), M["wood"],
|
|
Vector3(chz.x + 0.88, 1.18, chz.y + z))
|
|
|
|
# 社務所 — the shrine office. A long single-storey building with a deep
|
|
# verandah, closing the west side of the court.
|
|
var off := Vector2(SHRINE_X - 26.0, cz + 3.0)
|
|
_solid(Vector3(off.x, 1.7, off.y), Vector3(9.0, 3.4, 7.0), M["wall_cream"], "wood")
|
|
_deco(_prism_mesh(Vector3(10.4, 1.9, 8.4)), M["roof_shrine"],
|
|
Vector3(off.x, 4.35, off.y))
|
|
_deco(_box_mesh(Vector3(10.6, 0.18, 8.6)), M["roof_shrine"],
|
|
Vector3(off.x, 3.46, off.y))
|
|
_deco(_box_mesh(Vector3(7.4, 1.7, 0.1)), M["glass"],
|
|
Vector3(off.x, 1.85, off.y - 3.55))
|
|
_solid(Vector3(off.x, 0.5, off.y - 4.4), Vector3(9.4, 0.16, 1.8), M["wood"], "wood")
|
|
for t in [-3.6, 0.0, 3.6]:
|
|
_post(Vector3(off.x + t, 1.9, off.y - 5.2), 0.12, 3.0, M["wood_dark"], "wood", 6)
|
|
SakuraSignage.label(_decor, "社務所", 0.34, SakuraPalette.INK,
|
|
Vector3(off.x, 2.9, off.y - 3.62), Vector3(0, 180, 0))
|
|
# Ceremonial sake barrels and a charm counter animate the office frontage.
|
|
for i in range(4):
|
|
var barrel_x := off.x - 3.0 + float(i) * 2.0
|
|
_solid(Vector3(barrel_x, 0.72, off.y - 5.4), Vector3(1.35, 1.35, 1.15),
|
|
M["wall_cream"], "wood")
|
|
for y in [0.22, 1.22]:
|
|
_deco(_box_mesh(Vector3(1.42, 0.10, 1.22)), M["wood_dark"],
|
|
Vector3(barrel_x, y, off.y - 5.4))
|
|
_solid(Vector3(off.x + 5.8, 1.02, off.y - 5.35), Vector3(2.2, 0.12, 0.70),
|
|
M["wood"], "wood")
|
|
|
|
# 絵馬 rack — a roofed frame hung with votive tablets.
|
|
var ema := Vector2(SHRINE_X + 12.0, cz - 2.0)
|
|
for sx in [-1.0, 1.0]:
|
|
_post(Vector3(ema.x + sx * 2.4, 1.1, ema.y), 0.11, 2.2, M["wood_dark"], "wood", 6)
|
|
_deco(_box_mesh(Vector3(5.6, 0.16, 1.0)), M["roof_shrine"],
|
|
Vector3(ema.x, 2.28, ema.y))
|
|
for i in range(9):
|
|
_deco(_box_mesh(Vector3(0.34, 0.26, 0.04)), LevelMaterials.unlit(SakuraPalette.EMA),
|
|
Vector3(ema.x - 2.0 + float(i) * 0.5, 1.72, ema.y))
|
|
_omikuji_rack(Vector3(SHRINE_X + 13.0, Y_GRASS, cz + 4.0))
|
|
|
|
# Guardians and banners establish a human-scale threshold behind the inner
|
|
# torii, while remaining outside the six-metre-wide central path.
|
|
for s in [-1.0, 1.0]:
|
|
_komainu(Vector3(SHRINE_X + s * 4.8, Y_GRASS, PRECINCT_Z0 + 5.5), 1.0)
|
|
_shrine_banner(Vector3(SHRINE_X + s * 7.2, Y_GRASS, PRECINCT_Z0 + 9.5),
|
|
"奉納" if s < 0.0 else "桜守")
|
|
|
|
# Lanterns down the axis, and a cedar grove closing the back.
|
|
for i in range(5):
|
|
for s in [-1.0, 1.0]:
|
|
_stone_lantern(Vector3(SHRINE_X + s * 4.6, Y_GRASS,
|
|
PRECINCT_Z0 + 6.0 + float(i) * 4.0))
|
|
var gx := x0 + 4.0
|
|
while gx < x1 + 10.0:
|
|
if absf(gx - SHRINE_X) > 8.0 \
|
|
and not _near_road_for_tree(Vector2(gx, PRECINCT_Z1 - 1.0)):
|
|
_cedar(Vector3(gx, 0.0, PRECINCT_Z1 - 1.0), _rng.randf_range(1.0, 1.5))
|
|
gx += _rng.randf_range(6.0, 10.0)
|
|
|
|
# A block wall along the precinct's east side, which is what separates a
|
|
# shrine's ground from the town beside it.
|
|
_block_wall(Vector2(x1 + 1.0, PRECINCT_Z0), Vector2(x1 + 1.0, PRECINCT_Z1), 2.0)
|
|
for z in [PRECINCT_Z0 + 4.0, PRECINCT_Z0 + 12.0, PRECINCT_Z0 + 18.0]:
|
|
_shrub(Vector3(x1 - 1.1, Y_GRASS, z))
|
|
|
|
_spawn_points.append(Vector3(SHRINE_X, 1.6, cz))
|
|
_spawn_points.append(Vector3(off.x, 1.6, off.y - 7.0))
|
|
_spawn_points.append(Vector3(SHRINE_X, 2.9, PRECINCT_Z1 - 11.0))
|
|
|
|
|
|
func _build_shrine_hall(base: Vector3) -> void:
|
|
# Raised neighbourhood haiden: stone plinth, deep eaves, open veranda and a
|
|
# layered timber front. The small human-scale details are what keep this from
|
|
# reading as a generic brown house under a large roof.
|
|
_solid(base + Vector3(0, 0.62, 0), Vector3(11.4, 1.24, 8.6),
|
|
M["stone"], "concrete")
|
|
_solid(base + Vector3(0, 2.58, 1.15), Vector3(7.8, 2.8, 5.2),
|
|
M["wood"], "wood")
|
|
_deco(_box_mesh(Vector3(11.3, 0.22, 8.8)), M["wood_dark"],
|
|
base + Vector3(0, 4.02, 1.05))
|
|
_deco(_prism_mesh(Vector3(10.8, 2.7, 8.4)), M["roof_shrine"],
|
|
base + Vector3(0, 5.34, 1.05))
|
|
_deco(_box_mesh(Vector3(0.30, 0.24, 8.8)), M["metal_dark"],
|
|
base + Vector3(0, 6.70, 1.05))
|
|
|
|
# Veranda and post rhythm across the front elevation.
|
|
_solid(base + Vector3(0, 1.30, -2.72), Vector3(9.2, 0.20, 1.40),
|
|
M["wood_dark"], "wood")
|
|
for px in [-3.25, -1.10, 1.10, 3.25]:
|
|
_post(base + Vector3(px, 2.60, -2.02), 0.17, 4.0,
|
|
M["wood_dark"], "wood", 6)
|
|
# Sliding doors and transom lattice behind the veranda.
|
|
for px in [-2.55, -0.85, 0.85, 2.55]:
|
|
_deco(_box_mesh(Vector3(1.52, 2.20, 0.10)), M["wall_cream"],
|
|
base + Vector3(px, 2.52, -1.52))
|
|
_deco(_box_mesh(Vector3(0.08, 2.28, 0.14)), M["wood_dark"],
|
|
base + Vector3(px - 0.80, 2.52, -1.58))
|
|
for y in [1.72, 2.26, 2.80, 3.34]:
|
|
_deco(_box_mesh(Vector3(7.0, 0.07, 0.15)), M["wood_dark"],
|
|
base + Vector3(0, y, -1.60))
|
|
|
|
# Four broad stone steps lead onto the veranda.
|
|
for i in range(4):
|
|
_solid(base + Vector3(0, 0.15 + float(i) * 0.30,
|
|
-4.55 + float(i) * 0.46), Vector3(5.4, 0.30, 0.58),
|
|
M["stone"], "concrete")
|
|
|
|
# Offering box, bell rope and the shrine name board at the focal point.
|
|
_solid(base + Vector3(0, 1.62, -2.96), Vector3(1.75, 0.82, 0.95),
|
|
M["wood_dark"], "wood")
|
|
for x in [-0.62, -0.31, 0.0, 0.31, 0.62]:
|
|
_deco(_box_mesh(Vector3(0.10, 0.07, 0.84)), M["wood"],
|
|
base + Vector3(x, 2.07, -2.96))
|
|
_deco(_cyl_mesh(0.055, 0.055, 1.75, 8), M["rope"],
|
|
base + Vector3(0, 3.12, -3.00))
|
|
_deco(_sphere_mesh(0.18, 8, 4), M["metal_warm"],
|
|
base + Vector3(0, 2.20, -3.00))
|
|
_sign_board(base + Vector3(0, 3.78, -1.66), -1.0, "桜守神社", 0.30,
|
|
SakuraPalette.SHRINE_WOOD_DARK, SakuraPalette.EMA, 0.22)
|
|
|
|
|
|
## ── The east district ────────────────────────────────────────────────────────
|
|
##
|
|
## The land the railway's turn opens up. North of the curve there is nothing but
|
|
## line; south of it there is a wedge big enough for a proper residential block,
|
|
## which is what stops the east half of the map being a second copy of the west
|
|
## half. It has its own two streets and three rows of houses, and the middle
|
|
## street is fronted from BOTH sides — the only two-sided residential street in
|
|
## the map, and the reason `_house` learned to face either way.
|
|
func _build_east_district() -> void:
|
|
# The north-south street off the south street, and the east-west one it
|
|
# feeds. Their pavements and kerbs come from the same helper every other
|
|
# street in the map uses.
|
|
var link_x0 := ROAD_HALF - 0.15
|
|
_slab(Vector2(EAST_ST_X, (SOUTH_ST_Z + EAST_DIST_Z1) * 0.5),
|
|
Vector2(EAST_ST_HALF * 2, EAST_DIST_Z1 - SOUTH_ST_Z), Y_ROAD_NS,
|
|
M["road"], "concrete")
|
|
# Carry the branch all the way into the main-road edge. It previously began
|
|
# at EAST_DIST_X0 (x=10), leaving 5.5 m of grass and pavement between two
|
|
# pieces of road that were meant to form one T-junction.
|
|
var east_link := _slab(Vector2((link_x0 + EAST_DIST_X1) * 0.5, EAST_ST_Z),
|
|
Vector2(EAST_DIST_X1 - link_x0, EAST_ST_HALF * 2), Y_ROAD_EW,
|
|
M["road"], "concrete")
|
|
east_link.name = "EastStreetLink"
|
|
east_link.set_meta("east_street_link", true)
|
|
_street_edges(false, EAST_ST_X, EAST_ST_HALF, SOUTH_ST_Z, EAST_DIST_Z1,
|
|
[Vector2(EAST_ST_Z - EAST_ST_HALF - PAVE_W - 0.3,
|
|
EAST_ST_Z + EAST_ST_HALF + PAVE_W + 0.3),
|
|
Vector2(SOUTH_ST_Z - SOUTH_ST_HALF - PAVE_W - 0.3,
|
|
SOUTH_ST_Z + SOUTH_ST_HALF + PAVE_W + 0.3)])
|
|
_street_edges(true, EAST_ST_Z, EAST_ST_HALF, link_x0, EAST_DIST_X1,
|
|
[Vector2(EAST_ST_X - EAST_ST_HALF - PAVE_W - 0.3,
|
|
EAST_ST_X + EAST_ST_HALF + PAVE_W + 0.3)])
|
|
|
|
# Match the established junction language: connected corner paving, tactile
|
|
# pads and a zebra across the side street.
|
|
var apron_span := PAVE_W + 0.60
|
|
for sz in [-1.0, 1.0]:
|
|
var apron := Vector2(ROAD_HALF + apron_span * 0.5,
|
|
EAST_ST_Z + sz * (EAST_ST_HALF + apron_span * 0.5))
|
|
var connector := _slab(apron, Vector2(apron_span, apron_span), Y_PAVE,
|
|
M["pave"], "concrete")
|
|
connector.name = "SidewalkConnector"
|
|
connector.set_meta("sidewalk_connector", true)
|
|
_tactile_dots(Vector2(ROAD_HALF + 1.3,
|
|
EAST_ST_Z + sz * (EAST_ST_HALF + 1.3)), Vector2(1.5, 1.5), Y_PAVE)
|
|
_zebra(Vector2(ROAD_HALF + 3.2, EAST_ST_Z), EAST_ST_HALF * 2.0 - 0.8,
|
|
2.4, false, Y_ROAD_EW)
|
|
|
|
# ── Two rows facing each other, set back far enough to have gardens ──────
|
|
#
|
|
# A house is not just its body: in front of it are 4.25 m of plot, a garden
|
|
# 3.4 m deep and a boundary wall — about 7.8 m all told. Placed ten metres
|
|
# off the centreline, the body cleared the road and the GARDEN did not, so
|
|
# the front walls and their cherry trees stood in the carriageway. The
|
|
# offsets below are measured from the far edge of the pavement, not from
|
|
# the middle of the street.
|
|
var garden_reach := 7.8
|
|
var pave_edge := EAST_ST_HALF + PAVE_W + 0.2
|
|
_house_row(EAST_DIST_X0 + 4.0, EAST_DIST_X1 - 10.0,
|
|
EAST_ST_Z - pave_edge - garden_reach, 1.0, 5)
|
|
_house_row(EAST_DIST_X0 + 8.0, EAST_DIST_X1 - 10.0,
|
|
EAST_ST_Z + pave_edge + garden_reach, -1.0, 11)
|
|
|
|
# Street trees and a couple of vending machines, so the district reads as
|
|
# lived in rather than as a housing estate diagram.
|
|
var tx := EAST_DIST_X0 + 8.0
|
|
while tx < EAST_DIST_X1 - 8.0:
|
|
if absf(tx - EAST_ST_X) > 8.0:
|
|
_cherry_tree(Vector3(tx, 0.0, EAST_ST_Z - EAST_ST_HALF - PAVE_W - 1.4),
|
|
_rng.randf_range(0.9, 1.3))
|
|
tx += _rng.randf_range(11.0, 16.0)
|
|
_vending_on_street(true, EAST_ST_Z, EAST_ST_HALF, 1.0, EAST_DIST_X0 + 18.0, 1)
|
|
_vending_on_street(false, EAST_ST_X, EAST_ST_HALF, -1.0, EAST_ST_Z + 22.0, 2)
|
|
|
|
_spawn_points.append(Vector3(EAST_ST_X, 1.6, EAST_ST_Z))
|
|
_spawn_points.append(Vector3(EAST_DIST_X0 + 20.0, 1.6, EAST_ST_Z))
|
|
_spawn_points.append(Vector3(EAST_DIST_X1 - 20.0, 1.6, EAST_DIST_Z1 - 16.0))
|
|
|
|
|
|
## The detached-house generator: a two-storey box, a pitched roof with deep
|
|
## eaves, a garden wall with a gate, and a front garden. Same principle as the
|
|
## shop — a house is a handful of numbers and everything else is derived from
|
|
## its footprint.
|
|
## `facing` is the direction the front door looks. Everything below is written
|
|
## for a street to the NORTH (-Z); a house that fronts +Z is the same house
|
|
## built under a pivot turned 180°, which is why the whole body is in local
|
|
## coordinates about the origin.
|
|
func _house(centre: Vector3, w: float, idx: int, facing: float = -1.0,
|
|
plot_w: float = -1.0) -> void:
|
|
var pivot := Node3D.new()
|
|
pivot.name = "House"
|
|
pivot.position = centre
|
|
if facing > 0.0:
|
|
pivot.rotation_degrees = Vector3(0, 180, 0)
|
|
add_child(pivot)
|
|
var prev := _group
|
|
_group = pivot
|
|
_house_local(w, idx, w + 4.6 if plot_w < 0.0 else plot_w)
|
|
_group = prev
|
|
|
|
|
|
func _house_local(w: float, idx: int, plot_w: float) -> void:
|
|
var centre := Vector3.ZERO
|
|
var d := 8.5
|
|
var h := 6.2
|
|
_solid(centre + Vector3(0, h * 0.5, 0), Vector3(w, h, d),
|
|
_wall_mat(idx + 3), "concrete", Vector3.ZERO, "HouseBody")
|
|
|
|
# The roof. A prism gives a clean faceted pitch with no UV work, and its
|
|
# ridge runs along Z so the gable faces the street.
|
|
var roof := _roof_mat(idx + 1)
|
|
_deco(_prism_mesh(Vector3(w + 1.2, 2.0, d + 1.2)), roof, centre + Vector3(0, h + 1.0, 0))
|
|
# ── and the pitch has to be SOLID, not just drawn ────────────────────────
|
|
#
|
|
# That prism is render-only, so for a while the only thing a player could
|
|
# stand on up here was the body's own flat top — which is INSIDE the visible
|
|
# roof. Anyone who grappled onto a house ended up standing in the middle of
|
|
# its tiles. Two rotated slabs give the pitch real collision and seal the
|
|
# flat top underneath, where nothing can now reach it.
|
|
var pitch_hw := (w + 1.2) * 0.5
|
|
var pitch_angle := rad_to_deg(atan2(2.0, pitch_hw))
|
|
var pitch_len := sqrt(pitch_hw * pitch_hw + 4.0)
|
|
var plot_half := plot_w * 0.5
|
|
for s in [-1.0, 1.0]:
|
|
# Rotating about Z by +θ lifts the +X end, so the -X pitch — the one
|
|
# that rises toward the ridge at +X — takes +θ, and its mirror takes -θ.
|
|
_solid(centre + Vector3(-s * pitch_hw * 0.5, h + 0.9, 0),
|
|
Vector3(pitch_len, 0.3, d + 1.2), roof, "wood",
|
|
Vector3(0, 0, s * pitch_angle))
|
|
# Eaves: a thin overhanging slab, and the deepest single shadow line on the
|
|
# building. A Japanese house is read by its eaves more than by its walls.
|
|
_deco(_box_mesh(Vector3(w + 1.4, 0.18, d + 1.4)), roof, centre + Vector3(0, h + 0.05, 0))
|
|
|
|
# The ridge cap and the barge boards on the gable — a pitched roof with no
|
|
# edge members reads as a solid wedge dropped on a box.
|
|
_deco(_box_mesh(Vector3(0.34, 0.22, d + 1.5)), M["concrete_dark"],
|
|
centre + Vector3(0, h + 2.02, 0))
|
|
for s in [-1.0, 1.0]:
|
|
_deco(_box_mesh(Vector3(w + 1.5, 0.14, 0.16)), M["trim_mat"],
|
|
centre + Vector3(0, h + 0.16, s * (d * 0.5 + 0.6)))
|
|
# The gutter along the eaves, with a downpipe off each end.
|
|
for s in [-1.0, 1.0]:
|
|
_deco(_box_mesh(Vector3(w + 1.3, 0.13, 0.13)), M["metal"],
|
|
centre + Vector3(0, h - 0.02, s * (d * 0.5 + 0.66)))
|
|
_downpipe(centre.x + w * 0.5 + 0.1, centre.z - d * 0.5 - 0.66, h, -1.0, M["metal"])
|
|
|
|
# The street face.
|
|
var f := centre.z - d * 0.5 - 0.05
|
|
var ground_win := Vector3(centre.x, 1.35, f)
|
|
var upper_win := Vector3(centre.x, 4.3, f)
|
|
_deco(_box_mesh(Vector3(w * 0.42, 1.9, 0.08)), M["glass"], ground_win)
|
|
_deco(_box_mesh(Vector3(w * 0.5, 1.3, 0.08)), M["glass_dark"], upper_win)
|
|
_window_trim(ground_win, w * 0.42, 1.9, -1.0, M["trim_mat"])
|
|
_window_trim(upper_win, w * 0.5, 1.3, -1.0, M["trim_mat"])
|
|
# Sliding-door and window mullions.
|
|
for t in [-0.34, 0.0, 0.34]:
|
|
_deco(_box_mesh(Vector3(0.07, 1.9, 0.10)), M["trim_mat"],
|
|
ground_win + Vector3(w * 0.42 * t, 0, -0.02))
|
|
# The entrance: a door, its frame, a small canopy over it and a nameplate.
|
|
var door_x := centre.x - w * 0.34
|
|
_deco(_box_mesh(Vector3(0.9, 2.0, 0.1)), M["wood"], Vector3(door_x, 1.0, f))
|
|
_deco(_box_mesh(Vector3(1.06, 2.16, 0.14)), M["wood_dark"], Vector3(door_x, 1.06, f + 0.03))
|
|
_deco(_box_mesh(Vector3(1.5, 0.1, 0.8)), _roof_mat(idx + 1),
|
|
Vector3(door_x, 2.3, f - 0.35), Vector3(-9, 0, 0))
|
|
_deco(_box_mesh(Vector3(0.30, 0.13, 0.04)), M["white_board"],
|
|
Vector3(door_x + 0.66, 1.62, f - 0.06))
|
|
# A ground-floor condenser and a meter, both by the door where they go.
|
|
_ac_unit(Vector3(centre.x + w * 0.36, 1.5, f), -1.0)
|
|
_meter_box(Vector3(door_x - 0.9, 1.2, f), -1.0)
|
|
|
|
# ── The flank walls ──────────────────────────────────────────────────────
|
|
#
|
|
# A detached house is read from the side as often as from the front, and the
|
|
# render showed the gables as some of the largest empty surfaces left in the
|
|
# map. Unlike a shop these do not take an advertisement — a house is not
|
|
# advertising anything — so they get what a house actually has: a small
|
|
# high window on each floor, a downpipe, and the garden wall returning up
|
|
# the plot boundary to cut the wall off at the bottom.
|
|
for s in [-1.0, 1.0]:
|
|
var sx: float = centre.x + s * (w * 0.5 + 0.05)
|
|
for wy in [1.6, 4.4]:
|
|
var wc := Vector3(sx, wy, centre.z - d * 0.18)
|
|
_deco(_box_mesh(Vector3(0.08, 0.95, 1.3)), M["glass_dark"], wc)
|
|
# Sill and lintel, turned to lie on a wall facing along X.
|
|
_deco(_box_mesh(Vector3(0.20, 0.09, 1.5)), M["trim_mat"],
|
|
wc + Vector3(s * 0.05, -0.52, 0))
|
|
_deco(_box_mesh(Vector3(0.14, 0.07, 1.46)), M["trim_mat"],
|
|
wc + Vector3(s * 0.04, 0.51, 0))
|
|
_deco(_cyl_mesh(0.05, 0.05, h - 0.3, 6), M["metal"],
|
|
Vector3(sx + s * 0.07, (h - 0.3) * 0.5 + 0.2, centre.z + d * 0.42))
|
|
# The boundary wall returning down the side of the plot.
|
|
_solid(Vector3(centre.x + s * plot_half, 0.72,
|
|
centre.z - d * 0.5 - 1.7), Vector3(0.24, 1.44, 3.4),
|
|
M["concrete"], "concrete")
|
|
|
|
# Garden wall along the street, with a gap for the gate. Chest high, so it
|
|
# is cover you can shoot over but not run through — the most useful height
|
|
# there is, and also what every one of these houses actually has.
|
|
var wall_z := centre.z - d * 0.5 - 3.4
|
|
var gate_x := centre.x + w * 0.22
|
|
for seg in [Vector2(centre.x - plot_half, gate_x - 1.1),
|
|
Vector2(gate_x + 1.1, centre.x + plot_half)]:
|
|
if seg.y - seg.x < 0.6:
|
|
continue
|
|
_solid(Vector3((seg.x + seg.y) * 0.5, 0.72, wall_z),
|
|
Vector3(seg.y - seg.x, 1.44, 0.28), M["concrete"], "concrete")
|
|
_deco(_box_mesh(Vector3(seg.y - seg.x + 0.12, 0.1, 0.4)), M["concrete_dark"],
|
|
Vector3((seg.x + seg.y) * 0.5, 1.48, wall_z))
|
|
for gs in [-1.0, 1.0]:
|
|
_post(Vector3(gate_x + gs * 1.1, 0.85, wall_z), 0.16, 1.7,
|
|
M["concrete_mid"], "concrete", 4)
|
|
|
|
# The front garden, with a cherry over the wall on every third plot so the
|
|
# canopy overhangs the pavement.
|
|
_slab(Vector2(centre.x, centre.z - d * 0.5 - 1.8), Vector2(plot_w - 0.3, 3.0),
|
|
Y_GRASS, M["grass"], "grass")
|
|
if idx % 3 == 0:
|
|
_cherry_tree(Vector3(centre.x - w * 0.3, Y_GRASS, centre.z - d * 0.5 - 2.0), 1.1)
|
|
else:
|
|
_shrub(Vector3(centre.x - w * 0.3, Y_GRASS, centre.z - d * 0.5 - 2.0))
|
|
|
|
|
|
func _shrub(pos: Vector3) -> void:
|
|
for i in range(3):
|
|
var r := _rng.randf_range(0.55, 0.85)
|
|
_deco(_sphere_mesh(r, 6, 3), M["leaf"] if i % 2 == 0 else M["leaf_deep"],
|
|
pos + Vector3(_rng.randf_range(-0.4, 0.4), r * 0.8, _rng.randf_range(-0.4, 0.4)))
|
|
|
|
|
|
# ── Cherry trees ─────────────────────────────────────────────────────────────
|
|
|
|
## The one thing in the map that has to be right.
|
|
##
|
|
## A cherry tree in this style is NOT a trunk with a green ball on it recoloured
|
|
## pink. Three things make it read:
|
|
##
|
|
## 1. The canopy is a CLUSTER of faceted blobs, not one sphere. Overlapping
|
|
## spheres at different radii give the lumpy silhouette the ink pass then
|
|
## draws around — and the ink is doing most of the work here, because a
|
|
## second difference fires on every blob boundary INSIDE the canopy as well
|
|
## as on its outline, which is exactly where an animator draws.
|
|
## 2. It is lit on the high-key ramp (see the `blossom` material), so its
|
|
## shadow side stays pale pink instead of going grey.
|
|
## 3. The canopy is WIDE and LOW relative to the trunk. A cherry is not a
|
|
## conifer; a tall narrow one reads as a lollipop.
|
|
func _cherry_tree(base: Vector3, scale: float = 1.0) -> void:
|
|
var trunk_h := 2.6 * scale
|
|
var body := _post(base + Vector3(0, trunk_h * 0.5, 0), 0.24 * scale, trunk_h,
|
|
M["trunk_dark"], "wood", 6)
|
|
body.name = "Cherry"
|
|
|
|
# Two or three main limbs, leaning out. They carry the canopy and they show
|
|
# through it from underneath, which is where a player standing under the tree
|
|
# is looking.
|
|
var limbs := 3
|
|
for i in range(limbs):
|
|
var a := TAU * float(i) / float(limbs) + _rng.randf_range(-0.4, 0.4)
|
|
var lean := _rng.randf_range(28.0, 44.0)
|
|
var l := 1.9 * scale
|
|
_deco(_cyl_mesh(0.15 * scale, 0.09 * scale, l, 5), M["trunk"],
|
|
base + Vector3(cos(a) * 0.45 * scale, trunk_h + l * 0.34, sin(a) * 0.45 * scale),
|
|
Vector3(cos(a) * lean, 0, -sin(a) * lean))
|
|
|
|
# The canopy: one broad low centre blob, a ring of smaller ones pushed out
|
|
# and slightly down, and a couple of light-toned caps on top. The reference
|
|
# gets its blossom variation from three tones of pink rather than from a
|
|
# gradient, and so does this.
|
|
var top := base.y + trunk_h + 1.5 * scale
|
|
_deco(_sphere_mesh(2.5 * scale, 7, 4), M["blossom"], Vector3(base.x, top, base.z))
|
|
var blobs := 7
|
|
for i in range(blobs):
|
|
var a := TAU * float(i) / float(blobs) + _rng.randf_range(-0.3, 0.3)
|
|
var r := _rng.randf_range(1.3, 1.9) * scale
|
|
var dist := _rng.randf_range(1.6, 2.3) * scale
|
|
var mat: Material = M["blossom_deep"] if i % 3 == 0 else M["blossom"]
|
|
_deco(_sphere_mesh(r, 6, 3), mat,
|
|
Vector3(base.x + cos(a) * dist,
|
|
top + _rng.randf_range(-0.7, 0.5) * scale,
|
|
base.z + sin(a) * dist))
|
|
for i in range(2):
|
|
_deco(_sphere_mesh(_rng.randf_range(1.0, 1.4) * scale, 6, 3), M["blossom_light"],
|
|
Vector3(base.x + _rng.randf_range(-1.0, 1.0) * scale,
|
|
top + 1.1 * scale,
|
|
base.z + _rng.randf_range(-1.0, 1.0) * scale))
|
|
|
|
# Fallen blossom underneath. A drift, not a disc: the ink pass draws its
|
|
# edge, so it wants an irregular outline, which overlapping rectangles give
|
|
# for nothing.
|
|
for i in range(4):
|
|
var a := _rng.randf_range(0.0, TAU)
|
|
var dd := _rng.randf_range(0.0, 1.8) * scale
|
|
_paint(Vector2(base.x + cos(a) * dd, base.z + sin(a) * dd),
|
|
Vector2(_rng.randf_range(1.6, 2.8) * scale, _rng.randf_range(1.6, 2.8) * scale),
|
|
base.y, M["petal_ground"])
|
|
|
|
|
|
# ── The footbridge ───────────────────────────────────────────────────────────
|
|
|
|
func _build_footbridge() -> void:
|
|
# The map's high ground, and the only place that sees the whole railway.
|
|
#
|
|
# Its two staircases run EAST-WEST, along the lanes, not north-south across
|
|
# them. That is a gameplay decision as much as a real-bridge one: a stair
|
|
# facing the tracks would let a player climb under cover of the bridge deck
|
|
# itself, and the whole point of the strongest position on the map is that
|
|
# getting to it should be seen.
|
|
var y := BRIDGE_DECK_Y
|
|
_solid(Vector3(BRIDGE_X, y - 0.15, 0), Vector3(2.6, 0.3, BRIDGE_SPAN),
|
|
M["concrete_mid"], "metal")
|
|
|
|
# ── Where each stair leaves the deck ─────────────────────────────────────
|
|
#
|
|
# The landings sit at the two ends and each stair leaves SIDEWAYS, along X.
|
|
# That means the parapet — which runs the length of the deck — has to open
|
|
# where a stair meets it. It did not, in the first build: the parapets ran
|
|
# unbroken for the full 22 m, so even a correctly-built flight would have
|
|
# arrived at a solid waist-high wall.
|
|
var land_z := BRIDGE_SPAN * 0.5 - 1.0 # ±10.0
|
|
var gap_lo := land_z - 1.1
|
|
var gap_hi := land_z + 1.1
|
|
# North landing (-Z) exits WEST down the service alley; south (+Z) exits
|
|
# EAST along the verge. So each parapet opens at one end only.
|
|
var west_gap := Vector2(-gap_hi, -gap_lo)
|
|
var east_gap := Vector2(gap_lo, gap_hi)
|
|
|
|
for s in [-1.0, 1.0]:
|
|
var gap: Vector2 = east_gap if s > 0.0 else west_gap
|
|
for seg in _segments(-BRIDGE_SPAN * 0.5, BRIDGE_SPAN * 0.5, [gap]):
|
|
_solid(Vector3(BRIDGE_X + s * 1.3, y + 0.5, (seg.x + seg.y) * 0.5),
|
|
Vector3(0.16, 1.0, seg.y - seg.x), M["metal"], "metal")
|
|
|
|
# A light roof on posts. It reads the bridge from a distance, and it stops a
|
|
# player on the deck being visible from directly above. Posts skip the
|
|
# openings, for the same reason the parapet does.
|
|
for i in range(7):
|
|
var z := -BRIDGE_SPAN * 0.5 + 2.0 + float(i) * (BRIDGE_SPAN - 4.0) / 6.0
|
|
for s in [-1.0, 1.0]:
|
|
var gap: Vector2 = east_gap if s > 0.0 else west_gap
|
|
if z > gap.x - 0.4 and z < gap.y + 0.4:
|
|
continue
|
|
_deco(_cyl_mesh(0.07, 0.07, 2.2, 5), M["metal_dark"],
|
|
Vector3(BRIDGE_X + s * 1.2, y + 1.3, z))
|
|
_deco(_box_mesh(Vector3(3.0, 0.12, BRIDGE_SPAN)), M["metal"],
|
|
Vector3(BRIDGE_X, y + 2.45, 0))
|
|
|
|
# Piers, clear of the track either side.
|
|
for z in [-RAIL_HALF - 0.6, RAIL_HALF + 0.6]:
|
|
_solid(Vector3(BRIDGE_X, y * 0.5, z), Vector3(1.2, y, 1.2), M["concrete"], "concrete")
|
|
|
|
# The landings, and the flights hanging off them. Each flight starts at the
|
|
# landing's outer edge and descends to the ground it serves.
|
|
for s in [-1.0, 1.0]:
|
|
var lz: float = s * land_z
|
|
_solid(Vector3(BRIDGE_X, y - 0.15, lz), Vector3(2.6, 0.3, 2.2),
|
|
M["concrete_mid"], "concrete")
|
|
var out_x := 1.0 if s > 0.0 else -1.0
|
|
var foot := _stair_flight(
|
|
Vector3(BRIDGE_X + out_x * 1.3, y, lz), Y_PAVE, out_x, 2.4, 1)
|
|
# A slab of pavement at the foot, so the flight lands on something
|
|
# level whatever the ground under it happens to be.
|
|
_slab(Vector2(foot.x + out_x * 1.2, foot.z), Vector2(3.4, 2.8), Y_PAVE,
|
|
M["pave_alt"], "concrete")
|
|
|
|
_spawn_points.append(Vector3(BRIDGE_X, y + 1.4, 0))
|
|
|
|
|
|
# ── The station ──────────────────────────────────────────────────────────────
|
|
|
|
func _build_station() -> void:
|
|
# A single side platform on the south track, with a canopy, a waiting room,
|
|
# a ramp up from the south street and a stair off the far end. The third
|
|
# crossing, and the only one that is a room — so it plays as a hold where
|
|
# the crossing plays as a dash.
|
|
var plat_y := 1.1
|
|
var z0 := RAIL_HALF - 1.0
|
|
var pd := 5.6
|
|
var pz := z0 + pd * 0.5
|
|
var plen := 30.0
|
|
|
|
_solid(Vector3(STATION_X, plat_y * 0.5, pz), Vector3(plen, plat_y, pd),
|
|
M["concrete"], "concrete")
|
|
_paint(Vector2(STATION_X, z0 + 0.45), Vector2(plen, 0.9), plat_y, M["tactile"])
|
|
_paint(Vector2(STATION_X, z0 + 1.15), Vector2(plen, 0.1), plat_y, M["line_white"])
|
|
# The platform face, dark, so the platform reads as raised from across the
|
|
# tracks — the angle most players will first see it from.
|
|
_deco(_box_mesh(Vector3(plen, plat_y, 0.1)), M["concrete_dark"],
|
|
Vector3(STATION_X, plat_y * 0.5, z0 - 0.05))
|
|
|
|
# ── The canopy, and why it is shorter than the platform ──────────────────
|
|
#
|
|
# It used to run the platform's full length, and that made the roof
|
|
# unreachable no matter where the access stair went: a stair rising to the
|
|
# canopy from underneath has to pass THROUGH the canopy to get on top of it.
|
|
# Leaving five metres of open platform at the east end gives the stair
|
|
# somewhere to stand, which is also how a real canopy is built — it stops
|
|
# short of the ramp end.
|
|
var can_len := 24.0
|
|
var can_x := STATION_X - 2.0
|
|
var can_e := can_x + can_len * 0.5 # the open end, x = 52
|
|
for i in range(6):
|
|
var x := can_x - can_len * 0.46 + float(i) * (can_len * 0.92 / 5.0)
|
|
for s in [0.0, 1.0]:
|
|
_post(Vector3(x, plat_y + 1.6, z0 + 0.9 + s * 3.4), 0.1, 3.2,
|
|
M["metal_dark"], "metal", 6)
|
|
_solid(Vector3(can_x, plat_y + 3.4, pz + 0.2),
|
|
Vector3(can_len, 0.22, pd - 0.4), M["roof_slate"], "metal")
|
|
# A fascia along the track edge of the canopy — where a station's name board
|
|
# goes, and where the eye lands from the crossing.
|
|
_deco(_box_mesh(Vector3(can_len, 0.5, 0.1)), M["white_board"],
|
|
Vector3(can_x, plat_y + 3.1, z0 + 0.85))
|
|
SakuraSignage.label(_decor, SakuraSignage.STATION_KANJI, 0.34,
|
|
SakuraPalette.INK, Vector3(STATION_X, plat_y + 3.1, z0 + 0.79),
|
|
Vector3(0, 180, 0))
|
|
|
|
# ── 駅名標 ────────────────────────────────────────────────────────────────
|
|
#
|
|
# The station name board, and it is the most-read object on any Japanese
|
|
# platform: the name large in kana, the kanji and the romaji under it, and
|
|
# the neighbouring stations either side with arrows. Two of them, at
|
|
# opposite ends, facing the train — which is to say facing the crossing,
|
|
# which is where the player reads them from.
|
|
for s in [-1.0, 1.0]:
|
|
var bx: float = STATION_X + s * plen * 0.28
|
|
var by := plat_y + 2.0
|
|
var bz := z0 + 0.55
|
|
_deco(_box_mesh(Vector3(3.5, 1.0, 0.1)), M["white_board"], Vector3(bx, by, bz))
|
|
_deco(_box_mesh(Vector3(3.62, 0.11, 0.14)), LevelMaterials.unlit(SakuraPalette.TEAL),
|
|
Vector3(bx, by - 0.55, bz))
|
|
var yaw := 180.0
|
|
SakuraSignage.label(_decor, SakuraSignage.STATION_KANA, 0.30,
|
|
SakuraPalette.INK, Vector3(bx, by + 0.20, bz - 0.06), Vector3(0, yaw, 0))
|
|
SakuraSignage.label(_decor, SakuraSignage.STATION_ROMAJI, 0.15,
|
|
SakuraPalette.INK, Vector3(bx, by - 0.14, bz - 0.06), Vector3(0, yaw, 0))
|
|
# The neighbours, in the corners, with an arrow toward each.
|
|
SakuraSignage.label(_decor, "← " + SakuraSignage.STATION_PREV, 0.12,
|
|
SakuraPalette.TRIM, Vector3(bx + 1.05, by - 0.38, bz - 0.06), Vector3(0, yaw, 0))
|
|
SakuraSignage.label(_decor, SakuraSignage.STATION_NEXT + " →", 0.12,
|
|
SakuraPalette.TRIM, Vector3(bx - 1.05, by - 0.38, bz - 0.06), Vector3(0, yaw, 0))
|
|
# The post it stands on.
|
|
_deco(_cyl_mesh(0.06, 0.06, 1.5, 6), M["metal_dark"],
|
|
Vector3(bx, plat_y + 0.75, bz + 0.02))
|
|
|
|
# Platform number, hanging under the canopy, and a timetable board on the
|
|
# waiting-room wall.
|
|
_deco(_box_mesh(Vector3(0.62, 0.62, 0.08)), LevelMaterials.unlit(SakuraPalette.BLUE),
|
|
Vector3(STATION_X + 6.0, plat_y + 2.66, z0 + 1.4))
|
|
SakuraSignage.label(_decor, "1", 0.36, SakuraPalette.WALL_WHITE,
|
|
Vector3(STATION_X + 6.0, plat_y + 2.66, z0 + 1.35), Vector3(0, 180, 0))
|
|
|
|
# The tactile strip along the platform edge, with its dots.
|
|
_tactile_dots(Vector2(STATION_X, z0 + 0.45), Vector2(plen - 0.6, 0.8), plat_y + 0.02)
|
|
|
|
# Boarding marks painted on the platform: a bracket where each set of doors
|
|
# stops, with 乗車口 beside it. More lettering on the floor, and the detail
|
|
# that most says "platform" rather than "raised walkway".
|
|
for i in range(4):
|
|
var mx := STATION_X - 10.5 + float(i) * 7.0
|
|
_paint(Vector2(mx, z0 + 1.5), Vector2(1.5, 0.09), plat_y, M["line_white"])
|
|
for s in [-1.0, 1.0]:
|
|
_paint(Vector2(mx + s * 0.71, z0 + 1.72), Vector2(0.09, 0.45),
|
|
plat_y, M["line_white"])
|
|
_road_text(SakuraSignage.PLATFORM_MARK, mx, z0 + 2.25, plat_y, 0.34,
|
|
SakuraPalette.LINE_WHITE, 0.0)
|
|
|
|
# The waiting room: a small glazed box and the map's only fully enclosed
|
|
# interior, with two open ends so it cannot become a hole to hide in.
|
|
var wr := Vector3(STATION_X - 8.0, plat_y, pz + 0.6)
|
|
_solid(wr + Vector3(0, 1.35, 1.4), Vector3(5.0, 2.7, 0.16), M["concrete"], "glass")
|
|
for s in [-1.0, 1.0]:
|
|
_solid(wr + Vector3(s * 2.42, 1.35, 0.4), Vector3(0.16, 2.7, 2.0),
|
|
M["concrete"], "glass")
|
|
_deco(_box_mesh(Vector3(4.7, 1.5, 0.06)), M["glass"], wr + Vector3(0, 1.7, 1.31))
|
|
_solid(wr + Vector3(0, 2.78, 0.6), Vector3(5.4, 0.16, 3.2), M["metal"], "metal")
|
|
# A bench, which is also the only crouch-height cover on the platform.
|
|
_solid(wr + Vector3(0, 0.45, 0.6), Vector3(3.4, 0.12, 0.5), M["wood"], "wood")
|
|
for s in [-1.4, 1.4]:
|
|
_deco(_box_mesh(Vector3(0.12, 0.45, 0.45)), M["metal_dark"], wr + Vector3(s, 0.22, 0.6))
|
|
|
|
# Ramp up from the south street at one end, stair off the other, so the
|
|
# platform always has two ways out of it.
|
|
_ramp_up(Vector3(STATION_X + plen * 0.5 - 3.0, Y_PAVE, pz + pd * 0.5 + 3.4),
|
|
plat_y - Y_PAVE, 3.0, 3.4)
|
|
# The stair hangs off the platform's own southern edge and descends away
|
|
# from it — it used to start at that edge and climb outward, so its top step
|
|
# ended a metre clear of the platform in mid-air.
|
|
_stair_flight(Vector3(STATION_X - plen * 0.5 + 2.0, plat_y, pz + pd * 0.5),
|
|
Y_PAVE, 1.0, 2.6, 0)
|
|
|
|
# ── Onto the canopy ──────────────────────────────────────────────────────
|
|
#
|
|
# The docstring at the top of this function calls the station "a roof to
|
|
# fight over", and for a while that was simply untrue: the walkability probe
|
|
# found the canopy as a 46-cell island with no route onto it at all. A
|
|
# maintenance stair at the east end fixes that, and a caged access stair up
|
|
# the end of a platform canopy is a real thing rather than an apology.
|
|
var canopy_top := plat_y + 3.51
|
|
# The flight stands on the OPEN platform beyond the canopy's east end and
|
|
# climbs back onto it, so nothing has to pass through the roof it serves.
|
|
_stair_flight(Vector3(can_e + 0.2, canopy_top, pz + 0.2),
|
|
plat_y, 1.0, 1.6, 1, 0.34)
|
|
# A guard rail round the canopy edge, so the roof is a position and not a
|
|
# slide off the side into the track. Open at the east end, where the stair
|
|
# arrives.
|
|
for s in [-1.0, 1.0]:
|
|
_solid(Vector3(can_x, canopy_top + 0.45, pz + 0.2 + s * (pd - 0.4) * 0.5),
|
|
Vector3(can_len, 0.9, 0.1), M["metal"], "metal")
|
|
_solid(Vector3(can_x - can_len * 0.5, canopy_top + 0.45, pz + 0.2),
|
|
Vector3(0.1, 0.9, pd - 0.4), M["metal"], "metal")
|
|
|
|
_spawn_points.append(Vector3(STATION_X, plat_y + 1.6, pz))
|
|
_spawn_points.append(Vector3(STATION_X + 10.0, plat_y + 1.6, pz + 1.5))
|
|
|
|
|
|
## A shallow ramp — one rotated slab, for wheeled access and for anything that
|
|
## wants to be slid down at speed.
|
|
func _ramp_up(base: Vector3, height: float, width: float, run: float) -> void:
|
|
var angle := rad_to_deg(atan2(height, run))
|
|
var length := sqrt(height * height + run * run)
|
|
_solid(base + Vector3(0, height * 0.5, -run * 0.5), Vector3(width, 0.3, length),
|
|
M["concrete_mid"], "concrete", Vector3(angle, 0, 0))
|
|
for s in [-1.0, 1.0]:
|
|
_solid(base + Vector3(s * (width * 0.5 + 0.1), height * 0.5 + 0.5, -run * 0.5),
|
|
Vector3(0.16, 0.9, length), M["metal"], "metal", Vector3(angle, 0, 0))
|
|
|
|
|
|
# ── The park and the shrine ──────────────────────────────────────────────────
|
|
|
|
func _park_lamp(base: Vector3) -> void:
|
|
var pole := _post(base + Vector3(0, 1.65, 0), 0.09, 3.3,
|
|
M["metal_dark"], "metal", 8)
|
|
pole.name = "ParkLamp"
|
|
_deco(_box_mesh(Vector3(0.72, 0.18, 0.72)), M["metal_dark"],
|
|
base + Vector3(0, 3.26, 0))
|
|
_deco(_box_mesh(Vector3(0.50, 0.42, 0.50)), M["lantern"],
|
|
base + Vector3(0, 3.02, 0))
|
|
_deco(_cyl_mesh(0.13, 0.03, 0.18, 6), M["metal_dark"],
|
|
base + Vector3(0, 3.48, 0))
|
|
|
|
|
|
func _park_bin(base: Vector3) -> void:
|
|
var body := _solid(base + Vector3(0, 0.46, 0), Vector3(0.64, 0.92, 0.58),
|
|
M["metal"], "metal")
|
|
body.name = "ParkBin"
|
|
_deco(_box_mesh(Vector3(0.50, 0.10, 0.42)), M["drain_dark"],
|
|
base + Vector3(0, 0.90, 0))
|
|
_deco(_box_mesh(Vector3(0.30, 0.16, 0.04)), M["white_board"],
|
|
base + Vector3(0, 0.56, -0.31))
|
|
|
|
|
|
func _picnic_table(base: Vector3, yaw: float = 0.0) -> void:
|
|
var pivot := Node3D.new()
|
|
pivot.position = base
|
|
pivot.rotation_degrees.y = yaw
|
|
_attach(pivot)
|
|
pivot.name = "PicnicTable"
|
|
var prev := _group
|
|
_group = pivot
|
|
_solid(Vector3(0, 0.78, 0), Vector3(3.1, 0.16, 1.05), M["wood"], "wood")
|
|
for z in [-1.02, 1.02]:
|
|
_solid(Vector3(0, 0.48, z), Vector3(3.2, 0.14, 0.48), M["wood"], "wood")
|
|
for x in [-1.05, 1.05]:
|
|
for z in [-0.56, 0.56]:
|
|
_solid(Vector3(x, 0.38, z), Vector3(0.16, 0.76, 0.16),
|
|
M["metal_dark"], "metal", Vector3(0, 0, 12.0 * signf(z)))
|
|
_group = prev
|
|
|
|
|
|
func _seesaw(base: Vector3, yaw: float = 0.0) -> void:
|
|
var pivot := Node3D.new()
|
|
pivot.name = "Seesaw"
|
|
pivot.position = base
|
|
pivot.rotation_degrees.y = yaw
|
|
_attach(pivot)
|
|
var prev := _group
|
|
_group = pivot
|
|
_post(Vector3(0, 0.52, 0), 0.18, 1.04, M["metal_dark"], "metal", 8)
|
|
_solid(Vector3(0, 0.98, 0), Vector3(5.0, 0.18, 0.34),
|
|
M["red"], "wood", Vector3(0, 0, 6.0))
|
|
for x in [-2.05, 2.05]:
|
|
_solid(Vector3(x, 1.24 + x * 0.045, 0), Vector3(0.54, 0.12, 0.62),
|
|
M["wood"], "wood", Vector3(0, 0, 6.0))
|
|
_post(Vector3(x - signf(x) * 0.35, 1.50 + x * 0.045, 0), 0.05, 0.55,
|
|
M["metal_dark"], "metal", 6)
|
|
_group = prev
|
|
|
|
|
|
func _climbing_frame(base: Vector3) -> void:
|
|
var marker := Node3D.new()
|
|
marker.name = "ClimbingFrame"
|
|
marker.position = base
|
|
_attach(marker)
|
|
for x in [-1.55, 1.55]:
|
|
for z in [-1.30, 1.30]:
|
|
_post(base + Vector3(x, 1.25, z), 0.08, 2.5, M["metal_dark"], "metal", 8)
|
|
for z in [-1.30, 1.30]:
|
|
_solid(base + Vector3(0, 2.46, z), Vector3(3.2, 0.13, 0.13),
|
|
M["red"], "metal")
|
|
for x in [-1.55, 1.55]:
|
|
_solid(base + Vector3(x, 2.46, 0), Vector3(0.13, 0.13, 2.7),
|
|
M["red"], "metal")
|
|
for i in range(5):
|
|
var x := -1.20 + float(i) * 0.60
|
|
_solid(base + Vector3(x, 2.46, 0), Vector3(0.08, 0.08, 2.6),
|
|
M["metal"], "metal")
|
|
|
|
|
|
func _komainu(base: Vector3, facing: float = -1.0) -> void:
|
|
var pedestal := _solid(base + Vector3(0, 0.34, 0), Vector3(1.10, 0.68, 1.10),
|
|
M["stone"], "concrete")
|
|
pedestal.name = "Komainu"
|
|
_deco(_sphere_mesh(0.48, 7, 4), M["stone_warm"], base + Vector3(0, 1.05, 0))
|
|
_deco(_sphere_mesh(0.37, 7, 4), M["stone_warm"],
|
|
base + Vector3(0, 1.55, facing * 0.15))
|
|
for x in [-0.22, 0.22]:
|
|
_deco(_cyl_mesh(0.09, 0.03, 0.23, 4), M["stone_dark"],
|
|
base + Vector3(x, 1.88, facing * 0.12))
|
|
_deco(_box_mesh(Vector3(0.34, 0.10, 0.16)), M["stone_dark"],
|
|
base + Vector3(0, 1.40, facing * 0.48))
|
|
|
|
|
|
func _shrine_banner(base: Vector3, text: String = "奉納") -> void:
|
|
var pole := _post(base + Vector3(0, 1.65, 0), 0.055, 3.3,
|
|
M["wood_dark"], "wood", 6)
|
|
pole.name = "ShrineBanner"
|
|
_deco(_box_mesh(Vector3(0.78, 2.05, 0.04)), M["wall_cream"],
|
|
base + Vector3(0.44, 2.15, 0))
|
|
_deco(_box_mesh(Vector3(0.10, 2.05, 0.06)), M["torii"],
|
|
base + Vector3(0.09, 2.15, 0))
|
|
SakuraSignage.vertical_label(_decor, text, 0.23, SakuraPalette.RED_DEEP,
|
|
base + Vector3(0.45, 2.94, -0.035), Vector3(0, 180, 0))
|
|
|
|
|
|
func _omikuji_rack(base: Vector3) -> void:
|
|
var marker := Node3D.new()
|
|
marker.name = "OmikujiRack"
|
|
marker.position = base
|
|
_attach(marker)
|
|
for x in [-1.8, 1.8]:
|
|
_post(base + Vector3(x, 1.1, 0), 0.09, 2.2, M["wood_dark"], "wood", 6)
|
|
for y in [0.75, 1.18, 1.61, 2.04]:
|
|
_solid(base + Vector3(0, y, 0), Vector3(3.7, 0.06, 0.08), M["wood"], "wood")
|
|
for i in range(15):
|
|
var x := -1.55 + float(i % 8) * 0.44
|
|
var y := 0.89 + float(i / 8) * 0.66
|
|
_deco(_box_mesh(Vector3(0.07, 0.34, 0.03)), M["white_board"],
|
|
base + Vector3(x, y, -0.06))
|
|
|
|
|
|
func _mini_shrine(base: Vector3) -> void:
|
|
var body := _solid(base + Vector3(0, 1.05, 0), Vector3(2.2, 1.8, 1.8),
|
|
M["wood"], "wood")
|
|
body.name = "Hokora"
|
|
_deco(_prism_mesh(Vector3(3.0, 1.15, 2.7)), M["roof_shrine"],
|
|
base + Vector3(0, 2.45, 0))
|
|
_deco(_box_mesh(Vector3(3.2, 0.15, 2.9)), M["roof_shrine"],
|
|
base + Vector3(0, 1.90, 0))
|
|
_deco(_box_mesh(Vector3(0.95, 1.25, 0.10)), M["wall_cream"],
|
|
base + Vector3(0, 1.05, -0.96))
|
|
_solid(base + Vector3(0, 0.16, -1.35), Vector3(2.8, 0.32, 1.4),
|
|
M["stone"], "concrete")
|
|
|
|
|
|
func _sacred_tree(base: Vector3) -> void:
|
|
var trunk := _post(base + Vector3(0, 2.5, 0), 0.62, 5.0,
|
|
M["trunk_dark"], "wood", 9)
|
|
trunk.name = "SacredTree"
|
|
for p in [Vector3(-1.5, 5.0, 0), Vector3(1.3, 5.3, 0.2), Vector3(0, 6.0, -0.4)]:
|
|
_deco(_sphere_mesh(2.5, 8, 4), M["leaf_deep"], base + p)
|
|
_deco(_cyl_mesh(0.72, 0.72, 0.16, 10), M["rope"], base + Vector3(0, 2.55, 0))
|
|
for x in [-0.42, 0.0, 0.42]:
|
|
_deco(_box_mesh(Vector3(0.16, 0.52, 0.04)), M["white_board"],
|
|
base + Vector3(x, 2.18, -0.64), Vector3(0, 0, 12.0 * signf(x)))
|
|
|
|
|
|
func _build_park() -> void:
|
|
# Grass, split around the main street: a lawn does not run across a road.
|
|
# The park is the WEST half of the south side now — the east half is the
|
|
# housing district the railway's turn opened up, and the two would otherwise
|
|
# be laid on top of each other.
|
|
for seg in _segments(-HALF_X + 12.0, EAST_DIST_X0 - 4.0,
|
|
[Vector2(-ROAD_HALF - PAVE_W - 0.3, ROAD_HALF + PAVE_W + 0.3)]):
|
|
_slab(Vector2((seg.x + seg.y) * 0.5, PARK_Z), Vector2(seg.y - seg.x, 20.0),
|
|
Y_GRASS, M["grass"], "grass")
|
|
|
|
# The playground, shrine approach and south-street pavement now form one
|
|
# continuous pedestrian sequence instead of three isolated clearings.
|
|
var play_entry_z := PARK_Z - 8.0
|
|
_slab(Vector2((-72.0 + SHRINE_X) * 0.5, play_entry_z),
|
|
Vector2(absf(-72.0 - SHRINE_X), 3.0), Y_GRASS + 0.025,
|
|
M["gravel"], "gravel")
|
|
_slab(Vector2(SHRINE_X, play_entry_z), Vector2(9.0, 7.0),
|
|
Y_GRASS + 0.03, M["stone"], "concrete")
|
|
|
|
# The shrine sits on its own axis, deliberately NOT the main street's: an
|
|
# approach you have to turn off the road to find is worth more than one you
|
|
# can see down from the crossing, and it gives the south-west corner a
|
|
# destination of its own.
|
|
# The 参道 runs the whole way from the south street's pavement to the shrine
|
|
# steps — 27.2 is the outer edge of that pavement. It used to start at the
|
|
# park boundary, which left the approach beginning nowhere and reachable
|
|
# only by walking around the houses.
|
|
var approach_z0 := 27.2
|
|
var approach_z1 := PRECINCT_Z1 - 7.0
|
|
_slab(Vector2(SHRINE_X, (approach_z0 + approach_z1) * 0.5),
|
|
Vector2(5.0, approach_z1 - approach_z0), Y_GRASS + 0.02,
|
|
M["gravel"], "gravel")
|
|
# Grass either side of the stretch that crosses the old house row, so the
|
|
# approach reads as part of the shrine's ground rather than as a path laid
|
|
# over bare earth.
|
|
for s in [-1.0, 1.0]:
|
|
_slab(Vector2(SHRINE_X + s * 6.0, (approach_z0 + 40.0) * 0.5),
|
|
Vector2(7.0, 40.0 - approach_z0), Y_GRASS, M["grass"], "grass")
|
|
# Low flanking walls, which is what a 参道 actually has, and which stop the
|
|
# approach reading as a strip of gravel in an open field.
|
|
for s in [-1.0, 1.0]:
|
|
_solid(Vector3(SHRINE_X + s * 2.8, Y_GRASS + 0.35,
|
|
(approach_z0 + PARK_Z - 9.0) * 0.5),
|
|
Vector3(0.35, 0.7, PARK_Z - 9.0 - approach_z0),
|
|
M["stone"], "concrete")
|
|
|
|
# The torii straddling the path. The single most saturated object in the map
|
|
# and the only one allowed to be — it is what the eye goes to from anywhere
|
|
# on the south side.
|
|
_torii(Vector3(SHRINE_X, Y_GRASS, PARK_Z - 8.0), 1.15)
|
|
for s in [-1.0, 1.0]:
|
|
_komainu(Vector3(SHRINE_X + s * 5.0, Y_GRASS, PARK_Z - 4.5), 1.0)
|
|
_shrine_banner(Vector3(SHRINE_X + s * 6.2, Y_GRASS, PARK_Z + 1.0),
|
|
"奉納" if s < 0.0 else "桜守")
|
|
|
|
# The hall itself now closes the precinct at the far end of this axis; keeping
|
|
# this first lawn open lets the torii and lantern procession read together.
|
|
# Stone lanterns flanking the path — small, dark, hard props that break up
|
|
# an otherwise open lawn.
|
|
for i in range(4):
|
|
for s in [-1.0, 1.0]:
|
|
_stone_lantern(Vector3(SHRINE_X + s * 4.2, Y_GRASS, PARK_Z - 6.0 + float(i) * 3.6))
|
|
|
|
# The cherry grove: the densest blossom in the map, deliberately, so the
|
|
# south lane's long angles are broken by cover you can move through but not
|
|
# see through.
|
|
for i in range(12):
|
|
var a := TAU * float(i) / 12.0
|
|
var r := _rng.randf_range(8.0, 12.0)
|
|
var p := Vector3(cos(a) * r * 2.2, Y_GRASS, PARK_Z + sin(a) * r * 0.7)
|
|
# The grove has to stay inside the park's own ground. It still spanned
|
|
# x = +-26 after the park lawn was cut back to the west half, so its
|
|
# eastern trees were growing in the east district's street.
|
|
if absf(p.x) < 8.0 or absf(p.x - SHRINE_X) < 3.6 or p.x > EAST_DIST_X0 - 6.0 or p.x < -HALF_X + 14.0:
|
|
continue
|
|
_cherry_tree(p, _rng.randf_range(0.95, 1.45))
|
|
|
|
for i in range(4):
|
|
var bx := -14.0 + float(i) * 14.0
|
|
if absf(bx) < 8.0:
|
|
continue
|
|
_solid(Vector3(bx, 0.55, PARK_Z - 7.0), Vector3(2.4, 0.12, 0.5), M["wood"], "wood")
|
|
for s in [-0.95, 0.95]:
|
|
_deco(_box_mesh(Vector3(0.14, 0.5, 0.5)), M["metal_dark"],
|
|
Vector3(bx + s, 0.35, PARK_Z - 7.0))
|
|
|
|
# Secondary destinations fill the two lawns without compromising the clear
|
|
# ceremonial axis: a sacred tree and hokora to discover, and a picnic node
|
|
# linking the playground to the shrine.
|
|
_sacred_tree(Vector3(-46.0, Y_GRASS, PARK_Z + 6.0))
|
|
_mini_shrine(Vector3(-11.5, Y_GRASS, PARK_Z + 7.0))
|
|
_picnic_table(Vector3(-48.0, Y_GRASS, PARK_Z - 3.0), 90.0)
|
|
_park_bin(Vector3(-55.0, Y_GRASS, play_entry_z + 2.3))
|
|
for x in [-62.0, -49.0, -36.0]:
|
|
_park_lamp(Vector3(x, Y_GRASS, play_entry_z - 2.3))
|
|
|
|
_build_park_playground()
|
|
|
|
_spawn_points.append(Vector3(-16, 1.6, PARK_Z))
|
|
_spawn_points.append(Vector3(18, 1.6, PARK_Z + 4.0))
|
|
|
|
|
|
func _build_park_playground() -> void:
|
|
# A compact neighbourhood playground with a real entrance, perimeter and
|
|
# activity zones. Every moving/play surface is derived from shared datums so
|
|
# chains meet beams, slides meet platforms and borders close at their corners.
|
|
var c := Vector2(-72.0, PARK_Z + 1.0)
|
|
_slab(c, Vector2(30.0, 17.0), Y_GRASS + 0.015, M["dirt"], "gravel")
|
|
_slab(Vector2(-72.0, (27.2 + PARK_Z - 7.0) * 0.5),
|
|
Vector2(3.2, PARK_Z - 7.0 - 27.2), Y_GRASS + 0.025,
|
|
M["gravel"], "gravel")
|
|
|
|
# The name board sits in a planted verge west of the entrance, never across
|
|
# the entry path or on the south-street pavement.
|
|
for x in [-97.2, -90.8]:
|
|
var post := _post(Vector3(x, 1.15, PARK_Z - 6.5), 0.10, 2.3,
|
|
M["metal_dark"], "metal", 6)
|
|
post.name = "ParkSign"
|
|
post.set_meta("ground_sign", true)
|
|
_sign_board(Vector3(-94.0, 1.72, PARK_Z - 6.5), -1.0,
|
|
"さくら児童公園", 0.28, SakuraPalette.WALL_SAGE, SakuraPalette.INK, 0.26)
|
|
|
|
# Low perimeter rail with an eight-metre opening aligned to the entry path.
|
|
for z in [PARK_Z - 7.5, PARK_Z + 9.5]:
|
|
var spans := [Vector2(-87.0, -76.0), Vector2(-68.0, -57.0)] \
|
|
if z < PARK_Z else [Vector2(-87.0, -57.0)]
|
|
for span in spans:
|
|
_solid(Vector3((span.x + span.y) * 0.5, 0.72, z),
|
|
Vector3(span.y - span.x, 0.10, 0.10), M["wood_dark"], "wood")
|
|
for x in [span.x, span.y]:
|
|
_post(Vector3(x, 0.62, z), 0.07, 1.24, M["wood_dark"], "wood", 6)
|
|
for x in [-87.0, -57.0]:
|
|
_solid(Vector3(x, 0.72, PARK_Z + 1.0), Vector3(0.10, 0.10, 17.0),
|
|
M["wood_dark"], "wood")
|
|
for z in [PARK_Z - 7.5, PARK_Z + 1.0, PARK_Z + 9.5]:
|
|
_post(Vector3(x, 0.62, z), 0.07, 1.24, M["wood_dark"], "wood", 6)
|
|
|
|
# Two-bay swing. A single overhead beam establishes the chain positions;
|
|
# four supports land directly below its ends and every seat uses two chains.
|
|
var swing_z := PARK_Z + 0.5
|
|
var swing_x0 := -85.0
|
|
var swing_x1 := -76.0
|
|
for x in [swing_x0, swing_x1]:
|
|
for z in [swing_z - 1.15, swing_z + 1.15]:
|
|
_post(Vector3(x, 1.55, z), 0.11, 3.1, M["metal_dark"], "metal", 8)
|
|
var swing_beam := _solid(Vector3((swing_x0 + swing_x1) * 0.5, 3.05, swing_z),
|
|
Vector3(swing_x1 - swing_x0 + 0.3, 0.18, 0.18), M["metal_dark"], "metal")
|
|
swing_beam.name = "SwingBeam"
|
|
for seat_x in [-82.5, -78.5]:
|
|
for chain_x in [seat_x - 0.52, seat_x + 0.52]:
|
|
var chain := _deco(_cyl_mesh(0.025, 0.025, 1.55, 6), M["metal"],
|
|
Vector3(chain_x, 2.24, swing_z))
|
|
chain.name = "SwingChain"
|
|
var seat := _solid(Vector3(seat_x, 1.45, swing_z),
|
|
Vector3(1.25, 0.10, 0.50), M["wood"], "wood")
|
|
seat.name = "SwingSeat"
|
|
|
|
# Slide, platform and ladder share the same top height and z datum.
|
|
var slide_x := -61.5
|
|
var slide_top_z := PARK_Z + 3.0
|
|
var slide_top := 1.35
|
|
var platform_depth := 2.2
|
|
var slide_platform := _solid(Vector3(slide_x, slide_top, slide_top_z),
|
|
Vector3(2.4, 0.18, platform_depth), M["metal"], "metal")
|
|
slide_platform.name = "SlidePlatform"
|
|
for x in [slide_x - 1.0, slide_x + 1.0]:
|
|
_post(Vector3(x, slide_top * 0.5, slide_top_z), 0.09, slide_top,
|
|
M["metal_dark"], "metal", 8)
|
|
var run := 4.6
|
|
var drop := slide_top - 0.13
|
|
var slope_len := sqrt(run * run + drop * drop)
|
|
# Put the incline's high endpoint on the near platform edge instead of
|
|
# burying half of the slide inside the platform.
|
|
var slope_cz := slide_top_z - platform_depth * 0.5 - run * 0.5
|
|
var slide_slope := _solid(Vector3(slide_x, slide_top * 0.5 + 0.06, slope_cz),
|
|
Vector3(1.1, 0.14, slope_len),
|
|
M["metal"], "metal", Vector3(-rad_to_deg(atan2(drop, run)), 0, 0))
|
|
slide_slope.name = "SlideSlope"
|
|
for x in [slide_x - 0.58, slide_x + 0.58]:
|
|
_deco(_box_mesh(Vector3(0.09, 0.34, slope_len)), M["red"],
|
|
Vector3(x, slide_top * 0.5 + 0.22, slope_cz),
|
|
Vector3(-rad_to_deg(atan2(drop, run)), 0, 0))
|
|
# Ladder behind the platform, with rungs landing between its two uprights.
|
|
for x in [slide_x - 0.75, slide_x + 0.75]:
|
|
_post(Vector3(x, slide_top * 0.5, slide_top_z + 1.45), 0.06,
|
|
slide_top + 0.3, M["metal_dark"], "metal", 8)
|
|
for y in [0.35, 0.68, 1.01, 1.34]:
|
|
_solid(Vector3(slide_x, y, slide_top_z + 1.45), Vector3(1.55, 0.07, 0.07),
|
|
M["metal"], "metal")
|
|
|
|
# Sandpit with a closed four-sided timber curb.
|
|
var sand := Vector2(-63.0, PARK_Z + 7.2)
|
|
var sandpit := _slab(sand, Vector2(8.0, 3.6),
|
|
Y_GRASS + 0.025, M["stone_warm"], "gravel")
|
|
sandpit.name = "Sandpit"
|
|
for s in [-1.0, 1.0]:
|
|
_solid(Vector3(sand.x + s * 4.0, 0.18, sand.y),
|
|
Vector3(0.18, 0.34, 4.2), M["wood"], "wood")
|
|
_solid(Vector3(sand.x, 0.18, sand.y + s * 1.8),
|
|
Vector3(8.2, 0.34, 0.18), M["wood"], "wood")
|
|
|
|
# More than one kind of play: climbing frame, seesaw and stepping pods form
|
|
# a central activity band without blocking the entrance or slide runout.
|
|
_climbing_frame(Vector3(-71.5, Y_GRASS, PARK_Z - 2.0))
|
|
_seesaw(Vector3(-73.0, Y_GRASS, PARK_Z + 6.3), 8.0)
|
|
for i in range(5):
|
|
_deco(_cyl_mesh(0.42, 0.42, 0.10, 10),
|
|
M["red"] if i % 2 == 0 else M["yellow"],
|
|
Vector3(-68.5 + float(i) * 1.2, Y_GRASS + 0.07, PARK_Z - 5.2))
|
|
|
|
# Drinking fountain, two benches, bins and lamps support the play space.
|
|
_solid(Vector3(-84.8, 0.70, PARK_Z + 7.0), Vector3(0.65, 1.35, 0.65),
|
|
M["stone"], "concrete")
|
|
_deco(_box_mesh(Vector3(0.45, 0.10, 0.45)), M["water"],
|
|
Vector3(-84.8, 1.40, PARK_Z + 7.0))
|
|
for bench_x in [-82.0, -62.0]:
|
|
_solid(Vector3(bench_x, 0.58, PARK_Z - 6.0), Vector3(3.6, 0.14, 0.56),
|
|
M["wood"], "wood")
|
|
for x in [bench_x - 1.45, bench_x + 1.45]:
|
|
_deco(_box_mesh(Vector3(0.14, 0.55, 0.52)), M["metal_dark"],
|
|
Vector3(x, 0.35, PARK_Z - 6.0))
|
|
_park_bin(Vector3(-76.0, Y_GRASS, PARK_Z - 5.8))
|
|
_park_lamp(Vector3(-85.0, Y_GRASS, PARK_Z - 5.8))
|
|
_park_lamp(Vector3(-59.0, Y_GRASS, PARK_Z - 5.8))
|
|
|
|
|
|
func _torii(base: Vector3, scale: float) -> void:
|
|
var h := 5.4 * scale
|
|
var w := 4.6 * scale
|
|
for s in [-1.0, 1.0]:
|
|
_post(base + Vector3(s * w * 0.5, h * 0.5, 0), 0.24 * scale, h, M["torii"], "wood", 8)
|
|
# The kasagi — the top lintel, wider than the pillars, in two members.
|
|
_deco(_box_mesh(Vector3(w + 1.9 * scale, 0.34 * scale, 0.6 * scale)), M["torii"],
|
|
base + Vector3(0, h + 0.1 * scale, 0))
|
|
_deco(_box_mesh(Vector3(w + 2.3 * scale, 0.2 * scale, 0.7 * scale)), M["torii"],
|
|
base + Vector3(0, h + 0.34 * scale, 0))
|
|
# The nuki — the lower tie beam — and the strut between the two.
|
|
_deco(_box_mesh(Vector3(w + 0.7 * scale, 0.26 * scale, 0.4 * scale)), M["torii"],
|
|
base + Vector3(0, h - 1.1 * scale, 0))
|
|
_deco(_box_mesh(Vector3(0.4 * scale, 1.2 * scale, 0.34 * scale)), M["torii"],
|
|
base + Vector3(0, h - 0.5 * scale, 0))
|
|
|
|
|
|
func _stone_lantern(base: Vector3) -> void:
|
|
_deco(_cyl_mesh(0.42, 0.34, 0.3, 6), M["stone"], base + Vector3(0, 0.15, 0))
|
|
_deco(_cyl_mesh(0.17, 0.17, 1.0, 6), M["stone"], base + Vector3(0, 0.8, 0))
|
|
_deco(_box_mesh(Vector3(0.62, 0.5, 0.62)), M["stone"], base + Vector3(0, 1.55, 0))
|
|
# The light box is drawn, not lit: a flat warm block inside a stone frame.
|
|
_deco(_box_mesh(Vector3(0.44, 0.36, 0.44)), M["lantern"], base + Vector3(0, 1.55, 0))
|
|
# A four-sided cone is a pyramid, which is the cap a 灯籠 actually has.
|
|
_deco(_cyl_mesh(0.62, 0.06, 0.36, 4), M["stone_warm"], base + Vector3(0, 1.98, 0))
|
|
_deco(_sphere_mesh(0.13, 6, 3), M["stone_warm"], base + Vector3(0, 2.2, 0))
|
|
|
|
|
|
# ── Street furniture ─────────────────────────────────────────────────────────
|
|
|
|
func _build_street_furniture() -> void:
|
|
# Vending machines. The reference is right that these are the single most
|
|
# characteristic prop of a Japanese street, and they are also perfect cover:
|
|
# 1.9 m tall, 1.1 wide, hard, and they glow, so they read at any distance.
|
|
# ── Where a vending machine actually stands ──────────────────────────────
|
|
#
|
|
# Against the BACK of the pavement, with its lit face turned to the road.
|
|
# The first pass hand-placed them at "street centre ± (road half + 1.4)",
|
|
# which is the exact middle of a 2.6 m pavement — so every one of them stood
|
|
# in the walking line with its back to half the people passing. They belong
|
|
# against the boundary they are served from, like every other piece of
|
|
# street furniture in the map.
|
|
#
|
|
# [east-west?, street centre, road half, side, distance along]
|
|
var spots := [
|
|
[false, 0.0, ROAD_HALF, -1.0, -RAIL_HALF - 5.0],
|
|
[false, 0.0, ROAD_HALF, 1.0, RAIL_HALF + 5.0],
|
|
[true, SHOP_ST_Z, SHOP_ST_HALF, 1.0, -24.0],
|
|
[true, SHOP_ST_Z, SHOP_ST_HALF, -1.0, 18.0],
|
|
[true, SOUTH_ST_Z, SOUTH_ST_HALF, -1.0, STATION_X - 18.0],
|
|
[true, SOUTH_ST_Z, SOUTH_ST_HALF, 1.0, -34.0],
|
|
[true, SOUTH_ST_Z, SOUTH_ST_HALF, 1.0, 26.0],
|
|
]
|
|
for i in range(spots.size()):
|
|
_vending_on_street(bool(spots[i][0]), float(spots[i][1]), float(spots[i][2]),
|
|
float(spots[i][3]), float(spots[i][4]), (i * 2) % 3)
|
|
|
|
# Utility poles and their wires. These are the map's vertical grid — the
|
|
# reference leans on them hard, and without them a low suburban skyline has
|
|
# nothing crossing it.
|
|
var prev := Vector3.ZERO
|
|
var have_prev := false
|
|
var px := -HALF_X + 6.0
|
|
while px < HALF_X:
|
|
if absf(px) > ROAD_HALF + 2.0:
|
|
var top := _utility_pole(Vector3(px, Y_PAVE, SOUTH_ST_Z + SOUTH_ST_HALF + 1.9))
|
|
if have_prev:
|
|
_wire(prev, top)
|
|
prev = top
|
|
have_prev = true
|
|
else:
|
|
have_prev = false
|
|
px += 16.0
|
|
|
|
# Bollards along the crossing approach, and a convex traffic mirror on the
|
|
# corner — both real, both small hard silhouettes at eye level.
|
|
for s in [-1.0, 1.0]:
|
|
for i in range(4):
|
|
for sx in [-1.0, 1.0]:
|
|
_deco(_cyl_mesh(0.09, 0.09, 0.85, 6), M["yellow"],
|
|
Vector3(sx * (ROAD_HALF + 0.5), Y_PAVE + 0.42,
|
|
s * (RAIL_HALF + 2.8 + float(i) * 2.2)))
|
|
_traffic_mirror(Vector3(-ROAD_HALF - 1.8, Y_PAVE, -RAIL_HALF - 2.4))
|
|
|
|
# Bicycles in a rack outside the station.
|
|
# West of the station ramp, not on it: the rack used to run x = 54 to 57.5
|
|
# at z = 13.6, and the ramp occupies x = 54 +- 1.5 over z = 11.6 to 15.0.
|
|
for i in range(6):
|
|
_bicycle(Vector3(STATION_X - 14.0 + float(i) * 0.7, Y_PAVE, SOUTH_ST_Z - 6.4))
|
|
|
|
# Street name plates at the junctions. Small, high, and the thing that tells
|
|
# a player which road they are actually on. Their poles stand in the verge
|
|
# BEHIND the pavement; the old offsets put all four directly on sidewalk
|
|
# corners, including one beside a zebra crossing.
|
|
var main_verge := ROAD_HALF + PAVE_W + 1.05
|
|
var plates := [
|
|
[Vector3(main_verge, 2.5,
|
|
SHOP_ST_Z + SHOP_ST_HALF + PAVE_W + 1.05), 0.0, 0],
|
|
[Vector3(-main_verge, 2.5,
|
|
SOUTH_ST_Z - SOUTH_ST_HALF - PAVE_W - 1.05), 0.0, 1],
|
|
[Vector3(main_verge, 2.5, RAIL_HALF + 4.0), 90.0, 2],
|
|
[Vector3(-main_verge, 2.5, -RAIL_HALF - 4.0), 90.0, 3],
|
|
]
|
|
for p in plates:
|
|
var pos: Vector3 = p[0]
|
|
var yaw: float = p[1]
|
|
var sign_post := _post(pos - Vector3(0, 1.25, 0), 0.05, 2.5,
|
|
M["metal"], "metal", 6)
|
|
sign_post.name = "StreetSign"
|
|
sign_post.set_meta("ground_sign", true)
|
|
_street_plate(pos, SakuraSignage.STREET_NAMES[int(p[2])], yaw)
|
|
|
|
# A public notice board by the crossing, and a litter bin on the platform
|
|
# approach. Both are the sort of thing that is always there and is never
|
|
# modelled, which is precisely why the absence of them is felt.
|
|
_deco(_box_mesh(Vector3(2.2, 1.3, 0.1)), M["white_board"],
|
|
Vector3(-ROAD_HALF - 4.6, 1.5, -RAIL_HALF - 4.2))
|
|
_deco(_box_mesh(Vector3(2.36, 0.14, 0.16)), M["wood_dark"],
|
|
Vector3(-ROAD_HALF - 4.6, 2.22, -RAIL_HALF - 4.2))
|
|
SakuraSignage.label(_decor, "お知らせ", 0.19, SakuraPalette.INK,
|
|
Vector3(-ROAD_HALF - 4.6, 1.94, -RAIL_HALF - 4.28), Vector3(0, 180, 0))
|
|
for s in [-1.0, 1.0]:
|
|
_post(Vector3(-ROAD_HALF - 4.6 + s * 1.0, 0.6, -RAIL_HALF - 4.2), 0.06, 1.2,
|
|
M["wood_dark"], "wood", 6)
|
|
|
|
|
|
## Stand a machine against the back edge of a pavement, facing the road.
|
|
##
|
|
## `side` is which side of the street: for an east-west street +1 is the south
|
|
## pavement, for a north-south street +1 is the east one. The yaw follows from
|
|
## that, so a machine can never end up with its back to the carriageway.
|
|
func _vending_on_street(east_west: bool, centre: float, road_half: float,
|
|
side: float, along: float, kind: int) -> void:
|
|
# Back edge of the pavement, less half the machine's depth.
|
|
var off := (road_half + 0.12 + PAVE_W - 0.45) * side
|
|
if east_west:
|
|
# side +1 sits south of the road, so it looks north: yaw 180.
|
|
_vending(Vector3(along, Y_PAVE, centre + off), kind,
|
|
180.0 if side > 0.0 else 0.0)
|
|
else:
|
|
# side +1 sits east of the road, so it looks west: yaw 270.
|
|
_vending(Vector3(centre + off, Y_PAVE, along), kind,
|
|
270.0 if side > 0.0 else 90.0)
|
|
|
|
|
|
func _vending(base: Vector3, kind: int, yaw: float) -> void:
|
|
var panel: Material = [M["vend_red"], M["vend_teal"], M["blue"]][kind]
|
|
var b := _solid(base + Vector3(0, 0.95, 0), Vector3(1.1, 1.9, 0.72),
|
|
M["vend_white"], "metal", Vector3(0, yaw, 0))
|
|
b.name = "Vending"
|
|
var yr := Vector3(0, yaw, 0)
|
|
var fwd := Vector3(sin(deg_to_rad(yaw)), 0, cos(deg_to_rad(yaw)))
|
|
# The lit display: a flat bright panel with a grid of drink blocks on it.
|
|
_deco(_box_mesh(Vector3(0.92, 1.05, 0.04)), panel,
|
|
base + Vector3(0, 1.32, 0) + fwd * 0.37, yr)
|
|
for r in range(3):
|
|
for c in range(4):
|
|
var col: Color = SakuraPalette.DRINKS[
|
|
(kind * 5 + r * 4 + c) % SakuraPalette.DRINKS.size()]
|
|
var off := Vector3((float(c) - 1.5) * 0.21, 1.62 - float(r) * 0.3, 0)
|
|
off = off.rotated(Vector3.UP, deg_to_rad(yaw))
|
|
_deco(_box_mesh(Vector3(0.16, 0.22, 0.03)), LevelMaterials.unlit(col),
|
|
base + off + fwd * 0.40, yr)
|
|
# The price strip under each shelf — the reference calls these out
|
|
# by name, and they are most of what makes a vending machine read as
|
|
# a vending machine rather than as a lit box.
|
|
var price: String = SakuraSignage.PRICES[(r + c) % SakuraSignage.PRICES.size()]
|
|
SakuraSignage.label(_decor, price, 0.055, SakuraPalette.INK,
|
|
base + off + Vector3(0, -0.135, 0).rotated(Vector3.UP, deg_to_rad(yaw))
|
|
+ fwd * 0.42, yr)
|
|
_deco(_box_mesh(Vector3(0.92, 0.2, 0.05)), M["ink"],
|
|
base + Vector3(0, 0.42, 0) + fwd * 0.37, yr)
|
|
_deco(_box_mesh(Vector3(0.92, 0.1, 0.04)), M["lantern"],
|
|
base + Vector3(0, 1.86, 0) + fwd * 0.37, yr)
|
|
# The brand band across the top, and the coin/return furniture down the side
|
|
# — small, dark, and what stops the front being one flat lit panel.
|
|
SakuraSignage.label(_decor, "自販機", 0.13, SakuraPalette.WALL_WHITE,
|
|
base + Vector3(0, 0.73, 0) + fwd * 0.40, yr)
|
|
_deco(_box_mesh(Vector3(0.20, 0.30, 0.04)), M["metal_dark"],
|
|
base + Vector3(0.34, 1.00, 0).rotated(Vector3.UP, deg_to_rad(yaw)) + fwd * 0.38, yr)
|
|
_deco(_box_mesh(Vector3(0.30, 0.10, 0.04)), M["metal"],
|
|
base + Vector3(-0.24, 0.94, 0).rotated(Vector3.UP, deg_to_rad(yaw)) + fwd * 0.38, yr)
|
|
|
|
|
|
func _utility_pole(base: Vector3) -> Vector3:
|
|
var h := 8.4
|
|
_post(base + Vector3(0, h * 0.5, 0), 0.13, h, M["concrete_dark"], "concrete", 6)
|
|
# Two crossarms and a transformer can. The can is what makes the pole read
|
|
# as a utility pole rather than as a flagpole.
|
|
for i in range(2):
|
|
var dy := h - 0.7 - float(i) * 0.9
|
|
_deco(_box_mesh(Vector3(0.09, 0.09, 2.2)), M["wood_dark"], base + Vector3(0, dy, 0))
|
|
for s in [-0.9, 0.9]:
|
|
_deco(_cyl_mesh(0.07, 0.07, 0.2, 5), M["glass"],
|
|
base + Vector3(0, dy + 0.15, s))
|
|
_deco(_cyl_mesh(0.28, 0.28, 0.8, 6), M["metal_dark"], base + Vector3(0.3, h - 2.4, 0))
|
|
# Cable drops down the pole, the banded ID plate every pole carries, and a
|
|
# wrap-around advert — all of which turn a bare cylinder into a utility pole.
|
|
for s in [-1.0, 1.0]:
|
|
_deco(_box_mesh(Vector3(0.045, h - 3.2, 0.045)), M["ink"],
|
|
base + Vector3(s * 0.15, (h - 3.2) * 0.5 + 0.4, 0.14))
|
|
_deco(_box_mesh(Vector3(0.30, 0.44, 0.02)), M["white_board"],
|
|
base + Vector3(0, 2.5, 0.145))
|
|
_deco(_box_mesh(Vector3(0.34, 0.9, 0.02)), LevelMaterials.unlit(SakuraPalette.RED),
|
|
base + Vector3(0, 4.1, 0.145))
|
|
SakuraSignage.vertical_label(_decor, "電柱", 0.20, SakuraPalette.WALL_WHITE,
|
|
base + Vector3(0, 4.42, 0.16))
|
|
return base + Vector3(0, h - 0.7, 0)
|
|
|
|
|
|
## A slack wire between two pole tops, drawn as two straight segments through a
|
|
## sag point. Two is enough: at this thickness the eye reads the droop, not the
|
|
## tessellation.
|
|
func _wire(a: Vector3, b: Vector3) -> void:
|
|
var pts := [a, (a + b) * 0.5 - Vector3(0, 0.9, 0), b]
|
|
for i in range(pts.size() - 1):
|
|
var p0: Vector3 = pts[i]
|
|
var p1: Vector3 = pts[i + 1]
|
|
var mid := (p0 + p1) * 0.5
|
|
var mi := _deco(_box_mesh(Vector3(0.05, 0.05, (p1 - p0).length())), M["ink"], mid)
|
|
mi.look_at_from_position(mid, p1, Vector3.UP)
|
|
|
|
|
|
func _traffic_mirror(base: Vector3) -> void:
|
|
_post(base + Vector3(0, 1.5, 0), 0.07, 3.0, M["metal"], "metal", 6)
|
|
_deco(_cyl_mesh(0.62, 0.62, 0.12, 10), M["mirror_back"],
|
|
base + Vector3(0, 3.0, 0), Vector3(72, 0, 0))
|
|
_deco(_cyl_mesh(0.54, 0.54, 0.06, 10), M["glass"],
|
|
base + Vector3(0, 3.02, -0.09), Vector3(72, 0, 0))
|
|
|
|
|
|
func _bicycle(base: Vector3) -> void:
|
|
# The two wheels are separated along Z, so this bicycle's frame runs along
|
|
# Z and its axles run along X. (90, 0, 0) put them along Z — in line with
|
|
# the frame, which is a unicycle at best.
|
|
for s in [-0.5, 0.5]:
|
|
_deco(_cyl_mesh(0.33, 0.33, 0.04, 10), M["ink"],
|
|
base + Vector3(0, 0.33, s), Vector3(0, 0, 90))
|
|
_deco(_box_mesh(Vector3(0.05, 0.05, 0.95)), M["teal"], base + Vector3(0, 0.55, 0))
|
|
_deco(_box_mesh(Vector3(0.05, 0.42, 0.05)), M["teal"], base + Vector3(0, 0.72, -0.4))
|
|
_deco(_box_mesh(Vector3(0.06, 0.06, 0.44)), M["ink"],
|
|
base + Vector3(0, 0.93, -0.4), Vector3(0, 90, 0))
|
|
_deco(_box_mesh(Vector3(0.22, 0.07, 0.3)), M["ink"], base + Vector3(0, 0.86, 0.28))
|
|
|
|
|
|
# ── Boundary ─────────────────────────────────────────────────────────────────
|
|
|
|
## A municipal road closure at a boundary opening. The striped rail is an
|
|
## explicit visual full stop, but low enough to vault or grapple over so the
|
|
## movement system and five-second recovery rule still work.
|
|
func _boundary_road_gate(pos: Vector3, yaw: float, width: float = 11.5) -> void:
|
|
var a := deg_to_rad(yaw)
|
|
var across := Vector3(cos(a), 0, -sin(a))
|
|
var forward := Vector3(sin(a), 0, cos(a))
|
|
var yr := Vector3(0, yaw, 0)
|
|
for s in [-1.0, 1.0]:
|
|
var foot: Vector3 = pos + across * (float(s) * width * 0.5)
|
|
_post(foot + Vector3(0, 0.82, 0), 0.16, 1.64,
|
|
M["black"], "metal", 8)
|
|
_deco(_box_mesh(Vector3(0.48, 0.12, 0.48)), M["yellow"],
|
|
foot + Vector3(0, 1.68, 0))
|
|
_deco(_cyl_mesh(0.12, 0.12, 0.12, 8), M["signal_red"],
|
|
foot + Vector3(0, 1.86, 0))
|
|
|
|
var stripes := 10
|
|
var seg := width / float(stripes)
|
|
for i in range(stripes):
|
|
var mat: Material = M["yellow"] if i % 2 == 0 else M["black"]
|
|
var beam := _solid(pos + across * (-width * 0.5 + seg * (float(i) + 0.5))
|
|
+ Vector3(0, 1.08, 0), Vector3(seg + 0.03, 0.20, 0.20),
|
|
mat, "metal", yr, "BoundaryGate")
|
|
beam.set_meta("boundary_gate", true)
|
|
|
|
var panel_pos := pos + Vector3(0, 1.56, 0)
|
|
_deco(_box_mesh(Vector3(3.7, 0.70, 0.12)), M["white_board"], panel_pos, yr)
|
|
for face in [-1.0, 1.0]:
|
|
SakuraSignage.label(_decor, "この先 立入禁止", 0.27, SakuraPalette.RED_DEEP,
|
|
panel_pos + forward * (face * 0.075),
|
|
Vector3(0, yaw + (180.0 if face < 0.0 else 0.0), 0))
|
|
|
|
|
|
func _build_boundary() -> void:
|
|
# A real edge, not an invisible wall: a concrete flood wall, a cedar
|
|
# treeline behind it, and hills behind that — so the boundary reads as where
|
|
# the town stops rather than as where the level does.
|
|
var h := 5.0
|
|
# ── Where the railway leaves ─────────────────────────────────────────────
|
|
#
|
|
# The wall has to open for it, at both ends. Straight out through the west
|
|
# wall at z = 0, and — since the line now turns — out through the EAST wall
|
|
# at whatever z the curve has reached by the time it gets there. That is
|
|
# computed from the path rather than written down, so moving the curve
|
|
# cannot leave the track clipping through solid concrete.
|
|
# ── The opening has to be as wide as the CROSSING, not as the corridor ───
|
|
#
|
|
# The line leaves the west wall square on, so there a half-gap of
|
|
# RAIL_HALF + 1.5 is right. It leaves the EAST wall at 55 degrees, and a
|
|
# fourteen-metre corridor crossing a wall at that angle needs an opening of
|
|
# 14 / cos(55°) ≈ 24 m in the wall's own direction, not 14. Cutting the
|
|
# straight-on figure is why the wall clipped into the railway.
|
|
var west_gap := Vector2(-RAIL_HALF - 1.5, RAIL_HALF + 1.5)
|
|
var east_gap := west_gap
|
|
for entry in _rail_path():
|
|
var p: Vector2 = entry[0]
|
|
if p.x >= HALF_X - 0.6 and p.x <= HALF_X + 0.6:
|
|
var t: Vector2 = entry[1]
|
|
var half: float = (RAIL_HALF + 1.5) / maxf(absf(t.x), 0.2)
|
|
east_gap = Vector2(p.y - half, p.y + half)
|
|
break
|
|
|
|
# The roads leave too. A wall that seals every street at the map edge is the
|
|
# clearest possible statement that there is nothing beyond it; opening it
|
|
# where a carriageway meets it — and running the carriageway on out into the
|
|
# district beyond — is most of what makes the boundary read as the edge of a
|
|
# NEIGHBOURHOOD rather than the edge of the world.
|
|
var road_gap := ROAD_HALF + PAVE_W + 0.6
|
|
var ew_gaps: Array = [
|
|
Vector2(SHOP_ST_Z - SHOP_ST_HALF - PAVE_W - 0.6,
|
|
SHOP_ST_Z + SHOP_ST_HALF + PAVE_W + 0.6),
|
|
Vector2(SOUTH_ST_Z - SOUTH_ST_HALF - PAVE_W - 0.6,
|
|
SOUTH_ST_Z + SOUTH_ST_HALF + PAVE_W + 0.6),
|
|
]
|
|
|
|
for s in [-1.0, 1.0]:
|
|
var side_gaps: Array = [east_gap if s > 0.0 else west_gap]
|
|
# The south street runs out both sides; the shopping street only west,
|
|
# since east of SHOP_ST_X1 it does not exist.
|
|
side_gaps.append(ew_gaps[1])
|
|
if s < 0.0:
|
|
side_gaps.append(ew_gaps[0])
|
|
for seg in _segments(-HALF_Z - 1.0, HALF_Z + 1.0, side_gaps):
|
|
_solid(Vector3(s * HALF_X, h * 0.5, (seg.x + seg.y) * 0.5),
|
|
Vector3(1.0, h, seg.y - seg.x), M["concrete"], "concrete")
|
|
_deco(_box_mesh(Vector3(1.3, 0.24, seg.y - seg.x)), M["concrete_dark"],
|
|
Vector3(s * HALF_X, h + 0.1, (seg.x + seg.y) * 0.5))
|
|
# The north wall opens twice: for the main street, and for the park
|
|
# road, which leaves on the far side of the park at PARK_RD_EXIT_X.
|
|
var ns_gaps: Array = [Vector2(-road_gap, road_gap)]
|
|
if s < 0.0:
|
|
ns_gaps.append(Vector2(PARK_RD_EXIT_X - road_gap,
|
|
PARK_RD_EXIT_X + road_gap))
|
|
for seg in _segments(-HALF_X - 1.0, HALF_X + 1.0, ns_gaps):
|
|
_solid(Vector3((seg.x + seg.y) * 0.5, h * 0.5, s * HALF_Z),
|
|
Vector3(seg.y - seg.x, h, 1.0), M["concrete"], "concrete")
|
|
_deco(_box_mesh(Vector3(seg.y - seg.x, 0.24, 1.3)), M["concrete_dark"],
|
|
Vector3((seg.x + seg.y) * 0.5, h + 0.1, s * HALF_Z))
|
|
|
|
# Every road aperture gets a closure on the wall centreline. The railway
|
|
# apertures stay open because the rails, fence and overhead wire already say
|
|
# "infrastructure", while a clean road otherwise promises more playable map.
|
|
_boundary_road_gate(Vector3(-HALF_X, 0, SHOP_ST_Z), 90.0)
|
|
_boundary_road_gate(Vector3(-HALF_X, 0, SOUTH_ST_Z), 90.0)
|
|
_boundary_road_gate(Vector3(HALF_X, 0, SOUTH_ST_Z), 90.0)
|
|
_boundary_road_gate(Vector3(0, 0, -HALF_Z), 0.0)
|
|
_boundary_road_gate(Vector3(0, 0, HALF_Z), 0.0)
|
|
_boundary_road_gate(Vector3(PARK_RD_EXIT_X, 0, -HALF_Z), 0.0)
|
|
|
|
_build_beyond()
|
|
|
|
# Treeline: a dense band of dark cedar just behind the wall, which is what
|
|
# actually hides the seam.
|
|
for i in range(72):
|
|
var p: Vector3
|
|
match i % 4:
|
|
0: p = Vector3(_rng.randf_range(-HALF_X, HALF_X), 0,
|
|
-HALF_Z - _rng.randf_range(3.0, 13.0))
|
|
1: p = Vector3(_rng.randf_range(-HALF_X, HALF_X), 0,
|
|
HALF_Z + _rng.randf_range(3.0, 13.0))
|
|
2: p = Vector3(-HALF_X - _rng.randf_range(3.0, 13.0), 0,
|
|
_rng.randf_range(-HALF_Z, HALF_Z))
|
|
_: p = Vector3(HALF_X + _rng.randf_range(3.0, 13.0), 0,
|
|
_rng.randf_range(-HALF_Z, HALF_Z))
|
|
# This loop is where the trees standing in the middle of the road came
|
|
# from: it predates the roads leaving the map and never knew about them.
|
|
# The same is true of the railway, which no longer runs along z = 0.
|
|
if _near_road_for_tree(Vector2(p.x, p.z)) or _near_railway(Vector2(p.x, p.z)):
|
|
continue
|
|
_cedar(p, _rng.randf_range(0.8, 1.5))
|
|
|
|
_build_outfield()
|
|
|
|
# Distant hills, in two rings at different values. That value step between
|
|
# near ridge and far ridge is the whole of aerial perspective in a cel
|
|
# image, and it costs sixteen meshes.
|
|
#
|
|
# They are squashed hard on Y and stretched on X, and sunk until only the
|
|
# top of each shows. A sphere left round reads as a boulder sitting on the
|
|
# horizon — what makes a RIDGE is that it is much wider than it is tall and
|
|
# that its base is never visible.
|
|
# `preview_backdrop` tells map_preview_capture these are painted scenery and
|
|
# not level, so they stay out of the bounds it frames the level-select card
|
|
# from. Without it the merged AABB is a kilometre across and the card comes
|
|
# out as a diorama floating in an empty sky.
|
|
for i in range(9):
|
|
var a := TAU * float(i) / 9.0
|
|
var mi := _deco(_sphere_mesh(_rng.randf_range(44.0, 68.0), 9, 4), M["hill"],
|
|
Vector3(cos(a) * 230.0, -34.0, sin(a) * 205.0))
|
|
mi.scale = Vector3(2.1, 0.62, 1.4)
|
|
mi.set_meta("preview_backdrop", true)
|
|
for i in range(7):
|
|
var a := TAU * (float(i) + 0.5) / 7.0
|
|
var mi := _deco(_sphere_mesh(_rng.randf_range(70.0, 105.0), 9, 4), M["hill_far"],
|
|
Vector3(cos(a) * 380.0, -66.0, sin(a) * 345.0))
|
|
mi.scale = Vector3(2.4, 0.58, 1.5)
|
|
mi.set_meta("preview_backdrop", true)
|
|
|
|
|
|
## The country outside the walls: real, walkable, and hilly.
|
|
##
|
|
## Everything here is SOLID, unlike the painted ridges on the horizon. That is
|
|
## the whole point — the combat volume already tells a player they have left,
|
|
## and the timer already kills them for staying out, but neither of those is any
|
|
## use if the moment they clear the wall they drop through the bottom of the
|
|
## world. Now they land on a hillside.
|
|
##
|
|
## It is deliberately empty: rolling ground, scattered cedar, no cover and no
|
|
## reason to be there. It should read as somewhere the map stops, not as more
|
|
## map.
|
|
func _build_outfield() -> void:
|
|
var inner_x := HALF_X + 6.0
|
|
var inner_z := HALF_Z + 6.0
|
|
|
|
# ── No separate turf layer out here ──────────────────────────────────────
|
|
#
|
|
# There used to be four grass bands laid over the margin at y = 0.04, from
|
|
# when the base plate was a pale `dirt`. The plate is grass now, so those
|
|
# bands were both redundant AND four centimetres proud of it — which is
|
|
# exactly the hard line visible where the extended ground met the old
|
|
# ground. One continuous surface, one elevation, no seam.
|
|
|
|
# ── Hills, and only beyond the suburb ────────────────────────────────────
|
|
#
|
|
# Every mound is pushed out until its NEAR EDGE clears HILL_START, so no
|
|
# hillside can reach back into the built-up ring. Checking the centre is not
|
|
# enough for a disc forty metres across — that was the bug that put
|
|
# hillsides through the outfield's houses and across its roads.
|
|
for i in range(84):
|
|
var a := TAU * float(i) / 84.0 + _rng.randf_range(-0.04, 0.04)
|
|
var dir := Vector2(cos(a), sin(a))
|
|
var r := _rng.randf_range(20.0, 44.0)
|
|
var p := dir * (HILL_START + r)
|
|
# Walk out along the ray until the whole disc is past HILL_START.
|
|
while _dist_to_town(p) - r < HILL_START and p.length() < 900.0:
|
|
p += dir * 6.0
|
|
p += dir * _rng.randf_range(0.0, 90.0)
|
|
if _near_railway(p, r + RAIL_HALF + 4.0):
|
|
continue
|
|
_mound(p, r, _rng.randf_range(6.0, 18.0))
|
|
# ── Cedar, in a band hugging the wall ────────────────────────────────────
|
|
#
|
|
# Only the first fifteen metres. Scattered across the whole margin they
|
|
# landed in the middle of the roads leaving the map and inside the buildings
|
|
# beyond it — a tree standing in a carriageway is the most conspicuous
|
|
# possible way to say "this scenery does not know what it is standing on".
|
|
# The distance is carried by the mounds and the city instead, and this is
|
|
# just the treeline outside the boundary.
|
|
for i in range(140):
|
|
var a := TAU * float(i) / 140.0 + _rng.randf_range(-0.03, 0.03)
|
|
var dir := Vector2(cos(a), sin(a))
|
|
# An absolute band four to twelve metres past the wall — between the
|
|
# boundary and where the suburb's first buildings start.
|
|
var p := dir * Vector2(inner_x, inner_z)
|
|
while _dist_to_town(p) < 4.0 and p.length() < 900.0:
|
|
p += dir * 2.0
|
|
p += dir * _rng.randf_range(0.0, 8.0)
|
|
if _near_road_for_tree(p) or _near_railway(p):
|
|
continue
|
|
_cedar(Vector3(p.x, 0.0, p.y), _rng.randf_range(0.9, 1.7))
|
|
|
|
|
|
## ── The city beyond the wall ─────────────────────────────────────────────────
|
|
##
|
|
## Everything here is outside the play volume and none of it is meant to be
|
|
## reached. It exists so that standing at the crossing and looking down any of
|
|
## the four roads shows a town CONTINUING rather than stopping.
|
|
##
|
|
## ── It is a grid, aligned to the roads that actually leave ───────────────────
|
|
##
|
|
## The first version placed rows of blocks flanking imaginary streets that had
|
|
## nothing to do with the real ones — so the main street ran out through its gap
|
|
## in the wall and straight into the side of a building. The layout is now a
|
|
## proper grid whose lines ARE the continuing roads: x = 0 is the main street,
|
|
## z = SOUTH_ST_Z is the south street, and the rest of the grid is stepped off
|
|
## those at a fixed pitch. Blocks only ever fill the cells between, so no road
|
|
## can run into anything.
|
|
##
|
|
## ── And it is built out of the same architecture ─────────────────────────────
|
|
##
|
|
## Plain boxes with window bands read as a different game from the shophouses
|
|
## and pitched-roof houses inside the wall. The buildings out here use cut-down
|
|
## versions of the same two forms: parapet-and-awning shophouses on the street
|
|
## frontages, pitched-roof houses with eaves behind them. One collider each,
|
|
## everything else drawn — they are scenery, not level.
|
|
const BEYOND_PITCH := 46.0 # block + street
|
|
const BEYOND_STREET := 11.0
|
|
const BEYOND_RINGS := 4 # how far the grid extends past the wall
|
|
|
|
|
|
func _build_beyond() -> void:
|
|
# Stops inside the suburb ring, so no carriageway ever runs into a hillside.
|
|
var road_out := CITY_REACH + 20.0
|
|
|
|
# ── The roads, continuing ────────────────────────────────────────────────
|
|
# Main street north and south, the south street east and west, and the
|
|
# shopping street west. Same width, same paint, AND the same kerb and
|
|
# pavement — without those the carriageway simply changed into a strip of
|
|
# grey at the wall, which is why the roads read as not connecting.
|
|
for s in [-1.0, 1.0]:
|
|
_slab(Vector2(0, s * (HALF_Z + road_out * 0.5)),
|
|
Vector2(ROAD_HALF * 2, road_out), Y_ROAD_NS, M["road"], "concrete")
|
|
_street_edges(false, 0.0, ROAD_HALF, HALF_Z - 1.0, HALF_Z + road_out, []) \
|
|
if s > 0.0 else \
|
|
_street_edges(false, 0.0, ROAD_HALF, -HALF_Z - road_out, -HALF_Z + 1.0, [])
|
|
_slab(Vector2(s * (HALF_X + road_out * 0.5), SOUTH_ST_Z),
|
|
Vector2(road_out, SOUTH_ST_HALF * 2), Y_ROAD_EW, M["road"], "concrete")
|
|
_street_edges(true, SOUTH_ST_Z, SOUTH_ST_HALF, HALF_X - 1.0,
|
|
HALF_X + road_out, []) if s > 0.0 else \
|
|
_street_edges(true, SOUTH_ST_Z, SOUTH_ST_HALF, -HALF_X - road_out,
|
|
-HALF_X + 1.0, [])
|
|
var z := HALF_Z + 4.0
|
|
while z < HALF_Z + road_out - 4.0:
|
|
_paint(Vector2(0, s * z), Vector2(0.16, 3.0), Y_ROAD_NS, M["line_white"])
|
|
z += 6.0
|
|
var x := HALF_X + 4.0
|
|
while x < HALF_X + road_out - 4.0:
|
|
_paint(Vector2(s * x, SOUTH_ST_Z), Vector2(3.0, 0.16), Y_ROAD_EW,
|
|
M["line_white"])
|
|
x += 6.0
|
|
_slab(Vector2(-(HALF_X + road_out * 0.5), SHOP_ST_Z),
|
|
Vector2(road_out, SHOP_ST_HALF * 2), Y_ROAD_EW, M["road"], "concrete")
|
|
_street_edges(true, SHOP_ST_Z, SHOP_ST_HALF, -HALF_X - road_out,
|
|
-HALF_X + 1.0, [])
|
|
|
|
_beyond_grid()
|
|
|
|
# ── The skyline ──────────────────────────────────────────────────────────
|
|
# Taller slabs well back, so the horizon has a city on it. Unlit and pale,
|
|
# like the ridges: at this distance they are atmosphere.
|
|
# Well beyond the hills, so nothing stands in one.
|
|
for i in range(18):
|
|
var a := TAU * float(i) / 18.0 + 0.19
|
|
var hh := _rng.randf_range(30.0, 70.0)
|
|
var d := _rng.randf_range(1.35, 1.75)
|
|
_deco(_box_mesh(Vector3(_rng.randf_range(16.0, 34.0), hh,
|
|
_rng.randf_range(16.0, 34.0))), M["hill"],
|
|
Vector3(cos(a) * (HALF_X + OUTFIELD) * d, hh * 0.5,
|
|
sin(a) * (HALF_Z + OUTFIELD) * d))
|
|
|
|
|
|
## The grid itself: streets stepped off the two roads that leave the map, and a
|
|
## block in every cell that is clear of the town, the railway and the roads.
|
|
func _beyond_grid() -> void:
|
|
var lim_x := HALF_X + OUTFIELD + 40.0
|
|
var lim_z := HALF_Z + OUTFIELD + 40.0
|
|
var path := _rail_path()
|
|
|
|
# Street centrelines. Anchored on the real roads so the grid lines up with
|
|
# what comes out of the wall.
|
|
var xs: Array[float] = []
|
|
var k := -BEYOND_RINGS
|
|
while k <= BEYOND_RINGS:
|
|
xs.append(0.0 + float(k) * BEYOND_PITCH)
|
|
k += 1
|
|
var zs: Array[float] = []
|
|
k = -BEYOND_RINGS
|
|
while k <= BEYOND_RINGS:
|
|
zs.append(SOUTH_ST_Z + float(k) * BEYOND_PITCH)
|
|
k += 1
|
|
|
|
# Draw the beyond-only streets, outside the town only. The two real ones are
|
|
# already laid above, at their own width.
|
|
for gx in xs:
|
|
if absf(gx) < 1.0:
|
|
continue
|
|
for seg in _segments(-lim_z, lim_z, [Vector2(-HALF_Z - 8.0, HALF_Z + 8.0)]):
|
|
_slab(Vector2(gx, (seg.x + seg.y) * 0.5),
|
|
Vector2(BEYOND_STREET, seg.y - seg.x), Y_ROAD_EW,
|
|
M["road"], "concrete")
|
|
for gz in zs:
|
|
if absf(gz - SOUTH_ST_Z) < 1.0:
|
|
continue
|
|
for seg in _segments(-lim_x, lim_x, [Vector2(-HALF_X - 8.0, HALF_X + 8.0)]):
|
|
_slab(Vector2((seg.x + seg.y) * 0.5, gz),
|
|
Vector2(seg.y - seg.x, BEYOND_STREET), Y_ROAD_EW,
|
|
M["road"], "concrete")
|
|
|
|
# A building block in every cell of that grid.
|
|
for i in range(xs.size() - 1):
|
|
for j in range(zs.size() - 1):
|
|
var x0: float = xs[i] + BEYOND_STREET * 0.5
|
|
var x1: float = xs[i + 1] - BEYOND_STREET * 0.5
|
|
var z0: float = zs[j] + BEYOND_STREET * 0.5
|
|
var z1: float = zs[j + 1] - BEYOND_STREET * 0.5
|
|
_beyond_cell(x0, x1, z0, z1, path)
|
|
|
|
|
|
## One city block: a frontage of shophouses on the two long sides, houses in the
|
|
## middle. Skipped wherever it would sit on the town, the railway or a road.
|
|
func _beyond_cell(x0: float, x1: float, z0: float, z1: float, path: Array) -> void:
|
|
var cx := (x0 + x1) * 0.5
|
|
var cz := (z0 + z1) * 0.5
|
|
# Clear of the walled town — with a margin, so nothing crowds the wall.
|
|
if absf(cx) < HALF_X + 14.0 and absf(cz) < HALF_Z + 14.0:
|
|
return
|
|
# And inside the suburb ring. Past CITY_REACH the ground is hills, and a
|
|
# building out there would be standing in one.
|
|
if _dist_to_town(Vector2(cx, cz)) > CITY_REACH:
|
|
return
|
|
# Clear of the railway, wherever it happens to run out here.
|
|
var rad := maxf(x1 - x0, z1 - z0) * 0.5 + 14.0
|
|
for e in path:
|
|
var p: Vector2 = e[0]
|
|
if absf(p.x - cx) < rad and absf(p.y - cz) < rad:
|
|
return
|
|
|
|
# ── Shophouses along the two street frontages ────────────────────────────
|
|
#
|
|
# Positioned by their FRONT, not their centre. Putting the centre on the
|
|
# cell edge left half of every building — four and a half metres of it —
|
|
# standing in the carriageway, which is what a road running into a house
|
|
# actually looked like.
|
|
var shop_d := 9.0
|
|
for side in [-1.0, 1.0]:
|
|
var fz: float = cz + side * ((z1 - z0) * 0.5)
|
|
var x := x0 + 2.0
|
|
var idx := int(absf(cx) + absf(cz)) % 8
|
|
while x < x1 - 8.0:
|
|
var w := _rng.randf_range(7.0, 12.0)
|
|
_beyond_shop(Vector3(x + w * 0.5, 0, fz - side * shop_d * 0.5),
|
|
w - 0.4, shop_d, side, idx)
|
|
x += w
|
|
idx += 1
|
|
|
|
# One row of houses down the middle of the block, clear of both frontages.
|
|
var hx := x0 + 6.0
|
|
while hx < x1 - 10.0:
|
|
var w := _rng.randf_range(8.0, 11.0)
|
|
_beyond_house(Vector3(hx + w * 0.5, 0, cz), w - 0.6, 8.0)
|
|
hx += w + 2.5
|
|
|
|
|
|
## A shophouse, cut down for distance: body, parapet, fascia band, awning and a
|
|
## sign. The same silhouette as the ones inside the wall, at a tenth the cost.
|
|
func _beyond_shop(centre: Vector3, w: float, d: float, facing: float,
|
|
idx: int) -> void:
|
|
var storeys := 2 if (idx % 3) != 0 else 3
|
|
var h := float(storeys) * FLOOR_H
|
|
var front := centre.z + facing * d * 0.5
|
|
_solid(centre + Vector3(0, h * 0.5, 0), Vector3(w, h, d), _wall_mat(idx),
|
|
"concrete")
|
|
# Parapet — the flat-roofed shophouse's defining edge.
|
|
_deco(_box_mesh(Vector3(w + 0.4, 0.7, d + 0.4)), _roof_mat(idx),
|
|
centre + Vector3(0, h + 0.35, 0))
|
|
# Ground-floor glazing under an awning, and a white fascia over it.
|
|
_deco(_box_mesh(Vector3(w - 1.0, 2.4, 0.1)), M["glass"],
|
|
Vector3(centre.x, 1.4, front + facing * 0.06))
|
|
var awn: Color = SakuraPalette.AWNINGS[(idx * 3 + 1) % SakuraPalette.AWNINGS.size()]
|
|
_deco(_box_mesh(Vector3(w - 0.6, 0.36, 1.5)),
|
|
LevelMaterials.cel(awn, LevelMaterials.RAMP_2, SakuraPalette.TINT_WARM),
|
|
Vector3(centre.x, 3.0, front + facing * 0.8))
|
|
_deco(_box_mesh(Vector3(w - 0.6, 0.6, 0.1)), M["white_board"],
|
|
Vector3(centre.x, 3.6, front + facing * 0.1))
|
|
# A vertical sign on the corner, in one of the drink colours the shopfronts
|
|
# inside the wall use.
|
|
_deco(_box_mesh(Vector3(0.7, h - 4.6, 0.12)),
|
|
LevelMaterials.unlit(SakuraPalette.DRINKS[(idx * 5) % SakuraPalette.DRINKS.size()]),
|
|
Vector3(centre.x + w * 0.5 - 0.6, h * 0.5 + 1.7, front + facing * 0.18))
|
|
# Upper window bands.
|
|
for s_i in range(1, storeys):
|
|
_deco(_box_mesh(Vector3(w - 1.4, 1.5, 0.08)), M["glass_dark"],
|
|
Vector3(centre.x, float(s_i) * FLOOR_H + 1.9, front + facing * 0.05))
|
|
|
|
|
|
## A detached house, cut down for distance: body, pitched roof, eaves, ridge and
|
|
## a garden wall. Same form as the ones on the south side.
|
|
func _beyond_house(centre: Vector3, w: float, d: float) -> void:
|
|
var h := 6.2
|
|
var idx := int(absf(centre.x) + absf(centre.z)) % 8
|
|
_solid(centre + Vector3(0, h * 0.5, 0), Vector3(w, h, d), _wall_mat(idx + 3),
|
|
"concrete")
|
|
var roof := _roof_mat(idx + 1)
|
|
_deco(_prism_mesh(Vector3(w + 1.2, 2.0, d + 1.2)), roof,
|
|
centre + Vector3(0, h + 1.0, 0))
|
|
_deco(_box_mesh(Vector3(w + 1.4, 0.18, d + 1.4)), roof,
|
|
centre + Vector3(0, h + 0.05, 0))
|
|
_deco(_box_mesh(Vector3(0.34, 0.22, d + 1.5)), M["concrete_dark"],
|
|
centre + Vector3(0, h + 2.02, 0))
|
|
_deco(_box_mesh(Vector3(w * 0.5, 1.6, 0.08)), M["glass_dark"],
|
|
centre + Vector3(0, 4.2, -d * 0.5 - 0.05))
|
|
_deco(_box_mesh(Vector3(w * 0.45, 1.9, 0.08)), M["glass"],
|
|
centre + Vector3(0, 1.35, -d * 0.5 - 0.05))
|
|
_deco(_box_mesh(Vector3(w + 1.0, 1.3, 0.24)), M["concrete"],
|
|
centre + Vector3(0, 0.65, -d * 0.5 - 2.6))
|
|
|
|
|
|
## Is this point within `margin` of the running line, anywhere along it?
|
|
##
|
|
## Roads are describable as a handful of rectangles; the railway is not, because
|
|
## it turns. Scenery has to be tested against the actual path or it ends up
|
|
## between the rails — which is precisely where the boundary treeline planted
|
|
## itself once the line stopped being a straight run along z = 0.
|
|
func _near_railway(p: Vector2, margin: float = RAIL_HALF + 2.0) -> bool:
|
|
for e in _rail_path():
|
|
var q: Vector2 = e[0]
|
|
# Cheap rejection first: the path has several hundred samples and this
|
|
# runs for every tree in the map.
|
|
if absf(q.x - p.x) > margin or absf(q.y - p.y) > margin:
|
|
continue
|
|
if q.distance_to(p) < margin:
|
|
return true
|
|
return false
|
|
|
|
|
|
## Is this point on any carriageway — inside the town, leaving it, or part of
|
|
## the grid beyond? Used to keep scenery out of the roads.
|
|
func _on_any_road(p: Vector2) -> bool:
|
|
if absf(p.x) < ROAD_HALF + PAVE_W + 2.5:
|
|
return true
|
|
if absf(p.y - SOUTH_ST_Z) < SOUTH_ST_HALF + PAVE_W + 2.5:
|
|
return true
|
|
if p.x < -HALF_X and absf(p.y - SHOP_ST_Z) < SHOP_ST_HALF + PAVE_W + 2.5:
|
|
return true
|
|
if _near_park_road(p, SHOP_ST_HALF + 2.5):
|
|
return true
|
|
# The beyond grid: streets every BEYOND_PITCH off the two real roads.
|
|
var gx: float = absf(p.x - roundf(p.x / BEYOND_PITCH) * BEYOND_PITCH)
|
|
var gz: float = absf((p.y - SOUTH_ST_Z)
|
|
- roundf((p.y - SOUTH_ST_Z) / BEYOND_PITCH) * BEYOND_PITCH)
|
|
return gx < BEYOND_STREET * 0.5 + 2.5 or gz < BEYOND_STREET * 0.5 + 2.5
|
|
|
|
|
|
## Trees need more clearance than walls or props because their canopy is much
|
|
## wider than the point used to place them. This keeps a legal trunk position
|
|
## from putting five metres of cedar crown over a road beyond a boundary gate.
|
|
func _near_road_for_tree(p: Vector2) -> bool:
|
|
var crown_margin := 8.0
|
|
# Keep the first view through each wall opening especially clean. A trunk
|
|
# can be legally outside the pavement and still put a large, nearby crown
|
|
# directly in front of the gate from a player's eye height.
|
|
if absf(p.y) > HALF_Z - 8.0:
|
|
if absf(p.x) < 24.0 or absf(p.x - PARK_RD_EXIT_X) < 24.0:
|
|
return true
|
|
if absf(p.x) > HALF_X - 2.0:
|
|
if absf(p.y - SOUTH_ST_Z) < 24.0:
|
|
return true
|
|
if p.x < 0.0 and absf(p.y - SHOP_ST_Z) < 24.0:
|
|
return true
|
|
if absf(p.x) < ROAD_HALF + PAVE_W + crown_margin:
|
|
return true
|
|
if absf(p.y - SOUTH_ST_Z) < SOUTH_ST_HALF + PAVE_W + crown_margin:
|
|
return true
|
|
if p.x < -HALF_X and absf(p.y - SHOP_ST_Z) \
|
|
< SHOP_ST_HALF + PAVE_W + crown_margin:
|
|
return true
|
|
if _near_park_road(p, SHOP_ST_HALF + crown_margin):
|
|
return true
|
|
var gx: float = absf(p.x - roundf(p.x / BEYOND_PITCH) * BEYOND_PITCH)
|
|
var gz: float = absf((p.y - SOUTH_ST_Z)
|
|
- roundf((p.y - SOUTH_ST_Z) / BEYOND_PITCH) * BEYOND_PITCH)
|
|
return gx < BEYOND_STREET * 0.5 + crown_margin \
|
|
or gz < BEYOND_STREET * 0.5 + crown_margin
|
|
|
|
|
|
## How far a point lies outside the walled town, in metres. Zero inside it.
|
|
func _dist_to_town(p: Vector2) -> float:
|
|
var q := Vector2(clampf(p.x, -HALF_X, HALF_X), clampf(p.y, -HALF_Z, HALF_Z))
|
|
return p.distance_to(q)
|
|
|
|
|
|
## ── Cover ────────────────────────────────────────────────────────────────────
|
|
##
|
|
## The map's three lanes are meant to be crossed under fire, and the long ones
|
|
## had almost nothing standing in them: a player entering the railway corridor
|
|
## or the south street could be seen from one end to the other.
|
|
##
|
|
## ── Hedges alone do not do this ──────────────────────────────────────────────
|
|
##
|
|
## The first pass at it was hedges and kei vans, and both are too SHORT. A
|
|
## 1.15 m hedge and a 1.7 m van are cover — you can get behind them — but a
|
|
## sightline is broken by something you cannot see OVER, and a standing player's
|
|
## eye is about 1.7 m. So the hedges went up to 2.1 m, and the real work is now
|
|
## done by things with mass: concrete block walls, containers, hoardings and box
|
|
## trucks. The soft stuff is still there, but it is the garnish rather than the
|
|
## meal.
|
|
##
|
|
## The rule for placing them is the same either way: break sight ALONG a lane
|
|
## without closing it. Staggered down alternate sides, never in a line across,
|
|
## and never over a doorway or a crossing approach.
|
|
|
|
## ブロック塀 — the pierced concrete block wall that bounds half the plots in
|
|
## Japan. Two metres of hard, opaque, entirely characteristic sightline break,
|
|
## and the single most useful object in this list.
|
|
func _block_wall(a: Vector2, b: Vector2, height: float = 2.0) -> void:
|
|
# A plot boundary never crosses a carriageway. Checked here rather than at
|
|
# each call site: this is the third separate hand-placed wall to land in a
|
|
# road, so the invariant belongs with the thing that has to hold it.
|
|
if _on_any_road(a) or _on_any_road(b) or _on_any_road((a + b) * 0.5):
|
|
return
|
|
var mid := (a + b) * 0.5
|
|
var d := b - a
|
|
var yaw := _yaw_of(d.normalized())
|
|
var body := _solid(Vector3(mid.x, height * 0.5, mid.y),
|
|
Vector3(d.length(), height, 0.3), M["concrete"], "concrete",
|
|
Vector3(0, yaw, 0))
|
|
body.name = "BlockWall"
|
|
# Capping, and a pier every few metres: without them it is a blank slab.
|
|
_deco(_box_mesh(Vector3(d.length() + 0.1, 0.14, 0.44)), M["concrete_dark"],
|
|
Vector3(mid.x, height + 0.05, mid.y), Vector3(0, yaw, 0))
|
|
var n := maxi(1, int(d.length() / 3.4))
|
|
for i in range(n + 1):
|
|
var p: Vector2 = a.lerp(b, float(i) / float(n))
|
|
_deco(_box_mesh(Vector3(0.42, height, 0.44)), M["concrete_mid"],
|
|
Vector3(p.x, height * 0.5, p.y), Vector3(0, yaw, 0))
|
|
|
|
|
|
## A lineside hut — the little rendered-block permanent-way store that stands
|
|
## beside every railway in Japan. Replaces the shipping containers this used to
|
|
## have: those were the one prop in the map borrowed from a different kind of
|
|
## place altogether, and they read as an industrial estate rather than a
|
|
## suburb. Same job — 2.6 m of opaque mass against the fence — in a form that
|
|
## belongs.
|
|
func _lineside_hut(pos: Vector3, yaw: float, idx: int) -> void:
|
|
var yr := Vector3(0, yaw, 0)
|
|
var body := _solid(pos + Vector3(0, 1.3, 0), Vector3(4.4, 2.6, 2.0),
|
|
_wall_mat(idx + 2), "concrete", yr)
|
|
body.name = "LinesideHut"
|
|
# A shallow pitched roof with eaves, the same form the houses use.
|
|
_deco(_prism_mesh(Vector3(4.9, 0.9, 2.4)), M["roof_slate"],
|
|
pos + Vector3(0, 3.05, 0), yr)
|
|
_deco(_box_mesh(Vector3(5.0, 0.14, 2.5)), M["roof_slate"],
|
|
pos + Vector3(0, 2.64, 0), yr)
|
|
var fwd := Vector3(sin(deg_to_rad(yaw)), 0, cos(deg_to_rad(yaw)))
|
|
_deco(_box_mesh(Vector3(1.0, 1.9, 0.1)), M["wood_dark"],
|
|
pos + Vector3(0, 0.95, 0) + fwd * 1.02, yr)
|
|
_deco(_box_mesh(Vector3(1.3, 0.9, 0.08)), M["glass_dark"],
|
|
pos + Vector3(1.4, 1.6, 0) + fwd * 1.02, yr)
|
|
_deco(_cyl_mesh(0.05, 0.05, 2.4, 6), M["metal"],
|
|
pos + Vector3(-2.05, 1.2, 0) + fwd * 1.0, yr)
|
|
|
|
|
|
## A hoarding — an advertising board on legs over a vacant lot. Tall, thin, and
|
|
## the cheapest possible way to cut a long view without adding a building.
|
|
func _billboard(pos: Vector3, yaw: float, idx: int) -> void:
|
|
var yr := Vector3(0, yaw, 0)
|
|
for s in [-1.0, 1.0]:
|
|
_post(pos + Vector3(cos(deg_to_rad(yaw)), 0, -sin(deg_to_rad(yaw)))
|
|
* (s * 2.6) + Vector3(0, 1.6, 0), 0.14, 3.2, M["metal_dark"], "metal", 6)
|
|
var body := _solid(pos + Vector3(0, 4.3, 0), Vector3(6.6, 3.0, 0.24),
|
|
M["white_board"], "wood", yr)
|
|
body.name = "Hoarding"
|
|
var col: Color = SakuraPalette.DRINKS[(idx * 3) % SakuraPalette.DRINKS.size()]
|
|
_deco(_box_mesh(Vector3(6.0, 2.4, 0.1)), LevelMaterials.unlit(col),
|
|
pos + Vector3(0, 4.3, 0)
|
|
+ Vector3(sin(deg_to_rad(yaw)), 0, cos(deg_to_rad(yaw))) * 0.18, yr)
|
|
SakuraSignage.label(_decor,
|
|
SakuraSignage.WALL_ADS[idx % SakuraSignage.WALL_ADS.size()], 0.8,
|
|
SakuraPalette.WALL_WHITE,
|
|
pos + Vector3(0, 4.3, 0)
|
|
+ Vector3(sin(deg_to_rad(yaw)), 0, cos(deg_to_rad(yaw))) * 0.26,
|
|
Vector3(0, yaw, 0))
|
|
|
|
|
|
## ── Vehicles ─────────────────────────────────────────────────────────────────
|
|
##
|
|
## A vehicle's LENGTH runs along its local +X, and `yaw` turns it. So a car
|
|
## parked on an east-west street takes yaw 0 or 180, and one on a north-south
|
|
## street takes 90 or 270. Getting that wrong is what made these look "squished
|
|
## lengthwise": every one of them was placed at 90 on an east-west street, so
|
|
## its three-and-a-half metre length ran ACROSS the carriageway and its 1.5 m
|
|
## width ran along it. They were the right size and pointing the wrong way.
|
|
|
|
|
|
## 軽バン — the kei van. The dimensions are the real regulatory ones, because
|
|
## the whole reason this shape is recognisable is that it is a box built to the
|
|
## legal maximum: 3.40 long, 1.48 wide, and tall for its footprint. Cab-over,
|
|
## so the windscreen is at the very front and there is no bonnet at all, and
|
|
## the wheels are pushed right into the corners.
|
|
func _kei_van(pos: Vector3, yaw: float, colour: Color) -> void:
|
|
var mat := LevelMaterials.cel(colour, LevelMaterials.RAMP_2, SakuraPalette.TINT_COOL)
|
|
var yr := Vector3(0, yaw, 0)
|
|
var fwd := Vector3(cos(deg_to_rad(yaw)), 0, -sin(deg_to_rad(yaw)))
|
|
var right := Vector3(sin(deg_to_rad(yaw)), 0, cos(deg_to_rad(yaw)))
|
|
|
|
# Body: a low sill, a distinct shoulder and a narrower greenhouse. Keeping
|
|
# all three the same box was accurate to the legal envelope but read as a
|
|
# delivery crate with wheels rather than a Japanese kei van.
|
|
var body := _solid(pos + Vector3(0, 0.66, 0), Vector3(3.40, 0.88, 1.48),
|
|
mat, "metal", yr)
|
|
body.name = "KeiVan"
|
|
_solid(pos + Vector3(-0.08, 1.36, 0), Vector3(2.92, 0.58, 1.38), mat, "metal", yr)
|
|
_deco(_box_mesh(Vector3(2.98, 0.10, 1.42)), M["metal"],
|
|
pos + Vector3(-0.08, 1.70, 0), yr)
|
|
_deco(_box_mesh(Vector3(3.34, 0.12, 1.50)), mat,
|
|
pos + Vector3(0, 1.02, 0), yr)
|
|
|
|
# Glass. The windscreen is nearly upright and sits over the front axle —
|
|
# that steep front face is most of the silhouette.
|
|
_deco(_box_mesh(Vector3(0.12, 0.58, 1.30)), M["glass_dark"],
|
|
pos + Vector3(0, 1.38, 0) + fwd * 1.48, Vector3(0, yaw, -6.0))
|
|
for s in [-1.0, 1.0]:
|
|
for xoff in [-0.74, 0.55]:
|
|
_deco(_box_mesh(Vector3(1.02, 0.44, 0.07)), M["glass_dark"],
|
|
pos + Vector3(0, 1.39, 0) + fwd * xoff + right * (s * 0.705), yr)
|
|
# Sliding-door seam and a small horizontal handle.
|
|
_deco(_box_mesh(Vector3(0.035, 0.80, 0.035)), M["metal_dark"],
|
|
pos - fwd * 0.15 + right * (s * 0.742) + Vector3(0, 0.92, 0), yr)
|
|
_deco(_box_mesh(Vector3(0.32, 0.06, 0.04)), M["metal_dark"],
|
|
pos - fwd * 0.55 + right * (s * 0.75) + Vector3(0, 1.03, 0), yr)
|
|
_deco(_box_mesh(Vector3(0.08, 0.44, 1.28)), M["glass_dark"],
|
|
pos + Vector3(0, 1.39, 0) - fwd * 1.52, yr)
|
|
|
|
# Wheels in the corners, with arches over them.
|
|
for s in [-1.0, 1.0]:
|
|
for f in [-1.0, 1.0]:
|
|
# ── Wheel orientation ────────────────────────────────────────
|
|
# A CylinderMesh's axis is local +Y, and a wheel's axle has to be
|
|
# the vehicle's LATERAL direction. rotation_degrees is YXZ, so the
|
|
# vector goes through Rz, then Rx, then Ry: (0, yaw, 90) sends
|
|
# +Y to -X and then swings it round to the vehicle's LENGTH, which
|
|
# is why every wheel on the map was turned side-on. (90, yaw, 0)
|
|
# sends +Y to +Z first, and Ry lands it on the lateral axis.
|
|
_deco(_cyl_mesh(0.28, 0.28, 0.18, 8), M["ink"],
|
|
pos + fwd * (f * 1.18) + right * (s * 0.70) + Vector3(0, 0.28, 0),
|
|
Vector3(90, yaw, 0))
|
|
_deco(_box_mesh(Vector3(0.78, 0.30, 0.1)), mat,
|
|
pos + fwd * (f * 1.18) + right * (s * 0.745) + Vector3(0, 0.46, 0), yr)
|
|
|
|
# Bumpers, lamps, plate, mirrors.
|
|
for f in [-1.0, 1.0]:
|
|
_deco(_box_mesh(Vector3(0.14, 0.22, 1.44)), M["metal_dark"],
|
|
pos + Vector3(0, 0.38, 0) + fwd * (f * 1.70), yr)
|
|
for s in [-1.0, 1.0]:
|
|
_deco(_box_mesh(Vector3(0.08, 0.16, 0.3)), M["lantern"],
|
|
pos + Vector3(0, 0.86, 0) + fwd * 1.70 + right * (s * 0.5), yr)
|
|
_deco(_box_mesh(Vector3(0.08, 0.14, 0.26)), M["red"],
|
|
pos + Vector3(0, 0.95, 0) - fwd * 1.70 + right * (s * 0.5), yr)
|
|
_deco(_box_mesh(Vector3(0.16, 0.16, 0.1)), M["metal_dark"],
|
|
pos + Vector3(0, 1.52, 0) + fwd * 1.30 + right * (s * 0.86), yr)
|
|
_deco(_box_mesh(Vector3(0.02, 0.14, 0.3)), M["white_board"],
|
|
pos + Vector3(0, 0.52, 0) - fwd * 1.78, yr)
|
|
# Narrow grille, centre badge and the tall yellow Japanese plate at the front.
|
|
_deco(_box_mesh(Vector3(0.04, 0.20, 0.70)), M["black"],
|
|
pos + Vector3(0, 0.62, 0) + fwd * 1.78, yr)
|
|
_deco(_box_mesh(Vector3(0.045, 0.16, 0.30)), M["yellow"],
|
|
pos + Vector3(0, 0.43, 0) + fwd * 1.80, yr)
|
|
|
|
|
|
## 軽自動車 — the five-door kei hatch. The van is common, but a street made
|
|
## only of vans and box trucks still reads like a service yard. This lower,
|
|
## short-bonnet silhouette supplies the ordinary private car Japan actually has
|
|
## in abundance while keeping the same tiny 3.4 by 1.48 metre footprint.
|
|
func _kei_hatch(pos: Vector3, yaw: float, colour: Color) -> void:
|
|
var pivot := Node3D.new()
|
|
pivot.name = "KeiCar"
|
|
pivot.position = pos
|
|
pivot.rotation_degrees = Vector3(0, yaw, 0)
|
|
add_child(pivot)
|
|
var prev := _group
|
|
_group = pivot
|
|
var mat := LevelMaterials.cel(colour, LevelMaterials.RAMP_2, SakuraPalette.TINT_COOL)
|
|
|
|
_solid(Vector3(0, 0.58, 0), Vector3(3.35, 0.74, 1.48), mat, "metal")
|
|
_solid(Vector3(-0.20, 1.18, 0), Vector3(2.35, 0.56, 1.36), mat, "metal")
|
|
_deco(_box_mesh(Vector3(2.40, 0.10, 1.40)), M["metal"], Vector3(-0.20, 1.51, 0))
|
|
# The tiny bonnet and sharply raked screens are the silhouette difference
|
|
# between this and the cab-over van.
|
|
_deco(_box_mesh(Vector3(0.72, 0.18, 1.43)), mat, Vector3(1.30, 0.91, 0),
|
|
Vector3(0, 0, -5.0))
|
|
_deco(_box_mesh(Vector3(0.08, 0.55, 1.29)), M["glass_dark"],
|
|
Vector3(1.00, 1.23, 0), Vector3(0, 0, -15.0))
|
|
_deco(_box_mesh(Vector3(0.08, 0.48, 1.27)), M["glass_dark"],
|
|
Vector3(-1.39, 1.20, 0), Vector3(0, 0, 12.0))
|
|
for side in [-1.0, 1.0]:
|
|
for xoff in [-0.72, 0.32]:
|
|
_deco(_box_mesh(Vector3(0.78, 0.40, 0.07)), M["glass_dark"],
|
|
Vector3(xoff, 1.22, side * 0.70))
|
|
_deco(_box_mesh(Vector3(0.30, 0.06, 0.04)), M["metal_dark"],
|
|
Vector3(-0.32, 0.92, side * 0.75))
|
|
_deco(_box_mesh(Vector3(0.18, 0.13, 0.12)), M["metal_dark"],
|
|
Vector3(0.86, 1.23, side * 0.84))
|
|
for axle_x in [-1.08, 1.08]:
|
|
_deco(_cyl_mesh(0.29, 0.29, 0.18, 10), M["ink"],
|
|
Vector3(axle_x, 0.29, side * 0.70), Vector3(90, 0, 0))
|
|
_deco(_cyl_mesh(0.13, 0.13, 0.185, 8), M["metal"],
|
|
Vector3(axle_x, 0.29, side * 0.705), Vector3(90, 0, 0))
|
|
for side in [-1.0, 1.0]:
|
|
_deco(_box_mesh(Vector3(0.08, 0.16, 0.28)), M["lantern"],
|
|
Vector3(1.69, 0.76, side * 0.50))
|
|
_deco(_box_mesh(Vector3(0.08, 0.20, 0.24)), M["red"],
|
|
Vector3(-1.69, 0.82, side * 0.50))
|
|
_deco(_box_mesh(Vector3(0.08, 0.18, 0.62)), M["black"], Vector3(1.71, 0.48, 0))
|
|
_deco(_box_mesh(Vector3(0.04, 0.15, 0.30)), M["yellow"], Vector3(1.76, 0.40, 0))
|
|
_deco(_box_mesh(Vector3(0.04, 0.14, 0.30)), M["white_board"], Vector3(-1.76, 0.45, 0))
|
|
_group = prev
|
|
|
|
|
|
## 小型トラック — the light cab-over truck that does every delivery in Japan.
|
|
##
|
|
## The proportions ARE the identity: a flat-faced cab sitting directly over the
|
|
## front axle with no bonnet, and a box body behind it that is both taller and
|
|
## slightly wider than the cab, so the profile steps UP from front to back. A
|
|
## generic "truck" with a nose and a body the same height as its cab reads as
|
|
## American and looks wrong parked on this street.
|
|
func _box_truck(pos: Vector3, yaw: float) -> void:
|
|
var yr := Vector3(0, yaw, 0)
|
|
var fwd := Vector3(cos(deg_to_rad(yaw)), 0, -sin(deg_to_rad(yaw)))
|
|
var right := Vector3(sin(deg_to_rad(yaw)), 0, cos(deg_to_rad(yaw)))
|
|
var cab: Material = M["vend_white"]
|
|
|
|
# Chassis, then the cab over the front axle, then the taller box behind.
|
|
_deco(_box_mesh(Vector3(5.5, 0.22, 1.5)), M["metal_dark"],
|
|
pos + Vector3(0, 0.70, 0), yr)
|
|
var cab_body := _solid(pos + Vector3(0, 1.66, 0) + fwd * 1.85,
|
|
Vector3(1.90, 1.72, 1.90), cab, "metal", yr)
|
|
cab_body.name = "Truck"
|
|
_solid(pos + Vector3(0, 2.02, 0) - fwd * 0.95,
|
|
Vector3(3.70, 2.14, 1.98), cab, "metal", yr)
|
|
|
|
# The box's frame lines and a coloured lower band — every one of these
|
|
# carries a livery stripe along the bottom of the body.
|
|
for f in range(5):
|
|
_deco(_box_mesh(Vector3(0.1, 2.1, 2.02)), M["metal"],
|
|
pos + Vector3(0, 2.02, 0) - fwd * (0.95 + (float(f) - 2.0) * 0.85), yr)
|
|
_deco(_box_mesh(Vector3(3.74, 0.34, 2.02)), LevelMaterials.cel(
|
|
SakuraPalette.TRAIN_STRIPE, LevelMaterials.RAMP_2, SakuraPalette.TINT_COOL),
|
|
pos + Vector3(0, 1.12, 0) - fwd * 0.95, yr)
|
|
# Roof cap, and the deflector every one of them has over the cab.
|
|
_deco(_box_mesh(Vector3(3.8, 0.12, 2.06)), M["metal"],
|
|
pos + Vector3(0, 3.12, 0) - fwd * 0.95, yr)
|
|
_deco(_box_mesh(Vector3(0.7, 0.5, 1.8)), cab,
|
|
pos + Vector3(0, 2.70, 0) + fwd * 1.7, Vector3(0, yaw, -18))
|
|
|
|
# The flat face: a big upright windscreen low down, and the grille below it.
|
|
_deco(_box_mesh(Vector3(0.12, 0.78, 1.76)), M["glass_dark"],
|
|
pos + Vector3(0, 2.08, 0) + fwd * 2.76, yr)
|
|
for s in [-1.0, 1.0]:
|
|
_deco(_box_mesh(Vector3(1.7, 0.6, 0.08)), M["glass_dark"],
|
|
pos + Vector3(0, 1.94, 0) + fwd * 1.85 + right * (s * 0.96), yr)
|
|
_deco(_box_mesh(Vector3(0.1, 0.3, 0.42)), M["lantern"],
|
|
pos + Vector3(0, 1.06, 0) + fwd * 2.76 + right * (s * 0.66), yr)
|
|
# Mirrors on stalks, which stand well proud of a cab-over face.
|
|
_deco(_box_mesh(Vector3(0.1, 0.42, 0.1)), M["metal_dark"],
|
|
pos + Vector3(0, 2.34, 0) + fwd * 2.7 + right * (s * 1.14), yr)
|
|
_deco(_box_mesh(Vector3(0.1, 0.44, 1.7)), M["metal_dark"],
|
|
pos + Vector3(0, 1.30, 0) + fwd * 2.78, yr)
|
|
_deco(_box_mesh(Vector3(0.14, 0.34, 1.9)), M["metal_dark"],
|
|
pos + Vector3(0, 0.80, 0) + fwd * 2.80, yr)
|
|
|
|
# Rear roller doors and road hardware. The old blank white end was the
|
|
# largest surface on the vehicle whenever it faced away from the camera.
|
|
var rear := pos - fwd * 2.83
|
|
_deco(_box_mesh(Vector3(0.10, 1.92, 1.82)), M["vend_white"],
|
|
rear + Vector3(0, 2.02, 0), yr)
|
|
for side in [-1.0, 0.0, 1.0]:
|
|
_deco(_box_mesh(Vector3(0.13, 1.94, 0.07)), M["metal"],
|
|
rear + Vector3(0, 2.02, 0) + right * (float(side) * 0.86), yr)
|
|
for y in [1.25, 1.75, 2.25, 2.75]:
|
|
_deco(_box_mesh(Vector3(0.12, 0.055, 1.76)), M["metal"],
|
|
rear + Vector3(0, y, 0), yr)
|
|
for side in [-1.0, 1.0]:
|
|
_deco(_box_mesh(Vector3(0.13, 0.24, 0.28)), M["red"],
|
|
rear + Vector3(0, 0.84, 0) + right * (float(side) * 0.62), yr)
|
|
_deco(_box_mesh(Vector3(0.13, 0.18, 0.38)), M["white_board"],
|
|
rear + Vector3(0, 0.72, 0), yr)
|
|
_deco(_box_mesh(Vector3(0.18, 0.16, 1.92)), M["metal_dark"],
|
|
rear + Vector3(0, 0.47, 0), yr)
|
|
|
|
# Wheels: one pair under the cab, one under the back of the box.
|
|
for s in [-1.0, 1.0]:
|
|
for f in [1.85, -1.85]:
|
|
_deco(_cyl_mesh(0.44, 0.44, 0.26, 10), M["ink"],
|
|
pos + fwd * f + right * (s * 0.88) + Vector3(0, 0.44, 0),
|
|
Vector3(90, yaw, 0))
|
|
|
|
|
|
## A clipped hedge. Now above standing eye height, so it actually breaks a view
|
|
## rather than merely giving somebody something to crouch behind.
|
|
func _hedge(a: Vector2, b: Vector2, height: float = 2.1) -> void:
|
|
var mid := (a + b) * 0.5
|
|
var d := b - a
|
|
var body := _solid(Vector3(mid.x, height * 0.5, mid.y),
|
|
Vector3(d.length(), height, 1.0), M["leaf_deep"], "wood",
|
|
Vector3(0, _yaw_of(d.normalized()), 0))
|
|
body.name = "Hedge"
|
|
_deco(_box_mesh(Vector3(d.length() - 0.2, 0.26, 1.12)), M["leaf"],
|
|
Vector3(mid.x, height + 0.03, mid.y),
|
|
Vector3(0, _yaw_of(d.normalized()), 0))
|
|
|
|
|
|
## A lineside relay cabinet or a roadside transformer box.
|
|
func _cabinet(pos: Vector3, yaw: float) -> void:
|
|
_solid(pos + Vector3(0, 0.7, 0), Vector3(1.15, 1.4, 0.7), M["cabinet"],
|
|
"metal", Vector3(0, yaw, 0))
|
|
_deco(_box_mesh(Vector3(1.26, 0.1, 0.82)), M["cabinet_top"],
|
|
pos + Vector3(0, 1.45, 0), Vector3(0, yaw, 0))
|
|
_deco(_box_mesh(Vector3(0.5, 0.28, 0.04)), M["yellow"],
|
|
pos + Vector3(0, 1.05, 0) + Vector3(sin(deg_to_rad(yaw)), 0,
|
|
cos(deg_to_rad(yaw))) * 0.37, Vector3(0, yaw, 0))
|
|
|
|
|
|
## A stack of delivery crates against a wall. Vaultable, and the only cover in
|
|
## the map a player can reliably get on top of.
|
|
func _crates(pos: Vector3, yaw: float) -> void:
|
|
var cols := [SakuraPalette.CRATE, SakuraPalette.CRATE_ALT, SakuraPalette.BASKET]
|
|
for i in range(3):
|
|
var c: Color = cols[(i + int(pos.x)) % cols.size()]
|
|
var s := 0.62 - float(i) * 0.05
|
|
_solid(pos + Vector3(0, 0.31 + float(i) * 0.62, 0),
|
|
Vector3(s * 1.5, 0.62, s * 1.2),
|
|
LevelMaterials.cel(c, LevelMaterials.RAMP_2, SakuraPalette.TINT_COOL),
|
|
"wood", Vector3(0, yaw + float(i) * 6.0, 0))
|
|
|
|
|
|
## A bus shelter: a roof on posts with one glazed side.
|
|
func _bus_shelter(pos: Vector3, yaw: float) -> void:
|
|
var yr := Vector3(0, yaw, 0)
|
|
var right := Vector3(cos(deg_to_rad(yaw)), 0, -sin(deg_to_rad(yaw)))
|
|
var back := Vector3(sin(deg_to_rad(yaw)), 0, cos(deg_to_rad(yaw)))
|
|
_solid(pos + Vector3(0, 1.2, 0) - back * 0.7, Vector3(4.2, 2.4, 0.12),
|
|
M["glass"], "glass", yr)
|
|
for s in [-1.0, 1.0]:
|
|
_post(pos + right * (s * 2.0) + Vector3(0, 1.3, 0), 0.07, 2.6,
|
|
M["metal_dark"], "metal", 6)
|
|
_solid(pos + Vector3(0, 2.68, 0), Vector3(4.6, 0.16, 1.8), M["roof_slate"],
|
|
"metal", yr)
|
|
_solid(pos + Vector3(0, 0.5, 0) - back * 0.45, Vector3(3.2, 0.12, 0.45),
|
|
M["wood"], "wood", yr)
|
|
SakuraSignage.label(_decor, "バス", 0.22, SakuraPalette.INK,
|
|
pos + Vector3(0, 2.35, 0) + back * 0.9, Vector3(0, yaw + 180.0, 0))
|
|
|
|
|
|
## Everything above, placed where the lanes are too open.
|
|
func _build_cover() -> void:
|
|
var van_cols := [SakuraPalette.WALL_WHITE, SakuraPalette.TRAIN_BODY,
|
|
SakuraPalette.WALL_BLUE, SakuraPalette.WALL_SAGE, SakuraPalette.RED_SOFT]
|
|
|
|
# ── The railway corridor ─────────────────────────────────────────────────
|
|
# A permanent-way store: containers and a hoarding, hard against the fence
|
|
# where they interrupt the two-hundred-metre view down the line without
|
|
# fouling the running lines.
|
|
var rx := -HALF_X + 26.0
|
|
var ci := 0
|
|
while rx < CURVE_START_X - 10.0:
|
|
if absf(rx) > ROAD_HALF + 12.0 and absf(rx - BRIDGE_X) > 9.0 \
|
|
and absf(rx - STATION_X) > 20.0:
|
|
var s: float = 1.0 if ci % 2 == 0 else -1.0
|
|
# OUTSIDE the fence (6.6), not merely clear of the sleepers. At
|
|
# z = 5.25 the hut still sat on the ballast, which reads as a shed
|
|
# on the tracks however the clearance maths works out.
|
|
_lineside_hut(Vector3(rx, 0.0, s * 8.9),
|
|
0.0 if s > 0.0 else 180.0, ci)
|
|
ci += 1
|
|
rx += _rng.randf_range(26.0, 38.0)
|
|
|
|
# ── The south street ─────────────────────────────────────────────────────
|
|
# Block walls between the plots — which is what is actually there — plus a
|
|
# truck or a van on alternate sides.
|
|
var i := 0
|
|
var x := -HALF_X + 20.0
|
|
while x < HALF_X - 20.0:
|
|
if absf(x) > ROAD_HALF + 9.0 and absf(x - EAST_ST_X) > 10.0 \
|
|
and absf(x - STATION_X) > 14.0:
|
|
var side: float = 1.0 if i % 2 == 0 else -1.0
|
|
if i % 4 == 2:
|
|
# East-west street: length along X. Facing with the traffic on
|
|
# its own side, so the two kerbs point opposite ways.
|
|
_box_truck(Vector3(x, Y_ROAD_EW,
|
|
SOUTH_ST_Z + side * (SOUTH_ST_HALF - 1.6)),
|
|
0.0 if side > 0.0 else 180.0)
|
|
elif i % 2 == 0:
|
|
_kei_hatch(Vector3(x, Y_ROAD_EW,
|
|
SOUTH_ST_Z + side * (SOUTH_ST_HALF - 1.2)),
|
|
0.0 if side > 0.0 else 180.0, van_cols[i % van_cols.size()])
|
|
else:
|
|
_kei_van(Vector3(x, Y_ROAD_EW,
|
|
SOUTH_ST_Z + side * (SOUTH_ST_HALF - 1.2)),
|
|
0.0 if side > 0.0 else 180.0, van_cols[i % van_cols.size()])
|
|
# A block wall running back off the pavement, so the lane is broken
|
|
# across as well as along.
|
|
_block_wall(Vector2(x + 6.0, SOUTH_ST_Z + side * (SOUTH_ST_HALF + PAVE_W + 0.6)),
|
|
Vector2(x + 6.0, SOUTH_ST_Z + side * (SOUTH_ST_HALF + PAVE_W + 9.0)), 2.0)
|
|
i += 1
|
|
x += _rng.randf_range(16.0, 24.0)
|
|
|
|
# ── The verge, between the railway fence and the south street ────────────
|
|
var hx := -HALF_X + 16.0
|
|
while hx < CURVE_START_X - 6.0:
|
|
var run := _rng.randf_range(8.0, 15.0)
|
|
if absf(hx) > ROAD_HALF + 14.0 and absf(hx + run) > ROAD_HALF + 14.0 \
|
|
and absf(hx - BRIDGE_X) > 9.0 and absf(hx - STATION_X) > 20.0:
|
|
if int(hx) % 2 == 0:
|
|
_hedge(Vector2(hx, 11.4), Vector2(hx + run, 11.4))
|
|
else:
|
|
_block_wall(Vector2(hx, 11.4), Vector2(hx + run, 11.4), 2.1)
|
|
hx += run + _rng.randf_range(6.0, 12.0)
|
|
|
|
# ── The shopping street ──────────────────────────────────────────────────
|
|
var sx := -HALF_X + 22.0
|
|
while sx < SHOP_ST_X1 - 14.0:
|
|
if absf(sx) > ROAD_HALF + 9.0 and absf(sx - BRIDGE_X) > 10.0:
|
|
if i % 5 == 0:
|
|
_box_truck(Vector3(sx, Y_ROAD_EW, SHOP_ST_Z + SHOP_ST_HALF - 1.6), 0.0)
|
|
elif i % 2 == 0:
|
|
_kei_hatch(Vector3(sx, Y_ROAD_EW, SHOP_ST_Z + SHOP_ST_HALF - 1.2),
|
|
0.0, van_cols[(i + 2) % van_cols.size()])
|
|
else:
|
|
_kei_van(Vector3(sx, Y_ROAD_EW, SHOP_ST_Z + SHOP_ST_HALF - 1.2),
|
|
0.0, van_cols[(i + 2) % van_cols.size()])
|
|
i += 1
|
|
sx += _rng.randf_range(19.0, 27.0)
|
|
|
|
# ── The service alley ────────────────────────────────────────────────────
|
|
var ax := -HALF_X + 16.0
|
|
while ax < SHOP_ST_X1 - 10.0:
|
|
if absf(ax) > ROAD_HALF + 5.0 and absf(ax - BRIDGE_X) > 9.0:
|
|
_crates(Vector3(ax, Y_GRASS, ALLEY_Z - 1.0), _rng.randf_range(0, 40))
|
|
if int(ax) % 3 == 0:
|
|
_crates(Vector3(ax + 5.0, Y_GRASS, ALLEY_Z - 0.6),
|
|
_rng.randf_range(0.0, 40.0))
|
|
ci += 1
|
|
ax += _rng.randf_range(15.0, 24.0)
|
|
|
|
# ── The crossing, the station and the east district ──────────────────────
|
|
# On the PAVEMENT, both of them. The first used to sit at z = 19, which is
|
|
# inside the south street's carriageway (15.5 to 24.5) — a bus shelter in
|
|
# the middle of the road.
|
|
_bus_shelter(Vector3(ROAD_HALF + 4.4, Y_PAVE,
|
|
SOUTH_ST_Z - SOUTH_ST_HALF - PAVE_W * 0.5 - 0.2), 0.0)
|
|
_bus_shelter(Vector3(STATION_X - 6.0, Y_PAVE,
|
|
SOUTH_ST_Z - SOUTH_ST_HALF - PAVE_W * 0.5 - 0.2), 0.0)
|
|
for s in [-1.0, 1.0]:
|
|
_cabinet(Vector3(s * (ROAD_HALF + 2.2), Y_PAVE, -RAIL_HALF - 9.0), 90.0)
|
|
# Hoardings on the vacant ground either side of the main street, which is
|
|
# the longest uninterrupted view in the map.
|
|
# Clear of every carriageway — the first pair used to stand at z = -29,
|
|
# which is inside the shopping street. Guarded rather than trusted, because
|
|
# a hand-written coordinate is exactly what put them there.
|
|
for s in [-1.0, 1.0]:
|
|
for spot in [Vector2(s * (ROAD_HALF + 10.0), -RAIL_HALF - 15.0)]:
|
|
if _on_any_road(spot):
|
|
continue
|
|
_billboard(Vector3(spot.x, 0.0, spot.y), 90.0 - 90.0 * s, int(spot.y))
|
|
|
|
var ex := EAST_DIST_X0 + 14.0
|
|
while ex < EAST_DIST_X1 - 14.0:
|
|
if absf(ex - EAST_ST_X) > 10.0:
|
|
if i % 2 == 0:
|
|
_kei_hatch(Vector3(ex, Y_ROAD_EW, EAST_ST_Z + EAST_ST_HALF - 1.2),
|
|
0.0, van_cols[(i + 1) % van_cols.size()])
|
|
else:
|
|
_kei_van(Vector3(ex, Y_ROAD_EW, EAST_ST_Z + EAST_ST_HALF - 1.2),
|
|
0.0, van_cols[(i + 1) % van_cols.size()])
|
|
i += 1
|
|
# Plot boundary walls, which is what separates these houses in life.
|
|
var north_wall_x := ex - 6.0
|
|
if absf(north_wall_x - EAST_ST_X) > EAST_ST_HALF + 0.5:
|
|
_block_wall(Vector2(north_wall_x,
|
|
EAST_ST_Z + EAST_ST_HALF + PAVE_W + 0.8),
|
|
Vector2(north_wall_x,
|
|
EAST_ST_Z + EAST_ST_HALF + PAVE_W + 9.0), 2.0)
|
|
var south_wall_x := ex - 14.0
|
|
if absf(south_wall_x - EAST_ST_X) > EAST_ST_HALF + 0.5:
|
|
_block_wall(Vector2(south_wall_x,
|
|
EAST_ST_Z - EAST_ST_HALF - PAVE_W - 0.8),
|
|
Vector2(south_wall_x,
|
|
EAST_ST_Z - EAST_ST_HALF - PAVE_W - 9.0), 2.0)
|
|
ex += _rng.randf_range(17.0, 24.0)
|
|
|
|
# ── The park ─────────────────────────────────────────────────────────────
|
|
for s in [-1.0, 1.0]:
|
|
_hedge(Vector2(SHRINE_X - 24.0, PARK_Z + s * 8.0),
|
|
Vector2(SHRINE_X - 8.0, PARK_Z + s * 8.0))
|
|
_hedge(Vector2(-6.0, PARK_Z - 8.0), Vector2(-6.0, PARK_Z + 8.0))
|
|
_block_wall(Vector2(SHRINE_X - 30.0, PARK_Z - 9.0),
|
|
Vector2(SHRINE_X - 30.0, PARK_Z + 9.0), 2.0)
|
|
|
|
# ── The north park ───────────────────────────────────────────────────────
|
|
# Its own hedges are laid in _build_north_park; this is the run of walls
|
|
# along its southern edge, facing the shopping street.
|
|
var fx := -HALF_X + 20.0
|
|
while fx < HALF_X - 20.0:
|
|
if absf(fx) > ROAD_HALF + 9.0 and not _near_park_road(Vector2(fx, -42.0)):
|
|
_block_wall(Vector2(fx, -42.0), Vector2(fx + 12.0, -42.0), 2.0)
|
|
fx += _rng.randf_range(22.0, 32.0)
|
|
|
|
|
|
## A hill, as three concentric terraces.
|
|
##
|
|
## Not a scaled cylinder, which is what this was first: `_post` at a negative Y
|
|
## with a squashed scale put every crown BELOW ground level — the arithmetic
|
|
## came out as `-0.30r + r·s/2`, which is negative for every scale in the range
|
|
## it was given, so seventy-two hills were buried and the margin rendered flat.
|
|
##
|
|
## Terraces rather than a dome because the collision has to be honest. A cone
|
|
## would need a cylinder collider the size of its base, so a player would stand
|
|
## on an invisible flat top; a squashed sphere needs non-uniform scale on a
|
|
## SphereShape3D, which Godot warns about. Three stacked cylinders collide
|
|
## exactly as they look — and a terraced hillside is hardly out of place here.
|
|
func _mound(centre: Vector2, radius: float, height: float) -> void:
|
|
var tiers := 3
|
|
for i in range(tiers):
|
|
var f := float(i) / float(tiers)
|
|
var r: float = radius * (1.0 - f * 0.62)
|
|
var top: float = height * (float(i + 1) / float(tiers))
|
|
# Each tier runs from below ground to its own top, so they nest and
|
|
# there is never a gap to fall into between two of them.
|
|
var h: float = top + 6.0
|
|
var body := _post(Vector3(centre.x, top - h * 0.5, centre.y), r, h,
|
|
M["grass"], "concrete", 7)
|
|
body.name = "Mound"
|
|
|
|
|
|
func _cedar(base: Vector3, scale: float) -> void:
|
|
var h := 9.0 * scale
|
|
var trunk := _deco(_cyl_mesh(0.28 * scale, 0.16 * scale, h * 0.4, 5),
|
|
M["trunk_dark"], base + Vector3(0, h * 0.2, 0))
|
|
trunk.name = "Cedar"
|
|
# Three tiers, narrowing. A conifer in this style is a stack of cones, and
|
|
# the ink pass draws the boundary between each pair of tiers — which is what
|
|
# gives it its layered look for three meshes.
|
|
for i in range(3):
|
|
var r := (2.3 - float(i) * 0.6) * scale
|
|
var th := (4.2 - float(i) * 0.7) * scale
|
|
_deco(_cyl_mesh(r, r * 0.16, th, 6), M["cedar"] if i < 2 else M["leaf_deep"],
|
|
base + Vector3(0, h * 0.32 + float(i) * 2.3 * scale, 0))
|
|
|
|
|
|
# ── Petals ───────────────────────────────────────────────────────────────────
|
|
|
|
func _build_petals() -> void:
|
|
# ── Where the petals were ────────────────────────────────────────────────
|
|
#
|
|
# They were there, and they were invisible, and the reason is worth writing
|
|
# down because it is not obvious: the emitter was a THIN box at 24 m, so
|
|
# every petal in the map spent its life falling from a ceiling. At eye level
|
|
# — the only height a first-person player ever looks at — the air was empty
|
|
# almost all of the time, and the handful of petals that had fallen far
|
|
# enough were three hundred metres of perspective away and a pixel wide.
|
|
#
|
|
# The fix is to emit through the whole VOLUME instead. A petal is spawned at
|
|
# any height from the pavement to above the rooflines, so the air is full at
|
|
# every height from the first frame, and the count went up with it.
|
|
_petal_field("PetalsAir", Vector3(0, 13.0, 0),
|
|
Vector3(HALF_X, 12.0, HALF_Z), 2400, Vector2(0.115, 0.075), 1.0)
|
|
|
|
# Two dense local fields over the groves, because blossom does not fall
|
|
# evenly over a town — it falls hardest under the trees dropping it, and
|
|
# that difference is most of what sells the effect as blossom rather than
|
|
# as weather.
|
|
_petal_field("PetalsPark", Vector3(0, 7.0, PARK_Z),
|
|
Vector3(HALF_X * 0.55, 6.5, 11.0), 1100, Vector2(0.13, 0.085), 0.85)
|
|
_petal_field("PetalsVerge", Vector3(0, 6.0, 11.4),
|
|
Vector3(HALF_X * 0.9, 5.5, 5.0), 900, Vector2(0.13, 0.085), 0.85)
|
|
|
|
|
|
## One field of falling blossom.
|
|
func _petal_field(node_name: String, centre: Vector3, extents: Vector3,
|
|
amount: int, size: Vector2, speed: float) -> void:
|
|
var particles := GPUParticles3D.new()
|
|
particles.name = node_name
|
|
particles.amount = amount
|
|
particles.lifetime = 16.0
|
|
# Preprocess a full lifetime so the field is already settled on frame one —
|
|
# without it a match opens on an empty sky that slowly fills.
|
|
particles.preprocess = 16.0
|
|
particles.visibility_aabb = AABB(
|
|
centre - extents - Vector3(20, 24, 20),
|
|
(extents + Vector3(20, 24, 20)) * 2.0)
|
|
|
|
var pm := ParticleProcessMaterial.new()
|
|
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
|
pm.emission_box_extents = extents
|
|
pm.direction = Vector3(0.35, -1, 0.1)
|
|
pm.spread = 26.0
|
|
pm.initial_velocity_min = 0.6 * speed
|
|
pm.initial_velocity_max = 1.5 * speed
|
|
pm.gravity = Vector3(0.45, -0.8, 0.15) * speed
|
|
# A petal does not fall, it tumbles. The angular velocity and the turbulence
|
|
# are what make it read as blossom rather than as snow.
|
|
pm.angular_velocity_min = -200.0
|
|
pm.angular_velocity_max = 200.0
|
|
pm.turbulence_enabled = true
|
|
pm.turbulence_noise_strength = 0.38
|
|
pm.turbulence_noise_scale = 1.5
|
|
pm.scale_min = 0.75
|
|
pm.scale_max = 1.15
|
|
particles.process_material = pm
|
|
|
|
var quad := QuadMesh.new()
|
|
quad.size = size
|
|
particles.draw_pass_1 = quad
|
|
|
|
# ── Why the petals are TRANSPARENT ───────────────────────────────────────
|
|
#
|
|
# The first version made them opaque, and every petal in the frame came out
|
|
# as a black speck: a 15 cm quad at any distance is one or two pixels, and
|
|
# an isolated one- or two-pixel depth spike is the largest second difference
|
|
# in the picture. The ink pass did exactly what it was asked to and inked
|
|
# each petal solid — the sky filled with what looked like flies.
|
|
#
|
|
# Making the material transparent puts it in the transparent pass, which
|
|
# runs after the ink quad and does not write depth. So the ink pass never
|
|
# sees a petal at all, and they composite over the finished frame as flat
|
|
# pink marks. Which is also how they are actually done: blossom is a
|
|
# separate cel laid over the background, not something in the scene.
|
|
var pmat := StandardMaterial3D.new()
|
|
pmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
pmat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
pmat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
|
# PETAL_DEEP rather than PETAL. The pale tone is right for blossom seen as a
|
|
# MASS on a branch, where it sits against the sky; a single petal in the air
|
|
# is read against the road, the buildings and the shadow side of everything,
|
|
# and at 0.98 luminance it came out as white confetti. The deep tone is the
|
|
# only one that still says "pink" at eleven centimetres across.
|
|
pmat.albedo_color = Color(SakuraPalette.PETAL_DEEP, 0.95)
|
|
pmat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
pmat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
|
|
particles.material_override = pmat
|
|
particles.position = centre
|
|
add_child(particles)
|
|
|
|
|
|
# ── Player ───────────────────────────────────────────────────────────────────
|
|
|
|
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.2, 1.2), 0, randf_range(-1.2, 1.2))
|
|
return player
|