gdharness 0.5.7 → 0.5.9
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 +134 -39
- package/build/godot/addons/auto_reload/auto_reload.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/bridge_client.gd +143 -2
- package/build/godot/addons/gdharness_editor/bridge_client.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/plugin.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/tool_executor.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/tools/animation_tools.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/tools/play_tools.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_capture.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +76 -0
- package/build/godot/addons/gdharness_runtime/runtime_input.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +9 -0
- package/build/godot/addons/gdharness_runtime/runtime_values.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd.uid +1 -0
- package/build/godot/operations/serialisation.gd +8 -5
- package/build/index.js +131 -45
- package/package.json +1 -1
|
@@ -11,21 +11,57 @@ signal tool_requested(request_id: String, tool_name: String, args: Dictionary)
|
|
|
11
11
|
const DEFAULT_URL: String = "ws://127.0.0.1:6505/godot"
|
|
12
12
|
## Written beside the addon by the install, so it names the version this copy came from.
|
|
13
13
|
const VERSION_MARKER: String = "res://addons/gdharness_editor/.gdharness-version"
|
|
14
|
+
## Written by the server that serves this project, saying where its bridge actually is. Kept in
|
|
15
|
+
## step with `announcementPath` in src/bridge-announce.ts.
|
|
16
|
+
const ANNOUNCEMENT: String = "res://.godot/gdharness-bridge.json"
|
|
17
|
+
const ANNOUNCE_PROTOCOL: int = 1
|
|
14
18
|
const RECONNECT_DELAY: float = 3.0
|
|
15
19
|
const MAX_RECONNECT_DELAY: float = 30.0
|
|
16
20
|
|
|
21
|
+
## How long one attempt is given before the address is called a bad one.
|
|
22
|
+
##
|
|
23
|
+
## A socket pointed at a port nothing holds gives up by itself, in thirty seconds on Windows. One
|
|
24
|
+
## pointed at something that accepts and then never speaks WebSocket does not give up at all:
|
|
25
|
+
## measured here, still connecting after forty-five seconds, because the peer has no handshake
|
|
26
|
+
## timeout of its own in 4.7. A leftover announcement can be either, and a port that has been
|
|
27
|
+
## reused by some other program is the second.
|
|
28
|
+
const CONNECT_TIMEOUT: float = 10.0
|
|
29
|
+
|
|
30
|
+
## How often the announcement is read again while connected, so a newer server is moved to
|
|
31
|
+
## rather than waited for. A harness reconnect leaves the server it replaced running and holding
|
|
32
|
+
## the old port, and an editor with no reason to look elsewhere stayed on it for the session.
|
|
33
|
+
const FOLLOW_INTERVAL: float = 5.0
|
|
34
|
+
|
|
17
35
|
var socket: WebSocketPeer = WebSocketPeer.new()
|
|
18
36
|
var server_url: String = DEFAULT_URL
|
|
37
|
+
|
|
38
|
+
## What this copy was when it loaded, which is what the editor is running until it is restarted.
|
|
39
|
+
## See [method _loaded_version] for why it is held rather than read when it is wanted.
|
|
40
|
+
var version_at_load: String = ""
|
|
41
|
+
|
|
19
42
|
var _is_connected: bool = false
|
|
20
43
|
var _reconnect_timer: Timer
|
|
21
44
|
var _current_reconnect_delay: float = RECONNECT_DELAY
|
|
22
|
-
|
|
45
|
+
## Nothing reconnects until somebody has asked to connect once.
|
|
46
|
+
var _should_reconnect: bool = false
|
|
23
47
|
var _project_path: String
|
|
24
48
|
var _initialized: bool = false
|
|
25
49
|
|
|
50
|
+
## Whether the address came from a caller rather than from the project, in which case it is not
|
|
51
|
+
## this node's to change.
|
|
52
|
+
var _named_by_caller: bool = false
|
|
53
|
+
var _since_looked: float = 0.0
|
|
54
|
+
|
|
55
|
+
## The announced address that did not answer, so the fallback gets a turn. Cleared the moment
|
|
56
|
+
## anything connects or the project names a different one.
|
|
57
|
+
var _refused_url: String = ""
|
|
58
|
+
var _tried_announced: bool = false
|
|
59
|
+
var _connecting_for: float = 0.0
|
|
60
|
+
|
|
26
61
|
|
|
27
62
|
func _ready() -> void:
|
|
28
63
|
_project_path = ProjectSettings.globalize_path("res://")
|
|
64
|
+
version_at_load = _loaded_version()
|
|
29
65
|
|
|
30
66
|
_reconnect_timer = Timer.new()
|
|
31
67
|
_reconnect_timer.one_shot = true
|
|
@@ -43,6 +79,13 @@ func _process(_delta: float) -> void:
|
|
|
43
79
|
if socket.get_ready_state() == WebSocketPeer.STATE_CLOSED:
|
|
44
80
|
if _is_connected:
|
|
45
81
|
_handle_disconnect()
|
|
82
|
+
elif _should_reconnect and _reconnect_timer.is_stopped():
|
|
83
|
+
# A connection refused never opened, so it reaches CLOSED without passing through
|
|
84
|
+
# _handle_disconnect and nothing would ask again. An editor opened before the server
|
|
85
|
+
# is the ordinary way that happens, and it then sat there for the rest of the day.
|
|
86
|
+
if _tried_announced:
|
|
87
|
+
_refused_url = server_url
|
|
88
|
+
_schedule_reconnect()
|
|
46
89
|
return
|
|
47
90
|
|
|
48
91
|
socket.poll()
|
|
@@ -51,11 +94,17 @@ func _process(_delta: float) -> void:
|
|
|
51
94
|
WebSocketPeer.STATE_OPEN:
|
|
52
95
|
if not _is_connected:
|
|
53
96
|
_handle_connect()
|
|
97
|
+
_follow_whoever_is_newest(_delta)
|
|
54
98
|
|
|
55
99
|
while socket.get_available_packet_count() > 0:
|
|
56
100
|
var packet: PackedByteArray = socket.get_packet()
|
|
57
101
|
_handle_message(packet.get_string_from_utf8())
|
|
58
102
|
|
|
103
|
+
WebSocketPeer.STATE_CONNECTING:
|
|
104
|
+
_connecting_for += _delta
|
|
105
|
+
if _connecting_for >= CONNECT_TIMEOUT:
|
|
106
|
+
_give_up_on_this_address()
|
|
107
|
+
|
|
59
108
|
WebSocketPeer.STATE_CLOSING:
|
|
60
109
|
pass
|
|
61
110
|
|
|
@@ -64,7 +113,38 @@ func _process(_delta: float) -> void:
|
|
|
64
113
|
_handle_disconnect()
|
|
65
114
|
|
|
66
115
|
|
|
116
|
+
## Moves to the server the project now names, when that is not the one this is talking to.
|
|
117
|
+
##
|
|
118
|
+
## A harness reconnect leaves the server it replaced running, still holding the port it bound and
|
|
119
|
+
## still answering: the editor has no reason to notice, and stayed on a server nothing was
|
|
120
|
+
## speaking to for the rest of the session. The replacement announces where it landed, so the
|
|
121
|
+
## editor can go to it rather than anybody ending a process.
|
|
122
|
+
##
|
|
123
|
+
## Only while connected by a URL this worked out for itself. A caller that named one is holding
|
|
124
|
+
## this to that address, which is what every fixture does.
|
|
125
|
+
func _follow_whoever_is_newest(delta: float) -> void:
|
|
126
|
+
if _named_by_caller:
|
|
127
|
+
return
|
|
128
|
+
_since_looked += delta
|
|
129
|
+
if _since_looked < FOLLOW_INTERVAL:
|
|
130
|
+
return
|
|
131
|
+
_since_looked = 0.0
|
|
132
|
+
|
|
133
|
+
var announced: String = announced_url()
|
|
134
|
+
if announced == "" or announced == server_url or announced == _refused_url:
|
|
135
|
+
return
|
|
136
|
+
server_url = announced
|
|
137
|
+
# Put down before it is picked up again, because the arrival is what tells a server who this
|
|
138
|
+
# editor is: left standing, the open socket on the new address would never be greeted and the
|
|
139
|
+
# server would report no editor while holding one.
|
|
140
|
+
_is_connected = false
|
|
141
|
+
disconnected.emit()
|
|
142
|
+
_current_reconnect_delay = RECONNECT_DELAY
|
|
143
|
+
_attempt_connection()
|
|
144
|
+
|
|
145
|
+
|
|
67
146
|
func connect_to_server(url: String = "") -> void:
|
|
147
|
+
_named_by_caller = url != ""
|
|
68
148
|
server_url = _resolve_server_url(url)
|
|
69
149
|
_should_reconnect = true
|
|
70
150
|
_current_reconnect_delay = RECONNECT_DELAY
|
|
@@ -75,6 +155,12 @@ func _resolve_server_url(explicit_url: String) -> String:
|
|
|
75
155
|
if explicit_url != "":
|
|
76
156
|
return explicit_url
|
|
77
157
|
|
|
158
|
+
var announced: String = announced_url()
|
|
159
|
+
if announced != "" and announced != _refused_url:
|
|
160
|
+
_tried_announced = true
|
|
161
|
+
return announced
|
|
162
|
+
_tried_announced = false
|
|
163
|
+
|
|
78
164
|
# The same variable the server reads, so the two agree on the port by construction.
|
|
79
165
|
var raw: String = OS.get_environment("GDHARNESS_BRIDGE_PORT")
|
|
80
166
|
if raw != "":
|
|
@@ -85,6 +171,48 @@ func _resolve_server_url(explicit_url: String) -> String:
|
|
|
85
171
|
return DEFAULT_URL
|
|
86
172
|
|
|
87
173
|
|
|
174
|
+
## Stops waiting on an address that is not answering, and asks again elsewhere.
|
|
175
|
+
func _give_up_on_this_address() -> void:
|
|
176
|
+
if _tried_announced:
|
|
177
|
+
_refused_url = server_url
|
|
178
|
+
_connecting_for = 0.0
|
|
179
|
+
socket.close()
|
|
180
|
+
_schedule_reconnect()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
## Where the server says its bridge is, or "" when nothing has said.
|
|
184
|
+
##
|
|
185
|
+
## Written by a server that knows which project it serves, inside that project, so the two sides
|
|
186
|
+
## agree by construction rather than by deriving a temporary directory the same way: they do not
|
|
187
|
+
## share an environment, and the runtime's own announcement cost a session learning that.
|
|
188
|
+
##
|
|
189
|
+
## A leftover is found out by trying it rather than by asking whether its process is still there.
|
|
190
|
+
## `OS.is_process_running` answers that only for a child of the caller on Unix, where it prints
|
|
191
|
+
## "does not exist or is not a child of the calling process" and says no about every server there
|
|
192
|
+
## is: it works on Windows and quietly disables the whole thing everywhere else. So an
|
|
193
|
+
## announcement that does not answer is set aside, the fallback gets the next turn, and a
|
|
194
|
+
## different announcement puts it back in play.
|
|
195
|
+
func announced_url() -> String:
|
|
196
|
+
if not FileAccess.file_exists(ANNOUNCEMENT):
|
|
197
|
+
return ""
|
|
198
|
+
var file: FileAccess = FileAccess.open(ANNOUNCEMENT, FileAccess.READ)
|
|
199
|
+
if file == null:
|
|
200
|
+
return ""
|
|
201
|
+
var said: Variant = JSON.parse_string(file.get_as_text())
|
|
202
|
+
file.close()
|
|
203
|
+
if not said is Dictionary:
|
|
204
|
+
return ""
|
|
205
|
+
|
|
206
|
+
var announcement: Dictionary = said
|
|
207
|
+
if int(announcement.get("protocol", 0)) != ANNOUNCE_PROTOCOL:
|
|
208
|
+
return ""
|
|
209
|
+
var port: int = int(announcement.get("port", 0))
|
|
210
|
+
if port < 1 or port > 65535:
|
|
211
|
+
return ""
|
|
212
|
+
var host: String = str(announcement.get("host", "127.0.0.1"))
|
|
213
|
+
return "ws://%s:%d/godot" % [host, port]
|
|
214
|
+
|
|
215
|
+
|
|
88
216
|
func disconnect_from_server() -> void:
|
|
89
217
|
_should_reconnect = false
|
|
90
218
|
if _reconnect_timer:
|
|
@@ -98,6 +226,7 @@ func _attempt_connection() -> void:
|
|
|
98
226
|
if socket.get_ready_state() != WebSocketPeer.STATE_CLOSED:
|
|
99
227
|
socket.close()
|
|
100
228
|
|
|
229
|
+
_connecting_for = 0.0
|
|
101
230
|
var err: Error = socket.connect_to_url(server_url)
|
|
102
231
|
if err != OK:
|
|
103
232
|
push_error("[gdharness] Failed to connect to %s: %s" % [server_url, error_string(err)])
|
|
@@ -107,6 +236,7 @@ func _attempt_connection() -> void:
|
|
|
107
236
|
func _handle_connect() -> void:
|
|
108
237
|
_is_connected = true
|
|
109
238
|
_current_reconnect_delay = RECONNECT_DELAY
|
|
239
|
+
_refused_url = ""
|
|
110
240
|
|
|
111
241
|
# The version reported is the one this editor loaded at startup, not the one on disk: an
|
|
112
242
|
# upgrade replaces the files under a running editor, which goes on serving the old code until
|
|
@@ -117,7 +247,7 @@ func _handle_connect() -> void:
|
|
|
117
247
|
{
|
|
118
248
|
"type": "godot_ready",
|
|
119
249
|
"project_path": _project_path,
|
|
120
|
-
"addon_version":
|
|
250
|
+
"addon_version": version_at_load,
|
|
121
251
|
"editor_pid": OS.get_process_id()
|
|
122
252
|
}
|
|
123
253
|
)
|
|
@@ -126,6 +256,13 @@ func _handle_connect() -> void:
|
|
|
126
256
|
|
|
127
257
|
|
|
128
258
|
## The version marker beside this addon, or "" when the copy was not installed by gdharness.
|
|
259
|
+
##
|
|
260
|
+
## Read once, when this copy loads, and never again: an upgrade replaces the files under a
|
|
261
|
+
## running editor and rewrites the marker with them, so reading it at connect time answers with
|
|
262
|
+
## the version on disk rather than the one in memory. That is the wrong answer at the one moment
|
|
263
|
+
## it matters. An upgrade ends with the harness reconnecting, the editor reconnecting behind it,
|
|
264
|
+
## and `addonIsStale` reporting false over an editor still running the old code, which is exactly
|
|
265
|
+
## what it exists to catch.
|
|
129
266
|
func _loaded_version() -> String:
|
|
130
267
|
if not FileAccess.file_exists(VERSION_MARKER):
|
|
131
268
|
return ""
|
|
@@ -153,6 +290,10 @@ func _schedule_reconnect() -> void:
|
|
|
153
290
|
|
|
154
291
|
|
|
155
292
|
func _on_reconnect_timer() -> void:
|
|
293
|
+
# Asked again rather than remembered: the reason a connection dropped is often that its server
|
|
294
|
+
# did, and the one that replaced it has said where it is since.
|
|
295
|
+
if not _named_by_caller:
|
|
296
|
+
server_url = _resolve_server_url("")
|
|
156
297
|
_attempt_connection()
|
|
157
298
|
|
|
158
299
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://t1telvjuccb0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://bxebcmjteyrwl
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://dmjxtcqxfrure
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://bbmtwsaoi4bh0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://bpusjmmbajpxq
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://d27rjl7bpjvss
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://bdfggiknkfi0f
|
|
@@ -66,6 +66,7 @@ func _init() -> void:
|
|
|
66
66
|
"capture_viewport": _capture.capture_viewport,
|
|
67
67
|
"inject_action": _input.inject_action,
|
|
68
68
|
"inject_key": _input.inject_key,
|
|
69
|
+
"inject_text": _input.inject_text,
|
|
69
70
|
"inject_mouse_click": _input.inject_mouse_click,
|
|
70
71
|
"inject_mouse_motion": _input.inject_mouse_motion,
|
|
71
72
|
"click": _input.click,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://c5crv4n4t7c5
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://cxhjoq814ox42
|
|
@@ -5,6 +5,13 @@ extends RefCounted
|
|
|
5
5
|
|
|
6
6
|
const Values = preload("runtime_values.gd")
|
|
7
7
|
|
|
8
|
+
## The distance from a capital letter to its small one in Unicode. A keycode holds the capital.
|
|
9
|
+
const TO_SMALL: int = 32
|
|
10
|
+
|
|
11
|
+
## The two characters a field reads as keys rather than as text.
|
|
12
|
+
const NEWLINE: int = 10
|
|
13
|
+
const TAB: int = 9
|
|
14
|
+
|
|
8
15
|
var _host: Node
|
|
9
16
|
var _values: Values
|
|
10
17
|
|
|
@@ -69,6 +76,12 @@ func inject_key(params: Dictionary) -> Dictionary:
|
|
|
69
76
|
event.ctrl_pressed = bool(params.get("ctrl", false))
|
|
70
77
|
event.alt_pressed = bool(params.get("alt", false))
|
|
71
78
|
|
|
79
|
+
# The fourth thing a real event carries, and the only one a text field reads: LineEdit and
|
|
80
|
+
# TextEdit insert `unicode` and never consult the keycode, so an injected key could press any
|
|
81
|
+
# action in the map and still type nothing into a search box. Godot's keycodes for printable
|
|
82
|
+
# keys are the code points themselves, which is what makes this a mapping and not a table.
|
|
83
|
+
event.unicode = _glyph_of(event.keycode, event.shift_pressed)
|
|
84
|
+
|
|
72
85
|
Input.parse_input_event(event)
|
|
73
86
|
|
|
74
87
|
return {
|
|
@@ -79,10 +92,73 @@ func inject_key(params: Dictionary) -> Dictionary:
|
|
|
79
92
|
"shift": event.shift_pressed,
|
|
80
93
|
"ctrl": event.ctrl_pressed,
|
|
81
94
|
"alt": event.alt_pressed,
|
|
95
|
+
"unicode": event.unicode,
|
|
82
96
|
"pressed": pressed
|
|
83
97
|
}
|
|
84
98
|
|
|
85
99
|
|
|
100
|
+
## Types [param text] wherever the focus is, one key event per character.
|
|
101
|
+
##
|
|
102
|
+
## A key on its own cannot do this and should not try: which character a key produces is the
|
|
103
|
+
## keyboard layout's business, and shift over a digit is an exclamation mark on one layout and
|
|
104
|
+
## something else on the next. Given the character instead there is nothing to guess, so a field
|
|
105
|
+
## can be filled with anything a player could type, this project's own two typefaces included.
|
|
106
|
+
##
|
|
107
|
+
## A newline and a tab are the two characters a field reads as keys rather than as text, so they
|
|
108
|
+
## are sent as those keys and carry no character of their own: typing a name and submitting it is
|
|
109
|
+
## one call rather than two.
|
|
110
|
+
##
|
|
111
|
+
## Pushed into the viewport for the reason [method click] is, and it matters more here: the focus
|
|
112
|
+
## is what decides where a character lands, so a caller that clicked a field and then typed would
|
|
113
|
+
## otherwise have both waiting in the same queue with nothing said about the order.
|
|
114
|
+
func inject_text(params: Dictionary) -> Dictionary:
|
|
115
|
+
var text: String = String(params.get("text", ""))
|
|
116
|
+
if text.is_empty():
|
|
117
|
+
return {"type": "error", "message": "text required"}
|
|
118
|
+
|
|
119
|
+
var viewport: Viewport = _host.get_tree().root
|
|
120
|
+
for index: int in text.length():
|
|
121
|
+
var down: InputEventKey = _typed(text.unicode_at(index))
|
|
122
|
+
viewport.push_input(down)
|
|
123
|
+
# The release as well, so nothing is left held down behind the caller.
|
|
124
|
+
var up: InputEventKey = down.duplicate()
|
|
125
|
+
up.pressed = false
|
|
126
|
+
viewport.push_input(up)
|
|
127
|
+
|
|
128
|
+
return {"type": "input_injected", "input_type": "text", "text": text, "characters": text.length()}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
## The key press that produces [param glyph], as a keyboard would send it.
|
|
132
|
+
func _typed(glyph: int) -> InputEventKey:
|
|
133
|
+
var event: InputEventKey = InputEventKey.new()
|
|
134
|
+
event.pressed = true
|
|
135
|
+
if glyph == NEWLINE:
|
|
136
|
+
event.keycode = KEY_ENTER
|
|
137
|
+
elif glyph == TAB:
|
|
138
|
+
event.keycode = KEY_TAB
|
|
139
|
+
else:
|
|
140
|
+
var capital: int = String.chr(glyph).to_upper().unicode_at(0)
|
|
141
|
+
event.keycode = capital as Key
|
|
142
|
+
event.shift_pressed = capital != glyph
|
|
143
|
+
event.unicode = glyph
|
|
144
|
+
event.physical_keycode = event.keycode
|
|
145
|
+
event.key_label = event.keycode
|
|
146
|
+
return event
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
## The character a key produces, or nothing for a key that produces none.
|
|
150
|
+
##
|
|
151
|
+
## Godot's keycodes below [constant KEY_SPECIAL] are the Unicode code points of the keys that
|
|
152
|
+
## print something, so the mapping is the value itself. Letters are held as their capitals, which
|
|
153
|
+
## is the one place the shift a caller asked for changes the answer rather than the key.
|
|
154
|
+
func _glyph_of(keycode: Key, shifted: bool) -> int:
|
|
155
|
+
if keycode >= KEY_SPECIAL:
|
|
156
|
+
return 0
|
|
157
|
+
if keycode >= KEY_A and keycode <= KEY_Z and not shifted:
|
|
158
|
+
return keycode + TO_SMALL
|
|
159
|
+
return keycode
|
|
160
|
+
|
|
161
|
+
|
|
86
162
|
## A point the tool schema sends as two flat numbers, or the older form of one [x, y] value.
|
|
87
163
|
## Answers a Vector2, or the String that says what was wrong with it.
|
|
88
164
|
func _read_point(params: Dictionary, x_key: String, y_key: String, pair_key: String) -> Variant:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://d1shdw1mlyig7
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://bueql5kulh1dm
|
|
@@ -28,7 +28,16 @@ const SERIALISERS: Dictionary = {
|
|
|
28
28
|
|
|
29
29
|
## Converts a Godot value into something JSON can carry. A type with no entry in the table
|
|
30
30
|
## passes through as itself, which is what the JSON-native ones want.
|
|
31
|
+
##
|
|
32
|
+
## A method or property typed as a class answers a null with a Variant of type OBJECT that has
|
|
33
|
+
## nothing behind it, and so does one whose object has been freed. Asking either for its class is
|
|
34
|
+
## a script error, and a script error here costs far more than the one answer: the request is
|
|
35
|
+
## never replied to at all, and an editor playing the game stops it dead on the error, so every
|
|
36
|
+
## question after it times out as well. `find_child` for a name nothing has cost a whole session
|
|
37
|
+
## that way.
|
|
31
38
|
func serialize(value: Variant) -> Variant:
|
|
39
|
+
if typeof(value) == TYPE_OBJECT and not is_instance_valid(value):
|
|
40
|
+
return null
|
|
32
41
|
var serialiser: String = SERIALISERS.get(typeof(value), "")
|
|
33
42
|
return call(serialiser, value) if not serialiser.is_empty() else value
|
|
34
43
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://bamdnyhpw2umv
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://cpx5hgxdn8dwd
|
|
@@ -27,7 +27,15 @@ const SERIALISERS: Dictionary = {
|
|
|
27
27
|
|
|
28
28
|
# Converts a Godot value into something JSON can carry. A type with no entry in the table
|
|
29
29
|
# passes through as itself, which is what the JSON-native ones want.
|
|
30
|
+
#
|
|
31
|
+
# A property that holds no object is Object-typed and null rather than nil, which is most of what
|
|
32
|
+
# a node's property list is, and an object that has been freed is the same shape again. Both are
|
|
33
|
+
# refused here rather than inside _serialize_object, because a freed one never reaches it: the
|
|
34
|
+
# call itself fails on the argument, with "previously freed is not a subclass of the expected
|
|
35
|
+
# argument class", and a guard behind that is a guard that never runs.
|
|
30
36
|
func serialize_value(value: Variant) -> Variant:
|
|
37
|
+
if typeof(value) == TYPE_OBJECT and not is_instance_valid(value):
|
|
38
|
+
return null
|
|
31
39
|
var serialiser: String = SERIALISERS.get(typeof(value), "")
|
|
32
40
|
return call(serialiser, value) if not serialiser.is_empty() else value
|
|
33
41
|
|
|
@@ -140,12 +148,7 @@ func _serialize_dictionary(value: Dictionary) -> Dictionary:
|
|
|
140
148
|
|
|
141
149
|
|
|
142
150
|
# A pathless Resource says so rather than inventing an empty path for the caller to load.
|
|
143
|
-
#
|
|
144
|
-
# A property that holds no object is Object-typed and null rather than nil, which is most of
|
|
145
|
-
# what a node's property list is, so this is the branch that runs the most.
|
|
146
151
|
func _serialize_object(value: Object) -> Variant:
|
|
147
|
-
if not is_instance_valid(value):
|
|
148
|
-
return null
|
|
149
152
|
if not value is Resource:
|
|
150
153
|
return {"_type": "Object", "class": value.get_class()}
|
|
151
154
|
var resource: Resource = value
|