This commit is contained in:
Nicholas Butzke
2026-08-02 02:20:02 -04:00
parent 61669627db
commit 922983429e
226 changed files with 34032 additions and 18521 deletions
+43
View File
@@ -0,0 +1,43 @@
extends Node
class_name CloudSunSync
## Keeps the fullscreen sky composite tied to the real DirectionalLight3D.
##
## The Environment sky shader receives LIGHT0_DIRECTION automatically, but the
## ink pass has to repaint background pixels after the screen copy. This bridge
## gives that second shader the same moving direction and color every frame.
var _sun: DirectionalLight3D
var _ink_material: ShaderMaterial
var _palette_sun_color := Color.WHITE
func configure(sun: DirectionalLight3D, ink_material: ShaderMaterial) -> void:
_sun = sun
_ink_material = ink_material
var authored = ink_material.get_shader_parameter("sky_sun_color")
if authored is Color:
_palette_sun_color = authored
_sync()
func _process(_delta: float) -> void:
_sync()
func _sync() -> void:
if not is_instance_valid(_sun) or not is_instance_valid(_ink_material):
set_process(false)
return
# DirectionalLight3D shines down its local -Z axis, so +Z points back
# toward the apparent sun—the direction a sky pixel uses for illumination.
var toward_sun := _sun.global_transform.basis.z.normalized()
_ink_material.set_shader_parameter("sky_sun_direction", toward_sun)
# Preserve the authored day/sunset palette while allowing a dynamically
# recolored sun to tint the cloud's lit face.
var live_color := _palette_sun_color.lerp(_sun.light_color, 0.35)
var energy_scale: float = clampf(_sun.light_energy / 1.4, 0.75, 1.25)
_ink_material.set_shader_parameter(
"sky_sun_color", live_color * energy_scale)
+1
View File
@@ -0,0 +1 @@
uid://51bevlnoh6tt
+237 -60
View File
@@ -1,6 +1,8 @@
extends Object
class_name LevelEnvironment
const CLOUD_SUN_SYNC_SCRIPT := preload("res://scenes/maps/cloud_sun_sync.gd")
## Shared stylized environment for every level: saturated anime-sky gradient,
## bloom for emissives (tracers, plasma), and a color grade that pushes the
## cel-shaded look (high saturation, slight contrast lift). Builders call
@@ -15,82 +17,205 @@ static func add_to(level: Node, sky_variant: String = "day") -> WorldEnvironment
env.environment = make_environment(sky_variant)
level.add_child(env)
if not level.has_node("Sun"):
var sun := DirectionalLight3D.new()
sun.name = "Sun"
sun.rotation_degrees = Vector3(-52, 38, 0)
sun.light_color = Color(1.0, 0.97, 0.88)
sun.light_energy = 1.4
sun.shadow_enabled = true
sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS
sun.directional_shadow_max_distance = 120.0
level.add_child(sun)
var fill := DirectionalLight3D.new()
fill.name = "FillLight"
fill.rotation_degrees = Vector3(-30, -142, 0)
fill.light_color = Color(0.6, 0.7, 1.0)
fill.light_energy = 0.25
fill.shadow_enabled = false
level.add_child(fill)
var sun := level.get_node_or_null("Sun") as DirectionalLight3D
if sun == null:
sun = _build_light_rig(level, sky_variant)
# Screen-space ink edge pass: drawn outlines on every model for whatever
# camera renders (fullscreen POSITION-override quad, never culled).
var ink_material: ShaderMaterial
if not level.has_node("InkEdgePost"):
var ink := MeshInstance3D.new()
ink.name = "InkEdgePost"
var quad := QuadMesh.new()
quad.size = Vector2(2, 2)
var ink_mat := ShaderMaterial.new()
ink_mat.shader = load("res://assets/shaders/ink_edge.gdshader")
quad.material = ink_mat
ink_material = ShaderMaterial.new()
ink_material.shader = load("res://assets/shaders/ink_edge.gdshader")
_apply_sky_palette(ink_material, sky_variant, true)
quad.material = ink_material
ink.mesh = quad
ink.extra_cull_margin = 16384.0
level.add_child(ink)
else:
var ink := level.get_node("InkEdgePost") as MeshInstance3D
if ink and ink.mesh:
ink_material = ink.mesh.material as ShaderMaterial
# Quiet ambient bed so the arena never sits in dead air.
if not level.has_node("AmbientBed") and ResourceLoader.exists("res://assets/sounds/wind.wav"):
var amb := AudioStreamPlayer.new()
amb.name = "AmbientBed"
amb.stream = load("res://assets/sounds/wind.wav")
if amb.stream is AudioStreamWAV:
amb.stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
amb.stream.loop_end = amb.stream.data.size() / 2
amb.volume_db = -30.0
amb.pitch_scale = 0.6
amb.bus = "SFX"
amb.autoplay = true
level.add_child(amb)
if sun and ink_material and not level.has_node("CloudSunSync"):
var sync := CLOUD_SUN_SYNC_SCRIPT.new()
sync.name = "CloudSunSync"
level.add_child(sync)
sync.configure(sun, ink_material)
# Deliberately no global broadband ambience. Constant air/noise masks the
# physical cues that matter in a competitive shooter and was perceived as
# television static. Authored map emitters may add localized machinery;
# traversal wind remains silent until the player is genuinely moving fast.
return env
## The anime light rig.
##
## Not one key and a token fill. The reference names four lights and every one
## of them is doing a specific job that the cel ramp cannot do without it:
##
## key Warm, and the only one casting shadows. It is what the ramp
## quantises, so it alone decides where every band edge in the frame
## falls.
## bounce A STRONG cool light from the opposite quarter — roughly a third of
## the key, not the tenth a PBR fill would be. This is the single most
## important light in an anime rig: it is what puts a distinct, cool,
## non-black value on every shadow plane, so a surface turning away
## from the sun turns INTO another colour rather than into darkness.
## up A weak violet up-light standing in for ground bounce, which keeps
## undersides — eaves, canopies, the underside of a train — from
## collapsing into one flat mass with the shadow planes.
## ground The hemisphere's ground colour, carried here by ambient (see
## make_environment): violet rather than grey, for the same reason the
## cel ramp's shadow tint is.
##
## The energies look high because the cel shaders divide by PI in light(), the
## same as any Lambert term: a key at 2.6 lands its top band at 2.6/PI ≈ 0.83 of
## the surface albedo, which is what "fully lit" should mean.
static func _build_light_rig(level: Node, sky_variant: String) -> DirectionalLight3D:
var warm := Color(1.0, 0.945, 0.847) # PAL.sun 0xfff1d8
var cool := Color(0.663, 0.741, 0.961) # PAL.fill 0xa9bdf5
var key_energy := 2.6
var bounce_energy := 0.95
var key_angle := Vector3(-48, 34, 0)
if sky_variant == "sunset":
warm = Color(1.0, 0.78, 0.58)
cool = Color(0.52, 0.56, 0.92)
key_energy = 2.2
bounce_energy = 1.1
key_angle = Vector3(-16, 28, 0)
var sun := DirectionalLight3D.new()
sun.name = "Sun"
sun.rotation_degrees = key_angle
sun.light_color = warm
sun.light_energy = key_energy
sun.shadow_enabled = true
sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS
sun.directional_shadow_max_distance = 160.0
level.add_child(sun)
# The opposite quarter: the key's yaw turned 180 degrees, and a shallower
# pitch, so the bounce rakes across the shadow planes instead of lighting
# them like a second sun.
var bounce := DirectionalLight3D.new()
bounce.name = "FillLight"
bounce.rotation_degrees = Vector3(-24, key_angle.y + 180.0, 0)
bounce.light_color = cool
bounce.light_energy = bounce_energy
bounce.shadow_enabled = false
level.add_child(bounce)
var up := DirectionalLight3D.new()
up.name = "UpLight"
up.rotation_degrees = Vector3(74, key_angle.y - 60.0, 0)
up.light_color = Color(0.714, 0.651, 0.776) # PAL.hemiGround 0xb6a6c6
up.light_energy = 0.30
up.shadow_enabled = false
level.add_child(up)
return sun
## Cel sky with rounded, flat-toned anime clouds.
##
## This replaces ProceduralSkyMaterial, whose smooth two-colour ramp was the
## largest single flat area in every frame — tools/levels.py measured a quarter
## to a third of each shot sitting at one shade, and almost all of it was sky.
## Clouds give the top half of the picture something to be. They use a layered
## 2D field and painted directional tones rather than volumetric ray marching,
## keeping the richer silhouette practical for a competitive shooter.
static func make_sky(sky_variant: String = "day") -> Sky:
var sky := Sky.new()
var mat := ShaderMaterial.new()
mat.shader = load("res://assets/shaders/anime_sky.gdshader")
_apply_sky_palette(mat, sky_variant)
sky.sky_material = mat
# The sky animates, so its radiance would otherwise be re-rendered in full
# every frame for the maps that take ambient from it. INCREMENTAL spreads
# that across frames, which is invisible at a drift this slow.
sky.process_mode = Sky.PROCESS_MODE_INCREMENTAL
return sky
## Keep the Environment sky (ambient/reflections) and the ink pass's final
## background composite on one authored palette.
static func _apply_sky_palette(
mat: ShaderMaterial, sky_variant: String, ink_pass: bool = false) -> void:
var prefix := "sky_" if ink_pass else ""
match sky_variant:
"sakura":
# A hand-painted anime background sky, which means PALE. The bold
# saturated blue the other variants use is a video-game sky; a cel
# background painter keeps the top of the picture light and low in
# contrast so the ink line work and the blossom read against it. The
# horizon carries the reference's pink haze (PAL.skyHaze), which is
# what makes the air itself look like it has petals in it.
mat.set_shader_parameter(prefix + "top_color", Color(0.561, 0.741, 0.918))
mat.set_shader_parameter(prefix + "horizon_color", Color(0.949, 0.910, 0.933))
mat.set_shader_parameter(prefix + "ground_color", Color(0.714, 0.651, 0.776))
mat.set_shader_parameter(prefix + "cloud_color", Color(0.992, 0.980, 0.973))
mat.set_shader_parameter(prefix + "cloud_shadow", Color(0.902, 0.902, 0.949))
mat.set_shader_parameter(prefix + "cloud_cover", 0.520)
mat.set_shader_parameter(prefix + "cloud_scale", 2.2)
# Harder cloud edges than the other skies. A cel cloud is a shape
# with a drawn outline, not a soft mass.
mat.set_shader_parameter(prefix + "cloud_softness", 0.014)
mat.set_shader_parameter(prefix + "horizon_falloff", 0.62)
if ink_pass:
mat.set_shader_parameter("sky_sun_color", Color(1.0, 0.945, 0.847))
mat.set_shader_parameter("sky_sun_halo", 0.25)
mat.set_shader_parameter("sky_sun_direction", Vector3(0.46, 0.52, -0.72))
"sunset":
mat.set_shader_parameter(prefix + "top_color", Color(0.25, 0.20, 0.50))
mat.set_shader_parameter(prefix + "horizon_color", Color(0.95, 0.60, 0.45))
mat.set_shader_parameter(prefix + "ground_color", Color(0.18, 0.11, 0.25))
# Sunset clouds are lit from underneath and from the side, so their
# lit face takes the sun's colour and their shadow goes violet
# rather than grey.
mat.set_shader_parameter(prefix + "cloud_color", Color(1.0, 0.80, 0.62))
mat.set_shader_parameter(prefix + "cloud_shadow", Color(0.52, 0.38, 0.55))
mat.set_shader_parameter(prefix + "cloud_cover", 0.515)
mat.set_shader_parameter(prefix + "cloud_scale", 2.8)
mat.set_shader_parameter(prefix + "cloud_softness", 0.020)
mat.set_shader_parameter(prefix + "sun_halo", 0.55)
if ink_pass:
mat.set_shader_parameter("sky_sun_color", Color(1.0, 0.62, 0.42))
mat.set_shader_parameter("sky_sun_direction", Vector3(0.55, 0.20, -0.81))
_:
# Bold anime day sky: deep saturated blue up top, bright cyan horizon.
mat.set_shader_parameter(prefix + "top_color", Color(0.18, 0.40, 0.85))
mat.set_shader_parameter(prefix + "horizon_color", Color(0.72, 0.88, 0.98))
mat.set_shader_parameter(prefix + "ground_color", Color(0.22, 0.20, 0.28))
mat.set_shader_parameter(prefix + "cloud_color", Color(1.0, 0.99, 0.97))
mat.set_shader_parameter(prefix + "cloud_shadow", Color(0.72, 0.75, 0.88))
mat.set_shader_parameter(prefix + "cloud_cover", 0.500)
mat.set_shader_parameter(prefix + "cloud_scale", 2.5)
mat.set_shader_parameter(prefix + "cloud_softness", 0.020)
static func make_environment(sky_variant: String = "day") -> Environment:
var environment := Environment.new()
environment.background_mode = Environment.BG_SKY
var sky := Sky.new()
var sky_mat := ProceduralSkyMaterial.new()
match sky_variant:
"sunset":
sky_mat.sky_top_color = Color(0.25, 0.2, 0.5)
sky_mat.sky_horizon_color = Color(0.95, 0.6, 0.45)
sky_mat.ground_bottom_color = Color(0.18, 0.12, 0.2)
sky_mat.ground_horizon_color = Color(0.85, 0.55, 0.45)
sky_mat.sun_curve = 0.12
_:
# Bold anime day sky: deep saturated blue up top, bright cyan horizon.
sky_mat.sky_top_color = Color(0.18, 0.4, 0.85)
sky_mat.sky_horizon_color = Color(0.72, 0.88, 0.98)
sky_mat.ground_bottom_color = Color(0.22, 0.2, 0.24)
sky_mat.ground_horizon_color = Color(0.62, 0.7, 0.75)
sky_mat.sun_curve = 0.1
sky_mat.sun_angle_max = 20.0
sky.sky_material = sky_mat
environment.sky = sky
environment.sky = make_sky(sky_variant)
# Flat-ish ambient keeps toon shadow bands readable (sun does the shaping).
environment.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
environment.ambient_light_energy = 0.55
if sky_variant == "sakura":
# The rig's bounce and up-light already put a cool value on every shadow
# plane, and the cel ramp's darkest stop is 0.36 rather than zero. Left
# at 0.55 on top of that, ambient becomes a flood: the bands converge,
# the violet shadow tint washes out, and the picture goes milky. This is
# the hemisphere's SKY half only — its ground half is the UpLight.
environment.ambient_light_energy = 0.22
# Filmic with a high white point: near-linear through the cel bands (they
# stay crisp) but a soft shoulder above 1.0. LINEAR clipped lit white
# surfaces and then bloomed the clip — every facade glowed like porcelain.
@@ -104,16 +229,68 @@ static func make_environment(sky_variant: String = "day") -> Environment:
environment.glow_bloom = 0.05
environment.glow_hdr_threshold = 1.45
# Cel color grade: a LIGHT saturation lift only. Anything stronger shifts
# hues — authored ginger hair grades into fire-truck red, pastels go neon —
# and the whole game reads "filtered". Let the textures' own colors carry
# the vibrancy.
environment.adjustment_enabled = true
environment.adjustment_saturation = 1.08
environment.adjustment_contrast = 1.03
# The colour grade moved into the ink pass, where it can be a SPLIT-tone
# (cool violet into the darks, warm paper white into the lights) instead of
# a single saturation number. Environment's adjustment stage runs after that
# pass, so leaving it at 1.08 would grade the frame twice and re-introduce
# exactly the hue shifting the note below warns about.
environment.adjustment_enabled = false
environment.adjustment_saturation = 1.0
environment.adjustment_contrast = 1.0
# A touch of depth haze for scale; far enough to not gray the arena.
environment.fog_enabled = true
environment.fog_light_color = Color(0.65, 0.75, 0.9)
environment.fog_density = 0.0012
if sky_variant == "sakura":
# Haze, not fog. The reference's far hills are pale and low-contrast
# because everything more than a hundred metres out is painted as
# atmosphere; the density is chosen so a two-hundred-metre view still
# reads as air rather than as weather.
environment.fog_light_color = Color(0.902, 0.925, 0.969) # PAL.fog
environment.fog_density = 0.0022
_add_grounding(environment)
return environment
## Contact darkening. This is what was missing more than anything else.
##
## Ambient here is a flood — a single energy applied to every surface no matter
## what is standing over it — so a lamp post met the pavement with no darkening
## at all, and so did every building, every crate and every character's feet.
## `tools/levels.py` puts a number on it: the Neon Alley aerial measured a 1st
## percentile of 119, meaning the DARKEST few pixels in the whole frame were
## still mid-grey. Nothing was dark, so nothing looked like it was resting on
## anything.
##
## Lowering the old 1.35 ambient flood restores form, but it cannot solve
## contact on its own. The rest of the fix is to take ambient away only where
## geometry is in the way, which is exactly what SSAO does.
##
## The tuning is what keeps it anime rather than "PBR game with dirt in the
## corners":
##
## radius 0.7 Contact, not global. A big radius produces the soft grey
## haze under everything that reads as smog; a tight one draws
## a dark seam right where two surfaces meet, which is what a
## cel artist would ink in by hand.
## power 3.0 Sharpens the falloff so the occlusion is nearly gone a
## half-metre out. Combined with the small radius this gives a
## defined contact line rather than a gradient.
## sharpness .98 Keeps the AO from bleeding across depth discontinuities.
## Blurry AO over a hard-edged cel look is instantly wrong.
## light_affect Only 0.08. Direct sunlight must keep landing in clean flat
## bands — that IS the style. AO belongs in the ambient term,
## where it darkens what the sun never reached anyway.
static func _add_grounding(environment: Environment) -> void:
environment.ssao_enabled = true
environment.ssao_radius = 0.7
environment.ssao_intensity = 3.2
environment.ssao_power = 3.0
environment.ssao_detail = 0.6
environment.ssao_horizon = 0.1
environment.ssao_sharpness = 0.98
environment.ssao_light_affect = 0.08
environment.ssao_ao_channel_affect = 0.0
+238 -3
View File
@@ -53,11 +53,56 @@ static func tinted(tint: Color, dark: bool = false) -> Material:
static var _flat_cache: Dictionary = {}
## Surface laws: what KIND of thing this is, rather than what colour it is.
##
## A flat cel colour is the style, but a forty-metre wall holding exactly one
## value is not stylised, it is unfinished — the probe measured 0.28 mean
## adjacent-pixel difference, meaning the only structure in the frame was the
## silhouettes. These give a surface its own quiet detail underneath the cel
## bands, all of it procedural and world-space, so nothing needs UVs, textures,
## or an artist.
##
## The laws are deliberately few. Every one of them has to be a thing a cel
## painter would actually draw:
##
## wall Panel seams at storey-ish spacing plus a grade that darkens the
## bottom few metres. The grade is the important half: it is
## painted-in ambient occlusion, it grounds a building at any
## distance, and unlike SSAO it does not vanish when the camera
## pulls back.
## ground Wider seams and no grade — a floor has no "bottom" to darken,
## and paving slabs are bigger than wall panels.
## panel Tight seams for machined props: shutters, containers, kiosks.
## trim Grade only. For kerbs, plinths and bases, where a seam grid
## would fight the shape but contact darkening still helps.
## "" Plain flat colour. Props, vehicles, foliage and anything whose
## silhouette is doing the work — a seam grid crawling over a tree
## or a car reads as dirt, not as construction.
const SURFACE_LAW := {
"wall": {"seam_scale": 2.6, "seam_strength": 0.20, "seam_width": 0.010,
"grade_height": 7.0, "grade_strength": 0.20},
"ground": {"seam_scale": 4.0, "seam_strength": 0.13, "seam_width": 0.006},
"panel": {"seam_scale": 0.9, "seam_strength": 0.24, "seam_width": 0.020},
"trim": {"grade_height": 1.6, "grade_strength": 0.24},
}
static func _apply_law(mat: ShaderMaterial, law: String) -> void:
if law == "" or not SURFACE_LAW.has(law):
return
for param in SURFACE_LAW[law]:
mat.set_shader_parameter(param, SURFACE_LAW[law][param])
## Flat cel color: toon banding with NO grid texture — the full stylized
## look for dressed maps (vs. tinted()'s greybox grid for blockouts).
## Rim/specular stay off (they blob on large level surfaces).
static func flat(tint: Color) -> Material:
var key := tint.to_html()
##
## `law` names a SURFACE_LAW: what the surface is made of, not what colour it
## is. Defaults to plain flat colour, so every existing call site is unchanged.
static func flat(tint: Color, law: String = "") -> Material:
var key := "%s|%s" % [tint.to_html(), law]
if _flat_cache.has(key):
return _flat_cache[key]
var mat := ShaderMaterial.new()
@@ -67,6 +112,7 @@ static func flat(tint: Color) -> Material:
mat.set_shader_parameter("albedo_color", tint)
mat.set_shader_parameter("rim_strength", 0.0)
mat.set_shader_parameter("specular_strength", 0.0)
_apply_law(mat, law)
_flat_cache[key] = mat
return mat
@@ -252,10 +298,114 @@ static func apply_character_look(root: Node, surfaces: SkinSurfaces = null) -> v
toon.set_shader_parameter("shadow_color", CHARACTER_SHADOW)
# Eye irises and highlights are flat art on a curved ball; an ink
# line around them reads as a second pupil.
# The dummy headless renderer has no material backend for an
# outline pass and emits teardown errors while following next_pass
# RIDs. CI needs the animation/scene graph, not pixels.
toon.next_pass = null if detail == "eyes" \
or DisplayServer.get_name() == "headless" \
else outline(look["outline"])
## First-person weapons live in their own viewport and are read at arm's
## length, so they need a different emphasis from distant world props: a broad
## cool rim for silhouette separation and a small stepped metal glint that
## reveals receivers, magazines and sights without turning the gun glossy.
## This is intentionally viewmodel-only; characters stay matte.
const VIEWMODEL_LOOK := {
"dark_metal": {
"rim": 0.12, "rim_width": 0.26, "band": 0.035, "mid": 0.62,
"shadow": Color(0.12, 0.15, 0.25), "cast": 0.34,
"specular": 0.14, "shininess": 104.0, "floor": 0.0,
"ambient": 0.0, "contrast": 1.12, "saturation": 1.02, "gain": 0.96,
"painted_light": 1.15, "vm_shadow": 0.18, "vm_mid": 0.58,
"crease": 0.34, "crease_threshold": 0.09,
},
"light_metal": {
"rim": 0.11, "rim_width": 0.28, "band": 0.045, "mid": 0.70,
"shadow": Color(0.30, 0.36, 0.52), "cast": 0.42,
"specular": 0.10, "shininess": 112.0, "floor": 0.0,
"ambient": 0.0, "contrast": 0.96, "saturation": 0.96, "gain": 0.52,
"painted_light": 0.35, "vm_shadow": 0.16, "vm_mid": 0.50,
"crease": 0.28, "crease_threshold": 0.10,
},
"painted": {
"rim": 0.12, "rim_width": 0.30, "band": 0.050, "mid": 0.72,
"shadow": Color(0.25, 0.30, 0.46), "cast": 0.44,
"specular": 0.035, "shininess": 88.0, "floor": 0.0,
"ambient": 0.0, "contrast": 0.90, "saturation": 1.30, "gain": 0.90,
"painted_light": 0.88, "vm_shadow": 0.14, "vm_mid": 0.48,
"crease": 0.24, "crease_threshold": 0.11,
},
"polymer": {
"rim": 0.09, "rim_width": 0.26, "band": 0.045, "mid": 0.64,
"shadow": Color(0.16, 0.19, 0.29), "cast": 0.38,
"specular": 0.015, "shininess": 72.0, "floor": 0.0,
"ambient": 0.0, "contrast": 0.82, "saturation": 1.02, "gain": 0.88,
"painted_light": 0.88, "vm_shadow": 0.11, "vm_mid": 0.44,
"crease": 0.30, "crease_threshold": 0.10,
},
}
static func _viewmodel_profile(mat: ShaderMaterial) -> String:
var value = mat.get_shader_parameter("albedo_color")
var color := value as Color if value is Color else Color.WHITE
var luma := color.get_luminance()
var chroma := maxf(color.r, maxf(color.g, color.b)) \
- minf(color.r, minf(color.g, color.b))
var textured := bool(mat.get_shader_parameter("has_texture"))
if textured or chroma > 0.045:
return "painted"
if luma < 0.34:
return "dark_metal"
if luma > 0.72:
return "light_metal"
return "polymer"
static func apply_viewmodel_look(root: Node) -> void:
for mi in root.find_children("*", "MeshInstance3D", true, false):
if not mi.mesh:
continue
for s in range(mi.mesh.get_surface_count()):
var toon := mi.get_surface_override_material(s) as ShaderMaterial
if toon == null:
continue
var look: Dictionary = VIEWMODEL_LOOK[_viewmodel_profile(toon)]
toon.set_shader_parameter("rim_strength", look["rim"])
toon.set_shader_parameter("rim_width", look["rim_width"])
toon.set_shader_parameter("band_softness", look["band"])
toon.set_shader_parameter("mid_tone", look["mid"])
toon.set_shader_parameter("shadow_color", look["shadow"])
toon.set_shader_parameter("cast_shadow_depth", look["cast"])
toon.set_shader_parameter("specular_strength", look["specular"])
toon.set_shader_parameter("specular_shininess", look["shininess"])
toon.set_shader_parameter("albedo_floor", look["floor"])
toon.set_shader_parameter("ambient_fill", look["ambient"])
toon.set_shader_parameter("albedo_contrast", look["contrast"])
toon.set_shader_parameter("albedo_saturation", look["saturation"])
toon.set_shader_parameter("albedo_gain", look["gain"])
toon.set_shader_parameter(
"viewmodel_light_strength", look["painted_light"])
toon.set_shader_parameter(
"viewmodel_shadow_tone", look["vm_shadow"])
toon.set_shader_parameter(
"viewmodel_mid_tone", look["vm_mid"])
toon.set_shader_parameter(
"viewmodel_crease_strength", look["crease"])
toon.set_shader_parameter(
"viewmodel_crease_threshold", look["crease_threshold"])
# Set these explicitly rather than relying on shader defaults.
# Runtime-created ShaderMaterials can predate a hot-reloaded shader
# default in the editor cache, which left the fill at black.
toon.set_shader_parameter(
"viewmodel_key_direction", Vector3(-0.42, 0.58, 0.70))
toon.set_shader_parameter(
"viewmodel_key_color", Color(0.98, 0.88, 0.76))
toon.set_shader_parameter(
"viewmodel_fill_color", Color(0.62, 0.68, 0.88))
## Swap every mesh surface under `node` to toon shading and add an
## inverted-hull outline overlay. Safe on skinned meshes (material_overlay
## re-renders the same deformed mesh).
@@ -265,14 +415,99 @@ static func apply_toon_recursive(node: Node, outline_width: float = 0.005) -> vo
var surface_count: int = mi.mesh.get_surface_count() if mi.mesh else 0
for s in range(surface_count):
var src := mi.get_active_material(s)
# Some kit surfaces intentionally omit a material. Leaving those
# null is visually equivalent to Godot's white default, but the
# headless renderer cannot query instance shader parameters from a
# null material during scene teardown. Give them an explicit toon
# default so runtime map sweeps remain error-free.
if src == null:
var fallback := StandardMaterial3D.new()
fallback.albedo_color = Color(0.78, 0.78, 0.82)
src = fallback
if src and not (src is ShaderMaterial):
mi.set_surface_override_material(s, toonify(src))
if outline_width > 0.0:
var toon := mi.get_surface_override_material(s) as ShaderMaterial
if toon:
var arrays: Array = mi.mesh.surface_get_arrays(s)
var has_vertex_colors: bool = arrays.size() > Mesh.ARRAY_COLOR \
and arrays[Mesh.ARRAY_COLOR] is PackedColorArray \
and not (arrays[Mesh.ARRAY_COLOR] as PackedColorArray).is_empty()
toon.set_shader_parameter("use_vertex_color", has_vertex_colors)
# Avoid constructing render-only overlay chains on the headless dummy
# backend. The real game renderer still receives the complete ink pass.
if outline_width > 0.0 and DisplayServer.get_name() != "headless":
mi.material_overlay = outline(outline_width)
for child in node.get_children():
apply_toon_recursive(child, outline_width)
## ── The sakura cel look ──────────────────────────────────────────────────────
##
## `flat()` above is the older path: toon.gdshader's three-tone break, calibrated
## against imported character textures that already carry painted shading. These
## build on sakura_cel.gdshader instead — a quantised N-band ramp with a
## hue-shifted shadow — which is what flat-coloured world geometry wants.
const CEL_SHADER := "res://assets/shaders/sakura_cel.gdshader"
## Ramp ids, matching `ramp_id` in sakura_cel.gdshader.
##
## RAMP_3 the default, and what most of a scene should be
## RAMP_2 two hard tones, for small props whose silhouette does the work
## RAMP_4/5 more steps, for large curved masses that would otherwise show
## their band edges as hard bars across a wall
## RAMP_SOFT* high key: the darkest band is 0.71 rather than 0.36, for pale
## masses (blossom, cloud, plaster) that must stay light even on
## the shadow side
enum {
RAMP_2 = 0,
RAMP_3 = 1,
RAMP_4 = 2,
RAMP_5 = 3,
RAMP_SOFT = 4,
RAMP_SOFT3 = 5,
}
static var _cel_cache: Dictionary = {}
static var _unlit_cache: Dictionary = {}
## A cel material. `law` names a SURFACE_LAW as `flat()` does; `faceted` maps to
## the shader's flat_shading and should stay on for anything built out of boxes
## and cylinders, which here is everything.
static func cel(color: Color, ramp: int = RAMP_3, tint: Color = SakuraPalette.TINT,
law: String = "", faceted: bool = true) -> ShaderMaterial:
var key := "%s|%d|%s|%s|%s" % [color.to_html(), ramp, tint.to_html(), law, faceted]
if _cel_cache.has(key):
return _cel_cache[key]
var mat := ShaderMaterial.new()
mat.shader = load(CEL_SHADER)
mat.set_shader_parameter("albedo_color", color)
mat.set_shader_parameter("has_texture", false)
mat.set_shader_parameter("ramp_id", ramp)
mat.set_shader_parameter("shadow_tint", tint)
mat.set_shader_parameter("flat_shading", faceted)
_apply_law(mat, law)
_cel_cache[key] = mat
return mat
## Unlit flat colour — the reference's `flat()`. For sky panels, distant
## silhouettes, sign faces, glass, lit windows and the ink details a cel painter
## would draw rather than light. Anything that takes light instead of being a
## drawing should use cel().
static func unlit(color: Color, double_sided: bool = false) -> StandardMaterial3D:
var key := "%s|%s" % [color.to_html(), double_sided]
if _unlit_cache.has(key):
return _unlit_cache[key]
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = color
if double_sided:
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
_unlit_cache[key] = mat
return mat
static func outline(width: float = 0.005) -> ShaderMaterial:
var key := "%.4f" % width
if _outline_cache.has(key):
+41
View File
@@ -4,6 +4,45 @@ class_name LevelRuntime
var _player: CharacterBody3D
## The play volume for an AUTHORED map.
##
## The code-built levels each pass their own extents, because they know them as
## constants. A hand-placed .tscn does not, so this measures the level instead —
## merge every reasonably-sized visual's world AABB, then pad it generously.
##
## Without one, a map on this runtime had no out-of-bounds handling at all: a
## player who left the geometry simply fell for ever, with no warning and no
## death. That was the state of fps_blockout.
func _build_combat_area() -> void:
var bounds := AABB()
var any := false
for child in find_children("*", "VisualInstance3D", true, false):
var vi := child as VisualInstance3D
var box := vi.get_aabb()
# Skip the sky, a directional light's own AABB, and anything else
# absurd — one of them would swallow the level and put the boundary
# somewhere useless.
if box.size.length() > 5000.0 or box.size.length() < 0.001:
continue
box = vi.global_transform * box
if not any:
bounds = box
any = true
else:
bounds = bounds.merge(box)
if not any:
bounds = AABB(Vector3(-50, -10, -50), Vector3(100, 60, 100))
# Padding, not a tight fit: the volume marks where a player is TOLD they
# have left, and a boundary hugging the geometry fires while someone is
# still standing on a legitimate ledge. Deep below, because falling out of
# the world is the case this exists to catch.
var pad := Vector3(20, 0, 20)
var size := bounds.size + pad * 2.0 + Vector3(0, 60, 0)
var centre := bounds.get_center() + Vector3(0, 20.0, 0)
CombatArea.add_to(self, size, centre)
func _ready() -> void:
# Hide mouse
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
@@ -13,6 +52,8 @@ func _ready() -> void:
if not has_node("WorldEnvironment"):
LevelEnvironment.add_to(self)
_build_combat_area()
# Multiplayer Spawning
var spawner = MultiplayerSpawner.new()
+310 -77
View File
@@ -96,26 +96,106 @@ func _kit_scene(key: String) -> PackedScene:
return _kit_cache[key]
## Combined rendered bounds expressed in an ancestor's local space. Imported
## kit dimensions are useful for layout, but collision must follow the actual
## imported geometry rather than a rounded catalog number.
func _visual_bounds_in(node: Node, relative_to: Node3D) -> AABB:
var result := AABB()
var has_bounds := false
var to_local := relative_to.global_transform.affine_inverse()
for child in node.find_children("*", "MeshInstance3D", true, false):
var mesh_instance := child as MeshInstance3D
if mesh_instance.mesh == null:
continue
var transformed := _transformed_aabb(
mesh_instance.get_aabb(), to_local * mesh_instance.global_transform)
if has_bounds:
result = result.merge(transformed)
else:
result = transformed
has_bounds = true
return result if has_bounds else AABB()
func _transformed_aabb(bounds: AABB, xform: Transform3D) -> AABB:
var result := AABB(xform * bounds.position, Vector3.ZERO)
for x in 2:
for y in 2:
for z in 2:
var corner := bounds.position + Vector3(
bounds.size.x * float(x),
bounds.size.y * float(y),
bounds.size.z * float(z))
result = result.expand(xform * corner)
return result
func _fit_box_collider(body: StaticBody3D, visual: Node,
horizontal_inset: float = 0.04) -> CollisionShape3D:
var bounds := _visual_bounds_in(visual, body)
var shape := CollisionShape3D.new()
shape.name = "CollisionShape3D"
var box := BoxShape3D.new()
box.size = Vector3(
maxf(0.05, bounds.size.x - horizontal_inset),
maxf(0.05, bounds.size.y - 0.02),
maxf(0.05, bounds.size.z - horizontal_inset))
shape.shape = box
shape.position = bounds.get_center()
body.add_child(shape)
body.set_meta("mesh_fitted_collision", true)
return shape
## Exact collision for imported art. One enclosing AABB is not mesh fitting:
## a window sill or roof overhang moves its face away from the playable wall
## and creates an invisible wallrun/bullet plane. Static concave shapes
## preserve every recess, opening and overhang in the rendered mesh.
func _fit_mesh_colliders(body: StaticBody3D, visual: Node) -> int:
var count := 0
var to_body := body.global_transform.affine_inverse()
for child in visual.find_children("*", "MeshInstance3D", true, false):
var mesh_instance := child as MeshInstance3D
if mesh_instance.mesh == null:
continue
var triangle_shape := mesh_instance.mesh.create_trimesh_shape()
if triangle_shape == null:
continue
var collision := CollisionShape3D.new()
collision.name = "ExactMeshCollision_%03d" % count
collision.shape = triangle_shape
body.add_child(collision)
collision.transform = to_body * mesh_instance.global_transform
collision.set_meta("source_mesh_path", str(mesh_instance.get_path()))
count += 1
if count == 0:
_fit_box_collider(body, visual, 0.06)
body.set_meta("exact_collision_fallback", true)
else:
body.set_meta("mesh_exact_collision", true)
body.set_meta("exact_collision_count", count)
return count
# ── Hero assets (original kitbash, tools/build_hero_assets.py) ──────────────
## Hero piece with a box collider. Front faces -Z before yaw (glTF Y-up).
func _hero(key: String, pos: Vector3, yaw_deg: float, col_size: Vector3,
col_center_y: float, acoustic: String = "concrete") -> void:
## Hero piece with exact imported-mesh collision. Front faces -Z before yaw.
func _hero(key: String, pos: Vector3, yaw_deg: float, _col_size: Vector3,
_col_center_y: float, acoustic: String = "concrete") -> void:
var body := StaticBody3D.new()
body.name = "Hero_" + key + "_%d" % _rng.randi()
body.set_meta("acoustic_material", acoustic)
add_child(body)
body.global_position = pos
body.rotation_degrees.y = yaw_deg
var shape := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = col_size
shape.shape = bs
shape.position = Vector3(0, col_center_y, 0)
body.add_child(shape)
var inst: Node3D = _kit_scene_at("res://assets/props/hero/" + key + ".glb").instantiate()
body.add_child(inst)
LevelMaterials.apply_toon_recursive(inst, 0.005)
# Authored origins differ between hero pieces. Seat the rendered bottom on
# the requested support plane before deriving collision from that geometry.
var bounds := _visual_bounds_in(inst, body)
inst.position.y -= bounds.position.y
_fit_mesh_colliders(body, inst)
## Small visual-only kit prop (parasols, awnings): no collider needed.
@@ -125,16 +205,19 @@ func _kit_prop(key: String, pos: Vector3, yaw_deg: float, scale_f: float) -> voi
add_child(inst)
inst.global_position = pos
inst.rotation_degrees.y = yaw_deg
inst.set_meta("source_asset_path", KIT_DIR + key + ".glb")
LevelMaterials.apply_toon_recursive(inst, 0.007)
var bounds := _visual_bounds_in(inst, self)
inst.global_position.y += pos.y - bounds.position.y
inst.set_meta("ground_fitted", true)
## Place a kit model with a box collider matching its scaled bounds.
## Place a kit model with exact per-mesh collision.
## front_pos: centre of the facade at ground level. yaw_deg: 0 faces +Z.
func _kit_building(key: String, front_pos: Vector3, yaw_deg: float, node_name: String,
scale_f: float = KIT_SCALE) -> float:
var dims: Vector3 = KIT_BUILDINGS.get(key, KIT_SKYSCRAPERS.get(key, Vector3.ONE))
var w := dims.x * scale_f
var h := dims.y * scale_f
var d := dims.z * scale_f
var fwd := Vector3(sin(deg_to_rad(yaw_deg)), 0, cos(deg_to_rad(yaw_deg)))
var center := front_pos - fwd * d * 0.5
@@ -145,19 +228,13 @@ func _kit_building(key: String, front_pos: Vector3, yaw_deg: float, node_name: S
add_child(body)
body.global_position = center
body.rotation_degrees.y = yaw_deg
var shape := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = Vector3(w, h, d)
shape.shape = bs
shape.position = Vector3(0, h * 0.5, 0)
body.add_child(shape)
var inst: Node3D = _kit_scene(key).instantiate()
inst.scale = Vector3.ONE * scale_f
body.add_child(inst)
LevelMaterials.apply_toon_recursive(inst, 0.0)
# Subtle per-building tint so repeated kit models read as different shops
_tint_recursive(inst, Color(1, 1, 1).lerp(Color.from_hsv(_rng.randf(), 0.3, 1.0), 0.16))
_fit_mesh_colliders(body, inst)
return w
@@ -246,14 +323,36 @@ func _kit_fill_side(front_x: float, dir: int, z0: float, z1: float, seed_id: Str
func _box_static(pos: Vector3, size: Vector3, color: Color, node_name: String = "",
acoustic: String = "") -> StaticBody3D:
var body := super._box_static(pos, size, color, node_name, acoustic)
var law := _surface_law(size, node_name, acoustic)
for mi in body.find_children("*", "MeshInstance3D", false, false):
mi.mesh.surface_set_material(0, LevelMaterials.flat(color))
mi.mesh.surface_set_material(0, LevelMaterials.flat(color, law))
if maxf(size.x, maxf(size.y, size.z)) < 10.0:
var w := clampf(maxf(size.x, maxf(size.y, size.z)) * 0.003, 0.007, 0.018)
mi.material_overlay = LevelMaterials.outline(w)
return body
## Choose a construction law from what the object is, not from its colour.
## This is the map-side half of LevelMaterials.SURFACE_LAW: it finally applies
## the shared surfacing kit to the actual city instead of only to the fidelity
## probe. Thin horizontal slabs read as paving, structural masonry gets the
## storey-scale wall grid, and machined metal gets tighter panels.
func _surface_law(size: Vector3, node_name: String, acoustic: String) -> String:
var footprint := minf(absf(size.x), absf(size.z))
if node_name == "Ground" or (absf(size.y) <= 0.5 and footprint >= 2.0):
return "ground"
match acoustic:
"concrete", "brick":
if absf(size.y) >= 3.0 and maxf(absf(size.x), absf(size.z)) >= 4.0:
return "wall"
return "trim"
"metal":
return "panel"
"wood":
return "trim"
return ""
# ── Ad graphics (generated library: tools/generate_ads.py) ──────────────────
var _ad_mats: Dictionary = {}
@@ -324,14 +423,15 @@ func _build_environment() -> void:
# Under the filmic shoulder the ambient's lavender tint actually shows
# (LINEAR used to clip it to white) — keep it lower so the sun shapes
# surfaces and lit faces stay bright.
env.environment.ambient_light_energy = 1.35
env.environment.ambient_light_energy = 0.92
env.environment.fog_density = 0.0007 # light city haze for depth over 600m
env.environment.fog_light_color = Color(0.58, 0.43, 0.66)
# Ambient occlusion grounds the modeled facade detail (ZZZ-style depth)
env.environment.ssao_enabled = true
env.environment.ssao_intensity = 2.0
var sun := get_node_or_null("Sun")
if sun:
sun.light_energy = 2.5
sun.light_energy = 1.85
sun.rotation_degrees = Vector3(-38, 55, 0)
sun.directional_shadow_max_distance = 220.0
@@ -339,6 +439,12 @@ func _build_environment() -> void:
func _build_geometry() -> void:
_build_environment()
_build_lighting()
# The play volume: just outside the perimeter wall, and tall enough to clear
# the skyscrapers. Without one a player who grappled over that wall fell out
# of the world with no warning and no death.
CombatArea.add_to(self,
Vector3(CITY_HALF * 2.0 + 26.0, 220.0, CITY_HALF * 2.0 + 26.0),
Vector3(0, 96.0, 0))
# Ground: one asphalt slab for the whole district
_box_static(Vector3(0, -0.5, 0), Vector3(CITY_HALF * 2.0 + 16.0, 1.0, CITY_HALF * 2.0 + 16.0), ASPHALT, "Ground")
# Perimeter wall
@@ -465,12 +571,28 @@ func _standard_block(c: Vector3, bx: int, bz: int) -> void:
# Detailed kit buildings fill both street-facing edges
_kit_fill_side(c.x - half, 1, c.z - half, c.z + half, "%d_%dW" % [bx, bz])
_kit_fill_side(c.x + half, -1, c.z - half, c.z + half, "%d_%dE" % [bx, bz])
# Fire escapes into the mid-block gap on some blocks
# A supported mid-block fire-escape scaffold on some blocks. The old
# version was two unrelated slabs suspended in open air.
if _rng.randi() % 2 == 0:
var escape_h := FLOOR_H * 2.0 - 0.4
for ex in [-1.1, 1.1]:
for ez in [2.9, 5.1]:
_box_static(c + Vector3(ex, escape_h * 0.5, ez),
Vector3(0.12, escape_h, 0.12), METAL.darkened(0.18),
"EscPost_%d_%d_%d_%d" % [bx, bz, int(ex), int(ez)], "metal")
for f in range(1, 3):
var y := float(f) * FLOOR_H - 0.4
_box_static(c + Vector3(-2.0 + float(f) * 1.3, y, 4.0), Vector3(2.2, 0.16, 2.2), METAL,
"Esc_%d_%d_%d" % [bx, bz, f], "metal")
_box_static(c + Vector3(0, y, 4.0), Vector3(2.6, 0.16, 2.4),
METAL, "EscLanding_%d_%d_%d" % [bx, bz, f], "metal")
var stair_angle := rad_to_deg(atan2(FLOOR_H, 4.0))
_ramp_static(c + Vector3(0, FLOOR_H * 0.45, 4.0),
Vector3(1.2, 0.16, sqrt(FLOOR_H * FLOOR_H + 16.0)),
Vector3(stair_angle, 0, 0), METAL.darkened(0.08),
"EscStairGround_%d_%d" % [bx, bz], "metal")
_ramp_static(c + Vector3(0, FLOOR_H + 1.6, 4.0),
Vector3(1.2, 0.16, sqrt(FLOOR_H * FLOOR_H + 16.0)),
Vector3(-stair_angle, 0, 0), METAL.darkened(0.08),
"EscStair_%d_%d" % [bx, bz], "metal")
# Sidewalk life: phone booth on some corners, bike rows by the fronts
if _rng.randi() % 4 == 0:
_hero("phonebooth", c + Vector3(-half - 0.6, 0.1, -half - 0.6), _rng.randf_range(0.0, 360.0),
@@ -506,16 +628,11 @@ func _tower_block(c: Vector3, bx: int, bz: int) -> void:
add_child(body)
body.global_position = c + Vector3(0, FLOOR_H, 0)
body.rotation_degrees.y = yaw
var shape := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = Vector3(dims.x * s, dims.y * s, dims.z * s)
shape.shape = bs
shape.position = Vector3(0, dims.y * s * 0.5, 0)
body.add_child(shape)
var inst: Node3D = _kit_scene(key).instantiate()
inst.scale = Vector3.ONE * s
body.add_child(inst)
LevelMaterials.apply_toon_recursive(inst, 0.0)
_fit_mesh_colliders(body, inst)
# Rooftop crown billboard both directions (skyline wayfinding)
var tw := dims.x * s
_box_static(c + Vector3(0, h + 2.4, 0), Vector3(0.6, 4.0, tw - 4.0), METAL, n + "_Crown", "metal")
@@ -542,7 +659,9 @@ func _shrine_block(c: Vector3, bx: int, bz: int) -> void:
Vector3(BLOCK - 6.0 if horiz else 0.6, 1.4, 0.6 if horiz else BLOCK - 6.0),
Color(0.55, 0.55, 0.52), n + "_Wall%d" % i, "brick")
# Kitbashed torii (curved kasagi) at the south entry; pillar colliders
var tz := c.z + (BLOCK - 6.0) * 0.5
# Local south-edge offset. This used to include c.z and was then added to c
# a second time, launching shrine gates/fences far outside their blocks.
var tz := (BLOCK - 6.0) * 0.5
_hero("torii", c + Vector3(0, 0.1, tz), 0.0, Vector3(0.1, 0.1, 0.1), 0.05, "wood")
_box_static(c + Vector3(-4.0, 3.5, tz), Vector3(0.9, 7.0, 0.9), VERMILION, n + "_PillarW", "wood").visible = false
_box_static(c + Vector3(4.0, 3.5, tz), Vector3(0.9, 7.0, 0.9), VERMILION, n + "_PillarE", "wood").visible = false
@@ -610,7 +729,7 @@ func _plaza_block(c: Vector3, bx: int, bz: int) -> void:
var a := TAU * float(i) / 4.0 + PI / 4.0
var p := c + Vector3(cos(a), 0, sin(a)) * (BLOCK * 0.32)
_box_static(Vector3(p.x, 0.55, p.z), Vector3(4.0, 1.1, 4.0), Color(0.5, 0.48, 0.52), "%s_Pl%d" % [n, i], "brick")
_kit_prop_path("res://assets/props/nature/tree_oak.glb", Vector3(p.x, 1.0, p.z), float(i) * 90.0, 4.5)
_kit_prop_path("res://assets/props/nature/tree_oak.glb", Vector3(p.x, 0.16, p.z), float(i) * 90.0, 4.5)
# Kitbashed clock tower on the south-west plaza cell
if bx == 4 and bz == 5:
_hero("clock", c + Vector3(0, 0.1, 0), 0.0, Vector3(3.2, 10.5, 3.2), 5.25, "concrete")
@@ -699,20 +818,10 @@ func _build_rail_line() -> void:
fb.set_meta("acoustic_material", "metal")
add_child(fb)
fb.global_position = Vector3(0, 0, fb_z) # span runs along x, across the avenue
var fb_shape := CollisionShape3D.new()
var fb_box := BoxShape3D.new()
fb_box.size = Vector3(18.0, 0.4, 3.0)
fb_shape.shape = fb_box
fb_shape.position = Vector3(0, 5.5, 0)
fb.add_child(fb_shape)
var fb_inst: Node3D = _kit_scene_at("res://assets/props/hero/footbridge.glb").instantiate()
fb.add_child(fb_inst)
LevelMaterials.apply_toon_recursive(fb_inst, 0.005)
# Walkable stair ramps at both ends (over the stair visuals)
for e in [-1.0, 1.0]:
var ang := rad_to_deg(atan2(5.5, 6.4))
_ramp_static(Vector3(e * (9.0 + 3.2), 2.65, fb_z), Vector3(2.8, 0.3, 8.6),
Vector3(0, 0, -ang * e), PAVING, "FootbridgeStair_%d" % int(e), "metal").visible = false
_fit_mesh_colliders(fb, fb_inst)
# Slide ramps to the deck at the two main-avenue crossings
for sx in [-PITCH * 0.5 - 4.0, PITCH * 0.5 + 4.0]:
@@ -746,15 +855,140 @@ func _road_piece(key: String, pos: Vector3, yaw_deg: float, sc: Vector3) -> void
var _path_cache: Dictionary = {}
var _power_cable_index := 0
var _power_curve_index := 0
var _utility_terminal_index := 0
func _kit_scene_at(path: String) -> PackedScene:
if not _path_cache.has(path):
_path_cache[path] = load(path)
return _path_cache[path]
func _cable_segment(from: Vector3, to: Vector3, curve_id: int,
segment_index: int, segment_count: int) -> void:
var direction := to - from
var cable := MeshInstance3D.new()
cable.name = "PowerCable_%d" % _power_cable_index
_power_cable_index += 1
var mesh := BoxMesh.new()
mesh.size = Vector3(0.055, 0.055, direction.length() + 0.02)
mesh.material = LevelMaterials.flat(INK_COLOR)
cable.mesh = mesh
add_child(cable)
cable.look_at_from_position((from + to) * 0.5, to, Vector3.UP)
cable.set_meta("cable_from", from)
cable.set_meta("cable_to", to)
cable.set_meta("curve_id", curve_id)
cable.set_meta("segment_index", segment_index)
cable.set_meta("segment_count", segment_count)
func _utility_terminal(pos: Vector3, terminal_kind: String) -> void:
var marker := Node3D.new()
marker.name = "UtilityTerminal_%04d" % _utility_terminal_index
_utility_terminal_index += 1
marker.position = pos
marker.set_meta("utility_terminal", true)
marker.set_meta("terminal_kind", terminal_kind)
add_child(marker)
func _cable_curve(from: Vector3, to: Vector3, sag: float,
segments: int = 8) -> void:
var curve_id := _power_curve_index
_power_curve_index += 1
for segment in segments:
var t0 := float(segment) / float(segments)
var t1 := float(segment + 1) / float(segments)
var p0 := from.lerp(to, t0) - Vector3.UP * sin(t0 * PI) * sag
var p1 := from.lerp(to, t1) - Vector3.UP * sin(t1 * PI) * sag
_cable_segment(p0, p1, curve_id, segment, segments)
func _utility_pole(pos: Vector3, height: float, pole_id: String) -> void:
var pole := _box_static(
pos + Vector3(0, height * 0.5, 0),
Vector3(0.24, height, 0.24), METAL.darkened(0.35),
"UtilityPole_" + pole_id, "wood")
for mesh in pole.find_children("*", "MeshInstance3D", false, false):
mesh.material_override = LevelMaterials.flat(METAL.darkened(0.35))
_deco_box(pos + Vector3(0, height, 0),
Vector3(1.35, 0.12, 0.16), METAL.darkened(0.2))
for lane in 3:
var lane_x := -0.42 + float(lane) * 0.42
var terminal := pos + Vector3(lane_x, height + 0.12, 0)
_deco_box(terminal,
Vector3(0.10, 0.16, 0.10), Color(0.32, 0.28, 0.34))
_utility_terminal(terminal, "pole")
# A transformer and grounded service cabinet make these read as utility
# poles rather than unexplained posts holding decorative strings.
_deco_box(pos + Vector3(0, height - 1.25, 0),
Vector3(0.56, 0.82, 0.42), METAL.darkened(0.08))
_box_static(pos + Vector3(0.38, 0.55, 0), Vector3(0.62, 1.1, 0.48),
METAL.darkened(0.16), "UtilityCabinet_" + pole_id, "metal")
func _storefront_service_drop(pole_pos: Vector3, height: float,
front_x: float, z: float, side: float, _service_id: String) -> void:
# Terminate on a facade service mast one metre outside the decorative
# sign/sill layer, then bracket that mast back to the wall. Driving the wire
# directly into the facade let it pass through three protruding sign columns.
var wall_x := front_x - side * 1.05
var service_height := 7.25
var pole_terminal := pole_pos + Vector3(side * 0.42, height + 0.12, 0)
var wall_terminal := Vector3(wall_x, service_height, z)
_utility_terminal(wall_terminal, "building_service")
_cable_curve(pole_terminal, wall_terminal, 0.16, 5)
# Junction box and conduit sit on the facade plane and carry the line down
# to street level: a visually meaningful endpoint.
_deco_box(wall_terminal, Vector3(0.16, 0.40, 0.46),
METAL.darkened(0.12))
_deco_box(Vector3(wall_x, service_height * 0.5, z),
Vector3(0.08, service_height, 0.10), METAL.darkened(0.25))
_deco_box(Vector3((wall_x + front_x) * 0.5, service_height, z),
Vector3(absf(front_x - wall_x), 0.08, 0.10), METAL.darkened(0.2))
func _build_utility_network() -> void:
var lines := _street_lines()
var pole_zs: Array[float] = []
for row in GRID:
pole_zs.append(_cell_center(0, row).z)
for line_index in range(0, lines.size(), 2):
var street_x: float = lines[line_index]
if absf(street_x) < 1.0:
continue
var side := 1.0 if line_index % 4 == 0 else -1.0
# Stay between the road edge and the furthest storefront sign/canopy.
# The previous 1.15 m offset grazed sign columns that project 0.8 m
# from the facade; 0.55 m is the clear sidewalk utility corridor.
var pole_x := street_x + side * (ROAD_W * 0.5 + 0.55)
var height := 11.35
for row in GRID:
var pole_pos := Vector3(pole_x, 0, pole_zs[row])
_utility_pole(pole_pos, height, "%d_%d" % [line_index, row])
if row < GRID - 1:
var next_pos := Vector3(pole_x, 0, pole_zs[row + 1])
for lane in 3:
var lane_x := -0.42 + float(lane) * 0.42
_cable_curve(
pole_pos + Vector3(lane_x, height + 0.12, 0),
next_pos + Vector3(lane_x, height + 0.12, 0),
0.58, 9)
var adjacent_bx := line_index + 1 if side > 0.0 else line_index
if adjacent_bx < 0 or adjacent_bx >= GRID:
continue
var block_type := LAYOUT[row][adjacent_bx]
if block_type != "S" and block_type != "M":
continue
var front_x := street_x + side * (STREET * 0.5)
_storefront_service_drop(
pole_pos, height, front_x, pole_zs[row], side,
"%d_%d" % [line_index, row])
func _build_streets() -> void:
var lines := _street_lines()
var seg := PITCH - ROAD_W # straight run between intersections
var y := 0.02
# Intersections with line markings
for x in lines:
@@ -776,24 +1010,7 @@ func _build_streets() -> void:
continue
_road_piece("road-straight", Vector3((x0 + x1) * 0.5, y, z), 90.0, Vector3(ROAD_W, 2.0, x1 - x0))
# Overhead cable bundles across east-west streets (urban-Japan clutter)
for z in lines:
var x := -CITY_HALF + 24.0
while x < CITY_HALF - 24.0:
if absf(x) > 14.0: # keep the central avenue sky clear
var h := 9.5 + fmod(absf(x), 3.0) * 0.4
for s3 in 3:
var t0 := float(s3) / 3.0
var t1 := float(s3 + 1) / 3.0
var sag0 := sin(t0 * PI) * 0.7
var sag1 := sin(t1 * PI) * 0.7
var za := z - STREET * 0.5 + STREET * t0
var zb := z - STREET * 0.5 + STREET * t1
var mid := Vector3(x, h - (sag0 + sag1) * 0.5, (za + zb) * 0.5)
_deco_box(mid, Vector3(0.07, 0.07, zb - za + 0.1), INK_COLOR)
x += 48.0
else:
x += 48.0
_build_utility_network()
# ── Main avenues: kit streetlights ───────────────────────────────────────────
@@ -801,16 +1018,21 @@ func _build_streets() -> void:
func _build_avenues() -> void:
var lines := _street_lines()
var lamp_scale := 10.0
# Keep pole bases a full character radius behind the 12 m road mesh. The
# old +0.4 m curb offset let the imported curved base lean into traffic.
var sidewalk_pole_offset := ROAD_W * 0.5 + 1.45
for z in lines:
for side: float in [-1.0, 1.0]:
# Double-arm lights along the central N-S avenue at every block line
var x := side * (ROAD_W * 0.5 + 0.4)
_box_static(Vector3(x, 3.4, z), Vector3(0.26, 6.8, 0.26), METAL, "LampP_%d_%d" % [int(z), int(side)], "metal")
var x := side * sidewalk_pole_offset
var lamp_z := z + side * sidewalk_pole_offset
_box_static(Vector3(x, 3.4, lamp_z), Vector3(0.26, 6.8, 0.26), METAL, "LampP_%d_%d" % [int(z), int(side)], "metal")
_kit_prop_path("res://assets/props/roads/light-curved.glb",
Vector3(x, 0, z), 90.0 if side > 0 else -90.0, lamp_scale)
Vector3(x, 0, lamp_z), 90.0 if side > 0 else -90.0, lamp_scale)
# Traffic signal on the opposite diagonal corner of each crossing
_kit_prop_path("res://assets/props/roads/light-square-cross.glb",
Vector3(x, 0, z - side * (ROAD_W * 0.5 + 0.8)), 90.0 if side > 0 else -90.0, lamp_scale * 0.8)
Vector3(x, 0, z - side * sidewalk_pole_offset),
90.0 if side > 0 else -90.0, lamp_scale * 0.8)
# Bus stop shelters on alternating blocks along the avenue
if int(roundf(z / PITCH)) % 2 == 0:
_hero("busstop", Vector3(ROAD_W * 0.5 + 2.0, 0.02, z + 18.0), -90.0,
@@ -822,9 +1044,10 @@ func _build_avenues() -> void:
continue
# Single lights down the central E-W avenue
for side: float in [-1.0, 1.0]:
var z2 := side * (ROAD_W * 0.5 + 0.4)
var z2 := side * sidewalk_pole_offset
_kit_prop_path("res://assets/props/roads/light-curved.glb",
Vector3(x, 0, z2), 0.0 if side > 0 else 180.0, lamp_scale)
Vector3(x + side * sidewalk_pole_offset, 0, z2),
0.0 if side > 0 else 180.0, lamp_scale)
# ── Parked cars (Kenney Car Kit) ─────────────────────────────────────────────
@@ -843,16 +1066,9 @@ func _parked_car(pos: Vector3, yaw_deg: float) -> void:
add_child(body)
body.global_position = pos
body.rotation_degrees.y = yaw_deg + _rng.randf_range(-3.0, 3.0)
var shape := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = Vector3(2.8, 2.6, 5.0)
shape.shape = bs
shape.position = Vector3(0, 1.3, 0)
body.add_child(shape)
var inst: Node3D = _kit_scene_at("res://assets/props/cars/" + key + ".glb").instantiate()
inst.scale = Vector3.ONE * CAR_SCALE
inst.position.y = 0.55 # wheel contact at ground after scaling
body.add_child(inst)
var wheel_scene := _kit_scene_at("res://assets/props/cars/wheel-default.glb")
for n in inst.find_children("wheel*", "Node3D", true, false):
@@ -860,8 +1076,16 @@ func _parked_car(pos: Vector3, yaw_deg: float) -> void:
var wheel: Node3D = wheel_scene.instantiate()
n.add_child(wheel)
LevelMaterials.apply_toon_recursive(inst, 0.0)
# Darken under the map lighting (kit albedo clips) + per-car hue
_tint_recursive(inst, Color(0.62, 0.62, 0.66) * Color(1, 1, 1).lerp(Color.from_hsv(_rng.randf(), 0.6, 1.0), 0.35))
# Kenney cars carry their paint/window/wheel palette as vertex colors.
# Toon conversion now preserves it; keep the seeded tint subtle enough that
# those authored regions remain legible.
_tint_recursive(inst, Color(0.82, 0.82, 0.86) \
* Color(1, 1, 1).lerp(Color.from_hsv(_rng.randf(), 0.55, 1.0), 0.22))
# Imported origins vary by car. Seat every variant by its actual lowest
# rendered point, then derive collision from the grounded visual.
var bounds := _visual_bounds_in(inst, body)
inst.position.y -= bounds.position.y
_fit_mesh_colliders(body, inst)
## Seeded parking lanes hugging the curbs of the N-S streets.
@@ -886,8 +1110,17 @@ func _kit_prop_path(path: String, pos: Vector3, yaw_deg: float, scale_f: float)
add_child(inst)
inst.global_position = pos
inst.rotation_degrees.y = yaw_deg
inst.set_meta("source_asset_path", path)
# Nature-kit foliage uses vertex-colored materials that the toon swap
# mangles (cyan trees) — keep original materials; the faceted low-poly
# look already reads cel under this lighting.
if not path.contains("/nature/"):
LevelMaterials.apply_toon_recursive(inst, 0.0)
# `pos.y` is always the intended support plane (ground, pedestal, or facade
# attachment). Imported origins vary, so align the actual rendered bottom
# to that plane for every kit prop. The old lantern/awning exceptions were
# the source of many inexplicable floating pieces.
var bounds := _visual_bounds_in(inst, self)
inst.global_position.y += pos.y - bounds.position.y
inst.set_meta("support_fitted", true)
inst.set_meta("support_plane_y", pos.y)
@@ -18,7 +18,12 @@ func _ready() -> void:
LevelEnvironment.add_to(self)
LevelMaterials.apply_toon_recursive(self, 0.0)
# The play volume. The arena is 3 x 5 sectors of 24 m, so 72 x 120; this is
# generous around that and deep enough below it that falling off an edge
# reads as out of bounds rather than as an endless drop.
CombatArea.add_to(self, Vector3(120, 80, 170), Vector3(0, 28, 0))
# Multiplayer Spawning
var spawner = MultiplayerSpawner.new()
spawner.name = "PlayerSpawner"
+5
View File
@@ -0,0 +1,5 @@
[map]
name="Sakura Crossing"
scene_path="res://scenes/maps/sakura_crossing/sakura_crossing.tscn"
color1=Color(0.984, 0.776, 0.847, 1.0)
color2=Color(0.561, 0.741, 0.918, 1.0)
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://csakura7crossg"]
[ext_resource type="Script" path="res://scenes/maps/sakura_crossing/sakura_crossing_builder.gd" id="1_sc"]
[node name="SakuraCrossing" type="Node3D"]
script = ExtResource("1_sc")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://revyva8kljmd
+228
View File
@@ -0,0 +1,228 @@
extends Object
class_name SakuraPalette
## The palette, ported from the Sakura Crossing reference (MIT).
##
## Kept as hex strings rather than float triples so every value can be checked
## against the source line by line. `Color(hex)` parses sRGB, which is what the
## reference authors in and what a `source_color` shader uniform expects.
##
## ── The discipline, which matters more than any individual value ─────────────
##
## The palette is deliberately NARROW, and it is narrow in a specific way:
##
## * The masses are pale. Walls, paving, plaster, concrete and sky are all
## high-value and low-saturation. A cel image is read from its dark end, so
## the large areas have to stay out of the way or there is nothing left for
## the ink line and the shadow bands to be read against.
## * The greens lean TEAL, never yellow. A yellow-green mass at this scale
## goes sour and fights the pink.
## * There are exactly four saturated accents — red, yellow, blue, teal — and
## they are reserved for FOCAL objects: the crossing gear, the vending
## machines, a shop awning, the torii. If a saturated colour is spread over
## a large area it becomes the loudest thing in the frame and the eye stops
## going where the level designer wants it to.
## * Shadow is a HUE, not a value. Nothing here has a "dark version"; the cel
## shader swings every shadow band toward a cool violet instead. That is why
## there are so few dark entries in this table — the darks are made by the
## light, not by the paint.
##
## Deviating from those four rules is what makes a cel scene look like a
## low-poly 3D scene with a filter on it.
# ── Sky & atmosphere ─────────────────────────────────────────────────────────
const SKY_TOP := Color("8fbdea")
const SKY_MID := Color("d4e8fa")
const SKY_HAZE := Color("fbe7e9")
const CLOUD := Color("fdfaf8")
const CLOUD_SHADE := Color("e6e6f2")
const FOG := Color("e6ecf7")
const HILL := Color("c6cfe6")
const HILL_FAR := Color("d8dded")
# ── Light ────────────────────────────────────────────────────────────────────
const SUN := Color("fff1d8")
const FILL := Color("a9bdf5")
const HEMI_SKY := Color("dcecff")
const HEMI_GROUND := Color("b6a6c6")
# ── Ink ──────────────────────────────────────────────────────────────────────
const INK := Color("39324f")
const INK_SOFT := Color("4a4468")
# ── Ground ───────────────────────────────────────────────────────────────────
const ROAD := Color("8e8a9c")
const ROAD_WORN := Color("9a95a6")
const ROAD_DARK := Color("7b7689")
const LINE_WHITE := Color("f4f2f6")
const LINE_YELLOW := Color("f0c341")
const TACTILE := Color("f2c53d")
const SIDEWALK := Color("dcd8e2")
const SIDEWALK_ALT := Color("e7e2e6")
const CURB := Color("c7c2d0")
const CONCRETE := Color("d9d5dd")
const CONCRETE_MID := Color("c2bdc8")
const CONCRETE_DARK := Color("a7a2b0")
const GUTTER := Color("bdb8c4")
const DRAIN := Color("6d687a")
const DIRT := Color("c9bfae")
const GRAVEL := Color("a9a3ab")
const BALLAST := Color("7d7686")
# ── Buildings ────────────────────────────────────────────────────────────────
const WALL_WHITE := Color("faf6ef")
const WALL_CREAM := Color("f2e7d3")
const WALL_BLUE := Color("d6e3ee")
const WALL_BEIGE := Color("e7dbc4")
const WALL_GRAY := Color("dedee6")
const WALL_PINK := Color("f0dcda")
const WALL_TEA := Color("dccdb6")
const WALL_SAGE := Color("dde2d6")
const ROOF_SLATE := Color("59617a")
const ROOF_BLUE := Color("4d5c78")
const ROOF_BROWN := Color("6b585c")
const ROOF_TEAL := Color("4f6b70")
const TRIM := Color("8b8496")
const GLASS := Color("9dc0d4")
const GLASS_DARK := Color("53627a")
const SHUTTER := Color("6e6a7a")
const SHUTTER_LIGHT := Color("847f92")
## The wall tones, as a list, for generators that want variety by index.
const WALLS: Array[Color] = [
WALL_WHITE, WALL_CREAM, WALL_BLUE, WALL_BEIGE,
WALL_GRAY, WALL_PINK, WALL_TEA, WALL_SAGE,
]
const ROOFS: Array[Color] = [ROOF_SLATE, ROOF_BLUE, ROOF_BROWN, ROOF_TEAL]
# ── Accents ──────────────────────────────────────────────────────────────────
# The four saturated colours, plus their deep variants. Focal objects only.
const RED := Color("e0453f")
const RED_DEEP := Color("b5322f")
const RED_SOFT := Color("ef6a60")
const YELLOW := Color("f4c033")
const YELLOW_DEEP := Color("d39c1f")
const BLACK := Color("322e3b")
const BLACK_SOFT := Color("453f4f")
const TEAL := Color("2f9c9a")
const TEAL_DEEP := Color("22736f")
const BLUE := Color("3d6ec4")
const BLUE_DEEP := Color("2a4f97")
const ORANGE := Color("ef8a3c")
const PURPLE := Color("8f6fb5")
# ── Vegetation ───────────────────────────────────────────────────────────────
const LEAF := Color("5aa578")
const LEAF_DEEP := Color("3f7f60")
const LEAF_PALE := Color("84bd97")
const GRASS := Color("86ab84")
const TRUNK := Color("9a8082")
const TRUNK_DARK := Color("765f62")
const MOSS := Color("7d9c74")
const BAMBOO := Color("94b06b")
const CEDAR := Color("3f6b52")
# ── Cherry blossom ───────────────────────────────────────────────────────────
const BLOSSOM := Color("fbc6d8")
const BLOSSOM_LIGHT := Color("fff0f4")
const BLOSSOM_WARM := Color("fedde2")
const BLOSSOM_DEEP := Color("f0a3c0")
const PETAL := Color("fcd9e4")
const PETAL_DEEP := Color("f6bccf")
# ── Railway ──────────────────────────────────────────────────────────────────
const RAIL_METAL := Color("6b6472")
const RAIL_HEAD := Color("c2bcc4")
const SLEEPER := Color("6d6576")
const SLEEPER_LIGHT := Color("847b8c")
const GATE_YELLOW := Color("f4c033")
const GATE_BLACK := Color("322e3b")
const SIGNAL_RED := Color("f2453c")
const SIGNAL_OFF := Color("6a3b44")
const CABINET := Color("d8d5da")
const CABINET_TOP := Color("b6b2bc")
# ── Train ────────────────────────────────────────────────────────────────────
const TRAIN_BODY := Color("f7f2e6")
const TRAIN_BODY_SHADE := Color("e6dfd0")
const TRAIN_STRIPE := Color("2f7fd0")
const TRAIN_STRIPE2 := Color("3fae9a")
const TRAIN_WINDOW := Color("3a4258")
const TRAIN_SKIRT := Color("9aa0ad")
const TRAIN_ROOF := Color("bdb8bd")
const TRAIN_DOOR := Color("eae4d8")
# ── Metal & props ────────────────────────────────────────────────────────────
const METAL := Color("b8bcc6")
const METAL_DARK := Color("878b96")
const METAL_WARM := Color("c9c0b4")
const MIRROR_BACK := Color("e4a83c")
const MIRROR_FACE := Color("c8d8e4")
const VEND_WHITE := Color("f8f5f0")
const VEND_RED := Color("db4038")
const VEND_TEAL := Color("2e9a98")
const CRATE := Color("3f7fbf")
const CRATE_ALT := Color("e25a4a")
const BASKET := Color("db5a4a")
const BIN := Color("5d8fb8")
## Bright can/bottle colours for vending machine shelves.
const DRINKS: Array[Color] = [
Color("e0453f"), Color("f4c033"), Color("3d6ec4"), Color("2f9c9a"),
Color("ef8a3c"), Color("8f6fb5"), Color("5aa578"), Color("f4f2f6"),
Color("e86f9c"), Color("44b4d8"), Color("c94f7a"), Color("9dbb3c"),
]
# ── Shopping street ──────────────────────────────────────────────────────────
const AWNING_GREEN := Color("4f8f6a")
const AWNING_ORANGE := Color("e08a3c")
const AWNING_BLUE := Color("4a7fae")
const AWNING_CREAM := Color("efe0c2")
const LANTERN := Color("f6e2c0")
const LANTERN_LIT := Color("ffd9a0")
const NOREN := Color("2f4a72")
const NOREN_RED := Color("b5322f")
const NOREN_CREAM := Color("f2e8d6")
const AWNINGS: Array[Color] = [
AWNING_GREEN, AWNING_ORANGE, AWNING_BLUE, AWNING_CREAM,
NOREN, NOREN_RED,
]
# ── Shrine ───────────────────────────────────────────────────────────────────
const TORII := Color("d8412f")
const TORII_DEEP := Color("a72f23")
const SHRINE_WOOD := Color("a9744f")
const SHRINE_WOOD_DARK := Color("8a604a")
const SHRINE_ROOF := Color("69707e")
const SHRINE_STONE := Color("cfcad2")
const ROPE := Color("f0e5ca")
const EMA := Color("e9d9b6")
# ── Stone & water ────────────────────────────────────────────────────────────
const STONE := Color("c6c0cb")
const STONE_DARK := Color("a39daf")
const STONE_WARM := Color("cfc6bc")
const WATER := Color("93b8ce")
const WATER_DEEP := Color("6d90ad")
const WATER_SKY := Color("cadff0")
const WATER_PETAL := Color("f3cada")
## ── Shadow tints ─────────────────────────────────────────────────────────────
##
## The cool violet each material's shadow bands swing toward. The reference's
## default is 0x6c5f8c and most things take it; the departures are all the same
## kind of decision, which is that a shadow keeps a memory of what the surface is
## MADE of:
##
## TINT_WARM timber, blossom, clay, lantern paper. Pushing these the full
## way to violet turns cedar grey and turns a cherry canopy into a
## storm cloud, which is the one thing it may never do.
## TINT_COOL metal, glass, water, rail. These genuinely are cooler in shade
## than anything else in the frame, and letting them go further
## than the default is most of what makes metal read as metal
## without a specular highlight.
const TINT := Color("6c5f8c")
const TINT_WARM := Color("8a7286")
const TINT_COOL := Color("5a5a8c")
const TINT_FOLIAGE := Color("6a7a8c")
+1
View File
@@ -0,0 +1 @@
uid://dwnx8r8t7pjyn
+220
View File
@@ -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 := "乗車口"
+1
View File
@@ -0,0 +1 @@
uid://k2moxja60sn0
+1 -1
View File
@@ -1,6 +1,6 @@
[gd_scene format=3 uid="uid://c5k51gbegvxss"]
[ext_resource type="Script" uid="uid://c7ltcn37gfd71" path="res://debug/test_level_builder.gd" id="1"]
[ext_resource type="Script" uid="uid://btkp2l168jj45" path="res://debug/test_level_builder.gd" id="1"]
[node name="TestLevel" type="Node3D" unique_id=130984349]
script = ExtResource("1")