gdharness 0.6.1 → 0.6.3
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 +36 -11
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +243 -12
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +117 -3
- package/build/index.js +33 -11
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -15785,7 +15785,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
15785
15785
|
requires: []
|
|
15786
15786
|
},
|
|
15787
15787
|
rect: {
|
|
15788
|
-
summary: "one node's rectangle or position, in canvas and in window pixels",
|
|
15788
|
+
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",
|
|
15789
15789
|
requires: ["nodePath"]
|
|
15790
15790
|
},
|
|
15791
15791
|
property: {
|
|
@@ -15834,17 +15834,24 @@ var init_tool_definitions = __esm(() => {
|
|
|
15834
15834
|
},
|
|
15835
15835
|
{
|
|
15836
15836
|
name: "runtime_input",
|
|
15837
|
-
description: "Input to the running game: a whole click on a Control named by path, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
|
|
15837
|
+
description: "Input to the running game: a whole click on a Control or a 3D node named by path, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
|
|
15838
15838
|
parameters: {
|
|
15839
15839
|
projectPath: RUNNING_PROJECT_PATH,
|
|
15840
|
-
nodePath: {
|
|
15840
|
+
nodePath: {
|
|
15841
|
+
type: "string",
|
|
15842
|
+
description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn. choose: the PopupMenu, or the OptionButton or MenuButton in front of one."
|
|
15843
|
+
},
|
|
15841
15844
|
action: { type: "string", description: "action: the InputMap action name." },
|
|
15842
15845
|
pressed: { type: "boolean", description: "Press or release. Default true." },
|
|
15843
15846
|
strength: { type: "number", description: "action: 0 to 1. Default 1." },
|
|
15844
15847
|
keycode: { type: "string", description: 'key: the key name, such as "Space" or "A".' },
|
|
15845
15848
|
text: {
|
|
15846
15849
|
type: "string",
|
|
15847
|
-
description: "text: what to type. A newline is Enter and a tab is Tab."
|
|
15850
|
+
description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
|
|
15851
|
+
},
|
|
15852
|
+
index: {
|
|
15853
|
+
type: "number",
|
|
15854
|
+
description: "choose: the item to take, by where it is in the list, when text will not do."
|
|
15848
15855
|
},
|
|
15849
15856
|
shift: { type: "boolean" },
|
|
15850
15857
|
ctrl: { type: "boolean" },
|
|
@@ -15863,7 +15870,11 @@ var init_tool_definitions = __esm(() => {
|
|
|
15863
15870
|
requires: [],
|
|
15864
15871
|
operations: {
|
|
15865
15872
|
click: {
|
|
15866
|
-
summary: "press and release on a Control, a frame apart, and answer with what was under the pointer and what became of the control: in_tree, removed or freed. A control out of sight inside a ScrollContainer is scrolled to first, and scrolled_into_view says whether the view moved",
|
|
15873
|
+
summary: "press and release on a Control, a frame apart, and answer with what was under the pointer and what became of the control: in_tree, removed or freed. A control out of sight inside a ScrollContainer is scrolled to first, and scrolled_into_view says whether the view moved. A 3D node is clicked where it is drawn, and landed then says the interface did not swallow the press",
|
|
15874
|
+
requires: ["nodePath"]
|
|
15875
|
+
},
|
|
15876
|
+
choose: {
|
|
15877
|
+
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",
|
|
15867
15878
|
requires: ["nodePath"]
|
|
15868
15879
|
},
|
|
15869
15880
|
action: { summary: "press or release an action", requires: ["action"] },
|
|
@@ -36304,12 +36315,23 @@ class GodotServer {
|
|
|
36304
36315
|
case "runtime_capture":
|
|
36305
36316
|
return await this.handleRuntimeCommand(op === "screenshot" ? "capture_screenshot" : "capture_viewport", args);
|
|
36306
36317
|
case "runtime_input":
|
|
36307
|
-
|
|
36308
|
-
|
|
36309
|
-
|
|
36310
|
-
|
|
36311
|
-
|
|
36312
|
-
|
|
36318
|
+
if (op === "click") {
|
|
36319
|
+
return await this.handleRuntimeCommand("click", {
|
|
36320
|
+
projectPath: args["projectPath"],
|
|
36321
|
+
path: readNonEmptyString(args, "nodePath") ?? "",
|
|
36322
|
+
button: readString(args, "button") ?? "left",
|
|
36323
|
+
double: readBoolean(args, "doubleClick") ?? false
|
|
36324
|
+
});
|
|
36325
|
+
}
|
|
36326
|
+
if (op === "choose") {
|
|
36327
|
+
return await this.handleRuntimeCommand("choose", {
|
|
36328
|
+
projectPath: args["projectPath"],
|
|
36329
|
+
path: readNonEmptyString(args, "nodePath") ?? "",
|
|
36330
|
+
text: readString(args, "text") ?? "",
|
|
36331
|
+
...args["index"] === undefined ? {} : { index: args["index"] }
|
|
36332
|
+
});
|
|
36333
|
+
}
|
|
36334
|
+
return await this.handleRuntimeCommand(`inject_${op}`, args);
|
|
36313
36335
|
case "runtime_wait":
|
|
36314
36336
|
return await this.handleRuntimeWait(op, args);
|
|
36315
36337
|
case "debug_breakpoint": {
|
|
@@ -38641,6 +38663,9 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
|
|
|
38641
38663
|
| What a property reads right now | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
|
|
38642
38664
|
| Press a button | \`runtime_input click\`, which says what was under the pointer |
|
|
38643
38665
|
| Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` |
|
|
38666
|
+
| Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
|
|
38667
|
+
| Pick something out of a dropdown | \`runtime_input choose\` on it, by what the item says |
|
|
38668
|
+
| Where a 3D node is on screen | \`runtime_inspect\` \`rect\`, rather than unprojecting by hand |
|
|
38644
38669
|
| Wait for something | \`runtime_wait\`, never a sleep |
|
|
38645
38670
|
| A picture, for a person who asked to see one | \`runtime_capture\` |
|
|
38646
38671
|
|
|
@@ -70,6 +70,7 @@ func _init() -> void:
|
|
|
70
70
|
"inject_mouse_click": _input.inject_mouse_click,
|
|
71
71
|
"inject_mouse_motion": _input.inject_mouse_motion,
|
|
72
72
|
"click": _input.click,
|
|
73
|
+
"choose": _input.choose,
|
|
73
74
|
"wait_frames": _waits.wait_frames,
|
|
74
75
|
"wait_signal": _waits.wait_signal,
|
|
75
76
|
"wait_until": _waits.wait_until,
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
extends RefCounted
|
|
2
2
|
|
|
3
3
|
## Input handed to the running game as if a player had given it: actions, keys, the mouse, and
|
|
4
|
-
## a whole click on a Control found by path.
|
|
4
|
+
## a whole click on a Control or a 3D node found by path.
|
|
5
5
|
|
|
6
6
|
const Values = preload("runtime_values.gd")
|
|
7
7
|
|
|
8
|
+
## Where a 3D node is drawn, which is what a click aimed at one has to work out first. Asked of the
|
|
9
|
+
## query module rather than worked out again here, so the place this aims at and the place a rect
|
|
10
|
+
## reports are the same place by construction rather than by agreement.
|
|
11
|
+
const Queries = preload("runtime_queries.gd")
|
|
12
|
+
|
|
8
13
|
## The distance from a capital letter to its small one in Unicode. A keycode holds the capital.
|
|
9
14
|
const TO_SMALL: int = 32
|
|
10
15
|
|
|
@@ -285,8 +290,13 @@ func click(params: Dictionary) -> Dictionary:
|
|
|
285
290
|
var node: Node = _host.get_tree().root.get_node_or_null(node_path)
|
|
286
291
|
if node == null:
|
|
287
292
|
return {"type": "error", "message": "Node not found: " + node_path}
|
|
293
|
+
if node is Node3D:
|
|
294
|
+
return await _click_in_the_world(node_path, node, params)
|
|
288
295
|
if not node is Control:
|
|
289
|
-
return {
|
|
296
|
+
return {
|
|
297
|
+
"type": "error",
|
|
298
|
+
"message": "%s is a %s, not a Control or a Node3D" % [node_path, node.get_class()]
|
|
299
|
+
}
|
|
290
300
|
var control: Control = node
|
|
291
301
|
if not control.is_visible_in_tree():
|
|
292
302
|
return {"type": "error", "message": "%s is not visible, so nothing can click it" % node_path}
|
|
@@ -312,18 +322,9 @@ func click(params: Dictionary) -> Dictionary:
|
|
|
312
322
|
# whatever the project settings say, which is the usual reason to be here and is not
|
|
313
323
|
# something the caller can read off the rect on its own.
|
|
314
324
|
if not viewport.get_visible_rect().has_point(centre):
|
|
315
|
-
var why: String =
|
|
325
|
+
var why: String = _no_window_note(viewport)
|
|
316
326
|
if scrolled:
|
|
317
327
|
why = ". It was scrolled as far as what holds it goes and is still out there"
|
|
318
|
-
elif not _host.get_tree().root.can_draw():
|
|
319
|
-
why = ". This game has no window: run it with a window to reach this control"
|
|
320
|
-
# Only where it is true. The rect is printed just above, so claiming 64 by 64 over a
|
|
321
|
-
# viewport somebody has resized says two different things in one sentence.
|
|
322
|
-
if viewport.get_visible_rect().size == HEADLESS_VIEWPORT:
|
|
323
|
-
why = (
|
|
324
|
-
". This game has no window, and a game with no window has a 64 by 64 viewport "
|
|
325
|
-
+ "whatever the project settings say: run it with a window to reach this control"
|
|
326
|
-
)
|
|
327
328
|
return {
|
|
328
329
|
"type": "error",
|
|
329
330
|
"message":
|
|
@@ -383,6 +384,236 @@ func click(params: Dictionary) -> Dictionary:
|
|
|
383
384
|
}
|
|
384
385
|
|
|
385
386
|
|
|
387
|
+
## What to add to a refusal about a point outside the viewport, when the reason is that nobody
|
|
388
|
+
## gave this game a window. The rect on its own does not say it, and it is the usual reason.
|
|
389
|
+
##
|
|
390
|
+
## The size is only claimed where it is true: the rect is printed beside this, so naming 64 by 64
|
|
391
|
+
## over a viewport somebody has resized says two different things in one sentence.
|
|
392
|
+
func _no_window_note(viewport: Viewport) -> String:
|
|
393
|
+
if _host.get_tree().root.can_draw():
|
|
394
|
+
return ""
|
|
395
|
+
if viewport.get_visible_rect().size == HEADLESS_VIEWPORT:
|
|
396
|
+
return (
|
|
397
|
+
". This game has no window, and a game with no window has a 64 by 64 viewport whatever "
|
|
398
|
+
+ "the project settings say: run it with a window to reach this control"
|
|
399
|
+
)
|
|
400
|
+
return ". This game has no window: run it with a window to reach this control"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
## Chooses an item out of a menu, by what it says or by where it is in the list.
|
|
404
|
+
##
|
|
405
|
+
## A menu's items are drawn rather than built, so there is no node under the pointer to aim at and
|
|
406
|
+
## no rectangle to ask for: [PopupMenu] exposes their text, their ids and which one has the focus,
|
|
407
|
+
## and nothing about where any of them is. So a click cannot reach one, and a whole click on the
|
|
408
|
+
## [OptionButton] in front of it opens the menu on the press and closes it again on the release.
|
|
409
|
+
## Every language picker, every filter and every dropdown in a game was unreachable, and the way
|
|
410
|
+
## past it was to call `select` and emit `item_selected`, which sets a number and runs none of the
|
|
411
|
+
## engine's own path.
|
|
412
|
+
##
|
|
413
|
+
## Chosen the way a keyboard chooses: the item takes the focus and then Enter presses it, which is
|
|
414
|
+
## the same route through [PopupMenu] a pointer takes and which needs no geometry, so it works in a
|
|
415
|
+
## game with no window as well.
|
|
416
|
+
##
|
|
417
|
+
## [param path] may be the menu or the button in front of it. Naming the button is what a caller
|
|
418
|
+
## has, since the menu is an internal child with a generated name that changes between runs.
|
|
419
|
+
func choose(params: Dictionary) -> Dictionary:
|
|
420
|
+
var node_path: String = str(params.get("path", ""))
|
|
421
|
+
if node_path.is_empty():
|
|
422
|
+
return {"type": "error", "message": "Node path required"}
|
|
423
|
+
var node: Node = _host.get_tree().root.get_node_or_null(node_path)
|
|
424
|
+
if node == null:
|
|
425
|
+
return {"type": "error", "message": "Node not found: " + node_path}
|
|
426
|
+
|
|
427
|
+
var menu: PopupMenu = _menu_of(node)
|
|
428
|
+
if menu == null:
|
|
429
|
+
return {
|
|
430
|
+
"type": "error",
|
|
431
|
+
"message":
|
|
432
|
+
(
|
|
433
|
+
"%s is a %s, which is neither a PopupMenu nor something holding one"
|
|
434
|
+
% [node_path, node.get_class()]
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
var index: int = _wanted_item(menu, params)
|
|
439
|
+
if index < 0:
|
|
440
|
+
return {
|
|
441
|
+
"type": "error",
|
|
442
|
+
"message": "%s has no such item. It holds: %s" % [node_path, ", ".join(_items_of(menu))]
|
|
443
|
+
}
|
|
444
|
+
if menu.is_item_separator(index):
|
|
445
|
+
return {"type": "error", "message": "%s item %d is a separator, not a choice" % [node_path, index]}
|
|
446
|
+
if menu.is_item_disabled(index):
|
|
447
|
+
return {
|
|
448
|
+
"type": "error",
|
|
449
|
+
"message": "%s item %d, %s, is disabled" % [node_path, index, menu.get_item_text(index)]
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
# Shown first, because a menu nobody has opened has no focus to move and Enter would go to
|
|
453
|
+
# whatever is behind it. An OptionButton opens its own; a bare PopupMenu is popped where it
|
|
454
|
+
# already sits, which leaves a menu that was already open where it is.
|
|
455
|
+
var opened: bool = _open_the_menu(node, menu)
|
|
456
|
+
await _host.get_tree().process_frame
|
|
457
|
+
|
|
458
|
+
menu.scroll_to_item(index)
|
|
459
|
+
menu.set_focused_item(index)
|
|
460
|
+
# Through Input rather than pushed at the menu, which is how a keyboard reaches an open one: a
|
|
461
|
+
# popup is a Window, it takes the focus when it opens, and Input delivers to whichever window
|
|
462
|
+
# has it. Pushed straight at the menu the event arrived and nothing happened.
|
|
463
|
+
Input.parse_input_event(_accept(true))
|
|
464
|
+
await _host.get_tree().process_frame
|
|
465
|
+
Input.parse_input_event(_accept(false))
|
|
466
|
+
await _host.get_tree().process_frame
|
|
467
|
+
|
|
468
|
+
var answer: Dictionary = {
|
|
469
|
+
"type": "chosen",
|
|
470
|
+
"path": node_path,
|
|
471
|
+
"index": index,
|
|
472
|
+
"text": menu.get_item_text(index),
|
|
473
|
+
"id": menu.get_item_id(index),
|
|
474
|
+
"opened": opened,
|
|
475
|
+
"menu": str(menu.get_path()),
|
|
476
|
+
}
|
|
477
|
+
# What the button in front of the menu reads now, which is the answer to "did it take": a menu
|
|
478
|
+
# item that fired changes the thing holding it, and nothing else about the press says so.
|
|
479
|
+
var chooser: OptionButton = node as OptionButton
|
|
480
|
+
if chooser != null:
|
|
481
|
+
answer["selected"] = chooser.get_selected()
|
|
482
|
+
answer["shows"] = chooser.text
|
|
483
|
+
return answer
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
## The menu [param node] is, or the one it holds. An [OptionButton] and a [MenuButton] both keep
|
|
487
|
+
## theirs as an internal child, which is a node a caller cannot name and should not have to.
|
|
488
|
+
static func _menu_of(node: Node) -> PopupMenu:
|
|
489
|
+
var menu: PopupMenu = node as PopupMenu
|
|
490
|
+
if menu != null:
|
|
491
|
+
return menu
|
|
492
|
+
if node.has_method("get_popup"):
|
|
493
|
+
var held: Variant = node.call("get_popup")
|
|
494
|
+
if held is PopupMenu:
|
|
495
|
+
return held
|
|
496
|
+
return null
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
## Which item was asked for: `text`, matched exactly and then case-insensitively, or `index`.
|
|
500
|
+
## Minus one when neither names one that is there.
|
|
501
|
+
static func _wanted_item(menu: PopupMenu, params: Dictionary) -> int:
|
|
502
|
+
if params.has("index"):
|
|
503
|
+
var asked: int = int(params.get("index", -1))
|
|
504
|
+
return asked if asked >= 0 and asked < menu.get_item_count() else -1
|
|
505
|
+
var wanted: String = str(params.get("text", ""))
|
|
506
|
+
if wanted.is_empty():
|
|
507
|
+
return -1
|
|
508
|
+
for index: int in menu.get_item_count():
|
|
509
|
+
if menu.get_item_text(index) == wanted:
|
|
510
|
+
return index
|
|
511
|
+
for index: int in menu.get_item_count():
|
|
512
|
+
if menu.get_item_text(index).nocasecmp_to(wanted) == 0:
|
|
513
|
+
return index
|
|
514
|
+
return -1
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
## What the menu says, for a refusal that names the choices rather than the miss.
|
|
518
|
+
static func _items_of(menu: PopupMenu) -> PackedStringArray:
|
|
519
|
+
var said: PackedStringArray = PackedStringArray()
|
|
520
|
+
for index: int in menu.get_item_count():
|
|
521
|
+
said.append("%d: %s" % [index, menu.get_item_text(index)])
|
|
522
|
+
return said
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
## Opens the menu if it is not already, and answers whether anything opened.
|
|
526
|
+
static func _open_the_menu(node: Node, menu: PopupMenu) -> bool:
|
|
527
|
+
if menu.visible:
|
|
528
|
+
return false
|
|
529
|
+
if node.has_method("show_popup"):
|
|
530
|
+
node.call("show_popup")
|
|
531
|
+
return true
|
|
532
|
+
menu.popup()
|
|
533
|
+
return true
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
func _accept(pressed: bool) -> InputEventKey:
|
|
537
|
+
var event: InputEventKey = InputEventKey.new()
|
|
538
|
+
event.keycode = KEY_ENTER
|
|
539
|
+
event.physical_keycode = KEY_ENTER
|
|
540
|
+
event.key_label = KEY_ENTER
|
|
541
|
+
event.pressed = pressed
|
|
542
|
+
return event
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
## A whole click aimed at where a 3D node is drawn, for a game that picks with a ray out of the
|
|
546
|
+
## cursor rather than with a Control.
|
|
547
|
+
##
|
|
548
|
+
## The alternative was three calls: read the node's position, find the camera, unproject it, then
|
|
549
|
+
## push raw mouse events at the answer. Anything that walks has walked by the third, so the click
|
|
550
|
+
## lands where it used to be, which is a miss that looks exactly like a game that ignored it.
|
|
551
|
+
##
|
|
552
|
+
## What this can honestly say is where the click went and whether the interface took it: a Control
|
|
553
|
+
## under the pointer swallows the press and the room never hears it, and that is the failure worth
|
|
554
|
+
## naming. Whether the game's own picking then chose this node is the game's rule rather than
|
|
555
|
+
## anything the engine can be asked, so it is not claimed.
|
|
556
|
+
func _click_in_the_world(node_path: String, item: Node3D, params: Dictionary) -> Dictionary:
|
|
557
|
+
if not item.is_visible_in_tree():
|
|
558
|
+
return {"type": "error", "message": "%s is not visible, so nothing can click it" % node_path}
|
|
559
|
+
|
|
560
|
+
var found: Dictionary = Queries.in_frame(item)
|
|
561
|
+
if found.is_empty():
|
|
562
|
+
return {
|
|
563
|
+
"type": "error",
|
|
564
|
+
"message":
|
|
565
|
+
"%s is not in a viewport with a current Camera3D, so there is nowhere to click it" % node_path
|
|
566
|
+
}
|
|
567
|
+
if not found.has("aim"):
|
|
568
|
+
return {
|
|
569
|
+
"type": "error",
|
|
570
|
+
"message": "%s is behind the camera drawing it, so it is not on screen to click" % node_path
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
var viewport: Viewport = item.get_viewport()
|
|
574
|
+
var aim: Vector2 = found["aim"]
|
|
575
|
+
if not viewport.get_visible_rect().has_point(aim):
|
|
576
|
+
return {
|
|
577
|
+
"type": "error",
|
|
578
|
+
"message":
|
|
579
|
+
(
|
|
580
|
+
"%s is drawn at %s, outside the viewport %s, so nothing can click it%s"
|
|
581
|
+
% [node_path, aim, viewport.get_visible_rect(), _no_window_note(viewport)]
|
|
582
|
+
)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
var position: Vector2 = viewport.get_final_transform() * aim
|
|
586
|
+
var button: int = _resolve_mouse_button(params.get("button", MOUSE_BUTTON_LEFT))
|
|
587
|
+
var double: bool = bool(params.get("double", false))
|
|
588
|
+
|
|
589
|
+
viewport.push_input(_motion(position, Vector2.ZERO))
|
|
590
|
+
# Read before the press, for the reason the Control click reads it: what the caller needs to
|
|
591
|
+
# know is whether a panel is sitting over the room, and the press is what would change it.
|
|
592
|
+
var hovered: Control = viewport.gui_get_hovered_control()
|
|
593
|
+
var hovered_path: Variant = null
|
|
594
|
+
if hovered != null:
|
|
595
|
+
hovered_path = str(hovered.get_path())
|
|
596
|
+
|
|
597
|
+
viewport.push_input(_button(position, button, true, double))
|
|
598
|
+
await _host.get_tree().process_frame
|
|
599
|
+
viewport.push_input(_button(position, button, false, false))
|
|
600
|
+
await _host.get_tree().process_frame
|
|
601
|
+
|
|
602
|
+
return {
|
|
603
|
+
"type": "clicked",
|
|
604
|
+
"path": node_path,
|
|
605
|
+
"position": _values.serialize(position),
|
|
606
|
+
"button": button,
|
|
607
|
+
"double": double,
|
|
608
|
+
"hovered": hovered_path,
|
|
609
|
+
# The interface did not take it, so it reached the game's own input. As close to "it
|
|
610
|
+
# landed" as anything outside the game can get, and said in the same word the Control
|
|
611
|
+
# click says it in.
|
|
612
|
+
"landed": hovered == null,
|
|
613
|
+
"camera": found["camera"],
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
|
|
386
617
|
func _motion(position: Vector2, relative: Vector2) -> InputEventMouseMotion:
|
|
387
618
|
var event: InputEventMouseMotion = InputEventMouseMotion.new()
|
|
388
619
|
event.position = position
|
|
@@ -134,9 +134,10 @@ func _matches(
|
|
|
134
134
|
return true
|
|
135
135
|
|
|
136
136
|
|
|
137
|
-
## Where a node is on screen: a Control's rectangle,
|
|
138
|
-
## canvas coordinates the node reports and the window pixels input
|
|
139
|
-
## whenever the project stretches its viewport, which is what made a
|
|
137
|
+
## Where a node is on screen: a Control's rectangle, a Node2D's position, or the place a 3D node
|
|
138
|
+
## is drawn in, in both the canvas coordinates the node reports and the window pixels input
|
|
139
|
+
## arrives in. The two differ whenever the project stretches its viewport, which is what made a
|
|
140
|
+
## rect unusable for a click.
|
|
140
141
|
func get_rect(params: Dictionary) -> Dictionary:
|
|
141
142
|
var node_path: String = str(params.get("path", ""))
|
|
142
143
|
if node_path.is_empty():
|
|
@@ -168,11 +169,124 @@ func get_rect(params: Dictionary) -> Dictionary:
|
|
|
168
169
|
"canvas": _values.serialize(canvas_position),
|
|
169
170
|
"window": _values.serialize(window_position),
|
|
170
171
|
}
|
|
172
|
+
if node is Node3D:
|
|
173
|
+
return _in_the_frame(node_path, node)
|
|
171
174
|
return {
|
|
172
175
|
"type": "error", "message": "%s is a %s, which has no place on screen" % [node_path, node.get_class()]
|
|
173
176
|
}
|
|
174
177
|
|
|
175
178
|
|
|
179
|
+
## Where a 3D node is in the frame drawing it: the point to aim at, and the rectangle its own
|
|
180
|
+
## geometry covers, each in canvas coordinates and in window pixels.
|
|
181
|
+
##
|
|
182
|
+
## A 3D node had no answer here at all, so placing one meant reading its position, finding the
|
|
183
|
+
## camera and calling unproject_position by hand. Three calls, and anything that walks has walked
|
|
184
|
+
## between the first and the third: the aim lands where the thing used to be.
|
|
185
|
+
##
|
|
186
|
+
## The camera is named in the answer, because "where is it on screen" is a question about a camera
|
|
187
|
+
## and a game with two of them has two answers.
|
|
188
|
+
func _in_the_frame(node_path: String, item: Node3D) -> Dictionary:
|
|
189
|
+
var found: Dictionary = in_frame(item)
|
|
190
|
+
if found.is_empty():
|
|
191
|
+
return {
|
|
192
|
+
"type": "error",
|
|
193
|
+
"message": "%s is not in a viewport with a current Camera3D, so nothing is drawing it" % node_path
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
var to_window: Transform2D = item.get_viewport().get_final_transform()
|
|
197
|
+
var answer: Dictionary = {
|
|
198
|
+
"type": "point",
|
|
199
|
+
"path": node_path,
|
|
200
|
+
"visible": item.is_visible_in_tree(),
|
|
201
|
+
"camera": found["camera"],
|
|
202
|
+
"behind_camera": found["behind"],
|
|
203
|
+
}
|
|
204
|
+
if found.has("aim"):
|
|
205
|
+
var aim: Vector2 = found["aim"]
|
|
206
|
+
answer["canvas"] = _values.serialize(aim)
|
|
207
|
+
answer["window"] = _values.serialize(to_window * aim)
|
|
208
|
+
if found.has("rect"):
|
|
209
|
+
var covered: Rect2 = found["rect"]
|
|
210
|
+
answer["covers"] = {
|
|
211
|
+
"canvas": _values.serialize(covered),
|
|
212
|
+
"window": _values.serialize(to_window * covered),
|
|
213
|
+
}
|
|
214
|
+
return answer
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
## What anything aiming at a 3D node needs: the camera that draws it, whether it is behind that
|
|
218
|
+
## camera, the point on screen to aim at, and the rectangle it covers. Empty when no camera is
|
|
219
|
+
## drawing it at all.
|
|
220
|
+
##
|
|
221
|
+
## The aim is the middle of what the node draws rather than its origin, because a person clicking
|
|
222
|
+
## a character clicks the character and a character's origin is on the floor under their feet. A
|
|
223
|
+
## node that draws nothing has no middle and falls back to the origin, which is still a place.
|
|
224
|
+
##
|
|
225
|
+
## Absent keys rather than nulls: nothing drawn, or behind the camera, and each of those is a
|
|
226
|
+
## different answer from a coordinate that happens to be zero. Public and static because the click
|
|
227
|
+
## has to aim at the same point this reports, and two copies of the arithmetic would be two places
|
|
228
|
+
## on screen for one node the first time either changed.
|
|
229
|
+
static func in_frame(item: Node3D) -> Dictionary:
|
|
230
|
+
var viewport: Viewport = item.get_viewport()
|
|
231
|
+
if viewport == null:
|
|
232
|
+
return {}
|
|
233
|
+
var camera: Camera3D = viewport.get_camera_3d()
|
|
234
|
+
if camera == null:
|
|
235
|
+
return {}
|
|
236
|
+
|
|
237
|
+
var origin: Vector3 = item.global_transform.origin
|
|
238
|
+
var found: Dictionary = {"camera": str(camera.get_path()), "behind": camera.is_position_behind(origin)}
|
|
239
|
+
var box: AABB = drawn_box(item)
|
|
240
|
+
var draws: bool = box.size != Vector3.ZERO
|
|
241
|
+
var middle: Vector3 = box.get_center() if draws else origin
|
|
242
|
+
if not camera.is_position_behind(middle):
|
|
243
|
+
found["aim"] = camera.unproject_position(middle)
|
|
244
|
+
if draws:
|
|
245
|
+
found.merge(_around(box, camera))
|
|
246
|
+
return found
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
## The rectangle [param box] covers on screen, under the key `rect`, or nothing when any of it is
|
|
250
|
+
## behind the camera.
|
|
251
|
+
##
|
|
252
|
+
## All eight corners, because a box in space is not a box in the frame: an orthographic camera
|
|
253
|
+
## looking down a diagonal draws a cube as a hexagon, and the rectangle worth answering is the one
|
|
254
|
+
## around every corner of it. Nothing rather than a guess when a corner is behind the camera,
|
|
255
|
+
## because [method Camera3D.unproject_position] mirrors those back into view and a rectangle built
|
|
256
|
+
## from one is a rectangle somewhere else entirely.
|
|
257
|
+
static func _around(box: AABB, camera: Camera3D) -> Dictionary:
|
|
258
|
+
var seen: Rect2 = Rect2()
|
|
259
|
+
for index: int in 8:
|
|
260
|
+
var corner: Vector3 = box.get_endpoint(index)
|
|
261
|
+
if camera.is_position_behind(corner):
|
|
262
|
+
return {}
|
|
263
|
+
var point: Vector2 = camera.unproject_position(corner)
|
|
264
|
+
seen = Rect2(point, Vector2.ZERO) if index == 0 else seen.expand(point)
|
|
265
|
+
return {"rect": seen}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
## The box everything drawn under [param item] fits in, in world space, or a box with no size when
|
|
269
|
+
## nothing under it draws.
|
|
270
|
+
##
|
|
271
|
+
## Walked rather than asked of [param item] itself, because the node a caller names is the one that
|
|
272
|
+
## moves and the thing on screen is the mesh hanging off it: a character is a Node3D with a
|
|
273
|
+
## skeleton and a mesh under it, and the Node3D has no extent of its own at all.
|
|
274
|
+
static func drawn_box(item: Node3D) -> AABB:
|
|
275
|
+
var merged: AABB = AABB()
|
|
276
|
+
var found: bool = false
|
|
277
|
+
var pending: Array[Node] = [item]
|
|
278
|
+
while not pending.is_empty():
|
|
279
|
+
var node: Node = pending.pop_back()
|
|
280
|
+
var visual: VisualInstance3D = node as VisualInstance3D
|
|
281
|
+
if visual != null and visual.is_visible_in_tree():
|
|
282
|
+
var box: AABB = visual.global_transform * visual.get_aabb()
|
|
283
|
+
merged = box if not found else merged.merge(box)
|
|
284
|
+
found = true
|
|
285
|
+
for child: Node in node.get_children(true):
|
|
286
|
+
pending.push_back(child)
|
|
287
|
+
return merged
|
|
288
|
+
|
|
289
|
+
|
|
176
290
|
func get_property(params: Dictionary) -> Dictionary:
|
|
177
291
|
var node_path: String = str(params.get("path", ""))
|
|
178
292
|
var property: String = str(params.get("property", ""))
|
package/build/index.js
CHANGED
|
@@ -28299,7 +28299,7 @@ var TOOL_SPECS = [
|
|
|
28299
28299
|
requires: []
|
|
28300
28300
|
},
|
|
28301
28301
|
rect: {
|
|
28302
|
-
summary: "one node's rectangle or position, in canvas and in window pixels",
|
|
28302
|
+
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",
|
|
28303
28303
|
requires: ["nodePath"]
|
|
28304
28304
|
},
|
|
28305
28305
|
property: {
|
|
@@ -28348,17 +28348,24 @@ var TOOL_SPECS = [
|
|
|
28348
28348
|
},
|
|
28349
28349
|
{
|
|
28350
28350
|
name: "runtime_input",
|
|
28351
|
-
description: "Input to the running game: a whole click on a Control named by path, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
|
|
28351
|
+
description: "Input to the running game: a whole click on a Control or a 3D node named by path, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
|
|
28352
28352
|
parameters: {
|
|
28353
28353
|
projectPath: RUNNING_PROJECT_PATH,
|
|
28354
|
-
nodePath: {
|
|
28354
|
+
nodePath: {
|
|
28355
|
+
type: "string",
|
|
28356
|
+
description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn. choose: the PopupMenu, or the OptionButton or MenuButton in front of one."
|
|
28357
|
+
},
|
|
28355
28358
|
action: { type: "string", description: "action: the InputMap action name." },
|
|
28356
28359
|
pressed: { type: "boolean", description: "Press or release. Default true." },
|
|
28357
28360
|
strength: { type: "number", description: "action: 0 to 1. Default 1." },
|
|
28358
28361
|
keycode: { type: "string", description: 'key: the key name, such as "Space" or "A".' },
|
|
28359
28362
|
text: {
|
|
28360
28363
|
type: "string",
|
|
28361
|
-
description: "text: what to type. A newline is Enter and a tab is Tab."
|
|
28364
|
+
description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
|
|
28365
|
+
},
|
|
28366
|
+
index: {
|
|
28367
|
+
type: "number",
|
|
28368
|
+
description: "choose: the item to take, by where it is in the list, when text will not do."
|
|
28362
28369
|
},
|
|
28363
28370
|
shift: { type: "boolean" },
|
|
28364
28371
|
ctrl: { type: "boolean" },
|
|
@@ -28377,7 +28384,11 @@ var TOOL_SPECS = [
|
|
|
28377
28384
|
requires: [],
|
|
28378
28385
|
operations: {
|
|
28379
28386
|
click: {
|
|
28380
|
-
summary: "press and release on a Control, a frame apart, and answer with what was under the pointer and what became of the control: in_tree, removed or freed. A control out of sight inside a ScrollContainer is scrolled to first, and scrolled_into_view says whether the view moved",
|
|
28387
|
+
summary: "press and release on a Control, a frame apart, and answer with what was under the pointer and what became of the control: in_tree, removed or freed. A control out of sight inside a ScrollContainer is scrolled to first, and scrolled_into_view says whether the view moved. A 3D node is clicked where it is drawn, and landed then says the interface did not swallow the press",
|
|
28388
|
+
requires: ["nodePath"]
|
|
28389
|
+
},
|
|
28390
|
+
choose: {
|
|
28391
|
+
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",
|
|
28381
28392
|
requires: ["nodePath"]
|
|
28382
28393
|
},
|
|
28383
28394
|
action: { summary: "press or release an action", requires: ["action"] },
|
|
@@ -29077,12 +29088,23 @@ class GodotServer {
|
|
|
29077
29088
|
case "runtime_capture":
|
|
29078
29089
|
return await this.handleRuntimeCommand(op === "screenshot" ? "capture_screenshot" : "capture_viewport", args);
|
|
29079
29090
|
case "runtime_input":
|
|
29080
|
-
|
|
29081
|
-
|
|
29082
|
-
|
|
29083
|
-
|
|
29084
|
-
|
|
29085
|
-
|
|
29091
|
+
if (op === "click") {
|
|
29092
|
+
return await this.handleRuntimeCommand("click", {
|
|
29093
|
+
projectPath: args["projectPath"],
|
|
29094
|
+
path: readNonEmptyString(args, "nodePath") ?? "",
|
|
29095
|
+
button: readString(args, "button") ?? "left",
|
|
29096
|
+
double: readBoolean(args, "doubleClick") ?? false
|
|
29097
|
+
});
|
|
29098
|
+
}
|
|
29099
|
+
if (op === "choose") {
|
|
29100
|
+
return await this.handleRuntimeCommand("choose", {
|
|
29101
|
+
projectPath: args["projectPath"],
|
|
29102
|
+
path: readNonEmptyString(args, "nodePath") ?? "",
|
|
29103
|
+
text: readString(args, "text") ?? "",
|
|
29104
|
+
...args["index"] === undefined ? {} : { index: args["index"] }
|
|
29105
|
+
});
|
|
29106
|
+
}
|
|
29107
|
+
return await this.handleRuntimeCommand(`inject_${op}`, args);
|
|
29086
29108
|
case "runtime_wait":
|
|
29087
29109
|
return await this.handleRuntimeWait(op, args);
|
|
29088
29110
|
case "debug_breakpoint": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
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",
|