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. # # #93: a mouse is a first-class way to drive this menu, not something # emulation turns into touches. The project used to set # emulate_touch_from_mouse, which made every click arrive here as an # InputEventScreenTouch for free; with that off, a menu that only reads # touch events is simply dead under a mouse - which is what the report was. # So the press and the pan are the behaviour, and touch and mouse are two # ways in to them. 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 @export var row_spacing: float = 250.0 # One wheel notch: about a third of the default row spacing, so a notch is a # clear step without skipping a whole depth of the tree. const WHEEL_STEP: float = 90.0 # #83: a second tap on the node already selected buys it. The separate buy # button is gone - it sat at the bottom of the panel, a long way from the node # being chosen, and meant every purchase was look-down-tap-look-up. Tapping # the thing you want is the shorter sentence. # # Deliberately "tap the SAME node twice" rather than a timed double-tap: no # window to miss on a slow phone, no accidental purchase from a fast # double-tap while browsing, and the first tap always does something visible # (it selects, and the panel fills in with the cost and what it unlocks) so # the second one is never a blind commitment. signal node_confirmed(id: String) # One physical tap arrives TWICE. Godot emulates a mouse button from every # touch and a touch from every mouse button - both settings are on - and the # menu's dispatcher routes InputEventScreenTouch and InputEventMouseButton # here alike. The second arrival found the node already selected and took it # for the confirming tap, so a single tap bought the research outright: the # exact thing the two-tap rule exists to prevent (#162). Measured, not # guessed - one tap produced two picks and two confirms. # # What is ignored is specifically the TWIN: a press at the same place, in the # same frame, arriving through the OTHER input kind. Two presses of the same # kind are always two taps however close together they are, so a player # tapping twice quickly is never swallowed. Deduping on frame and position # alone did swallow it, which the test caught. enum PressSource { TOUCH, MOUSE } var _last_press_frame: int = -1 var _last_press_at: Vector2 = Vector2.ZERO var _last_press_source: int = -1 var selected_id: String = "" var scroll_y: float = 0.0 var _dragging: bool = false func _content_height() -> float: # The canvas deliberately exceeds the viewport. Four depths at this # spacing leave clear breathing room around 92px-wide nodes and make each # research branch read as a route rising away from Shipyards. return maxf(size.y, GameState.max_skill_depth() * row_spacing + node_radius * 3.0) func _max_scroll() -> float: return maxf(0.0, _content_height() - size.y) # #112: one glyph per node, as a child IconButton with its frame turned off. # Drawn as nodes rather than into _draw() because the glyphs already exist # there and are non-trivial - reimplementing a dozen of them here would be # two places to change every time one is redrawn. They are inert: # MOUSE_FILTER_IGNORE means the tree's own raw-input handling still sees # every press, exactly as it did when there was nothing on top of the nodes. var _glyphs: Dictionary = {} func _build_glyphs() -> void: for node in GameState.SKILL_TREE: var icon: int = GameState.skill_node_icon(node) if icon < 0: continue var glyph := IconButton.new() glyph.icon = icon glyph.show_frame = false glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(glyph) _glyphs[String(node.id)] = glyph func _layout_glyphs() -> void: var span: float = node_radius * 1.5 for node in GameState.SKILL_TREE: var glyph: Control = _glyphs.get(String(node.id)) if glyph == null: continue var centre: Vector2 = node_screen_pos(node) glyph.position = centre - Vector2(span, span) * 0.5 glyph.size = Vector2(span, span) # Off the top or bottom of the panel entirely: hidden rather than # clipped, so a glyph never bleeds past the canvas the tree scrolls # inside. glyph.visible = centre.y > -span and centre.y < size.y + span glyph.glow_color = _state_color(node) glyph.queue_redraw() func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE _build_glyphs() call_deferred("reset_scroll") func reset_scroll() -> void: # Start at Shipyards, the base of the tree, whenever the menu opens. # The player then swipes up to travel along the branches. scroll_y = _max_scroll() queue_redraw() func node_screen_pos(node: Dictionary) -> Vector2: # Every dependency depth gets its own widely-spaced horizontal row. The # x lane is stable within a branch (set beside SKILL_TREE), so Logistics, # Freight and Freight Yards rise vertically instead of zig-zagging across # the display. scroll_y exposes the part of this tall canvas in view. var margin: float = node_radius * 1.4 return Vector2( lerpf(margin, size.x - margin, float(node.x)), _content_height() - margin - float(GameState.skill_depth(node.id)) * row_spacing - scroll_y ) 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: return handle_press(event.pressed, event.position, PressSource.TOUCH) func handle_drag(event: InputEventScreenDrag) -> bool: return handle_pan(event.relative) # A left click is the same gesture as a tap. Other buttons are left alone so # they stay available for whatever else wants them. func handle_mouse_button(event: InputEventMouseButton) -> bool: if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed: return handle_pan(Vector2(0.0, WHEEL_STEP)) if event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed: return handle_pan(Vector2(0.0, -WHEEL_STEP)) if event.button_index != MOUSE_BUTTON_LEFT: return false return handle_press(event.pressed, event.position, PressSource.MOUSE) # Only while the button is held. A touch drag cannot arrive without a finger # on the glass, but the mouse moves across the menu the whole time it is # open, and panning the tree under a hovering pointer would make the menu # impossible to read. func handle_mouse_motion(event: InputEventMouseMotion) -> bool: if not _dragging: return false return handle_pan(event.relative) func handle_press(pressed: bool, at: Vector2, source: int = PressSource.TOUCH) -> bool: if not visible: return false if not pressed: _dragging = false return true if _is_emulated_twin(at, source): return true _dragging = true var local: Vector2 = at - global_position for node in GameState.SKILL_TREE: if local.distance_to(node_screen_pos(node)) <= node_radius * 1.25: var confirming: bool = selected_id == node.id selected_id = node.id queue_redraw() node_picked.emit(node.id) if confirming: node_confirmed.emit(node.id) return true return false # True only for the emulated twin: same frame, same place, other input kind. func _is_emulated_twin(at: Vector2, source: int) -> bool: var frame: int = Engine.get_process_frames() var twin: bool = frame == _last_press_frame \ and source != _last_press_source \ and at.distance_to(_last_press_at) < 24.0 if twin: return true _last_press_frame = frame _last_press_at = at _last_press_source = source return false func handle_pan(relative: Vector2) -> bool: if not visible: return false # Dragging a finger upward reveals higher research; dragging downward # returns to the root. Clamp hard so the canvas never leaves an empty gap. scroll_y = clampf(scroll_y - relative.y, 0.0, _max_scroll()) queue_redraw() return true func _draw() -> void: _layout_glyphs() # 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)