First publishable version of the mod
This commit is contained in:
ElHombreAlex committed 2026-10-03 12:59:33 +02:00
commit 35cdf71054
112 files changed
+18125

No files matched your search

+326
View File
@@ -0,0 +1,326 @@
extends Node3D
const NATIVE_PRESENTATION_GRACE_MS := 300
var shots: Array[Dictionary] = []
var impacts: Array[Dictionary] = []
var simulation_paused := false
var unit_beam_mesh: CylinderMesh
func spawn_shot(event: Dictionary, sender: Node3D, receiver: Node3D) -> void:
var kind := str(event.get("kind", "laser"))
var origin_ship := sender
if event.has("origin_space") and int(event["origin_space"]) == int(receiver.get("enemy")):
origin_ship = receiver
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 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"]))
var outcome := str(event.get("outcome", "hull"))
if outcome == "shield":
end = receiver.shield_intercept(start, end)
elif outcome == "miss":
end += Vector3(0.0, 0.5, 1.0)
var color := Color(0.3, 0.75, 1.0) if kind == "ion" else Color(1.0, 0.35, 0.12)
if kind == "missile":
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)
add_child(node)
node.global_position = start
var duration := maxf(0.01, float(event.get("duration", 0.7)))
var beam_end: Vector3 = receiver.room_target(int(event.get("end_room", event.get("target_room", 0))))
if event.has("end_point"):
beam_end = receiver.to_global(receiver.pixel_point(event["end_point"]))
if kind == "beam":
if outcome == "shield":
beam_end = receiver.shield_intercept(start, beam_end)
elif kind == "bomb":
node.visible = false
# Bombs appear at their destination rather than crossing space.
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),
"live_progress": 0.0, "wanted_progress": 0.0, "missed": false,
"end_point": event.get("end_point", {}),
"origin_point": event.get("origin_point", {}), "origin_ship": origin_ship,
"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})
func _process(delta: float) -> void:
if simulation_paused:
return
for i in range(impacts.size() - 1, -1, -1):
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)
var ring := flash.get_node_or_null("ImpactRing") as MeshInstance3D
if ring != null:
ring.scale = Vector3.ONE * (1.0 + age * 3.0)
if float(impacts[i]["time"]) >= 0.22:
flash.queue_free()
impacts.remove_at(i)
for i in range(shots.size() - 1, -1, -1):
var shot: Dictionary = shots[i]
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["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"])
if not shot["target_point"].is_empty():
shot["end"] = shot["receiver"].to_global(shot["receiver"].pixel_point(shot["target_point"]))
if shot["missed"]:
shot["end"] += Vector3(0.0, 0.25, 0.5)
if not shot["end_point"].is_empty():
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))
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)
if not shot["beam_point"].is_empty():
beam_target = shot["receiver"].to_global(shot["receiver"].pixel_point(shot["beam_point"]))
var line: ImmediateMesh = node.mesh
line.clear_surfaces()
line.surface_begin(Mesh.PRIMITIVE_LINES)
line.surface_add_vertex(to_local(shot["start"]))
line.surface_add_vertex(to_local(beam_target))
line.surface_end()
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"])
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 shot["outcome"] == "shield":
shot["receiver"].flash_shield()
if shot["outcome"] not in ["miss", "pending"]:
_impact(shot["beam_end"] if shot["kind"] == "beam" else shot["end"], shot["kind"], shot["outcome"] == "shield")
node.queue_free()
shots.remove_at(i)
func resolve_live(event: Dictionary, receiver: Node3D) -> void:
var projectile_id := str(event.get("projectile_id", ""))
var point: Vector3 = receiver.to_global(receiver.pixel_point(event.get("target", {})))
if event.get("outcome", "") == "beam":
_impact(point, "beam")
return
var kind := str(event.get("kind", "laser"))
for i in range(shots.size() - 1, -1, -1):
if shots[i]["projectile_id"] == projectile_id:
kind = str(shots[i]["kind"])
if not event.has("target"):
point = shots[i]["end"]
if event.get("outcome", "") == "shield":
point = receiver.shield_intercept(shots[i]["start"], point)
shots[i]["node"].queue_free()
shots.remove_at(i)
if event.get("outcome", "") == "shield":
receiver.flash_shield()
if event.get("outcome", "") in ["shield", "hull"]:
_impact(point, kind, event.get("outcome", "") == "shield")
func update_live_projectiles(projectiles: Array) -> void:
var present: Dictionary = {}
for projectile in projectiles:
present[str(projectile["id"])] = projectile
for i in range(shots.size() - 1, -1, -1):
var shot: Dictionary = shots[i]
if shot["outcome"] != "pending":
continue
if present.has(shot["projectile_id"]):
var projectile: Dictionary = present[shot["projectile_id"]]
shot["native_present"] = true
shot["wanted_progress"] = float(projectile.get("progress", 0))
shot["missed"] = bool(projectile.get("missed", false))
shot["beam_point"] = projectile.get("beam_point", {})
elif shot["native_present"] or Time.get_ticks_msec() - int(shot["created_ms"]) >= NATIVE_PRESENTATION_GRACE_MS:
# Native absence ends a known projectile immediately. Newly delivered
# fire records get a short presentation grace for the next snapshot;
# its monotonic clock keeps working while gameplay is paused.
shot["node"].queue_free()
shots.remove_at(i)
func _impact(point: Vector3, kind: String = "laser", shield: bool = false) -> void:
var flash := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.025 if kind not in ["missile", "bomb"] else 0.04
sphere.height = sphere.radius * 2.0
sphere.radial_segments = 10
sphere.rings = 5
flash.mesh = sphere
var color := Color(0.32, 0.73, 1.0) if shield or kind == "ion" else Color(1.0, 0.78, 0.28)
flash.material_override = _glow(color)
flash.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var ring := MeshInstance3D.new()
ring.name = "ImpactRing"
var torus := TorusMesh.new()
torus.inner_radius = 0.024
torus.outer_radius = 0.028
torus.rings = 16
torus.ring_segments = 6
ring.mesh = torus
ring.material_override = _glow(Color(color, 0.45), true)
ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
flash.add_child(ring)
if kind in ["missile", "bomb"]:
for i in range(6):
var spark := _box(Vector3(0.007, 0.007, 0.025), Color(1.0, 0.38, 0.05))
var direction := Vector3(cos(i * TAU / 6.0), 0.3, sin(i * TAU / 6.0))
spark.position = direction * 0.04
spark.basis = Basis.looking_at(direction, Vector3.UP)
flash.add_child(spark)
add_child(flash)
flash.global_position = point
impacts.append({"node": flash, "time": 0.0})
func _projectile_visual(kind: String, color: Color) -> MeshInstance3D:
var node := MeshInstance3D.new()
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
node.material_override = _glow(color)
match kind:
"beam":
node.mesh = ImmediateMesh.new()
if unit_beam_mesh == null:
unit_beam_mesh = CylinderMesh.new()
unit_beam_mesh.height = 1.0
unit_beam_mesh.top_radius = 1.0
unit_beam_mesh.bottom_radius = 1.0
unit_beam_mesh.radial_segments = 8
for part in ["BeamCore", "BeamGlow"]:
var cylinder := MeshInstance3D.new()
cylinder.name = part
cylinder.mesh = unit_beam_mesh
cylinder.material_override = _glow(Color(1.0, 0.94, 0.67) if part == "BeamCore" else Color(1.0, 0.35, 0.1, 0.2), part == "BeamGlow")
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.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
nose.position.z = -0.128
nose.rotation.x = -PI / 2.0
nose.material_override = _solid(Color(0.73, 0.17, 0.08))
node.add_child(nose)
for axis in [Vector3.RIGHT, Vector3.UP]:
var fin := _box(Vector3(0.14, 0.012, 0.065) if axis == Vector3.RIGHT else Vector3(0.012, 0.12, 0.065), Color(0.27, 0.3, 0.33), false)
fin.position.z = 0.068
node.add_child(fin)
var exhaust := _box(Vector3(0.035, 0.035, 0.12), Color(1.0, 0.36, 0.08, 0.65))
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
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
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
node.material_override = _solid(color)
_:
var core := BoxMesh.new()
core.size = Vector3(0.028, 0.028, 0.26)
node.mesh = core
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
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.material_override = _glow(color, color.a < 1.0) if luminous else _solid(color)
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
return node
func _glow(color: Color, additive: bool = false) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.albedo_color = color
material.emission_enabled = true
material.emission = Color(color, 1.0)
if additive:
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
return material
func _solid(color: Color) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.albedo_color = color
material.roughness = 0.82
return material
func _segment(node: MeshInstance3D, start: Vector3, end: Vector3, radius: float) -> void:
var direction := end - start
var length := direction.length()
node.visible = length > 0.00001
if not node.visible:
return
var axis := direction / length
var reference := Vector3.RIGHT if absf(axis.dot(Vector3.UP)) > 0.98 else Vector3.UP
var side := reference.cross(axis).normalized()
# A shared unit cylinder stays immutable. Transforming it avoids rebuilding
# two procedural meshes on every beam sweep frame.
node.global_basis = Basis(side * radius, axis * length, side.cross(axis) * radius)
node.global_position = (start + end) * 0.5
+1
View File
@@ -0,0 +1 @@
uid://dhy7cqbe4ytgj
+150
View File
@@ -0,0 +1,150 @@
extends Node3D
const UI = preload("res://scripts/ftl_ui_theme.gd")
# An original vector schematic, rendered once. It labels the active bindings
# rather than reproducing controller industrial design or extracted artwork.
var surface_size := Vector2(0.88, 0.53)
var render_priority := 122
var viewport: SubViewport
var canvas: Control
func _ready() -> void:
viewport = SubViewport.new()
viewport.size = Vector2i(1000, 600)
viewport.transparent_bg = true
viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
add_child(viewport)
canvas = HelpCanvas.new()
canvas.size = Vector2(1000, 600)
canvas.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
viewport.add_child(canvas)
var display := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = surface_size
display.mesh = quad
display.position.y = 0.03
display.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.no_depth_test = true
material.render_priority = render_priority
material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
material.albedo_texture = viewport.get_texture()
display.material_override = material
add_child(display)
class HelpCanvas extends Control:
const INK := UI.INK
const GREEN := UI.GREEN
const MUTED := UI.MUTED
const DARK := UI.DARK
var font: Font = UI.ASSETS.font("body")
func _label(value: String, at: Vector2, font_size: int = 20, color: Color = INK) -> void:
draw_string(font,at,value,HORIZONTAL_ALIGNMENT_LEFT,-1.0,roundi(font_size * 1.45),color)
func _leader(pin: Vector2, edge: Vector2, channel_x: float) -> void:
var points := PackedVector2Array([pin, Vector2(channel_x, pin.y), Vector2(channel_x, edge.y), edge])
draw_polyline(points, MUTED, 2.0, true)
draw_circle(pin, 4.0, GREEN)
func _callout(title: String, lines: Array, at: Vector2, pin: Vector2, left_side: bool) -> void:
_label(title, at, 20, GREEN)
for i in range(lines.size()):
_label(str(lines[i]), at + Vector2(0, 22 * (i + 1)), 19)
var edge := Vector2(199 if left_side else 771, at.y + 5)
_leader(pin, edge, 210 if left_side else 750)
func _controller(center_x: float, right: bool) -> void:
var outline := PackedVector2Array([
Vector2(center_x - 67, 153), Vector2(center_x - 52, 136),
Vector2(center_x - 24, 124), Vector2(center_x + 31, 132),
Vector2(center_x + 61, 152), Vector2(center_x + 72, 202),
Vector2(center_x + 44, 241), Vector2(center_x + 38, 321),
Vector2(center_x + 20, 350), Vector2(center_x - 23, 350),
Vector2(center_x - 43, 320), Vector2(center_x - 46, 240),
Vector2(center_x - 68, 199)])
draw_colored_polygon(outline, Color(0.065, 0.125, 0.139))
var border := outline.duplicate()
border.append(outline[0])
draw_polyline(border, INK, 3.0, true)
for line in range(4):
draw_line(Vector2(center_x - 26, 299 + line * 10), Vector2(center_x + 24, 299 + line * 10), MUTED, 2.0, true)
var stick := Vector2(center_x - 17, 185)
draw_circle(stick, 26, DARK)
draw_arc(stick, 26, 0, TAU, 32, GREEN, 3.0, true)
draw_circle(stick, 11, MUTED)
_label("STICK", stick + Vector2(-26, -36), 16, MUTED)
if right:
_button(Vector2(center_x - 14, 250), "B")
_button(Vector2(center_x + 31, 264), "A")
_button(Vector2(center_x - 39,222),"X",14)
_button(Vector2(center_x + 5,216),"Y",14)
draw_line(Vector2(center_x - 28, 127), Vector2(center_x + 10, 132), GREEN, 6.0, true)
else:
draw_line(Vector2(center_x - 28,127),Vector2(center_x + 10,132),GREEN,6.0,true)
var pad := Vector2(center_x + 22, 252)
draw_rect(Rect2(pad - Vector2(12, 34), Vector2(24, 68)), DARK)
draw_rect(Rect2(pad - Vector2(34, 12), Vector2(68, 24)), DARK)
draw_line(pad + Vector2(-24, 0), pad + Vector2(24, 0), GREEN, 4.0, true)
draw_line(pad + Vector2(0, -24), pad + Vector2(0, 24), GREEN, 4.0, true)
draw_rect(Rect2(center_x - 36, 276, 24, 16), MUTED, false, 2.0)
_label("VIEW", Vector2(center_x - 49, 268), 15, MUTED)
func _button(at: Vector2, text: String, radius: float = 18.0) -> void:
draw_circle(at, radius, DARK)
draw_arc(at, radius, 0, TAU, 24, GREEN, 2.0, true)
_label(text, at + Vector2(-7, 7), 21, GREEN)
func _badge(text: String, at: Vector2, width: float = 32.0) -> void:
draw_rect(Rect2(at + Vector2(0, -20), Vector2(width, 28)), Color(0.08, 0.2, 0.19))
draw_rect(Rect2(at + Vector2(0, -20), Vector2(width, 28)), MUTED, false, 1.0)
_label(text, at + Vector2(7, 1), 20, GREEN)
func _draw() -> void:
UI.frame(self,Rect2(2,2,996,596),DARK,UI.EDGE,14)
UI.text(self,"STEAM FRAME CONTROLS",Vector2(28,14),30,INK,"header")
UI.text(self,"BINDING DIAGRAM",Vector2(785,23),24,MUTED)
draw_line(Vector2(25, 58), Vector2(975, 58), MUTED, 2.0)
draw_line(Vector2(500, 78), Vector2(500, 470), MUTED, 1.0)
_label("LEFT / TABLE + NAVIGATION", Vector2(28, 91), 22, GREEN)
_label("RIGHT / POINT + ACT", Vector2(526, 91), 22, GREEN)
_controller(290, false)
_controller(660, true)
_callout("TRIGGER", ["Power page: + power", "Else: stations"], Vector2(28, 132), Vector2(301, 149), true)
_callout("STICK", ["Rotate / scale", "Click: recenter"], Vector2(28, 212), Vector2(273, 185), true)
_callout("GRIP", ["Move tabletop"], Vector2(28, 305), Vector2(222, 253), true)
_callout("BUMPER", ["Hold: 1-8 wheel", "Release: confirm"], Vector2(784, 119), Vector2(646, 128), false)
_callout("TRIGGER", ["Click / hold crew", "Drop / beam drag"], Vector2(784, 210), Vector2(694, 149), false)
_callout("GRIP", ["Hold for SHIFT"], Vector2(784, 305), Vector2(718, 253), false)
_label("DPAD / VIEW", Vector2(28, 369), 19, MUTED)
_leader(Vector2(312, 252), Vector2(431, 366), 434)
_badge("UP", Vector2(28, 394), 49)
_label("Shortcuts", Vector2(91, 394), 20)
_badge("VIEW",Vector2(296,394),72)
_label("Pause",Vector2(380,394),20)
_badge("L / R", Vector2(28, 425), 72)
_label("Previous / next screen", Vector2(112, 425), 20)
_badge("DOWN", Vector2(28, 456), 80)
_label("Tactical screen toggle", Vector2(121, 456), 20)
_badge("A", Vector2(526, 394))
_label("Inspect rooms / wheel category", Vector2(568, 394), 20)
_badge("B", Vector2(526, 425))
_label("Right-click / cancel grab or wheel", Vector2(568, 425), 19)
_label("STICK: wheel selection / click: CTRL", Vector2(526, 456), 20)
draw_line(Vector2(25, 480), Vector2(975, 480), MUTED, 2.0)
_label("SHIFT: add crew / depower weapons and systems", Vector2(28, 508), 19, GREEN)
_label("SHIFT + L trigger: save stations | CTRL: reverse autofire", Vector2(28, 534), 19)
_label("POWER PAGE: L trigger adds power / L bumper removes power", Vector2(28, 560), 19, GREEN)
_label("POWER PAGE: RIGHT X / Y select previous / next, or point at a system", Vector2(28, 586), 19)
+281
View File
@@ -0,0 +1,281 @@
extends Node3D
# The panel owns visuals only. Legacy navigation Area3Ds can retain their exact
# geometry; power cards are picked on this surface without physics collisions.
const UI = preload("res://scripts/ftl_ui_theme.gd")
# Native RenderPowerBoxes uses an orange battery outline, yellow bonus power,
# cyan ion locks and purple hacking. The allocated/battery/bonus fields are
# separate in ShipSystem; GetEffectivePower also includes native manning boosts.
const BATTERY := Color("e66e1e")
const BONUS := Color("fffa5a")
const ION_POWER := Color("85e7ed")
const HACKED_POWER := Color("cf46fd")
const SIZE := Vector2(0.92, 0.72)
const PIXELS := Vector2(1150, 900)
const SYSTEM_NAMES := {"shields":"SHIELDS", "engines":"ENGINES", "oxygen":"OXYGEN", "medbay":"MEDBAY", "clonebay":"CLONE BAY", "weapons":"WEAPONS", "drones":"DRONES", "teleporter":"TELEPORTER", "cloaking":"CLOAKING", "mind":"MIND CONTROL", "hacking":"HACKING", "artillery":"ARTILLERY", "battery":"BATTERY"}
const SYSTEM_ORDER := ["shields","engines","oxygen","medbay","clonebay","weapons","drones","teleporter","cloaking","mind","hacking","artillery","battery"]
var surface_size := SIZE
var viewport: SubViewport
var canvas: Control
var heading := "NAVIGATION"
var buttons: Array = []
var power_rows: Array = []
var reactor: Dictionary = {}
var selected_key := ""
var power_mode := false
var paused := false
var content_revision := 0
func _ready() -> void:
viewport = SubViewport.new()
viewport.size = Vector2i(PIXELS)
viewport.transparent_bg = true
viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
add_child(viewport)
canvas = PanelCanvas.new()
canvas.panel = self
canvas.size = PIXELS
canvas.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
viewport.add_child(canvas)
var display := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = SIZE
display.mesh = quad
display.position.z = 0.012
display.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.no_depth_test = true
material.render_priority = 121
material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
material.albedo_texture = viewport.get_texture()
display.material_override = material
add_child(display)
func _redraw() -> void:
content_revision += 1
if canvas != null:
canvas.queue_redraw()
viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
func set_navigation(title: String, entries: Array, is_paused: bool = false) -> void:
if not power_mode and heading == title and buttons == entries and paused == is_paused:
return
heading = title
buttons = entries.duplicate(true)
paused = is_paused
power_mode = false
_redraw()
func set_power(player: Dictionary, is_paused: bool = false) -> void:
var rows := _power_rows(player)
var raw_power: Variant = player.get("reactor_status", player.get("reactor", {}))
var power: Dictionary = raw_power if raw_power is Dictionary else {}
if power_mode and rows == power_rows and power == reactor and paused == is_paused:
return
power_rows = rows
reactor = power.duplicate(true)
power_mode = true
paused = is_paused
heading = "SYSTEM POWER"
if selected_system().is_empty():
selected_key = str(power_rows[0].get("key", "")) if not power_rows.is_empty() else ""
_redraw()
func _power_rows(player: Dictionary) -> Array:
var value: Variant = player.get("system_status", player.get("system_power", []))
var result: Array = []
if value is Array:
for row in value:
if row is Dictionary and row.get("powerable", true):
var copy: Dictionary = row.duplicate(true)
copy.key = str(copy.get("key", copy.get("name", "")))
copy.name = str(copy.get("label", SYSTEM_NAMES.get(copy.key,copy.key.to_upper())))
result.append(copy)
else:
# Compatibility with an older bridge: only real power details are used;
# boolean system-presence flags must never invent allocated power.
var systems: Dictionary = player.get("systems", {})
for key in SYSTEM_ORDER:
var row: Variant = systems.get(key, null)
if row is Dictionary and row.get("powerable", true):
var copy: Dictionary = row.duplicate(true)
copy.key = key
copy.name = SYSTEM_NAMES.get(key, key.to_upper())
result.append(copy)
return result
func select_system(key: String) -> void:
if selected_key == key:
return
for row in power_rows:
if str(row.get("key", "")) == key:
selected_key = key
_redraw()
return
func step_selection(direction: int) -> void:
if power_rows.is_empty():
return
var index := 0
for i in range(power_rows.size()):
if str(power_rows[i].key) == selected_key:
index = i
break
select_system(str(power_rows[posmod(index + direction,power_rows.size())].key))
func selected_system() -> Dictionary:
for row in power_rows:
if str(row.get("key", "")) == selected_key:
return row
return {}
func power_card_rect(index: int) -> Rect2:
var count := maxi(1,power_rows.size())
var rows_per_column := ceili(count / 2.0)
var height := minf(106.0, 642.0 / rows_per_column)
return Rect2(190 + floori(float(index) / rows_per_column) * 450, 140 + (index % rows_per_column) * height, 422, height - 10)
func ray_hit(from: Vector3, direction: Vector3) -> Dictionary:
if not is_visible_in_tree():
return {}
var start := to_local(from)
var ray := to_local(from + direction) - start
if absf(ray.z) < 0.00001:
return {}
var distance := (0.012 - start.z) / ray.z
if distance <= 0:
return {}
var point := start + distance * ray
var pixel := Vector2(point.x / SIZE.x + 0.5,0.5 - point.y / SIZE.y) * PIXELS
if not Rect2(Vector2.ZERO,PIXELS).has_point(pixel):
return {}
var hit := {"position":to_global(point), "system_key":""}
if power_mode:
for i in range(power_rows.size()):
if power_card_rect(i).has_point(pixel):
hit.system_key = str(power_rows[i].key)
break
return hit
class PanelCanvas extends Control:
var panel: Node3D
var icons: Dictionary = {}
var base_path := ""
func _ready() -> void:
base_path = ProjectSettings.globalize_path("res://local_game_data") if OS.has_feature("editor") else OS.get_executable_path().get_base_dir().path_join("local_game_data")
func _icon(key: String) -> Texture2D:
if not icons.has(key):
var path := base_path.path_join("ui/img/icons/s_%s.png" % key)
icons[key] = ImageTexture.create_from_image(Image.load_from_file(path)) if FileAccess.file_exists(path) else null
return icons[key]
func _draw() -> void:
UI.frame(self,Rect2(3,3,1144,894),UI.DARK,UI.EDGE,5)
UI.text(self,panel.heading,Vector2(32,29),32,UI.INK,"header")
draw_line(Vector2(28,85),Vector2(1122,85),UI.MUTED,1)
if panel.paused:
UI.centered_text(self,"PAUSED",Vector2(1024,46),25,UI.GREEN,"header")
if panel.power_mode:
_draw_power()
else:
var low_buttons := false
for item in panel.buttons:
var point: Vector2 = item.get("position",Vector2.ZERO)
low_buttons = low_buttons or point.y < -.25
var center := Vector2(point.x / SIZE.x + .5,.5 - point.y / SIZE.y) * PIXELS
var rect := Rect2(center - Vector2(250,41.875),Vector2(500,83.75))
UI.button(self,rect,str(item.get("label","")),item.get("enabled",true))
if low_buttons:
UI.centered_text(self,"DPAD L/R: SCREEN DOWN: TACTICAL VIEW: PAUSE",Vector2(575,877),22,UI.MUTED)
else:
draw_line(Vector2(38,829),Vector2(1112,829),UI.MUTED,1)
UI.centered_text(self,"DPAD L/R: SCREEN DOWN: TACTICAL VIEW: PAUSE",Vector2(575,850),24,UI.MUTED)
UI.centered_text(self,"LEFT TRIGGER: STATIONS RIGHT BUMPER: ACTION WHEEL",Vector2(575,877),22,UI.INK)
func _draw_power() -> void:
UI.text(self,"REACTOR",Vector2(31,108),27,UI.INK)
var total := clampi(int(panel.reactor.get("total",panel.reactor.get("max",0))),0,50)
var available := clampi(int(panel.reactor.get("available",panel.reactor.get("current",0))),0,total)
UI.frame(self,Rect2(41,145,103,610),UI.PANEL,UI.EDGE,9)
var pitch := minf(20,562.0 / maxi(1,total))
for cell in range(total):
var rect := Rect2(57,731 - cell * pitch,71,pitch - 3)
draw_rect(rect,UI.GREEN if cell < available else UI.DARK)
draw_rect(rect,UI.EDGE,false,1)
UI.centered_text(self,"%d/%d" % [available,total],Vector2(94,779),32,UI.GREEN)
UI.centered_text(self,"FREE",Vector2(94,809),22,UI.MUTED)
for i in range(panel.power_rows.size()):
var row: Dictionary = panel.power_rows[i]
var rect: Rect2 = panel.power_card_rect(i)
var active: bool = str(row.key) == panel.selected_key
UI.frame(self,rect,UI.SELECTED if active else UI.PANEL,UI.GOLD if active else UI.EDGE,9)
var texture := _icon(str(row.key))
if texture != null:
draw_texture_rect(texture,Rect2(rect.position + Vector2(15,13),Vector2(42,42)),false,UI.GOLD if active else UI.INK)
else:
draw_rect(Rect2(rect.position + Vector2(18,18),Vector2(34,34)),UI.MUTED,false,2)
UI.centered_text(self,str(row.key).substr(0,1),rect.position + Vector2(35,35),30,UI.INK)
UI.text(self,str(row.name),rect.position + Vector2(68,12),30,UI.GOLD if active else UI.INK)
var capacity := clampi(int(row.get("max_power",row.get("max",0))),0,16)
var allocated := clampi(int(row.get("allocated",row.get("power",0))),0,capacity)
var battery := clampi(int(row.get("battery_power",0)),0,capacity)
var bonus := clampi(int(row.get("bonus_power",0)),0,capacity)
var effective := maxi(0,int(row.get("effective_power",allocated + battery + bonus)))
var damaged := clampi(int(row.get("damaged",0)),0,capacity)
var cap := clampi(int(row.get("power_cap",capacity)),0,capacity)
var ionized := bool(row.get("ionized",false))
var locked := bool(row.get("locked",false))
var hacked := int(row.get("hacked",0)) > 1
var bar_y := rect.end.y - 30
var cell_width := minf(22,(250.0 - maxi(0,capacity - 1) * 3) / maxi(1,capacity))
for cell in range(capacity):
var bar := Rect2(rect.position.x + 68 + cell * (cell_width + 3),bar_y,cell_width,15)
var color := UI.DARK
var outline := UI.EDGE
var battery_cell := cell >= allocated and cell < allocated + battery
if cell < allocated:
color = HACKED_POWER if hacked else ION_POWER if ionized else UI.INK if locked else UI.GREEN
elif battery_cell:
outline = BATTERY
elif cell < allocated + battery + bonus:
color = BONUS
elif cell >= capacity - damaged:
color = UI.RED
elif cell >= cap:
outline = ION_POWER
draw_rect(bar,color)
if battery_cell:
draw_rect(bar.grow(-3),UI.GREEN)
draw_rect(bar,outline,false,1)
# Keep the native effective total; do not count battery twice or debit
# reactor availability for Zoltan power. Raw selected rows are untouched.
UI.text(self,"%d/%d" % [effective,capacity],Vector2(rect.end.x - 75,bar_y - 2),22,UI.INK)
if hacked or ionized or locked:
UI.text(self,"HACK" if hacked else "LOCK",Vector2(rect.end.x - 75,rect.position.y + 16),20,HACKED_POWER if hacked else ION_POWER if ionized else UI.INK)
if panel.power_rows.is_empty():
UI.centered_text(self,"WAITING FOR LIVE SYSTEM STATUS",Vector2(661,440),30,UI.MUTED)
for source in [[250,"REACTOR",UI.GREEN],[445,"BATTERY",BATTERY],[640,"ZOLTAN",BONUS],[826,"DAMAGED",UI.RED]]:
draw_rect(Rect2(int(source[0]),791,12,12),source[2])
UI.text(self,str(source[1]),Vector2(int(source[0]) + 22,788),22,source[2])
draw_line(Vector2(185,814),Vector2(1107,814),UI.MUTED,1)
UI.centered_text(self,"L TRIGGER: + POWER L BUMPER: - POWER",Vector2(661,842),28,UI.INK)
UI.centered_text(self,"RIGHT X/Y: PREV/NEXT POINT: SELECT SYSTEM",Vector2(661,873),28,UI.GOLD)
+85
View File
@@ -0,0 +1,85 @@
extends Node3D
const UI = preload("res://scripts/ftl_ui_theme.gd")
const WIDTH := 0.64
const HEIGHT := 0.10
const PIXELS := Vector2i(770,130)
const HULL_COLOR := Color("85ff79")
var viewport: SubViewport
var canvas: Control
var plate: MeshInstance3D
var hull := 0
var hull_max := 0
var ship_label := "HOSTILE"
var signature := ""
func build() -> void:
viewport = SubViewport.new()
viewport.size = PIXELS
viewport.transparent_bg = true
viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
add_child(viewport)
canvas = Control.new()
canvas.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
canvas.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
canvas.draw.connect(_draw)
viewport.add_child(canvas)
plate = MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = Vector2(WIDTH, HEIGHT)
plate.mesh = quad
var material := StandardMaterial3D.new()
material.albedo_texture = viewport.get_texture()
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
plate.material_override = material
plate.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(plate)
visible = false
func set_state(data: Dictionary) -> void:
hull = maxi(0, int(data.get("hull", 0)))
hull_max = maxi(0, int(data.get("hull_max", 0)))
ship_label = str(data.get("name", "HOSTILE")).to_upper()
if ship_label.is_empty():
ship_label = "HOSTILE"
visible = not data.is_empty() and hull_max > 0 and not bool(data.get("destroyed", false))
var next := "%d:%d:%s" % [hull, hull_max, ship_label]
if next == signature:
return
signature = next
canvas.queue_redraw()
viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
func face_viewer(viewer: Vector3) -> void:
if visible and global_position.distance_squared_to(viewer) > 0.0001:
look_at(viewer, Vector3.UP, true)
func _draw() -> void:
var fraction := clampf(float(hull) / maxf(hull_max, 1), 0.0, 1.0)
var frame := UI.ASSETS.texture("img/statusUI/top_hull_red.png" if fraction <= .33 else "img/statusUI/top_hull.png")
var mask := UI.ASSETS.texture("img/statusUI/top_hull_bar_mask.png")
var label := UI.ASSETS.texture("img/statusUI/top_hull_red_label.png" if fraction <= .33 else "img/statusUI/top_hull_label.png")
# Use the actual vanilla stepped contour and segmented mask. Only owner-local
# textures are read; no FTL imagery is embedded in the distributable mod.
if frame != null and mask != null and label != null:
canvas.draw_texture_rect(mask,Rect2(34,0,720,130),false,Color("253b32"))
if fraction > 0:
canvas.draw_texture_rect_region(mask,Rect2(34,0,720 * fraction,130),Rect2(0,0,360 * fraction,65),HULL_COLOR if fraction > .33 else UI.RED)
canvas.draw_texture_rect(frame,Rect2(0,0,770,130),false)
canvas.draw_texture_rect(label,Rect2(18,0,104,56),false)
else:
# Missing owned art still leaves a readable small bar during setup.
var outline := PackedVector2Array([Vector2(16,80),Vector2(16,42),Vector2(255,42),Vector2(267,32),Vector2(496,32),Vector2(506,24),Vector2(755,24),Vector2(755,80),Vector2(16,80)])
canvas.draw_colored_polygon(outline,UI.DARK)
canvas.draw_polyline(outline,UI.EDGE,2)
for i in range(30):
canvas.draw_rect(Rect2(26 + i * 24,52,21,22),HULL_COLOR if float(i) / 30 < fraction else Color("253b32"))
UI.text(canvas,"HULL",Vector2(28,4),29,UI.DARK,"header")
UI.text(canvas,"%d / %d" % [hull,hull_max],Vector2(639,0),28,UI.INK)
+122
View File
@@ -0,0 +1,122 @@
extends RefCounted
# Owner-local SIL bitmap fonts. Preserve the original atlas, baseline, glyph
# bearings and 1/256-pixel advance; no editor import or installed font is needed.
const FONT_NAMES := {"body":"JustinFont10.font", "header":"c&c.font", "bold":"JustinFont11Bold.font"}
static var fonts: Dictionary = {}
static var textures: Dictionary = {}
static func data_path() -> String:
if OS.has_feature("editor") or OS.get_cmdline_user_args().has("--desktop"):
return ProjectSettings.globalize_path("res://local_game_data")
return OS.get_executable_path().get_base_dir().path_join("local_game_data")
static func font(role: String = "body") -> Font:
var name: String = FONT_NAMES.get(role,FONT_NAMES.body)
if not fonts.has(name):
var native := load_font(data_path().path_join("fonts").path_join(name))
fonts[name] = native if native != null else ThemeDB.fallback_font
return fonts[name]
static func texture(relative_path: String) -> Texture2D:
if not textures.has(relative_path):
var path := data_path().path_join("ui").path_join(relative_path)
var image := Image.load_from_file(path) if FileAccess.file_exists(path) else null
textures[relative_path] = ImageTexture.create_from_image(image) if image != null and not image.is_empty() else null
return textures[relative_path]
static func _be16(bytes: PackedByteArray, at: int) -> int:
return (int(bytes[at]) << 8) | int(bytes[at + 1])
static func _be32(bytes: PackedByteArray, at: int) -> int:
return (_be16(bytes,at) << 16) | _be16(bytes,at + 2)
static func _signed(value: int, bits: int) -> int:
return value - (1 << bits) if value & (1 << (bits - 1)) else value
static func load_font(path: String) -> FontFile:
if not FileAccess.file_exists(path): return null
var bytes := FileAccess.get_file_as_bytes(path)
if bytes.size() < 24 or bytes.slice(0,4).get_string_from_ascii() != "FONT" or bytes[4] != 1: return null
var height := int(bytes[5])
var baseline := int(bytes[6])
var info := _be32(bytes,8)
var count := _be16(bytes,12)
var stride := _be16(bytes,14)
var tex := _be32(bytes,16)
var tex_length := _be32(bytes,20)
if height < 1 or baseline > height or stride != 16 or count < 1 or info < 24 or info + count * stride > bytes.size(): return null
if tex < 24 or tex_length < 32 or tex + tex_length > bytes.size(): return null
# FTL's western fonts contain a SIL v2, uncompressed A8 texture (0x40).
if bytes.slice(tex,tex + 4).get_string_from_ascii() != "TEX\n" or bytes[tex + 4] != 2 or bytes[tex + 5] != 0x40: return null
var width := _be16(bytes,tex + 8)
var atlas_height := _be16(bytes,tex + 10)
var pixels := _be32(bytes,tex + 16)
var pixel_count := _be32(bytes,tex + 20)
if width < 1 or atlas_height < 1 or width > 2048 or atlas_height > 2048 or pixel_count != width * atlas_height or pixels < 32 or pixels + pixel_count > tex_length: return null
# Validate all glyph bounds before mutating the font cache.
for i in range(count):
var offset := info + i * stride
if _be32(bytes,offset) > 0x10ffff or _be16(bytes,offset + 4) + int(bytes[offset + 8]) > width or _be16(bytes,offset + 6) + int(bytes[offset + 9]) > atlas_height: return null
var rgba := PackedByteArray()
rgba.resize(pixel_count * 4)
for i in range(pixel_count):
rgba[i * 4] = 255
rgba[i * 4 + 1] = 255
rgba[i * 4 + 2] = 255
rgba[i * 4 + 3] = bytes[tex + pixels + i]
var source_image := Image.create_from_data(width,atlas_height,false,Image.FORMAT_RGBA8,rgba)
# Godot's 3D glyph quads need transparent margins around a narrow stroke.
# Repack original pixels without changing bearings/advances; a stroke at the
# right edge of a 2px I/i/l glyph otherwise disappears in Label3D sampling.
const PADDING := 2
var atlas_width := 512
var uv_rects: Array[Rect2i] = []
var cursor := Vector2i.ZERO
var row_height := 0
for i in range(count):
var offset := info + i * stride
var padded := Vector2i(int(bytes[offset + 8]),int(bytes[offset + 9])) + Vector2i.ONE * (PADDING * 2)
if cursor.x + padded.x > atlas_width:
cursor = Vector2i(0,cursor.y + row_height)
row_height = 0
uv_rects.append(Rect2i(cursor,padded))
cursor.x += padded.x
row_height = maxi(row_height,padded.y)
var padded_height := 1
while padded_height < cursor.y + row_height: padded_height <<= 1
var atlas := Image.create(atlas_width,padded_height,false,Image.FORMAT_RGBA8)
atlas.fill(Color(1,1,1,0))
for i in range(count):
var offset := info + i * stride
var original := Rect2i(_be16(bytes,offset + 4),_be16(bytes,offset + 6),int(bytes[offset + 8]),int(bytes[offset + 9]))
if original.size.x > 0 and original.size.y > 0:
atlas.blit_rect(source_image,original,uv_rects[i].position + Vector2i.ONE * PADDING)
var result := FontFile.new()
result.font_name = path.get_file().get_basename()
result.fixed_size = height
result.fixed_size_scale_mode = TextServer.FIXED_SIZE_SCALE_ENABLED
result.antialiasing = TextServer.FONT_ANTIALIASING_NONE
result.allow_system_fallback = false
result.set_cache_ascent(0,height,baseline)
result.set_cache_descent(0,height,height - baseline)
result.set_texture_image(0,Vector2i(height,0),0,atlas)
for i in range(count):
var offset := info + i * stride
var codepoint := _be32(bytes,offset)
var glyph_size := Vector2(int(bytes[offset + 8]),int(bytes[offset + 9]))
var pre := _signed(_be16(bytes,offset + 12),16) / 256.0
var post := _signed(_be16(bytes,offset + 14),16) / 256.0
result.set_glyph_advance(0,height,codepoint,Vector2(pre + glyph_size.x + post,0))
result.set_glyph_offset(0,Vector2i(height,0),codepoint,Vector2(pre - PADDING,-_signed(int(bytes[offset + 10]),8) - PADDING))
result.set_glyph_size(0,Vector2i(height,0),codepoint,glyph_size + Vector2.ONE * (PADDING * 2))
result.set_glyph_uv_rect(0,Vector2i(height,0),codepoint,Rect2(uv_rects[i]))
result.set_glyph_texture_idx(0,Vector2i(height,0),codepoint,0)
return result
+47
View File
@@ -0,0 +1,47 @@
extends RefCounted
const ASSETS = preload("res://scripts/ftl_ui_assets.gd")
# Native FTL uses near-black panels, pale outlines and restrained green/gold.
const INK := Color("e0ead4")
const EDGE := Color("a2b5a5")
const GREEN := Color("7fff74")
const MUTED := Color("70857d")
const DARK := Color("081012")
const PANEL := Color("102023")
const SELECTED := Color("20352c")
const GOLD := Color("ffe89a")
const RED := Color("ed6260")
const ION := Color("7bb8ff")
static func text_size(value: String, font_size: int, role: String = "body") -> Vector2:
var font := ASSETS.font(role)
return Vector2(font.get_string_size(value,HORIZONTAL_ALIGNMENT_LEFT,-1,font_size).x,font.get_height(font_size))
static func text(canvas: CanvasItem, value: String, at: Vector2, font_size: int = 28, color: Color = INK, role: String = "body") -> void:
var font := ASSETS.font(role)
canvas.draw_string(font,(at + Vector2(0,font.get_ascent(font_size))).round(),value,HORIZONTAL_ALIGNMENT_LEFT,-1,font_size,color)
static func centered_text(canvas: CanvasItem, value: String, center: Vector2, font_size: int = 28, color: Color = INK, role: String = "body") -> void:
text(canvas,value,center - text_size(value,font_size,role) * .5,font_size,color,role)
static func clipped_rect(rect: Rect2, cut: float = 5.0) -> PackedVector2Array:
var p := rect.position
var q := rect.end
return PackedVector2Array([Vector2(p.x + cut,p.y),Vector2(q.x - cut,p.y),Vector2(q.x,p.y + cut),Vector2(q.x,q.y - cut),Vector2(q.x - cut,q.y),Vector2(p.x + cut,q.y),Vector2(p.x,q.y - cut),Vector2(p.x,p.y + cut)])
static func frame(canvas: CanvasItem, rect: Rect2, fill: Color = PANEL, edge: Color = EDGE, cut: float = 5.0) -> void:
var polygon := clipped_rect(rect,minf(cut,5.0))
canvas.draw_colored_polygon(polygon,fill)
polygon.append(polygon[0])
canvas.draw_polyline(polygon,edge,1.5,false)
static func button(canvas: CanvasItem, rect: Rect2, label: String, enabled: bool = true, highlighted: bool = false) -> void:
frame(canvas,rect,SELECTED if highlighted else PANEL,GOLD if highlighted else EDGE if enabled else MUTED,4)
var size_px := 34 if label.length() <= 20 else 28
centered_text(canvas,label,rect.get_center(),size_px,INK if enabled else MUTED)
+205
View File
@@ -0,0 +1,205 @@
extends Control
signal texture_changed
var state: Dictionary = {}
var textures: Dictionary = {}
var original_frame: Texture2D
var frame_image: Image
var frame_signature := ""
var frame_file := "hud_frame.png"
var use_original_frame := true
var source_rect := Rect2(0, 0, 1280, 720)
var base_path := ""
var font: Font = ThemeDB.fallback_font
var received_frames := 0
var capture_time_unix := 0.0
const NATIVE_SIZE := Vector2(1280, 720)
const RAW_HEADER_SIZE := 36
func set_state(value: Dictionary) -> void:
state = value
if original_frame == null:
request_redraw()
func request_redraw() -> void:
queue_redraw()
texture_changed.emit()
func poll_original_frame() -> void:
if not use_original_frame:
return
var path := base_path.path_join(frame_file)
var raw_path := path.get_basename() + ".rgba"
if FileAccess.file_exists(raw_path):
_poll_raw_frame(raw_path)
return
if not FileAccess.file_exists(path):
var had_frame := original_frame != null
original_frame = null
frame_image = null
frame_signature = ""
if had_frame:
request_redraw()
return
var source := FileAccess.open(path, FileAccess.READ)
if source == null:
return
var bytes := source.get_buffer(source.get_length())
source.close()
var digest := HashingContext.new()
digest.start(HashingContext.HASH_SHA256)
digest.update(bytes)
var signature := frame_file + digest.finish().hex_encode()
if signature != frame_signature:
var image := Image.new()
if image.load_png_from_buffer(bytes) == OK and not image.is_empty():
_accept_frame(image, signature)
func _poll_raw_frame(path: String) -> void:
var source := FileAccess.open(path, FileAccess.READ)
if source == null:
return
# Header-only polling avoids reading, hashing and decoding an unchanged image.
var header := source.get_buffer(RAW_HEADER_SIZE)
if header.size() != RAW_HEADER_SIZE or header.slice(0, 4).get_string_from_ascii() != "FVR1" or header.decode_u32(4) != 1:
source.close()
return
var width := int(header.decode_u32(8))
var height := int(header.decode_u32(12))
if width < 1 or height < 1 or width > 2560 or height > 1440 or header.decode_u32(16) != 4 or source.get_length() != RAW_HEADER_SIZE + width * height * 4:
source.close()
return
var signature := frame_file + header.hex_encode()
if signature == frame_signature:
source.close()
return
var bytes := source.get_buffer(width * height * 4)
source.close()
if bytes.size() != width * height * 4:
return
capture_time_unix = header.decode_double(28)
_accept_frame(Image.create_from_data(width, height, false, Image.FORMAT_RGBA8, bytes), signature)
func _accept_frame(image: Image, signature: String) -> void:
frame_image = image
if original_frame is ImageTexture and Vector2i(original_frame.get_size()) == image.get_size():
original_frame.update(image)
else:
original_frame = ImageTexture.create_from_image(image)
frame_signature = signature
received_frames += 1
request_redraw()
func native_alpha_at(pixel: Vector2i) -> float:
if frame_image == null:
return 0.0
var image_size := frame_image.get_size()
var scaled := Vector2(pixel) * Vector2(image_size) / NATIVE_SIZE
var sample := Vector2i(clampi(floori(scaled.x), 0, image_size.x - 1), clampi(floori(scaled.y), 0, image_size.y - 1))
return frame_image.get_pixelv(sample).a
func native_used_rect() -> Rect2i:
if frame_image == null:
return Rect2i()
var used := frame_image.get_used_rect()
var scale := NATIVE_SIZE / Vector2(frame_image.get_size())
var first := Vector2(used.position) * scale
var last := Vector2(used.end) * scale
return Rect2i(Vector2i(floori(first.x), floori(first.y)), Vector2i(ceili(last.x), ceili(last.y)) - Vector2i(floori(first.x), floori(first.y)))
func _texture(path: String) -> Texture2D:
if textures.has(path):
return textures[path]
var file := base_path.path_join("ui/img").path_join(path)
if not FileAccess.file_exists(file):
return null
var image := Image.load_from_file(file)
var texture := ImageTexture.create_from_image(image)
textures[path] = texture
return texture
func _art(path: String, point: Vector2) -> void:
var texture := _texture(path)
if texture != null:
draw_texture(texture, point)
func _text(value: String, point: Vector2, size_px: int = 17, color: Color = Color(0.92, 0.97, 0.87)) -> void:
draw_string(font, point, value, HORIZONTAL_ALIGNMENT_LEFT, -1.0, size_px, color)
func _draw() -> void:
if original_frame != null:
# The live bridge can provide the original game's transparent UI render target.
var transport_rect := Rect2(source_rect.position * original_frame.get_size() / NATIVE_SIZE, source_rect.size * original_frame.get_size() / NATIVE_SIZE)
draw_texture_rect_region(original_frame, Rect2(Vector2.ZERO, size), transport_rect)
return
_art("statusUI/top_hull.png", Vector2(5, 0))
_art("statusUI/top_hull_label.png", Vector2(5, 0))
_text("HULL", Vector2(12, 22), 18, Color(0.04, 0.12, 0.13))
var hull := int(state.get("hull", 30))
var hull_max := maxi(1, int(state.get("hull_max", 30)))
for i in range(30):
var color := Color(0.48, 1.0, 0.47) if i < roundi(30.0 * hull / hull_max) else Color(0.11, 0.18, 0.16)
draw_rect(Rect2(19 + i * 11.7, 32, 10, 14), color)
_art("statusUI/top_scrap.png", Vector2(393, 0))
_text(str(state.get("scrap", 52)), Vector2(444, 34))
_art("statusUI/top_shields4_on.png", Vector2(5, 57))
for i in range(clampi(int(state.get("shield", 2)), 0, 4)):
_art("statusUI/top_shieldsquare1_on.png", Vector2(31 + i * 23, 64))
for entry in [["fuel", 140, 17], ["missiles", 220, 8], ["drones", 300, 2]]:
_art("statusUI/top_%s_on.png" % entry[0], Vector2(entry[1], 57))
_text(str(state.get(entry[0], entry[2])), Vector2(entry[1] + 42, 84))
_art("statusUI/top_evade_oxygen.png", Vector2(5, 106))
_text("%d%%" % int(state.get("evasion", 25)), Vector2(48, 128), 15)
_text("%d%%" % int(state.get("oxygen", 100)), Vector2(48, 152), 15)
var crew: Array = state.get("crew", [{"name": "Crew 1", "health": 100}, {"name": "Crew 2", "health": 100}, {"name": "Crew 3", "health": 100}])
for i in range(crew.size()):
var y := 178 + i * 31
draw_rect(Rect2(8, y, 108, 26), Color(0.06, 0.13, 0.14, 0.9))
draw_rect(Rect2(8, y, 108, 26), Color(0.58, 0.68, 0.6), false)
draw_circle(Vector2(21, y + 10), 6, Color(0.6, 0.9, 0.34))
_text(str(crew[i].get("name", "Crew")), Vector2(34, y + 16), 14)
draw_rect(Rect2(13, y + 21, 94 * clampf(float(crew[i].get("health", 100)) / 100.0, 0, 1), 3), Color(0.36, 0.88, 0.33))
var reactor := int(state.get("reactor", 8))
for i in range(25):
draw_rect(Rect2(20, 682 - i * 6, 20, 4), Color(0.53, 0.97, 0.49) if i < reactor else Color(0.08, 0.1, 0.11))
draw_rect(Rect2(20, 682 - i * 6, 20, 4), Color(0.55, 0.61, 0.6), false)
var systems: Dictionary = state.get("systems", {"shields": 2, "engines": 2, "medbay": 1, "oxygen": 1, "weapons": 3, "drones": 0})
var system_order := ["shields", "engines", "medbay", "oxygen", "weapons", "drones"]
for i in range(system_order.size()):
var x := 62 + i * 43
var system_name: String = system_order[i]
_art("icons/s_%s_green1.png" % system_name, Vector2(x, 666))
_art("systemUI/button_default_base.png", Vector2(x, 592))
for cell in range(int(systems.get(system_name, 0))):
draw_rect(Rect2(x + 12, 651 - cell * 9, 17, 7), Color(0.4, 0.94, 0.35))
_art("box_weapons_bottom4.png", Vector2(341, 616))
_art("box_weapons_bottom_label.png", Vector2(341, 680))
_text("WEAPONS", Vector2(352, 705), 17, Color(0.07, 0.17, 0.17))
var weapons: Array = state.get("weapons", [{"name": "Burst Laser II", "charge": 1.0}, {"name": "Artemis", "charge": 1.0}])
for i in range(mini(4, weapons.size())):
var x := 350 + i * 99
draw_rect(Rect2(x, 624, 93, 46), Color(0.02, 0.08, 0.06, 0.88))
draw_rect(Rect2(x, 624, 93, 46), Color(0.75, 0.87, 0.78), false)
draw_rect(Rect2(x + 3, 628, 5, 37 * clampf(float(weapons[i].get("charge", 0)), 0, 1)), Color(0.38, 0.97, 0.38))
_text(str(weapons[i].get("name", "Weapon")), Vector2(x + 12, 647), 11)
_text(str(i + 1), Vector2(x + 12, 665), 13)
_art("box_weapons_autofire_base.png", Vector2(663, 678))
_text("AUTOFIRE", Vector2(676, 698), 12, Color(0.12, 0.2, 0.12))
_art("box_subsystems4.png", Vector2(1080, 678))
for i in range(3):
_art("icons/s_%s_green1.png" % ["pilot", "sensors", "doors"][i], Vector2(1083 + i * 43, 646))
_text("SUBSYSTEMS", Vector2(1089, 703), 15, Color(0.07, 0.17, 0.17))
if bool(state.get("paused", false)):
_art("Text_pause1.png", Vector2(536, 523))
+1
View File
@@ -0,0 +1 @@
uid://dcei0upg4ewds
+111
View File
@@ -0,0 +1,111 @@
extends Node3D
const HudCanvas = preload("res://scripts/hud_canvas.gd")
var canvas: Control
var viewport: SubViewport
var timer := 0.0
var frame_file := "hud_frame.png"
var render_order := 0
var source_rect := Rect2(0, 0, 1280, 720)
var surface_size := Vector2(2.85, 1.603125)
var quad: QuadMesh
var mesh_instance: MeshInstance3D
const SURFACE_SIZE := Vector2(2.85, 1.603125)
func _ready() -> void:
viewport = SubViewport.new()
viewport.size = Vector2i(1280, 720)
viewport.size_2d_override = Vector2i(1280, 720)
viewport.size_2d_override_stretch = true
viewport.transparent_bg = true
viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
add_child(viewport)
canvas = HudCanvas.new()
canvas.frame_file = frame_file
canvas.source_rect = source_rect
canvas.size = Vector2(1280, 720)
canvas.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
canvas.base_path = ProjectSettings.globalize_path("res://local_game_data") if OS.has_feature("editor") else OS.get_executable_path().get_base_dir().path_join("local_game_data")
viewport.add_child(canvas)
canvas.texture_changed.connect(_request_update)
var mesh := MeshInstance3D.new()
mesh_instance = mesh
quad = QuadMesh.new()
quad.size = surface_size
mesh.mesh = quad
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.no_depth_test = true
material.render_priority = render_order
material.albedo_texture = viewport.get_texture()
mesh.material_override = material
add_child(mesh)
func set_state(state: Dictionary) -> void:
canvas.set_state(state)
func set_render_order(value: int) -> void:
render_order = value
if mesh_instance != null:
mesh_instance.material_override.render_priority = value
func _request_update() -> void:
# Tactical is reduced; the same stream carries the full-resolution jump
# map, whose small labels must retain their native rendering resolution.
var dimensions := Vector2i(1280, 720)
if canvas.frame_file == "screen_frame.png" and canvas.frame_image != null:
dimensions = canvas.frame_image.get_size()
if viewport.size != dimensions:
viewport.size = dimensions
viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
func ray_pixel(from: Vector3, direction: Vector3) -> Variant:
if not is_visible_in_tree() or canvas.frame_image == null:
return null
var local_from := to_local(from)
var local_end := to_local(from + direction)
var local_direction := local_end - local_from
if absf(local_direction.z) < 0.00001:
return null
var distance := -local_from.z / local_direction.z
if distance <= 0.0:
return null
var point := local_from + distance * local_direction
var uv := Vector2(point.x / surface_size.x + 0.5, 0.5 - point.y / surface_size.y)
if uv.x < 0.0 or uv.x >= 1.0 or uv.y < 0.0 or uv.y >= 1.0:
return null
var native := source_rect.position + uv * source_rect.size
var pixel := Vector2i(clampi(roundi(native.x), maxi(0, ceili(source_rect.position.x)), mini(1279, ceili(source_rect.end.x) - 1)), clampi(roundi(native.y), maxi(0, ceili(source_rect.position.y)), mini(719, ceili(source_rect.end.y) - 1)))
if canvas.native_alpha_at(pixel) < 0.1:
return null
return pixel
func pixel_world(pixel: Vector2i) -> Vector3:
var uv := (Vector2(pixel) - source_rect.position) / source_rect.size
return to_global(Vector3((uv.x - 0.5) * surface_size.x, (0.5 - uv.y) * surface_size.y, 0.01))
func set_crop(rect: Rect2, dimensions: Vector2) -> void:
if source_rect == rect and surface_size == dimensions:
return
source_rect = rect
surface_size = dimensions
quad.size = dimensions
canvas.source_rect = rect
canvas.request_redraw()
func _process(delta: float) -> void:
if not is_visible_in_tree():
return
timer += delta
if timer >= 1.0 / 60.0:
timer = fmod(timer, 1.0 / 60.0)
canvas.poll_original_frame()
+1
View File
@@ -0,0 +1 @@
uid://rs7vf2puuej1
+1596
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
uid://0j15khj8bggh
+35
View File
@@ -0,0 +1,35 @@
extends RefCounted
var position_deadband := 0.0015
var angle_deadband := deg_to_rad(0.15)
var time_constant := 0.075
var fast_time_constant := 0.025
var initialized := false
var value := Transform3D.IDENTITY
func reset() -> void:
initialized = false
func update(target: Transform3D, delta: float) -> Transform3D:
var rotation := target.basis.orthonormalized().get_rotation_quaternion()
if not initialized:
value = target
initialized = true
return value
var current := value.basis.orthonormalized().get_rotation_quaternion()
var distance := value.origin.distance_to(target.origin)
var angle := current.angle_to(rotation)
# Reacquired tracking and explicit recentering must not leave a long trail.
if distance > 0.35 or angle > deg_to_rad(70.0):
value = target
return value
var moving := distance > 0.025 or angle > deg_to_rad(8.0)
var alpha := 1.0 - exp(-maxf(delta, 0.0) / (fast_time_constant if moving else time_constant))
if distance > position_deadband:
value.origin = value.origin.lerp(target.origin, alpha)
if angle > angle_deadband:
current = current.slerp(rotation, alpha)
value.basis = Basis(current).scaled(target.basis.get_scale())
return value
+144
View File
@@ -0,0 +1,144 @@
extends Node3D
# One rendered surface, with geometric key picking. Text remains authoritative
# in FTL; the keyboard only forwards scoped native text events.
const SIZE := Vector2(0.86, 0.39)
const PIXELS := Vector2(1000, 454)
const UI = preload("res://scripts/ftl_ui_theme.gd")
var entry: Dictionary = {}
var upper := true
var keys: Array = []
var viewport: SubViewport
var canvas: Control
var title: Label
var value_label: Label
var hint: Label
func _ready() -> void:
viewport = SubViewport.new()
viewport.size = Vector2i(PIXELS)
viewport.transparent_bg = true
viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
add_child(viewport)
canvas = Control.new()
canvas.size = PIXELS
canvas.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
viewport.add_child(canvas)
var background := ColorRect.new()
background.size = PIXELS
background.color = UI.DARK
canvas.add_child(background)
title = _label(Vector2(20, 5), 29, "RENAME")
title.add_theme_font_override("font",UI.ASSETS.font("header"))
value_label = _label(Vector2(20, 44), 38, "")
hint = _label(Vector2(20, 417), 28, "Right trigger: type B: cancel Native name length applies")
var rows := ["1234567890", "QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM-_"]
for row in range(rows.size()):
var line: String = rows[row]
var left := (PIXELS.x - line.length() * 94.0) * 0.5
for column in range(line.length()):
_key(Rect2(left + column * 94.0, 100 + row * 61, 88, 55), line[column], "insert", line[column])
_key(Rect2(30, 350, 100, 58), "SHIFT", "shift", "")
_key(Rect2(140, 350, 230, 58), "SPACE", "insert", " ")
_key(Rect2(380, 350, 100, 58), "CLEAR", "clear", "")
_key(Rect2(490, 350, 140, 58), "BACK", "backspace", "")
_key(Rect2(640, 350, 150, 58), "CANCEL", "cancel", "")
_key(Rect2(800, 350, 170, 58), "DONE", "confirm", "")
var mesh := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = SIZE
mesh.mesh = quad
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.no_depth_test = true
material.render_priority = 124
material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
material.albedo_texture = viewport.get_texture()
mesh.material_override = material
add_child(mesh)
visible = false
func _label(point: Vector2, font_size: int, text: String) -> Label:
var label := Label.new()
label.position = point
label.add_theme_font_override("font",UI.ASSETS.font("body"))
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color",UI.INK)
label.text = text
canvas.add_child(label)
return label
func _key(rect: Rect2, text: String, op: String, character: String) -> void:
var plate := Panel.new()
plate.position = rect.position
plate.size = rect.size
var style := StyleBoxFlat.new()
style.bg_color = UI.PANEL if op != "confirm" else UI.SELECTED
style.border_color = UI.EDGE if op != "confirm" else UI.GREEN
style.set_border_width_all(1)
plate.add_theme_stylebox_override("panel",style)
canvas.add_child(plate)
var label := _label(rect.position + Vector2(0,4),32,text)
label.size = rect.size - Vector2(0, 6)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
keys.append({"rect": rect, "op": op, "text": character, "label": label})
func set_entry(value: Dictionary) -> void:
if value == entry:
return
var first := str(value.get("id", "")) != str(entry.get("id", ""))
entry = value.duplicate(true)
visible = bool(entry.get("active", false))
if first:
upper = true
_update_case()
title.text = "RENAME %s" % str(entry.get("kind", "NAME")).to_upper()
var text := str(entry.get("text", ""))
var cursor := clampi(int(entry.get("cursor", text.length())), 0, text.length())
value_label.text = text.substr(0, cursor) + "|" + text.substr(cursor)
viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
func _update_case() -> void:
for key in keys:
if key.op == "insert" and key.text != " ":
key.label.text = str(key.text).to_upper() if upper else str(key.text).to_lower()
viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
func ray_hit(from: Vector3, direction: Vector3) -> Dictionary:
if not is_visible_in_tree():
return {}
var start := to_local(from)
var ray := to_local(from + direction) - start
if absf(ray.z) < 0.00001:
return {}
var distance := -start.z / ray.z
if distance <= 0:
return {}
var local_point := start + ray * distance
var pixel := Vector2(local_point.x / SIZE.x + 0.5, 0.5 - local_point.y / SIZE.y) * PIXELS
if not Rect2(Vector2.ZERO, PIXELS).has_point(pixel):
return {}
var hit := {"position": to_global(local_point), "op": ""}
for key in keys:
if key.rect.has_point(pixel):
hit.op = key.op
hit.text = str(key.text).to_upper() if upper else str(key.text).to_lower()
break
return hit
func activate(hit: Dictionary) -> Dictionary:
if hit.get("op", "") == "shift":
upper = not upper
_update_case()
return {}
if hit.get("op", "") == "":
return {}
return {"entry_id": entry.get("id", ""), "op": hit.op, "text": hit.get("text", "")}
+103
View File
@@ -0,0 +1,103 @@
extends Node3D
const Voxels = preload("res://scripts/voxel_mesh.gd")
const MAX_HAZARDS := 128
static var shared_visuals: Dictionary = {}
var flame_outer: MultiMeshInstance3D
var flame_inner: MultiMeshInstance3D
var breaches: MultiMeshInstance3D
var air_streams: MultiMeshInstance3D
var fire_points: Array[Vector3] = []
var breach_points: Array[Vector3] = []
var animation_time := 0.0
var native_signature := ""
func build() -> void:
# Shared meshes and materials: no particles, lights, or per-frame mesh rebuilds.
flame_outer = _batch("FireOuter", [
Voxels.block(Vector3(0, 0.016, 0), Vector3(0.074, 0.032, 0.061), Color("df4225")),
Voxels.block(Vector3(0.010, 0.049, 0.005), Vector3(0.051, 0.035, 0.043), Color("ff842f")),
Voxels.block(Vector3(-0.008, 0.079, 0), Vector3(0.024, 0.027, 0.026), Color("ffb64a")),
Voxels.block(Vector3(0.020, 0.098, 0.004), Vector3(0.015, 0.020, 0.013), Color("ffd978"))], true)
flame_inner = _batch("FireInner", [
Voxels.block(Vector3(0, 0.019, 0), Vector3(0.038, 0.037, 0.039), Color("ffde80")),
Voxels.block(Vector3(-0.004, 0.045, 0), Vector3(0.019, 0.028, 0.022), Color("fff1b3"))], true)
var breach_blocks := [Voxels.block(Vector3(0, 0.0, 0), Vector3(0.085, 0.003, 0.085), Color("090f17"))]
for i in range(8):
var angle := i * TAU / 8.0
var radius := 0.046 if i % 2 == 0 else 0.049
breach_blocks.append(Voxels.block(Vector3(cos(angle) * radius, 0.003, sin(angle) * radius),
Vector3(0.025, 0.011, 0.021), Color("9a8b77") if i % 2 == 0 else Color("566772")))
breaches = _batch("HullBreaches", breach_blocks, false)
air_streams = _batch("BreachAir", [
Voxels.block(Vector3(-0.018, 0.029, 0), Vector3(0.004, 0.040, 0.004), Color("6c9caa")),
Voxels.block(Vector3(0.008, 0.044, -0.010), Vector3(0.004, 0.035, 0.004), Color("9ac4cc")),
Voxels.block(Vector3(0.016, 0.063, 0.014), Vector3(0.004, 0.029, 0.004), Color("6c9caa"))], true)
func _batch(node_name: String, blocks: Array, emissive: bool) -> MultiMeshInstance3D:
if not shared_visuals.has(node_name):
var source: MeshInstance3D = Voxels.make(blocks)
var material: StandardMaterial3D = source.material_override
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.emission_enabled = emissive
material.emission = Color("ffab60") if node_name.begins_with("Fire") else Color("527a8b")
material.emission_energy_multiplier = 0.22
shared_visuals[node_name] = {"mesh": source.mesh, "material": material}
source.free()
var batch := MultiMeshInstance3D.new()
batch.name = node_name
batch.multimesh = MultiMesh.new()
batch.multimesh.transform_format = MultiMesh.TRANSFORM_3D
batch.multimesh.mesh = shared_visuals[node_name].mesh
batch.material_override = shared_visuals[node_name].material
batch.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(batch)
return batch
func set_live(ship: Node3D, data: Dictionary) -> void:
var fires: Array[Vector3] = []
var holes: Array[Vector3] = []
for room in data.get("rooms", []):
if bool(ship.enemy) and (not bool(ship.inspect_rooms) or not bool(room.get("visible", false))):
continue
for point in room.get("fire_tiles", []):
if fires.size() < MAX_HAZARDS:
fires.append(ship.pixel_point(point, 0.082))
for point in room.get("breach_tiles", []):
if holes.size() < MAX_HAZARDS:
holes.append(ship.pixel_point(point, 0.082))
var signature := str(fires) + ":" + str(holes)
if signature == native_signature:
return
native_signature = signature
fire_points = fires
breach_points = holes
flame_outer.multimesh.instance_count = fires.size()
flame_inner.multimesh.instance_count = fires.size()
breaches.multimesh.instance_count = holes.size()
air_streams.multimesh.instance_count = holes.size()
for i in range(holes.size()):
breaches.multimesh.set_instance_transform(i, Transform3D(Basis().rotated(Vector3.UP, (i % 4) * PI / 2.0), holes[i]))
_render_animation()
func animate(delta: float) -> void:
if fire_points.is_empty() and breach_points.is_empty():
return
animation_time += delta
_render_animation()
func _render_animation() -> void:
for i in range(fire_points.size()):
var phase := animation_time * 8.5 + i * 2.7
var outer := Basis().rotated(Vector3.UP, (i % 4) * PI / 2.0)
outer = Basis(outer.x * (0.92 + sin(phase) * 0.07), outer.y * (0.87 + sin(phase + 0.8) * 0.16), outer.z)
flame_outer.multimesh.set_instance_transform(i, Transform3D(outer, fire_points[i]))
flame_inner.multimesh.set_instance_transform(i, Transform3D(Basis().scaled(Vector3(1.0, 0.88 + sin(phase + 1.7) * 0.18, 1.0)), fire_points[i]))
for i in range(breach_points.size()):
var phase := fposmod(animation_time * 0.7 + i * 0.19, 1.0)
air_streams.multimesh.set_instance_transform(i, Transform3D(Basis().scaled(Vector3(1.0, 0.6 + phase * 0.6, 1.0)), breach_points[i] + Vector3.UP * phase * 0.025))
+15
View File
@@ -0,0 +1,15 @@
shader_type spatial;
render_mode unshaded, blend_mix, cull_back, depth_draw_never;
uniform vec4 shield_color : source_color = vec4(0.18, 0.58, 1.0, 1.0);
uniform float strength = 0.5;
uniform float hit_flash = 0.0;
void fragment() {
float edge = pow(1.0 - abs(dot(normalize(NORMAL), normalize(VIEW))), 2.4);
vec2 grid = abs(fract(UV * vec2(40.0, 22.0)) - 0.5);
float line = smoothstep(0.45, 0.49, max(grid.x, grid.y));
ALBEDO = shield_color.rgb;
EMISSION = shield_color.rgb * (0.5 + hit_flash * 2.0);
ALPHA = (0.025 + edge * 0.3 + line * 0.035) * strength + hit_flash * 0.2;
}
+1
View File
@@ -0,0 +1 @@
uid://bnlk3ceim4cxm
+803
View File
@@ -0,0 +1,803 @@
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)
+1
View File
@@ -0,0 +1 @@
uid://dqk6ls4dn358p
+114
View File
@@ -0,0 +1,114 @@
extends Node3D
# One FTL-styled radial surface. Choosing a sector redraws once; no animated
# viewport or per-label meshes are needed while the wheel is held open.
const UI = preload("res://scripts/ftl_ui_theme.gd")
const SIZE := Vector2(0.54,0.60)
const PIXELS := Vector2(720,800)
var selected := -1
var entries: Array = []
var heading := "ACTIONS"
var viewport: SubViewport
var canvas: Control
var content_revision := 0
func _ready() -> void:
viewport = SubViewport.new()
viewport.size = Vector2i(PIXELS)
viewport.transparent_bg = true
viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
add_child(viewport)
canvas = WheelCanvas.new()
canvas.wheel = self
canvas.size = PIXELS
canvas.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
viewport.add_child(canvas)
var backing := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = SIZE
backing.mesh = quad
backing.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.no_depth_test = true
material.render_priority = 125
material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
material.albedo_texture = viewport.get_texture()
backing.material_override = material
add_child(backing)
visible = false
func set_entries(value: Array, title: String) -> void:
if entries == value and heading == title:
return
entries = value.duplicate(true)
heading = title
_redraw()
func choose(stick: Vector2) -> void:
# Top = 1, clockwise through 8. Center releases without an action.
var next := -1 if stick.length() < 0.45 else posmod(roundi(atan2(stick.x,stick.y) / (TAU / 8.0)),8)
if next == selected:
return
selected = next
_redraw()
func _redraw() -> void:
content_revision += 1
if canvas != null:
canvas.queue_redraw()
viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
class WheelCanvas extends Control:
var wheel: Node3D
const CENTER := Vector2(360,353)
func _sector(angle: float) -> PackedVector2Array:
var points := PackedVector2Array()
var half := TAU / 16.0 - .025
for j in range(13):
var a := angle - half + (half * 2) * j / 12.0
points.append(CENTER + Vector2(sin(a),-cos(a)) * 326)
for j in range(12,-1,-1):
var a := angle - half + (half * 2) * j / 12.0
points.append(CENTER + Vector2(sin(a),-cos(a)) * 129)
return points
func _draw() -> void:
for i in range(8):
var enabled: bool = i < wheel.entries.size() and bool(wheel.entries[i].get("enabled",true))
var active: bool = wheel.selected == i and enabled
var angle := i * TAU / 8.0
var outline := _sector(angle)
draw_colored_polygon(outline,UI.SELECTED if active else UI.PANEL if enabled else UI.DARK)
outline.append(outline[0])
draw_polyline(outline,UI.GOLD if active else UI.EDGE if enabled else UI.MUTED,1.5,false)
var center := CENTER + Vector2(sin(angle),-cos(angle)) * 225
UI.centered_text(self,str(i + 1),center + Vector2(0,-22),38,UI.GOLD if active else UI.GREEN if enabled else UI.MUTED,"header")
var label := str(wheel.entries[i].get("label","")) if i < wheel.entries.size() else "-"
var lines := label.replace("\n"," ").split(" ",false)
var current := ""
var wrapped: Array[String] = []
for word in lines:
if current.length() + word.length() + 1 > 12 and current != "":
wrapped.append(current)
current = ""
current = word if current == "" else current + " " + word
if current != "": wrapped.append(current)
for line in range(mini(3,wrapped.size())):
UI.centered_text(self,wrapped[line],center + Vector2(0,11 + line * 21),24,UI.INK if enabled else UI.MUTED)
UI.frame(self,Rect2(CENTER - Vector2(102,56),Vector2(204,112)),UI.DARK,UI.EDGE,17)
var titles := str(wheel.heading).split("\n")
for i in range(titles.size()):
UI.centered_text(self,titles[i],CENTER + Vector2(0,(i - (titles.size() - 1) * .5) * 29),25,UI.INK,"header")
UI.frame(self,Rect2(38,700,644,88),UI.DARK,UI.EDGE,12)
UI.centered_text(self,"STICK: CHOOSE RELEASE BUMPER: CONFIRM",Vector2(360,727),24,UI.INK)
UI.centered_text(self,"A: CATEGORY B / CENTER: CANCEL",Vector2(360,760),24,UI.MUTED)
+1
View File
@@ -0,0 +1 @@
uid://bx7kp57ainba2
+337
View File
@@ -0,0 +1,337 @@
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.
const STAR_COUNT := 1600
const NEAR_STAR_COUNT := 96
const GALAXY_COUNT := 420
const VALID_HAZARDS := ["clear", "asteroid", "sun", "storm", "nebula", "pulsar"]
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
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"
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))
var next := str(state.get("hazard", "clear"))
if next in VALID_HAZARDS and next != hazard:
_set_hazard(next)
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
stars.custom_aabb = AABB(Vector3.ONE * -far_radius, Vector3.ONE * far_radius * 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
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
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 simulation_paused or not built:
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
+1
View File
@@ -0,0 +1 @@
uid://d2rmdd3mkhfap
+190
View File
@@ -0,0 +1,190 @@
extends Node3D
const Voxels = preload("res://scripts/voxel_mesh.gd")
const UNIT := 0.012
const SPECIES := ["human", "engi", "mantis", "rock", "slug", "energy", "crystal", "lanius"]
var species := "human"
var animation_state := "idle"
var animation_time := 0.0
var torso: Node3D
var head: Node3D
var arms: Array[Node3D] = []
var legs: Array[Node3D] = []
var tool: Node3D
var facing := 0.0
var viewer_facing_valid := false
func build(race: String) -> void:
for child in get_children():
remove_child(child)
child.queue_free()
arms.clear()
legs.clear()
species = canonical_species(race)
if not SPECIES.has(species):
species = "human"
torso = Node3D.new()
torso.name = "Torso"
add_child(torso)
head = Node3D.new()
head.name = "Head"
head.position = Vector3(0, 8.4, 0) * UNIT
torso.add_child(head)
var dark := Color("253744")
var light := Color("b9c8cc")
var eye := Color("a4f4ff")
var body_blocks: Array = []
var head_blocks: Array = []
var arm_blocks: Array = []
var leg_blocks: Array = []
match species:
"human":
body_blocks = [_b(0, 5.4, 0, 4.2, 4.8, 2.6, Color("467da6")), _b(0, 3.3, 0, 4.3, 0.7, 2.8, dark), _b(0, 6.3, -1.5, 1.4, 1.4, 0.5, Color("ffd777"))]
head_blocks = [_b(0, 0.8, 0, 3.2, 3.1, 3.0, Color("c89571")), _b(0, 2.2, 0.2, 3.4, 0.8, 3.2, Color("5a392c")), _b(-0.7, 0.9, -1.55, 0.55, 0.6, 0.2, dark), _b(0.7, 0.9, -1.55, 0.55, 0.6, 0.2, dark)]
arm_blocks = [_b(0, -1.5, 0, 1.4, 3.3, 1.7, Color("467da6")), _b(0, -3.25, -0.2, 1.3, 1.0, 1.4, Color("c89571"))]
leg_blocks = [_b(0, -1.5, 0, 1.6, 3.2, 1.8, dark), _b(0, -3.2, -0.4, 1.7, 0.9, 2.5, Color("16232e"))]
"engi":
body_blocks = [_b(0, 5.0, 0, 4.6, 3.8, 3.2, light), _b(0, 5.2, -1.8, 2.1, 2.2, 0.6, dark), _b(0, 5.2, -2.15, 1.2, 0.6, 0.2, eye), _b(0, 7.1, 0, 2.0, 1.0, 2.0, dark)]
head_blocks = [_b(0, 0.5, 0, 3.4, 2.6, 3.0, light), _b(0, 0.7, -1.6, 2.5, 1.1, 0.4, dark), _b(-0.65, 0.7, -1.85, 0.6, 0.6, 0.15, eye), _b(0.65, 0.7, -1.85, 0.6, 0.6, 0.15, eye)]
arm_blocks = [_b(0, -0.8, 0, 1.6, 1.6, 1.7, light), _b(0, -2, 0, 0.7, 1.1, 0.9, dark), _b(0, -3, -0.2, 1.8, 1.4, 1.7, light)]
leg_blocks = [_b(0, -0.7, 0, 1.4, 1.5, 1.8, light), _b(0, -1.9, 0, 0.7, 1.0, 1.0, dark), _b(0, -3, -0.5, 1.9, 1.4, 2.7, light)]
"mantis":
body_blocks = [_b(0, 5.2, 0, 2.7, 4.5, 2.4, Color("568e3b")), _b(0, 4.1, 1.2, 3.5, 2.3, 3.0, Color("759f45")), _b(0, 7, 0, 2.0, 1.4, 2.1, Color("375d29"))]
head_blocks = [_b(0, 0.5, -0.5, 3.7, 2.8, 2.7, Color("8fb654")), _b(-1.5, 0.8, -2.0, 0.9, 1.2, 0.7, Color("df5146")), _b(1.5, 0.8, -2.0, 0.9, 1.2, 0.7, Color("df5146")), _b(-1.2, 2.9, 0, 0.35, 2.2, 0.4, dark), _b(1.2, 2.9, 0, 0.35, 2.2, 0.4, dark), _b(-0.9, -1.0, -2.0, 0.5, 1.2, 1.2, Color("e7d09a")), _b(0.9, -1.0, -2.0, 0.5, 1.2, 1.2, Color("e7d09a"))]
arm_blocks = [_b(0, -1.3, 0, 0.8, 3.3, 1.0, Color("568e3b")), _b(0, -3.2, -0.7, 0.9, 2.3, 1.8, Color("bdd887")), _b(0, -4.5, -1.4, 0.7, 1.2, 0.7, Color("e7d09a"))]
leg_blocks = [_b(0, -1.4, 0.5, 0.9, 2.8, 1.1, Color("568e3b")), _b(0, -3.0, -0.7, 0.8, 1.4, 2.3, Color("759f45"))]
"rock":
body_blocks = [_b(0, 5.1, 0, 6.4, 4.8, 4.3, Color("8c7252")), _b(-2.4, 6.3, 0, 2.5, 3.1, 3.4, Color("ac9065")), _b(2.4, 5.8, 0, 2.3, 3.3, 3.8, Color("74624b")), _b(0.7, 3.4, -2.3, 2.3, 1.9, 0.7, Color("6a563f"))]
head_blocks = [_b(0, 0.4, 0, 4.6, 3.5, 3.7, Color("ac9065")), _b(-1.8, 1.4, 0.2, 1.2, 2.5, 2.7, Color("8c7252")), _b(0, 0.3, -2.0, 3.1, 0.7, 0.4, dark), _b(-0.8, 0.3, -2.25, 0.7, 0.35, 0.2, Color("ffe4a0")), _b(0.8, 0.3, -2.25, 0.7, 0.35, 0.2, Color("ffe4a0"))]
arm_blocks = [_b(0, -1.3, 0, 2.3, 3.7, 3.1, Color("8c7252")), _b(0, -3.2, -0.3, 2.6, 1.9, 3.0, Color("ac9065"))]
leg_blocks = [_b(0, -1.6, 0, 2.3, 3.3, 2.7, Color("74624b")), _b(0, -3.1, -0.5, 2.6, 1.1, 3.4, Color("8c7252"))]
"slug":
body_blocks = [_b(0, 3.2, 0.9, 5.4, 3.0, 6.5, Color("846aa8")), _b(0, 5.0, 0, 4.6, 3.8, 4.0, Color("a887c1")), _b(0, 2.0, 3.4, 3.3, 1.7, 3.6, Color("725793")), _b(0, 1.25, 1.0, 5.8, 0.7, 8.2, Color("58446e"))]
head_blocks = [_b(0, -0.5, 0, 4.5, 2.6, 3.8, Color("a887c1")), _b(-1.5, 1.6, -1.1, 0.7, 3.5, 0.7, Color("a887c1")), _b(1.5, 1.6, -1.1, 0.7, 3.5, 0.7, Color("a887c1")), _b(-1.5, 3.2, -1.2, 1.3, 1.0, 1.1, Color("e1e791")), _b(1.5, 3.2, -1.2, 1.3, 1.0, 1.1, Color("e1e791")), _b(-1.5, 3.2, -1.8, 0.5, 0.7, 0.2, dark), _b(1.5, 3.2, -1.8, 0.5, 0.7, 0.2, dark)]
arm_blocks = [_b(0, -1.5, -0.3, 1.0, 3.1, 1.4, Color("a887c1")), _b(0, -2.8, -1.1, 1.5, 1.0, 1.5, Color("846aa8"))]
"energy":
body_blocks = [_b(0, 5.2, 0, 3.5, 4.7, 2.4, Color("57c282")), _b(0, 5.5, -1.35, 1.0, 3.1, 0.4, Color("b4ffb0")), _b(-1.9, 6.4, 0, 0.6, 2.0, 2.6, Color("91e999")), _b(1.9, 6.4, 0, 0.6, 2.0, 2.6, Color("91e999"))]
head_blocks = [_b(0, 0.8, 0, 2.7, 3.5, 2.5, Color("8be891")), _b(0, 2.8, 0, 1.8, 1.0, 1.7, Color("b4ffb0")), _b(0, 0.9, -1.4, 1.7, 0.6, 0.3, Color("e6ffd1"))]
arm_blocks = [_b(0, -1.7, 0, 1.0, 3.7, 1.3, Color("8be891")), _b(0, -3.7, 0, 1.1, 0.6, 1.4, Color("b4ffb0"))]
leg_blocks = [_b(0, -1.7, 0, 1.1, 3.5, 1.6, Color("57c282")), _b(0, -3.4, -0.3, 1.3, 0.8, 2.1, Color("b4ffb0"))]
"crystal":
body_blocks = [_b(0, 5.1, 0, 4.7, 5.0, 3.5, Color("65a6bf")), _b(-1.9, 6.2, 0, 1.7, 4.4, 2.7, Color("b3e3ed")), _b(1.9, 5.4, -0.2, 1.7, 3.2, 3.2, Color("85c4d7")), _b(0, 5.4, -2.0, 1.6, 2.5, 0.7, Color("c9f5f8"))]
head_blocks = [_b(0, 0.6, 0, 3.5, 3.4, 3.1, Color("85c4d7")), _b(-0.9, 2.8, 0.2, 1.3, 2.0, 1.9, Color("c9f5f8")), _b(1.0, 2.1, 0.4, 1.4, 1.2, 2.2, Color("b3e3ed")), _b(0, 0.8, -1.7, 2.2, 0.6, 0.3, dark)]
arm_blocks = [_b(0, -1.6, 0, 1.8, 3.8, 2.3, Color("85c4d7")), _b(0, -3.6, -0.2, 1.9, 1.4, 2.4, Color("b3e3ed"))]
leg_blocks = [_b(0, -1.8, 0, 1.8, 3.7, 2.3, Color("65a6bf")), _b(0, -3.5, -0.5, 2.0, 1.0, 2.8, Color("85c4d7"))]
"lanius":
body_blocks = [_b(0, 5.2, 0, 3.4, 4.5, 2.3, Color("9eaaaf")), _b(-1.6, 6.6, 0, 1.3, 2.0, 2.4, Color("d0d8dc")), _b(1.6, 6.6, 0, 1.3, 2.0, 2.4, Color("d0d8dc")), _b(0, 5.0, -1.5, 0.7, 2.7, 0.8, Color("667f8c"))]
head_blocks = [_b(0, 0.9, 0, 2.7, 3.2, 2.7, Color("d0d8dc")), _b(-1.25, 2.8, 0.3, 0.6, 2.5, 0.7, Color("9eaaaf")), _b(1.25, 2.8, 0.3, 0.6, 2.5, 0.7, Color("9eaaaf")), _b(0, 0.5, -1.6, 1.9, 0.6, 0.4, Color("e7f788"))]
arm_blocks = [_b(0, -1.5, 0, 1.0, 3.5, 1.4, Color("9eaaaf")), _b(0, -3.4, -0.6, 0.6, 2.2, 1.5, Color("d0d8dc"))]
leg_blocks = [_b(0, -1.7, 0, 1.0, 3.7, 1.4, Color("9eaaaf")), _b(0, -3.5, -0.7, 0.9, 1.2, 2.9, Color("d0d8dc"))]
torso.add_child(Voxels.make(body_blocks))
head.add_child(Voxels.make(head_blocks))
for side in [-1.0, 1.0]:
var arm := Node3D.new()
arm.position = Vector3(side * (3.4 if species == "rock" else 2.5), 7.0, 0) * UNIT
arm.add_child(Voxels.make(arm_blocks))
torso.add_child(arm)
arms.append(arm)
if species != "slug":
var leg := Node3D.new()
leg.position = Vector3(side * (1.6 if species == "rock" else 1.0), 3.6, 0) * UNIT
leg.add_child(Voxels.make(leg_blocks))
torso.add_child(leg)
legs.append(leg)
tool = Voxels.make([_b(0, 0, 0, 0.8, 3.5, 0.8, light), _b(0, -1.5, 0, 2.2, 0.8, 1.1, Color("ffc967"))])
tool.position = Vector3(0, -3.6, -0.8) * UNIT
arms[1].add_child(tool)
tool.visible = false
animate(0.0)
func set_state(crew: Dictionary) -> void:
var previous_state := animation_state
if bool(crew.get("fighting", false)):
animation_state = "fight"
elif bool(crew.get("repairing", false)):
animation_state = "repair"
elif bool(crew.get("running", false)):
animation_state = "run"
elif bool(crew.get("working", false)):
animation_state = "work"
else:
animation_state = "idle"
tool.visible = animation_state == "repair"
if animation_state != previous_state:
# Native state changes must select the right pose even when an actor is
# first seen while paused. Preserve the frozen animation phase.
animate(0.0)
func face_viewer(deck_direction: Vector3, delta: float) -> void:
# Rotate around the deck normal only. Looking straight down at an actor
# must not tip its body or cause unstable yaw from a near-zero projection.
if animation_state == "run" or Vector2(deck_direction.x, deck_direction.z).length_squared() < 0.0004:
return
var target := atan2(-deck_direction.x, -deck_direction.z)
var difference := wrapf(target - facing, -PI, PI)
if not viewer_facing_valid:
facing = target
viewer_facing_valid = true
elif absf(difference) > 0.012:
facing = lerp_angle(facing, target, 1.0 - exp(-delta * 12.0))
rotation.y = facing
func face_movement(deck_direction: Vector3) -> void:
if animation_state != "run" or deck_direction.length_squared() < 0.00004:
return
facing = atan2(-deck_direction.x, -deck_direction.z)
rotation.y = facing
viewer_facing_valid = false
func animate(delta: float) -> void:
animation_time += delta
var t := animation_time
for part in arms + legs:
part.rotation = Vector3.ZERO
torso.position = Vector3.ZERO
torso.rotation = Vector3.ZERO
head.rotation = Vector3.ZERO
match animation_state:
"run":
for i in range(legs.size()):
legs[i].rotation.x = sin(t * 14.0 + i * PI) * 0.65
for i in range(arms.size()):
arms[i].rotation.x = sin(t * 14.0 + i * PI + PI) * 0.5
torso.position.y = absf(sin(t * 14.0)) * 0.004
torso.rotation.x = -0.12
if species == "slug":
torso.scale.z = 1.0 + sin(t * 9.0) * 0.08
"work":
arms[0].rotation.x = 0.9 + sin(t * 5.0) * 0.14
arms[1].rotation.x = 0.9 + sin(t * 5.0 + PI) * 0.14
head.rotation.x = -0.12
"repair":
arms[0].rotation.x = 0.8
arms[1].rotation.x = 1.0 + sin(t * 11.0) * 0.45
torso.rotation.x = -0.2
torso.position.y = -0.005
head.rotation.x = -0.18
"fight":
arms[0].rotation.x = 0.9 + sin(t * 9.0) * 0.65
arms[1].rotation.x = 0.9 + sin(t * 9.0 + PI) * 0.65
torso.rotation.y = sin(t * 9.0) * 0.12
torso.position.z = sin(t * 9.0) * 0.004
_:
head.rotation.y = sin(t * 1.4) * 0.06
torso.position.y = sin(t * 2.0) * 0.001
if animation_state != "run":
torso.scale.z = 1.0
rotation.y = facing
func _b(x: float, y: float, z: float, w: float, h: float, d: float, color: Color) -> Array:
return Voxels.block(Vector3(x, y, z) * UNIT, Vector3(w, h, d) * UNIT, color)
static func canonical_species(race: String) -> String:
return {"zoltan": "energy", "anaerobic": "lanius"}.get(race.to_lower(), race.to_lower())
+1
View File
@@ -0,0 +1 @@
uid://bbm2s2addmmih
+247
View File
@@ -0,0 +1,247 @@
extends Node3D
const Voxels = preload("res://scripts/voxel_mesh.gd")
const UNIT := 0.01
const STEEL := Color("a5b8be")
const RIM := Color("d3dcda")
const DARK := Color("354b59")
const BLACK := Color("182832")
const BRASS := Color("c6ac75")
static var body_meshes: Dictionary = {}
static var body_material: StandardMaterial3D
var drone_name := ""
var kind := "combat"
var family := "combat"
var is_space := true
var body: MeshInstance3D
var thruster: MeshInstance3D
var emitter: MeshInstance3D
var tool: MeshInstance3D
var live_data: Dictionary = {}
var animation_time := 0.0
var firing := false
static func model_family(data: Dictionary) -> String:
# Species and blueprint names are native identifiers, independent of the
# player's language. Crew drone names can be translated or renamed.
var identity := (str(data.get("kind", "")) + " " + str(data.get("species", "")) + " " + str(data.get("name", ""))).to_lower()
if not bool(data.get("is_space", true)):
match str(data.get("species", "")).to_lower():
"repair": return "repair"
"battle", "boarder": return "battle"
"ion": return "ion_boarder"
if identity.contains("ion"): return "ion_boarder"
if identity.contains("battle") or identity.contains("board") or identity.contains("anti_person"): return "battle"
return "repair"
if identity.contains("hack"): return "hacking"
if identity.contains("anti") and identity.contains("drone"): return "anti_drone"
if identity.contains("shield"): return "shield"
if identity.contains("board") or identity.contains("ion"): return "boarding"
if identity.contains("defense") or identity.contains("defence"): return "defense"
if identity.contains("beam"): return "beam"
return "combat"
func build(data: Dictionary) -> void:
for child in get_children():
remove_child(child)
child.queue_free()
drone_name = str(data.get("name", ""))
kind = str(data.get("kind", "combat")).to_lower()
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"]
var cache_key := family + ("_2" if mark_two else "")
if not body_meshes.has(cache_key):
var source := Voxels.make(_body_blocks(mark_two))
body_meshes[cache_key] = source.mesh
if body_material == null:
body_material = source.material_override
source.free()
body = MeshInstance3D.new()
body.name = "Body"
body.mesh = body_meshes[cache_key]
body.material_override = body_material
add_child(body)
var exhaust: Array = []
var lights: Array = []
var glow := Color("69d8fa")
if is_space:
if family == "boarding":
exhaust = [_b(-8.0, 0, -2.5, 2.8, 1.5, 1.5, glow), _b(-8.0, 0, 2.5, 2.8, 1.5, 1.5, glow)]
elif family == "shield":
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)]
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))
match family:
"combat":
glow = Color("f2725b")
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":
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))
"hacking":
glow = Color("b591e0")
lights = [_b(0, 2.6, 0, 2.6, 0.4, 2.6, glow)]
"shield":
for side in [-1.0, 1.0]:
lights.append(_b(0, 1.4, side * 6.2, 4.2, 0.4, 2.0, glow))
lights.append(_b(side * 6.2, 1.4, 0, 2.0, 0.4, 4.2, glow))
"boarding": lights = [_b(8.3, 0, 0, 0.5, 1.5, 2.5, Color("e69468"))]
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.name = "Exhaust"
add_child(thruster)
emitter = _glow_mesh(lights, glow)
emitter.name = "Emitter"
add_child(emitter)
tool = null
if not is_space:
var tool_blocks: Array
if family == "repair":
tool_blocks = [_b(0, -0.5, 0, 0.9, 3.0, 1.0, STEEL), _b(0, -2.1, -0.7, 2.4, 0.7, 1.3, BRASS),
_b(-0.9, -2.6, -0.8, 0.5, 1.1, 1.3, RIM), _b(0.9, -2.6, -0.8, 0.5, 1.1, 1.3, RIM)]
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.name = "Tool"
tool.position = Vector3(5.2, 6.3, 0) * UNIT
add_child(tool)
set_live(data)
func _body_blocks(mark_two: bool) -> Array:
var blocks: Array = []
if not is_space:
# Repair: wheeled service robot and wrench. Battle: an armored torso,
# articulated claws and feet. Ion boarder: conductive antenna vanes.
var armored := family != "repair"
blocks = [_b(0, 5.0, 0, 6.4 if armored else 5.5, 5.0, 4.8, STEEL),
_b(0, 3.0, 0, 6.8 if armored else 5.8, 1.0, 5.0, DARK),
_b(0, 8.8, 0, 4.7, 2.5, 5.2, DARK), _b(0, 10.0, 0.5, 5.2, 0.6, 4.2, STEEL),
_b(0, 9.0, -2.8, 3.5, 1.5, 0.4, BLACK), _b(0, 5.2, -2.6, 2.8, 2.0, 0.5, DARK)]
for side in [-1.0, 1.0]:
blocks.append(_b(side * 4.1, 6.6, 0, 2.0, 2.0, 3.0, BRASS if not armored else STEEL))
blocks.append(_b(side * 5.2, 5.2, 0, 0.9, 2.0, 1.2, DARK))
if armored:
blocks.append(_b(side * 2.0, 1.8, 0, 1.8, 2.5, 2.0, DARK))
blocks.append(_b(side * 2.0, 0.6, -0.8, 2.4, 1.2, 4.1, STEEL))
blocks.append(_b(side * 4.3, 6.6, -0.5, 2.7, 1.2, 3.8, RIM))
else:
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))
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")))
blocks.append(_b(side * 3.2, 5.4, -2.9, 0.6, 3.8, 0.8, Color("6aa8c5")))
return blocks
match family:
"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]:
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))
"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)]
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))
"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),
_b(-1, 2.7, 0, 7.0, 0.7, 4.0, RIM)]
for side in [-1.0, 1.0]:
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 = [_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)]
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))
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))
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))
blocks.append(_b(3.1, 1.4, 0, 2.5, 0.8, 4.0, BRASS))
"defense", "anti_drone":
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]):
blocks.append(_b(6.0, 2.4, z, 8.0, 1.3, 1.3, DARK))
blocks.append(_b(9.7, 2.4, z, 1.2, 2.0, 2.0, RIM))
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))
_:
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(4.0, 0.7, 0, 1.2, 4.0, 6.0, BRASS))
return blocks
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))
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
func animate(delta: float) -> void:
animation_time += delta
if thruster.visible:
thruster.scale.x = 0.88 + sin(animation_time * 28.0) * 0.12
if tool != null:
tool.rotation.x = 0.0
if firing:
tool.rotation.x = 0.7 + sin(animation_time * 9.0) * 0.5
elif bool(live_data.get("repairing", false)):
tool.rotation.x = 0.55 + sin(animation_time * 11.0) * 0.35
func _glow_mesh(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
material.emission_enabled = true
material.emission = color
material.emission_energy_multiplier = 1.5
return node
func _b(x: float, y: float, z: float, w: float, h: float, d: float, color: Color) -> Array:
return Voxels.block(Vector3(x, y, z) * UNIT, Vector3(w, h, d) * UNIT, color)
+1
View File
@@ -0,0 +1 @@
uid://cyhvbhf12ndf6
+33
View File
@@ -0,0 +1,33 @@
extends RefCounted
# A model part is one mesh, regardless of how many colored blocks it contains.
static func make(blocks: Array) -> MeshInstance3D:
var surface := SurfaceTool.new()
surface.begin(Mesh.PRIMITIVE_TRIANGLES)
for block in blocks:
var center: Vector3 = block[0]
var half: Vector3 = Vector3(block[1]) * 0.5
var color: Color = block[2]
var corners := [center + Vector3(-half.x, -half.y, -half.z),
center + Vector3(half.x, -half.y, -half.z), center + Vector3(half.x, half.y, -half.z),
center + Vector3(-half.x, half.y, -half.z), center + Vector3(-half.x, -half.y, half.z),
center + Vector3(half.x, -half.y, half.z), center + Vector3(half.x, half.y, half.z),
center + Vector3(-half.x, half.y, half.z)]
for face in [[0, 3, 2, 1], [5, 6, 7, 4], [4, 7, 3, 0], [1, 2, 6, 5], [3, 7, 6, 2], [4, 0, 1, 5]]:
var normal: Vector3 = (corners[face[1]] - corners[face[0]]).cross(corners[face[2]] - corners[face[0]]).normalized()
for index in [0, 1, 2, 0, 2, 3]:
surface.set_color(color)
surface.set_normal(normal)
surface.add_vertex(corners[face[index]])
var node := MeshInstance3D.new()
node.mesh = surface.commit()
var material := StandardMaterial3D.new()
material.vertex_color_use_as_albedo = true
material.roughness = 0.88
material.cull_mode = BaseMaterial3D.CULL_DISABLED
node.material_override = material
return node
static func block(center: Vector3, size: Vector3, color: Color) -> Array:
return [center, size, color]
+1
View File
@@ -0,0 +1 @@
uid://1a3ux30lk6yx
+183
View File
@@ -0,0 +1,183 @@
extends Node3D
const Voxels = preload("res://scripts/voxel_mesh.gd")
var kind := "laser"
var weapon_name := ""
var body: Node3D
var emitter: MeshInstance3D
var charge_strip: MeshInstance3D
var charge_fill: MeshInstance3D
var charge_pips: Array[MeshInstance3D] = []
var charge_level := 0
var charge_max := 1
var live_signature := ""
var muzzle_color := Color.WHITE
var charge := 0.0
var powered := false
var recoil := 0.0
var last_firing := false
func build(data: Dictionary) -> void:
for child in get_children():
remove_child(child)
child.queue_free()
charge_pips.clear()
live_signature = ""
last_firing = false
recoil = 0.0
kind = canonical_kind(str(data.get("kind", "laser")))
weapon_name = str(data.get("name", ""))
body = Node3D.new()
add_child(body)
var steel := Color("778e9b")
var dark := Color("293944")
var black := Color("141f28")
var rim := Color("c2ccd0")
var blocks := [Voxels.block(Vector3(0, 0.0, 0), Vector3(0.12, 0.035, 0.12), dark),
Voxels.block(Vector3(0, -0.065, 0), Vector3(0.06, 0.115, 0.06), steel),
Voxels.block(Vector3(0, -0.13, 0), Vector3(0.14, 0.015, 0.12), dark),
Voxels.block(Vector3(-0.047, 0.007, 0), Vector3(0.013, 0.048, 0.086), rim)]
var glow := Color("ef4940")
var emitter_position := Vector3(0.171, 0.047, 0)
var emitter_size := Vector3(0.008, 0.023, 0.025)
var emitter_blocks: Array = []
match kind:
"missile":
blocks.append(Voxels.block(Vector3(0.01, 0.048, 0), Vector3(0.17, 0.075, 0.118), steel))
blocks.append(Voxels.block(Vector3(-0.06, 0.052, 0), Vector3(0.026, 0.099, 0.135), dark))
for z in [-0.028, 0.028]:
blocks.append(Voxels.block(Vector3(0.098, 0.05, z), Vector3(0.025, 0.050, 0.049), rim))
blocks.append(Voxels.block(Vector3(0.112, 0.05, z), Vector3(0.006, 0.035, 0.033), black))
blocks.append(Voxels.block(Vector3(-0.04, 0.091, z), Vector3(0.10, 0.013, 0.015), Color("e3ae68")))
emitter_blocks.append(Voxels.block(Vector3(0.117, 0.05, z), Vector3(0.008, 0.027, 0.027), Color("ffbf5c")))
emitter_position = Vector3(0.117, 0.05, 0)
emitter_size = Vector3(0.008, 0.011, 0.077)
glow = Color("ffbf5c")
"beam":
blocks.append(Voxels.block(Vector3(0.0, 0.044, 0), Vector3(0.12, 0.065, 0.08), steel))
blocks.append(Voxels.block(Vector3(0.075, 0.064, 0), Vector3(0.15, 0.028, 0.10), dark))
for z in [-0.045, 0.045]:
blocks.append(Voxels.block(Vector3(0.13, 0.054, z), Vector3(0.12, 0.045, 0.018), rim))
blocks.append(Voxels.block(Vector3(0.152, 0.056, z), Vector3(0.017, 0.061, 0.028), Color("a79566")))
blocks.append(Voxels.block(Vector3(-0.032, 0.066, 0), Vector3(0.038, 0.024, 0.10), rim))
emitter_position = Vector3(0.17, 0.055, 0)
emitter_size = Vector3(0.011, 0.025, 0.062)
glow = Color("ffdd75")
"bomb":
blocks.append(Voxels.block(Vector3(0, 0.06, 0), Vector3(0.12, 0.10, 0.10), steel))
blocks.append(Voxels.block(Vector3(0.075, 0.06, 0), Vector3(0.055, 0.07, 0.075), dark))
for z in [-0.06, 0.06]:
blocks.append(Voxels.block(Vector3(0, 0.06, z), Vector3(0.065, 0.07, 0.018), Color("b591b7")))
blocks.append(Voxels.block(Vector3(0.039, 0.073, 0), Vector3(0.014, 0.087, 0.098), rim))
blocks.append(Voxels.block(Vector3(0.008, 0.106, 0), Vector3(0.049, 0.016, 0.049), dark))
emitter_position = Vector3(0.109, 0.062, 0)
emitter_size = Vector3(0.011, 0.039, 0.040)
glow = Color("cf83ff")
"ion":
blocks.append(Voxels.block(Vector3(0, 0.04, 0), Vector3(0.115, 0.06, 0.09), steel))
for x in [0.045, 0.075, 0.105]:
blocks.append(Voxels.block(Vector3(x, 0.044, 0), Vector3(0.018, 0.075, 0.075), Color("548dba")))
blocks.append(Voxels.block(Vector3(x, 0.084, 0), Vector3(0.016, 0.006, 0.059), rim))
blocks.append(Voxels.block(Vector3(0.083, 0.044, 0), Vector3(0.12, 0.025, 0.025), dark))
blocks.append(Voxels.block(Vector3(0.146, 0.044, 0), Vector3(0.016, 0.045, 0.045), dark))
emitter_position = Vector3(0.155, 0.044, 0)
emitter_size = Vector3(0.006, 0.028, 0.028)
glow = Color("6ae0ff")
"flak":
blocks.append(Voxels.block(Vector3(0, 0.048, 0), Vector3(0.12, 0.075, 0.115), steel))
for z in [-0.038, 0.0, 0.038]:
blocks.append(Voxels.block(Vector3(0.098, 0.05, z), Vector3(0.145, 0.027, 0.027), dark))
blocks.append(Voxels.block(Vector3(0.157, 0.05, z), Vector3(0.022, 0.035, 0.035), rim))
emitter_blocks.append(Voxels.block(Vector3(0.170, 0.05, z), Vector3(0.006, 0.018, 0.018), Color("f9a451")))
for z in [-0.070, 0.070]:
blocks.append(Voxels.block(Vector3(-0.024, 0.041, z), Vector3(0.082, 0.057, 0.028), Color("988e72")))
emitter_position = Vector3(0.170, 0.05, 0)
emitter_size = Vector3(0.006, 0.018, 0.090)
glow = Color("f9a451")
_:
blocks.append(Voxels.block(Vector3(0, 0.041, 0), Vector3(0.13, 0.065, 0.085), steel))
var barrels := 2 if weapon_name.contains("BURST") or weapon_name.contains("DUAL") else 1
for i in range(barrels):
var z := (i - (barrels - 1) * 0.5) * 0.043
blocks.append(Voxels.block(Vector3(0.10, 0.047, z), Vector3(0.14, 0.033, 0.026), dark))
blocks.append(Voxels.block(Vector3(0.165, 0.047, z), Vector3(0.02, 0.046, 0.04), rim))
emitter_blocks.append(Voxels.block(Vector3(0.176, 0.047, z), Vector3(0.007, 0.025, 0.026), glow))
blocks.append(Voxels.block(Vector3(-0.025, 0.079, 0), Vector3(0.07, 0.013, 0.055), Color("a06155")))
blocks.append(Voxels.block(Vector3(0.074, 0.046, 0), Vector3(0.019, 0.055, 0.094), steel))
if weapon_name.contains("HEAVY"):
blocks.append(Voxels.block(Vector3(0.122, 0.047, 0), Vector3(0.086, 0.057, 0.071), steel))
# Cooling fins and a green charge strip on the upper armor remain readable
# from a tabletop angle, rather than a subtle glow hidden inside a barrel.
for x in [-0.052, -0.035, -0.018]:
for z in [-0.052, 0.052]:
blocks.append(Voxels.block(Vector3(x, 0.037, z), Vector3(0.008, 0.055, 0.012), rim))
for x in [-0.046, 0.049]:
blocks.append(Voxels.block(Vector3(x, 0.101, 0), Vector3(0.014, 0.043, 0.034), steel))
body.add_child(Voxels.make(blocks))
muzzle_color = glow
if emitter_blocks.is_empty():
emitter_blocks.append(Voxels.block(emitter_position, emitter_size, glow))
emitter = Voxels.make(emitter_blocks)
var material: StandardMaterial3D = emitter.material_override
material.emission_enabled = true
material.emission = glow
material.emission_energy_multiplier = 0.0
body.add_child(emitter)
charge_strip = Voxels.make([Voxels.block(Vector3(0.004, 0.122, 0), Vector3(0.125, 0.012, 0.047), dark),
Voxels.block(Vector3(0.004, 0.129, -0.024), Vector3(0.127, 0.005, 0.006), rim),
Voxels.block(Vector3(0.004, 0.129, 0.024), Vector3(0.127, 0.005, 0.006), rim)])
body.add_child(charge_strip)
charge_fill = _indicator(Vector3.ZERO, Vector3(1.0, 0.006, 0.033))
charge_fill.position.y = 0.131
body.add_child(charge_fill)
charge_max = clampi(int(data.get("charge_max", 1)), 1, 8)
if charge_max > 1:
for i in range(charge_max):
var pip := _indicator(Vector3.ZERO, Vector3(0.014, 0.007, 0.013))
pip.position = Vector3(-0.046 + i * 0.018, 0.134, -0.037)
body.add_child(pip)
charge_pips.append(pip)
set_live(data)
func _indicator(center: Vector3, size: Vector3) -> MeshInstance3D:
var indicator: MeshInstance3D = Voxels.make([Voxels.block(center, size, Color.WHITE)])
var material: StandardMaterial3D = indicator.material_override
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.emission_enabled = true
material.emission = Color("84dc7c")
material.emission_energy_multiplier = 0.3
return indicator
func set_live(data: Dictionary) -> void:
powered = bool(data.get("powered", false))
charge = clampf(float(data.get("charge_fraction", float(data.get("charge", 0)) / maxf(float(data.get("cooldown", 1)), 0.001))), 0.0, 1.0)
charge_level = clampi(int(data.get("charge_level", 0)), 0, charge_max)
var firing := bool(data.get("firing", false))
if firing and not last_firing:
recoil = 0.018
last_firing = firing
var next := str(powered) + ":" + str(charge) + ":" + str(charge_level)
if live_signature != next:
live_signature = next
var material: StandardMaterial3D = emitter.material_override
material.emission_energy_multiplier = (0.15 + charge * 1.8) if powered else 0.0
emitter.visible = powered
charge_fill.visible = powered and charge > 0.001
charge_fill.scale.x = maxf(0.001, charge * 0.11)
charge_fill.position.x = -0.051 + charge * 0.055
var indicator_material: StandardMaterial3D = charge_fill.material_override
indicator_material.albedo_color = Color("92e87d") if charge > 0.995 else Color("b8d97a")
for i in range(charge_pips.size()):
charge_pips[i].material_override.albedo_color = Color("92e87d") if powered and i < charge_level else Color("2a4245")
func animate(delta: float) -> void:
recoil = maxf(0.0, recoil - delta * 0.12)
body.position.x = -recoil
static func canonical_kind(native_kind: String) -> String:
return {"missiles": "missile", "burst": "flak"}.get(native_kind.to_lower(), native_kind.to_lower())
+1
View File
@@ -0,0 +1 @@
uid://d4fvj2g2umjrg