big
This commit is contained in:
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://51bevlnoh6tt
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dwnx8r8t7pjyn
|
||||
@@ -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 := "乗車口"
|
||||
@@ -0,0 +1 @@
|
||||
uid://k2moxja60sn0
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user