mirror of
https://github.com/ElHombreAlex/FTL-Faster-Than-Light-VR.git
synced 2026-10-06 06:00:10 +02:00
Add native room locks, jump stars and refined drones
This commit is contained in:
1 parent
46102cabf7
commit
e857f4eee9
23 files changed
+1349
-108
No files matched your search
+95
-53
@@ -6,6 +6,8 @@ const MISS_CUE_DURATION := 1.0
|
||||
const MAX_MISS_CUES := 24
|
||||
static var impact_meshes: Dictionary = {}
|
||||
static var glow_materials: Dictionary = {}
|
||||
static var projectile_meshes: Dictionary = {}
|
||||
static var solid_materials: Dictionary = {}
|
||||
|
||||
var shots: Array[Dictionary] = []
|
||||
var impacts: Array[Dictionary] = []
|
||||
@@ -22,8 +24,13 @@ func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
|
||||
if event.has("target_space") and int(event["target_space"]) == int(sender.get("enemy")):
|
||||
receiver = sender
|
||||
var start: Vector3 = sender.weapon_origin(int(event.get("weapon_slot", 0)))
|
||||
if event.has("origin_point"):
|
||||
start = origin_ship.to_global(origin_ship.pixel_point(event["origin_point"]))
|
||||
var drone_id := str(event.get("drone_id", ""))
|
||||
var drone: Node3D = _drone(origin_ship, drone_id)
|
||||
if drone != null:
|
||||
start = drone.muzzle_position(str(event.get("projectile_id", "")))
|
||||
drone.present_fire()
|
||||
elif event.has("origin_point"):
|
||||
start = origin_ship.to_global(origin_ship.pixel_point(event["origin_point"], 0.3 if not drone_id.is_empty() else 0.18))
|
||||
var end: Vector3 = receiver.room_target(int(event.get("target_room", 0)))
|
||||
if event.has("target") and not event["target"].is_empty():
|
||||
end = receiver.to_global(receiver.pixel_point(event["target"]))
|
||||
@@ -37,7 +44,10 @@ func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
|
||||
color = Color(0.9, 0.9, 0.85)
|
||||
elif kind == "asteroid":
|
||||
color = Color(0.4, 0.42, 0.45)
|
||||
var node := _projectile_visual(kind, color)
|
||||
var drone_family := str(drone.family) if drone != null else ""
|
||||
if kind == "laser" and drone_family in ["defense", "anti_drone"]:
|
||||
color = Color("98e5aa") if drone_family == "defense" else Color("a9dfe9")
|
||||
var node := _projectile_visual(kind, color, not drone_id.is_empty())
|
||||
add_child(node)
|
||||
node.global_position = start
|
||||
var duration := maxf(0.01, float(event.get("duration", 0.7)))
|
||||
@@ -53,10 +63,11 @@ func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
|
||||
shots.append({"node": node, "start": start, "end": end, "beam_end": beam_end,
|
||||
"projectile_id": str(event.get("projectile_id", "")), "sender": sender,
|
||||
"created_ms": Time.get_ticks_msec(), "native_present": false,
|
||||
"visual_scale": maxf(0.1, sender.global_basis.get_scale().abs().x),
|
||||
"visual_scale": maxf(0.1, (origin_ship if not drone_id.is_empty() else sender).global_basis.get_scale().abs().x),
|
||||
"live_progress": 0.0, "wanted_progress": 0.0, "missed": false, "miss_feedback": false,
|
||||
"end_point": event.get("end_point", {}),
|
||||
"origin_point": event.get("origin_point", {}), "origin_ship": origin_ship,
|
||||
"drone_id": drone_id, "launch_point": origin_ship.to_local(start),
|
||||
"target_point": event.get("target", {}), "beam_point": {},
|
||||
"slot": int(event.get("weapon_slot", 0)), "room": int(event.get("target_room", 0)),
|
||||
"time": 0.0, "duration": duration, "kind": kind, "outcome": outcome, "receiver": receiver})
|
||||
@@ -81,10 +92,9 @@ func _process(delta: float) -> void:
|
||||
if age >= 1.0:
|
||||
label.queue_free()
|
||||
misses.remove_at(i)
|
||||
if simulation_paused:
|
||||
return
|
||||
for i in range(impacts.size() - 1, -1, -1):
|
||||
impacts[i]["time"] += delta
|
||||
if not simulation_paused:
|
||||
impacts[i]["time"] += delta
|
||||
var flash: MeshInstance3D = impacts[i]["node"]
|
||||
var age := float(impacts[i]["time"]) / 0.22
|
||||
flash.scale = Vector3.ONE * maxf(0.01, 1.0 - age)
|
||||
@@ -96,11 +106,18 @@ func _process(delta: float) -> void:
|
||||
impacts.remove_at(i)
|
||||
for i in range(shots.size() - 1, -1, -1):
|
||||
var shot: Dictionary = shots[i]
|
||||
shot["time"] += delta
|
||||
if not simulation_paused:
|
||||
shot["time"] += delta
|
||||
# Recompute endpoints after the user moves/rotates/scales the encounter.
|
||||
shot["visual_scale"] = maxf(0.1, shot["sender"].global_basis.get_scale().abs().x)
|
||||
shot["start"] = shot["sender"].weapon_origin(shot["slot"])
|
||||
if not shot["origin_point"].is_empty():
|
||||
shot["visual_scale"] = maxf(0.1, (shot["origin_ship"] if not shot["drone_id"].is_empty() else shot["sender"]).global_basis.get_scale().abs().x)
|
||||
if not shot["drone_id"].is_empty():
|
||||
# Moving an orbiting drone cannot drag the launch point of a bullet.
|
||||
# A continuing beam is the one native effect attached to its emitter.
|
||||
var drone: Node3D = _drone(shot["origin_ship"], shot["drone_id"])
|
||||
shot["start"] = drone.muzzle_position(shot["projectile_id"]) if shot["kind"] == "beam" and drone != null else shot["origin_ship"].to_global(shot["launch_point"])
|
||||
else:
|
||||
shot["start"] = shot["sender"].weapon_origin(shot["slot"])
|
||||
if shot["drone_id"].is_empty() and not shot["origin_point"].is_empty():
|
||||
shot["start"] = shot["origin_ship"].to_global(shot["origin_ship"].pixel_point(shot["origin_point"]))
|
||||
if shot["outcome"] == "pending":
|
||||
shot["end"] = shot["receiver"].room_target(shot["room"])
|
||||
@@ -112,10 +129,9 @@ func _process(delta: float) -> void:
|
||||
shot["beam_end"] = shot["receiver"].to_global(shot["receiver"].pixel_point(shot["end_point"]))
|
||||
var progress := clampf(float(shot["time"]) / float(shot["duration"]), 0.0, 1.0)
|
||||
if shot["outcome"] == "pending":
|
||||
shot["live_progress"] = lerpf(shot["live_progress"], shot["wanted_progress"], minf(1.0, delta * 20.0))
|
||||
if not simulation_paused:
|
||||
shot["live_progress"] = lerpf(shot["live_progress"], shot["wanted_progress"], minf(1.0, delta * 20.0))
|
||||
progress = shot["live_progress"]
|
||||
if shot["time"] > 20.0:
|
||||
progress = 1.0
|
||||
var node: MeshInstance3D = shot["node"]
|
||||
if shot["kind"] == "beam":
|
||||
var beam_target: Vector3 = Vector3(shot["end"]).lerp(shot["beam_end"], progress)
|
||||
@@ -130,15 +146,15 @@ func _process(delta: float) -> void:
|
||||
node.position = Vector3.ZERO
|
||||
# Keep the precise native sweep line, then surround it with real 3D
|
||||
# cylinders so its width remains visible in stereo at oblique angles.
|
||||
_segment(node.get_node("BeamCore"), shot["start"], beam_target, 0.012 * shot["visual_scale"])
|
||||
_segment(node.get_node("BeamGlow"), shot["start"], beam_target, 0.032 * shot["visual_scale"])
|
||||
_segment(node.get_node("BeamCore"), shot["start"], beam_target, (0.007 if not shot["drone_id"].is_empty() else 0.012) * shot["visual_scale"])
|
||||
_segment(node.get_node("BeamGlow"), shot["start"], beam_target, (0.018 if not shot["drone_id"].is_empty() else 0.032) * shot["visual_scale"])
|
||||
elif shot["kind"] != "bomb":
|
||||
node.global_position = Vector3(shot["start"]).lerp(shot["end"], progress)
|
||||
var direction: Vector3 = Vector3(shot["end"]) - Vector3(shot["start"])
|
||||
if direction.length_squared() > 0.000001:
|
||||
var up := Vector3.RIGHT if absf(direction.normalized().dot(Vector3.UP)) > 0.98 else Vector3.UP
|
||||
node.global_basis = Basis.looking_at(direction.normalized(), up).scaled(Vector3.ONE * shot["visual_scale"])
|
||||
if progress >= 1.0:
|
||||
if progress >= 1.0 and shot["outcome"] != "pending":
|
||||
if shot["outcome"] == "shield":
|
||||
shot["receiver"].flash_shield()
|
||||
if shot["outcome"] not in ["miss", "pending"]:
|
||||
@@ -294,7 +310,16 @@ func _miss_feedback(point: Vector3, receiver: Node3D, projectile_id: String) ->
|
||||
"time": 0.0, "projectile_id": projectile_id})
|
||||
|
||||
|
||||
func _projectile_visual(kind: String, color: Color) -> MeshInstance3D:
|
||||
func _drone(ship: Node3D, drone_id: String) -> Node3D:
|
||||
if drone_id.is_empty() or (drone_id.is_valid_int() and drone_id.to_int() < 0):
|
||||
return null
|
||||
var nodes: Variant = ship.get("drone_nodes")
|
||||
if nodes is Dictionary and nodes.has(drone_id) and is_instance_valid(nodes[drone_id]):
|
||||
return nodes[drone_id]
|
||||
return null
|
||||
|
||||
|
||||
func _projectile_visual(kind: String, color: Color, drone_shot: bool = false) -> MeshInstance3D:
|
||||
var node := MeshInstance3D.new()
|
||||
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
node.material_override = _glow(color)
|
||||
@@ -315,17 +340,17 @@ func _projectile_visual(kind: String, color: Color) -> MeshInstance3D:
|
||||
cylinder.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
node.add_child(cylinder)
|
||||
"missile":
|
||||
var body := BoxMesh.new()
|
||||
body.size = Vector3(0.075, 0.065, 0.19)
|
||||
node.mesh = body
|
||||
node.mesh = _box_mesh(Vector3(0.075, 0.065, 0.19))
|
||||
node.material_override = _solid(Color(0.72, 0.76, 0.79))
|
||||
var nose := MeshInstance3D.new()
|
||||
var pyramid := CylinderMesh.new()
|
||||
pyramid.top_radius = 0.0
|
||||
pyramid.bottom_radius = 0.052
|
||||
pyramid.height = 0.072
|
||||
pyramid.radial_segments = 4
|
||||
nose.mesh = pyramid
|
||||
if not projectile_meshes.has("missile_nose"):
|
||||
var pyramid := CylinderMesh.new()
|
||||
pyramid.top_radius = 0.0
|
||||
pyramid.bottom_radius = 0.052
|
||||
pyramid.height = 0.072
|
||||
pyramid.radial_segments = 4
|
||||
projectile_meshes["missile_nose"] = pyramid
|
||||
nose.mesh = projectile_meshes["missile_nose"]
|
||||
nose.position.z = -0.128
|
||||
nose.rotation.x = -PI / 2.0
|
||||
nose.material_override = _solid(Color(0.73, 0.17, 0.08))
|
||||
@@ -338,53 +363,66 @@ func _projectile_visual(kind: String, color: Color) -> MeshInstance3D:
|
||||
exhaust.position.z = 0.145
|
||||
node.add_child(exhaust)
|
||||
"ion":
|
||||
var sphere := SphereMesh.new()
|
||||
sphere.radius = 0.052
|
||||
sphere.height = 0.104
|
||||
sphere.radial_segments = 10
|
||||
sphere.rings = 5
|
||||
node.mesh = sphere
|
||||
var ion_radius := 0.032 if drone_shot else 0.052
|
||||
var ion_key := "drone_ion" if drone_shot else "ion"
|
||||
if not projectile_meshes.has(ion_key):
|
||||
var sphere := SphereMesh.new()
|
||||
sphere.radius = ion_radius
|
||||
sphere.height = ion_radius * 2.0
|
||||
sphere.radial_segments = 8
|
||||
sphere.rings = 4
|
||||
projectile_meshes[ion_key] = sphere
|
||||
node.mesh = projectile_meshes[ion_key]
|
||||
for axis in [0, 1]:
|
||||
var ring := MeshInstance3D.new()
|
||||
var torus := TorusMesh.new()
|
||||
torus.inner_radius = 0.066
|
||||
torus.outer_radius = 0.078
|
||||
torus.rings = 12
|
||||
torus.ring_segments = 4
|
||||
ring.mesh = torus
|
||||
if not projectile_meshes.has(ion_key + "_ring"):
|
||||
var torus := TorusMesh.new()
|
||||
torus.inner_radius = ion_radius * 1.27
|
||||
torus.outer_radius = ion_radius * 1.5
|
||||
torus.rings = 12
|
||||
torus.ring_segments = 4
|
||||
projectile_meshes[ion_key + "_ring"] = torus
|
||||
ring.mesh = projectile_meshes[ion_key + "_ring"]
|
||||
ring.rotation.x = PI / 2.0 if axis == 0 else 0.0
|
||||
ring.material_override = _glow(Color(0.15, 0.6, 1.0, 0.6), true)
|
||||
ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
node.add_child(ring)
|
||||
"asteroid":
|
||||
var sphere := SphereMesh.new()
|
||||
sphere.radius = 0.2
|
||||
sphere.height = 0.34
|
||||
sphere.radial_segments = 7
|
||||
sphere.rings = 4
|
||||
node.mesh = sphere
|
||||
if not projectile_meshes.has("asteroid"):
|
||||
var sphere := SphereMesh.new()
|
||||
sphere.radius = 0.2
|
||||
sphere.height = 0.34
|
||||
sphere.radial_segments = 7
|
||||
sphere.rings = 4
|
||||
projectile_meshes["asteroid"] = sphere
|
||||
node.mesh = projectile_meshes["asteroid"]
|
||||
node.material_override = _solid(color)
|
||||
_:
|
||||
var core := BoxMesh.new()
|
||||
core.size = Vector3(0.028, 0.028, 0.26)
|
||||
node.mesh = core
|
||||
node.mesh = _box_mesh(Vector3(0.020, 0.020, 0.09) if drone_shot else Vector3(0.028, 0.028, 0.26))
|
||||
node.material_override = _glow(Color(1.0, 0.93, 0.72))
|
||||
var trail := _box(Vector3(0.066, 0.066, 0.39), Color(color, 0.27))
|
||||
trail.position.z = 0.07
|
||||
var trail := _box(Vector3(0.042, 0.042, 0.16) if drone_shot else Vector3(0.066, 0.066, 0.39), Color(color, 0.27))
|
||||
trail.position.z = 0.038 if drone_shot else 0.07
|
||||
node.add_child(trail)
|
||||
return node
|
||||
|
||||
|
||||
func _box(size: Vector3, color: Color, luminous: bool = true) -> MeshInstance3D:
|
||||
var node := MeshInstance3D.new()
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = size
|
||||
node.mesh = mesh
|
||||
node.mesh = _box_mesh(size)
|
||||
node.material_override = _glow(color, color.a < 1.0) if luminous else _solid(color)
|
||||
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
return node
|
||||
|
||||
|
||||
func _box_mesh(size: Vector3) -> BoxMesh:
|
||||
var key := "box:" + str(size)
|
||||
if not projectile_meshes.has(key):
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = size
|
||||
projectile_meshes[key] = mesh
|
||||
return projectile_meshes[key]
|
||||
|
||||
|
||||
func _glow(color: Color, additive: bool = false) -> StandardMaterial3D:
|
||||
var key := color.to_html() + ":" + str(additive)
|
||||
if glow_materials.has(key):
|
||||
@@ -402,9 +440,13 @@ func _glow(color: Color, additive: bool = false) -> StandardMaterial3D:
|
||||
|
||||
|
||||
func _solid(color: Color) -> StandardMaterial3D:
|
||||
var key := color.to_html()
|
||||
if solid_materials.has(key):
|
||||
return solid_materials[key]
|
||||
var material := StandardMaterial3D.new()
|
||||
material.albedo_color = color
|
||||
material.roughness = 0.82
|
||||
solid_materials[key] = material
|
||||
return material
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const ControllerPanel = preload("res://scripts/controller_panel.gd")
|
||||
const PoseFilter = preload("res://scripts/pose_filter.gd")
|
||||
const EnemyHullBar = preload("res://scripts/enemy_hull_bar.gd")
|
||||
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
|
||||
const TargetLocks = preload("res://scripts/target_locks.gd")
|
||||
|
||||
var xr_active := false
|
||||
var origin: XROrigin3D
|
||||
@@ -27,6 +28,7 @@ var gameplay_hud := false
|
||||
const GAMEPLAY_HUD_RECT := Rect2(0, 0, 872, 510)
|
||||
const GAMEPLAY_HUD_SIZE := Vector2(1.10, 1.10 * 510.0 / 872.0)
|
||||
var combat_effects: Node3D
|
||||
var target_locks: Node3D
|
||||
var hud_state: Dictionary = {}
|
||||
var seen_shots: Dictionary = {}
|
||||
var right_laser: MeshInstance3D
|
||||
@@ -140,6 +142,8 @@ func _ready() -> void:
|
||||
_create_rename_keyboard()
|
||||
wheel = ShortcutWheel.new()
|
||||
add_child(wheel)
|
||||
target_locks = TargetLocks.new()
|
||||
add_child(target_locks)
|
||||
_set_hazard("clear")
|
||||
var interface := XRServer.find_interface("OpenXR")
|
||||
if "--desktop" not in OS.get_cmdline_user_args() and interface != null and (interface.is_initialized() or interface.initialize()):
|
||||
@@ -627,6 +631,8 @@ func _update_power_controls(remove: bool, previous: bool, next: bool) -> void:
|
||||
func _update_wheel() -> void:
|
||||
var down := right_hand.get_is_active() and right_hand.is_button_pressed("wheel_button")
|
||||
if down and not wheel_was_down:
|
||||
# Categories apply to this hold only; every new opening starts on equipment.
|
||||
wheel_category = 0
|
||||
wheel_committed = false
|
||||
wheel_dialog_id = str(hud_state.get("dialog", {}).get("id", ""))
|
||||
wheel.visible = not rename_keyboard.visible and (hud_state.get("event_open", false) or _game_actions_available())
|
||||
@@ -646,6 +652,7 @@ func _update_wheel() -> void:
|
||||
if wheel.visible:
|
||||
_commit_wheel()
|
||||
wheel.visible = false
|
||||
wheel_category = 0
|
||||
wheel_was_down = down
|
||||
|
||||
|
||||
@@ -690,6 +697,11 @@ func _commit_wheel() -> void:
|
||||
wheel.visible = false
|
||||
|
||||
|
||||
func _sync_jump_heading() -> void:
|
||||
var local_bow := Vector3.FORWARD if int(player_ship.layout_data.get("vertical", 0)) != 0 else Vector3.RIGHT
|
||||
space_environment.set_travel_direction(player_ship.global_basis * local_bow)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if world_surface.visible:
|
||||
world_panel_open_age += delta
|
||||
@@ -703,6 +715,7 @@ func _process(delta: float) -> void:
|
||||
player_ship.simulation_paused = paused
|
||||
enemy_ship.simulation_paused = paused
|
||||
space_environment.simulation_paused = paused
|
||||
_sync_jump_heading()
|
||||
var viewer := camera.get_camera_transform().origin
|
||||
player_ship.set_viewer_position(viewer)
|
||||
enemy_ship.set_viewer_position(viewer)
|
||||
@@ -1185,6 +1198,7 @@ func _poll_bridge_state() -> void:
|
||||
if new_battle != battle:
|
||||
battle = new_battle
|
||||
_update_ship_positions()
|
||||
target_locks.apply_snapshot(state, player_ship, enemy_ship)
|
||||
var new_hazard := str(state.get("hazard", hazard))
|
||||
if new_hazard in ["clear", "asteroid", "sun", "storm", "nebula", "pulsar"] and new_hazard != hazard:
|
||||
_set_hazard(new_hazard)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
extends Node3D
|
||||
|
||||
# Stationary surroundings: three draw calls cover the entire sky, with a sparse
|
||||
# nearer shell for stereo parallax. No captured 2D background is used here.
|
||||
# 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"
|
||||
@@ -19,6 +21,25 @@ 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;
|
||||
@@ -92,6 +113,11 @@ func build() -> void:
|
||||
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)
|
||||
@@ -107,11 +133,51 @@ 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()
|
||||
@@ -131,7 +197,9 @@ func _star_shell(node_name: String, count: int, near_radius: float, far_radius:
|
||||
stars.use_colors = true
|
||||
stars.mesh = sphere
|
||||
stars.instance_count = count
|
||||
stars.custom_aabb = AABB(Vector3.ONE * -far_radius, Vector3.ONE * far_radius * 2.0)
|
||||
# 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)
|
||||
@@ -156,11 +224,7 @@ func _star_shell(node_name: String, count: int, near_radius: float, far_radius:
|
||||
node.name = node_name
|
||||
node.multimesh = stars
|
||||
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
var material := StandardMaterial3D.new()
|
||||
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
material.vertex_color_use_as_albedo = true
|
||||
material.albedo_color = Color.WHITE
|
||||
node.material_override = material
|
||||
node.material_override = star_material
|
||||
add_child(node)
|
||||
|
||||
|
||||
@@ -318,7 +382,13 @@ func _build_nebula(kind: String) -> void:
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if simulation_paused or not built:
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
extends Node3D
|
||||
|
||||
# Mirror WeaponControl::RenderAimingWeapon: player-owned weapon.targets are the
|
||||
# authority, including while the armed cursor is closed. Never use lastTargets
|
||||
# or enemy weapon targeting, and never infer locks from projectile history.
|
||||
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
|
||||
const NATIVE_PIXEL := 0.18 / 35.0
|
||||
const HEIGHT := 0.195
|
||||
const NORMAL := Color("ff4b19")
|
||||
const AUTOFIRE := Color("ffdf00")
|
||||
const INK := Color("080b10")
|
||||
var locks: Dictionary = {}
|
||||
|
||||
|
||||
func apply_snapshot(state: Dictionary, player_ship: Node3D, enemy_ship: Node3D) -> void:
|
||||
if not bool(state.get("ready", false)) or bool(state.get("transition", false)):
|
||||
clear()
|
||||
return
|
||||
var player: Variant = state.get("player", {})
|
||||
if not player is Dictionary or bool(player.get("destroyed", false)):
|
||||
clear()
|
||||
return
|
||||
var weapons: Variant = player.get("weapons", [])
|
||||
if not weapons is Array:
|
||||
clear()
|
||||
return
|
||||
var keep: Dictionary = {}
|
||||
for i in range(weapons.size()):
|
||||
var weapon: Variant = weapons[i]
|
||||
if not weapon is Dictionary:
|
||||
continue
|
||||
var slot := int(weapon.get("slot", i))
|
||||
var side := int(weapon.get("target_ship", -1))
|
||||
# Native self-aiming remains valid without an enemy (for healing bombs).
|
||||
# Only targets on the enemy require an active encounter.
|
||||
if side == 1 and not bool(state.get("combat", false)):
|
||||
continue
|
||||
var receiver := player_ship if side == 0 else enemy_ship if side == 1 else null
|
||||
var receiver_state: Variant = state.get("player" if side == 0 else "enemy", {})
|
||||
if slot < 0 or keep.has(slot) or not is_instance_valid(receiver) or not receiver.has_method("pixel_point"):
|
||||
continue
|
||||
if not receiver_state is Dictionary or receiver_state.is_empty() or bool(receiver_state.get("destroyed", false)):
|
||||
continue
|
||||
var targets: Variant = weapon.get("targets", [])
|
||||
if not targets is Array or targets.is_empty() or not _valid_point(targets[0]):
|
||||
continue
|
||||
var beam := str(weapon.get("kind", "")) == "beam"
|
||||
var pinpoint := beam and int(weapon.get("beam_length", 2)) == 1
|
||||
if beam and not pinpoint and (targets.size() < 2 or not _valid_point(targets[1])):
|
||||
continue
|
||||
var points: Array[Dictionary] = [targets[0]]
|
||||
if beam and not pinpoint:
|
||||
points.append(targets[1])
|
||||
var autofire := bool(weapon.get("autofire", false))
|
||||
var radius := maxf(0.0, float(weapon.get("target_radius", 0))) if not beam else 0.0
|
||||
var layout: Dictionary = receiver.get("layout_data")
|
||||
var signature := JSON.stringify([points, beam, pinpoint, autofire, radius, receiver.get_instance_id(), receiver.get("layout_center"), layout.get("x_offset", 0), layout.get("y_offset", 0)])
|
||||
if locks.has(slot) and locks[slot].signature != signature:
|
||||
_remove(slot)
|
||||
if not locks.has(slot):
|
||||
var node := Node3D.new()
|
||||
node.name = "WeaponLock%d" % (slot + 1)
|
||||
node.set_meta("weapon_slot", slot)
|
||||
node.set_meta("target_ship", side)
|
||||
node.set_meta("autofire", autofire)
|
||||
add_child(node)
|
||||
var color := AUTOFIRE if autofire else NORMAL
|
||||
var anchor: Vector3 = receiver.pixel_point(points[0], HEIGHT)
|
||||
if pinpoint:
|
||||
# Hyperspace uses the native tile center for length-one beams.
|
||||
anchor = receiver.pixel_point({"x": floorf(float(points[0].x) / 35.0) * 35.0 + 17.5,
|
||||
"y": floorf(float(points[0].y) / 35.0) * 35.0 + 17.5}, HEIGHT)
|
||||
var start := anchor
|
||||
if beam and not pinpoint:
|
||||
# Native targets[1] is the sweep start, targets[0] is its end.
|
||||
start = receiver.pixel_point(points[1], HEIGHT)
|
||||
_make_beam(node, start, anchor, color, slot)
|
||||
else:
|
||||
_make_reticle(node, anchor, slot, autofire)
|
||||
if radius > 0.0:
|
||||
_make_radius(node, anchor, radius * NATIVE_PIXEL, color)
|
||||
_finish_lines(node)
|
||||
locks[slot] = {"node": node, "receiver": receiver, "signature": signature,
|
||||
"start": start, "end": anchor, "beam": beam and not pinpoint, "radius": radius * NATIVE_PIXEL}
|
||||
keep[slot] = true
|
||||
for slot in locks.keys():
|
||||
if not keep.has(slot):
|
||||
_remove(slot)
|
||||
refresh_transforms()
|
||||
|
||||
|
||||
func _valid_point(point: Variant) -> bool:
|
||||
if not point is Dictionary or not point.has("x") or not point.has("y"):
|
||||
return false
|
||||
return (point.x is int or point.x is float) and (point.y is int or point.y is float) and is_finite(float(point.x)) and is_finite(float(point.y))
|
||||
|
||||
|
||||
func refresh_transforms() -> void:
|
||||
for slot in locks.keys():
|
||||
var lock: Dictionary = locks[slot]
|
||||
if not is_instance_valid(lock.receiver) or not is_instance_valid(lock.node):
|
||||
_remove(slot)
|
||||
continue
|
||||
var receiver: Node3D = lock.receiver
|
||||
var node: Node3D = lock.node
|
||||
node.global_transform = receiver.global_transform
|
||||
node.visible = receiver.is_visible_in_tree()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
refresh_transforms()
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
for slot in locks.keys():
|
||||
_remove(slot)
|
||||
|
||||
|
||||
func _remove(slot: int) -> void:
|
||||
if not locks.has(slot):
|
||||
return
|
||||
var node: Node3D = locks[slot].node
|
||||
if is_instance_valid(node):
|
||||
node.hide()
|
||||
remove_child(node)
|
||||
node.queue_free()
|
||||
locks.erase(slot)
|
||||
|
||||
|
||||
func _make_reticle(parent: Node3D, point: Vector3, slot: int, autofire: bool) -> void:
|
||||
var suffix := "_yellow" if autofire else ""
|
||||
var path := "img/misc/crosshairs_placed%d%s.png" % [slot + 1, suffix]
|
||||
var texture: Texture2D = UiAssets.texture(path)
|
||||
if texture != null:
|
||||
var quad := QuadMesh.new()
|
||||
quad.size = texture.get_size() * NATIVE_PIXEL
|
||||
var mesh := MeshInstance3D.new()
|
||||
mesh.name = "NativeReticle"
|
||||
mesh.mesh = quad
|
||||
mesh.position = point
|
||||
mesh.rotation_degrees.x = -90.0
|
||||
var material := _material(Color.WHITE)
|
||||
material.albedo_texture = texture
|
||||
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
|
||||
material.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
mesh.material_override = material
|
||||
parent.add_child(mesh)
|
||||
return
|
||||
# Owner-local assets are optional in source-only installs. Preserve the native
|
||||
# circle, four crosshair ticks, slot number and autofire colour procedurally.
|
||||
var color := AUTOFIRE if autofire else NORMAL
|
||||
var ring_radius := 16.0 * NATIVE_PIXEL
|
||||
for i in range(32):
|
||||
var a := float(i) * TAU / 32.0
|
||||
var b := float(i + 1) * TAU / 32.0
|
||||
_line(parent, point + Vector3(cos(a), 0, sin(a)) * ring_radius,
|
||||
point + Vector3(cos(b), 0, sin(b)) * ring_radius, color, 1.7 * NATIVE_PIXEL)
|
||||
for direction in [Vector3.RIGHT, Vector3.LEFT, Vector3.FORWARD, Vector3.BACK]:
|
||||
_line(parent, point + direction * 10.0 * NATIVE_PIXEL, point + direction * 22.0 * NATIVE_PIXEL, color, 1.7 * NATIVE_PIXEL)
|
||||
_slot_label(parent, slot, point + Vector3(0, 0.007, 23.0 * NATIVE_PIXEL), color)
|
||||
|
||||
|
||||
func _make_beam(parent: Node3D, start: Vector3, end: Vector3, color: Color, slot: int) -> void:
|
||||
_line(parent, start, end, color, 1.8 * NATIVE_PIXEL)
|
||||
var direction := (end - start).normalized()
|
||||
if start.distance_to(end) > 0.002:
|
||||
var across := Vector3(-direction.z, 0, direction.x)
|
||||
var tip := start.lerp(end, 0.72)
|
||||
var length := minf(8.0 * NATIVE_PIXEL, start.distance_to(end) * 0.20)
|
||||
_line(parent, tip, tip - direction * length + across * length * 0.55, color, 1.8 * NATIVE_PIXEL)
|
||||
_line(parent, tip, tip - direction * length - across * length * 0.55, color, 1.8 * NATIVE_PIXEL)
|
||||
for point in [start, end]:
|
||||
_line(parent, point - Vector3.RIGHT * 5.0 * NATIVE_PIXEL, point + Vector3.RIGHT * 5.0 * NATIVE_PIXEL, color, 1.8 * NATIVE_PIXEL)
|
||||
_line(parent, point - Vector3.BACK * 5.0 * NATIVE_PIXEL, point + Vector3.BACK * 5.0 * NATIVE_PIXEL, color, 1.8 * NATIVE_PIXEL)
|
||||
_slot_label(parent, slot, start + Vector3(0, 0.009, 11.0 * NATIVE_PIXEL), color)
|
||||
|
||||
|
||||
func _make_radius(parent: Node3D, point: Vector3, radius: float, color: Color) -> void:
|
||||
var disk := CylinderMesh.new()
|
||||
disk.top_radius = radius
|
||||
disk.bottom_radius = radius
|
||||
disk.height = 0.001
|
||||
disk.radial_segments = 48
|
||||
var mesh := MeshInstance3D.new()
|
||||
mesh.name = "FlakRadius"
|
||||
mesh.mesh = disk
|
||||
mesh.position = point - Vector3.UP * 0.007
|
||||
var material := _material(Color(color, 0.17))
|
||||
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
material.render_priority = 7
|
||||
mesh.material_override = material
|
||||
parent.add_child(mesh)
|
||||
|
||||
|
||||
func _slot_label(parent: Node3D, slot: int, point: Vector3, color: Color) -> void:
|
||||
var label := Label3D.new()
|
||||
label.name = "WeaponSlot"
|
||||
label.text = str(slot + 1)
|
||||
label.font = UiAssets.font("body")
|
||||
label.font_size = 14
|
||||
label.pixel_size = NATIVE_PIXEL * 0.75
|
||||
label.modulate = color
|
||||
label.outline_modulate = INK
|
||||
label.outline_size = 3
|
||||
label.no_depth_test = true
|
||||
label.render_priority = 10
|
||||
label.position = point
|
||||
label.rotation_degrees.x = -90.0
|
||||
parent.add_child(label)
|
||||
|
||||
|
||||
func _line(parent: Node3D, start: Vector3, end: Vector3, color: Color, width: float) -> void:
|
||||
if start.distance_to(end) < 0.00001:
|
||||
return
|
||||
var lines: Array = parent.get_meta("lines", [])
|
||||
lines.append([start, end, color, width])
|
||||
parent.set_meta("lines", lines)
|
||||
|
||||
|
||||
func _finish_lines(parent: Node3D) -> void:
|
||||
var lines: Array = parent.get_meta("lines", [])
|
||||
if lines.is_empty():
|
||||
return
|
||||
# One surface for all endpoint, arrow and fallback-reticle strokes.
|
||||
var geometry := ImmediateMesh.new()
|
||||
geometry.surface_begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
for outline in [true, false]:
|
||||
for line: Array in lines:
|
||||
var start: Vector3 = line[0]
|
||||
var end: Vector3 = line[1]
|
||||
var direction := (end - start).normalized()
|
||||
var width: float = line[3]
|
||||
var across := Vector3(-direction.z, 0, direction.x) * width * (1.0 if outline else 0.5)
|
||||
var offset := Vector3.UP * (0.0 if outline else 0.001)
|
||||
if outline:
|
||||
start -= direction * width * 0.5
|
||||
end += direction * width * 0.5
|
||||
geometry.surface_set_color(INK if outline else line[2])
|
||||
for vertex in [start - across, end - across, end + across, start - across, end + across, start + across]:
|
||||
geometry.surface_add_vertex(vertex + offset)
|
||||
geometry.surface_end()
|
||||
var mesh := MeshInstance3D.new()
|
||||
mesh.name = "LockLines"
|
||||
mesh.mesh = geometry
|
||||
var material := _material(Color.WHITE)
|
||||
material.vertex_color_use_as_albedo = true
|
||||
material.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
mesh.material_override = material
|
||||
parent.add_child(mesh)
|
||||
parent.remove_meta("lines")
|
||||
|
||||
|
||||
func _material(color: Color) -> StandardMaterial3D:
|
||||
var material := StandardMaterial3D.new()
|
||||
material.albedo_color = color
|
||||
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
material.no_depth_test = true
|
||||
material.render_priority = 8
|
||||
return material
|
||||
+135
-25
@@ -7,7 +7,11 @@ const RIM := Color("d3dcda")
|
||||
const DARK := Color("354b59")
|
||||
const BLACK := Color("182832")
|
||||
const BRASS := Color("c6ac75")
|
||||
const SLATE := Color("718b9a")
|
||||
const RED := Color("b75847")
|
||||
static var body_meshes: Dictionary = {}
|
||||
static var detail_meshes: Dictionary = {}
|
||||
static var glow_materials: Dictionary = {}
|
||||
static var body_material: StandardMaterial3D
|
||||
var drone_name := ""
|
||||
var kind := "combat"
|
||||
@@ -20,6 +24,9 @@ var tool: MeshInstance3D
|
||||
var live_data: Dictionary = {}
|
||||
var animation_time := 0.0
|
||||
var firing := false
|
||||
var mark_two := false
|
||||
var fire_flash := 0.0
|
||||
var emitter_color := Color.WHITE
|
||||
|
||||
|
||||
static func model_family(data: Dictionary) -> String:
|
||||
@@ -52,7 +59,9 @@ func build(data: Dictionary) -> void:
|
||||
is_space = bool(data.get("is_space", true))
|
||||
family = model_family(data)
|
||||
animation_time = 0.0
|
||||
var mark_two := drone_name.ends_with("_2") and family in ["combat", "defense"]
|
||||
firing = false
|
||||
fire_flash = 0.0
|
||||
mark_two = drone_name.ends_with("_2") and family in ["combat", "beam", "defense"]
|
||||
var cache_key := family + ("_2" if mark_two else "")
|
||||
if not body_meshes.has(cache_key):
|
||||
var source := Voxels.make(_body_blocks(mark_two))
|
||||
@@ -75,6 +84,8 @@ func build(data: Dictionary) -> void:
|
||||
exhaust = [_b(-6.6, -1.3, 0, 2.0, 1.0, 2.4, glow)]
|
||||
elif family == "hacking":
|
||||
exhaust = [_b(-5.4, 0, 0, 2.0, 1.2, 2.0, glow)]
|
||||
elif family == "anti_drone":
|
||||
exhaust = [_b(-8.4, -0.5, -4.8, 2.4, 1.2, 1.8, glow), _b(-8.4, -0.5, 4.8, 2.4, 1.2, 1.8, glow)]
|
||||
else:
|
||||
for side in [-1.0, 1.0]:
|
||||
exhaust.append(_b(-8.4, -0.5, side * 5.4, 2.4, 1.2, 1.8, glow))
|
||||
@@ -84,10 +95,13 @@ func build(data: Dictionary) -> void:
|
||||
for z in ([-1.8, 1.8] if mark_two else [0.0]): lights.append(_b(13.2, 0.3, z, 0.6, 1.3, 1.3, glow))
|
||||
"beam":
|
||||
glow = Color("ffdc76")
|
||||
lights = [_b(13.0, 0, 0, 0.6, 1.0, 3.7, glow)]
|
||||
"defense", "anti_drone":
|
||||
lights = [_b(13.0, 0, 0, 0.6, 1.0, 5.4 if mark_two else 3.7, glow)]
|
||||
"defense":
|
||||
glow = Color("98e5aa")
|
||||
for z in ([-1.5, 1.5] if mark_two else [0.0]): lights.append(_b(10.7, 2.4, z, 0.6, 1.3, 1.3, glow))
|
||||
"anti_drone":
|
||||
glow = Color("a9dfe9")
|
||||
lights = [_b(12.4, 1.1, -3.0, 0.7, 1.0, 1.0, glow), _b(12.4, 1.1, 3.0, 0.7, 1.0, 1.0, glow)]
|
||||
"hacking":
|
||||
glow = Color("b591e0")
|
||||
lights = [_b(0, 2.6, 0, 2.6, 0.4, 2.6, glow)]
|
||||
@@ -99,10 +113,11 @@ func build(data: Dictionary) -> void:
|
||||
else:
|
||||
glow = Color("ef736a") if family == "battle" else Color("7fdafa")
|
||||
lights = [_b(0, 9.0, -3.0, 2.8, 0.7, 0.3, glow)]
|
||||
thruster = _glow_mesh(exhaust, Color("69d8fa"))
|
||||
thruster = _glow_mesh(cache_key + ":exhaust", exhaust, Color("69d8fa"))
|
||||
thruster.name = "Exhaust"
|
||||
add_child(thruster)
|
||||
emitter = _glow_mesh(lights, glow)
|
||||
emitter_color = glow
|
||||
emitter = _glow_mesh(cache_key + ":emitter", lights, glow)
|
||||
emitter.name = "Emitter"
|
||||
add_child(emitter)
|
||||
tool = null
|
||||
@@ -114,13 +129,32 @@ func build(data: Dictionary) -> void:
|
||||
else:
|
||||
tool_blocks = [_b(0, -1.2, 0, 1.8, 3.0, 2.0, DARK), _b(0, -2.4, -1.0, 2.7, 1.0, 3.0, STEEL),
|
||||
_b(-1.0, -2.9, -2.0, 0.6, 1.1, 1.7, RIM), _b(1.0, -2.9, -2.0, 0.6, 1.1, 1.7, RIM)]
|
||||
tool = Voxels.make(tool_blocks)
|
||||
tool = _cached_part(cache_key + ":tool", tool_blocks)
|
||||
tool.name = "Tool"
|
||||
tool.position = Vector3(5.2, 6.3, 0) * UNIT
|
||||
add_child(tool)
|
||||
set_live(data)
|
||||
|
||||
|
||||
func muzzle_position(projectile_id: String = "") -> Vector3:
|
||||
# Models face +X like native drone angles. Resolve through the model's real
|
||||
# world transform, including the encounter tilt, scale and native orbit.
|
||||
var point := Vector3(13.5, 0.3, 0)
|
||||
match family:
|
||||
"beam": point = Vector3(13.3, 0, 0)
|
||||
"defense": point = Vector3(11.1, 2.4, 0)
|
||||
"anti_drone": point = Vector3(12.8, 1.1, -3.0)
|
||||
if mark_two and family in ["combat", "defense"]:
|
||||
point.z = (-1.0 if projectile_id.hash() % 2 == 0 else 1.0) * (1.8 if family == "combat" else 1.5)
|
||||
return to_global(point * UNIT)
|
||||
|
||||
|
||||
func present_fire() -> void:
|
||||
# This is called only for an actual native DRONE_FIRE record.
|
||||
fire_flash = 0.13
|
||||
emitter.material_override = _glow_material(emitter_color, 1.5)
|
||||
|
||||
|
||||
func _body_blocks(mark_two: bool) -> Array:
|
||||
var blocks: Array = []
|
||||
if not is_space:
|
||||
@@ -142,6 +176,12 @@ func _body_blocks(mark_two: bool) -> Array:
|
||||
blocks.append(_b(side * 2.7, 1.0, 0, 1.8, 2.0, 5.5, DARK))
|
||||
for z in [-1.8, 0.0, 1.8]: blocks.append(_b(side * 3.7, 1.0, z, 0.3, 1.3, 0.7, RIM))
|
||||
blocks.append(_b(-5.1, 3.7, -0.7, 1.4, 1.2, 2.8, STEEL))
|
||||
# Small inset plates and stripe pixels retain the FTL steel/brass palette.
|
||||
blocks.append(_b(0, 6.7, -2.65, 3.5, 0.55, 0.35, RIM))
|
||||
blocks.append(_b(0, 4.1, -2.65, 1.4, 0.6, 0.35, BRASS if not armored else RED))
|
||||
for side in [-1.0, 1.0]:
|
||||
blocks.append(_b(side * 1.7, 9.5, -2.95, 0.45, 0.4, 0.25, RIM))
|
||||
blocks.append(_b(side * 2.4, 6.0, 2.6, 0.65, 2.4, 0.45, SLATE))
|
||||
if family == "ion_boarder":
|
||||
for side in [-1.0, 1.0]:
|
||||
blocks.append(_b(side * 2.0, 11.2, 0, 0.8, 2.0, 2.3, Color("6aa8c5")))
|
||||
@@ -151,19 +191,27 @@ func _body_blocks(mark_two: bool) -> Array:
|
||||
"hacking":
|
||||
blocks = [_b(0, 0, 0, 8.5, 3.7, 8.5, DARK), _b(0, 1.7, 0, 5.8, 1.5, 5.8, STEEL),
|
||||
_b(0, 2.5, 0, 3.5, 0.6, 3.5, BLACK), _b(-4.5, 0, 0, 2.2, 2.8, 2.8, STEEL)]
|
||||
for side in [-1.0, 1.0]:
|
||||
blocks.append(_b(side * 2.8, 2.5, 0, 0.7, 0.7, 4.8, BRASS))
|
||||
blocks.append(_b(0, 2.5, side * 2.8, 4.8, 0.7, 0.7, RIM))
|
||||
for side in [-1.0, 1.0]:
|
||||
for end in [-1.0, 1.0]:
|
||||
blocks.append(_b(side * 4.8, -0.5, end * 4.2, 2.0, 1.3, 3.2, STEEL))
|
||||
blocks.append(_b(side * 5.2, -2.5, end * 5.4, 1.1, 3.2, 1.1, BRASS))
|
||||
blocks.append(_b(side * 4.7, -4.0, end * 5.4, 2.1, 0.7, 1.3, DARK))
|
||||
blocks.append(_b(side * 5.1, -2.5, end * 6.0, 0.8, 2.1, 0.3, RIM))
|
||||
"shield":
|
||||
blocks = [_b(0, -0.6, 0, 6.0, 3.0, 6.0, DARK), _b(0, 1.0, 0, 3.0, 1.0, 3.0, STEEL)]
|
||||
# Stepped, open quadrants read as a compact generator ring in stereo.
|
||||
blocks = [_b(0, -0.6, 0, 6.0, 3.0, 6.0, DARK), _b(0, 1.0, 0, 3.0, 1.0, 3.0, STEEL),
|
||||
_b(0, 1.7, 0, 1.7, 0.4, 1.7, BRASS)]
|
||||
for side in [-1.0, 1.0]:
|
||||
blocks.append(_b(0, 0, side * 6.2, 7.2, 2.0, 4.2, STEEL))
|
||||
blocks.append(_b(side * 6.2, 0, 0, 4.2, 2.0, 7.2, STEEL))
|
||||
blocks.append(_b(0, 1.1, side * 6.2, 5.0, 0.5, 2.8, DARK))
|
||||
blocks.append(_b(side * 6.2, 1.1, 0, 2.8, 0.5, 5.0, DARK))
|
||||
blocks.append(_b(side * 4.7, -1.0, side * 4.7, 2.8, 0.8, 2.8, BRASS))
|
||||
for other in [-1.0, 1.0]:
|
||||
blocks.append(_b(side * 4.7, -1.0, other * 4.7, 2.8, 0.8, 2.8, BRASS))
|
||||
blocks.append(_b(side * 4.6, 0.2, other * 4.6, 1.5, 1.3, 1.5, DARK))
|
||||
"boarding":
|
||||
blocks = [_b(-1, 0, 0, 13.0, 5.0, 7.0, STEEL), _b(4.7, 0, 0, 5.0, 4.0, 6.0, DARK),
|
||||
_b(7.7, 0, 0, 1.2, 3.0, 4.0, BRASS), _b(-6.0, 0, 0, 2.0, 6.0, 8.5, DARK),
|
||||
@@ -172,23 +220,50 @@ func _body_blocks(mark_two: bool) -> Array:
|
||||
blocks.append(_b(-2.0, -0.5, side * 4.6, 8.0, 1.0, 2.4, DARK))
|
||||
blocks.append(_b(-6.6, 0, side * 2.5, 2.5, 2.5, 2.4, STEEL))
|
||||
blocks.append(_b(3.5, 0, side * 3.4, 0.7, 4.0, 1.0, BRASS))
|
||||
blocks.append(_b(-1, 1.0, side * 3.65, 8.7, 1.0, 0.4, RED))
|
||||
for x in [-4.0, -1.5, 1.0]:
|
||||
blocks.append(_b(x, 2.95, side * 2.1, 0.7, 0.5, 1.0, DARK))
|
||||
blocks.append(_b(6.6, 2.1, 0, 1.0, 0.5, 4.7, RIM))
|
||||
"anti_drone":
|
||||
# Narrow angular interceptor, distinct from the defense turret.
|
||||
blocks = [_b(-2.0, -0.7, 0, 11.0, 2.8, 5.5, DARK), _b(-2.7, 0.8, 0, 8.5, 2.2, 5.0, STEEL),
|
||||
_b(-3.0, 2.1, 0, 5.0, 0.5, 3.5, RIM), _b(2.0, 0.4, 0, 4.0, 1.3, 3.0, BRASS)]
|
||||
for side in [-1.0, 1.0]:
|
||||
blocks.append(_b(-3.4, -0.2, side * 4.8, 9.0, 2.0, 2.2, STEEL))
|
||||
blocks.append(_b(-7.7, -0.2, side * 4.8, 1.0, 1.6, 1.8, BLACK))
|
||||
blocks.append(_b(5.5, 1.1, side * 3.0, 12.3, 1.4, 1.3, DARK))
|
||||
blocks.append(_b(9.0, 1.1, side * 3.0, 2.0, 2.0, 1.9, STEEL))
|
||||
blocks.append(_b(11.8, 1.1, side * 3.0, 1.0, 2.0, 2.0, RIM))
|
||||
blocks.append(_b(-1.0, 2.0, side * 4.8, 1.3, 0.6, 1.7, BRASS))
|
||||
_:
|
||||
blocks = [_b(-1.5, -1.4, 0, 12.0, 2.0, 8.0, DARK), _b(-1.0, 0, 0, 11.0, 3.8, 7.0, STEEL),
|
||||
_b(-2.2, 2.2, 0, 6.0, 1.1, 5.0, RIM), _b(-6.5, 0, 0, 2.0, 3.0, 6.5, DARK)]
|
||||
# Pixel-sized steps and inset dark seams give the armor an FTL sprite
|
||||
# silhouette instead of one uniform cuboid, with one batched body mesh.
|
||||
blocks = [_b(-1.8, -1.4, 0, 10.8, 1.6, 7.0, DARK), _b(-1.5, -0.2, 0, 11.0, 2.6, 7.0, SLATE),
|
||||
_b(-2.0, 1.1, 0, 8.8, 1.4, 6.4, STEEL), _b(-2.4, 2.1, 0, 6.0, 0.6, 5.0, RIM),
|
||||
_b(-6.5, 0, 0, 2.0, 3.0, 6.5, DARK), _b(3.5, 0.4, 0, 2.0, 1.5, 5.1, STEEL),
|
||||
_b(-0.8, 2.5, 0, 1.0, 0.4, 3.4, DARK), _b(-4.4, 2.5, 0, 0.6, 0.4, 3.6, BRASS)]
|
||||
for side in [-1.0, 1.0]:
|
||||
blocks.append(_b(-2.8, -0.7, side * 4.4, 7.6, 1.1, 3.0, DARK))
|
||||
blocks.append(_b(-4.5, -0.3, side * 5.4, 6.4, 2.6, 3.0, STEEL))
|
||||
blocks.append(_b(-7.4, -0.3, side * 5.4, 1.0, 2.2, 2.6, BLACK))
|
||||
blocks.append(_b(-2.5, 1.2, side * 5.4, 1.0, 0.5, 2.8, BRASS))
|
||||
blocks.append(_b(-4.4, 1.2, side * 5.4, 1.5, 0.5, 2.2, RIM))
|
||||
blocks.append(_b(0.6, -0.6, side * 4.3, 1.5, 0.8, 1.1, STEEL))
|
||||
for x in [-4.2, -2.8, -1.4]: blocks.append(_b(x, 1.5, side * 2.6, 0.5, 0.5, 1.1, DARK))
|
||||
match family:
|
||||
"beam":
|
||||
blocks.append(_b(5.4, 0, 0, 10.0, 2.0, 4.5, DARK))
|
||||
blocks.append(_b(5.4, 0, 0, 10.0, 2.0, 6.0 if mark_two else 4.5, DARK))
|
||||
for side in [-1.0, 1.0]:
|
||||
blocks.append(_b(9.0, 0, side * 2.9, 7.0, 2.5, 1.3, BRASS))
|
||||
blocks.append(_b(12.0, 0, side * 2.9, 1.0, 3.2, 1.8, RIM))
|
||||
var rail_z: float = side * (3.7 if mark_two else 2.9)
|
||||
blocks.append(_b(9.0, 0, rail_z, 7.0, 2.5, 1.3, BRASS))
|
||||
blocks.append(_b(12.0, 0, rail_z, 1.0, 3.2, 1.8, RIM))
|
||||
blocks.append(_b(7.2, 1.5, rail_z, 1.0, 0.6, 1.8, STEEL))
|
||||
blocks.append(_b(3.1, 1.4, 0, 2.5, 0.8, 4.0, BRASS))
|
||||
"defense", "anti_drone":
|
||||
blocks.append(_b(12.4, 0, 0, 0.64, 1.8, 6.0 if mark_two else 4.5, BLACK))
|
||||
if mark_two:
|
||||
blocks.append(_b(1.2, 3.0, 0, 3.0, 1.0, 4.6, STEEL))
|
||||
blocks.append(_b(1.2, 3.7, 0, 2.0, 0.4, 3.6, BRASS))
|
||||
"defense":
|
||||
blocks.append(_b(1.3, 2.0, 0, 5.8, 2.0, 5.8, DARK))
|
||||
blocks.append(_b(1.3, 3.1, 0, 4.2, 0.6, 4.2, STEEL))
|
||||
for z in ([-1.5, 1.5] if mark_two else [0.0]):
|
||||
@@ -197,14 +272,21 @@ func _body_blocks(mark_two: bool) -> Array:
|
||||
for side in [-1.0, 1.0]:
|
||||
blocks.append(_b(-1.5, 0, side * 8.0, 9.0, 0.8, 2.0, BRASS))
|
||||
blocks.append(_b(-4.7, 2.0, side * 5.4, 0.5, 4.0, 0.5, DARK))
|
||||
if family == "anti_drone":
|
||||
blocks.append(_b(4.5, 1.7, -4.2, 7.0, 1.0, 1.0, BRASS))
|
||||
blocks.append(_b(4.5, 1.7, 4.2, 7.0, 1.0, 1.0, BRASS))
|
||||
blocks.append(_b(-4.7, 4.2, side * 5.4, 1.5, 0.5, 1.5, RIM))
|
||||
if mark_two:
|
||||
blocks.append(_b(1.3, 4.0, 0, 3.0, 1.0, 3.0, RIM))
|
||||
blocks.append(_b(1.3, 4.65, 0, 2.0, 0.3, 2.0, SLATE))
|
||||
_:
|
||||
for z in ([-1.8, 1.8] if mark_two else [0.0]):
|
||||
blocks.append(_b(7.3, 0.3, z, 10.5, 2.0, 2.0, DARK))
|
||||
blocks.append(_b(11.7, 0.3, z, 1.4, 3.0, 3.0, RIM))
|
||||
blocks.append(_b(12.6, 0.3, z, 0.64, 2.0, 2.0, BLACK))
|
||||
blocks.append(_b(4.0, 0.7, 0, 1.2, 4.0, 6.0, BRASS))
|
||||
for side in [-1.0, 1.0]:
|
||||
blocks.append(_b(6.3, 1.6, side * (1.8 if mark_two else 0.85), 3.7, 0.6, 0.6, STEEL))
|
||||
if mark_two:
|
||||
blocks.append(_b(-2.0, 2.8, 0, 5.8, 0.8, 5.4, DARK))
|
||||
blocks.append(_b(-2.0, 3.4, 0, 4.8, 0.4, 4.4, STEEL))
|
||||
return blocks
|
||||
|
||||
|
||||
@@ -212,15 +294,21 @@ func set_live(data: Dictionary) -> void:
|
||||
live_data = data
|
||||
visible = not bool(data.get("dead", false)) and (bool(data.get("deployed", false)) or not is_space)
|
||||
var powered := bool(data.get("powered", true))
|
||||
firing = bool(data.get("firing", false)) if is_space else bool(data.get("fighting", false))
|
||||
var native_firing := bool(data.get("firing", false)) if is_space else bool(data.get("fighting", false))
|
||||
if native_firing and not firing and is_space:
|
||||
present_fire()
|
||||
firing = native_firing
|
||||
thruster.visible = is_space and visible and powered
|
||||
emitter.visible = visible and powered
|
||||
var material: StandardMaterial3D = emitter.material_override
|
||||
material.emission_energy_multiplier = 1.5 if firing else 0.25
|
||||
emitter.material_override = _glow_material(emitter_color, 1.5 if firing or fire_flash > 0.0 else 0.25)
|
||||
|
||||
|
||||
func animate(delta: float) -> void:
|
||||
animation_time += delta
|
||||
fire_flash = maxf(0.0, fire_flash - delta)
|
||||
if is_space:
|
||||
body.position.x = -0.008 * (fire_flash / 0.13)
|
||||
emitter.material_override = _glow_material(emitter_color, 1.5 if firing or fire_flash > 0.0 else 0.25)
|
||||
if thruster.visible:
|
||||
thruster.scale.x = 0.88 + sin(animation_time * 28.0) * 0.12
|
||||
if tool != null:
|
||||
@@ -231,16 +319,38 @@ func animate(delta: float) -> void:
|
||||
tool.rotation.x = 0.55 + sin(animation_time * 11.0) * 0.35
|
||||
|
||||
|
||||
func _glow_mesh(blocks: Array, color: Color) -> MeshInstance3D:
|
||||
func _cached_part(key: String, blocks: Array) -> MeshInstance3D:
|
||||
if not detail_meshes.has(key):
|
||||
var source := Voxels.make(blocks)
|
||||
detail_meshes[key] = source.mesh
|
||||
source.free()
|
||||
var node := MeshInstance3D.new()
|
||||
node.mesh = detail_meshes[key]
|
||||
node.material_override = body_material
|
||||
return node
|
||||
|
||||
|
||||
func _glow_mesh(key: String, blocks: Array, color: Color) -> MeshInstance3D:
|
||||
# Interior robots have no exhaust; keep a harmless hidden mesh for the
|
||||
# shared animation interface rather than creating an empty surface.
|
||||
if blocks.is_empty(): blocks = [_b(0, 0, 0, 0.1, 0.1, 0.1, color)]
|
||||
var node := Voxels.make(blocks)
|
||||
var material: StandardMaterial3D = node.material_override
|
||||
var node := _cached_part(key, blocks)
|
||||
node.material_override = _glow_material(color, 1.5)
|
||||
return node
|
||||
|
||||
|
||||
func _glow_material(color: Color, energy: float) -> StandardMaterial3D:
|
||||
var key := color.to_html() + ":" + str(energy)
|
||||
if glow_materials.has(key):
|
||||
return glow_materials[key]
|
||||
var material := StandardMaterial3D.new()
|
||||
material.vertex_color_use_as_albedo = true
|
||||
material.roughness = 0.88
|
||||
material.emission_enabled = true
|
||||
material.emission = color
|
||||
material.emission_energy_multiplier = 1.5
|
||||
return node
|
||||
material.emission_energy_multiplier = energy
|
||||
glow_materials[key] = material
|
||||
return material
|
||||
|
||||
|
||||
func _b(x: float, y: float, z: float, w: float, h: float, d: float, color: Color) -> Array:
|
||||
|
||||
Reference in new issue
Block a user