Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@Node macro #659

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion Sources/SwiftGodot/MacroDefs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,28 @@ public macro NativeHandleDiscarding() = #externalMacro(module: "SwiftGodotMacroL
/// }
/// ```
///
/// - Important: This property will become a computed property, and it cannot be reassigned later.
/// The generated property will be computed, and therefore read-only.
@attached(accessor)
public macro SceneTree(path: String? = nil) = #externalMacro(module: "SwiftGodotMacroLibrary", type: "SceneTreeMacro")

/// A macro that finds and assigns a node from the scene tree to a stored property.
///
/// Use this to quickly assign a stored property to a node in the scene tree.
/// ```swift
/// class MyNode: Node2D {
/// @Node("Entities/Player")
/// var player: CharacterBody2D
/// }
/// ```
///
/// If you declare the property as optional, the property will be `nil` if the node is missing.
/// If you declare the property as non-optional, or forced-unwrap, it will be a runtime error for the node to be missing.
///
/// The generated property will be computed, and therefore read-only.
@attached(accessor)
public macro Node(_ path: String? = nil) = #externalMacro(module: "SwiftGodotMacroLibrary", type: "SceneTreeMacro")


/// Defines a Godot signal on a class.
///
/// The `@Godot` macro will register any #signal defined signals so that they can be used in the editor.
Expand Down
63 changes: 38 additions & 25 deletions Sources/SwiftGodot/SwiftGodot.docc/BindingNodes.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,62 @@
# Referencing Nodes from your Scene

You will find yourself referencing nodes from a scene in your code. In
You will find yourself referencing nodes from a scene in your code. In
GDScript, that is usually achieved by using the dollar sign and the name of the
object you want to reference.

With SwiftGodot, you can achieve the same behavior by using the
``SceneTreeMacro`` macro (or if you are not using Macros, the ``BindNode``
property wrapper. The macro can produce a nil value if the node is not found,
which forces you to check if it succeeded, and will produce more resilient code
as you modify your scenes.
``@Node`` macro to define a property.

## SceneTree

You typically use the `path` parameter to specify the path to the node in your
The macro takes a single parameter, which is the path to the node in your
scene that you want to reference:

```swift
@Godot
class Main: Node {
@SceneTree(path: "CharacterBody2D") var player: PlayerController?
@SceneTree(path: "locations/spawnpoint") var spawnpoint: Node2D?
@SceneTree(path: "Telepoint") var teleportArea: Area2D?
@Node("CharacterBody2D") var player: PlayerController?
@Node("locations/spawnpoint") var spawnpoint: Node2D?
@Node("Telepoint") var teleportArea: Area2D
}
```

## BindNode
When you access the property, the node is looked up, using the specified path.

BindNode is an older version, but is not as convenient as using the SceneTree
macro.
### Implicit Path

In your class declaration, use the ``BindNode`` property wrapper like this to
reference the nodes that you created with the Godot Editor:
You can also omit the path - in which case the name of the property
is also assumed to be the path to the node.

```swift
@Godot
class Main: Node {
@BindNode(withPath:"timer") var startTimer: SwiftGodot.Timer
@BindNode(withPath:"music") var music: AudioStreamPlayer
@BindNode(withPath:"mobTimer") var mobTimer: SwiftGodot.Timer

func newGame () {
startTimer.start ()
}
@Node var myNode: Node2D? // this is the eqivalent of @Node("myNode")...
}
```

If you omit the `withPath` parameter, depending on your system, the result might
not resolve at runtime.
### Missing Nodes

Node lookup happens at runtime, when you access the property, and it's possible that the node won't be found.

This could happen because the path is wrong, or the node has been removed from the scene.
It is also possible that a node is found, but it's the wrong type.

What happens in this situation depends on whether you defined the associated
property as an optional type.

If a property is defined as optional (eg `player` or `spawnpoint` in the above
example), then the property value will simply be `nil`.

However, it is also possible to define a non-optional property (eg `teleportArea`
in the above example). In this situation, accessing the property will cause
a fatal runtime error if the associated node can't be found.

Which style you use is largely a matter of personal preference.

The optional style produces more resilient code that can keep running as you
modify your scenes, but requires you to unwrap the optional property every
time you use it.

Using the non-optional style is equivalent to asserting that the node must
be present, and that it's a coding error if it isn't. If you know that your
node is loaded as part of a scene file and will never be missing, you may
prefer this style, which results in more compact code.
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import SwiftGodot

@Godot
class MainLevel: Node2D {
@SceneTree(path: "CharacterBody2D") var player: PlayerController?
@SceneTree(path: "Spawnpoint") var spawnpoint: Node2D?
@SceneTree(path: "Telepoint") var teleportArea: Area2D?
@Node("CharacterBody2D") var player: PlayerController?
@Node("Spawnpoint") var spawnpoint: Node2D?
@Node("Telepoint") var teleportArea: Area2D?

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import SwiftGodot

@Godot
class MainLevel: Node2D {
@SceneTree(path: "CharacterBody2D") var player: PlayerController?
@SceneTree(path: "Spawnpoint") var spawnpoint: Node2D?
@SceneTree(path: "Telepoint") var teleportArea: Area2D?
@Node("CharacterBody2D") var player: PlayerController?
@Node("Spawnpoint") var spawnpoint: Node2D?
@Node("Telepoint") var teleportArea: Area2D?

private func teleportPlayerToTop() {
guard let player, let spawnpoint else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import SwiftGodot

@Godot
class MainLevel: Node2D {
@SceneTree(path: "CharacterBody2D") var player: PlayerController?
@SceneTree(path: "Spawnpoint") var spawnpoint: Node2D?
@SceneTree(path: "Telepoint") var teleportArea: Area2D?
@Node("CharacterBody2D") var player: PlayerController?
@Node("Spawnpoint") var spawnpoint: Node2D?
@Node("Telepoint") var teleportArea: Area2D?

private func teleportPlayerToTop() {
guard let player, let spawnpoint else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import SwiftGodot

@Godot
class MainLevel: Node2D {
@SceneTree(path: "CharacterBody2D") var player: PlayerController?
@SceneTree(path: "Spawnpoint") var spawnpoint: Node2D?
@SceneTree(path: "Telepoint") var teleportArea: Area2D?
@Node("CharacterBody2D") var player: PlayerController?
@Node("Spawnpoint") var spawnpoint: Node2D?
@Node("Telepoint") var teleportArea: Area2D?

override func _ready() {
teleportArea?.bodyEntered.connect { [self] enteredBody in
Expand Down
39 changes: 11 additions & 28 deletions Sources/SwiftGodotMacroLibrary/SceneTreeMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ public struct SceneTreeMacro: AccessorMacro {
enum ProviderDiagnostic: String, DiagnosticMessage {
case invalidDeclaration
case missingTypeAnnotation
case nonOptionalTypeAnnotation

var severity: DiagnosticSeverity { .error }

Expand All @@ -26,8 +25,6 @@ public struct SceneTreeMacro: AccessorMacro {
"SceneTree can only be applied to stored properties"
case .missingTypeAnnotation:
"SceneTree requires an explicit type declaration"
case .nonOptionalTypeAnnotation:
"Stored properties with SceneTree must be marked as Optional"
}
}

Expand All @@ -36,16 +33,6 @@ public struct SceneTreeMacro: AccessorMacro {
}
}

struct MarkOptionalMessage: FixItMessage {
var message: String {
"Mark as Optional"
}

var fixItID: SwiftDiagnostics.MessageID {
ProviderDiagnostic.nonOptionalTypeAnnotation.diagnosticID
}
}

public static func expansion(of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext) throws -> [AccessorDeclSyntax]
Expand All @@ -70,26 +57,22 @@ public struct SceneTreeMacro: AccessorMacro {
let preferredContent = preferredIdentifierExpr?.segments.first?.as(StringSegmentSyntax.self)?.content
let preferredIdentifier = preferredContent?.text

let unwrappedType = nodeType.as(OptionalTypeSyntax.self)?.wrappedType ?? nodeType.as(ImplicitlyUnwrappedOptionalTypeSyntax.self)?.wrappedType
let optionalType = nodeType.as(OptionalTypeSyntax.self)?.wrappedType
let implicitType = nodeType.as(ImplicitlyUnwrappedOptionalTypeSyntax.self)?.wrappedType
let unwrappedType = implicitType ?? optionalType ?? nodeType

guard let unwrappedType else {
let newOptional = OptionalTypeSyntax(wrappedType: nodeType)
let addOptionalFix = FixIt(message: MarkOptionalMessage(),
changes: [.replace(oldNode: Syntax(nodeType), newNode: Syntax(newOptional))])
let nonOptional = Diagnostic(node: nodeType.root,
message: ProviderDiagnostic.nonOptionalTypeAnnotation,
fixIts: [addOptionalFix])
context.diagnose(nonOptional)
return [
"""
get { getNodeOrNull(path: NodePath(stringLiteral: \"\(raw: preferredIdentifier ?? nodeIdentifier.text)\")) as? \(nodeType) }
""",
]
let castOperator: String
if let _ = optionalType ?? implicitType {
// the type was optional or implicit, so use an as? case
castOperator = "as?"
} else {
// the type was non-optional, so use as! and force unwrap; this will be a runtime error if the node is not found
castOperator = "as!"
}

return [
"""
get { getNodeOrNull(path: NodePath(stringLiteral: \"\(raw: preferredIdentifier ?? nodeIdentifier.text)\")) as? \(unwrappedType) }
get { getNodeOrNull(path: NodePath(stringLiteral: \"\(raw: preferredIdentifier ?? nodeIdentifier.text)\")) \(raw: castOperator) \(unwrappedType) }
""",
]
}
Expand Down
62 changes: 48 additions & 14 deletions Tests/SwiftGodotMacrosTests/SceneTreeMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import XCTest
final class SceneTreeMacroTests: XCTestCase {
let testMacros: [String: Macro.Type] = [
"SceneTree": SceneTreeMacro.self,
"Node": SceneTreeMacro.self,
]

func testMacroExpansion() {
func testSceneTreeMacroExpansion() {
assertMacroExpansion(
"""
class MyNode: Node {
Expand All @@ -36,7 +37,7 @@ final class SceneTreeMacroTests: XCTestCase {
)
}

func testMacroExpansionWithImplicitlyUnwrappedOptional() {
func testSceneTreeMacroExpansionWithImplicitlyUnwrappedOptional() {
assertMacroExpansion(
"""
class MyNode: Node {
Expand All @@ -57,7 +58,7 @@ final class SceneTreeMacroTests: XCTestCase {
)
}

func testMacroExpansionWithDefaultArgument() {
func testSceneTreeMacroExpansionWithDefaultArgument() {
assertMacroExpansion(
"""
class MyNode: Node {
Expand All @@ -77,31 +78,64 @@ final class SceneTreeMacroTests: XCTestCase {
)
}

func testMacroNotOptionalDiagnostic() {
func testNodeMacroExpansionWithOptional() {
assertMacroExpansion(
"""
class MyNode: Node {
@SceneTree(path: "Entities/CharacterBody2D")
@Node("Entities/CharacterBody2D")
var character: CharacterBody2D?
}
""",
expandedSource: """
class MyNode: Node {
var character: CharacterBody2D? {
get {
getNodeOrNull(path: NodePath(stringLiteral: "Entities/CharacterBody2D")) as? CharacterBody2D
}
}
}
""",
macros: testMacros
)
}

func testNodeMacroExpansion() {
assertMacroExpansion(
"""
class MyNode: Node {
@Node("Entities/CharacterBody2D")
var character: CharacterBody2D
}
""",
expandedSource: """
class MyNode: Node {
var character: CharacterBody2D {
get {
getNodeOrNull(path: NodePath(stringLiteral: "Entities/CharacterBody2D")) as? CharacterBody2D
getNodeOrNull(path: NodePath(stringLiteral: "Entities/CharacterBody2D")) as! CharacterBody2D
}
}
}
""",
macros: testMacros
)
}

func testNodeMacroExpansionWithDefaultArgument() {
assertMacroExpansion(
"""
class MyNode: Node {
@Node var character: CharacterBody2D?
}
""",
expandedSource: """
class MyNode: Node {
var character: CharacterBody2D? {
get {
getNodeOrNull(path: NodePath(stringLiteral: "character")) as? CharacterBody2D
}
}
}
""",
diagnostics: [
.init(message: "Stored properties with SceneTree must be marked as Optional",
line: 2,
column: 5,
fixIts: [
.init(message: "Mark as Optional"),
]),
],
macros: testMacros
)
}
Expand Down