mirror of
https://github.com/ElHombreAlex/FTL-Faster-Than-Light-VR.git
synced 2026-10-06 01:00:06 +02:00
408 lines
15 KiB
GDScript
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
|