gdharness 1.1.12 → 1.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/cli.js CHANGED
@@ -17091,7 +17091,7 @@ var init_tool_definitions = __esm(() => {
17091
17091
  requires: []
17092
17092
  },
17093
17093
  rect: {
17094
- summary: "one node's rectangle or position, in canvas and in window pixels. A 3D node answers with the point to aim at, which is the middle of what it draws rather than the origin it stands on, the rectangle it covers under covers, the camera that drew it, and behind_camera when it is not in front of one",
17094
+ summary: "one node's rectangle or position, in canvas and in window pixels. Canvas is the node's own viewport; window is where it is drawn in the game's window, through any embedded dialog or SubViewport it sits in, which is what runtime_input mouse_click and mouse_motion take. A 3D node answers with the point to aim at, which is the middle of what it draws rather than the origin it stands on, the rectangle it covers under covers, the camera that drew it, and behind_camera when it is not in front of one",
17095
17095
  requires: ["nodePath"]
17096
17096
  },
17097
17097
  property: {
@@ -17290,7 +17290,7 @@ var init_tool_definitions = __esm(() => {
17290
17290
  requires: []
17291
17291
  },
17292
17292
  choose: {
17293
- summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen and what the button in front of it shows now",
17293
+ summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen, read before the press, what became of the control named (in_tree, removed or freed, since a game can rebuild its screen on the pick), and what the button in front of it shows now when it is still there",
17294
17294
  requires: ["nodePath"]
17295
17295
  },
17296
17296
  action: { summary: "press an action, or hold it", requires: ["action"] },
@@ -10,6 +10,8 @@ const Values = preload("runtime_values.gd")
10
10
  ## reports are the same place by construction rather than by agreement.
11
11
  const Queries = preload("runtime_queries.gd")
12
12
  const Read = preload("reading.gd")
13
+ const Menus = preload("runtime_menus.gd")
14
+ const Screen = preload("runtime_screen.gd")
13
15
  const Targets = preload("runtime_targets.gd")
14
16
  const Words = preload("runtime_words.gd")
15
17
 
@@ -410,36 +412,6 @@ func inject_mouse_motion(params: Dictionary) -> Dictionary:
410
412
  }
411
413
 
412
414
 
413
- ## The viewport a click aimed at [param control] has to be pushed into.
414
- ##
415
- ## Its own, unless that is an embedded [Window]. A ConfirmationDialog is a Window, and under
416
- ## `gui_embed_subwindows` a Window is drawn inside its parent rather than given one by the
417
- ## desktop: pushing into its own viewport delivers nothing, measured, with the pointer reading as
418
- ## over no control at all. What reaches it is the parent, which is what the engine does with a
419
- ## real pointer. Walked rather than stepped once, because a dialog can open a dialog.
420
- static func _clicking_viewport(control: Control) -> Viewport:
421
- var viewport: Viewport = control.get_viewport()
422
- var window: Window = viewport as Window
423
- while window != null and window.is_embedded() and window.get_parent() != null:
424
- viewport = window.get_parent().get_viewport()
425
- window = viewport as Window
426
- return viewport
427
-
428
-
429
- ## Where [param control]'s centre is in the viewport [method _clicking_viewport] names.
430
- ##
431
- ## The transform is against the control's own viewport, so every embedded window between it and
432
- ## that one contributes its offset. The same walk, because the two answers have to agree about
433
- ## which viewport they are describing.
434
- static func _centre_of(control: Control) -> Vector2:
435
- var centre: Vector2 = control.get_global_transform_with_canvas() * (control.size * 0.5)
436
- var window: Window = control.get_viewport() as Window
437
- while window != null and window.is_embedded() and window.get_parent() != null:
438
- centre += Vector2(window.position)
439
- window = window.get_parent().get_viewport() as Window
440
- return centre
441
-
442
-
443
415
  ## Scrolls whatever is holding [param control] until it is on screen, and answers whether
444
416
  ## anything moved.
445
417
  ##
@@ -521,8 +493,9 @@ func click(params: Dictionary) -> Dictionary:
521
493
  if not control.is_visible_in_tree():
522
494
  return {"type": "error", "message": "%s is not visible, so nothing can click it" % node_path}
523
495
 
524
- var viewport: Viewport = _clicking_viewport(control)
525
- var centre: Vector2 = _centre_of(control)
496
+ # In the control's own viewport first, which is the space its scrolling happens in.
497
+ var own: Viewport = control.get_viewport()
498
+ var local: Vector2 = control.get_global_transform_with_canvas() * (control.size * 0.5)
526
499
 
527
500
  # A control out of sight inside a ScrollContainer is not out of reach, it is one scroll away,
528
501
  # which is what a person does without thinking about it before they click. Refusing it
@@ -534,19 +507,23 @@ func click(params: Dictionary) -> Dictionary:
534
507
  # the click went to that instead and said so. Measured on a hall whose staff panel had been
535
508
  # scrolled past: the button was at y 69 and its container started at y 166.
536
509
  var scrolled: bool = false
537
- if not _in_sight(control, viewport, centre):
510
+ if not _in_sight(control, own, local):
538
511
  scrolled = _scroll_into_view(control)
539
512
  if scrolled:
540
513
  # A container moves its child on the next layout pass rather than inside the call.
541
514
  await _host.get_tree().process_frame
542
- centre = control.get_global_transform_with_canvas() * (control.size * 0.5)
515
+ local = control.get_global_transform_with_canvas() * (control.size * 0.5)
543
516
 
517
+ var reached: Dictionary = Screen.reach(own, local)
518
+ var viewport: Viewport = reached["viewport"]
519
+ var centre: Vector2 = reached["point"]
544
520
  var position: Vector2 = viewport.get_final_transform() * centre
545
521
  # The GUI only delivers to what is inside the viewport, so a centre outside it would be a
546
522
  # click that silently reached nothing. A game with no window has a 64 by 64 viewport
547
523
  # whatever the project settings say, which is the usual reason to be here and is not
548
- # something the caller can read off the rect on its own.
549
- if not viewport.get_visible_rect().has_point(centre):
524
+ # something the caller can read off the rect on its own. Its own viewport as well, since a
525
+ # control outside its SubViewport is drawn nowhere.
526
+ if not viewport.get_visible_rect().has_point(centre) or not own.get_visible_rect().has_point(local):
550
527
  var why: String = _no_window_note(viewport)
551
528
  if scrolled:
552
529
  why = ". It was scrolled as far as what holds it goes and is still out there"
@@ -583,20 +560,17 @@ func click(params: Dictionary) -> Dictionary:
583
560
  hovered_path = str(hovered.get_path())
584
561
  var landed: bool = hovered == control or (hovered != null and control.is_ancestor_of(hovered))
585
562
 
586
- viewport.push_input(_button(position, button, true, double))
563
+ _press_button(viewport, _button(position, button, true, double))
587
564
  await _host.get_tree().process_frame
588
- viewport.push_input(_button(position, button, false, false))
565
+ _press_button(viewport, _button(position, button, false, false))
589
566
  # The release is what a button acts on, and a queue_free it causes lands at the end of
590
567
  # this frame; the frame passes so the answer describes the control as the click left it.
591
568
  await _host.get_tree().process_frame
592
569
 
593
570
  # What became of the control: still in the tree, taken out of it, or freed. A button that
594
571
  # opened another screen is the second or the third, and the caller wants to hear that
595
- # rather than guess it from a tree that has changed shape. A freed reference cannot be
596
- # handed to anything typed, so the question is asked here.
597
- var afterwards: String = "freed"
598
- if is_instance_valid(control):
599
- afterwards = "in_tree" if control.is_inside_tree() else "removed"
572
+ # rather than guess it from a tree that has changed shape.
573
+ var afterwards: String = _afterwards(control)
600
574
 
601
575
  var answer: Dictionary = {
602
576
  "type": "clicked",
@@ -655,7 +629,7 @@ func choose(params: Dictionary) -> Dictionary:
655
629
  return standing
656
630
  var node: Node = standing["node"]
657
631
 
658
- var menu: PopupMenu = _menu_of(node)
632
+ var menu: PopupMenu = Menus.menu_of(node)
659
633
  if menu == null:
660
634
  return {
661
635
  "type": "error",
@@ -672,15 +646,15 @@ func choose(params: Dictionary) -> Dictionary:
672
646
  "message":
673
647
  (
674
648
  "%s needs the item named, by text or index. It holds: %s"
675
- % [node_path, ", ".join(_items_of(menu))]
649
+ % [node_path, ", ".join(Menus.items_of(menu))]
676
650
  )
677
651
  }
678
652
 
679
- var index: int = _wanted_item(menu, params)
653
+ var index: int = Menus.wanted_item(menu, params)
680
654
  if index < 0:
681
655
  return {
682
656
  "type": "error",
683
- "message": "%s has no such item. It holds: %s" % [node_path, ", ".join(_items_of(menu))]
657
+ "message": "%s has no such item. It holds: %s" % [node_path, ", ".join(Menus.items_of(menu))]
684
658
  }
685
659
  if menu.is_item_separator(index):
686
660
  var heading: String = Words.item_says(menu, index).strip_edges()
@@ -705,9 +679,22 @@ func choose(params: Dictionary) -> Dictionary:
705
679
  # Shown first, because a menu nobody has opened has no focus to move and Enter would go to
706
680
  # whatever is behind it. An OptionButton opens its own; a bare PopupMenu is popped where it
707
681
  # already sits, which leaves a menu that was already open where it is.
708
- var opened: bool = _open_the_menu(node, menu)
682
+ var opened: bool = Menus.open_the_menu(node, menu)
709
683
  await _host.get_tree().process_frame
710
684
 
685
+ # Read before the press, because the press can take the menu away: a game that rebuilds its
686
+ # settings screen on item_selected frees the button and its menu inside the pick, and reading
687
+ # the item afterwards was an engine error in the game's log and an empty answer to a pick that
688
+ # had taken.
689
+ var answer: Dictionary = {
690
+ "type": "chosen",
691
+ "path": node_path,
692
+ "index": index,
693
+ "text": Words.item_says(menu, index),
694
+ "id": menu.get_item_id(index),
695
+ "opened": opened,
696
+ "menu": str(menu.get_path()),
697
+ }
711
698
  menu.scroll_to_item(index)
712
699
  menu.set_focused_item(index)
713
700
  # Through Input rather than pushed at the menu, which is how a keyboard reaches an open one: a
@@ -718,96 +705,49 @@ func choose(params: Dictionary) -> Dictionary:
718
705
  Input.parse_input_event(_accept(false))
719
706
  await _host.get_tree().process_frame
720
707
 
721
- var answer: Dictionary = {
722
- "type": "chosen",
723
- "path": node_path,
724
- "index": index,
725
- "text": Words.item_says(menu, index),
726
- "id": menu.get_item_id(index),
727
- "opened": opened,
728
- "menu": str(menu.get_path()),
729
- }
708
+ answer["control_afterwards"] = _afterwards(node)
730
709
  # What the button in front of the menu reads now, which is the answer to "did it take": a menu
731
- # item that fired changes the thing holding it, and nothing else about the press says so.
732
- var chooser: OptionButton = node as OptionButton
733
- if chooser != null:
710
+ # item that fired changes the thing holding it, and nothing else about the press says so. A
711
+ # button the pick took away has nothing to read, and that it went is the answer.
712
+ if answer["control_afterwards"] == "in_tree" and node is OptionButton:
713
+ var chooser: OptionButton = node
734
714
  answer["selected"] = chooser.get_selected()
735
715
  answer["shows"] = Words.said_by(chooser)
736
716
  return answer
737
717
 
738
718
 
739
- ## The menu [param node] is, or the one it holds. An [OptionButton] and a [MenuButton] both keep
740
- ## theirs as an internal child, which is a node a caller cannot name and should not have to.
741
- static func _menu_of(node: Node) -> PopupMenu:
742
- var menu: PopupMenu = node as PopupMenu
743
- if menu != null:
744
- return menu
745
- if node.has_method("get_popup"):
746
- var held: Variant = node.call("get_popup")
747
- if held is PopupMenu:
748
- return held
749
- return null
719
+ ## Sends a click's press or release into [param viewport], through Input when that is the game's
720
+ ## own window.
721
+ ##
722
+ ## Through Input so that Input knows a button is held, which is what a real mouse tells it. A
723
+ ## PopupMenu opened by a press asks Input whether a button is down, and when one is, it lets the
724
+ ## release that ends that press go by rather than choosing the item under it. Pushed straight into
725
+ ## the viewport, the press left Input thinking nothing was held, so a dropdown near the bottom of
726
+ ## the screen, whose menu opens over it and under the pointer, had its first item chosen by the
727
+ ## click meant to open it. Games read that state too, through Input.is_mouse_button_pressed.
728
+ ##
729
+ ## Any other viewport is pushed into directly: Input reaches only the game's window. That is a
730
+ ## viewport with no place on the screen, since `Screen.reach` carries every other one there.
731
+ func _press_button(viewport: Viewport, event: InputEventMouseButton) -> void:
732
+ if viewport == _host.get_tree().root:
733
+ Input.parse_input_event(event)
734
+ # Delivered now, as a push is, rather than at the next frame: the answer is read a frame
735
+ # after the release, and a button that frees itself on it has to be gone by then.
736
+ Input.flush_buffered_events()
737
+ else:
738
+ viewport.push_input(event)
750
739
 
751
740
 
752
- ## Which item was asked for: `text`, matched exactly and then case-insensitively, or `index`.
753
- ## Minus one when neither names one that is there.
754
- ##
755
- ## `text` is matched against the words the item shows before the ones it holds. In a game with
756
- ## translations they differ, and the shown ones are what a caller has read off the screen: an item
757
- ## held as `ACT_ENGAGE` and shown as "Hire" refused "Hire" and listed the keys. The held words
758
- ## still count after them, for a caller that has the key from the source.
759
- static func _wanted_item(menu: PopupMenu, params: Dictionary) -> int:
760
- if params.has("index"):
761
- var asked: int = Read.as_int(params.get("index", -1), -1)
762
- return asked if asked >= 0 and asked < menu.get_item_count() else -1
763
- var wanted: String = str(params.get("text", ""))
764
- if wanted.is_empty():
765
- return -1
766
- # The items before the separators, because a heading can carry the same words as an item under
767
- # it, and finding the heading first refused the choice the caller meant. A separator is still
768
- # looked for after, so a heading asked for by name is refused as one rather than as missing.
769
- var order: Array[int] = []
770
- for separators: bool in [false, true]:
771
- for index: int in menu.get_item_count():
772
- if menu.is_item_separator(index) == separators:
773
- order.append(index)
774
- var shown: Array[String] = []
775
- var held: Array[String] = []
776
- for index: int in order:
777
- shown.append(Words.item_says(menu, index))
778
- held.append(menu.get_item_text(index))
779
- for words: Array[String] in [shown, held]:
780
- for at: int in words.size():
781
- if words[at] == wanted:
782
- return order[at]
783
- for at: int in words.size():
784
- if words[at].nocasecmp_to(wanted) == 0:
785
- return order[at]
786
- return -1
787
-
788
-
789
- ## What the menu shows, for a refusal that names the choices rather than the miss: a titled
790
- ## separator marked as the heading it is, and one with no title left out, since it shows nothing.
791
- static func _items_of(menu: PopupMenu) -> Array[String]:
792
- var said: Array[String] = []
793
- for index: int in menu.get_item_count():
794
- var words: String = Words.item_says(menu, index)
795
- if not menu.is_item_separator(index):
796
- said.append("%d: %s" % [index, words])
797
- elif not words.strip_edges().is_empty():
798
- said.append("%d: %s (a heading)" % [index, words])
799
- return said
800
-
801
-
802
- ## Opens the menu if it is not already, and answers whether anything opened.
803
- static func _open_the_menu(node: Node, menu: PopupMenu) -> bool:
804
- if menu.visible:
805
- return false
806
- if node.has_method("show_popup"):
807
- node.call("show_popup")
808
- return true
809
- menu.popup()
810
- return true
741
+ ## What became of [param node] once an input it was given has run: still in the tree, taken out
742
+ ## of it, or freed. Untyped, because a freed reference handed to a typed parameter is itself the
743
+ ## engine error this is here to avoid.
744
+ static func _afterwards(node: Variant) -> String:
745
+ if not is_instance_valid(node):
746
+ return "freed"
747
+ if not node is Node:
748
+ return "removed"
749
+ var placed: Node = node
750
+ return "in_tree" if placed.is_inside_tree() else "removed"
811
751
 
812
752
 
813
753
  func _accept(pressed: bool) -> InputEventKey:
@@ -859,13 +799,16 @@ func _click_in_the_world(node_path: String, item: Node3D, params: Dictionary) ->
859
799
  )
860
800
  }
861
801
 
862
- var position: Vector2 = viewport.get_final_transform() * aim
802
+ var reached: Dictionary = Screen.reach(viewport, aim)
803
+ var arriving: Viewport = reached["viewport"]
804
+ var reached_point: Vector2 = reached["point"]
805
+ var position: Vector2 = arriving.get_final_transform() * reached_point
863
806
  var button: int = _resolve_mouse_button(params.get("button", MOUSE_BUTTON_LEFT))
864
807
  if button < 0:
865
808
  return _no_such_button(params.get("button"))
866
809
  var double: bool = Read.as_bool(params.get("double", false))
867
810
 
868
- viewport.push_input(_motion(position, _arrive(position)))
811
+ arriving.push_input(_motion(position, _arrive(position)))
869
812
  # Read before the press, for the reason the Control click reads it: what the caller needs to
870
813
  # know is whether a panel is sitting over the room, and the press is what would change it.
871
814
  var hovered: Control = viewport.gui_get_hovered_control()
@@ -873,9 +816,9 @@ func _click_in_the_world(node_path: String, item: Node3D, params: Dictionary) ->
873
816
  if hovered != null:
874
817
  hovered_path = str(hovered.get_path())
875
818
 
876
- viewport.push_input(_button(position, button, true, double))
819
+ _press_button(arriving, _button(position, button, true, double))
877
820
  await _host.get_tree().process_frame
878
- viewport.push_input(_button(position, button, false, false))
821
+ _press_button(arriving, _button(position, button, false, false))
879
822
  await _host.get_tree().process_frame
880
823
 
881
824
  return {
@@ -0,0 +1,81 @@
1
+ extends RefCounted
2
+
3
+ ## The menu a choice is made from and the item in it, for a choice made by what an item says or by
4
+ ## where it is in the list.
5
+
6
+ const Read = preload("reading.gd")
7
+ const Words = preload("runtime_words.gd")
8
+
9
+
10
+ ## The menu [param node] is, or the one it holds. An [OptionButton] and a [MenuButton] both keep
11
+ ## theirs as an internal child, which is a node a caller cannot name and should not have to.
12
+ static func menu_of(node: Node) -> PopupMenu:
13
+ var menu: PopupMenu = node as PopupMenu
14
+ if menu != null:
15
+ return menu
16
+ if node.has_method("get_popup"):
17
+ var held: Variant = node.call("get_popup")
18
+ if held is PopupMenu:
19
+ return held
20
+ return null
21
+
22
+
23
+ ## Which item was asked for: `text`, matched exactly and then case-insensitively, or `index`.
24
+ ## Minus one when neither names one that is there.
25
+ ##
26
+ ## `text` is matched against the words the item shows before the ones it holds. In a game with
27
+ ## translations they differ, and the shown ones are what a caller has read off the screen: an item
28
+ ## held as `ACT_ENGAGE` and shown as "Hire" refused "Hire" and listed the keys. The held words
29
+ ## still count after them, for a caller that has the key from the source.
30
+ static func wanted_item(menu: PopupMenu, params: Dictionary) -> int:
31
+ if params.has("index"):
32
+ var asked: int = Read.as_int(params.get("index", -1), -1)
33
+ return asked if asked >= 0 and asked < menu.get_item_count() else -1
34
+ var wanted: String = str(params.get("text", ""))
35
+ if wanted.is_empty():
36
+ return -1
37
+ # The items before the separators, because a heading can carry the same words as an item under
38
+ # it, and finding the heading first refused the choice the caller meant. A separator is still
39
+ # looked for after, so a heading asked for by name is refused as one rather than as missing.
40
+ var order: Array[int] = []
41
+ for separators: bool in [false, true]:
42
+ for index: int in menu.get_item_count():
43
+ if menu.is_item_separator(index) == separators:
44
+ order.append(index)
45
+ var shown: Array[String] = []
46
+ var held: Array[String] = []
47
+ for index: int in order:
48
+ shown.append(Words.item_says(menu, index))
49
+ held.append(menu.get_item_text(index))
50
+ for words: Array[String] in [shown, held]:
51
+ for at: int in words.size():
52
+ if words[at] == wanted:
53
+ return order[at]
54
+ for at: int in words.size():
55
+ if words[at].nocasecmp_to(wanted) == 0:
56
+ return order[at]
57
+ return -1
58
+
59
+
60
+ ## What the menu shows, for a refusal that names the choices rather than the miss: a titled
61
+ ## separator marked as the heading it is, and one with no title left out, since it shows nothing.
62
+ static func items_of(menu: PopupMenu) -> Array[String]:
63
+ var said: Array[String] = []
64
+ for index: int in menu.get_item_count():
65
+ var words: String = Words.item_says(menu, index)
66
+ if not menu.is_item_separator(index):
67
+ said.append("%d: %s" % [index, words])
68
+ elif not words.strip_edges().is_empty():
69
+ said.append("%d: %s (a heading)" % [index, words])
70
+ return said
71
+
72
+
73
+ ## Opens the menu if it is not already, and answers whether anything opened.
74
+ static func open_the_menu(node: Node, menu: PopupMenu) -> bool:
75
+ if menu.visible:
76
+ return false
77
+ if node.has_method("show_popup"):
78
+ node.call("show_popup")
79
+ return true
80
+ menu.popup()
81
+ return true
@@ -0,0 +1 @@
1
+ uid://iv31ebsnrf1i
@@ -5,6 +5,7 @@ extends RefCounted
5
5
 
6
6
  const Paths = preload("runtime_paths.gd")
7
7
  const Read = preload("reading.gd")
8
+ const Screen = preload("runtime_screen.gd")
8
9
  const Values = preload("runtime_values.gd")
9
10
  const Words = preload("runtime_words.gd")
10
11
 
@@ -423,21 +424,30 @@ func get_rect(params: Dictionary) -> Dictionary:
423
424
  return standing
424
425
  var node: Node = standing["node"]
425
426
 
427
+ # In the window's pixels, which is what a mouse event names, through every embedded window and
428
+ # SubViewport between the node and the window: multiplied by its own viewport's transform alone,
429
+ # a control in a dialog lost the dialog's offset and one in a SubViewport was given its place in
430
+ # the SubViewport, and a mouse event sent there missed it.
426
431
  if node is Control:
427
432
  var control: Control = node
428
- var to_window: Transform2D = control.get_viewport().get_final_transform()
429
433
  var canvas_rect: Rect2 = control.get_global_rect()
430
434
  return {
431
435
  "type": "rect",
432
436
  "path": node_path,
433
437
  "visible": control.is_visible_in_tree(),
434
438
  "canvas": _values.serialize(canvas_rect),
435
- "window": _values.serialize(to_window * canvas_rect),
439
+ "window":
440
+ _values.serialize(
441
+ Screen.rect_in_window(
442
+ control.get_viewport(),
443
+ control.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, control.size)
444
+ )
445
+ ),
436
446
  }
437
447
  if node is Node2D:
438
448
  var item: Node2D = node
439
449
  var canvas_position: Vector2 = item.get_global_transform_with_canvas().origin
440
- var window_position: Vector2 = item.get_viewport().get_final_transform() * canvas_position
450
+ var window_position: Vector2 = Screen.in_window(item.get_viewport(), canvas_position)
441
451
  return {
442
452
  "type": "point",
443
453
  "path": node_path,
@@ -470,7 +480,7 @@ func _in_the_frame(node_path: String, item: Node3D) -> Dictionary:
470
480
  "message": "%s is not in a viewport with a current Camera3D, so nothing is drawing it" % node_path
471
481
  }
472
482
 
473
- var to_window: Transform2D = item.get_viewport().get_final_transform()
483
+ var drawn_in: Viewport = item.get_viewport()
474
484
  var answer: Dictionary = {
475
485
  "type": "point",
476
486
  "path": node_path,
@@ -481,12 +491,12 @@ func _in_the_frame(node_path: String, item: Node3D) -> Dictionary:
481
491
  if found.has("aim"):
482
492
  var aim: Vector2 = found["aim"]
483
493
  answer["canvas"] = _values.serialize(aim)
484
- answer["window"] = _values.serialize(to_window * aim)
494
+ answer["window"] = _values.serialize(Screen.in_window(drawn_in, aim))
485
495
  if found.has("rect"):
486
496
  var covered: Rect2 = found["rect"]
487
497
  answer["covers"] = {
488
498
  "canvas": _values.serialize(covered),
489
- "window": _values.serialize(to_window * covered),
499
+ "window": _values.serialize(Screen.rect_in_window(drawn_in, covered)),
490
500
  }
491
501
  return answer
492
502
 
@@ -0,0 +1,50 @@
1
+ extends RefCounted
2
+
3
+ ## Where a point in some viewport is on the game's own screen, for a click to be sent there and for
4
+ ## an answer to say where something is drawn.
5
+
6
+
7
+ ## The viewport a pointer at [param point], in [param viewport]'s own coordinates, arrives through,
8
+ ## and where it is there: {"viewport", "point"}.
9
+ ##
10
+ ## A real pointer arrives at the outermost viewport showing this one and is handed down, so that is
11
+ ## where a click is sent. An embedded [Window], a ConfirmationDialog for one, is drawn inside its
12
+ ## parent under `gui_embed_subwindows`: pushing into its own viewport delivered nothing, measured,
13
+ ## with the pointer reading as over no control at all. A [SubViewport] shown by a
14
+ ## [SubViewportContainer] is drawn inside the container, scaled by its shrink when it stretches, and
15
+ ## pushing into it read the point as the container's: the click on a dropdown inside one landed on
16
+ ## nothing. Walked rather than stepped once, because a dialog can open a dialog and a container can
17
+ ## sit inside another viewport. A SubViewport drawn some other way, onto a mesh for one, has no
18
+ ## place on the screen to carry the point to, and the point stays in it.
19
+ static func reach(viewport: Viewport, point: Vector2) -> Dictionary:
20
+ while true:
21
+ var window: Window = viewport as Window
22
+ if window != null and window.is_embedded() and window.get_parent() != null:
23
+ point += Vector2(window.position)
24
+ viewport = window.get_parent().get_viewport()
25
+ continue
26
+ var shown_by: SubViewportContainer = viewport.get_parent() as SubViewportContainer
27
+ if viewport is SubViewport and shown_by != null:
28
+ var scaled: Vector2 = point * float(shown_by.stretch_shrink) if shown_by.stretch else point
29
+ point = shown_by.get_global_transform_with_canvas() * scaled
30
+ viewport = shown_by.get_viewport()
31
+ continue
32
+ break
33
+ return {"viewport": viewport, "point": point}
34
+
35
+
36
+ ## Where [param point], in [param viewport]'s own coordinates, is in the pixels of the window the
37
+ ## game is shown in, which is what a mouse event names.
38
+ static func in_window(viewport: Viewport, point: Vector2) -> Vector2:
39
+ var reached: Dictionary = reach(viewport, point)
40
+ var outermost: Viewport = reached["viewport"]
41
+ var there: Vector2 = reached["point"]
42
+ return outermost.get_final_transform() * there
43
+
44
+
45
+ ## [param rect], in [param viewport]'s own coordinates, in the window's pixels: the box its two
46
+ ## corners make once carried there, which is the rect itself for anything not turned.
47
+ static func rect_in_window(viewport: Viewport, rect: Rect2) -> Rect2:
48
+ var start: Vector2 = in_window(viewport, rect.position)
49
+ var end: Vector2 = in_window(viewport, rect.end)
50
+ return Rect2(start.min(end), (end - start).abs())
@@ -0,0 +1 @@
1
+ uid://be3gcwt5krw7k
@@ -64,11 +64,12 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
64
64
  pending.push_front(children[index])
65
65
 
66
66
  var drawn: Array[Node] = _pointer_takers(root)
67
+ var areas: Array[Rect2] = _areas_of(drawn)
67
68
  var covered: int = 0
68
69
  var cover: Node = null
69
70
  var open: Array[int] = []
70
71
  for index: int in matched.size():
71
- var over: Node = _cover_of(matched[index], drawn)
72
+ var over: Node = _cover_of(matched[index], drawn, areas)
72
73
  if over == null:
73
74
  open.append(index)
74
75
  else:
@@ -224,6 +225,24 @@ static func _pointer_takers(root: Node) -> Array[Node]:
224
225
  return found
225
226
 
226
227
 
228
+ ## The box each of [param drawn] takes up in its viewport: a window's rectangle, and the box around
229
+ ## a control's however it is turned or scaled.
230
+ ##
231
+ ## Worked out once for a click rather than once for each match. A plain word on a screen of prose
232
+ ## matches a hundred labels, and asking all four thousand controls of a hall for their transform
233
+ ## for each of them took a click by words 80 ms, where the matching alone takes half that.
234
+ static func _areas_of(drawn: Array[Node]) -> Array[Rect2]:
235
+ var areas: Array[Rect2] = []
236
+ for each: Node in drawn:
237
+ var window: Window = each as Window
238
+ if window != null:
239
+ areas.append(Rect2(window.position, window.size))
240
+ else:
241
+ var control: Control = each
242
+ areas.append(control.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, control.size))
243
+ return areas
244
+
245
+
227
246
  ## What a pointer at [param target]'s centre reaches instead of it, or null when nothing does.
228
247
  ##
229
248
  ## The engine's own order for which control takes the pointer: an embedded window over everything
@@ -231,20 +250,25 @@ static func _pointer_takers(root: Node) -> Array[Node]:
231
250
  ## control holding the target or held by it is part of the same click. A target whose centre its
232
251
  ## own container clips away is judged not covered, since the click scrolls it into view first and
233
252
  ## what is drawn there now is not what it will land on.
234
- static func _cover_of(target: Control, drawn: Array[Node]) -> Node:
253
+ ##
254
+ ## [param areas] holds the box around each of [param drawn], so everything whose box misses the
255
+ ## point is passed over before anything costlier is asked of it.
256
+ static func _cover_of(target: Control, drawn: Array[Node], areas: Array[Rect2]) -> Node:
235
257
  var viewport: Viewport = target.get_viewport()
236
258
  var point: Vector2 = target.get_global_transform_with_canvas() * (target.size * 0.5)
237
259
  if _clipped_away(target, point):
238
260
  return null
239
261
  var layer: int = _layer_of(target)
240
- for other: Node in drawn:
262
+ for at: int in drawn.size():
263
+ if not areas[at].has_point(point):
264
+ continue
265
+ var other: Node = drawn[at]
241
266
  var window: Window = other as Window
242
267
  if window != null:
243
268
  if (
244
269
  window.get_parent() != null
245
270
  and window.get_parent().get_viewport() == viewport
246
271
  and not window.is_ancestor_of(target)
247
- and Rect2(window.position, window.size).has_point(point)
248
272
  ):
249
273
  return window
250
274
  continue
package/build/index.js CHANGED
@@ -30754,7 +30754,7 @@ var TOOL_SPECS = [
30754
30754
  requires: []
30755
30755
  },
30756
30756
  rect: {
30757
- summary: "one node's rectangle or position, in canvas and in window pixels. A 3D node answers with the point to aim at, which is the middle of what it draws rather than the origin it stands on, the rectangle it covers under covers, the camera that drew it, and behind_camera when it is not in front of one",
30757
+ summary: "one node's rectangle or position, in canvas and in window pixels. Canvas is the node's own viewport; window is where it is drawn in the game's window, through any embedded dialog or SubViewport it sits in, which is what runtime_input mouse_click and mouse_motion take. A 3D node answers with the point to aim at, which is the middle of what it draws rather than the origin it stands on, the rectangle it covers under covers, the camera that drew it, and behind_camera when it is not in front of one",
30758
30758
  requires: ["nodePath"]
30759
30759
  },
30760
30760
  property: {
@@ -30953,7 +30953,7 @@ var TOOL_SPECS = [
30953
30953
  requires: []
30954
30954
  },
30955
30955
  choose: {
30956
- summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen and what the button in front of it shows now",
30956
+ summary: "take an item out of a menu, by what it says or by where it is in the list. A menu's items are drawn rather than built, so there is nothing to click: the item takes the focus and Enter presses it, which is the engine's own path and needs no window. Answers with what was chosen, read before the press, what became of the control named (in_tree, removed or freed, since a game can rebuild its screen on the pick), and what the button in front of it shows now when it is still there",
30957
30957
  requires: ["nodePath"]
30958
30958
  },
30959
30959
  action: { summary: "press an action, or hold it", requires: ["action"] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.12",
3
+ "version": "1.1.13",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",