Files
Papay-Shooter/scenes/maps/sakura_crossing/sakura_crossing_builder.gd
T
2026-08-02 02:20:02 -04:00

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