Skip to content

Commit 01c9cb4

Browse files
authored
Merge pull request #699 from endlessm/theres-a-flower-in-the-garden
Add flowerbed/forest generator
2 parents f7e64ba + 302706e commit 01c9cb4

File tree

10 files changed

+491
-27
lines changed

10 files changed

+491
-27
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# SPDX-FileCopyrightText: The Threadbare Authors
2+
# SPDX-License-Identifier: MPL-2.0
3+
@tool
4+
class_name AreaFiller
5+
extends Node
6+
## Fills a CollisionObject2D with child scenes,
7+
## spaced randomly with a minimum separation.
8+
##
9+
## This can be used with [StaticBody2D] to create a forest at the boundary of
10+
## the map which the player cannot walk through, or with [Area2D] to create a
11+
## patch of wild flowers.
12+
##
13+
## The level designer should set [member area]'s [CollisionPolygon2D](s)
14+
## and the parameters of this node as desired, then click Refill in the
15+
## inspector to fill the area with a
16+
## new random arrangement of child scenes. These children are saved to the
17+
## scene, and no random generation occurs at runtime.
18+
19+
## Scenes that will be randomly placed into [member area]. There is an equal
20+
## probability of each scene being used each time. This list must not be
21+
## empty.
22+
@export var scenes: Array[PackedScene] = []:
23+
set(new_value):
24+
scenes = new_value
25+
update_configuration_warnings()
26+
27+
## If non-empty, each placed scene will have a randomly-selected element of this
28+
## list assigned to its [code]sprite_frames[/code] property.
29+
@export var sprite_frames: Array[SpriteFrames] = []
30+
31+
## Minimum separation between placed scenes. The maximum separation is twice
32+
## this value.
33+
@export_range(16.0, 128.0, 1.0) var minimum_separation: float = 64.0
34+
35+
@export_tool_button("Refill") var fill_button: Callable = fill
36+
37+
var _area: CollisionObject2D
38+
39+
40+
func _enter_tree() -> void:
41+
var parent := get_parent()
42+
if parent is CollisionObject2D:
43+
_area = parent
44+
update_configuration_warnings()
45+
46+
47+
func _exit_tree() -> void:
48+
_area = null
49+
update_configuration_warnings()
50+
51+
52+
func _get_configuration_warnings() -> PackedStringArray:
53+
var warnings: PackedStringArray
54+
55+
if not _area:
56+
warnings.append("Parent is not a CollisionObject2D (e.g. Area2D or StaticBody2D)")
57+
58+
if not scenes:
59+
warnings.append("At least one scene must be provided")
60+
61+
return warnings
62+
63+
64+
## Generate random points that fill the shapes of [param area], at least
65+
## [param minimum_separation] px apart.
66+
func _generate_points() -> PackedVector2Array:
67+
var points: PackedVector2Array
68+
69+
# TODO: Handling multiple shapes separately will give incorrect results if
70+
# they overlap. A more correct approach would be to merge the polygons, and
71+
# deal with shapes that have holes in!
72+
for owner_id: int in _area.get_shape_owners():
73+
var o := _area.shape_owner_get_owner(owner_id)
74+
if o is CollisionPolygon2D:
75+
# If the polygon has a transform we have to feed the sampler the
76+
# transformed points. Otherwise you get weird results when the
77+
# polygon is scaled or skewed because the minimum_separation has
78+
# not been.
79+
var transformed_polygon: PackedVector2Array = o.transform * o.polygon
80+
var sampler := PoissonDiscSampler.new()
81+
sampler.initialise(transformed_polygon, minimum_separation)
82+
sampler.fill()
83+
84+
points.append_array(sampler.points)
85+
else:
86+
push_warning("%s not supported (use CollisionPolygon2D)" % o)
87+
88+
return points
89+
90+
91+
func _clear_area() -> void:
92+
for child: Node in _area.get_children():
93+
if child != self and child is not CollisionPolygon2D and child is not CollisionShape2D:
94+
child.queue_free()
95+
96+
97+
func _add_child(pos: Vector2) -> void:
98+
var child: Node2D = scenes.pick_random().instantiate()
99+
child.position = pos
100+
if sprite_frames and "sprite_frames" in child:
101+
child.sprite_frames = sprite_frames.pick_random()
102+
_area.add_child(child, true)
103+
child.owner = get_tree().edited_scene_root
104+
105+
106+
## Clears [member area] (except for this node and any collision shapes),
107+
## generate a new set of points according to the current
108+
## parameters, and fill [member area] with instances of [member scenes]
109+
## at those points.
110+
func fill() -> void:
111+
_clear_area()
112+
113+
# Wait a tick so that the old children are freed and their names can be reused
114+
await get_tree().process_frame
115+
116+
var points := _generate_points()
117+
for point in points:
118+
_add_child(point)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
uid://bdhjixygupit1
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# SPDX-FileCopyrightText: The Threadbare Authors
2+
# SPDX-License-Identifier: MPL-2.0
3+
4+
class_name PoissonDiscSampler
5+
extends Object
6+
7+
# https://www.cs.ubc.ca/~rbridson/docs/bridson-siggraph07-poissondisk.pdf
8+
# https://www.jasondavies.com/poisson-disc/
9+
10+
# Number of attempts to make around each queued point
11+
const K := 30
12+
13+
## Generated points
14+
var points: PackedVector2Array
15+
16+
var _polygon: PackedVector2Array
17+
var _bbox: Rect2
18+
19+
## Radius: minimum distance between generated points
20+
var _r: float
21+
## [member _r] squared – this is needed frequently so cache it for a potential
22+
## modest performance improvement.
23+
var _r_squared: float
24+
25+
## A grid covering the area to be filled. Each element is an index into [member
26+
## points] or [code]-1[/code] if the cell is unfilled. Cells are conceptually
27+
## square, with side [member _cell_size].
28+
var _grid: PackedInt32Array
29+
30+
## Size of sides of cells in [member _grid], derived from [member _r].
31+
var _cell_size: float
32+
33+
## Points in [member _grid] are relative to this origin, so that all coordinates
34+
## are positive.
35+
var _grid_origin: Vector2
36+
37+
## Dimensions of [member _grid]
38+
var _grid_size: Vector2i
39+
40+
## Elements of [member _points] that we will look to place further points near.
41+
## When we fail to place a new point near an element of this array, it is removed.
42+
## Generation is complete when this is empty.
43+
var _active: PackedVector2Array
44+
45+
46+
static func _bounding_box(polygon: PackedVector2Array) -> Rect2:
47+
if polygon.is_empty():
48+
return Rect2()
49+
50+
var bbox: Rect2 = Rect2(polygon[0], Vector2.ZERO)
51+
for point: Vector2 in polygon:
52+
bbox = bbox.expand(point)
53+
54+
return bbox
55+
56+
57+
func initialise(polygon: PackedVector2Array, minimum_separation: float = 64) -> void:
58+
_polygon = polygon
59+
_bbox = _bounding_box(polygon)
60+
61+
_r = minimum_separation
62+
_r_squared = _r * _r
63+
64+
# Pick the cell size to be bounded by r/sqrt(2), so that each grid cell
65+
# contains at most one sample. sin(45°) = sqrt(0.5)
66+
_cell_size = _r * sqrt(0.5)
67+
_grid_origin = _bbox.position
68+
_grid_size = Vector2i(
69+
ceili(_bbox.size.x / _cell_size),
70+
ceili(_bbox.size.y / _cell_size),
71+
)
72+
_grid.resize(_grid_size.x * _grid_size.y)
73+
assert(_grid.size() < ((1 << 31) - 1), "Grid is too large for int32 indices")
74+
_grid.fill(-1)
75+
76+
points.clear()
77+
_active.clear()
78+
79+
80+
## Runs generate until no more points can be discovered
81+
func fill() -> void:
82+
while generate():
83+
pass
84+
85+
86+
func _generate_first_point() -> Vector2:
87+
# TODO: Triangulate polygon; pick triangle, weighted by area; pick random point in triangle
88+
# (search keyword: barycentric).
89+
90+
# Simpler implementation: Pick a random point on a random edge.
91+
var i := randi_range(0, _polygon.size() - 1)
92+
var j := (i + 1) % _polygon.size()
93+
return _polygon[i].lerp(_polygon[j], randf())
94+
95+
96+
## Generates a single point and appends it to [member points].
97+
## Returns [code]false[/code] when no more points can be generated.
98+
func generate() -> bool:
99+
if not points:
100+
_add_sample(_generate_first_point())
101+
102+
while _active:
103+
# While the active list is not empty, choose a random index
104+
# from it (say i).
105+
var n := _active.size() - 1
106+
var i := randi_range(0, n)
107+
var p := _active[i]
108+
109+
for j in range(K):
110+
var q := _generate_around(p)
111+
if Geometry2D.is_point_in_polygon(q, _polygon) and not _has_nearby_point(q):
112+
_add_sample(q)
113+
return true
114+
115+
# No suitable candidate found near p; remove it from the queue
116+
_active[i] = _active[n]
117+
_active.remove_at(n)
118+
119+
# No further points to search around
120+
return false
121+
122+
123+
func finished() -> bool:
124+
return _active.is_empty()
125+
126+
127+
func _to_grid_coords(point: Vector2) -> Vector2i:
128+
var transformed := point - _grid_origin
129+
return Vector2i(
130+
floori(transformed.x / _cell_size),
131+
floori(transformed.y / _cell_size),
132+
)
133+
134+
135+
func _add_sample(point: Vector2) -> void:
136+
points.push_back(point)
137+
_active.push_back(point)
138+
139+
var gc := _to_grid_coords(point)
140+
var grid_index := _grid_size.x * gc.y + gc.x
141+
var existing_ix := _grid[grid_index]
142+
assert(
143+
existing_ix == -1,
144+
(
145+
"Existing point %s at grid coord %s when inserting %s"
146+
% [
147+
points[existing_ix],
148+
gc,
149+
point,
150+
]
151+
)
152+
)
153+
_grid[grid_index] = points.size() - 1
154+
155+
156+
## Generates a point between r and 2r away from p, uniformly distributed.
157+
func _generate_around(p: Vector2) -> Vector2:
158+
var theta := randf_range(0, TAU)
159+
# https://stackoverflow.com/a/9048443 via https://www.jasondavies.com/poisson-disc/
160+
var distance := sqrt(randf_range(_r_squared, 4 * _r_squared))
161+
var q := p + (distance * Vector2.from_angle(theta))
162+
return q
163+
164+
165+
## Searches [member _grid] to check if an existing point is within a distance of r from p.
166+
func _has_nearby_point(p: Vector2) -> bool:
167+
var n := 2
168+
var gc := _to_grid_coords(p)
169+
var x0 := maxi(gc.x - n, 0)
170+
var y0 := maxi(gc.y - n, 0)
171+
var x1 := mini(gc.x + n + 1, _grid_size.x)
172+
var y1 := mini(gc.y + n + 1, _grid_size.y)
173+
for y in range(y0, y1):
174+
var gy := y * _grid_size.x
175+
for x in range(x0, x1):
176+
var existing_ix := _grid[gy + x]
177+
if existing_ix != -1:
178+
var existing := points[existing_ix]
179+
if existing.distance_squared_to(p) < _r_squared:
180+
return true
181+
return false
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
uid://utixw0krlol4
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
[remap]
2+
3+
importer="texture"
4+
type="CompressedTexture2D"
5+
uid="uid://dt4lx6310tcoy"
6+
path="res://.godot/imported/lcjt-flower.png-d6fabfbb70fcabe42e0f32acab468c79.ctex"
7+
metadata={
8+
"vram_texture": false
9+
}
10+
11+
[deps]
12+
13+
source_file="res://scenes/game_elements/props/decoration/flower/assets/lcjt-flower.png"
14+
dest_files=["res://.godot/imported/lcjt-flower.png-d6fabfbb70fcabe42e0f32acab468c79.ctex"]
15+
16+
[params]
17+
18+
compress/mode=0
19+
compress/high_quality=false
20+
compress/lossy_quality=0.7
21+
compress/hdr_compression=1
22+
compress/normal_map=0
23+
compress/channel_pack=0
24+
mipmaps/generate=false
25+
mipmaps/limit=-1
26+
roughness/mode=0
27+
roughness/src_normal=""
28+
process/fix_alpha_border=true
29+
process/premult_alpha=false
30+
process/normal_map_invert_y=false
31+
process/hdr_as_srgb=false
32+
process/hdr_clamp_exposure=false
33+
process/size_limit=0
34+
detect_3d/compress_to=1

0 commit comments

Comments
 (0)