121 lines
4.8 KiB
GDScript3
121 lines
4.8 KiB
GDScript3
|
|
extends Control
|
||
|
|
|
||
|
|
# The mothership's research tree, drawn in the game's own neon style rather
|
||
|
|
# than with Godot UI widgets, for the same reason everything else here is
|
||
|
|
# hand-drawn: a themed Button would look nothing like the rest of the game.
|
||
|
|
#
|
||
|
|
# Nodes are laid out from the normalised `pos` in GameState.SKILL_TREE, so
|
||
|
|
# the tree's shape is data next to the upgrades themselves instead of being
|
||
|
|
# duplicated here.
|
||
|
|
#
|
||
|
|
# Input arrives through BuildController._unhandled_input, NOT _gui_input -
|
||
|
|
# a Control that consumes touches through the GUI system swallows them
|
||
|
|
# before the menu can act on them, which is the bug that made the formation
|
||
|
|
# editor's drag silently do nothing. Everything in this menu must stay
|
||
|
|
# mouse_filter = IGNORE.
|
||
|
|
|
||
|
|
signal node_picked(id: String)
|
||
|
|
|
||
|
|
const LOCKED_COLOR: Color = Color(0.36, 0.39, 0.5)
|
||
|
|
const READY_COLOR: Color = Color(0.55, 0.6, 1.0)
|
||
|
|
const DONE_COLOR: Color = Color(0.45, 0.9, 0.55)
|
||
|
|
const SELECTED_RING: Color = Color(1.0, 0.85, 0.4)
|
||
|
|
# A locked node needs a dark core as well as a dim glow. NeonDraw tops every
|
||
|
|
# shape with a near-white core stroke, which at this size dominates the glow
|
||
|
|
# entirely - so a "dim" colour alone still came out the brightest thing on
|
||
|
|
# screen, making unreachable nodes look like the highlighted ones.
|
||
|
|
const LOCKED_CORE: Color = Color(0.17, 0.19, 0.26)
|
||
|
|
const LOCKED_PULSE: float = 0.5
|
||
|
|
|
||
|
|
@export var node_radius: float = 46.0
|
||
|
|
|
||
|
|
var selected_id: String = ""
|
||
|
|
|
||
|
|
|
||
|
|
func _ready() -> void:
|
||
|
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
|
|
|
||
|
|
|
||
|
|
func node_screen_pos(node: Dictionary) -> Vector2:
|
||
|
|
# Height comes from dependency depth, not from the data: the root sits at
|
||
|
|
# the bottom and each further prerequisite climbs a row, so the tree grows
|
||
|
|
# upwards as it is researched and stays correct when nodes are added.
|
||
|
|
var deepest: int = maxi(GameState.max_skill_depth(), 1)
|
||
|
|
var climb: float = float(GameState.skill_depth(node.id)) / float(deepest)
|
||
|
|
# Inset so a node on row 0 or column 0 is not clipped in half.
|
||
|
|
var margin: float = node_radius * 1.4
|
||
|
|
return Vector2(
|
||
|
|
lerpf(margin, size.x - margin, float(node.x)),
|
||
|
|
lerpf(size.y - margin, margin, climb)
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
func _state_color(node: Dictionary) -> Color:
|
||
|
|
if GameState.is_unlocked(node.id):
|
||
|
|
return DONE_COLOR
|
||
|
|
if GameState.prerequisite_met(node.id):
|
||
|
|
return READY_COLOR
|
||
|
|
return LOCKED_COLOR
|
||
|
|
|
||
|
|
|
||
|
|
# Returns true when the press landed on a node, so the caller knows the
|
||
|
|
# touch was consumed.
|
||
|
|
func handle_touch(event: InputEventScreenTouch) -> bool:
|
||
|
|
if not event.pressed or not visible:
|
||
|
|
return false
|
||
|
|
var local: Vector2 = event.position - global_position
|
||
|
|
for node in GameState.SKILL_TREE:
|
||
|
|
if local.distance_to(node_screen_pos(node)) <= node_radius * 1.25:
|
||
|
|
selected_id = node.id
|
||
|
|
queue_redraw()
|
||
|
|
node_picked.emit(node.id)
|
||
|
|
return true
|
||
|
|
return false
|
||
|
|
|
||
|
|
|
||
|
|
func _draw() -> void:
|
||
|
|
# Links first, so nodes sit on top of them.
|
||
|
|
for node in GameState.SKILL_TREE:
|
||
|
|
if node.requires == "":
|
||
|
|
continue
|
||
|
|
var parent: Dictionary = GameState.skill_node(node.requires)
|
||
|
|
if parent.is_empty():
|
||
|
|
continue
|
||
|
|
var from: Vector2 = node_screen_pos(parent)
|
||
|
|
var to: Vector2 = node_screen_pos(node)
|
||
|
|
# A link reads as "live" only once the step it leads to is actually
|
||
|
|
# reachable, so the eye follows the researched path outwards.
|
||
|
|
var lit: bool = GameState.is_unlocked(node.id)
|
||
|
|
var reachable: bool = GameState.prerequisite_met(node.id)
|
||
|
|
var link_color: Color = DONE_COLOR if lit else (READY_COLOR if reachable else LOCKED_COLOR)
|
||
|
|
var trim: Vector2 = (to - from).normalized() * node_radius
|
||
|
|
NeonDraw.draw_glowing_line(self, from + trim, to - trim, link_color,
|
||
|
|
1.0 if reachable else LOCKED_PULSE, 0.4,
|
||
|
|
NeonDraw.DEFAULT_CORE if reachable else LOCKED_CORE)
|
||
|
|
|
||
|
|
for node in GameState.SKILL_TREE:
|
||
|
|
var centre: Vector2 = node_screen_pos(node)
|
||
|
|
var colour: Color = _state_color(node)
|
||
|
|
var reach: bool = GameState.prerequisite_met(node.id)
|
||
|
|
var pulse: float = 1.0 if reach else LOCKED_PULSE
|
||
|
|
var core_stroke: Color = NeonDraw.DEFAULT_CORE if reach else LOCKED_CORE
|
||
|
|
var hexagon: PackedVector2Array = PackedVector2Array()
|
||
|
|
for i in range(6):
|
||
|
|
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
|
||
|
|
hexagon.append(centre + Vector2(cos(a), sin(a)) * node_radius)
|
||
|
|
NeonDraw.draw_glowing_polygon(self, hexagon, colour, pulse, core_stroke)
|
||
|
|
|
||
|
|
# Researched nodes get a filled-in core; the rest stay hollow.
|
||
|
|
if GameState.is_unlocked(node.id):
|
||
|
|
var core: PackedVector2Array = PackedVector2Array()
|
||
|
|
for i in range(6):
|
||
|
|
var a: float = (float(i) / 6.0) * TAU + PI / 6.0
|
||
|
|
core.append(centre + Vector2(cos(a), sin(a)) * node_radius * 0.42)
|
||
|
|
NeonDraw.draw_glowing_polygon(self, core, colour, 1.0)
|
||
|
|
|
||
|
|
if node.id == selected_id:
|
||
|
|
var ring: PackedVector2Array = PackedVector2Array()
|
||
|
|
for i in range(6):
|
||
|
|
var a: float = (float(i) / 6.0) * TAU
|
||
|
|
ring.append(centre + Vector2(cos(a), sin(a)) * node_radius * 1.32)
|
||
|
|
NeonDraw.draw_glowing_polygon(self, ring, SELECTED_RING, 0.9)
|