gdharness 1.3.65 → 1.3.67
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/README.md +1 -1
- package/build/cli.js +371 -138
- package/build/godot/addons/gdharness_editor/bridge_client.gd +13 -2
- package/build/godot/addons/gdharness_editor/picture_saves.gd +48 -0
- package/build/godot/addons/gdharness_editor/picture_saves.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/plugin.gd +4 -1
- package/build/godot/addons/gdharness_editor/scene_file.gd +5 -0
- package/build/godot/addons/gdharness_editor/tool_executor.gd +15 -1
- package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +8 -6
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +4 -1
- package/build/godot/addons/gdharness_editor/tools/view_tools.gd +121 -0
- package/build/godot/addons/gdharness_editor/tools/view_tools.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/picture_saves.gd +48 -0
- package/build/godot/addons/gdharness_runtime/picture_saves.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_capture.gd +3 -43
- package/build/godot/operations/gdscript_analysis.gd +17 -0
- package/build/godot/operations/picture_saves.gd +48 -0
- package/package.json +1 -1
|
@@ -48,6 +48,9 @@ const DAP_ASKED: String = "GDHARNESS_DAP_PORT"
|
|
|
48
48
|
## What a server that opened this editor says about itself, so that being opened by one is stated
|
|
49
49
|
## rather than guessed at from the ports. Kept in step with `OPENED_BY_A_SERVER` in src/launch.ts.
|
|
50
50
|
const OPENED_BY_A_SERVER: String = "GDHARNESS_OPENED_BY_A_SERVER"
|
|
51
|
+
## Set for an editor a server started on its hidden desktop. Kept in step with `DESKTOP_VARIABLE`
|
|
52
|
+
## in src/desktop.ts.
|
|
53
|
+
const DESKTOP_VARIABLE: String = "GDHARNESS_DESKTOP"
|
|
51
54
|
## Put into this editor's own environment as it loads, so every game it plays inherits it and
|
|
52
55
|
## the runtime addon can announce which editor played it. A game of the same project that some
|
|
53
56
|
## other server started has no such variable, or another editor's number, and that is what
|
|
@@ -357,8 +360,10 @@ func _handle_connect() -> void:
|
|
|
357
360
|
# Whether a session opening on the adapter is told this editor's breakpoints or takes
|
|
358
361
|
# them away, so a server can say which before it sets one.
|
|
359
362
|
"syncs_breakpoints": _syncs_breakpoints(),
|
|
360
|
-
# Whether this editor runs with no window, which for one opened by hand
|
|
361
|
-
|
|
363
|
+
# Whether this editor runs with no window anybody sees, which for one opened by hand
|
|
364
|
+
# only
|
|
365
|
+
# it can say: headless, or a window on the hidden desktop a server started it on.
|
|
366
|
+
"hidden": DisplayServer.get_name() == "headless" or _on_the_hidden_desktop(),
|
|
362
367
|
# Whether a server started this editor, which is the only kind that can be started again
|
|
363
368
|
# as itself: the engine hands back none of the arguments it was given, so the arguments
|
|
364
369
|
# have to come from whoever wrote them. See `restart_editor` in play_tools.gd.
|
|
@@ -442,6 +447,12 @@ func _keep_breakpoints_through_sessions() -> void:
|
|
|
442
447
|
)
|
|
443
448
|
|
|
444
449
|
|
|
450
|
+
## Whether a server started this editor on its hidden desktop, where its window draws and never
|
|
451
|
+
## shows.
|
|
452
|
+
static func _on_the_hidden_desktop() -> bool:
|
|
453
|
+
return OS.get_name() == "Windows" and not OS.get_environment(DESKTOP_VARIABLE).is_empty()
|
|
454
|
+
|
|
455
|
+
|
|
445
456
|
## Whether the editor shares its breakpoints with a session as it opens. See the setting.
|
|
446
457
|
func _syncs_breakpoints() -> bool:
|
|
447
458
|
if not Engine.is_editor_hint():
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
extends RefCounted
|
|
2
|
+
|
|
3
|
+
# How a captured picture is sized and saved, for the game's captures and the editor's alike.
|
|
4
|
+
#
|
|
5
|
+
# This file is the one beside the operations, and `bun run sync:gd` copies it into each addon,
|
|
6
|
+
# because an addon is installed as a directory and cannot preload out of one.
|
|
7
|
+
|
|
8
|
+
## Measured on a downstream game's 1280x720 frame: its interface text and pixel art read as they do
|
|
9
|
+
## in the PNG, at a fifth of the PNG's size.
|
|
10
|
+
const JPEG_QUALITY: float = 0.8
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## The size a capture drawn at [param drawn] is scaled to, from the width and height asked for, 0
|
|
14
|
+
## being not asked. One side alone keeps the picture's proportions, since that is what asking for a
|
|
15
|
+
## smaller picture means: it was ignored unless both came, so the full-size picture was sent back.
|
|
16
|
+
static func scaled_to(drawn: Vector2i, width: int, height: int) -> Vector2i:
|
|
17
|
+
if width > 0 and height > 0:
|
|
18
|
+
return Vector2i(width, height)
|
|
19
|
+
if width > 0 and drawn.x > 0:
|
|
20
|
+
return Vector2i(width, maxi(1, roundi(float(drawn.y) * width / drawn.x)))
|
|
21
|
+
if height > 0 and drawn.y > 0:
|
|
22
|
+
return Vector2i(maxi(1, roundi(float(drawn.x) * height / drawn.y)), height)
|
|
23
|
+
return drawn
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## [param image] saved as a PNG at [param png_path], and as a JPEG beside it, which the server sends
|
|
27
|
+
## instead when it is the smaller: a rendered frame is several times smaller as one, and every
|
|
28
|
+
## picture sent stays in the caller's session. Not for a picture with transparency, which a JPEG
|
|
29
|
+
## would flatten.
|
|
30
|
+
static func saved(image: Image, png_path: String) -> Dictionary:
|
|
31
|
+
var save_error: Error = image.save_png(png_path)
|
|
32
|
+
if save_error != OK:
|
|
33
|
+
return {
|
|
34
|
+
"type": "error",
|
|
35
|
+
"message": "Failed to save screenshot as PNG: " + str(save_error),
|
|
36
|
+
}
|
|
37
|
+
var answer: Dictionary = {
|
|
38
|
+
"type": "screenshot_file",
|
|
39
|
+
"format": "png",
|
|
40
|
+
"width": image.get_width(),
|
|
41
|
+
"height": image.get_height(),
|
|
42
|
+
"path": png_path,
|
|
43
|
+
}
|
|
44
|
+
if image.detect_alpha() == Image.ALPHA_NONE:
|
|
45
|
+
var jpeg_path: String = png_path.get_basename() + ".jpg"
|
|
46
|
+
if image.save_jpg(jpeg_path, JPEG_QUALITY) == OK:
|
|
47
|
+
answer["jpegPath"] = jpeg_path
|
|
48
|
+
return answer
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://c2lleb9sm7gnl
|
|
@@ -106,7 +106,10 @@ func _on_disconnected() -> void:
|
|
|
106
106
|
## Whether [param tool_name] waits for the editor to finish scanning or importing. A save during a
|
|
107
107
|
## scan updates the file list the scan's thread is writing out (see SceneFile.scan_later), and an
|
|
108
108
|
## import can run frames of the editor from inside itself, the one handling this call among them.
|
|
109
|
+
## Every call waits out a capture, which shows a scene for frames before it reads the view.
|
|
109
110
|
func turned_away(tool_name: String) -> bool:
|
|
111
|
+
if _tool_executor != null and _tool_executor.capturing():
|
|
112
|
+
return true
|
|
110
113
|
var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
|
|
111
114
|
return (
|
|
112
115
|
not ANSWERED_WHILE_SCANNING.has(tool_name)
|
|
@@ -124,7 +127,7 @@ func _on_tool_requested(request_id: String, tool_name: String, args: Dictionary)
|
|
|
124
127
|
_client.send_busy(request_id, tool_name)
|
|
125
128
|
return
|
|
126
129
|
|
|
127
|
-
var result: Dictionary = _tool_executor.execute_tool(tool_name, args)
|
|
130
|
+
var result: Dictionary = await _tool_executor.execute_tool(tool_name, args)
|
|
128
131
|
var success: bool = result.get("ok", false)
|
|
129
132
|
|
|
130
133
|
if success:
|
|
@@ -1197,6 +1197,11 @@ func _strays(state: SceneState) -> Array[Array]:
|
|
|
1197
1197
|
continue
|
|
1198
1198
|
if not held and GdhHeadlessCheck.at_default(node, name):
|
|
1199
1199
|
continue
|
|
1200
|
+
# Set in the file to what the node would have anyway, such as an instance naming the
|
|
1201
|
+
# script its own scene gives it. The pack leaves it out, as the editor's does, and the
|
|
1202
|
+
# node loads the same.
|
|
1203
|
+
if held and not stored.has(name) and GdhSame.same(node.get(name), filed[name]):
|
|
1204
|
+
continue
|
|
1200
1205
|
if _engine_kept.has("%d:%s" % [id, name]):
|
|
1201
1206
|
continue
|
|
1202
1207
|
stray.append([node, name, held, filed.get(name)])
|
|
@@ -17,6 +17,7 @@ const GdhClassTools = preload("tools/class_tools.gd")
|
|
|
17
17
|
const GdhSettingsTools = preload("tools/settings_tools.gd")
|
|
18
18
|
const GdhFilesystemTools = preload("tools/filesystem_tools.gd")
|
|
19
19
|
const GdhProfileTools = preload("tools/profile_tools.gd")
|
|
20
|
+
const GdhViewTools = preload("tools/view_tools.gd")
|
|
20
21
|
|
|
21
22
|
var _editor_plugin: EditorPlugin = null
|
|
22
23
|
var _scene_tools: GdhSceneTools = null
|
|
@@ -31,6 +32,7 @@ var _class_tools: GdhClassTools = null
|
|
|
31
32
|
var _settings_tools: GdhSettingsTools = null
|
|
32
33
|
var _filesystem_tools: GdhFilesystemTools = null
|
|
33
34
|
var _profile_tools: GdhProfileTools = null
|
|
35
|
+
var _view_tools: GdhViewTools = null
|
|
34
36
|
var _tool_map: Dictionary = {}
|
|
35
37
|
var _initialized: bool = false
|
|
36
38
|
|
|
@@ -48,6 +50,7 @@ func set_editor_plugin(plugin: EditorPlugin) -> void:
|
|
|
48
50
|
_class_tools.set_editor_plugin(plugin)
|
|
49
51
|
_filesystem_tools.set_editor_plugin(plugin)
|
|
50
52
|
_profile_tools.set_editor_plugin(plugin)
|
|
53
|
+
_view_tools.set_editor_plugin(plugin)
|
|
51
54
|
|
|
52
55
|
|
|
53
56
|
func _init_tools() -> void:
|
|
@@ -103,6 +106,10 @@ func _init_tools() -> void:
|
|
|
103
106
|
_profile_tools.name = "ProfileTools"
|
|
104
107
|
add_child(_profile_tools)
|
|
105
108
|
|
|
109
|
+
_view_tools = GdhViewTools.new()
|
|
110
|
+
_view_tools.name = "ViewTools"
|
|
111
|
+
add_child(_view_tools)
|
|
112
|
+
|
|
106
113
|
_tool_map = {
|
|
107
114
|
# Scene tools
|
|
108
115
|
"create_scene": [_scene_tools, "create_scene"],
|
|
@@ -148,6 +155,7 @@ func _init_tools() -> void:
|
|
|
148
155
|
# The script profiler on a game the editor plays
|
|
149
156
|
"profile_start": [_profile_tools, "profile_start"],
|
|
150
157
|
"profile_read": [_profile_tools, "profile_read"],
|
|
158
|
+
"capture_editor_view": [_view_tools, "capture_editor_view"],
|
|
151
159
|
# Animation tools
|
|
152
160
|
"read_animations": [_animation_tools, "read_animations"],
|
|
153
161
|
"read_animation_states": [_animation_tools, "read_animation_states"],
|
|
@@ -164,6 +172,11 @@ func _init_tools() -> void:
|
|
|
164
172
|
}
|
|
165
173
|
|
|
166
174
|
|
|
175
|
+
## Whether a capture is waiting for the editor to draw, which another call must not move it from.
|
|
176
|
+
func capturing() -> bool:
|
|
177
|
+
return _view_tools != null and _view_tools.capturing
|
|
178
|
+
|
|
179
|
+
|
|
167
180
|
func execute_tool(tool_name: String, args: Dictionary) -> Dictionary:
|
|
168
181
|
if not _tool_map.has(tool_name):
|
|
169
182
|
return { "ok": false, "error": "Unknown tool: " + tool_name }
|
|
@@ -177,7 +190,8 @@ func execute_tool(tool_name: String, args: Dictionary) -> Dictionary:
|
|
|
177
190
|
|
|
178
191
|
GdhSceneFile.also_saved.clear()
|
|
179
192
|
GdhSceneFile.unmade_note = ""
|
|
180
|
-
|
|
193
|
+
# Awaited for the tools that wait for the editor to draw. The rest answer at once.
|
|
194
|
+
var result: Variant = await node.call(method, args)
|
|
181
195
|
if result is Dictionary:
|
|
182
196
|
var answered: Dictionary = result
|
|
183
197
|
if not GdhSceneFile.also_saved.is_empty():
|
|
@@ -163,14 +163,16 @@ static func reconnect(copies: Dictionary, top: Node) -> void:
|
|
|
163
163
|
)
|
|
164
164
|
|
|
165
165
|
|
|
166
|
-
## Owns [param node]
|
|
167
|
-
##
|
|
168
|
-
##
|
|
169
|
-
|
|
166
|
+
## Owns [param node] to [param root], and under it the nodes the root owns already and those of
|
|
167
|
+
## [param placed], the copies a duplicate placed. A node nothing owns is not the scene's: a script's
|
|
168
|
+
## _init made it, which a headless engine runs and the editor does not, and owning it saved it into
|
|
169
|
+
## the scene, where the script makes it again at run time. An instanced scene's own nodes stay its
|
|
170
|
+
## own: giving those to the root made the pack write them into this scene as overrides.
|
|
171
|
+
static func own(node: Node, root: Node, placed: Dictionary = {}) -> void:
|
|
170
172
|
node.owner = root
|
|
171
173
|
for child: Node in node.get_children():
|
|
172
|
-
if child.owner ==
|
|
173
|
-
own(child, root)
|
|
174
|
+
if child.owner == root or placed.has(child):
|
|
175
|
+
own(child, root, placed)
|
|
174
176
|
|
|
175
177
|
|
|
176
178
|
## Why [param asked] is not a position [param node] can take among [param parent]'s children, or ""
|
|
@@ -482,7 +482,10 @@ func duplicate_node(args: Dictionary) -> Dictionary:
|
|
|
482
482
|
duplicated_node.name = new_name
|
|
483
483
|
target_parent.add_child(duplicated_node)
|
|
484
484
|
GdhPlacing.place(duplicated_node, place)
|
|
485
|
-
|
|
485
|
+
var made: Dictionary = {}
|
|
486
|
+
for copy: Variant in copies.values():
|
|
487
|
+
made[copy] = true
|
|
488
|
+
GdhPlacing.own(duplicated_node, root, made)
|
|
486
489
|
GdhPlacing.reconnect(copies, source)
|
|
487
490
|
scene.copied(copies)
|
|
488
491
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
@tool
|
|
2
|
+
extends Node
|
|
3
|
+
## Pictures of a scene as the editor draws it, with what its @tool scripts draw only there.
|
|
4
|
+
##
|
|
5
|
+
## A game never runs a script's editor branch, so runtime_capture cannot show it, and a headless
|
|
6
|
+
## engine draws nothing at all. The editor's own 2D and 3D views are the one place it is drawn.
|
|
7
|
+
|
|
8
|
+
const GdhRead = preload("../reading.gd")
|
|
9
|
+
const GdhSceneFile = preload("../scene_file.gd")
|
|
10
|
+
const GdhPictures = preload("../picture_saves.gd")
|
|
11
|
+
const HEADLESS_DISPLAY: String = "headless"
|
|
12
|
+
## Frames let pass once the scene is shown: the first lays the screen out, and the next run the
|
|
13
|
+
## redraws its @tool scripts queued as they entered the tree.
|
|
14
|
+
const SETTLING_FRAMES: int = 3
|
|
15
|
+
|
|
16
|
+
## Whether a capture is under way, during which the editor turns other calls away, since one that
|
|
17
|
+
## switched scenes between the open and the read would be what was captured.
|
|
18
|
+
var capturing: bool = false
|
|
19
|
+
var _editor_plugin: EditorPlugin = null
|
|
20
|
+
## The main screen the editor shows, as it last said, or "" before it has said.
|
|
21
|
+
var _screen: String = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
|
25
|
+
_editor_plugin = plugin
|
|
26
|
+
if not plugin.main_screen_changed.is_connected(_on_main_screen_changed):
|
|
27
|
+
var joined: int = plugin.main_screen_changed.connect(_on_main_screen_changed)
|
|
28
|
+
if joined != OK:
|
|
29
|
+
push_warning("gdharness: following the editor's main screen: %s" % error_string(joined))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
func _on_main_screen_changed(screen_name: String) -> void:
|
|
33
|
+
_screen = screen_name
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
## The scene at scenePath as the editor's 2D or 3D view draws it, saved at output_path, which the
|
|
37
|
+
## server names. The editor is left showing the scene and the screen it showed before.
|
|
38
|
+
func capture_editor_view(args: Dictionary) -> Dictionary:
|
|
39
|
+
var output_path: String = str(args.get("output_path", ""))
|
|
40
|
+
if output_path.is_empty():
|
|
41
|
+
return { "ok": false, "error": "output_path required" }
|
|
42
|
+
if DisplayServer.get_name() == HEADLESS_DISPLAY:
|
|
43
|
+
return {
|
|
44
|
+
"ok": false,
|
|
45
|
+
"error":
|
|
46
|
+
(
|
|
47
|
+
"This editor runs headless, so it draws nothing to capture. Use a visible "
|
|
48
|
+
+ "editor, or a hidden one on Windows."
|
|
49
|
+
),
|
|
50
|
+
}
|
|
51
|
+
var project_path: String = str(args.get("projectPath", ""))
|
|
52
|
+
var scene_path: String = GdhSceneFile.res_path(project_path, str(args.get("scenePath", "")))
|
|
53
|
+
if not ResourceLoader.exists(scene_path, "PackedScene"):
|
|
54
|
+
return { "ok": false, "error": "Scene not found: " + scene_path }
|
|
55
|
+
var view: String = str(args.get("view", ""))
|
|
56
|
+
var shown: Node = EditorInterface.get_edited_scene_root()
|
|
57
|
+
var previous: String = shown.scene_file_path if shown != null else ""
|
|
58
|
+
var was_open: bool = EditorInterface.get_open_scenes().has(scene_path)
|
|
59
|
+
var screen_before: String = _screen
|
|
60
|
+
|
|
61
|
+
capturing = true
|
|
62
|
+
EditorInterface.open_scene_from_path(scene_path)
|
|
63
|
+
var root: Node = EditorInterface.get_edited_scene_root()
|
|
64
|
+
if root == null or root.scene_file_path != scene_path:
|
|
65
|
+
capturing = false
|
|
66
|
+
return { "ok": false, "error": "The editor did not open %s." % scene_path }
|
|
67
|
+
if view.is_empty():
|
|
68
|
+
view = "3d" if root is Node3D else "2d"
|
|
69
|
+
EditorInterface.set_main_screen_editor("3D" if view == "3d" else "2D")
|
|
70
|
+
for frame: int in SETTLING_FRAMES:
|
|
71
|
+
await get_tree().process_frame
|
|
72
|
+
var viewport: SubViewport = (
|
|
73
|
+
EditorInterface.get_editor_viewport_3d(0)
|
|
74
|
+
if view == "3d"
|
|
75
|
+
else EditorInterface.get_editor_viewport_2d()
|
|
76
|
+
)
|
|
77
|
+
var image: Image = viewport.get_texture().get_image()
|
|
78
|
+
var unsaved: bool = GdhSceneFile.unsaved(scene_path)
|
|
79
|
+
|
|
80
|
+
if not was_open:
|
|
81
|
+
var closing: Error = EditorInterface.close_scene()
|
|
82
|
+
if closing != OK:
|
|
83
|
+
push_warning(
|
|
84
|
+
"gdharness: closing %s after its capture: %s" % [scene_path, error_string(closing)]
|
|
85
|
+
)
|
|
86
|
+
if not previous.is_empty() and previous != scene_path:
|
|
87
|
+
EditorInterface.open_scene_from_path(previous)
|
|
88
|
+
if not screen_before.is_empty() and screen_before != ("3D" if view == "3d" else "2D"):
|
|
89
|
+
EditorInterface.set_main_screen_editor(screen_before)
|
|
90
|
+
capturing = false
|
|
91
|
+
|
|
92
|
+
if image == null or image.is_empty():
|
|
93
|
+
return { "ok": false, "error": "The editor's %s view held no picture." % view.to_upper() }
|
|
94
|
+
var drawn: Vector2i = image.get_size()
|
|
95
|
+
var target: Vector2i = GdhPictures.scaled_to(
|
|
96
|
+
drawn, GdhRead.as_int(args.get("width", 0)), GdhRead.as_int(args.get("height", 0))
|
|
97
|
+
)
|
|
98
|
+
if target != drawn:
|
|
99
|
+
image.resize(target.x, target.y)
|
|
100
|
+
# The server makes the folder for each capture and takes it away when it stops waiting.
|
|
101
|
+
if not DirAccess.dir_exists_absolute(output_path.get_base_dir()):
|
|
102
|
+
return {
|
|
103
|
+
"ok": false,
|
|
104
|
+
"error":
|
|
105
|
+
(
|
|
106
|
+
"The folder to save the capture in, %s, is gone because the server that asked "
|
|
107
|
+
+ "for it stopped waiting."
|
|
108
|
+
)
|
|
109
|
+
% output_path.get_base_dir(),
|
|
110
|
+
}
|
|
111
|
+
var answer: Dictionary = GdhPictures.saved(image, output_path)
|
|
112
|
+
if answer.get("type", "") == "error":
|
|
113
|
+
return { "ok": false, "error": str(answer.get("message", "")) }
|
|
114
|
+
answer["ok"] = true
|
|
115
|
+
answer["scenePath"] = scene_path
|
|
116
|
+
answer["view"] = view
|
|
117
|
+
if target != drawn:
|
|
118
|
+
answer["drawnAt"] = { "width": drawn.x, "height": drawn.y }
|
|
119
|
+
if unsaved:
|
|
120
|
+
answer["unsavedInEditor"] = true
|
|
121
|
+
return answer
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://bbvurjno0b6f8
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
extends RefCounted
|
|
2
|
+
|
|
3
|
+
# How a captured picture is sized and saved, for the game's captures and the editor's alike.
|
|
4
|
+
#
|
|
5
|
+
# This file is the one beside the operations, and `bun run sync:gd` copies it into each addon,
|
|
6
|
+
# because an addon is installed as a directory and cannot preload out of one.
|
|
7
|
+
|
|
8
|
+
## Measured on a downstream game's 1280x720 frame: its interface text and pixel art read as they do
|
|
9
|
+
## in the PNG, at a fifth of the PNG's size.
|
|
10
|
+
const JPEG_QUALITY: float = 0.8
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## The size a capture drawn at [param drawn] is scaled to, from the width and height asked for, 0
|
|
14
|
+
## being not asked. One side alone keeps the picture's proportions, since that is what asking for a
|
|
15
|
+
## smaller picture means: it was ignored unless both came, so the full-size picture was sent back.
|
|
16
|
+
static func scaled_to(drawn: Vector2i, width: int, height: int) -> Vector2i:
|
|
17
|
+
if width > 0 and height > 0:
|
|
18
|
+
return Vector2i(width, height)
|
|
19
|
+
if width > 0 and drawn.x > 0:
|
|
20
|
+
return Vector2i(width, maxi(1, roundi(float(drawn.y) * width / drawn.x)))
|
|
21
|
+
if height > 0 and drawn.y > 0:
|
|
22
|
+
return Vector2i(maxi(1, roundi(float(drawn.x) * height / drawn.y)), height)
|
|
23
|
+
return drawn
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## [param image] saved as a PNG at [param png_path], and as a JPEG beside it, which the server sends
|
|
27
|
+
## instead when it is the smaller: a rendered frame is several times smaller as one, and every
|
|
28
|
+
## picture sent stays in the caller's session. Not for a picture with transparency, which a JPEG
|
|
29
|
+
## would flatten.
|
|
30
|
+
static func saved(image: Image, png_path: String) -> Dictionary:
|
|
31
|
+
var save_error: Error = image.save_png(png_path)
|
|
32
|
+
if save_error != OK:
|
|
33
|
+
return {
|
|
34
|
+
"type": "error",
|
|
35
|
+
"message": "Failed to save screenshot as PNG: " + str(save_error),
|
|
36
|
+
}
|
|
37
|
+
var answer: Dictionary = {
|
|
38
|
+
"type": "screenshot_file",
|
|
39
|
+
"format": "png",
|
|
40
|
+
"width": image.get_width(),
|
|
41
|
+
"height": image.get_height(),
|
|
42
|
+
"path": png_path,
|
|
43
|
+
}
|
|
44
|
+
if image.detect_alpha() == Image.ALPHA_NONE:
|
|
45
|
+
var jpeg_path: String = png_path.get_basename() + ".jpg"
|
|
46
|
+
if image.save_jpg(jpeg_path, JPEG_QUALITY) == OK:
|
|
47
|
+
answer["jpegPath"] = jpeg_path
|
|
48
|
+
return answer
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://c3m5b63kjrfs2
|
|
@@ -5,12 +5,10 @@ const GdhRead = preload("reading.gd")
|
|
|
5
5
|
const GdhValues = preload("runtime_values.gd")
|
|
6
6
|
const GdhQueries = preload("runtime_queries.gd")
|
|
7
7
|
const GdhScreen = preload("runtime_screen.gd")
|
|
8
|
+
const GdhPictures = preload("picture_saves.gd")
|
|
8
9
|
## How far zoom enlarges at most. Sixteen makes a 3-pixel ring 48 pixels across, past any need to
|
|
9
10
|
## see it, and a 200-pixel region 3200, past the size anybody reads a picture at.
|
|
10
11
|
const ZOOM_MOST: int = 16
|
|
11
|
-
## Measured on a downstream game's 1280x720 frame: its interface text and pixel art read as they do
|
|
12
|
-
## in the PNG, at a fifth of the PNG's size.
|
|
13
|
-
const JPEG_QUALITY: float = 0.8
|
|
14
12
|
|
|
15
13
|
var _host: Node
|
|
16
14
|
var _values: GdhValues
|
|
@@ -116,19 +114,6 @@ func _lay_native_windows(image: Image) -> void:
|
|
|
116
114
|
)
|
|
117
115
|
|
|
118
116
|
|
|
119
|
-
## The size a capture drawn at [param drawn] is scaled to, from the width and height asked for, 0
|
|
120
|
-
## being not asked. One side alone keeps the picture's proportions, since that is what asking for a
|
|
121
|
-
## smaller picture means: it was ignored unless both came, so the full-size picture was sent back.
|
|
122
|
-
static func scaled_to(drawn: Vector2i, width: int, height: int) -> Vector2i:
|
|
123
|
-
if width > 0 and height > 0:
|
|
124
|
-
return Vector2i(width, height)
|
|
125
|
-
if width > 0 and drawn.x > 0:
|
|
126
|
-
return Vector2i(width, maxi(1, roundi(float(drawn.y) * width / drawn.x)))
|
|
127
|
-
if height > 0 and drawn.y > 0:
|
|
128
|
-
return Vector2i(maxi(1, roundi(float(drawn.x) * height / drawn.y)), height)
|
|
129
|
-
return drawn
|
|
130
|
-
|
|
131
|
-
|
|
132
117
|
## What part of the picture a capture is cut to and how far it is enlarged, from what was asked, or
|
|
133
118
|
## a refusal saying what is wrong with the asking. Read before a frame is waited for, so a call that
|
|
134
119
|
## cannot be answered costs no wait.
|
|
@@ -390,7 +375,7 @@ func _capture(viewport: Viewport, params: Dictionary, with_windows: bool = false
|
|
|
390
375
|
described["zoom"] = zoom
|
|
391
376
|
else:
|
|
392
377
|
var drawn: Vector2i = Vector2i(image.get_width(), image.get_height())
|
|
393
|
-
var target: Vector2i = scaled_to(
|
|
378
|
+
var target: Vector2i = GdhPictures.scaled_to(
|
|
394
379
|
drawn, GdhRead.as_int(params.get("width", 0)), GdhRead.as_int(params.get("height", 0))
|
|
395
380
|
)
|
|
396
381
|
if target != drawn:
|
|
@@ -412,31 +397,6 @@ func _capture(viewport: Viewport, params: Dictionary, with_windows: bool = false
|
|
|
412
397
|
% screenshot_path.get_base_dir()
|
|
413
398
|
)
|
|
414
399
|
)
|
|
415
|
-
var answer: Dictionary = saved(image, screenshot_path)
|
|
400
|
+
var answer: Dictionary = GdhPictures.saved(image, screenshot_path)
|
|
416
401
|
answer.merge(described)
|
|
417
402
|
return answer
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
## [param image] saved as a PNG at [param png_path], and as a JPEG beside it, which the server sends
|
|
421
|
-
## instead when it is the smaller: a rendered frame is several times smaller as one, and every
|
|
422
|
-
## picture sent stays in the caller's session. Not for a picture with transparency, which a JPEG
|
|
423
|
-
## would flatten.
|
|
424
|
-
static func saved(image: Image, png_path: String) -> Dictionary:
|
|
425
|
-
var save_error: Error = image.save_png(png_path)
|
|
426
|
-
if save_error != OK:
|
|
427
|
-
return {
|
|
428
|
-
"type": "error",
|
|
429
|
-
"message": "Failed to save screenshot as PNG: " + str(save_error),
|
|
430
|
-
}
|
|
431
|
-
var answer: Dictionary = {
|
|
432
|
-
"type": "screenshot_file",
|
|
433
|
-
"format": "png",
|
|
434
|
-
"width": image.get_width(),
|
|
435
|
-
"height": image.get_height(),
|
|
436
|
-
"path": png_path,
|
|
437
|
-
}
|
|
438
|
-
if image.detect_alpha() == Image.ALPHA_NONE:
|
|
439
|
-
var jpeg_path: String = png_path.get_basename() + ".jpg"
|
|
440
|
-
if image.save_jpg(jpeg_path, JPEG_QUALITY) == OK:
|
|
441
|
-
answer["jpegPath"] = jpeg_path
|
|
442
|
-
return answer
|
|
@@ -331,6 +331,23 @@ static func _not_a_script(path: String) -> String:
|
|
|
331
331
|
)
|
|
332
332
|
"tres", "res":
|
|
333
333
|
return said + ": resource_edit read reads a resource."
|
|
334
|
+
"":
|
|
335
|
+
# A class name given where the path goes.
|
|
336
|
+
var named: String = path.get_file()
|
|
337
|
+
for entry: Dictionary in ProjectSettings.get_global_class_list():
|
|
338
|
+
if str(entry.get("class", "")) == named:
|
|
339
|
+
return (
|
|
340
|
+
said
|
|
341
|
+
+ (
|
|
342
|
+
": %s is the class_name of %s. Call script_info with that path."
|
|
343
|
+
% [named, str(entry.get("path", ""))]
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
if ClassDB.class_exists(named):
|
|
347
|
+
return (
|
|
348
|
+
said
|
|
349
|
+
+ ": %s is an engine class. Call editor_classes info for its members." % named
|
|
350
|
+
)
|
|
334
351
|
return said + "."
|
|
335
352
|
|
|
336
353
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
extends RefCounted
|
|
2
|
+
|
|
3
|
+
# How a captured picture is sized and saved, for the game's captures and the editor's alike.
|
|
4
|
+
#
|
|
5
|
+
# This file is the one beside the operations, and `bun run sync:gd` copies it into each addon,
|
|
6
|
+
# because an addon is installed as a directory and cannot preload out of one.
|
|
7
|
+
|
|
8
|
+
## Measured on a downstream game's 1280x720 frame: its interface text and pixel art read as they do
|
|
9
|
+
## in the PNG, at a fifth of the PNG's size.
|
|
10
|
+
const JPEG_QUALITY: float = 0.8
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## The size a capture drawn at [param drawn] is scaled to, from the width and height asked for, 0
|
|
14
|
+
## being not asked. One side alone keeps the picture's proportions, since that is what asking for a
|
|
15
|
+
## smaller picture means: it was ignored unless both came, so the full-size picture was sent back.
|
|
16
|
+
static func scaled_to(drawn: Vector2i, width: int, height: int) -> Vector2i:
|
|
17
|
+
if width > 0 and height > 0:
|
|
18
|
+
return Vector2i(width, height)
|
|
19
|
+
if width > 0 and drawn.x > 0:
|
|
20
|
+
return Vector2i(width, maxi(1, roundi(float(drawn.y) * width / drawn.x)))
|
|
21
|
+
if height > 0 and drawn.y > 0:
|
|
22
|
+
return Vector2i(maxi(1, roundi(float(drawn.x) * height / drawn.y)), height)
|
|
23
|
+
return drawn
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## [param image] saved as a PNG at [param png_path], and as a JPEG beside it, which the server sends
|
|
27
|
+
## instead when it is the smaller: a rendered frame is several times smaller as one, and every
|
|
28
|
+
## picture sent stays in the caller's session. Not for a picture with transparency, which a JPEG
|
|
29
|
+
## would flatten.
|
|
30
|
+
static func saved(image: Image, png_path: String) -> Dictionary:
|
|
31
|
+
var save_error: Error = image.save_png(png_path)
|
|
32
|
+
if save_error != OK:
|
|
33
|
+
return {
|
|
34
|
+
"type": "error",
|
|
35
|
+
"message": "Failed to save screenshot as PNG: " + str(save_error),
|
|
36
|
+
}
|
|
37
|
+
var answer: Dictionary = {
|
|
38
|
+
"type": "screenshot_file",
|
|
39
|
+
"format": "png",
|
|
40
|
+
"width": image.get_width(),
|
|
41
|
+
"height": image.get_height(),
|
|
42
|
+
"path": png_path,
|
|
43
|
+
}
|
|
44
|
+
if image.detect_alpha() == Image.ALPHA_NONE:
|
|
45
|
+
var jpeg_path: String = png_path.get_basename() + ".jpg"
|
|
46
|
+
if image.save_jpg(jpeg_path, JPEG_QUALITY) == OK:
|
|
47
|
+
answer["jpegPath"] = jpeg_path
|
|
48
|
+
return answer
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.67",
|
|
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",
|