extends Object class_name SakuraSignage ## Runtime type, for signage, road markings and every other piece of lettering ## in the world. ## ## ── Why this exists ────────────────────────────────────────────────────────── ## ## The reference is emphatic about it: "Every sign, fascia, lantern and price ## strip above is drawn at runtime with Canvas2D — there is not one image asset ## in `src/`." That is not a technical footnote, it is most of why the place ## reads as a place. A Japanese street is *made* of lettering — fascia boards, ## projecting 袖看板, noren, price strips, road paint, the station name board — ## and a street built without any of it reads as an architectural massing study, ## which is exactly what the first pass of this map was. ## ## ── How it is done here ────────────────────────────────────────────────────── ## ## Godot's answer to the reference's Canvas2D is `TextMesh`: real geometry ## generated from a font, which suits this renderer better than a drawn texture ## would. The whole map is untextured flat colour, so a texture-mapped sign would ## be the only bitmap in it — and, more to the point, geometry gets INKED. Every ## glyph picks up the screen-space outline exactly as the buildings do, which is ## what makes the lettering sit in the picture rather than on top of it. ## ## Two measurements decided the settings: ## ## depth = 0 Extruded text costs 4 500 vertices for four kanji; flat text ## costs 2 100. The side walls of the extrusion are most of the ## mesh and none of them are ever seen on a sign fixed to a wall. ## Flat glyphs sit a centimetre proud of their board instead. ## curve_step 8 Tessellation. 0.5 (the default) is 43 000 vertices for three ## signs; 8 is 13 700 and is visually identical at any distance a ## sign is read from — and slightly chunkier glyph curves are, if ## anything, more correct for a cel image. ## ## The font is a SYSTEM font stack, and deliberately the same one the reference ## uses: 'Yu Gothic', 'Meiryo', 'Hiragino Kaku Gothic ProN', 'MS Gothic'. That ## ships no font file and gets a real Japanese face on any machine likely to run ## this. If none of them resolve, TextMesh yields an empty mesh — signs go blank, ## nothing crashes. const FONT_STACK := ["Yu Gothic", "Meiryo", "Hiragino Kaku Gothic ProN", "MS Gothic"] ## TextMesh generates at this nominal size and is scaled by `pixel_size`, so one ## font size serves every sign in the map and the cache keys stay small. const NOMINAL_SIZE := 64 ## Measured: a TextMesh's AABB height is about 1.229x (font_size * pixel_size) ## for CJK glyphs. Used to turn a requested cap height in metres into a ## pixel_size, so callers can ask for "0.4 m tall" and get it. const HEIGHT_RATIO := 1.229 static var _font: Font = null static var _mesh_cache: Dictionary = {} static func font() -> Font: if _font != null: return _font var f := SystemFont.new() f.font_names = PackedStringArray(FONT_STACK) # Signage is nearly always heavy. A regular weight at these sizes reads as # body copy, which no shop fascia in the world is set in. f.font_weight = 700 f.subpixel_positioning = TextServer.SUBPIXEL_POSITIONING_DISABLED _font = f return _font ## A flat text mesh whose glyphs are `height` metres tall, centred on its origin. ## Cached by (text, height) — a row of shops reusing a name shares one mesh. static func text_mesh(text: String, height: float) -> TextMesh: var key := "%s|%.3f" % [text, height] if _mesh_cache.has(key): return _mesh_cache[key] var tm := TextMesh.new() tm.font = font() tm.text = text tm.font_size = NOMINAL_SIZE tm.pixel_size = height / (float(NOMINAL_SIZE) * HEIGHT_RATIO) tm.depth = 0.0 tm.curve_step = 8.0 tm.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER tm.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _mesh_cache[key] = tm return tm ## How wide that mesh comes out, for sizing the board behind it. static func text_width(text: String, height: float) -> float: return text_mesh(text, height).get_aabb().size.x ## Place a line of text. `rot` is in degrees; the mesh faces +Z unrotated, so a ## sign on a wall facing -Z wants rot.y = 180 and a marking painted on the road ## wants rot.x = -90. ## `inked` decides whether the screen-space ink pass may draw around the glyphs. ## ## Raised lettering on a sign board WANTS the outline — it is an object standing ## off a wall and the ink is what seats it there. **Paint on the ground does ## not.** Road markings were inked at first and the result was hollow letters: ## a stroke of 止まれ read at a grazing angle is only a few pixels wide, the ink ## fired on both of its edges, and the two outlines met in the middle and ate the ## letter. Which is also the correct art call — no cel painter inks around each ## stroke of road paint, because paint on tarmac has no silhouette. Passing ## `false` puts the glyphs in the transparent pass, where the ink cannot see ## them and they composite as flat marks on the road. static func label(parent: Node, text: String, height: float, colour: Color, pos: Vector3, rot: Vector3 = Vector3.ZERO, inked: bool = true) -> MeshInstance3D: var mi := MeshInstance3D.new() mi.mesh = text_mesh(text, height) mi.material_override = _unlit(colour, inked) mi.position = pos if rot != Vector3.ZERO: mi.rotation_degrees = rot parent.add_child(mi) return mi ## Vertical writing (縦書き), one glyph per line, running downward. ## ## There is no vertical layout mode on TextMesh, and it does not need one: a ## 袖看板 sets one character per line anyway, so stacking single-character meshes ## IS the correct typography here rather than a workaround for a missing feature. static func vertical_label(parent: Node, text: String, height: float, colour: Color, top: Vector3, rot: Vector3 = Vector3.ZERO) -> void: var pitch := height * 1.34 var chars := text.length() for i in range(chars): var p := top - Vector3(0, float(i) * pitch, 0) if rot != Vector3.ZERO: # Stack along the sign's own down axis, which is world down for any # rotation this map uses (signs are never rolled). p = top - Vector3(0, float(i) * pitch, 0) label(parent, text[i], height, colour, p, rot) static var _unlit_cache: Dictionary = {} static func _unlit(colour: Color, inked: bool = true) -> StandardMaterial3D: var key := "%s|%s" % [colour.to_html(), inked] if _unlit_cache.has(key): return _unlit_cache[key] var m := StandardMaterial3D.new() m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED m.albedo_color = colour if not inked: # The transparent pass runs after the ink quad and writes no depth, so # these glyphs are never seen by the edge detector. See label(). m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA m.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED # Lettering is read from both sides on projecting signs and hanging banners, # and a one-sided glyph that vanishes as you walk past it is worse than no # glyph at all. m.cull_mode = BaseMaterial3D.CULL_DISABLED _unlit_cache[key] = m return m # ── The copy ───────────────────────────────────────────────────────────────── # # Every string in the map, gathered here so the writing can be read as writing # rather than hunted for through the geometry. It is all ordinary suburban # signage — shops, warnings, road paint — because that is what the reference's # world is made of and what makes it feel like somewhere rather than like a set. ## Shop names for the 商店街. One per unit, cycled. const SHOP_NAMES := [ "さくら書店", "ラーメン", "花や", "米店", "パン工房", "喫茶ふみきり", "電器", "時計店", "八百屋", "酒", "薬局", "たばこ", "クリーニング", "精肉店", "魚屋", "そば", "文具", "写真館", ] ## The short word that goes on a projecting side sign, keyed to the same index. const SHOP_VERTICAL := [ "本", "麺", "花", "米", "パン", "珈琲", "電器", "時計", "青果", "酒", "薬", "煙草", "洗濯", "肉", "魚", "蕎麦", "文具", "写真", ] ## Noren carry one or two characters, never a whole name. const NOREN_TEXT := ["営業中", "ゆ", "そば", "酒", "めし", "喫茶"] ## Painted on the road, in the order they are met approaching the crossing. const ROAD_STOP := "止まれ" const ROAD_SLOW := "徐行" ## The crossing itself. const CROSSING_WARN := "踏切注意" const CROSSING_STOP := "とまれ" ## The station. A 駅名標 gives the name in kana, then the neighbours either side. const STATION_KANA := "さくらふみきり" const STATION_KANJI := "桜踏切" const STATION_ROMAJI := "Sakura-Fumikiri" const STATION_PREV := "みなみおか" const STATION_NEXT := "きたの" ## Street name plates. const STREET_NAMES := ["桜通り", "駅前通り", "踏切通り", "本町"] ## Vending machine price strips. const PRICES := ["¥130", "¥150", "¥160", "¥110"] ## Large painted signs for the exposed FLANK walls of a terrace. ## ## Wherever a run of shops is cut — at the crossing, at the map edge — the end ## unit turns a blank two- or three-storey gable to the street, and the render ## showed several of them as the largest empty surfaces in the map. In a real ## shopping street that wall is the most valuable advertising space on the block ## and is never blank; here it is also the one place a piece of type can be big ## enough to read from the far side of the tracks. const WALL_ADS := [ "大売出し", "さくら電器", "駐車場", "うどん", "スーパー", "歯科", "不動産", "美容室", ] ## Painted on the platform where the doors will be. const PLATFORM_MARK := "乗車口"