-
-
Notifications
You must be signed in to change notification settings - Fork 41
Expand file tree
/
Copy pathTools.gd
More file actions
778 lines (581 loc) · 37.2 KB
/
Tools.gd
File metadata and controls
778 lines (581 loc) · 37.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
## Helper functions for built-in Godot nodes and types to assist with common tasks.
## Most of this is stuff that should be built-in Godot but isn't :')
## and can't be injected into the base types such as Node etc. because GDScript doesn't have a feature like Swift's "extension" :(
class_name Tools
extends GDScript
#region Constants
## The cardinal & ordinal directions, each assigned a number representing the associated rotation angle in degrees, with East = 0 and incrementing by 45
enum CompassDirection {
# DESIGN: Start from East to match the default rotation angle of 0
# TBD: Should this be in `Tools.gd` or in `Global.gd`? :')
none = -1,
east = 0,
southEast = 45,
south = 90,
southWest = 135,
west = 180,
northWest = 225,
north = 270,
northEast = 315
}
const compassDirectionVectors: Dictionary[CompassDirection, Vector2i] = {
CompassDirection.none: Vector2i.ZERO,
CompassDirection.east: Vector2i.RIGHT,
CompassDirection.southEast: Vector2i(+1, +1),
CompassDirection.south: Vector2i.DOWN,
CompassDirection.southWest: Vector2i(-1, +1),
CompassDirection.west: Vector2i.LEFT,
CompassDirection.northWest: Vector2i(-1, -1),
CompassDirection.north: Vector2i.UP,
CompassDirection.northEast: Vector2i(+1, -1)
}
const compassDirectionOpposites: Dictionary[CompassDirection, CompassDirection] = {
CompassDirection.none: CompassDirection.none,
CompassDirection.east: CompassDirection.west,
CompassDirection.southEast: CompassDirection.northWest,
CompassDirection.south: CompassDirection.north,
CompassDirection.southWest: CompassDirection.northEast,
CompassDirection.west: CompassDirection.east,
CompassDirection.northWest: CompassDirection.southEast,
CompassDirection.north: CompassDirection.south,
CompassDirection.northEast: CompassDirection.southWest,
}
## A list of unit vectors representing 8 compass directions.
class CompassVectors:
# TBD: PERFORMANCE: Replace with `compassDirectionVectors[CompassDirection]` or are these simple `const`ants faster?
const none := Vector2i.ZERO
const east := Vector2i.RIGHT
const southEast := Vector2i(+1, +1)
const south := Vector2i.DOWN
const southWest := Vector2i(-1, +1)
const west := Vector2i.LEFT
const northWest := Vector2i(-1, -1)
const north := Vector2i.UP
const northEast := Vector2i(+1, -1)
enum Shape {
none,
circle,
rectangle, # Wanted to call #2 "square" to match with "²" :')
triangle
}
## For use with [method Array.pick_random] with an optional scaling factor.
const plusMinusOneOrZero: Array[int] = [-1, 0, +1] # TBD: Name :')
## For use with [method Array.pick_random] with an optional scaling factor.
const plusMinusOneOrZeroFloat: Array[float] = [-1.0, 0.0, +1.0] # TBD: Name :')
## For use with [method Array.pick_random] with an optional scaling factor.
const plusMinusOne: Array[int] = [-1, +1] # TBD: Name :')
## For use with [method Array.pick_random] with an optional scaling factor.
const plusMinusOneFloat: Array[float] = [-1.0, +1.0] # TBD: Name :')
## A sequence of float numbers from -1.0 to +1.0 stepped by 0.1
## TIP: Use [method Array.pick_random] to pick a random variation from this list for colors etc.
const sequenceNegative1toPositive1stepPoint1: Array[float] = [-1.0, -0.9, -0.8, -0.7, -0.6, -0.5, -0.4, -0.3, -0.2, -0.1, 0, +0.1, +0.2, +0.3, +0.4, +0.5, +0.6, +0.7, +0.8, +0.9, +1.0] # TBD: Better name pleawse :')
#endregion
#region Subclasses
## A set of parameters for [method CanvasItem.draw_line]
class Line: # UNUSED: Until Godot can support custom class @export :')
var start: Vector2
var end: Vector2
var color: Color = Color.WHITE
var width: float = -1.0 ## A negative means the line will remain a "2-point primitive" i.e. always be a 1-width line regardless of scaling.
#endregion
#region Scene Management
# See SceneManager.gd
#endregion
#region Script Tools
## Connects or reconnects a [Signal] to a [Callable] only if the connection does not already exist, to silence any annoying Godot errors about existing connections (presumably for reference counting).
static func connectSignal(sourceSignal: Signal, targetCallable: Callable, flags: int = 0) -> int:
if not sourceSignal.is_connected(targetCallable):
return sourceSignal.connect(targetCallable, flags) # No idea what the return value is for.
else:
return 0
## Disconnects a [Signal] from a [Callable] only if the connection actually exists, to silence any annoying Godot errors about missing connections (presumably for reference counting).
static func disconnectSignal(sourceSignal: Signal, targetCallable: Callable) -> void:
if sourceSignal.is_connected(targetCallable):
sourceSignal.disconnect(targetCallable)
## Connects/reconnects OR disconnects a [Signal] from a [Callable] safely, based on the [param reconnect] flag.
## TIP: This saves having to type `if someFlag: connectSignal(…) else: disconnectSignal(…)`
static func toggleSignal(sourceSignal: Signal, targetCallable: Callable, reconnect: bool, flags: int = 0) -> int:
if reconnect and not sourceSignal.is_connected(targetCallable):
return sourceSignal.connect(targetCallable, flags) # No idea what the return value is for.
elif not reconnect and sourceSignal.is_connected(targetCallable):
sourceSignal.disconnect(targetCallable)
# else:
return 0
## A safe wrapper around [method Object.call] or [method Object.callv] that does not crash if the function/method name is missing.
## Returns the result of the call.
## TIP: Useful for passing customizable functions such as dynamically choosing different animations on `Animations.gd`
## ALERT: Does NOT check if [param object] is a valid non-null [Object]
static func callCustom(object: Object, functionName: StringName, ...arguments: Array) -> Variant:
if object.has_method(functionName):
return object.callv(functionName, arguments)
else:
Debug.printWarning(str("callCustom(): ", object, " invalid or has no such function: " + functionName), object)
return null
## Returns a [StringName] with the `class_name` from a [Script] type.
## NOTE: This method is needed because we cannot directly write `SomeTypeName.get_global_name()` :(
static func getStringNameFromClass(type: Script) -> StringName:
return type.get_global_name()
## Checks whether a script has a function/method with the specified name.
## NOTE: Only checks for the name, NOT the arguments or return type.
## ALERT: Use the EXACT SAME CASE as the method you need to find!
static func findMethodInScript(script: Script, methodName: StringName) -> bool: # TBD: Should it be [StringName]?
# TODO: A variant or option to check for multiple methods.
# TODO: Check arguments and return type.
var methodDictionary: Array[Dictionary] = script.get_script_method_list()
for method in methodDictionary:
# DEBUG: Debug.printDebug(str("findMethodInScript() script: ", script, " searching: ", method))
if method["name"] == methodName: return true
return false
#endregion
#region Node Management
# See NodeTools.gd
#endregion
#region NodePath Functions
## Convert a [NodePath] from the `./` form to the absolute representation: `/root/` INCLUDING the property path if any.
static func convertRelativeNodePathToAbsolute(parentNodeToConvertFrom: Node, relativePath: NodePath) -> NodePath:
var absoluteNodePath: String = parentNodeToConvertFrom.get_node(relativePath).get_path()
var propertyPath: String = str(":", relativePath.get_concatenated_subnames())
var absolutePathIncludingProperty: NodePath = NodePath(str(absoluteNodePath, propertyPath))
# DEBUG:
#Debug.printLog(str("Tools.convertRelativeNodePathToAbsolute() parentNodeToConvertFrom: ", parentNodeToConvertFrom, \
#", relativePath: ", relativePath, \
#", absoluteNodePath: ", absoluteNodePath, \
#", propertyPath: ", propertyPath))
return absolutePathIncludingProperty
## Splits a [NodePath] into an Array of 2 paths where index [0] is the node's path and [1] is the property chain, e.g. `/root:size:x` → [`/root`, `:size:x`]
static func splitPathIntoNodeAndProperty(path: NodePath) -> Array[NodePath]:
var nodePath: NodePath = NodePath(str("/" if path.is_absolute() else "", path.get_concatenated_names()))
var subnames: String = path.get_concatenated_subnames()
var propertyPath: NodePath = NodePath(str(":", subnames)) if not subnames.is_empty() else NodePath() # Avoid an invalid trailing `:` if there is no property
return [nodePath, propertyPath]
#endregion
#region Area & Shape Geometry
static func getRectCorner(rectangle: Rect2, compassDirection: Vector2i) -> Vector2:
var position: Vector2 = rectangle.position
var center: Vector2 = rectangle.get_center()
var end: Vector2 = rectangle.end
match compassDirection:
CompassVectors.northWest: return Vector2(position.x, position.y)
CompassVectors.north: return Vector2(center.x, position.y)
CompassVectors.northEast: return Vector2(end.x, position.y)
CompassVectors.east: return Vector2(end.x, center.y)
CompassVectors.southEast: return Vector2(end.x, end.y)
CompassVectors.south: return Vector2(center.x, end.y)
CompassVectors.southWest: return Vector2(position.x, end.y)
CompassVectors.west: return Vector2(position.x, center.y)
_: return Vector2.ZERO
## Returns a [Rect2] representing the boundary/extents of the FIRST [CollisionShape2D] child of a [CollisionObject2D] (e.g. [Area2D] or [CharacterBody2D]).
## NOTE: The rectangle is in the coordinates of the shape's [CollisionShape2D] container, with its anchor at the CENTER.
## Works most accurately & reliably for areas with a single [RectangleShape2D].
## Returns: A [Rect2] of the bounds. On failure: a rectangle with size -1 and the position set to the [CollisionObject2D]'s local position.
static func getShapeBounds(node: CollisionObject2D) -> Rect2:
# HACK: Sigh @ Godot for making this so hard...
# Find a CollisionShape2D child.
var shapeNode: CollisionShape2D = NodeTools.findFirstChildOfType(node, CollisionShape2D)
if not shapeNode:
Debug.printWarning("getShapeBounds(): Cannot find a CollisionShape2D child", node)
return Rect2(node.position.x, node.position.y, -1, -1) # Return an invalid negative-sized rectangle matching the node's origin.
return shapeNode.shape.get_rect()
## Returns a [Rect2] representing the combined rectangular boundaries/extents of ALL the [CollisionShape2D] children of an a [CollisionObject2D] (e.g. [Area2D] or [CharacterBody2D]).
## To get the bounds of the first shape only, set [param maximumShapeCount] to 1.
## NOTE: The rectangle is in the LOCAL coordinates of the [CollisionObject2D]. To convert to GLOBAL coordinates, add + the area's [member Node2D.global_position].
## Works most accurately & reliably for areas/bodies with a single [RectangleShape2D].
## Returns: A [Rect2] of all the merged bounds. On failure: a rectangle with size -1 and origin (0,0)
static func getShapeBoundsInNode(node: CollisionObject2D, maximumShapeCount: int = 100) -> Rect2:
# TBD: PERFORMANCE: Option to cache results?
# HACK: Sigh @ Godot for making this so hard...
# INFO: PLAN: Overview: An [CollisionObject2D] has a [CollisionShape2D] child [Node], which in turn has a [Shape2D] [Resource].
# In the parent CollisionObject2D, the CollisionShape2D's "anchor point" is at the top-left corner, so its `position` may be 0,0.
# But inside the CollisionShape2D, the Shape2D's anchor point is at the CENTER of the shape, so its `position` would be for example 16,16 for a rectangle of 32x32.
# SO, we have to figure out the Shape2D's rectangle in the coordinate space of the CollisionObject2D.
# THEN convert it to global coordinates.
if node.get_child_count() < 1: return Rect2(0, 0, -1, -1) # On failure, return an invalid negative-sized rectangle
# Get all CollisionShape2D children
var combinedShapeBounds: Rect2
var shapesAdded: int = 0
var shapeSize: Vector2
var shapeBounds: Rect2
for shapeNode in node.get_children(): # TBD: PERFORMANCE: Use Node.find_children()?
if shapeNode is CollisionShape2D:
shapeSize = shapeNode.shape.get_rect().size # TBD: Should we use `extents`? It seems to be half of the size, but it seems to be a hidden property [as of 4.3 Dev 3].
# Because a [CollisionShape2D]'s anchor is at the center of, we have to get it's top-left corner, by subtracting HALF the size of the actual SHAPE:
shapeBounds = Rect2(shapeNode.position - shapeSize / 2, shapeSize) # TBD: PERFORMANCE: Use * 0.5?
if shapesAdded < 1: combinedShapeBounds = shapeBounds # Is it the first shape?
else: combinedShapeBounds = combinedShapeBounds.merge(shapeBounds)
# DEBUG: Debug.printDebug(str("shape: ", shapeNode.shape, ", rect: ", shapeNode.shape.get_rect(), ", bounds in node: ", shapeBounds, ", combinedShapeBounds: ", combinedShapeBounds), node)
shapesAdded += 1
if shapesAdded >= maximumShapeCount: break
if shapesAdded < 1:
Debug.printWarning("getShapeBoundsInNode(): Cannot find a CollisionShape2D child", node)
return Rect2(0, 0, -1, -1) # On failure, return an invalid negative-sized rectangle
else:
# DEBUG: Debug.printTrace([combinedShapeBounds, node.get_child_count(), shapesAdded], node)
return combinedShapeBounds
## Calls [method Tools.getShapeBoundsInNode] and returns the [Rect2] representing the combined rectangular boundaries/extents of ALL the [CollisionShape2D] children of a [CollisionObject2D] (e.g. [Area2D] or [CharacterBody2D]), converted to GLOBAL coordinates.
## Useful for comparing the [Area2D]s etc. of 2 separate nodes/entities.
## WARNING: May not work correctly with rotation, scaling or negative dimensions.
static func getShapeGlobalBounds(node: CollisionObject2D) -> Rect2:
# TBD: PERFORMANCE: Option to cache results?
var shapeGlobalBounds: Rect2 = getShapeBoundsInNode(node)
shapeGlobalBounds.position = node.to_global(shapeGlobalBounds.position)
return shapeGlobalBounds
## Returns a [Vector2] representing the distance by which an [intended] inner/"contained" [Rect2] is outside of an outer/"container" [Rect2], e.g. a player's [ClimbComponent] in relation to a Climbable [Area2D] "ladder" etc.
## TIP: To put the inner rectangle back inside the container rectangle, SUBTRACT (or add the negative of) the returned offset from the [param containedRect]'s [member Rect2.position] (or from the position of the Entity it represents).
## WARNING: Does NOT include rotation or scaling etc.
## Returns: The offset/displacement by which the [param containedRect] is outside the bounds of the [param containerRect].
## Negative -X values mean to the left, +X means to the right. -Y means jutting upwards, +Y means downwards.
## (0,0) if the [param containedRect] is completely inside the [param containerRect].
static func getRectOffsetOutsideContainer(containedRect: Rect2, containerRect: Rect2) -> Vector2:
# If the container completely encloses the containee, no need to do anything.
if containerRect.encloses(containedRect): return Vector2.ZERO
var displacement: Vector2
# Out to the left?
if containedRect.position.x < containerRect.position.x:
displacement.x = containedRect.position.x - containerRect.position.x # Negative if the containee's left edge is further left
# Out to the right?
elif containedRect.end.x > containerRect.end.x:
displacement.x = containedRect.end.x - containerRect.end.x # Positive if the containee's right edge is further right
# Out over the top?
if containedRect.position.y < containerRect.position.y:
displacement.y = containedRect.position.y - containerRect.position.y # Negative if the containee's top is higher
# Out under the bottom?
elif containedRect.end.y > containerRect.end.y:
displacement.y = containedRect.end.y - containerRect.end.y # Positive if the containee's bottom is lower
return displacement
## Checks a list of [Rect2]s and returns the rectangle nearest to a specified reference rectangle.
## The [param comparedRects] would usually represent static "zones" and the [param referenceRect] may be the bounds of a player Entity or another character etc.
static func findNearestRect(referenceRect: Rect2, comparedRects: Array[Rect2]) -> Rect2:
# TBD: PERFORMANCE: Option to cache results?
var nearestRect: Rect2
var minimumDistance: float = INF # Start with infinity
# TBD: PERFORMANCE: All these variables could be replaced by directly accessing Rect2.position & Rect2.end etc. but these names may make the code easier to read and understand.
var referenceLeft: float = referenceRect.position.x
var referenceRight: float = referenceRect.end.x
var referenceTop: float = referenceRect.position.y
var referenceBottom:float = referenceRect.end.y
var comparedLeft: float
var comparedRight: float
var comparedTop: float
var comparedBottom: float
var gap: Vector2 # The pixels between the area edges
var distance: float # The Euclidean distance between edges
for comparedRect: Rect2 in comparedRects:
if not comparedRect.abs().has_area(): continue # Skip rect if it doesn't have an area
# If both regions are exactly the same position & size,
# or either of them completely contain the other, then you can't get any nearer than that!
if comparedRect.is_equal_approx(referenceRect) \
or comparedRect.encloses(referenceRect) or referenceRect.encloses(comparedRect):
minimumDistance = 0
nearestRect = comparedRect
break
# Simplify names
comparedLeft = comparedRect.position.x
comparedRight = comparedRect.end.x
comparedTop = comparedRect.position.y
comparedBottom = comparedRect.end.y
gap = Vector2.ZERO # Gaps will default to 0 if the edges are touching
# Compute horizontal gap
if referenceRight < comparedLeft: gap.x = comparedLeft - referenceRight # Primary to the left of Compared?
elif comparedRight < referenceLeft: gap.x = referenceLeft - comparedRight # or to the right?
# Compute vertical gap
if referenceBottom < comparedTop: gap.y = comparedTop - referenceBottom # Primary above Compared?
elif comparedBottom < referenceTop: gap.y = referenceTop - comparedBottom # or below?
# Get the Euclidean distance between edges
distance = sqrt(gap.x * gap.x + gap.y * gap.y)
# We have a nearer `nearestRect` if this is a new minimum
if distance < minimumDistance:
minimumDistance = distance
nearestRect = comparedRect
return nearestRect
## Checks a list of [Area2D]s and returns the area nearest to a specified reference area.
## The [param comparedAreas] would usually be static "zones" and the [param referenceArea] may be the bounds of a player Entity or another character etc.
## NOTE: If 2 different [Area2D]s are at the same distance from [param referenceArea] then the one on top i.e. with the higher [member CanvasItem.z_index] will be used.
static func findNearestArea(referenceArea: Area2D, comparedAreas: Array[Area2D]) -> Area2D:
# TBD: PERFORMANCE: Option to cache results?
# DESIGN: PERFORMANCE: Cannot use findNearestRect() because that would require calling getShapeGlobalBounds() on all areas beforehand,
# and there is a separate tie-break based on the Z index, so there has to be some code dpulication :')
var nearestArea: Area2D = null # Initialize with `null` to avoid the "used before assigning a value" warning
var minimumDistance: float = INF # Start with infinity
var referenceAreaBounds: Rect2 = Tools.getShapeGlobalBounds(referenceArea)
var comparedAreaBounds: Rect2
# TBD: PERFORMANCE: All these variables could be replaced by directly accessing Rect2.position & Rect2.end etc. but these names may make the code easier to read and understand.
var referenceLeft: float = referenceAreaBounds.position.x
var referenceRight: float = referenceAreaBounds.end.x
var referenceTop: float = referenceAreaBounds.position.y
var referenceBottom:float = referenceAreaBounds.end.y
var comparedLeft: float
var comparedRight: float
var comparedTop: float
var comparedBottom: float
var gap: Vector2 # The pixels between the area edges
var distance: float # The Euclidean distance between edges
for comparedArea: Area2D in comparedAreas:
if comparedArea == referenceArea: continue
comparedAreaBounds = Tools.getShapeGlobalBounds(comparedArea)
if not comparedAreaBounds.abs().has_area(): continue # Skip area if it doesn't have an area!
# If both regions are exactly the same position & size,
# or either of them completely contain the other, then you can't get any nearer than that!
if comparedAreaBounds.is_equal_approx(referenceAreaBounds) \
or comparedAreaBounds.encloses(referenceAreaBounds) or referenceAreaBounds.encloses(comparedAreaBounds):
# Is this the first overlapping area? (i.e. the minimum distance is not already 0)
# or is it another overlapping area visually on top (with a higher Z index) of a previous overlapping area?
if not is_zero_approx(minimumDistance) \
or (nearestArea and comparedArea.z_index > nearestArea.z_index):
minimumDistance = 0
nearestArea = comparedArea
continue # NOTE: Do NOT `break` the loop here! Keep checking for multiple overlapping areas to choose the one with the highest Z index.
# Simplify names
comparedLeft = comparedAreaBounds.position.x
comparedRight = comparedAreaBounds.end.x
comparedTop = comparedAreaBounds.position.y
comparedBottom = comparedAreaBounds.end.y
gap = Vector2.ZERO # Gaps will default to 0 if the edges are touching
# Compute horizontal gap
if referenceRight < comparedLeft: gap.x = comparedLeft - referenceRight # Primary to the left of Compared?
elif comparedRight < referenceLeft: gap.x = referenceLeft - comparedRight # or to the right?
# Compute vertical gap
if referenceBottom < comparedTop: gap.y = comparedTop - referenceBottom # Primary above Compared?
elif comparedBottom < referenceTop: gap.y = referenceTop - comparedBottom # or below?
# Get the Euclidean distance between edges
distance = sqrt(gap.x * gap.x + gap.y * gap.y)
# We have a nearer `nearestArea` if this is a new minimum
if distance < minimumDistance:
minimumDistance = distance
nearestArea = comparedArea
# If 2 different [Area2D]s have the same distance,
# use the one that is visually on top of the other: with a higher Z index
elif is_equal_approx(distance, minimumDistance) \
and nearestArea and comparedArea.z_index > nearestArea.z_index:
nearestArea = comparedArea
# TBD: Otherwise, keep the first area.
return nearestArea
## Returns a random point inside the combined rectangular boundary of ALL an [Area2D]'s [Shape2D]s.
## NOTE: Does NOT verify whether a point is actually enclosed inside a [Shape2D].
## Works most accurately & reliably for areas with a single [RectangleShape2D].
static func getRandomPositionInArea(area: Area2D) -> Vector2:
var areaBounds: Rect2 = getShapeBoundsInNode(area)
# Generate a random position within the area.
#randomize() # TBD: Do we need this?
#var isWithinArea: bool = false
#while not isWithinArea:
var x: float = randf_range(areaBounds.position.x, areaBounds.end.x)
var y: float = randf_range(areaBounds.position.y, areaBounds.end.y)
var randomPosition: Vector2 = Vector2(x, y)
#if shouldVerifyWithinArea: isWithinArea = ... # TODO: Cannot check if a point is within an area :( [as of 4.3 Dev 3]
#else: isWithinArea = true
# DEBUG: Debug.printDebug(str("area: ", area, ", areaBounds: ", areaBounds, ", randomPosition: ", randomPosition))
return randomPosition
## Returns a COPY of a [Vector2i] moved in the specified [enum CompassDirection]
static func offsetVectorByCompassDirection(vector: Vector2i, direction: CompassDirection) -> Vector2i:
return vector + Tools.compassDirectionVectors[direction]
#endregion
#region Physics Functions
## Sets the X and/or Y components of [member CharacterBody2D.velocity] to 0 if the [method CharacterBody2D.get_last_motion()] is 0 in the respective axes.
## This prevents the "glue effect" where if the player keeps inputting a direction while the character is pushed against a wall,
## it will take a noticeable delay to move in the other direction while the velocity gradually changes from the wall's direction to away from the wall.
static func resetBodyVelocityIfZeroMotion(body: CharacterBody2D) -> Vector2:
var lastMotion: Vector2 = body.get_last_motion()
if is_zero_approx(lastMotion.x): body.velocity.x = 0
if is_zero_approx(lastMotion.y): body.velocity.y = 0
return lastMotion
## Returns the [Shape2D] from a [CollisionObject2D]-based node (such as [Area2D] or [CharacterBody2D]) and a given "shape index"
## @experimental
static func getCollisionShape(node: CollisionObject2D, shapeIndex: int = 0) -> Shape2D:
# What is this hell...
var areaShapeOwnerID: int = node.shape_find_owner(shapeIndex)
# UNUSED: var areaShapeOwner: CollisionShape2D = node.shape_owner_get_owner(areaShapeOwnerID)
return node.shape_owner_get_shape(areaShapeOwnerID, shapeIndex) # CHECK: Should it be `shapeIndex` or 0?
#endregion
#region Visual Functions
static func addRandomDistance(position: Vector2, \
minimumDistance: Vector2, maximumDistance: Vector2, \
xScale: float = 1.0, yScale: float = 1.0) -> Vector2:
var randomizedPosition: Vector2 = position
randomizedPosition.x += randf_range(minimumDistance.x, maximumDistance.x) * xScale
randomizedPosition.y += randf_range(minimumDistance.y, maximumDistance.y) * yScale
return randomizedPosition
## Returns a [Color] with R,G,B each set to a random value "quantized" to steps of 0.25
static func getRandomQuantizedColor() -> Color:
const steps: Array[float] = [0.25, 0.5, 0.75, 1.0]
return Color(steps.pick_random(), steps.pick_random(), steps.pick_random())
## Returns the global position of the top-left corner of the screen in the camera's view.
static func getScreenTopLeftInCamera(camera: Camera2D) -> Vector2:
var cameraCenter: Vector2 = camera.get_screen_center_position()
return cameraCenter - camera.get_viewport_rect().size / 2
#endregion
#region Tile Map Functions
# See TileMapTools.gd
#endregion
#region UI Functions
## Creates a new copy of a [Control]'s [StyleBox] to avoid affecting other controls sharing the same StyleBox,
## and sets the specified color on the specified property.
## @experimental
static func setNewStyleBoxColor(control: Control, color: Color, styleBoxName: StringName = &"fill", propertyName: StringName = &"bg_color") -> StyleBox:
var styleBox: StyleBox = control.get_theme_stylebox(styleBoxName)
if not styleBox:
Debug.printWarning(str("Tools.setNewStyleBoxColor(): Cannot get StyleBox: ", styleBoxName), control)
return null
if styleBox is StyleBoxFlat:
var newStyleBox: StyleBox = styleBox.duplicate() # NOTE: Don't want to change the color of ALL controls sharing the same StyleBox!
newStyleBox.set(propertyName, color)
control.add_theme_stylebox_override(styleBoxName, newStyleBox)
return newStyleBox
else:
# TBD: Handle other StyleBox variants?
Debug.printWarning(str("Tools.setNewStyleBoxColor(): Unsupported StyleBox type: ", styleBox), control)
return null
## Sets the text of [Label]s from a [Dictionary].
## Iterates over an array of [Label]s, and takes the prefix of the node name by removing the "Label" suffix, if any, and making it LOWER CASE,
## and searches the [param dictionary] for any String keys which match the label's name prefix. If there is a match, sets the label's text to the dictionary value for each key.
## Example: `logMessageLabel.text = dictionary["logmessage"]`
## TIP: Use to quickly populate an "inspector" UI with text representing multiple properties of a selected object etc.
## NOTE: The dictionary keys must all be fully LOWER CASE.
static func setLabelsWithDictionary(labels: Array[Label], dictionary: Dictionary[String, Variant], shouldShowPrefix: bool = false, shouldHideEmptyLabels: bool = false) -> void:
# DESIGN: We don't accept an array of any Control/Node because Labels may be in different containers, and some Labels may not need to be assigned from the Dictionary.
for label: Label in labels:
if not label: continue
var namePrefix: String = label.name.trim_suffix("Label").to_lower()
var dictionaryValue: Variant = dictionary.get(namePrefix)
var valueText: String
if dictionary.has(namePrefix): # NOTE: Do NOT check `dictionaryValue` because then values like `0`, `false`, empty strings will be considered non-existent!
valueText = str(dictionaryValue) if dictionaryValue != null else ""
else:
valueText = ""
label.text = namePrefix + ":" if shouldShowPrefix else "" # TBD: Space after colon?
label.text += valueText
if shouldHideEmptyLabels: label.visible = not valueText.is_empty() # Hides missing keys AND empty/false/zero values. Also automatically shows non-empty labels in case they were hidden before
## Displays the values of the specified [Object]'s properties in different [Label]s.
## Each [Label] must have EXACTLY the same case-sensitie name as a matching property in [param object]: `isEnabled` but NOT `IsEnabled` or `EnabledLabel` etc.
## TIP: Example: May be used to quickly display a [Resource] or [Component]'s data in a UI [Container].
## RETURNS: The number of [Label]s with names matching [param object] properties.
## For a script to attach to a UI [Container], use "PrintPropertiesToLabels.gd"
static func printPropertiesToLabels(object: Object, labels: Array[Label], shouldShowPropertyNames: bool = true, shouldHideNullProperties: bool = true, shouldUnhideAvailableLabels: bool = true) -> int:
var value: Variant # NOTE: Should not be String so we can explicitly check for `null`
var matchCount: int = 0
# Go through all our Labels
for label in labels:
# Does the object have a property with a matching name?
value = object.get(label.name)
if shouldShowPropertyNames: label.text = label.name + ": "
else: label.text = ""
# NOTE: Explicitly check for `null` to avoid cases like "0.0" being treated as a non-existent property.
if value != null:
label.text += str(value)
if shouldUnhideAvailableLabels: label.visible = true
matchCount += 1
else:
label.text += "null" if shouldShowPropertyNames else ""
if shouldHideNullProperties: label.visible = false
return matchCount
#endregion
#region Text Functions
## Returns an Enum's value along with its key as a text string.
## TIP: To just get the Enum key corresponding to the specified value, use [method Dictionary.find_key].
## WARNING: May NOT work as expected for enums with non-sequential values or starting below 0, or if there are multiple identical values, or if there is a 'null' key.
static func getEnumText(enumType: Dictionary, value: int) -> String:
# TBD: Less ambiguous name?
var key: String
key = str(enumType.find_key(value)) # TBD: Check for `null`?
if key.is_empty(): key = "[invalid key/value]"
return str(value, " (", key, ")")
## Iterates over a [String] and replaces all occurrences of text matching the [param substitutions] [Dictionary]'s [method Dictionary.keys] with the values for those keys.
## Example: A Dictionary of { "Apple":"Banana", "Cat":"Dog" } would replace all "Apple" in [param sourceString] with "Banana" and all "Cat" with "Dog".
## NOTE: Does NOT modify the [param sourceString], instead returns a modified string.
static func replaceStrings(sourceString: String, substitutions: Dictionary[String, String]) -> String:
var modifiedString: String = sourceString
for key: String in substitutions.keys():
modifiedString = modifiedString.replace(key, substitutions[key])
return modifiedString
#endregion
#region Maths Functions
## TIP: To "truncate" the number of decimal points, use Godot's [method @GlobalScope.snappedf] function.
## "Rolls" a random integer number from 1…100 (inclusive) and returns `true` if the result is less than or equal to the specified [param chancePercent].
## i.e. If the chance is 10% then a roll of 1…10 will succeed but 11…100 (90 possibilities) will fail.
static func rollChance(chancePercent: int) -> bool:
return randi_range(1, 100) <= chancePercent
## Returns a copy of a number wrapped around to the [param minimum] or [param maximum] value if it exceeds or goes below either limit (inclusive).
## May be used to cycle through a range by adding/subtracting an offset to [param current] such as +1 or -1. The number may be an array index or `enum` state, or a sprite position to wrap it around the screen Pac-Man-style.
## If [param minimum] > [param maximum] then [param current] is returned unmodified.
static func wrapInteger(minimum: int, current: int, maximum: int) -> int:
# NOTE: Cannot use Godot's pingpong() because it "bounces" not "wraps"
if minimum > maximum:
Debug.printWarning(str("wrapInteger(): minimum ", minimum, " > maximum ", maximum, ", returning current: ", current))
return current # TBD: Return `current` or `minimum` or `maximum` in case of invalid arguments??
elif minimum == maximum: # If there is no difference between the range, just return either.
return minimum
# NOTE: Do NOT clamp first! So that an already-offset value may be provided for `current`
# THANKS: rubenverg@Discord, lololol__@Discord
return posmod(current - minimum, maximum - minimum + 1) + minimum # +1 to make limits inclusive
## Wraps a [float] value around if it is below 0.0 or higher than 1.0
static func wrapUnitFloat(value: float) -> float:
if value < 0.0 or value > 1.0: return fposmod(value, 1.0)
else: return value
#endregion
#region Array Functions
static func validateArrayIndex(array: Array, index: int) -> bool:
return index >= 0 and index < array.size()
## Takes a [param index] and increments it by the specified amount, wrapping it around to 0 + remainder if it exceeds an [param array]'s size.
## Returns 0 if the array is empty, which will be an invalid index.
static func wrapArrayIndex(array: Variant, index: int, increment: int) -> int: # NOTE: Typed as [Variant] instead of [Array] in order to also accept [PackedStringArray] etc.
if not array.is_empty(): return Tools.wrapInteger(0, index + increment, array.size() - 1)
else: return 0
#endregion
#region File System Functions
# See FileSystemTools.gd
#endregion
#region Miscellaneous Functions
## Checks whether a [Variant] value may be considered a "success", for example the return of a function.
## If [param value] is a [bool], then it is returned as is.
## If the value is an [Array] or [DIctionary], `true` is returned if it's not empty.
## For all other types, `true` is returned if the value is not `null`.
## TIP: Use for verifying whether a [Payload]'s [method executeImplementation] executed successfully.
static func checkResult(value: Variant) -> bool:
# Because GDScript doesn't have Tuples :')
if value is bool: return value
elif value is Array or value is Dictionary: return not value.is_empty()
elif value != null: return true
else: return false
## Stops a [Timer] and emits its [signal Timer.timeout] signal.
## WARNING: This may cause bugs, especially when multiple objects are using `await` to wait for a Timer.
## Returns: The leftover time before the timer was stopped. WARNING: May not be accurate!
static func skipTimer(timer: Timer) -> float:
# WARNING: This may not be accurate because the Timer is still running until the `stop()` call.
var leftoverTime: float = timer.time_left
timer.stop()
timer.timeout.emit()
return leftoverTime
## Searches for a [param value] in an [param options] array and if found, returns the next item from the list.
## If [param value] is the last member of the array, then the array's first item is returned.
## If there is only 1 item in the array, then the same value is returned, or `null` if [param value] is not found.
## TIP: May be used to cycle through a list of possible options, such as [42, 69, 420, 666]
## WARNING: The cycle may get "stuck" if there are 2 or more identical values in the list: [a, b, b, c] will always only return the 2nd `b`
static func cycleThroughList(value: Variant, list: Array[Variant]) -> Variant:
if list.is_empty(): return null # NOTE: Do NOT check `if value` because that will exclude 0, `false` and empty strings etc.!
var index: int = list.find(value)
if index < 0: return null # -1 means `value` not found
elif list.size() == 1: return value # If there's only 1 item, there's nothing else to return
elif index < list.size() - 1: return list[index + 1] # Return the next item from the array
else: return list[0] # Wrap around if `value` is at the end of the array
## Resets a [Resource] to its saved default values by reloading its `.tres` file from the project bundle.
## Copies all serialized properties back onto the live instance IN-PLACE,
## preserving all signal connections, [Dictionary] caches, and external references.
## Returns `true` if successful. Returns `false` if the [param resource] has no [member Resource.resource_path] (e.g. if it's an inline Resource inside a `.tscn` scene)
## TIP: For a [Stat], this restores the [member Stat.value] to the designer's saved default, which may differ from [member Stat.min] and [member Stat.max]
## ALERT: Property setters WILL fire during the reset, which may emit signals such as [signal Resource.changed]/[signal Stat.didMin]/[signal Stat.didMax]
## @experimental
static func resetResource(resource: Resource) -> bool:
# TBD: CHECK: Is there a better way?
if not resource or resource.resource_path.is_empty():
Debug.printWarning(str("resetResourceToDefaults() Resource: ", resource, " has no resource_path • May be inline/dynamic resource?"), resource)
return false
var savedResource: Resource = ResourceLoader.load(resource.resource_path, "", ResourceLoader.CACHE_MODE_IGNORE) # TBD: Use `CACHE_MODE_REPLACE_DEEP`?
if not savedResource:
Debug.printWarning("resetResourceToDefaults() ResourceLoader.load failed: " + resource.resource_path, resource)
return false
# NOTE: Copy each property, to reset without destroying the existing Resource instance
# to preserve existing signals etc.
for property: Dictionary in savedResource.get_property_list():
if property.usage & PROPERTY_USAGE_STORAGE:
resource.set(property.name, savedResource.get(property.name))
return true
#endregion