godotengine / godot-proposals

Godot Improvement Proposals (GIPs)
MIT License
1.11k stars 69 forks source link

Add `Spawner` node #3651

Open Xrayez opened 2 years ago

Xrayez commented 2 years ago

Describe the project you are working on

Goost - Godot Engine Extension

Describe the problem or limitation you are having in your project

I often find myself in the need to spawn various nodes/objects in the scene. The problem is that it's not as convenient as it could be, especially when more advanced spawning options are needed.

Note that this proposal is completely different to #3359, because this proposal solves completely different set of use cases.

Describe the feature / enhancement and how it helps to overcome the problem or limitation

I propose adding Spawner2D and Spawner3D nodes to facilitate the common need to spawn objects in the scene. Note that some of this functionality can overlap with particles nodes in Godot, this makes sense to me and I think what I propose below would be an intuitive API.

Describe how your proposal will work, with code, pseudo-code, mock-ups, and/or diagrams

Core

A general-purpose Spawner2D/3D node should have ability to set any kind of object to be used as a blueprint for spawning. For instance, Spawner.object property could accept:

Add spawn() method that will instantiate an object as a direct child of Spawner. Perhaps node_path could be exposed to make this more flexible.

See also https://github.com/godotengine/godot-proposals/issues/3651#issuecomment-991614188.

Spawning behavior and timing

Timer functionality would definitely be essential to have as well. Spawning could be manual (by calling method spawn() above), or automatic/repeated.

Adding a spawned signal would be needed for automatic spawning (which is not always a direct result of calling spawn()).

For more advanced use cases, perhaps just exposing Timer node with Spawner.get_timer() would be enough, but it should be used with caution (godotengine/godot#30391 would be useful here).

But I think that timer functionality should be an implementation detail, because it would also be possible to use another external Timer that would create spawn waves. At the same time, it's not even required to use another Timer to achieve this, because you could use a Spawner node to spawn more Spawner nodes. 😉

See also https://github.com/godotengine/godot-proposals/issues/3651#issuecomment-991640984.

Overriding callbacks

A _spawn() virtual method could be added for scripts to override. For instance, you could make a Spawner launch projectiles:

class_name Launcher2D extends Spawner2D

export(Vector2) var direction
export(float) var power

func _spawn(object):
    if object is RigidBody2D:
        object.apply_central_impulse(direction * power)

Region

By default, a Spawner would only instantiate nodes at origin. You'd think that it's enough, but there are more advanced use cases that are difficult to solve:

I realize that spawning inside something randomly asks for adding rand methods inside the class, which should likely be avoided as this stuff could be done via script. However, we could still provide some sort of SpawnShape enum/property for common shapes (similarly to emission_shape in ParticlesMaterial), for use cases where determinism is not needed and does not affect gameplay logic (for instance, particle effects that react to collisions).

The Spawner node's position/offset could be animated in various ways, also mimicking the spawn region that way.

Usage

The way it would work:

Related

If this enhancement will not be used often, can it be worked around with a few lines of script?

It will be used often:

Is there a reason why this should be core and not an add-on in the asset library?

This could be in core because even more lightweight game engines like Orx has this, see for instance this API: https://github.com/orx/orx/blob/5c01d089fc452ee8d78e3a238b73775769091f2e/code/include/object/orxSpawner.h

I'd like to implement what I proposed above in Goost, regardless of whether this proposal is approved to be implemented in Godot.

Feedback welcome! I'm interested in what kind of functionality you'd find essential and useful.

fire commented 2 years ago

Not sure how related it to https://github.com/godotengine/godot-proposals/issues/3359 which has a MultiplayerSpawn node.

Xrayez commented 2 years ago

Not sure how related it to #3359 which has a MultiplayerSpawn node.

It's only related by the fact that MultiplayerSpawn node has Spawn word in it. What's proposed here is a completely different set of use cases. Updated the proposal to signify this.

Xrayez commented 2 years ago

After working on the implementation a bit, I figured that:

Spawner.object cannot really accept a script, because some scripts may have required arguments to pass in _init(), and there's no API to workaround this. However, this would work fine with scripts that don't require passing arguments to constructor. Would be nice to have #1935 implemented in the first place, or similar.

Accepting Object/Node instances won't work reliably either when exposed as a property (crashes the editor).

Due to this, I think that passing a scene would be enough, therefore I'll likely just rename the property to Spawner.scene that will only accept PackedScene, or could accept general-purpose Resource that accepts both PackedScene and Script.

Xrayez commented 2 years ago

Regarding time: there are actually two approaches:

So, I think that it makes sense to add both rate and step properties. A rate would specify how many objects are spawned per time step, which is 1 second by default. The step would allow to configure the default.

Accordingly, amount/number would have to be renamed to max_objects/limit (before spawning stops). But note that the limit would be different from the concept of "maximum allowed objects at the same time", this will be rather controlled by lifetime property.

Xrayez commented 2 years ago

I have created initial implementation for Spawner2D class in Goost, see goostengine/goost#165.

https://user-images.githubusercontent.com/17108460/145723389-a9059a23-6222-484f-9365-8e494ba196b3.mp4

mrjustaguy commented 2 years ago

It's nothing that is too difficult to make currently, however this would be a very commonly used feature and would make things a fair bit easier.

Also, another thing that could be added to the spawning node, is the angle of the spawned object, for things like Bullet spread. This would also make the workflow a bit more similar to particles, just for creating children instead which is an added bonus.

Xrayez commented 2 years ago

Also, another thing that could be added to the spawning node, is the angle of the spawned object, for things like Bullet spread. This would also make the workflow a bit more similar to particles, just for creating children instead which is an added bonus.

I see what you mean, however, consider this script:

extends Node2D

var direction = Vector2()

func _ready():
    direction = Random2D.direction
    rotation = Random2D.rotation

func _process(delta):
    global_position += direction * delta * 100

In this case, Spawner2D would override rotation of the spawned node, even if the node itself randomizes the rotation first. So I'm not sure about this, because setting rotation should be a responsibility of a bullet itself, especially when bullet may change direction in the process.

However, the issue with overriding can be solved by connecting the node_spawned signal:

func _on_node_spawned(node):
    node.rotation = Random2D.rotation

The signal approach would also be superior if you switch spawned scene/script at run-time, so the same randomization could be applied to any kind of bullet, for instance.

Note that this would only be needed if you're not interested in synchronizing transform of the spawned node from Spawner2D by default.

So I guess it's fine to synchronize global transform (and not just position) upon spawning by default because there's a way to override it via signal.

Alternatively, we could add sync_position, sync_rotation, sync_scale as properties, similarly to RemoteTransform2D.update_* properties, which is the ultimate solution for spawning in both local and global coordinates.

Xrayez commented 2 years ago

Alternatively, we could add sync_position, sync_rotation, sync_scale as properties, similarly to RemoteTransform2D.update_* properties, which is the ultimate solution for spawning in both local and global coordinates.

I have added ability to modify transform properties upon spawning:

image

Let me know if Synchronize would be a better word to describe this behavior. But unlike RemoteTransform2D, Spawner2D will only modify transform upon spawning, and won't continuously update the transform.

Xrayez commented 2 years ago

I've added spawn_path NodePath property to customize to which node the spawner is going to instantiate new nodes in the scene. For instance, this is needed if you have a system which relies on node hierarchy. In Goost, destructible terrain could be implemented by constructing nested PolyNode2Ds:

image

Those spawners that create explosions at run-time (delayed), are going to sequentially add PolyNode2D with OP_DIFFERENCE:

image

Just wanted to document this use case.

Xrayez commented 2 years ago

@anunknowperson I've noticed your 👎 on the proposal, could you explain the reason why you dislike this proposal? Is there anything that you don't like regarding the implementation, or you simply don't want to see this feature to be integrated in Godot specifically?

I'm looking forward to your feedback, thank you.

anunknowperson commented 2 years ago

It just seems to me that from the point of view of implementation, this function is very simple. However, in real conditions, people will still have to write their own implementation that is more suitable for their needs.

Xrayez commented 2 years ago

I believe the proposed implementation should be useful for at least 70% of use cases. The "Enemy spawning" is probably one the most common use cases in game development in general, but I'm not sure what percentage of people using Godot make these kind of games, since this requires AI programming, and there are not a lot of features in Godot that facilitate AI development, unfortunately.

The current approach is "data-driven" as well: you don't even have to write a single line of code to spawn scenes. Currently in Godot, if you'd like to implement some kind of scene spawner, you have to create an entire script with export(PackedScene) var scene exposed, or you'd have to preload("res://enemy.tscn"), yet hardcoding scene paths inside scripts is not always handy in cases where you move scripts around during refactor, leading to broken scripts.

So even if you think that it's not useful in real conditions, this is certainly useful for prototyping and even (stress)-testing purposes, taking into account that Godot is used a lot for prototyping.

That said, if it seems like the current implementation is not sufficient for variety of use cases, I invite and recommend trying it out (see build artifacts):

If not, please tell me what kind of concrete use cases you typically need to solve, because there will always be specific use cases for every feature in Godot, whatever the implementation.

this function is very simple

It took me 3 days to come up with a decent implementation with all corner cases to account that you'd need to solve via script in real conditions anyways: 300 lines of code! 🙂

djrain commented 2 years ago

I have to agree that as is, it's a very simplistic design. I'm not sure I would make much use of it. It seems like something that would be nice for beginners to get a quick result, or stress testing / prototyping, but not much beyond that. And that would certainly be better than nothing. But I think maybe we can do better.

Almost anytime I'm spawning anything, I use some randomness to make things feel less artificial. Like, every random(1.5, 2) seconds spawn between 2 and 4 objects. Or something similar.

Another thing I often use is a random bag (i.e. shuffled array) that I fill what whatever. It's a simple way to randomly spawn, for example, different enemies, or shapes in Tetris, while keeping the game fair. (This could perhaps be a new datatype by itself.) But on that note, I feel that having some way to spawn multiple types of objects is important to make this really useful.

Zireael07 commented 2 years ago

I think it would be good to have a built-in something that people can then expand on if they need to (e.g. via signals, IIRC this sends a signal whenever it spawns something)

Xrayez commented 2 years ago

I'm wary to add randomization features directly in C++ because it's something that might be indeed specific and the features may go out of control (you may want to use randf(), or randfn() etc). But if you've already used Goost, then Random/Random2D singletons do provide useful functions to do this:

# Pick a random point in circle and spawn an enemy.
$Spawner2D.position = Random2D.point_in_circle() * radius
$Spawner.spawn() # You don't have to call this manually, only when `enabled = false`.

Another thing I often use is a random bag (i.e. shuffled array) that I fill what whatever.

Similarly, you can choose a random scene:

# Pick a random scene from an array and spawn it.
$Spawner2D.resource = Random.choice(scenes)
$Spawner2D.spawn()

You could use a Timer to randomize any property of Spawner2D, or connect to Spawner2D.node_added signal and override those properties on each spawn:

func _on_node_spawned(node):
    $Spawner2D.step = rand_range(1.0, 3.0)  # Randomize time step for the next spawn.

So, perhaps in order for this feature to be truly useful, there are two choices:

But on that note, I feel that having some way to spawn multiple types of objects is important to make this really useful.

This is why I've mentioned about the use case of spawn sequences in goostengine/goost#165, but not sure about implementation. In any case, currently you can either:

spawn between 2 and 4 objects

Currently Spawner2D can only spawn 1 object at a time. Perhaps we could add a property for this, like quantity (would probably need to rename the Quantity group to Amount in the inspector), or vice versa... Upon further thinking this won't really work well, because spawn() will return a single node, so:

for i in randi_range(2, 4):
    $Spawner2D.spawn()

In any case, Godot development does prefer the approach of "do one thing and do it right", or "single responsibility principle", and the current implementation reflects that.

djrain commented 2 years ago
class_name Launcher2D extends Spawner2D

export(Vector2) var direction
export(float) var power

func _spawn(object):
    if object is RigidBody2D:
        object.apply_central_impulse(direction * power)

I don't understand, seems like you're not really overriding the spawn function. Was "object" already spawned here? If that's the case couldn't this code just go in the signal callback?

Xrayez commented 2 years ago

Yes, the development is moving fast, I no longer think the virtual callback is needed, connecting to the node_spawned signal is enough (I proposed adding the callback prior to adding the signal).

djrain commented 2 years ago
for i in randi_range(2, 4):
    $Spawner2D.spawn()

But wouldn't I still have to manage my own timer that has this code as a callback? so basically might as well not use the spawner at all? Or, I guess you mentioned the timer could be exposed, but then I'd just be using it as a Timer, essentially.

I dunno... if I have to micro-manage the spawner by explicitly setting the step / resource / position even for fairly simple things like this, then it kinda feels more like a burden than something that is useful and extendable.

Xrayez commented 2 years ago

But wouldn't I still have to manage my own timer that has this code as a callback?

Not necessarily. You can still connect to node_spawned signal. At the very least, you need to spawn at least one node, otherwise there's no need to use Spawner2D in the first place, right?

Then, whenever a new node is spawned, you handle it via _on_node_spawned(node) method previously connected. You can then decide to spawn a new set of N number of nodes, minus one (if this matters at all):

func _on_node_spawned(node):
    for i in randi_range(2, 4) - 1:
        var n = $Spawner2D.spawn()
        n.position += Random2D.point_in_circle() * radius

With the above snippet, the first spawned node will remain at the origin of Spawner2D, while other nodes will spread out. Of course, this is just an example, you can do whatever you want with those nodes, including using other Spawner2D nodes to spawn nodes from different scenes.

For what it's worth, Spawner2D/3D is a node which is an enhanced/specialized version of Timer. You'd need to do extra work if you choose to use Timer node alone. The only thing which is not implemented in Spawner2D currently is pause() method, but could be easily added (though I've rarely if ever had to use Timer.pause() to be honest).

Xrayez commented 2 years ago

I dunno... if I have to micro-manage the spawner by explicitly setting the step / resource / position even for fairly simple things like this, then it kinda feels more like a burden than something that is useful and extendable.

I think what you really ask for are those randomization features, what I proposed so far is core functionality.

I'm thinking that perhaps we need to go with Particles2D/3D approach and add SpawnMaterial class, that way we could implement all those rand features freely without fearing the API bloat of the main class.

djrain commented 2 years ago

I'm thinking that perhaps we need to go with Particles2D/3D approach and add SpawnMaterial class

I'd definitely be curious to see what that would look like :)

func _on_node_spawned(node):
    for i in randi_range(2, 4) - 1:
        var n = $Spawner2D.spawn()
        n.position += Random2D.point_in_circle() * radius

Would this not cause an infinite loop of spawning?

Xrayez commented 2 years ago

Would this not cause an infinite loop of spawning?

Yeah, I've been thinking whether force_spawn() is needed. But this won't cause infinite loop if you spawn from different Spawner2D which is not connected to that signal, since you asked for ability to spawn different objects at the same time.

There's' still a way to workaround this by calling Object.set_block_signals():

func _on_node_spawned(node):
    $Spawner2D.set_block_signals(true)
    var n = $Spawner2D.spawn()
    $Spawner2D.set_block_signals(false)

I realize that this is micro-managing, but we're discussing whether current functionality is sufficient to fulfill those needs to justify adding more complexity to the core class.

(Update: I have changed the behavior now, calling spawn() manually will no longer emit node_spawned signal, the signal will only be emitted per time step if enabled = true).


Note that I'm not against adding randomization features, and that was actually my original intent (for instance, see "Region" section in this proposal). But unlike Particles2D/3D, the Spawner2D/3D will be used for gameplay logic for 90-95% of use cases, and only 5-10% for effects. Therefore, anything related to randomness must be dealt with care.

So far I see two options:

The SpawnerMaterial is likely superior because you could reuse it (Resource) between scenes/nodes.

But I'd like to say that the current feature set is fine in and of itself (according to my needs, at least), especially when there's already a way to randomize properties using RandomNumberGenerator. Note that it's also possible to spawn everything deterministically if you use your own instance of RandomNumberGenerator, which is important to some people (including myself: godotengine/godot#45019).

It's also a question of whether this feature will end up implemented in Godot or in Goost. If latter, then I can also take advantage of existing Goost classes like PolyNode2D and family (in order to conveniently define spawn regions via boolean operations, like spawning inside a "donut" region, for instance see use case in this proposal: #199).

It's not really a matter of whether you're beginner or not either: anything that speeds up existing workflow is an improvement in my eyes. Of course when I say this, I'm mostly talking from the standpoint of Goost development principles, which might not be in alignment with Godot ones.

So, back to design now!.. I have merged goostengine/goost#165 to let people play with the core implementation. Note that you can download latest nightly editor builds from https://github.com/goostengine/goost#building-. See also example project I've used at https://github.com/goostengine/goost-examples/tree/gd3/2d/spawner.