221 lines
9.9 KiB
GDScript
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 := "乗車口"
|