gdharness 1.3.66 → 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.
@@ -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 only it can say.
361
- "hidden": DisplayServer.get_name() == "headless",
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
- var result: Variant = node.call(method, args)
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] and the nodes under it to [param root], except those an instanced scene
167
- ## inside it owns: giving those to the root made the pack write the instance's own nodes into this
168
- ## scene as overrides.
169
- static func own(node: Node, root: Node) -> void:
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 == null or child.owner == root:
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
- GdhPlacing.own(duplicated_node, root)
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.66",
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",