Files

408 lines
15 KiB
GDScript

extends Node3D
# Three draw calls cover the entire sky, with a sparse nearer shell for stereo
# parallax. Native FTL travel stretches those same stars along the ship's bow.
const STAR_COUNT := 1600
const NEAR_STAR_COUNT := 96
const GALAXY_COUNT := 420
const VALID_HAZARDS := ["clear", "asteroid", "sun", "storm", "nebula", "pulsar"]
const JUMP_STRETCH_RATE := 4.0
const STAR_MAX_STRETCH := 55.0
var star_positions := PackedVector3Array()
var hazard := "clear"
var simulation_paused := false
var elapsed := 0.0
var hazard_root: Node3D
var rocks: Array[Dictionary] = []
var clouds: Array[Node3D] = []
var body: Node3D
var storm_arcs: Array[MeshInstance3D] = []
var built := false
var cloud_material: ShaderMaterial
var body_material: ShaderMaterial
var star_material: ShaderMaterial
var travel_direction := Vector3.RIGHT
var jump_active := false
var jump_stretch := 0.0
var _arrival_during_jump := false
const STAR_SHADER := """
shader_type spatial;
render_mode unshaded;
uniform vec3 travel_direction = vec3(1.0, 0.0, 0.0);
uniform float jump_stretch : hint_range(0.0, 1.0) = 0.0;
uniform float max_stretch = 55.0;
void vertex() {
float amount = smoothstep(0.0, 1.0, jump_stretch);
float along = dot(VERTEX, travel_direction);
VERTEX += travel_direction * along * amount * (max_stretch - 1.0);
}
void fragment() { ALBEDO = COLOR.rgb; }
"""
const CLOUD_SHADER := """
shader_type spatial;
render_mode unshaded, blend_mix, depth_draw_never, cull_front;
uniform vec4 tint : source_color = vec4(0.3, 0.15, 0.45, 0.18);
uniform float clock = 0.0;
varying vec3 local_point;
float hash(vec3 p) {
p = fract(p * vec3(0.1031, 0.1030, 0.0973));
p += dot(p, p.yxz + 33.33);
return fract((p.x + p.y) * p.z);
}
float noise(vec3 p) {
vec3 i = floor(p); vec3 f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(mix(hash(i), hash(i+vec3(1,0,0)), f.x),
mix(hash(i+vec3(0,1,0)), hash(i+vec3(1,1,0)), f.x), f.y),
mix(mix(hash(i+vec3(0,0,1)), hash(i+vec3(1,0,1)), f.x),
mix(hash(i+vec3(0,1,1)), hash(i+vec3(1,1,1)), f.x), f.y), f.z);
}
void vertex() { local_point = VERTEX; }
void fragment() {
vec3 p = local_point * 0.23 + vec3(clock * 0.012, 0.0, clock * 0.007);
float n = noise(p) * 0.58 + noise(p * 2.1) * 0.28 + noise(p * 4.3) * 0.14;
ALBEDO = tint.rgb * (0.5 + n * 0.5);
ALPHA = smoothstep(0.34, 0.7, n) * tint.a;
}
"""
const STAR_BODY_SHADER := """
shader_type spatial;
render_mode unshaded;
uniform vec4 tint : source_color = vec4(1.0, 0.22, 0.015, 1.0);
uniform float clock = 0.0;
varying vec3 local_point;
float hash(vec3 p) {
p = fract(p * vec3(0.1031, 0.1030, 0.0973));
p += dot(p, p.yxz + 33.33);
return fract((p.x + p.y) * p.z);
}
float noise(vec3 p) {
vec3 i = floor(p); vec3 f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(mix(hash(i), hash(i+vec3(1,0,0)), f.x),
mix(hash(i+vec3(0,1,0)), hash(i+vec3(1,1,0)), f.x), f.y),
mix(mix(hash(i+vec3(0,0,1)), hash(i+vec3(1,0,1)), f.x),
mix(hash(i+vec3(0,1,1)), hash(i+vec3(1,1,1)), f.x), f.y), f.z);
}
void vertex() { local_point = VERTEX; }
void fragment() {
vec3 p = normalize(local_point);
vec3 drift = vec3(clock * 0.035, clock * 0.012, 0.0);
float cells = smoothstep(0.18, 0.78, noise(p * 23.0 + drift) * 0.65 + noise(p * 47.0) * 0.35);
ALBEDO = mix(tint.rgb * 0.34, tint.rgb, cells);
EMISSION = mix(tint.rgb, vec3(1.0, 0.88, 0.54), cells * 0.35) * 1.8;
}
"""
const CORONA_SHADER := """
shader_type spatial;
render_mode unshaded, blend_add, depth_draw_never, cull_back;
uniform vec4 tint : source_color = vec4(1.0, 0.3, 0.035, 0.42);
void fragment() {
float rim = pow(max(dot(normalize(NORMAL), normalize(VIEW)), 0.0), 2.5);
ALBEDO = tint.rgb;
ALPHA = rim * tint.a;
}
"""
func build() -> void:
if built:
return
built = true
name = "SpaceEnvironment"
star_material = ShaderMaterial.new()
star_material.shader = Shader.new()
star_material.shader.code = STAR_SHADER
star_material.set_shader_parameter("max_stretch", STAR_MAX_STRETCH)
_update_travel_direction()
var rng := RandomNumberGenerator.new()
rng.seed = 26011002
_star_shell("DistantStars360", STAR_COUNT, 30.0, 58.0, rng, false)
_star_shell("NearStarsParallax", NEAR_STAR_COUNT, 8.0, 17.0, rng, false)
_star_shell("DistantGalaxyBand", GALAXY_COUNT, 35.0, 54.0, rng, true)
hazard_root = Node3D.new()
hazard_root.name = "NativeHazard"
add_child(hazard_root)
_set_hazard("clear")
func apply_state(state: Dictionary) -> void:
if not built:
build()
simulation_paused = bool(state.get("paused", false))
# transition also covers pending events, so only the native ship jump flag
# starts travel. Opening the map or charging FTL cannot animate the sky.
var native_jumping := bool(state.get("jumping", false))
if not native_jumping:
_arrival_during_jump = false
if bool(state.get("event_open", false)):
_arrival_during_jump = native_jumping
jump_active = native_jumping and not _arrival_during_jump and \
not bool(state.get("event_open", false)) and bool(state.get("ready", true)) and \
str(state.get("ui_mode", "game")) != "menu"
if not jump_active:
_set_jump_stretch(0.0)
# The old beacon's sun/asteroids/clouds do not travel with the ship.
hazard_root.visible = not jump_active
var next := str(state.get("hazard", "clear"))
if next in VALID_HAZARDS and next != hazard:
_set_hazard(next)
func set_travel_direction(world_direction: Vector3) -> void:
# Main supplies the transformed player ship bow; headset turns never change
# this direction. FTL's tabletop travel stays horizontal in the room.
var horizontal := Vector3(world_direction.x, 0.0, world_direction.z)
if horizontal.length_squared() < 0.0001 or not horizontal.is_finite():
return
var direction := horizontal.normalized()
if direction.is_equal_approx(travel_direction):
return
travel_direction = direction
_update_travel_direction()
func _update_travel_direction() -> void:
if star_material != null:
var local_direction := global_basis.inverse() * travel_direction
star_material.set_shader_parameter("travel_direction", local_direction.normalized())
func _set_jump_stretch(amount: float) -> void:
if is_equal_approx(jump_stretch, amount):
return
jump_stretch = amount
star_material.set_shader_parameter("jump_stretch", jump_stretch)
func set_hazard(kind: String) -> void:
if not built:
build()
if kind in VALID_HAZARDS and kind != hazard:
_set_hazard(kind)
func _star_shell(node_name: String, count: int, near_radius: float, far_radius: float,
rng: RandomNumberGenerator, galaxy: bool) -> void:
var sphere := SphereMesh.new()
sphere.radius = 1.0
sphere.height = 2.0
sphere.radial_segments = 6
sphere.rings = 3
var stars := MultiMesh.new()
stars.transform_format = MultiMesh.TRANSFORM_3D
stars.use_colors = true
stars.mesh = sphere
stars.instance_count = count
# Vertex shader streaks extend beyond the unmodified MultiMesh bounds.
var bound := far_radius + STAR_MAX_STRETCH * 0.05
stars.custom_aabb = AABB(Vector3.ONE * -bound, Vector3.ONE * bound * 2.0)
for i in range(count):
var y := rng.randf_range(-1.0, 1.0)
var angle := rng.randf_range(0.0, TAU)
if galaxy:
y = rng.randfn(0.0, 0.1)
var radial := sqrt(maxf(0.0, 1.0 - y * y))
var direction := Vector3(radial * cos(angle), y, radial * sin(angle)).normalized()
if galaxy:
direction = direction.rotated(Vector3.FORWARD, 0.42)
var point := direction * rng.randf_range(near_radius, far_radius)
var size := rng.randf_range(0.019, 0.048) * (near_radius / 30.0)
if galaxy:
size *= 0.8
stars.set_instance_transform(i, Transform3D(Basis.IDENTITY.scaled(Vector3.ONE * size), point))
var tint := Color(0.7, 0.81, 1.0).lerp(Color(1.0, 0.83, 0.65), rng.randf())
tint *= rng.randf_range(0.42, 1.0) if not galaxy else rng.randf_range(0.12, 0.28)
tint.a = 1.0
stars.set_instance_color(i, tint)
if not galaxy:
star_positions.append(point)
var node := MultiMeshInstance3D.new()
node.name = node_name
node.multimesh = stars
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
node.material_override = star_material
add_child(node)
func _set_hazard(kind: String) -> void:
hazard = kind
for child in hazard_root.get_children():
hazard_root.remove_child(child)
child.queue_free()
rocks.clear()
clouds.clear()
storm_arcs.clear()
body = null
cloud_material = null
body_material = null
elapsed = 0.0
match kind:
"asteroid":
_build_asteroids()
"sun", "pulsar":
_build_star_body(kind)
"storm", "nebula":
_build_nebula(kind)
func _build_asteroids() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = 7262026
var mesh := SphereMesh.new()
mesh.radius = 1.0
mesh.height = 2.0
mesh.radial_segments = 7
mesh.rings = 4
var material := StandardMaterial3D.new()
material.albedo_color = Color(0.31, 0.29, 0.28)
material.roughness = 1.0
for i in range(32):
var rock := MeshInstance3D.new()
rock.mesh = mesh
rock.material_override = material
rock.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var angle := rng.randf_range(0.0, TAU)
var radius := rng.randf_range(3.0, 9.0)
rock.position = Vector3(cos(angle) * radius, rng.randf_range(-1.7, 5.0), sin(angle) * radius)
var size := rng.randf_range(0.12, 0.38)
rock.scale = Vector3(rng.randf_range(0.7, 1.2), rng.randf_range(0.65, 1.1), rng.randf_range(0.6, 1.2)) * size
rock.rotation = Vector3(rng.randf(), rng.randf(), rng.randf()) * TAU
hazard_root.add_child(rock)
rocks.append({"node": rock, "start": rock.position,
"velocity": Vector3(0.055, 0.007, -0.022) * rng.randf_range(0.5, 1.4),
"spin": Vector3(rng.randf_range(-0.2, 0.2), rng.randf_range(-0.2, 0.2), rng.randf_range(-0.2, 0.2))})
func _build_star_body(kind: String) -> void:
body = Node3D.new()
body.name = "Sun" if kind == "sun" else "Pulsar"
body.position = Vector3(-12.0, 8.0, -19.0)
body.rotation = Vector3(0.28, 0.0, -0.45)
hazard_root.add_child(body)
var color := Color(1.0, 0.23, 0.025) if kind == "sun" else Color(0.26, 0.67, 1.0)
var radius := 4.0 if kind == "sun" else 1.9
var surface := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = radius
sphere.height = radius * 2.0
sphere.radial_segments = 32
sphere.rings = 16
surface.mesh = sphere
body_material = ShaderMaterial.new()
body_material.shader = Shader.new()
body_material.shader.code = STAR_BODY_SHADER
body_material.set_shader_parameter("tint", color)
surface.material_override = body_material
surface.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
body.add_child(surface)
for scale_factor in [1.08, 1.23]:
var corona := MeshInstance3D.new()
corona.mesh = sphere
corona.scale = Vector3.ONE * scale_factor
var material := ShaderMaterial.new()
material.shader = Shader.new()
material.shader.code = CORONA_SHADER
material.set_shader_parameter("tint", Color(color, 0.32))
corona.material_override = material
corona.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
body.add_child(corona)
if kind == "pulsar":
for sign_value in [-1.0, 1.0]:
var jet := MeshInstance3D.new()
var cone := CylinderMesh.new()
cone.top_radius = 1.25 if sign_value > 0.0 else 0.06
cone.bottom_radius = 0.06 if sign_value > 0.0 else 1.25
cone.height = 11.0
cone.radial_segments = 12
jet.mesh = cone
jet.position.y = sign_value * 6.5
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
material.albedo_color = Color(0.1, 0.42, 0.85, 0.09)
jet.material_override = material
jet.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
body.add_child(jet)
func _build_nebula(kind: String) -> void:
var rng := RandomNumberGenerator.new()
rng.seed = 729 if kind == "storm" else 820
cloud_material = ShaderMaterial.new()
cloud_material.shader = Shader.new()
cloud_material.shader.code = CLOUD_SHADER
cloud_material.set_shader_parameter("tint", Color(0.12, 0.42, 0.7, 0.24) if kind == "storm" else Color(0.5, 0.19, 0.67, 0.23))
# Concentric interior shells avoid the visible triangle intersections caused
# by overlapping transparent cloud spheres. Their distinct depths/rotations
# still give stereo parallax while the nebula surrounds the player fully.
for i in range(3):
var cloud := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 16.0 + float(i) * 9.0
sphere.height = sphere.radius * 2.0
sphere.radial_segments = 64
sphere.rings = 32
cloud.mesh = sphere
cloud.position.y = 1.6
cloud.rotation = Vector3(rng.randf(), rng.randf(), rng.randf()) * TAU
cloud.material_override = cloud_material
cloud.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
hazard_root.add_child(cloud)
clouds.append(cloud)
if kind == "storm":
# Faint distant electrical filaments are atmosphere only; native FTL still
# decides every ion hit and hazard effect.
for i in range(5):
var arc := MeshInstance3D.new()
var mesh := ImmediateMesh.new()
mesh.surface_begin(Mesh.PRIMITIVE_LINES)
var angle := float(i) / 5.0 * TAU
var point := Vector3(cos(angle) * 10.0, rng.randf_range(0.5, 6.0), sin(angle) * 10.0)
for part in range(5):
var next := point + Vector3(rng.randf_range(-0.8, 0.8), rng.randf_range(-0.7, 0.7), rng.randf_range(-0.7, 0.7))
mesh.surface_add_vertex(point)
mesh.surface_add_vertex(next)
point = next
mesh.surface_end()
arc.mesh = mesh
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
material.albedo_color = Color(0.28, 0.58, 0.82, 0.25)
arc.material_override = material
arc.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
hazard_root.add_child(arc)
storm_arcs.append(arc)
func _process(delta: float) -> void:
if not built:
return
# Native jump rendering advances while combat simulation is paused. This
# presentation follows that travel even when paused/frozen is reported.
if jump_active:
_set_jump_stretch(move_toward(jump_stretch, 1.0, maxf(0.0, delta) * JUMP_STRETCH_RATE))
if simulation_paused or jump_active:
return
elapsed += delta
for rock in rocks:
var node: Node3D = rock["node"]
node.position += Vector3(rock["velocity"]) * delta
node.rotation += Vector3(rock["spin"]) * delta
if node.position.distance_to(Vector3.ZERO) > 11.0:
node.position = rock["start"]
if cloud_material != null:
cloud_material.set_shader_parameter("clock", elapsed)
if body_material != null:
body_material.set_shader_parameter("clock", elapsed)
if is_instance_valid(body):
body.rotation.y += delta * 0.005
for i in range(storm_arcs.size()):
storm_arcs[i].visible = sin(elapsed * 0.43 + i * 1.73) > 0.88