-
-
Notifications
You must be signed in to change notification settings - Fork 36
Expand file tree
/
Copy pathTools.gd
More file actions
1478 lines (1101 loc) · 73 KB
/
Tools.gd
File metadata and controls
1478 lines (1101 loc) · 73 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
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
## 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. :(
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: Replace with `compassDirectionVectors[CompassDirection]`?
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)
## 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:
# TBD: Should `reconnect` be a nullable Variant?
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`
static func callCustom(object: Variant, functionName: StringName, ...arguments: Array) -> Variant:
if object.has_method(functionName):
return object.callv(functionName, arguments)
else:
Debug.printWarning(str("callCustom(): ", object, " has no such function: " + functionName), "Tools.gd")
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()` :(
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
## Calls [param parent].[method Node.add_child] and sets the [param child].[member Node.owner].
## This is necessary for persistence to a [PackedScene] for save/load.
## NOTE: Also sets the `force_readable_name` parameter, which may slow performance if used frequently.
static func addChildAndSetOwner(child: Node, parent: Node) -> void: # DESIGN: TBD: Should `parent` be the 1st argument or 2nd? All global functions operate on the 1st argument, the parent [Node], but this method's name has "child" as the first word, so the `child` should be the 1st argument, right? :')
parent.add_child(child, Debug.shouldForceReadableName) # PERFORMANCE: force_readable_name only if debugging
child.owner = parent
## Adds & returns a child node at the position of another node, and optionally copies the rotation and scale of the [member placementNode].
## Also sets the child's owner to the new parent.
## Example: Using [Marker2D]s as placeholders for objects like doors etc. during procedural map generation from a template.
## NOTE: Also sets the `force_readable_name` parameter, which may slow performance if used frequently.
static func addChildAtNode(child: Node2D, placementNode: Node2D, parent: Node, copyRotation: bool = true, copyScale: bool = true) -> Node2D:
child.position = placementNode.position
if copyRotation: child.rotation = placementNode.rotation
if copyScale: child.scale = placementNode.scale
parent.add_child(child, Debug.shouldForceReadableName) # PERFORMANCE: force_readable_name only if debugging
child.owner = parent
return child
## Returns the first child of [param parentNode] which matches the specified [param type].
## If [param includeParent] is `true` (default) then the [param parentNode] ITSELF may be returned if it is node of a matching type. This may be useful for [Sprite2D] or [Area2D] etc. nodes with the `Entity.gd` script.
static func findFirstChildOfType(parentNode: Node, childType: Variant, includeParent: bool = true) -> Node:
if includeParent and is_instance_of(parentNode, childType):
return parentNode
var children: Array[Node] = parentNode.get_children()
for child in children:
if is_instance_of(child, childType): return child # break
#else
return null
## Calls [method Tools.findFirstChildOfType] to return the first child of [param parentNode] which matches ANY of the specified [param types] (searched in the array order).
## If [param includeParent] is `true` (default) then the [param parentNode] ITSELF is returned AFTER none of the requested types are found.
## This may be useful for choosing certain child nodes of an entity to operate on, like an [AnimatedSprite2D] or [Sprite2D] to animate, otherwise operate on the entity itself.
## WARNING: [param returnParentIfNoMatches] returns the [param parentNode] even if it is NOT one of the [param childTypes]!
## PERFORMANCE: Should be the same as multiple calls to [method Tools.findFirstChildOfType] in order of the desired types.
static func findFirstChildOfAnyTypes(parentNode: Node, childTypes: Array[Variant], returnParentIfNoMatches: bool = true) -> Node:
# TBD: Better name
# Nodes may be an instance of multiple inherited types, so check each of the requested types.
# NOTE: Types must be the outer loop, so that when searching for [AnimatedSprite2D, Sprite2D], the first [AnimatedSprite2D] is returned.
# If child nodes are the outer loop, then a [Sprite2D] might be returned if it is higher in the child tree than the [AnimatedSprite2D].
for type: Variant in childTypes:
for child in parentNode.get_children():
if is_instance_of(child, type): return child # break
# Return the parent itself AFTER none of the requested types are found.
# DESIGN: REASON: This may be useful for situations like choosing an [AnimatedSprite2D] or [Sprite2D] otherwise operate on the entity itself.
return parentNode if returnParentIfNoMatches else null
## Searches up the tree until a matching parent or grandparent is found.
static func findFirstParentOfType(childNode: Node, parentType: Variant) -> Node:
var parent: Node = childNode.get_parent() # parentOrGrandparent
# If parent is not the matching type, get the grandparent (parent's parent) and keep searching up the tree, until we run out of parents (null).
while parent != null and not is_instance_of(parent, parentType): # NOTE: Avoid calling get_parent() on `null`
parent = parent.get_parent()
return parent
## Appends a linear/"flattened" list of ALL the child nodes AND their subchildren and so on, recursively, from the specified [param firstNode].
## e.g. `[FirstNode, Child1ofFirstNode, Child1ofChild1ofFirstNode, Child2ofChild1ofFirstNode, Child2ofFirstNode, …]`
## TIP: EXAMPLE USAGE: This may be useful for setting UI focus chains in trees/lists etc.
## @experimental
static func flatMapNodeTree(nodeToIterate: Node, existingList: Array[Node]) -> void:
# TODO: Better name?
# TODO: Filtering
# TODO: This should be a generic function for flattening trees of any type :')
existingList.append(nodeToIterate)
for index in nodeToIterate.get_child_count(): # No need to -1 because the end of a range is EXCLUSIVE
flatMapNodeTree(nodeToIterate.get_child(index), existingList)
## Calls [method Tools.flatMapNodeTree] to return a linear/"flattened" list of ALL the child nodes AND their subchildren, recursively, from the specified [param firstNode].
## @experimental
static func getAllChildrenRecursively(firstNode: Node) -> Array[Node]:
# TBD: Merge with flatMapNodeTree()?
var flatList: Array[Node]
Tools.flatMapNodeTree(firstNode, flatList)
return flatList
## Replaces a child node with another node at the same index (order), optionally copying the position, rotation and/or scale.
## NOTE: The previous child and its sub-children are NOT deleted by default. To delete a child, set [param freeReplacedChild] or use [method Node.queue_free].
## Returns: `true` if [param childToReplace] was found and replaced.
static func replaceChild(parentNode: Node, childToReplace: Node, newChild: Node, copyPosition: bool = false, copyRotation: bool = false, copyScale: bool = false, freeReplacedChild: bool = false) -> bool:
if childToReplace.get_parent() != parentNode:
Debug.printWarning(str("replaceChild() childToReplace.get_parent(): ", childToReplace.get_parent(), " != parentNode: ", parentNode))
return false
# Is the new child already in another parent?
# TODO: Option to remove new child from existing parent
var newChildCurrentParent: Node = newChild.get_parent()
if newChildCurrentParent != null and newChildCurrentParent != parentNode:
Debug.printWarning("replaceChild(): newChild already in another parent: " + str(newChild, " in ", newChildCurrentParent))
return false
# Copy properties
if copyPosition: newChild.position = childToReplace.position
if copyRotation: newChild.rotation = childToReplace.rotation
if copyScale: newChild.scale = childToReplace.scale
# Swap the kids
var previousChildIndex: int = childToReplace.get_index() # The original index
parentNode.remove_child(childToReplace) # NOTE: Do not use `replace_by()` which transfers all sub-children as well.
Tools.addChildAndSetOwner(newChild, parentNode) # Ensure persistence
parentNode.move_child(newChild, previousChildIndex)
newChild.owner = parentNode # INFO: Necessary for persistence to a [PackedScene] for save/load.
# Yeet the disowned child?
if freeReplacedChild: childToReplace.queue_free()
return true
## Removes the first child of the [param parentNode], if any, and adds the specified [param newChild]. Optionally copies the position, rotation and/or scale.
## NOTE: The new child is added regardless of whether the parent already had a child or not.
## NOTE: The previous child and its sub-children are NOT deleted by default. To delete a child, set [param freeReplacedChild] or use [method Node.queue_free].
static func replaceFirstChild(parentNode: Node, newChild: Node, copyPosition: bool = false, copyRotation: bool = false, copyScale: bool = false, freeReplacedChild: bool = false) -> void:
var childToReplace: Control = parentNode.findFirstChildControl()
# Debug.printDebug(str("replaceFirstChildControl(): ", childToReplace, " → ", newChild), parentNode)
if childToReplace:
Tools.replaceChild(parentNode, childToReplace, newChild, copyPosition, copyRotation, copyScale, freeReplacedChild)
else: # If there are no children, just add the new one.
Tools.addChildAndSetOwner(newChild, parentNode) # Ensure persistence
newChild.owner = parentNode # For persistence
## Removes each child from the [parameter parent] then calls [method Node.queue_free] on the child.
## Returns: The number of removed children.
static func removeAllChildren(parent: Node) -> int:
var removalCount: int = 0
for child in parent.get_children():
parent.remove_child(child) # TBD: Is this needed? Does NOT delete nodes, unlike queue_free()
child.queue_free()
removalCount += 1
return removalCount
## Asks a node's parent to remove all other children of the same class/type/script as the calling [param node].
## NOTE: If there are multiple children of the same "type" such as [Label] but they have different SCRIPTS, they will NOT count as the "same type"!
## Returns: The number of nodes removed.
## @experimental
static func removeSiblingsOfSameType(node: Node, shouldFree: bool = false) -> int:
# TODO: Handle subclasses
# NOTE: "Built‑in" Godot types such as Sprite2D, Label etc. may have an empty get_script()
# so we may have to try checking the "class name" too.
var parent: Node = node.get_parent()
var nodeScript: Variant = node.get_script()
var nodeClass: String = node.get_class()
if not is_instance_valid(parent):
Debug.printWarning(str("removeSiblingsOfSameType(): ", node, " has no valid parent!"))
return 0
var removalCount: int = 0
for sibling: Node in parent.get_children(false): # Don't include sub-children
if sibling == node: continue # Is it us?
var isSameType: bool = false
# CHECK: Does this work in all cases?
if nodeScript: isSameType = sibling.get_script() == nodeScript # Do we have the same script?
else: isSameType = sibling.get_class() == nodeClass # Otherwise compare the class
if isSameType:
parent.remove_child(sibling)
if shouldFree: sibling.queue_free()
removalCount += 1
return removalCount
## Moves nodes from one parent to another and returns an array of all children that were successfully reparented.
static func reparentNodes(currentParent: Node, nodesToTransfer: Array[Node], newParent: Node, keepGlobalTransform: bool = true) -> Array[Node]:
var transferredNodes: Array[Node]
for node in nodesToTransfer:
if node.get_parent() == currentParent: # TBD: Is this extra layer of "security" necessary?
node.reparent(newParent, keepGlobalTransform)
node.owner = newParent # For persistence etc.
if node.get_parent() == newParent: # TBD: Is this verification necessary?
transferredNodes.append(node)
else:
Debug.printWarning(str("transferNodes(): ", node, " could not be moved from ", currentParent, " to newParent: ", newParent), node)
continue
else:
Debug.printWarning(str("transferNodes(): ", node, " does not belong to currentParent: ", currentParent), node)
continue
return transferredNodes
## Searches a group of nodes and returns the node nearest to the specified reference position.
## Compares the [member Node2D.global_position]s.
## TIP: May be used to find the closest player for monsters to chase, or the nearest mosnter for a homing missile weapon to attack, etc.
static func findNearestNodeInGroup(referencePosition: Vector2, targetGroup: StringName) -> Node2D:
# NOTE: Use Engine.get_main_loop() instead of Node.get_tree()
# because when called by ChaseComponent etc. the parent entity may not be in a SceneTree yet
var nodesInGroup: Array[Node] = Engine.get_main_loop().get_nodes_in_group(targetGroup)
if nodesInGroup.is_empty(): return null
var nearestNode: Node2D = null
var minimumDistance: float = INF # Start with infinity
var checkingDistance: float
for nodeToCheck in nodesInGroup:
if nodeToCheck is Node2D:
checkingDistance = referencePosition.distance_squared_to(nodeToCheck.global_position) # PERFORMANCE: distance_squared_to() is faster than distance_to()
if is_zero_approx(checkingDistance):
return nearestNode # Can't get any closer than 0!
elif checkingDistance < minimumDistance:
minimumDistance = checkingDistance
nearestNode = nodeToCheck
return nearestNode
## Returns a copy of a [Rect2] transformed from a node's local coordinates to the global position.
## TIP: PERFORMANCE: This function may be replaced with `Rect2(rect.position + node.global_position, rect.size)` to avoid an extra call.
## TIP: Combine with the output from [member getShapeBoundsInNode] to get an [Area2D]'s global region.
## WARNING: May not work correctly with rotation, scaling or negative dimensions.
static func convertNodeRectToGlobalCoordinates(node: CanvasItem, rect: Rect2) -> Rect2:
# TODO: Account for rotation & scaling
return Rect2(rect.position + node.global_position, rect.size)
#endregion
#region NodePath Functionss
## 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
var propertyPath: NodePath
nodePath = NodePath(str("/" if path.is_absolute() else "", path.get_concatenated_names()))
propertyPath = NodePath(str(":", path.get_concatenated_subnames()))
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 = 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 the position set to the [CollisionObject2D]'s local position.
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(node.position.x, node.position.y, -1, -1) # In case of failure, return an invalid negative-sized rectangle matching the node's origin.
# 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.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(node.position.x, node.position.y, -1, -1)
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.
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
## Returns an offset by which to modify the GLOBAL position of a node to keep it clamped within a maximum distance/radius (in any direction) from another node.
## If the [param nodeToClamp] is within the [param maxDistance] of the [param anchor] then (0,0) is returned i.e. no movement required.
## May be used to tether a visual effect (such as a targeting cursor) to an anchor such as a character sprite, as in [AimingCursorComponent] & [TetherComponent].
## NOTE: Does NOT return a direct position, so the [param nodeToClamp]'s `global_position` must be updated via `+=` NOT `=`!
static func clampPositionToAnchor(nodeToClamp: Node2D, anchor: Node2D, maxDistance: float) -> Vector2:
var difference: Vector2 = nodeToClamp.global_position - anchor.global_position # Use global position in case it's a parent/child relationship e.g. a visual component staying near its entity.
var distance: float = difference.length()
if distance > maxDistance:
var offset: Vector2 = difference.normalized() * maxDistance
return (anchor.global_position + offset) - nodeToClamp.global_position
else:
return Vector2.ZERO
## 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 specified "design size" centered on a Node's Viewport.
## NOTE: The viewport size may different from the scaled screen/window size.
static func getCenteredPositionOnViewport(node: Node2D, designWidth: float, designHeight: float) -> Vector2:
# TBD: Better name?
# The "design size" has to be specified because it's hard to get the actual size, accounting for scaling etc.
var viewport: Rect2 = node.get_viewport_rect() # First see what the viewport size is
var center: Vector2 = Vector2(viewport.size.x / 2.0, viewport.size.y / 2.0) # Get the viewport center
var designSize: Vector2 = Vector2(designWidth, designHeight) # Get the node design size
return center - (designSize / 2.0) # Center the size on the viewport
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 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
## NOTE: Does NOT add the new copy to the original node's parent. Follow up with [method Tools.addChildAndSetOwner].
## Default flags: DUPLICATE_SIGNALS + DUPLICATE_GROUPS + DUPLICATE_SCRIPTS + DUPLICATE_USE_INSTANTIATION
static func createScaledCopy(nodeToDuplicate: Node2D, copyScale: Vector2, flags: int = 15) -> Node2D:
var scaledCopy: Node2D = nodeToDuplicate.duplicate(flags)
scaledCopy.scale = copyScale
return scaledCopy
#endregion
#region Tile Map Functions
static func getCellGlobalPosition(map: TileMapLayer, coordinates: Vector2i) -> Vector2:
var cellPosition: Vector2 = map.map_to_local(coordinates)
var cellGlobalPosition: Vector2 = map.to_global(cellPosition)
return cellGlobalPosition
## For a list of custom data layer names, see [Global.TileMapCustomData].
static func getTileData(map: TileMapLayer, coordinates: Vector2i, dataName: StringName) -> Variant:
var tileData: TileData = map.get_cell_tile_data(coordinates)
return tileData.get_custom_data(dataName) if tileData else null
## Gets custom data for an individual cell of a [TileMapCellData].
## NOTE: CELLS are different from TILES; A Tile is the resource used by a [TileSet] to paint multiple cells of a [TileMapLayer].
## DESIGN: This is a separate function on top of [TileMapCellData] because it may redirect to a native Godot feature in the future.
static func getCellData(map: TileMapLayerWithCellData, coordinates: Vector2i, key: StringName) -> Variant:
return map.getCellData(coordinates, key)
## Sets custom data for an individual cell of a [TileMapLayerWithCellData].
## NOTE: CELLS are different from TILES; A Tile is the resource used by a [TileSet] to paint multiple cells of a [TileMapLayer].
## DESIGN: This is a separate function on top of [TileMapLayerWithCellData] because it may redirect to a native Godot feature in the future.
static func setCellData(map: TileMapLayerWithCellData, coordinates: Vector2i, key: StringName, value: Variant) -> void:
map.setCellData(coordinates, key, value)
## Uses a custom data structure to check if individual [TileMap] cells (not tiles) are occupied by an [Entity] and returns it.
## NOTE: Does NOT check for [member Global.TileMapCustomData.isOccupied] first, only the [member Global.TileMapCustomData.occupant]
static func getCellOccupant(data: TileMapCellData, coordinates: Vector2i) -> Entity:
return data.getCellData(coordinates, Global.TileMapCustomData.occupant)
## Uses a custom data structure to mark individual [TileMap] cells (not tiles) as occupied or unoccupied by an [Entity].
static func setCellOccupancy(data: TileMapCellData, coordinates: Vector2i, isOccupied: bool, occupant: Entity) -> void:
data.setCellData(coordinates, Global.TileMapCustomData.isOccupied, isOccupied)
data.setCellData(coordinates, Global.TileMapCustomData.occupant, occupant if isOccupied else null)
static func checkTileAndCellVacancy(map: TileMapLayer, data: TileMapCellData, coordinates: Vector2i, ignoreEntity: Entity) -> bool:
# CHECK: First check the CELL data because it's quicker, right?
var isCellVacant: bool = Tools.checkCellVacancy(data, coordinates, ignoreEntity)
if not isCellVacant: return false # If there is an occupant, no need to check the Tile data, just scram
# Then check the TILE data
var isTileVacant: bool = Tools.checkTileVacancy(map, coordinates)
return isCellVacant and isTileVacant
## Checks if the specified tile is vacant by examining the custom tile/cell data for flags such as [constant Global.TileMapCustomData.isWalkable].
static func checkTileVacancy(map: TileMapLayer, coordinates: Vector2i) -> bool:
var isTileVacant: bool = false
# NOTE: DESIGN: Missing values should be considered as `true` to assist with quick prototyping
# TODO: Check all this in a more elegant way
var tileData: TileData = map.get_cell_tile_data(coordinates)
var isWalkable: Variant
var isBlocked: Variant
if tileData:
isWalkable = tileData.get_custom_data(Global.TileMapCustomData.isWalkable)
isBlocked = tileData.get_custom_data(Global.TileMapCustomData.isBlocked)
if map is TileMapLayerWithCellData and map.debugMode: Debug.printDebug(str("tileData[isWalkable]: ", isWalkable, ", [isBlocked]: ", isBlocked))
# If there is no data, assume the tile is always vacant.
isTileVacant = (isWalkable or isWalkable == null) and (not isBlocked or isWalkable == null)
return isTileVacant
## Checks if the specified tile is vacant by examining the custom tile/cell data for flags such as [constant Global.TileMapCustomData.isWalkable].
static func checkCellVacancy(mapData: TileMapCellData, coordinates: Vector2i, ignoreEntity: Entity) -> bool:
var isCellVacant: bool = false
# First check the CELL data because it's quicker
var cellDataOccupied: Variant = mapData.getCellData(coordinates, Global.TileMapCustomData.isOccupied) # NOTE: Should not be `bool` so it can be `null` if missing, NOT `false` if missing.
var cellDataOccupant: Entity = mapData.getCellData(coordinates, Global.TileMapCustomData.occupant)
if mapData.debugMode: Debug.printDebug(str("checkCellVacancy() ", mapData, " @", coordinates, " cellData[cellDataOccupied]: ", cellDataOccupied, ", occupant: ", cellDataOccupant))
if cellDataOccupied is bool:
isCellVacant = not cellDataOccupied or cellDataOccupant == ignoreEntity
else:
# If there is no data, assume the cell is always unoccupied.
isCellVacant = true
# If there is an occupant, no need to check the Tile data, just scram
if not isCellVacant: return false
return isCellVacant
## Verifies that the given coordinates are within the specified [TileMapLayer]'s grid.
static func checkTileMapCoordinates(map: TileMapLayer, coordinates: Vector2i) -> bool:
var gridRect: Rect2i = map.get_used_rect()
return gridRect.has_point(coordinates)
## Returns the rectangular bounds of a [TileMapLayer] containing all of its "used" or "painted" cells, in the coordinate space of the TileMap's parent.
## ALERT: This may not correspond to the visual position of a cell/tile, i.e. it ignores the [member TileData.texture_origin] property of individual tiles.
static func getTileMapScreenBounds(map: TileMapLayer) -> Rect2: # TBD: Rename to getTileMapBounds()?
var cellGrid: Rect2 = Rect2(map.get_used_rect()) # Convert integer `Rect2i` to float to simplify calculations
if not cellGrid.has_area(): return Rect2() # Null area if there are no cells
var screenRect: Rect2
var tileSize: Vector2 = Vector2(map.tile_set.tile_size) # Convert integer `Vector2i` to float to simplify calculations
# The points will initially be in the TileMap's own space
screenRect.position = cellGrid.position * tileSize
screenRect.size = cellGrid.size * tileSize
# Offset the bounds by the map's own position in the map's parent's space
screenRect.position += map.position
return screenRect
## Checks if a [Vector2] is inside a [TileMapLayer].
## IMPORTANT: The [param point] must be in the coordinate space of the [param map]'s parent node. See [method Node2D.to_local].
## WARNING: Internal float-based positions may have fractional values like 0.5 etc. which may cause calculations to return a result that does not match the visuals onscreen, e.g. intersections may return false.
static func isPointInTileMap(point: Vector2, map: TileMapLayer) -> bool:
# NOTE: Apparently there is no need to grow_individual() the Rect2's right & bottom edges by 1 pixel even though Rect2.has_point() does NOT include points on those edges, according to the Godot documentation.
return Tools.getTileMapScreenBounds(map).has_point(point)
## Checks if a [Rect2]'s [member Rect2.position] origin and/or [member Rect2.end] points are inside a [TileMapLayer].
## If [param checkOriginAndEnd] is `true` (default) then this method returns `true` only if the rectangle's origin AND end are BOTH fully inside the TileMap.
## If [param checkOriginAndEnd] is `false` then even a partial intersection returns `true`.
## IMPORTANT: The [param rectangle] must be in the coordinate space of the [param map]'s parent node. See [method Node2D.to_local].
## NOTE: Rotation and other transforms are NOT supported.
## WARNING: Internal float-based positions may have fractional values like 0.5 etc. which may cause calculations to return a result that does not match the visuals onscreen, e.g. intersections may return false.
static func isRectInTileMap(rectangle: Rect2, map: TileMapLayer, checkOriginAndEnd: bool = true) -> bool:
var tileMapBounds: Rect2 = Tools.getTileMapScreenBounds(map)
return tileMapBounds.encloses(rectangle) if checkOriginAndEnd else rectangle.intersects(tileMapBounds)
## Checks for a collision between a [TileMapLayer] and physics body at the specified tile coordinates.
## ALERT: UNIMPLEMENTED: Will ALWAYS return `true`. Currently there seems to be no way to easily check this in Godot yet.
## @experimental
static func checkTileCollision(map: TileMapLayer, _body: PhysicsBody2D, _coordinates: Vector2i) -> bool:
# If the TileMap or its collisions are disabled, then the tile is always available.
if not map.enabled or not map.collision_enabled: return true
return true # HACK: TODO: Implement
## Converts [TileMap] cell coordinates from [param sourceMap] to [param destinationMap].
## The conversion is performed by converting cell coordinates to pixel/screen coordinates first.
static func convertCoordinatesBetweenTileMaps(sourceMap: TileMapLayer, cellCoordinatesInSourceMap: Vector2i, destinationMap: TileMapLayer) -> Vector2i:
# 1: Convert the source TileMap's cell coordinates to pixel (screen) coordinates, in the source map's space.
# NOTE: This may not correspond to the visual position of the tile; it ignores `TileData.texture_origin` of the individual tiles.
var pixelPositionInSourceMap: Vector2 = sourceMap.map_to_local(cellCoordinatesInSourceMap)
# 2: Convert the pixel position to the global space
var globalPosition: Vector2 = sourceMap.to_global(pixelPositionInSourceMap)
# 3: Convert the global position to the destination TileMap's space
var pixelPositionInDestinationMap: Vector2 = destinationMap.to_local(globalPosition)
# 4: Convert the pixel position to the destination map's cell coordinates
var cellCoordinatesInDestinationMap: Vector2i = destinationMap.local_to_map(pixelPositionInDestinationMap)
Debug.printDebug(str("Tools.convertCoordinatesBetweenTileMaps() ", sourceMap, " @", cellCoordinatesInSourceMap, " → sourcePixel: ", pixelPositionInSourceMap, " → globalPixel: ", globalPosition, " → destinationPixel: ", pixelPositionInDestinationMap, " → @", cellCoordinatesInDestinationMap, " ", destinationMap))
return cellCoordinatesInDestinationMap
## Damages a [TileMapLayer] Cell if it is [member Global.TileMapCustomData.isDestructible].
## Changes the cell's tile to the [member Global.TileMapCustomData.nextTileOnDamage] if there is any,
## or erases the cell if there is no "next tile" specified or both X & Y coordinates are below 0 i.e. (-1,-1)
## Returns `true` if the cell was damaged.
## @experimental
static func damageTileMapCell(map: TileMapLayer, coordinates: Vector2i) -> bool:
# TODO: Variable health & damage
# PERFORMANCE: Do not call Tools.getTileData() to reduce calls
var tileData: TileData = map.get_cell_tile_data(coordinates)
if tileData:
var isDestructible: bool = tileData.get_custom_data(Global.TileMapCustomData.isDestructible)
if isDestructible:
var nextTileOnDamage: Vector2i = tileData.get_custom_data(Global.TileMapCustomData.nextTileOnDamage)
if nextTileOnDamage and (nextTileOnDamage.x >= 0 or nextTileOnDamage.y >= 0): # Both negative coordinates are invalid or mean "destroy on damage"
map.set_cell(coordinates, 0, nextTileOnDamage)
else: map.erase_cell(coordinates)
return true
return false
## Returns an array of random coordinates on a [TileMapLayer] from the specified grid range.
## WARNING: Do NOT use [method TileMapLayer.get_used_rect()] [member Rect2i.size] or [member Rect2i.end] as it is NOT 0-based: It will be +1 outside the map's actual grid! TIP: Use [method Rect2i.grow](-1)
static func findRandomTileMapCells(map: TileMapLayer,
selectionChance: float = 1.0,
includeUsedCells: bool = true,