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

221 lines
9.9 KiB
GDScript

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 := "乗車口"