diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index cd3bd69..9fa9aec 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -32,14 +32,14 @@ const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd") get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"), get_node("../UI/LabMenu/VesselC"), get_node("../UI/LabMenu/VesselD"), get_node("../UI/LabMenu/VesselE"), get_node("../UI/LabMenu/VesselF"), - get_node("../UI/LabMenu/VesselG"), + get_node("../UI/LabMenu/VesselG"), get_node("../UI/LabMenu/VesselH"), ] @onready var lab_vessel_label: Label = get_node("../UI/LabMenu/VesselLabel") @onready var lab_vessel_labels: Array = [ get_node("../UI/LabMenu/VesselALabel"), get_node("../UI/LabMenu/VesselBLabel"), get_node("../UI/LabMenu/VesselCLabel"), get_node("../UI/LabMenu/VesselDLabel"), get_node("../UI/LabMenu/VesselELabel"), get_node("../UI/LabMenu/VesselFLabel"), - get_node("../UI/LabMenu/VesselGLabel"), + get_node("../UI/LabMenu/VesselGLabel"), get_node("../UI/LabMenu/VesselHLabel"), ] @onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel") @onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel") @@ -267,6 +267,8 @@ func _seat_hud_columns() -> void: func _ready() -> void: _seat_hud_columns() _layout_hangar() + _build_craft_price_strips() + _layout_craft_menu() build_button.pressed.connect(_on_build_pressed) build_close_button.pressed.connect(_close_build_menu) garrison_close_button.pressed.connect(_close_garrison_menu) @@ -385,6 +387,14 @@ func _unhandled_input(event: InputEvent) -> void: formation_editor.handle_touch(event) elif event is InputEventScreenDrag: formation_editor.handle_drag(event) + elif event is InputEventMouseButton: + # #121: a click is not a touch. Same routing gap as #93's + # research tree - the panel is transparent to GUI input so the + # raw handlers can see the drag, which means nothing turns a + # click into one on the way. + formation_editor.handle_mouse_button(event) + elif event is InputEventMouseMotion: + formation_editor.handle_mouse_motion(event) get_viewport().set_input_as_handled() return if build_menu.visible or craft_menu.visible or world_map.visible or lab_menu.visible \ @@ -1027,6 +1037,58 @@ func _format_missing_costs(costs: Dictionary) -> String: return " ยท ".join(parts) +# --- the craft menu's own layout (#111) ------------------------------------ +# +# Six vessels were authored as one row of 65px buttons with 105px captions +# overlapping each other, which left "Energy Propulser" clipped and the price +# crushed onto a second line under it. Laid out here instead, in two rows of +# three, so a cell is wide enough for the name to breathe - and the price is +# glyphs rather than words (see VesselPriceStrip). +const CRAFT_COLUMNS: int = 3 +const CRAFT_ROW_TOPS: Array = [-96.0, 104.0] +const CRAFT_COL_X: float = 190.0 +const CRAFT_BUTTON_HALF: float = 56.0 +const CRAFT_BUTTON_H: float = 112.0 +const CRAFT_LABEL_HALF: float = 92.0 +const CRAFT_LABEL_H: float = 62.0 +const CRAFT_LABEL_OFFSET: float = 118.0 +const CRAFT_PRICE_HALF: float = 92.0 +const CRAFT_PRICE_H: float = 32.0 +const CRAFT_PRICE_OFFSET: float = 182.0 + +var craft_price_strips: Array = [] + + +func _build_craft_price_strips() -> void: + var strip_script: Script = load("res://ui/VesselPriceStrip.gd") + for i in range(craft_vessel_buttons.size()): + var strip: Control = strip_script.new() + strip.name = "CraftPrice%d" % i + # Centre anchors, matching the buttons and captions authored in the + # scene - _place_entry writes offsets, and an offset means something + # different against a top-left anchor. + strip.anchor_left = 0.5 + strip.anchor_right = 0.5 + strip.anchor_top = 0.5 + strip.anchor_bottom = 0.5 + craft_menu.add_child(strip) + craft_price_strips.append(strip) + + +func _layout_craft_menu() -> void: + for i in range(craft_vessel_buttons.size()): + var row: int = i / CRAFT_COLUMNS + var column: int = i % CRAFT_COLUMNS + var cx: float = (float(column) - 1.0) * CRAFT_COL_X + var top: float = CRAFT_ROW_TOPS[mini(row, CRAFT_ROW_TOPS.size() - 1)] + _place_entry(craft_vessel_buttons[i], cx, top, CRAFT_BUTTON_HALF, CRAFT_BUTTON_H) + _place_entry(craft_vessel_labels[i], cx, top + CRAFT_LABEL_OFFSET, + CRAFT_LABEL_HALF, CRAFT_LABEL_H) + if i < craft_price_strips.size(): + _place_entry(craft_price_strips[i], cx, top + CRAFT_PRICE_OFFSET, + CRAFT_PRICE_HALF, CRAFT_PRICE_H) + + func _refresh_craft_status() -> void: # Prices live with the buttons they describe. The top of the screen is # intentionally reserved for the shared resource bar and a short action @@ -1036,8 +1098,11 @@ func _refresh_craft_status() -> void: for i in range(mini(definitions.size(), craft_vessel_buttons.size())): var definition: Resource = definitions[i] var button: IconButton = craft_vessel_buttons[i] - craft_vessel_labels[i].text = "%s\n%s" % [definition.display_name, - _format_costs(GameState.product_costs(definition.definition_id))] + # #111: the name alone. The price is the strip under it, in glyphs. + craft_vessel_labels[i].text = String(definition.display_name) + if i < craft_price_strips.size(): + craft_price_strips[i].set_costs( + GameState.product_costs(definition.definition_id)) button.icon = definition.icon button.glow_color = definition.glow_color button.enabled = GameState.vessel_is_unlocked(definition.definition_id) @@ -1392,6 +1457,15 @@ func _hangar_types() -> Array: # each cell room, and the second row costs nothing: the lower half of the # menu was empty. const HANGAR_COLUMNS: int = 3 +# #120: room for a two-line caption under each icon, and air around the rule +# that separates the two halves. +const HANGAR_LABEL_GAP: float = 2.0 +const HANGAR_DIVIDER_GAP: float = 10.0 + +# Where the rule between "available" and "in the fleet" is drawn. Computed by +# the layout and read by the menu's own _draw, so the line cannot drift away +# from the sections it divides. +var hangar_divider_y: float = 0.0 # Arranges both hangar sections as grids sized from the space actually @@ -1416,10 +1490,12 @@ func _layout_hangar() -> void: var cell_width: float = width / float(columns) var cell_height: float = section_height / float(rows) - # Squarer than the authored 75x120: a grid cell is wider than it is tall - # here, so height is what constrains the icon. - var slot_size: float = minf(cell_height * 0.58, cell_width * 0.42) - var label_height: float = maxf(cell_height - slot_size - 6.0, 1.0) + # #120: the icon gives up height so the caption under it has somewhere to + # go. Vessel names are two and three words long and were wrapping into a + # box barely tall enough for one line, so the second line was clipped and + # a "Gas Bomber" read as "Gas". + var slot_size: float = minf(cell_height * 0.46, cell_width * 0.40) + var label_height: float = maxf(cell_height - slot_size - HANGAR_LABEL_GAP, 1.0) _layout_hangar_section(hangar_available_slots, hangar_available_labels, top, columns, cell_width, cell_height, slot_size, label_height, width) @@ -1427,10 +1503,15 @@ func _layout_hangar() -> void: # by subtracting from the second section's top put it back inside the # first one, straight over its bottom row. var available_bottom: float = top + section_height - hangar_fleet_title.position.y = available_bottom + 4.0 - var fleet_top: float = available_bottom + title_height + 8.0 + # #120: a rule between the two halves. They are different questions - + # what is parked here, and what is flying - and with only a heading + # between them the grids read as one long list. + hangar_divider_y = available_bottom + HANGAR_DIVIDER_GAP + hangar_fleet_title.position.y = hangar_divider_y + HANGAR_DIVIDER_GAP + var fleet_top: float = hangar_fleet_title.position.y + title_height + 8.0 _layout_hangar_section(hangar_fleet_slots, hangar_fleet_labels, fleet_top, columns, cell_width, cell_height, slot_size, label_height, width) + hangar_menu.queue_redraw() func _layout_hangar_section(slots: Array, labels: Array, top: float, columns: int, diff --git a/controllers/GameController.gd b/controllers/GameController.gd index a2fe086..bd605b3 100644 --- a/controllers/GameController.gd +++ b/controllers/GameController.gd @@ -293,8 +293,16 @@ func _unhandled_input(event: InputEvent) -> void: _handle_key(event) elif event is InputEventMouseMotion: _note_keyboard() - elif event is InputEventMouseButton and event.pressed: + if speed_fader != null: + speed_fader.handle_mouse_motion(event) + elif event is InputEventMouseButton: _note_keyboard() + # #118: the fader gets first refusal, so dragging the boost does not + # also pan or zoom the world behind it. + if speed_fader != null and speed_fader.handle_mouse_button(event): + return + if not event.pressed: + return # Wheel zoom, the desktop equivalent of the pinch. if event.button_index == MOUSE_BUTTON_WHEEL_UP: _set_zoom(camera.zoom.x * WHEEL_ZOOM_STEP) diff --git a/ui/FormationEditor.gd b/ui/FormationEditor.gd index 9ecab2a..79a05b8 100644 --- a/ui/FormationEditor.gd +++ b/ui/FormationEditor.gd @@ -47,11 +47,78 @@ func _screen_to_formation(p: Vector2) -> Vector2: # Returns true when the event was consumed. Called from BuildController, # which forwards input while the editor is open. +# #121: the formation could not be rearranged on a desktop at all. Like the +# research tree (#93) and the speed fader (#118), this control reads raw +# touches, and the project no longer turns clicks into them. Wrappers over +# the same press/drag logic, never a second implementation. +# +# The wheel replaces the pinch: a mouse has no second finger, and zoom is +# the other half of what the panel does. +const MOUSE_TOUCH_INDEX: int = -2 +const WHEEL_ZOOM_STEP: float = 1.12 + + +func handle_mouse_button(event: InputEventMouseButton) -> bool: + if not visible: + return false + if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed: + return _zoom_by(WHEEL_ZOOM_STEP) + if event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed: + return _zoom_by(1.0 / WHEEL_ZOOM_STEP) + if event.button_index != MOUSE_BUTTON_LEFT: + return false + if event.pressed: + return _grab(event.position, MOUSE_TOUCH_INDEX) + if drag_touch_index != MOUSE_TOUCH_INDEX: + return false + dragging_index = -1 + drag_touch_index = -1 + queue_redraw() + return true + + +func handle_mouse_motion(event: InputEventMouseMotion) -> bool: + if drag_touch_index != MOUSE_TOUCH_INDEX or dragging_index == -1: + return false + _move_slot(event.position) + return true + + +func _zoom_by(factor: float) -> bool: + scale_factor = clamp(scale_factor * factor, min_scale, max_scale) + queue_redraw() + return true + + +func _grab(at: Vector2, index: int) -> bool: + if dragging_index != -1: + return false + var local: Vector2 = at - global_position + for i in range(GameState.max_fleet_size()): + if local.distance_to(_slot_screen_pos(i)) <= marker_radius * 1.4: + dragging_index = i + drag_touch_index = index + queue_redraw() + return true + return false + + +func _move_slot(at: Vector2) -> void: + var local: Vector2 = at - global_position + # Clamped so a slot can't be dragged off the panel and become + # unreachable (and so the fleet can't be spread absurdly far apart). + var f: Vector2 = _screen_to_formation(local) + var limit: float = min(size.x, size.y) / (2.0 * scale_factor) - marker_radius / scale_factor + GameState.formation[dragging_index] = Vector2( + clamp(f.x, -limit, limit), clamp(f.y, -limit, limit) + ) + queue_redraw() + formation_changed.emit() + + func handle_touch(event: InputEventScreenTouch) -> bool: if not visible: return false - var local: Vector2 = event.position - global_position - if event.pressed: touches[event.index] = event.position if touches.size() == 2: @@ -65,15 +132,7 @@ func handle_touch(event: InputEventScreenTouch) -> bool: pinch_active = true queue_redraw() return true - if dragging_index != -1: - return false - for i in range(GameState.max_fleet_size()): - if local.distance_to(_slot_screen_pos(i)) <= marker_radius * 1.4: - dragging_index = i - drag_touch_index = event.index - queue_redraw() - return true - return false + return _grab(event.position, event.index) touches.erase(event.index) if touches.size() < 2: @@ -105,16 +164,7 @@ func handle_drag(event: InputEventScreenDrag) -> bool: if event.index != drag_touch_index or dragging_index == -1: return false - var local: Vector2 = event.position - global_position - # Clamped so a slot can't be dragged off the panel and become - # unreachable (and so the fleet can't be spread absurdly far apart). - var f: Vector2 = _screen_to_formation(local) - var limit: float = min(size.x, size.y) / (2.0 * scale_factor) - marker_radius / scale_factor - GameState.formation[dragging_index] = Vector2( - clamp(f.x, -limit, limit), clamp(f.y, -limit, limit) - ) - queue_redraw() - formation_changed.emit() + _move_slot(event.position) return true @@ -126,13 +176,46 @@ func _draw() -> void: NeonDraw.draw_glowing_line(self, centre + Vector2(-tick, 0), centre + Vector2(tick, 0), Color(0.4, 0.45, 0.5), 0.5, 0.3) NeonDraw.draw_glowing_line(self, centre + Vector2(0, -tick), centre + Vector2(0, tick), Color(0.4, 0.45, 0.5), 0.5, 0.3) + # #119: each slot is drawn as the vessel actually standing in it, in its + # own colour and its own hull, rather than as an identical placeholder + # dart. Arranging a formation means deciding where the shield sits + # relative to the guns, and that is impossible to see when every marker + # is the same triangle. + var crewed: PackedStringArray = GameState.fleet_vessel_types for i in range(GameState.max_fleet_size()): var p: Vector2 = _slot_screen_pos(i) var held: bool = i == dragging_index - # Ship glyph, so the layout reads the way it will look in flight. - var points: PackedVector2Array = PackedVector2Array([ - p + Vector2(0, -marker_radius), - p + Vector2(-marker_radius * 0.66, marker_radius * 0.72), - p + Vector2(marker_radius * 0.66, marker_radius * 0.72), - ]) - NeonDraw.draw_glowing_polygon(self, points, glow_color, 1.0 if held else 0.7) + var vessel_type: String = String(crewed[i]) if i < crewed.size() else "" + var definition: Resource = GameState.vessel_definition(vessel_type) \ + if vessel_type != "" else null + if definition == null: + # An empty slot: the outline of a berth, not a ship. A hollow + # marker is how the panel says "nothing is standing here yet". + _draw_empty_slot(p, held) + continue + _draw_vessel_slot(p, definition, held) + + +# Scaled so the biggest hull in the catalog still fits the marker, and drawn +# from the definition's own points so a vessel reshaped in its .tres is +# reshaped here too. +func _draw_vessel_slot(at: Vector2, definition: Resource, held: bool) -> void: + var points: PackedVector2Array = definition.hull_points + var extent: float = 1.0 + for point in points: + extent = maxf(extent, point.length()) + var factor: float = marker_radius / extent + var shaped: PackedVector2Array = PackedVector2Array() + for point in points: + shaped.append(at + point * factor) + NeonDraw.draw_glowing_polygon(self, shaped, definition.glow_color, + 1.0 if held else 0.75) + + +func _draw_empty_slot(at: Vector2, held: bool) -> void: + var ring: PackedVector2Array = PackedVector2Array() + for i in range(12): + var a: float = (float(i) / 12.0) * TAU + ring.append(at + Vector2(cos(a), sin(a)) * marker_radius * 0.78) + NeonDraw.draw_glowing_polygon(self, ring, Color(0.42, 0.47, 0.58), + 0.8 if held else 0.4) diff --git a/ui/SpeedFader.gd b/ui/SpeedFader.gd index 69d9a0f..bebda79 100644 --- a/ui/SpeedFader.gd +++ b/ui/SpeedFader.gd @@ -77,17 +77,45 @@ func _blocked_by_modal() -> bool: return false +# #118: the same fader, driven by a mouse. +# +# The project used to set emulate_touch_from_mouse, which handed every raw +# touch handler a synthetic touch per click; with that gone this control was +# inert on a desktop and there was no other way to reach the boost. Written +# as thin wrappers over the touch path rather than as a second +# implementation, so the two cannot drift - the same mistake #93 fixed in +# the research tree. +const MOUSE_TOUCH_INDEX: int = -2 + + +func handle_mouse_button(event: InputEventMouseButton) -> bool: + if event.button_index != MOUSE_BUTTON_LEFT: + return false + return _press(event.pressed, event.position, MOUSE_TOUCH_INDEX) + + +func handle_mouse_motion(event: InputEventMouseMotion) -> bool: + if active_touch_index != MOUSE_TOUCH_INDEX: + return false + _update_from_local(event.position - global_position) + return true + + func handle_touch(event: InputEventScreenTouch) -> bool: - var local: Vector2 = event.position - global_position - if event.pressed: + return _press(event.pressed, event.position, event.index) + + +func _press(pressed: bool, at: Vector2, index: int) -> bool: + var local: Vector2 = at - global_position + if pressed: if not visible or active_touch_index != -1 or _blocked_by_modal(): return false if not Rect2(Vector2.ZERO, size).has_point(local): return false - active_touch_index = event.index + active_touch_index = index _update_from_local(local) return true - if event.index != active_touch_index: + if index != active_touch_index: return false active_touch_index = -1 queue_redraw()