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

282 lines
11 KiB
GDScript

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)