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