big
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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 := "乗車口"
|
||||
Reference in New Issue
Block a user