Feat/14 movement overhaul #20

Merged
Dotts merged 86 commits from feat/14-movement-overhaul into main 2026-07-17 10:44:23 -07:00
11 changed files with 274 additions and 60 deletions
Showing only changes of commit 37c15bed1a - Show all commits
+80
View File
@@ -0,0 +1,80 @@
shader_type spatial;
// Cel/toon surface shader used across the game.
// - Banded (stepped) diffuse with a tinted shadow color instead of black
// - Soft rim light for silhouette pop
// - Optional world-space triplanar albedo (level geometry: no UVs needed,
// grid stays world-scaled) — otherwise standard UV sampling (characters)
uniform vec4 albedo_color : source_color = vec4(1.0);
uniform sampler2D albedo_texture : source_color, filter_linear_mipmap, repeat_enable;
uniform bool has_texture = true;
uniform bool use_triplanar = false;
uniform float triplanar_tile = 2.0; // world units per texture tile
uniform float band_edge : hint_range(-1.0, 1.0) = 0.05; // NdotL where light band starts
uniform float band_softness : hint_range(0.001, 0.5) = 0.04;
uniform float mid_band_edge : hint_range(-1.0, 1.0) = 0.55; // second, brighter band
uniform vec4 shadow_color : source_color = vec4(0.62, 0.65, 0.78, 1.0); // cool shadow tint
uniform float rim_strength : hint_range(0.0, 2.0) = 0.35;
uniform float rim_width : hint_range(0.0, 1.0) = 0.65;
uniform float specular_strength : hint_range(0.0, 1.0) = 0.25;
uniform float specular_shininess : hint_range(1.0, 128.0) = 24.0;
varying vec3 world_pos;
varying vec3 world_normal;
void vertex() {
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
}
vec3 sample_triplanar(vec3 p, vec3 n) {
vec3 w = abs(n);
w = pow(w, vec3(4.0));
w /= (w.x + w.y + w.z);
vec2 uv_x = p.zy / triplanar_tile;
vec2 uv_y = p.xz / triplanar_tile;
vec2 uv_z = p.xy / triplanar_tile;
vec3 cx = texture(albedo_texture, uv_x).rgb;
vec3 cy = texture(albedo_texture, uv_y).rgb;
vec3 cz = texture(albedo_texture, uv_z).rgb;
return cx * w.x + cy * w.y + cz * w.z;
}
void fragment() {
vec3 base = albedo_color.rgb;
if (has_texture) {
if (use_triplanar) {
base *= sample_triplanar(world_pos, world_normal);
} else {
base *= texture(albedo_texture, UV).rgb;
}
}
ALBEDO = base;
ROUGHNESS = 1.0;
SPECULAR = 0.0;
// Rim: brighten grazing angles for that inked-silhouette pop.
float rim = 1.0 - clamp(dot(normalize(VIEW), NORMAL), 0.0, 1.0);
rim = smoothstep(1.0 - rim_width, 1.0, rim);
EMISSION = base * rim * rim_strength;
}
void light() {
float ndotl = dot(NORMAL, LIGHT);
// Shadowing folds into the band test so shadow edges band too.
float lit = ndotl * ATTENUATION;
float band = smoothstep(band_edge - band_softness, band_edge + band_softness, lit);
float mid = smoothstep(mid_band_edge - band_softness, mid_band_edge + band_softness, lit);
// 3 tones: shadow tint -> base band (0.82) -> full light.
float tone = mix(0.82, 1.0, mid);
vec3 shade = mix(shadow_color.rgb, vec3(tone), band);
DIFFUSE_LIGHT += ALBEDO * LIGHT_COLOR / PI * shade;
// Stepped specular dot for glossy toon highlights.
vec3 h = normalize(VIEW + LIGHT);
float spec = pow(clamp(dot(NORMAL, h), 0.0, 1.0), specular_shininess);
spec = smoothstep(0.5 - band_softness, 0.5 + band_softness, spec);
SPECULAR_LIGHT += LIGHT_COLOR * spec * specular_strength * band;
}
+1
View File
@@ -0,0 +1 @@
uid://rm8cyl45cawk
+17
View File
@@ -0,0 +1,17 @@
shader_type spatial;
render_mode cull_front, unshaded;
// Inverted-hull outline. Assign as material_overlay on a GeometryInstance3D:
// the mesh renders a second time, grown along its normals with front faces
// culled, leaving a colored shell visible only at the silhouette.
uniform vec4 outline_color : source_color = vec4(0.06, 0.05, 0.09, 1.0);
uniform float outline_width : hint_range(0.0, 0.1) = 0.02;
void vertex() {
VERTEX += NORMAL * outline_width;
}
void fragment() {
ALBEDO = outline_color.rgb;
}
+1
View File
@@ -0,0 +1 @@
uid://dn2biq23l8yuv
+3
View File
@@ -108,6 +108,9 @@ func load_model(path: String) -> void:
_pose_mod = ShooterPoseModifier.new() _pose_mod = ShooterPoseModifier.new()
_pose_mod.name = "ShooterPose" _pose_mod.name = "ShooterPose"
skeleton.add_child(_pose_mod) skeleton.add_child(_pose_mod)
# Cel-shaded look: toon shading over the imported textures + ink outline.
LevelMaterials.apply_toon_recursive(scene)
if animation_player: if animation_player:
_index_animations() _index_animations()
else: else:
+1 -1
View File
@@ -7,7 +7,7 @@ func _build_geometry() -> void:
var env = get_node_or_null("WorldEnvironment") var env = get_node_or_null("WorldEnvironment")
if env: if env:
var environment = env.environment var environment = env.environment
environment.tonemap_mode = Environment.TONE_MAPPER_ACES # (keep the shared linear tonemap — ACES crushes the cel bands)
if environment.sky and environment.sky.sky_material: if environment.sky and environment.sky.sky_material:
var sky_mat = environment.sky.sky_material var sky_mat = environment.sky.sky_material
sky_mat.sky_top_color = Color(0.3, 0.5, 0.8) sky_mat.sky_top_color = Color(0.3, 0.5, 0.8)
+6 -41
View File
@@ -96,29 +96,9 @@ func _ramp_static(pos: Vector3, size: Vector3, rot_deg: Vector3, color: Color, n
# ── Environment ─────────────────────────────────────────────────────────────── # ── Environment ───────────────────────────────────────────────────────────────
func _build_environment() -> void: func _build_environment() -> void:
var env := WorldEnvironment.new() # Shared stylized environment (anime sky, bloom, cel color grade).
env.name = "WorldEnvironment" # Also creates the Sun/FillLight pair since none exists yet.
var environment := Environment.new() LevelEnvironment.add_to(self)
environment.background_mode = Environment.BG_SKY
var sky := Sky.new()
var sky_mat := ProceduralSkyMaterial.new()
sky_mat.sky_top_color = Color(0.15, 0.2, 0.35)
sky_mat.sky_horizon_color = Color(0.45, 0.5, 0.65)
sky_mat.ground_bottom_color = Color(0.1, 0.08, 0.06)
sky_mat.ground_horizon_color = Color(0.35, 0.3, 0.25)
sky.sky_material = sky_mat
environment.sky = sky
environment.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
environment.ambient_light_energy = 0.4
environment.tonemap_mode = Environment.TONE_MAPPER_FILMIC
environment.glow_enabled = true
environment.glow_intensity = 0.3
environment.glow_bloom = 0.1
environment.fog_enabled = true
environment.fog_light_color = Color(0.5, 0.55, 0.65)
environment.fog_density = 0.002
env.environment = environment
add_child(env)
# ── Floor ───────────────────────────────────────────────────────────────────── # ── Floor ─────────────────────────────────────────────────────────────────────
@@ -261,24 +241,9 @@ func _build_target_dummy() -> void:
# ── Lighting ────────────────────────────────────────────────────────────────── # ── Lighting ──────────────────────────────────────────────────────────────────
func _build_lighting() -> void: func _build_lighting() -> void:
var sun := DirectionalLight3D.new() # Sun and fill are created by LevelEnvironment.add_to in _build_environment;
sun.name = "Sun" # kept as a hook for subclasses that re-style them (see dust2).
sun.rotation_degrees = Vector3(-50, 30, 0) pass
sun.light_color = Color(1.0, 0.95, 0.85)
sun.light_energy = 1.2
sun.shadow_enabled = true
sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS
sun.directional_shadow_max_distance = 100.0
add_child(sun)
# Fill light (opposite side)
var fill := DirectionalLight3D.new()
fill.name = "FillLight"
fill.rotation_degrees = Vector3(-30, -150, 0)
fill.light_color = Color(0.6, 0.7, 0.9)
fill.light_energy = 0.3
fill.shadow_enabled = false
add_child(fill)
# ── Player ──────────────────────────────────────────────────────────────────── # ── Player ────────────────────────────────────────────────────────────────────
+84
View File
@@ -0,0 +1,84 @@
extends Object
class_name LevelEnvironment
## 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
## add_to(level) instead of hand-rolling WorldEnvironment + sun.
## Creates and adds a WorldEnvironment + key sun + cool fill light.
## Returns the WorldEnvironment node.
static func add_to(level: Node, sky_variant: String = "day") -> WorldEnvironment:
var env := WorldEnvironment.new()
env.name = "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)
return env
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
# 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
# Filmic crushes the cel bands; linear-ish keeps them crisp.
environment.tonemap_mode = Environment.TONE_MAPPER_LINEAR
# Bloom sells emissives (tracers, plasma, rim highlights).
environment.glow_enabled = true
environment.glow_intensity = 0.5
environment.glow_bloom = 0.05
environment.glow_hdr_threshold = 1.1
# Cel color grade: punchy saturation, hint of contrast.
environment.adjustment_enabled = true
environment.adjustment_saturation = 1.22
environment.adjustment_contrast = 1.05
# 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
return environment
+1
View File
@@ -0,0 +1 @@
uid://cpl4d7ndkc4ft
+69 -17
View File
@@ -1,41 +1,93 @@
extends Object extends Object
class_name LevelMaterials class_name LevelMaterials
## Shared prototype-grid materials for code-built levels. ## Shared cel-shaded materials for code-built levels and characters.
## ##
## Every level builder makes its geometry from flat-colored boxes; this gives ## Level geometry gets the toon shader with world-space triplanar grid
## them all the same world-space grid surface (0.5 m cells via triplanar ## (0.5 m cells) tinted per surface; characters get the same toon shading
## mapping) so speed and distance stay readable at movement-shooter velocity, ## over their own textures via convert_to_toon(), plus an inverted-hull
## while keeping each builder's color coding as a tint. ## outline overlay for the inked silhouette.
const GRID_GRAY := "res://assets/textures/prototype/grid_gray.png" const GRID_GRAY := "res://assets/textures/prototype/grid_gray.png"
const GRID_DARK := "res://assets/textures/prototype/grid_dark.png" const GRID_DARK := "res://assets/textures/prototype/grid_dark.png"
const TOON_SHADER := "res://assets/shaders/toon.gdshader"
const OUTLINE_SHADER := "res://assets/shaders/toon_outline.gdshader"
## One texture tile = 2 m of world, so one grid cell = 0.5 m. ## One texture tile = 2 m of world, so one grid cell = 0.5 m.
const WORLD_UNITS_PER_TILE := 2.0 const WORLD_UNITS_PER_TILE := 2.0
static var _cache: Dictionary = {} static var _cache: Dictionary = {}
static var _outline_cache: Dictionary = {}
## A tinted grid material. Cached per (tint, dark) so identical surfaces share ## A tinted toon grid material for level geometry. Cached per (tint, dark) so
## one material (fewer draw-state changes, and edits apply everywhere). ## identical surfaces share one material.
static func tinted(tint: Color, dark: bool = false) -> StandardMaterial3D: static func tinted(tint: Color, dark: bool = false) -> Material:
var key := "%s|%s" % [tint.to_html(), dark] var key := "%s|%s" % [tint.to_html(), dark]
if _cache.has(key): if _cache.has(key):
return _cache[key] return _cache[key]
var mat := StandardMaterial3D.new() var shader: Shader = load(TOON_SHADER)
var mat := ShaderMaterial.new()
mat.shader = shader
var tex_path := GRID_DARK if dark else GRID_GRAY var tex_path := GRID_DARK if dark else GRID_GRAY
var tex: Texture2D = load(tex_path) if ResourceLoader.exists(tex_path) else null var tex: Texture2D = load(tex_path) if ResourceLoader.exists(tex_path) else null
if tex: if tex:
mat.albedo_texture = tex mat.set_shader_parameter("albedo_texture", tex)
# World triplanar: boxes need no UVs and the grid stays world-scaled. mat.set_shader_parameter("has_texture", true)
mat.uv1_triplanar = true mat.set_shader_parameter("use_triplanar", true)
mat.uv1_world_triplanar = true mat.set_shader_parameter("triplanar_tile", WORLD_UNITS_PER_TILE)
mat.uv1_scale = Vector3.ONE / WORLD_UNITS_PER_TILE else:
mat.set_shader_parameter("has_texture", false)
# The texture is grayscale ~mid value; multiply by ~2x-brightened tint to # The texture is grayscale ~mid value; multiply by ~2x-brightened tint to
# land near the original flat color while keeping the grid contrast. # land near the original flat color while keeping the grid contrast.
mat.albedo_color = Color( mat.set_shader_parameter("albedo_color", Color(
minf(tint.r * 1.9, 1.0), minf(tint.g * 1.9, 1.0), minf(tint.b * 1.9, 1.0)) minf(tint.r * 1.9, 1.0), minf(tint.g * 1.9, 1.0), minf(tint.b * 1.9, 1.0)))
mat.roughness = 0.85
_cache[key] = mat _cache[key] = mat
return mat return mat
## Toon version of an arbitrary material (usually a character's imported
## StandardMaterial3D): keeps its albedo texture/color, swaps the shading.
static func toonify(src: Material) -> Material:
var mat := ShaderMaterial.new()
mat.shader = load(TOON_SHADER)
var tex: Texture2D = null
var col := Color.WHITE
if src is BaseMaterial3D:
tex = src.albedo_texture
col = src.albedo_color
mat.set_shader_parameter("albedo_texture", tex)
mat.set_shader_parameter("has_texture", tex != null)
mat.set_shader_parameter("use_triplanar", false)
mat.set_shader_parameter("albedo_color", col)
# Characters read best with a slightly wider lit band and stronger rim.
mat.set_shader_parameter("rim_strength", 0.45)
return mat
## 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).
static func apply_toon_recursive(node: Node, outline_width: float = 0.015) -> void:
if node is MeshInstance3D:
var mi := node as MeshInstance3D
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)
if src and not (src is ShaderMaterial):
mi.set_surface_override_material(s, toonify(src))
if outline_width > 0.0:
mi.material_overlay = outline(outline_width)
for child in node.get_children():
apply_toon_recursive(child, outline_width)
static func outline(width: float = 0.015) -> ShaderMaterial:
var key := "%.4f" % width
if _outline_cache.has(key):
return _outline_cache[key]
var mat := ShaderMaterial.new()
mat.shader = load(OUTLINE_SHADER)
mat.set_shader_parameter("outline_width", width)
_outline_cache[key] = mat
return mat
@@ -19,6 +19,16 @@ func _ready() -> void:
# Hide mouse # Hide mouse
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
# Cel-shaded presentation: stylized sky/grade + toon materials over the
# arena's baked geometry. The scene ships its own DirectionalLight3D;
# rename it to Sun so the helper doesn't stack extra suns.
var existing_light := get_node_or_null("DirectionalLight3D")
if existing_light:
existing_light.name = "Sun"
if not has_node("WorldEnvironment"):
LevelEnvironment.add_to(self)
LevelMaterials.apply_toon_recursive(self, 0.0)
_build_hud() _build_hud()
# Multiplayer Spawning # Multiplayer Spawning