Files
ElHombreAlex--FTL-Faster-Th…/scripts/ship_model.gd
T
ElHombreAlex 35cdf71054 V0.1
First publishable version of the mod
2026-10-03 12:59:33 +02:00

804 lines
33 KiB
GDScript

extends Node3D
const TILE := 0.18
const CrewModel = preload("res://scripts/voxel_crew.gd")
const WeaponModel = preload("res://scripts/voxel_weapon.gd")
const DroneModel = preload("res://scripts/voxel_drone.gd")
const Voxels = preload("res://scripts/voxel_mesh.gd")
const RoomHazards = preload("res://scripts/room_hazards.gd")
const UiAssets = preload("res://scripts/ftl_ui_assets.gd")
static var system_icon_textures: Dictionary = {}
static var door_leaf_meshes: Dictionary = {}
var ship_name := ""
var enemy := false
var layout_data: Dictionary = {}
var layout_center := Vector2.ZERO
var room_points: Dictionary = {}
var mount_points: Array[Vector3] = []
var shield_shell: MeshInstance3D
var shield_radii := Vector3(1.8, 0.48, 1.1)
var shield_center := Vector3.ZERO
var shield_charge := 2
var shield_flash := 0.0
var simulation_paused := false
var crew_nodes: Dictionary = {}
var room_nodes: Dictionary = {}
var room_bounds: Dictionary = {}
var room_visibility: Dictionary = {}
var weapon_nodes: Array[Node3D] = []
var door_nodes: Dictionary = {}
var drone_nodes: Dictionary = {}
var drop_target_room := -1
var hull_voxel_count := 0
var shield_fit_points := PackedVector3Array()
var shield_fit_padding := 1.0
var live_data: Dictionary = {}
var inspect_rooms := false
var demo_crew_marker: Area3D
var native_shield_geometry: Dictionary = {}
var shield_state_cache := Vector2i(-1, -1)
var room_hazards: Node3D
var viewer_position := Vector3.ZERO
var viewer_position_valid := false
func build(name: String, is_enemy: bool) -> void:
for child in get_children():
remove_child(child)
child.queue_free()
room_points.clear()
mount_points.clear()
room_nodes.clear()
room_bounds.clear()
room_visibility.clear()
crew_nodes.clear()
weapon_nodes.clear()
door_nodes.clear()
drone_nodes.clear()
drop_target_room = -1
hull_voxel_count = 0
shield_fit_points.clear()
shield_fit_padding = 1.0
demo_crew_marker = null
native_shield_geometry.clear()
shield_state_cache = Vector2i(-1, -1)
ship_name = name
enemy = is_enemy
var path := _data_path("%s.json" % name)
var layout: Dictionary = {}
if FileAccess.file_exists(path):
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
if parsed is Dictionary:
layout = parsed
if layout.is_empty():
layout = _placeholder_layout()
var rooms: Array = layout.get("rooms", [])
var min_x := 10000.0
var min_y := 10000.0
var max_x := -10000.0
var max_y := -10000.0
for room in rooms:
min_x = minf(min_x, float(room["x"]))
min_y = minf(min_y, float(room["y"]))
max_x = maxf(max_x, float(room["x"] + room["w"]))
max_y = maxf(max_y, float(room["y"] + room["h"]))
var center := Vector2((min_x + max_x) / 2.0, (min_y + max_y) / 2.0)
layout_data = layout
layout_center = center
_make_hull(layout, center)
for room in rooms:
_make_room(room, center, str(layout.get("systems", {}).get(str(int(room["id"])), "")))
if not enemy:
_make_crew(rooms, center)
_make_weapon_mounts(layout, center)
_make_shield(layout)
room_hazards = RoomHazards.new()
room_hazards.name = "RoomHazards"
room_hazards.build()
add_child(room_hazards)
func _placeholder_layout() -> Dictionary:
var rooms: Array = []
for x in range(3):
for y in range(2):
rooms.append({"id": x * 2 + y, "x": x * 2, "y": y * 2, "w": 2, "h": 2})
return {"rooms": rooms}
func _make_hull(layout: Dictionary, center: Vector2) -> void:
var rect: Dictionary = layout.get("image_rect", {})
var texture_path := _data_path("%s.png" % ship_name)
if rect.is_empty() or not FileAccess.file_exists(texture_path):
var block := MeshInstance3D.new()
block.mesh = BoxMesh.new()
block.scale = Vector3(1.8, 0.07, 1.15)
block.material_override = _material(Color(0.28, 0.32, 0.43) if enemy else Color(0.28, 0.36, 0.52))
add_child(block)
return
var image := Image.load_from_file(texture_path)
if image == null:
return
_make_hull_depth(image, rect, center)
var quad := QuadMesh.new()
quad.size = Vector2(float(rect["w"]) / 35.0 * TILE, float(rect["h"]) / 35.0 * TILE)
var mesh := MeshInstance3D.new()
mesh.mesh = quad
mesh.position = Vector3((float(rect["x"]) / 35.0 + float(rect["w"]) / 70.0 - center.x) * TILE,
0.005, (float(rect["y"]) / 35.0 + float(rect["h"]) / 70.0 - center.y) * TILE)
mesh.rotation_degrees.x = -90.0
var mat := StandardMaterial3D.new()
mat.albedo_texture = ImageTexture.create_from_image(image)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mesh.material_override = mat
add_child(mesh)
func _make_hull_depth(image: Image, rect: Dictionary, center: Vector2) -> void:
# Extrude the owned silhouette in colored, stepped armor. A MultiMesh keeps
# thousands of hull blocks to one draw call per ship, with no external model.
var step := 7
# Map the extrusion through the same native image rectangle as the deck.
# This also handles archives whose only available sprite is a cropped image.
var image_scale := Vector2(float(rect["w"]) / image.get_width(), float(rect["h"]) / image.get_height())
var opaque_cells: Array = []
for py in range(0, image.get_height(), step):
for px in range(0, image.get_width(), step):
var cell_size := Vector2(mini(step, image.get_width() - px), mini(step, image.get_height() - py))
var sample_x := mini(px + step / 2, image.get_width() - 1)
var sample_y := mini(py + step / 2, image.get_height() - 1)
var color := image.get_pixel(sample_x, sample_y)
if color.a < 0.35:
continue
var edge := false
for offset in [Vector2i(-step * 2, 0), Vector2i(step * 2, 0), Vector2i(0, -step * 2), Vector2i(0, step * 2)]:
var sx: int = sample_x + offset.x
var sy: int = sample_y + offset.y
if sx < 0 or sy < 0 or sx >= image.get_width() or sy >= image.get_height() or image.get_pixel(sx, sy).a < 0.35:
edge = true
var height := 0.19 if edge else 0.32
# Bright armor plates become a separate lower keel tier; the textured
# deck remains above it and room floors retain their original layout.
height += 0.035 if color.get_luminance() > 0.55 and not edge else 0.0
var native_cell := cell_size * image_scale
var point := Vector3((float(rect["x"]) + (px + cell_size.x * 0.5) * image_scale.x) / 35.0 * TILE - center.x * TILE,
-height * 0.5 - 0.005, (float(rect["y"]) + (py + cell_size.y * 0.5) * image_scale.y) / 35.0 * TILE - center.y * TILE)
var half_size := native_cell / 35.0 * TILE * 0.5
for x_sign in [-1.0, 1.0]:
for y_sign in [-1.0, 1.0]:
for z_sign in [-1.0, 1.0]:
shield_fit_points.append(point + Vector3(x_sign * half_size.x, y_sign * height * 0.5, z_sign * half_size.y))
color = color.darkened(0.27)
color.a = 1.0
opaque_cells.append([point, height, color, native_cell / 35.0 * TILE])
var block := BoxMesh.new()
block.size = Vector3.ONE
var multi := MultiMesh.new()
multi.transform_format = MultiMesh.TRANSFORM_3D
multi.use_colors = true
multi.mesh = block
multi.instance_count = opaque_cells.size()
for i in range(opaque_cells.size()):
multi.set_instance_transform(i, Transform3D(Basis().scaled(Vector3(opaque_cells[i][3].x, opaque_cells[i][1], opaque_cells[i][3].y)), opaque_cells[i][0]))
multi.set_instance_color(i, opaque_cells[i][2])
var hull := MultiMeshInstance3D.new()
hull.name = "ExtrudedHull"
hull.multimesh = multi
var material := _material(Color.WHITE)
material.vertex_color_use_as_albedo = true
material.roughness = 0.82
hull.material_override = material
hull_voxel_count = opaque_cells.size()
add_child(hull)
func _make_room(room: Dictionary, center: Vector2, system_name: String) -> void:
var width := float(room["w"]) * TILE
var depth := float(room["h"]) * TILE
var location := Vector3((float(room["x"]) + float(room["w"]) / 2.0 - center.x) * TILE,
0.065, (float(room["y"]) + float(room["h"]) / 2.0 - center.y) * TILE)
room_points[int(room["id"])] = location + Vector3(0.0, 0.12, 0.0)
var area := Area3D.new()
area.set_meta("kind", "room")
area.set_meta("ship", "enemy" if enemy else "player")
area.set_meta("room_id", int(room["id"]))
area.position = location
add_child(area)
room_nodes[int(room["id"])] = area
room_bounds[int(room["id"])] = Rect2(Vector2(location.x - width * 0.5, location.z - depth * 0.5), Vector2(width, depth))
for x_sign in [-1.0, 1.0]:
for z_sign in [-1.0, 1.0]:
shield_fit_points.append(location + Vector3(x_sign * width * 0.5, 0.125, z_sign * depth * 0.5))
var shape := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(width * 0.97, 0.025, depth * 0.97)
shape.shape = box
area.add_child(shape)
var floor := MeshInstance3D.new()
floor.name = "Floor"
var floor_mesh := BoxMesh.new()
floor_mesh.size = Vector3(width * 0.97, 0.025, depth * 0.97)
floor.mesh = floor_mesh
floor.material_override = _material(Color(0.31, 0.35, 0.45) if enemy else Color(0.73, 0.74, 0.69))
area.add_child(floor)
_make_room_walls(room, area)
_set_room_role(area, system_name)
func _make_room_walls(room: Dictionary, area: Area3D) -> void:
var blocks: Array = []
var color := Color("573b32") if enemy else Color("344450")
for side in range(4):
var horizontal := side < 2
var count := int(room["w"] if horizontal else room["h"])
for tile in range(count):
var grid_x := int(room["x"]) + tile if horizontal else int(room["x"]) + (int(room["w"]) if side == 3 else 0)
var grid_y := int(room["y"]) + (int(room["h"]) if side == 1 else 0) if horizontal else int(room["y"]) + tile
var doorway := false
for door in layout_data.get("doors", []):
if int(door["x"]) == grid_x and int(door["y"]) == grid_y and bool(door["vertical"]) != horizontal:
doorway = true
var point := Vector3((grid_x + (0.5 if horizontal else 0.0) - layout_center.x) * TILE,
0.065, (grid_y + (0.0 if horizontal else 0.5) - layout_center.y) * TILE) - area.position
point.y = 0.063
if doorway:
for offset in [-1.0, 1.0]:
var jamb := point + (Vector3(offset * 0.077, 0, 0) if horizontal else Vector3(0, 0, offset * 0.077))
blocks.append(Voxels.block(jamb, Vector3(0.025, 0.10, 0.014) if horizontal else Vector3(0.014, 0.10, 0.025), color))
else:
blocks.append(Voxels.block(point, Vector3(TILE, 0.10, 0.014) if horizontal else Vector3(0.014, 0.10, TILE), color))
blocks.append(Voxels.block(point + Vector3(0, 0.054, 0), Vector3(TILE, 0.01, 0.021) if horizontal else Vector3(0.021, 0.01, TILE), color.lightened(0.15)))
var walls := Voxels.make(blocks)
walls.name = "Walls"
area.add_child(walls)
func _set_room_role(area: Area3D, role: String) -> void:
if area.get_meta("system_role", "__unset") == role:
return
area.set_meta("system_role", role)
for node_name in ["SystemIcon", "SystemRole"]:
var old := area.get_node_or_null(node_name)
if old != null:
area.remove_child(old)
old.queue_free()
if role.is_empty():
return
var path := _data_path("ui/img/icons/s_%s.png" % role)
if FileAccess.file_exists(path):
if not system_icon_textures.has(path):
var image := Image.load_from_file(path)
if image != null:
system_icon_textures[path] = ImageTexture.create_from_image(image)
if system_icon_textures.has(path):
var icon := MeshInstance3D.new()
icon.name = "SystemIcon"
var quad := QuadMesh.new()
quad.size = Vector2(0.12, 0.12)
icon.mesh = quad
icon.rotation.x = -PI / 2.0
icon.position.y = 0.022
var material := StandardMaterial3D.new()
material.albedo_texture = system_icon_textures[path]
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
material.cull_mode = BaseMaterial3D.CULL_DISABLED
material.albedo_color = Color("fff2c4") if enemy else Color("223844")
icon.material_override = material
area.add_child(icon)
var label := Label3D.new()
label.name = "SystemRole"
label.font = UiAssets.font("body")
label.outline_size = 0
var names := {"pilot": "PILOT", "weapons": "WEAPONS", "shields": "SHIELDS", "engines": "ENGINES",
"oxygen": "OXYGEN", "medbay": "MEDBAY", "doors": "DOORS", "sensors": "SENSORS", "drones": "DRONES",
"teleporter": "TELEPORT", "cloaking": "CLOAK", "artillery": "ARTILLERY", "battery": "BATTERY",
"clonebay": "CLONE BAY", "mind": "MIND", "hacking": "HACKING"}
label.text = str(names.get(role, role.to_upper()))
label.font_size = 32
label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
label.pixel_size = 0.00075
label.modulate = Color("fff2c4") if enemy else Color("223844")
label.position = Vector3(0, 0.023, 0.09)
label.rotation.x = -PI / 2.0
area.add_child(label)
func set_drop_target(room_id: int = -1) -> void:
if drop_target_room == room_id:
return
drop_target_room = room_id
for id in room_nodes:
var floor: MeshInstance3D = room_nodes[id].get_node("Floor")
floor.material_override.albedo_color = Color(0.35, 0.95, 0.71) if id == room_id else floor.get_meta("normal_tint", Color(0.65, 0.7, 0.73))
func room_ray_hit(from: Vector3, direction: Vector3, margin_m: float = 0.0) -> Dictionary:
# Test the floor the player sees, rather than the taller crew/door colliders.
# The ray parameter remains a WORLD distance through scaled ship transforms.
if not is_visible_in_tree() or direction.length_squared() < 0.000001:
return {}
var normalized := direction.normalized()
var local_from := to_local(from)
var local_direction := global_basis.inverse() * normalized
if absf(local_direction.y) < 0.000001:
return {}
var distance := (0.0775 - local_from.y) / local_direction.y
if distance < 0.0 or distance > 20.0:
return {}
var point := local_from + local_direction * distance
var floor_point := Vector2(point.x, point.z)
var best_id := -1
var best_edge_distance := INF
for id in room_bounds:
var rect: Rect2 = room_bounds[id]
if rect.has_point(floor_point):
best_id = id
best_edge_distance = 0.0
break
if margin_m <= 0.0:
continue
var closest := floor_point.clamp(rect.position, rect.end)
var closest_world := to_global(Vector3(closest.x, 0.0775, closest.y))
var edge_distance := closest_world.distance_to(to_global(point))
if edge_distance <= margin_m and edge_distance < best_edge_distance:
best_id = id
best_edge_distance = edge_distance
if best_id < 0:
return {}
var selected_rect: Rect2 = room_bounds[best_id]
# Keep rounded native mouse coordinates one pixel inside the chosen room.
var native_pixel_padding := Vector2.ONE * TILE / 35.0
var command_point := floor_point.clamp(selected_rect.position + native_pixel_padding, selected_rect.end - native_pixel_padding)
return {"collider": room_nodes[best_id], "position": to_global(point), "distance": distance,
"room_id": best_id, "ship": "enemy" if enemy else "player", "edge_margin": best_edge_distance > 0.0,
"edge_distance": best_edge_distance, "room_position": to_global(Vector3(command_point.x, point.y, command_point.y)),
"pixel": {"x": (command_point.x / TILE + layout_center.x + float(layout_data.get("x_offset", 0))) * 35.0,
"y": (command_point.y / TILE + layout_center.y + float(layout_data.get("y_offset", 0))) * 35.0}}
func _apply_doors(doors: Array) -> void:
var present: Dictionary = {}
for data in doors:
var id := str(data["id"])
present[id] = true
if not door_nodes.has(id):
var area := Area3D.new()
area.set_meta("kind", "door")
area.set_meta("door_id", data["id"])
area.set_meta("ship", "enemy" if enemy else "player")
var collider := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = Vector3(0.15, 0.15, 0.04)
collider.shape = shape
area.add_child(collider)
add_child(area)
door_nodes[id] = area
var door: Area3D = door_nodes[id]
# iBlast reports the actual native blast-door strength, including crew
# bonuses. Old snapshots can fall back to the installed doors level.
var tier := clampi(int(data.get("blast", maxi(0, int(data.get("level", live_data.get("door_level", 1))) - 1))), 0, 3)
if door.get_meta("blast_tier", -1) != tier:
door.set_meta("blast_tier", tier)
door.remove_meta("tint")
for side in [-1.0, 1.0]:
var leaf_name := "Left" if side < 0 else "Right"
var previous := door.get_node_or_null(leaf_name)
if previous != null:
door.remove_child(previous)
previous.queue_free()
var leaf := _make_door_leaf(tier)
leaf.name = leaf_name
leaf.position.x = side * (0.031 + float(door.get_meta("opening", 0.0)) * 0.065)
door.add_child(leaf)
door.position = pixel_point(data, 0.13)
door.rotation.y = PI / 2.0 if bool(data.get("vertical", false)) else 0.0
door.visible = not enemy or (inspect_rooms and (_room_visible(int(data.get("room_a", -1))) or _room_visible(int(data.get("room_b", -1)))))
door.collision_layer = 1 if door.visible and not enemy and bool(data.get("controllable", false)) else 0
door.set_meta("open", bool(data.get("open", false)))
door.set_meta("native", data)
var tint := Color.WHITE
if bool(data.get("hacked", false)):
tint = Color("ac70ca")
elif bool(data.get("locked", false)) or bool(data.get("ionized", false)):
tint = Color("eab166")
elif float(data.get("health", 1)) < float(data.get("max_health", 1)):
tint = Color("ea7166")
if door.get_meta("tint", Color(-1, -1, -1)) != tint:
door.set_meta("tint", tint)
for name in ["Left", "Right"]:
door.get_node(name).material_override.albedo_color = tint
for id in door_nodes.keys():
if not present.has(id):
door_nodes[id].queue_free()
door_nodes.erase(id)
func _make_door_leaf(tier: int) -> MeshInstance3D:
if not door_leaf_meshes.has(tier):
# All native strength tiers fit the same jamb opening. Reinforcement
# remains shallow and inset rather than stacking oversized outer frames.
var blocks := [Voxels.block(Vector3.ZERO, Vector3(0.059, 0.092, 0.015), Color("bac9d0")),
Voxels.block(Vector3(0, -0.035, 0), Vector3(0.057, 0.008, 0.019), Color("6d858d"))]
for z in [-0.0085, 0.0085]:
blocks.append(Voxels.block(Vector3(0, 0.020, z), Vector3(0.033, 0.008, 0.003), Color("4c8792")))
if tier > 0:
for z in [-0.010, 0.010]:
blocks.append(Voxels.block(Vector3(0, -0.006, z), Vector3(0.047, 0.048, 0.005), Color("82959d")))
if tier > 1:
for z in [-0.0135, 0.0135]:
for y in [-0.023, 0.007]:
blocks.append(Voxels.block(Vector3(0, y, z), Vector3(0.046, 0.006, 0.003), Color("b0ad92")))
if tier > 2:
for z in [-0.0135, 0.0135]:
for x in [-0.021, 0.021]:
blocks.append(Voxels.block(Vector3(x, -0.008, z), Vector3(0.004, 0.043, 0.003), Color("b0ad92")))
blocks.append(Voxels.block(Vector3(0, 0.032, z), Vector3(0.014, 0.005, 0.003), Color("8f9e9c")))
var source: MeshInstance3D = Voxels.make(blocks)
door_leaf_meshes[tier] = source.mesh
source.free()
var leaf := MeshInstance3D.new()
leaf.mesh = door_leaf_meshes[tier]
var material := StandardMaterial3D.new()
material.vertex_color_use_as_albedo = true
material.roughness = 0.88
leaf.material_override = material
return leaf
func _apply_drones(drones: Array) -> void:
var present: Dictionary = {}
for data in drones:
if not bool(data.get("is_space", false)) or not bool(data.get("deployed", false)) or bool(data.get("dead", false)):
continue
var id := str(data["id"])
present[id] = true
if not drone_nodes.has(id):
var model := DroneModel.new()
model.build(data)
add_child(model)
drone_nodes[id] = model
model.position = pixel_point(data, 0.3)
model.rotation.y = -deg_to_rad(float(data.get("angle", 0)))
var drone: Node3D = drone_nodes[id]
drone.set_live(data)
# Positions come from the native drone's current space, regrouped by the
# bridge; attack drones therefore orbit the receiver, not their owner.
drone.set_meta("target", pixel_point(data, 0.3))
# FTL's current_angle wraps at 360 (native CustomDrones.cpp).
drone.set_meta("target_angle", -deg_to_rad(float(data.get("angle", 0))))
for id in drone_nodes.keys():
if not present.has(id):
drone_nodes[id].queue_free()
drone_nodes.erase(id)
func _make_crew(rooms: Array, center: Vector2) -> void:
if rooms.is_empty():
return
var room: Dictionary = rooms[min(rooms.size() - 1, 2)]
var marker := _make_miniature({"id": "crew_1", "name": "Crew", "species": "human", "x": 0, "y": 0})
demo_crew_marker = marker
marker.position = Vector3((float(room["x"]) + 0.5 - center.x) * TILE, 0.19,
(float(room["y"]) + 0.5 - center.y) * TILE)
func _make_weapon_mounts(layout: Dictionary, center: Vector2) -> void:
var rect: Dictionary = layout.get("image_rect", {"x": 0, "y": 0})
for mount in layout.get("weapon_mounts", []):
var point := Vector3((float(rect["x"]) + float(mount["x"])) / 35.0 * TILE - center.x * TILE,
0.14, (float(rect["y"]) + float(mount["y"])) / 35.0 * TILE - center.y * TILE)
mount_points.append(point)
var gun := WeaponModel.new()
gun.position = point
gun.rotation.y = 0.0 if str(mount.get("rotate", "false")) == "true" else PI / 2.0
gun.build({"kind": "laser", "powered": false})
add_child(gun)
weapon_nodes.append(gun)
if mount_points.is_empty():
mount_points.append(Vector3(0.5, 0.14, 0.0))
func _make_shield(layout: Dictionary) -> void:
var ellipse: Array = layout.get("ellipse", [350, 220, 0, 0])
shield_radii = Vector3(float(ellipse[0]) / 35.0 * TILE, 0.48, float(ellipse[1]) / 35.0 * TILE)
shield_center = Vector3(0.0, 0.06, 0.0)
var rect: Dictionary = layout.get("image_rect", {})
if not rect.is_empty():
# A preview without live FTL uses the hull center. The live snapshot
# replaces this with GetBaseEllipse; layout ELLIPSE offsets cannot be
# added to the image center because they use the native graph alignment.
shield_center.x = (float(rect["x"]) + float(rect["w"]) / 2.0) / 35.0 * TILE - layout_center.x * TILE
shield_center.z = (float(rect["y"]) + float(rect["h"]) / 2.0) / 35.0 * TILE - layout_center.y * TILE
shield_shell = MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 1.0
sphere.height = 2.0
sphere.radial_segments = 64
sphere.rings = 32
shield_shell.mesh = sphere
shield_radii = _fit_shield_volume(shield_radii)
shield_shell.position = shield_center
shield_shell.scale = shield_radii
var material := ShaderMaterial.new()
material.shader = preload("res://scripts/shield.gdshader")
shield_shell.material_override = material
add_child(shield_shell)
set_shields(2)
func set_shields(charge: int, super_shield: int = 0) -> void:
shield_charge = maxi(charge, 0)
if shield_shell == null:
return
var current := Vector2i(shield_charge, super_shield)
if current == shield_state_cache:
return
shield_state_cache = current
shield_shell.visible = shield_charge > 0 or super_shield > 0
var material: ShaderMaterial = shield_shell.material_override
material.set_shader_parameter("shield_color", Color(0.23, 0.95, 0.35) if super_shield > 0 else Color(0.18, 0.58, 1.0))
material.set_shader_parameter("strength", clampf(shield_charge / 4.0, 0.25, 1.0))
func _apply_shield_shape(shape: Dictionary) -> void:
if shape.is_empty() or shape == native_shield_geometry or shield_shell == null:
return
var center: Dictionary = shape.get("center", {})
var radius_x := float(shape.get("a", 0))
var radius_z := float(shape.get("b", 0))
if center.is_empty() or radius_x <= 0.0 or radius_z <= 0.0:
return
# GetBaseEllipse is in the same native ship-local pixel space as crew,
# rooms and doors. Enemy layout ELLIPSE offsets include native alignment
# adjustments; adding them to the hull image center a second time was wrong.
native_shield_geometry = shape.duplicate(true)
shield_center = pixel_point(center, 0.06)
shield_radii = Vector3(radius_x / 35.0 * TILE, 0.48, radius_z / 35.0 * TILE)
shield_radii = _fit_shield_volume(shield_radii)
shield_shell.position = shield_center
shield_shell.scale = shield_radii
func _fit_shield_volume(native_radii: Vector3) -> Vector3:
# Keep the native X/Z center and near-native footprint. The 2D game's
# ellipse needs vertical clearance for the newly extruded keel and walls.
# Measure actual model corners rather than sizing a bubble by ship class.
var max_radial := 0.0
var minimum_y := INF
var maximum_y := -INF
for point in shield_fit_points:
var delta := point - shield_center
var radial := Vector2(delta.x / native_radii.x, delta.z / native_radii.z).length()
max_radial = maxf(max_radial, radial)
minimum_y = minf(minimum_y, point.y)
maximum_y = maxf(maximum_y, point.y)
if not shield_fit_points.is_empty():
# Native FTL has no height axis. Center that presentation axis between
# the keel and the deck, with room for miniature heads above the walls.
shield_center.y = (minimum_y + maximum_y + 0.06) * 0.5
# At most 12% footprint padding: a bad native coordinate transform must
# remain detectable instead of being hidden by an enormous shell.
shield_fit_padding = minf(1.12, maxf(1.035, max_radial * 1.035))
var fitted := Vector3(native_radii.x * shield_fit_padding, native_radii.y, native_radii.z * shield_fit_padding)
for point in shield_fit_points:
var delta := point - shield_center
var radial_squared := pow(delta.x / fitted.x, 2) + pow(delta.z / fitted.z, 2)
if radial_squared < 0.9999:
var required_height := absf(delta.y) / sqrt(1.0 - radial_squared)
fitted.y = maxf(fitted.y, required_height * 1.02)
return fitted
func flash_shield() -> void:
shield_flash = 1.0
func set_viewer_position(world_position: Vector3) -> void:
viewer_position = world_position
viewer_position_valid = true
func _process(delta: float) -> void:
if not simulation_paused:
shield_flash = maxf(0.0, shield_flash - delta * 3.0)
if shield_shell != null:
var material: ShaderMaterial = shield_shell.material_override
material.set_shader_parameter("hit_flash", shield_flash)
var local_viewer := to_local(viewer_position) if viewer_position_valid else Vector3.ZERO
for id in crew_nodes:
var miniature: Node3D = crew_nodes[id]
var model: Node3D = miniature.get_node("Model")
if miniature.has_meta("target"):
var movement: Vector3 = Vector3(miniature.get_meta("target")) - miniature.position
miniature.position = miniature.position.lerp(miniature.get_meta("target"), minf(1.0, delta * 18.0))
if model is CrewModel:
model.face_movement(movement)
if model is CrewModel and viewer_position_valid:
# The inverse ship transform handles rotated, scaled and tilted
# tables. Viewer yaw follows presentation even when FTL is paused.
model.face_viewer(local_viewer - miniature.position, delta)
if not simulation_paused:
model.animate(delta)
if not simulation_paused:
for weapon in weapon_nodes:
weapon.animate(delta)
for drone in drone_nodes.values():
drone.animate(delta)
if is_instance_valid(room_hazards):
room_hazards.animate(delta)
for drone in drone_nodes.values():
# Smooth native 10 Hz samples without extrapolating an orbit or target.
drone.position = drone.position.lerp(drone.get_meta("target", drone.position), minf(1.0, delta * 18.0))
drone.rotation.y = lerp_angle(drone.rotation.y, float(drone.get_meta("target_angle", drone.rotation.y)), minf(1.0, delta * 18.0))
for door in door_nodes.values():
var opening := float(door.get_meta("opening", 0.0))
opening = move_toward(opening, 1.0 if door.get_meta("open", false) else 0.0, delta * 8.0)
door.set_meta("opening", opening)
door.get_node("Left").position.x = -0.031 - opening * 0.065
door.get_node("Right").position.x = 0.031 + opening * 0.065
func pixel_point(point: Dictionary, height: float = 0.18) -> Vector3:
return Vector3((float(point.get("x", 0)) / 35.0 - float(layout_data.get("x_offset", 0)) - layout_center.x) * TILE,
height, (float(point.get("y", 0)) / 35.0 - float(layout_data.get("y_offset", 0)) - layout_center.y) * TILE)
func apply_live(data: Dictionary) -> void:
live_data = data
room_visibility.clear()
for room in data.get("rooms", []):
room_visibility[int(room["id"])] = bool(room.get("visible", false))
# Replace the demonstration marker with actual crew; ids remain stable per run.
if is_instance_valid(demo_crew_marker):
remove_child(demo_crew_marker)
demo_crew_marker.queue_free()
demo_crew_marker = null
var present: Dictionary = {}
for crew in data.get("crew", []):
var id := str(crew["id"])
present[id] = true
if not crew_nodes.has(id):
crew_nodes[id] = _make_miniature(crew)
var miniature: Area3D = crew_nodes[id]
miniature.set_meta("target", pixel_point(crew, 0.085))
miniature.visible = not enemy or (inspect_rooms and _room_visible(int(crew["room"])))
miniature.set_meta("controllable", bool(crew.get("controllable", false)))
miniature.collision_layer = 1 if miniature.visible and bool(crew.get("controllable", false)) else 0
var label: Label3D = miniature.get_node("Name")
label.text = str(crew["name"]) + (" *" if crew.get("selected", false) else "")
label.modulate = Color(1.0, 0.4, 0.3) if int(crew.get("owner", 0)) != 0 else Color.WHITE
var model: Node3D = miniature.get_node("Model")
if model is CrewModel:
var race: String = CrewModel.canonical_species(str(crew.get("species", "human")))
if model.species != race and CrewModel.SPECIES.has(race):
model.build(race)
model.set_state(crew)
else:
model.set_live(crew)
for id in crew_nodes.keys():
if not present.has(id):
crew_nodes[id].queue_free()
crew_nodes.erase(id)
for room in data.get("rooms", []):
var id := int(room["id"])
if not room_nodes.has(id):
continue
var area: Area3D = room_nodes[id]
if data.has("room_systems"):
_set_room_role(area, str(data.get("room_systems", {}).get(str(id), "")))
for visual in area.get_children():
if visual is VisualInstance3D:
visual.visible = not enemy or inspect_rooms
# Installed systems remain useful targeting information in vanilla
# FTL when enemy crew / oxygen are obscured by the sensor fog.
var floor_mesh: MeshInstance3D = area.get_node("Floor")
var oxygen := float(room.get("oxygen", 100)) / 100.0
var tint := Color(0.9, 0.22, 0.08) if int(room.get("fires", 0)) > 0 else Color(0.65, 0.7, 0.73).lerp(Color(0.95, 0.38, 0.4), 1.0 - oxygen)
if enemy and not bool(room.get("visible", false)):
tint = Color(0.12, 0.14, 0.17)
floor_mesh.set_meta("normal_tint", tint)
if id == drop_target_room:
tint = Color(0.35, 0.95, 0.71)
if floor_mesh.material_override.albedo_color != tint:
floor_mesh.material_override.albedo_color = tint
var weapons: Array = data.get("weapons", [])
for slot in range(weapon_nodes.size()):
weapon_nodes[slot].visible = slot < weapons.size()
if slot < weapons.size():
var weapon: Node3D = weapon_nodes[slot]
if weapon.kind != WeaponModel.canonical_kind(str(weapons[slot].get("kind", "laser"))) or weapon.weapon_name != str(weapons[slot].get("name", "")):
weapon.build(weapons[slot])
weapon.set_live(weapons[slot])
_apply_doors(data.get("doors", []))
_apply_drones(data.get("drones", []))
if is_instance_valid(room_hazards):
room_hazards.set_live(self, data)
_apply_shield_shape(data.get("shield_shape", {}))
set_shields(int(data.get("shield", 0)), int(data.get("super_shield", 0)))
func _room_visible(id: int) -> bool:
return bool(room_visibility.get(id, false))
func _make_miniature(crew: Dictionary) -> Area3D:
var miniature := Area3D.new()
miniature.set_meta("kind", "crew")
miniature.set_meta("crew_id", str(crew["id"]))
miniature.set_meta("ship", "enemy" if enemy else "player")
miniature.position = pixel_point(crew, 0.085)
add_child(miniature)
var collision := CollisionShape3D.new()
var hitbox := CapsuleShape3D.new()
hitbox.radius = 0.05
hitbox.height = 0.18
collision.shape = hitbox
collision.position.y = 0.075
miniature.add_child(collision)
var model: Node3D
if bool(crew.get("is_drone", false)):
model = DroneModel.new()
var drone_data := crew.duplicate()
drone_data["is_space"] = false
model.build(drone_data)
else:
model = CrewModel.new()
model.build(str(crew.get("species", "human")))
model.name = "Model"
miniature.add_child(model)
var label := Label3D.new()
label.name = "Name"
label.font = UiAssets.font("body")
label.outline_size = 0
label.text = str(crew["name"])
label.font_size = 32
label.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
label.pixel_size = 0.001
label.position.y = 0.18
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
miniature.add_child(label)
return miniature
func weapon_origin(slot: int) -> Vector3:
return to_global(mount_points[posmod(slot, mount_points.size())])
func room_target(room_id: int) -> Vector3:
return to_global(room_points.get(room_id, Vector3(0.0, 0.18, 0.0)))
func shield_intercept(from: Vector3, target: Vector3) -> Vector3:
var local_start := (to_local(from) - shield_center) / shield_radii
var local_end := (to_local(target) - shield_center) / shield_radii
var direction := local_end - local_start
var a := direction.dot(direction)
var b := 2.0 * local_start.dot(direction)
var c := local_start.dot(local_start) - 1.0
var discriminant := b * b - 4.0 * a * c
if a < 0.00001 or discriminant < 0.0:
return target
var t := (-b - sqrt(discriminant)) / (2.0 * a)
if t < 0.0 or t > 1.0:
return target
return from.lerp(target, t)
func _material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
if color.a < 1.0:
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
return mat
func _data_path(file_name: String) -> String:
if OS.has_feature("editor"):
return ProjectSettings.globalize_path("res://local_game_data/%s" % file_name)
return OS.get_executable_path().get_base_dir().path_join("local_game_data").path_join(file_name)