spawnpoint-studio 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spawnpoint/__init__.py +3 -0
- spawnpoint/_data/examples/coin_hopper.json +228 -0
- spawnpoint/_data/examples/neon_arena.json +251 -0
- spawnpoint/_data/examples/pixel_arena.json +242 -0
- spawnpoint/_data/examples/pixel_hopper.json +221 -0
- spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
- spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
- spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
- spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
- spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
- spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
- spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
- spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
- spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
- spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
- spawnpoint/analyze.py +387 -0
- spawnpoint/build.py +146 -0
- spawnpoint/cli.py +290 -0
- spawnpoint/cockpit/__init__.py +0 -0
- spawnpoint/cockpit/server.py +473 -0
- spawnpoint/cockpit/static/app.css +111 -0
- spawnpoint/cockpit/static/app.js +327 -0
- spawnpoint/cockpit/static/index.html +79 -0
- spawnpoint/diagnostics.py +91 -0
- spawnpoint/expr.py +219 -0
- spawnpoint/gif.py +58 -0
- spawnpoint/godot.py +107 -0
- spawnpoint/graph.py +175 -0
- spawnpoint/level.py +157 -0
- spawnpoint/llm_server.py +77 -0
- spawnpoint/manifest.py +222 -0
- spawnpoint/playtest/__init__.py +104 -0
- spawnpoint/playtest/report.py +148 -0
- spawnpoint/playtest/runner.py +51 -0
- spawnpoint/playtest/tuner.py +151 -0
- spawnpoint/project.py +94 -0
- spawnpoint/py.typed +0 -0
- spawnpoint/rules.py +310 -0
- spawnpoint/sfx.py +352 -0
- spawnpoint/sprites/__init__.py +0 -0
- spawnpoint/sprites/ai.py +222 -0
- spawnpoint/sprites/contour.py +147 -0
- spawnpoint/sprites/generate.py +507 -0
- spawnpoint/sprites/importer.py +212 -0
- spawnpoint/sprites/resolve.py +139 -0
- spawnpoint/sprites/spriteset.py +159 -0
- spawnpoint/sprites/tiles.py +102 -0
- spawnpoint/studio/__init__.py +0 -0
- spawnpoint/studio/assemble.py +528 -0
- spawnpoint/studio/brief.py +197 -0
- spawnpoint/studio/creative.py +124 -0
- spawnpoint/studio/dataset.py +136 -0
- spawnpoint/studio/edits.py +206 -0
- spawnpoint/studio/levelgen.py +352 -0
- spawnpoint/studio/llm.py +145 -0
- spawnpoint/studio/orchestrator.py +341 -0
- spawnpoint/studio/prompts.py +122 -0
- spawnpoint/studio/web.py +123 -0
- spawnpoint/studio/webcheck.py +210 -0
- spawnpoint/studio_cli.py +232 -0
- spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
- spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
- spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
- spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
- spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
- spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
- tests/conftest.py +49 -0
- tests/test_ai_sprites.py +122 -0
- tests/test_analyze.py +116 -0
- tests/test_cockpit.py +148 -0
- tests/test_e2e.py +33 -0
- tests/test_expr.py +59 -0
- tests/test_gif.py +19 -0
- tests/test_godot_library.py +17 -0
- tests/test_graph.py +44 -0
- tests/test_level.py +64 -0
- tests/test_live_llm.py +38 -0
- tests/test_manifest.py +54 -0
- tests/test_playtest.py +129 -0
- tests/test_rules.py +85 -0
- tests/test_sfx.py +30 -0
- tests/test_sprites.py +261 -0
- tests/test_studio.py +335 -0
- tests/test_web.py +41 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
extends Node
|
|
2
|
+
## Build backend: turns res://build_plan.json (written by the Python compiler) into scenes.
|
|
3
|
+
## Run as a scene so autoloads and the class cache behave exactly like in the game:
|
|
4
|
+
## godot4 --headless --path <project> res://addons/spawn_point/tools/build.tscn
|
|
5
|
+
## Godot itself serializes every scene, so the output is always format-valid.
|
|
6
|
+
|
|
7
|
+
const PLAN_PATH := "res://build_plan.json"
|
|
8
|
+
const ENTITY_CORE := "res://addons/spawn_point/runtime/entity_core.gd"
|
|
9
|
+
const LEVEL_SCRIPT := "res://addons/spawn_point/runtime/level.gd"
|
|
10
|
+
const CAMERA_SCRIPT := "res://addons/spawn_point/runtime/camera_rig.gd"
|
|
11
|
+
const HUD_SCRIPT := "res://addons/spawn_point/runtime/hud.gd"
|
|
12
|
+
|
|
13
|
+
const TILE_SOLID_TOP := Vector2i(0, 0)
|
|
14
|
+
const TILE_SOLID := Vector2i(1, 0)
|
|
15
|
+
const TILE_ONEWAY := Vector2i(2, 0)
|
|
16
|
+
|
|
17
|
+
var _errors: Array[String] = []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
func _ready() -> void:
|
|
21
|
+
var text := FileAccess.get_file_as_string(PLAN_PATH)
|
|
22
|
+
var plan: Variant = JSON.parse_string(text)
|
|
23
|
+
if typeof(plan) != TYPE_DICTIONARY:
|
|
24
|
+
_fail("cannot parse %s" % PLAN_PATH)
|
|
25
|
+
return
|
|
26
|
+
for dir in ["res://entities", "res://levels"]:
|
|
27
|
+
DirAccess.make_dir_recursive_absolute(dir)
|
|
28
|
+
|
|
29
|
+
for e: Dictionary in plan.entities:
|
|
30
|
+
var node := _build_entity(e)
|
|
31
|
+
_save_scene(node, "res://entities/%s.tscn" % e.name)
|
|
32
|
+
var tileset := _build_tileset(plan)
|
|
33
|
+
var err := ResourceSaver.save(tileset, "res://levels/tiles.tres")
|
|
34
|
+
if err != OK:
|
|
35
|
+
_errors.append("saving tiles.tres failed: %s" % error_string(err))
|
|
36
|
+
var level := _build_level(plan, load("res://levels/tiles.tres"))
|
|
37
|
+
_save_scene(level, "res://levels/main.tscn")
|
|
38
|
+
|
|
39
|
+
if _errors.is_empty():
|
|
40
|
+
print("SPAWNPOINT_BUILD_OK")
|
|
41
|
+
get_tree().quit(0)
|
|
42
|
+
else:
|
|
43
|
+
for e in _errors:
|
|
44
|
+
printerr("SPAWNPOINT_BUILD_ERROR: ", e)
|
|
45
|
+
get_tree().quit(1)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
func _fail(msg: String) -> void:
|
|
49
|
+
printerr("SPAWNPOINT_BUILD_ERROR: ", msg)
|
|
50
|
+
get_tree().quit(1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- entities ---------------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
func _build_entity(e: Dictionary) -> Node2D:
|
|
56
|
+
var body: CollisionObject2D
|
|
57
|
+
match String(e.body):
|
|
58
|
+
"character":
|
|
59
|
+
body = CharacterBody2D.new()
|
|
60
|
+
"static":
|
|
61
|
+
body = StaticBody2D.new()
|
|
62
|
+
_:
|
|
63
|
+
body = Area2D.new()
|
|
64
|
+
body.name = e.name
|
|
65
|
+
body.collision_layer = int(e.layer)
|
|
66
|
+
body.collision_mask = int(e.mask)
|
|
67
|
+
|
|
68
|
+
var core: Node = load(ENTITY_CORE).new()
|
|
69
|
+
core.name = "Entity"
|
|
70
|
+
core.set(&"entity_type", e.name)
|
|
71
|
+
core.set(&"team", e.team)
|
|
72
|
+
core.set(&"is_player", e.is_player)
|
|
73
|
+
_own(body, core, body)
|
|
74
|
+
|
|
75
|
+
var shape_type := String(e.shape.type)
|
|
76
|
+
var size := Vector2(e.shape.size[0], e.shape.size[1])
|
|
77
|
+
var color := Color(String(e.shape.color))
|
|
78
|
+
var sprite: Variant = e.get("sprite")
|
|
79
|
+
var polygon := PackedVector2Array()
|
|
80
|
+
if sprite != null:
|
|
81
|
+
for p in sprite.polygon:
|
|
82
|
+
polygon.append(Vector2(p[0], p[1]))
|
|
83
|
+
body.set_meta(&"sp_half_size", size * 0.5)
|
|
84
|
+
if shape_type != "none":
|
|
85
|
+
if polygon.size() >= 3 and String(e.body) != "character":
|
|
86
|
+
# Pickups, hazards and static bodies collide with the drawing's outline.
|
|
87
|
+
var cp := CollisionPolygon2D.new()
|
|
88
|
+
cp.name = "CollisionPolygon2D"
|
|
89
|
+
cp.polygon = polygon
|
|
90
|
+
_own(body, cp, body)
|
|
91
|
+
else:
|
|
92
|
+
# Moving bodies keep a smooth shape fitted to the sprite (no snagging on tile seams).
|
|
93
|
+
var cs := CollisionShape2D.new()
|
|
94
|
+
cs.name = "CollisionShape2D"
|
|
95
|
+
cs.shape = _collision_shape(shape_type, size, 1.0)
|
|
96
|
+
_own(body, cs, body)
|
|
97
|
+
var visual: Node2D
|
|
98
|
+
if sprite != null:
|
|
99
|
+
visual = _sprite_visual(sprite, size, String(e.pivot) == "bottom")
|
|
100
|
+
else:
|
|
101
|
+
visual = _visual(shape_type, size, color, e.eye, String(e.pivot) == "bottom")
|
|
102
|
+
_own(body, visual, body)
|
|
103
|
+
_own_tree(visual, body)
|
|
104
|
+
|
|
105
|
+
for c: Dictionary in e.components:
|
|
106
|
+
var script := load(String(c.script)) as Script
|
|
107
|
+
if script == null:
|
|
108
|
+
_errors.append("%s: cannot load component script %s" % [e.name, c.script])
|
|
109
|
+
continue
|
|
110
|
+
var node: Node = script.new()
|
|
111
|
+
node.name = c.name
|
|
112
|
+
_apply_params(node, c.params, "%s.%s" % [e.name, c.name])
|
|
113
|
+
_own(body, node, body)
|
|
114
|
+
if c.area_scale != null and shape_type != "none" and polygon.size() >= 3:
|
|
115
|
+
# Hit/hurt areas follow the sprite outline: the hitbox matches the drawing.
|
|
116
|
+
var acp := CollisionPolygon2D.new()
|
|
117
|
+
acp.name = "CollisionPolygon2D"
|
|
118
|
+
var scaled := PackedVector2Array()
|
|
119
|
+
for p in polygon:
|
|
120
|
+
scaled.append(p * float(c.area_scale))
|
|
121
|
+
acp.polygon = scaled
|
|
122
|
+
_own(node, acp, body)
|
|
123
|
+
elif c.area_scale != null and shape_type != "none":
|
|
124
|
+
var acs := CollisionShape2D.new()
|
|
125
|
+
acs.name = "CollisionShape2D"
|
|
126
|
+
acs.shape = _collision_shape(shape_type, size, float(c.area_scale))
|
|
127
|
+
_own(node, acs, body)
|
|
128
|
+
return body
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
func _apply_params(node: Object, params: Dictionary, where: String) -> void:
|
|
132
|
+
for key: String in params:
|
|
133
|
+
var current: Variant = node.get(key)
|
|
134
|
+
var v: Variant = params[key]
|
|
135
|
+
var converted: Variant
|
|
136
|
+
match typeof(current):
|
|
137
|
+
TYPE_INT:
|
|
138
|
+
converted = int(v)
|
|
139
|
+
TYPE_FLOAT:
|
|
140
|
+
converted = float(v)
|
|
141
|
+
TYPE_BOOL:
|
|
142
|
+
converted = bool(v)
|
|
143
|
+
TYPE_VECTOR2:
|
|
144
|
+
converted = Vector2(v[0], v[1])
|
|
145
|
+
TYPE_COLOR:
|
|
146
|
+
converted = Color(String(v))
|
|
147
|
+
TYPE_STRING:
|
|
148
|
+
converted = String(v)
|
|
149
|
+
TYPE_STRING_NAME:
|
|
150
|
+
converted = StringName(v)
|
|
151
|
+
_:
|
|
152
|
+
_errors.append("%s: parameter '%s' has unsupported type" % [where, key])
|
|
153
|
+
continue
|
|
154
|
+
node.set(key, converted)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
func _collision_shape(type: String, size: Vector2, scale: float) -> Shape2D:
|
|
158
|
+
var s := size * scale
|
|
159
|
+
match type:
|
|
160
|
+
"triangle":
|
|
161
|
+
var tri := ConvexPolygonShape2D.new()
|
|
162
|
+
tri.points = PackedVector2Array([Vector2(0, -s.y * 0.5), Vector2(s.x * 0.5, s.y * 0.5), Vector2(-s.x * 0.5, s.y * 0.5)])
|
|
163
|
+
return tri
|
|
164
|
+
"spikes":
|
|
165
|
+
# Only the lower 60% hurts: brushing the very tips is forgiven.
|
|
166
|
+
var sp := RectangleShape2D.new()
|
|
167
|
+
sp.size = Vector2(s.x * 0.9, s.y * 0.6)
|
|
168
|
+
return sp
|
|
169
|
+
"circle":
|
|
170
|
+
var c := CircleShape2D.new()
|
|
171
|
+
c.radius = minf(s.x, s.y) * 0.5
|
|
172
|
+
return c
|
|
173
|
+
"capsule":
|
|
174
|
+
var cap := CapsuleShape2D.new()
|
|
175
|
+
cap.radius = minf(s.x, s.y) * 0.5
|
|
176
|
+
cap.height = maxf(s.y, cap.radius * 2.0)
|
|
177
|
+
return cap
|
|
178
|
+
var r := RectangleShape2D.new()
|
|
179
|
+
r.size = s
|
|
180
|
+
return r
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
func _visual(type: String, size: Vector2, color: Color, eye: bool, pivot_bottom: bool) -> Node2D:
|
|
184
|
+
var visual := Node2D.new()
|
|
185
|
+
visual.name = "Visual"
|
|
186
|
+
var shape := Polygon2D.new()
|
|
187
|
+
shape.name = "Shape"
|
|
188
|
+
shape.polygon = _polygon(type, size)
|
|
189
|
+
shape.color = color
|
|
190
|
+
shape.antialiased = true
|
|
191
|
+
if pivot_bottom:
|
|
192
|
+
# Squash & stretch pivots at the feet.
|
|
193
|
+
visual.position = Vector2(0, size.y * 0.5)
|
|
194
|
+
shape.position = Vector2(0, -size.y * 0.5)
|
|
195
|
+
visual.add_child(shape)
|
|
196
|
+
# Darker lower half gives the flat shape some volume.
|
|
197
|
+
var shade := Polygon2D.new()
|
|
198
|
+
shade.name = "Shade"
|
|
199
|
+
shade.polygon = _lower_half(shape.polygon)
|
|
200
|
+
shade.color = Color(0, 0, 0, 0.18)
|
|
201
|
+
shape.add_child(shade)
|
|
202
|
+
if eye:
|
|
203
|
+
var r := clampf(minf(size.x, size.y) * 0.16, 2.5, 7.0)
|
|
204
|
+
var white := Polygon2D.new()
|
|
205
|
+
white.name = "Eye"
|
|
206
|
+
white.polygon = _circle_points(r, 12)
|
|
207
|
+
white.color = Color("#fff1e8")
|
|
208
|
+
white.position = Vector2(size.x * 0.2, -size.y * 0.18)
|
|
209
|
+
shape.add_child(white)
|
|
210
|
+
var pupil := Polygon2D.new()
|
|
211
|
+
pupil.name = "Pupil"
|
|
212
|
+
pupil.polygon = _circle_points(r * 0.5, 10)
|
|
213
|
+
pupil.color = Color("#1d2b53")
|
|
214
|
+
pupil.position = Vector2(r * 0.35, 0)
|
|
215
|
+
white.add_child(pupil)
|
|
216
|
+
return visual
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
func _sprite_visual(sprite: Dictionary, size: Vector2, pivot_bottom: bool) -> Node2D:
|
|
220
|
+
var visual := Node2D.new()
|
|
221
|
+
visual.name = "Visual"
|
|
222
|
+
visual.set_meta(&"sp_color", Color(String(sprite.color)))
|
|
223
|
+
var holder := Node2D.new() # flipped to face left/right; the sprite inside keeps its scale
|
|
224
|
+
holder.name = "Shape"
|
|
225
|
+
visual.add_child(holder)
|
|
226
|
+
if pivot_bottom:
|
|
227
|
+
visual.position = Vector2(0, size.y * 0.5)
|
|
228
|
+
holder.position = Vector2(0, -size.y * 0.5)
|
|
229
|
+
var tex := load(String(sprite.texture)) as Texture2D
|
|
230
|
+
if tex == null:
|
|
231
|
+
_errors.append("cannot load sprite texture %s" % sprite.texture)
|
|
232
|
+
return visual
|
|
233
|
+
var fw := int(sprite.frame_size[0])
|
|
234
|
+
var fh := int(sprite.frame_size[1])
|
|
235
|
+
var frames := SpriteFrames.new()
|
|
236
|
+
for anim_name: String in sprite.animations:
|
|
237
|
+
var a: Dictionary = sprite.animations[anim_name]
|
|
238
|
+
if anim_name != "default":
|
|
239
|
+
frames.add_animation(anim_name)
|
|
240
|
+
frames.set_animation_speed(anim_name, float(a.fps))
|
|
241
|
+
frames.set_animation_loop(anim_name, bool(a.loop))
|
|
242
|
+
for f in a.frames:
|
|
243
|
+
var at := AtlasTexture.new()
|
|
244
|
+
at.atlas = tex
|
|
245
|
+
at.region = Rect2(int(f[0]) * fw, int(f[1]) * fh, fw, fh)
|
|
246
|
+
frames.add_frame(anim_name, at)
|
|
247
|
+
if not sprite.animations.has("default"):
|
|
248
|
+
frames.remove_animation(&"default")
|
|
249
|
+
var spr := AnimatedSprite2D.new()
|
|
250
|
+
spr.name = "Sprite"
|
|
251
|
+
spr.sprite_frames = frames
|
|
252
|
+
var first: String = "idle" if sprite.animations.has("idle") else String(sprite.animations.keys()[0])
|
|
253
|
+
spr.animation = first
|
|
254
|
+
spr.autoplay = first
|
|
255
|
+
spr.scale = Vector2(sprite.scale, sprite.scale)
|
|
256
|
+
spr.position = Vector2(sprite.offset[0], sprite.offset[1])
|
|
257
|
+
spr.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
|
258
|
+
holder.add_child(spr)
|
|
259
|
+
return visual
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
func _polygon(type: String, size: Vector2) -> PackedVector2Array:
|
|
263
|
+
var hx := size.x * 0.5
|
|
264
|
+
var hy := size.y * 0.5
|
|
265
|
+
match type:
|
|
266
|
+
"triangle":
|
|
267
|
+
return PackedVector2Array([Vector2(0, -hy), Vector2(hx, hy), Vector2(-hx, hy)])
|
|
268
|
+
"spikes":
|
|
269
|
+
# Zigzag row of teeth, one per ~10 px.
|
|
270
|
+
var teeth := maxi(int(size.x / 10.0), 1)
|
|
271
|
+
var pts := PackedVector2Array([Vector2(-hx, hy)])
|
|
272
|
+
for i in teeth:
|
|
273
|
+
var x0 := -hx + size.x * i / teeth
|
|
274
|
+
pts.append(Vector2(x0 + size.x / teeth * 0.5, -hy))
|
|
275
|
+
pts.append(Vector2(x0 + size.x / teeth, hy))
|
|
276
|
+
return pts
|
|
277
|
+
"circle":
|
|
278
|
+
var pts := PackedVector2Array()
|
|
279
|
+
for i in 32:
|
|
280
|
+
var a := TAU * i / 32.0
|
|
281
|
+
pts.append(Vector2(cos(a) * hx, sin(a) * hy))
|
|
282
|
+
return pts
|
|
283
|
+
"capsule":
|
|
284
|
+
var r := minf(hx, hy)
|
|
285
|
+
var pts := PackedVector2Array()
|
|
286
|
+
var top := -hy + r
|
|
287
|
+
var bottom := hy - r
|
|
288
|
+
for i in 13:
|
|
289
|
+
var a := PI + PI * i / 12.0
|
|
290
|
+
pts.append(Vector2(cos(a) * r, top + sin(a) * r))
|
|
291
|
+
for i in 13:
|
|
292
|
+
var a := PI * i / 12.0
|
|
293
|
+
pts.append(Vector2(cos(a) * r, bottom + sin(a) * r))
|
|
294
|
+
return pts
|
|
295
|
+
# Rounded rectangle.
|
|
296
|
+
var rad := minf(minf(hx, hy) * 0.35, 6.0)
|
|
297
|
+
var pts := PackedVector2Array()
|
|
298
|
+
var corners := [Vector2(hx - rad, -hy + rad), Vector2(hx - rad, hy - rad), Vector2(-hx + rad, hy - rad), Vector2(-hx + rad, -hy + rad)]
|
|
299
|
+
for ci in 4:
|
|
300
|
+
for i in 5:
|
|
301
|
+
var a := -PI * 0.5 + PI * 0.5 * ci + PI * 0.5 * i / 4.0
|
|
302
|
+
pts.append(corners[ci] + Vector2(cos(a), sin(a)) * rad)
|
|
303
|
+
return pts
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
func _lower_half(poly: PackedVector2Array) -> PackedVector2Array:
|
|
307
|
+
var out := PackedVector2Array()
|
|
308
|
+
var min_y := INF
|
|
309
|
+
var max_y := -INF
|
|
310
|
+
for p in poly:
|
|
311
|
+
min_y = minf(min_y, p.y)
|
|
312
|
+
max_y = maxf(max_y, p.y)
|
|
313
|
+
var cut := lerpf(min_y, max_y, 0.55)
|
|
314
|
+
for p in poly:
|
|
315
|
+
out.append(Vector2(p.x, maxf(p.y, cut)))
|
|
316
|
+
return out
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
func _circle_points(r: float, n: int) -> PackedVector2Array:
|
|
320
|
+
var pts := PackedVector2Array()
|
|
321
|
+
for i in n:
|
|
322
|
+
var a := TAU * i / n
|
|
323
|
+
pts.append(Vector2(cos(a), sin(a)) * r)
|
|
324
|
+
return pts
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# --- tiles ------------------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
func _build_tileset(plan: Dictionary) -> TileSet:
|
|
330
|
+
var ts := int(plan.tile_size)
|
|
331
|
+
var base := Color(String(plan.tiles.solid.color))
|
|
332
|
+
var top := Color(String(plan.tiles.solid.top))
|
|
333
|
+
var plank := Color(String(plan.tiles.oneway.color))
|
|
334
|
+
var img := Image.create(ts * 3, ts, false, Image.FORMAT_RGBA8)
|
|
335
|
+
img.fill(Color(0, 0, 0, 0))
|
|
336
|
+
_paint_block(img, 0, ts, base)
|
|
337
|
+
_paint_top(img, 0, ts, top)
|
|
338
|
+
_paint_block(img, ts, ts, base)
|
|
339
|
+
_paint_plank(img, ts * 2, ts, plank)
|
|
340
|
+
|
|
341
|
+
var pixel_tex: Texture2D = load(String(plan.tiles.texture)) if plan.tiles.has("texture") else null
|
|
342
|
+
var tileset := TileSet.new()
|
|
343
|
+
tileset.tile_size = Vector2i(ts, ts)
|
|
344
|
+
tileset.add_physics_layer()
|
|
345
|
+
tileset.set_physics_layer_collision_layer(0, 1)
|
|
346
|
+
tileset.set_physics_layer_collision_mask(0, 0)
|
|
347
|
+
var src := TileSetAtlasSource.new()
|
|
348
|
+
# Pixel-art atlas from the compiler when present; otherwise the painted flat tiles.
|
|
349
|
+
src.texture = pixel_tex if pixel_tex else ImageTexture.create_from_image(img)
|
|
350
|
+
src.texture_region_size = Vector2i(ts, ts)
|
|
351
|
+
tileset.add_source(src, 0)
|
|
352
|
+
var h := ts * 0.5
|
|
353
|
+
for coords in [TILE_SOLID_TOP, TILE_SOLID]:
|
|
354
|
+
src.create_tile(coords)
|
|
355
|
+
var td := src.get_tile_data(coords, 0)
|
|
356
|
+
td.add_collision_polygon(0)
|
|
357
|
+
td.set_collision_polygon_points(0, 0, PackedVector2Array([Vector2(-h, -h), Vector2(h, -h), Vector2(h, h), Vector2(-h, h)]))
|
|
358
|
+
src.create_tile(TILE_ONEWAY)
|
|
359
|
+
var od := src.get_tile_data(TILE_ONEWAY, 0)
|
|
360
|
+
od.add_collision_polygon(0)
|
|
361
|
+
var plank_h := maxf(ts * 0.3, 6.0)
|
|
362
|
+
od.set_collision_polygon_points(0, 0, PackedVector2Array([Vector2(-h, -h), Vector2(h, -h), Vector2(h, -h + plank_h), Vector2(-h, -h + plank_h)]))
|
|
363
|
+
od.set_collision_polygon_one_way(0, 0, true)
|
|
364
|
+
return tileset
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
func _paint_block(img: Image, x0: int, ts: int, c: Color) -> void:
|
|
368
|
+
var rng := RandomNumberGenerator.new()
|
|
369
|
+
rng.seed = 1234
|
|
370
|
+
var edge := maxi(ts / 16, 1)
|
|
371
|
+
for y in ts:
|
|
372
|
+
for x in ts:
|
|
373
|
+
var col := c
|
|
374
|
+
if x < edge or y < edge:
|
|
375
|
+
col = c.lightened(0.12)
|
|
376
|
+
elif x >= ts - edge or y >= ts - edge:
|
|
377
|
+
col = c.darkened(0.25)
|
|
378
|
+
elif rng.randf() < 0.06:
|
|
379
|
+
col = c.darkened(0.12)
|
|
380
|
+
img.set_pixel(x0 + x, y, col)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
func _paint_top(img: Image, x0: int, ts: int, c: Color) -> void:
|
|
384
|
+
var depth := maxi(ts / 5, 3)
|
|
385
|
+
for x in ts:
|
|
386
|
+
# Slightly ragged lower edge (grass / trim).
|
|
387
|
+
var d := depth + (1 if (x / maxi(ts / 8, 1)) % 2 == 0 else 0)
|
|
388
|
+
for y in d:
|
|
389
|
+
var col := c.lightened(0.25) if y < maxi(ts / 16, 1) else c
|
|
390
|
+
img.set_pixel(x0 + x, y, col)
|
|
391
|
+
img.set_pixel(x0 + x, d, c.darkened(0.3))
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
func _paint_plank(img: Image, x0: int, ts: int, c: Color) -> void:
|
|
395
|
+
var plank_h := maxi(int(ts * 0.3), 6)
|
|
396
|
+
for y in plank_h:
|
|
397
|
+
for x in ts:
|
|
398
|
+
var col := c
|
|
399
|
+
if y < maxi(ts / 16, 1):
|
|
400
|
+
col = c.lightened(0.25)
|
|
401
|
+
elif y >= plank_h - maxi(ts / 16, 1):
|
|
402
|
+
col = c.darkened(0.3)
|
|
403
|
+
elif x == ts / 2:
|
|
404
|
+
col = c.darkened(0.2)
|
|
405
|
+
img.set_pixel(x0 + x, y, col)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# --- level ------------------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
func _build_level(plan: Dictionary, tileset: TileSet) -> Node2D:
|
|
411
|
+
var ts := int(plan.tile_size)
|
|
412
|
+
var bounds := Rect2(plan.bounds[0], plan.bounds[1], plan.bounds[2], plan.bounds[3])
|
|
413
|
+
var root := Node2D.new()
|
|
414
|
+
root.name = "Main"
|
|
415
|
+
root.set_script(load(LEVEL_SCRIPT))
|
|
416
|
+
root.set(&"genre", plan.genre)
|
|
417
|
+
root.set(&"lives", int(plan.lives))
|
|
418
|
+
root.set(&"tile_size", ts)
|
|
419
|
+
root.set(&"bounds", bounds)
|
|
420
|
+
|
|
421
|
+
_build_backdrop(root, plan, bounds)
|
|
422
|
+
|
|
423
|
+
var tiles := TileMapLayer.new()
|
|
424
|
+
tiles.name = "Tiles"
|
|
425
|
+
tiles.tile_set = tileset
|
|
426
|
+
var solid := {}
|
|
427
|
+
for cell in plan.map.cells:
|
|
428
|
+
if cell[2] == "solid":
|
|
429
|
+
solid[Vector2i(cell[0], cell[1])] = true
|
|
430
|
+
for cell in plan.map.cells:
|
|
431
|
+
var pos := Vector2i(cell[0], cell[1])
|
|
432
|
+
var atlas := TILE_ONEWAY
|
|
433
|
+
if cell[2] == "solid":
|
|
434
|
+
atlas = TILE_SOLID if solid.has(pos + Vector2i.UP) else TILE_SOLID_TOP
|
|
435
|
+
tiles.set_cell(pos, 0, atlas)
|
|
436
|
+
_own(root, tiles, root)
|
|
437
|
+
|
|
438
|
+
var walls := StaticBody2D.new()
|
|
439
|
+
walls.name = "Bounds"
|
|
440
|
+
walls.collision_layer = 1
|
|
441
|
+
walls.collision_mask = 0
|
|
442
|
+
_own(root, walls, root)
|
|
443
|
+
for side: String in plan.walls:
|
|
444
|
+
var wb := WorldBoundaryShape2D.new()
|
|
445
|
+
match side:
|
|
446
|
+
"left":
|
|
447
|
+
wb.normal = Vector2.RIGHT
|
|
448
|
+
wb.distance = bounds.position.x
|
|
449
|
+
"right":
|
|
450
|
+
wb.normal = Vector2.LEFT
|
|
451
|
+
wb.distance = -bounds.end.x
|
|
452
|
+
"top":
|
|
453
|
+
wb.normal = Vector2.DOWN
|
|
454
|
+
wb.distance = bounds.position.y
|
|
455
|
+
"bottom":
|
|
456
|
+
wb.normal = Vector2.UP
|
|
457
|
+
wb.distance = -bounds.end.y
|
|
458
|
+
var cs := CollisionShape2D.new()
|
|
459
|
+
cs.name = "Wall" + side.capitalize()
|
|
460
|
+
cs.shape = wb
|
|
461
|
+
_own(walls, cs, root)
|
|
462
|
+
|
|
463
|
+
var entities := Node2D.new()
|
|
464
|
+
entities.name = "Entities"
|
|
465
|
+
_own(root, entities, root)
|
|
466
|
+
var counts := {}
|
|
467
|
+
for p: Dictionary in plan.placements:
|
|
468
|
+
var scene := load("res://entities/%s.tscn" % p.entity) as PackedScene
|
|
469
|
+
var inst := scene.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE) as Node2D
|
|
470
|
+
var n: int = counts.get(p.entity, 0) + 1
|
|
471
|
+
counts[p.entity] = n
|
|
472
|
+
inst.name = p.entity if n == 1 else "%s%d" % [p.entity, n]
|
|
473
|
+
inst.position = Vector2(p.x, p.y)
|
|
474
|
+
entities.add_child(inst)
|
|
475
|
+
inst.owner = root
|
|
476
|
+
|
|
477
|
+
var cam := Camera2D.new()
|
|
478
|
+
cam.name = "Camera"
|
|
479
|
+
cam.set_script(load(CAMERA_SCRIPT))
|
|
480
|
+
var zoom := float(plan.get("zoom", 1.0))
|
|
481
|
+
cam.zoom = Vector2(zoom, zoom)
|
|
482
|
+
var vp := Vector2(plan.viewport[0], plan.viewport[1]) / zoom # what the camera actually shows
|
|
483
|
+
_apply_camera_limits(cam, bounds, vp)
|
|
484
|
+
_own(root, cam, root)
|
|
485
|
+
|
|
486
|
+
var hud := CanvasLayer.new()
|
|
487
|
+
hud.name = "HUD"
|
|
488
|
+
hud.set_script(load(HUD_SCRIPT))
|
|
489
|
+
_own(root, hud, root)
|
|
490
|
+
|
|
491
|
+
var rules := Node.new()
|
|
492
|
+
rules.name = "Rules"
|
|
493
|
+
var rules_script := load(String(plan.rules_script)) as Script
|
|
494
|
+
if rules_script == null or not rules_script.can_instantiate():
|
|
495
|
+
_errors.append("generated rules script %s does not compile" % plan.rules_script)
|
|
496
|
+
else:
|
|
497
|
+
rules.set_script(rules_script)
|
|
498
|
+
_own(root, rules, root)
|
|
499
|
+
return root
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# Cheap depth: rolling parallax hills and clouds for platformers, a checker floor for top-down.
|
|
503
|
+
func _build_backdrop(root: Node2D, plan: Dictionary, bounds: Rect2) -> void:
|
|
504
|
+
var bg := Color(String(plan.background))
|
|
505
|
+
var holder := Node2D.new()
|
|
506
|
+
holder.name = "Backdrop"
|
|
507
|
+
holder.z_index = -10
|
|
508
|
+
_own(root, holder, root)
|
|
509
|
+
if plan.genre == "topdown":
|
|
510
|
+
var floor_rect := ColorRect.new()
|
|
511
|
+
floor_rect.name = "Floor"
|
|
512
|
+
floor_rect.position = bounds.position
|
|
513
|
+
floor_rect.size = bounds.size
|
|
514
|
+
floor_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
515
|
+
var mat := ShaderMaterial.new()
|
|
516
|
+
mat.shader = load("res://addons/spawn_point/shaders/floor_grid.gdshader")
|
|
517
|
+
mat.set_shader_parameter(&"tile_size", float(plan.tile_size))
|
|
518
|
+
mat.set_shader_parameter(&"size", bounds.size)
|
|
519
|
+
mat.set_shader_parameter(&"color_a", bg.lightened(0.04))
|
|
520
|
+
mat.set_shader_parameter(&"color_b", bg.lightened(0.08))
|
|
521
|
+
floor_rect.material = mat
|
|
522
|
+
_own(holder, floor_rect, root)
|
|
523
|
+
return
|
|
524
|
+
var width := 1280.0
|
|
525
|
+
var ground := bounds.end.y
|
|
526
|
+
var layers := [
|
|
527
|
+
{"name": "Clouds", "scroll": 0.1, "color": Color(1, 1, 1, 0.05), "kind": "clouds"},
|
|
528
|
+
{"name": "FarHills", "scroll": 0.25, "color": bg.lightened(0.07), "kind": "hills", "base": ground - 220.0, "amp": 70.0, "seed": 1.3},
|
|
529
|
+
{"name": "NearHills", "scroll": 0.5, "color": bg.lightened(0.13), "kind": "hills", "base": ground - 120.0, "amp": 45.0, "seed": 4.1},
|
|
530
|
+
]
|
|
531
|
+
for layer: Dictionary in layers:
|
|
532
|
+
var px := Parallax2D.new()
|
|
533
|
+
px.name = layer.name
|
|
534
|
+
px.scroll_scale = Vector2(layer.scroll, 1.0)
|
|
535
|
+
px.repeat_size = Vector2(width, 0)
|
|
536
|
+
px.repeat_times = 3
|
|
537
|
+
_own(holder, px, root)
|
|
538
|
+
if layer.kind == "hills":
|
|
539
|
+
var poly := Polygon2D.new()
|
|
540
|
+
poly.name = "Shape"
|
|
541
|
+
var pts := PackedVector2Array()
|
|
542
|
+
var steps := 64
|
|
543
|
+
for i in steps + 1:
|
|
544
|
+
var x := width * i / steps
|
|
545
|
+
var t := TAU * i / steps
|
|
546
|
+
var y: float = layer.base - layer.amp * (0.6 * sin(t * 2.0 + layer.seed) + 0.4 * sin(t * 5.0 + layer.seed * 2.0))
|
|
547
|
+
pts.append(Vector2(x, y))
|
|
548
|
+
pts.append(Vector2(width, ground + 600.0))
|
|
549
|
+
pts.append(Vector2(0, ground + 600.0))
|
|
550
|
+
poly.polygon = pts
|
|
551
|
+
poly.color = layer.color
|
|
552
|
+
_own(px, poly, root)
|
|
553
|
+
else:
|
|
554
|
+
var rng := RandomNumberGenerator.new()
|
|
555
|
+
rng.seed = 99
|
|
556
|
+
for i in 6:
|
|
557
|
+
var cloud := Polygon2D.new()
|
|
558
|
+
cloud.name = "Cloud%d" % i
|
|
559
|
+
var w := rng.randf_range(80, 180)
|
|
560
|
+
cloud.polygon = _ellipse(w, w * 0.3)
|
|
561
|
+
cloud.position = Vector2(width * i / 6.0 + rng.randf_range(0, 120), bounds.position.y + rng.randf_range(20, 160))
|
|
562
|
+
cloud.color = layer.color
|
|
563
|
+
_own(px, cloud, root)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
func _ellipse(w: float, h: float) -> PackedVector2Array:
|
|
567
|
+
var pts := PackedVector2Array()
|
|
568
|
+
for i in 24:
|
|
569
|
+
var a := TAU * i / 24.0
|
|
570
|
+
pts.append(Vector2(cos(a) * w * 0.5, sin(a) * h * 0.5))
|
|
571
|
+
return pts
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
func _apply_camera_limits(cam: Camera2D, bounds: Rect2, vp: Vector2) -> void:
|
|
575
|
+
# When the level is smaller than the screen on an axis, center it instead of clamping.
|
|
576
|
+
if bounds.size.x >= vp.x:
|
|
577
|
+
cam.limit_left = int(bounds.position.x)
|
|
578
|
+
cam.limit_right = int(bounds.end.x)
|
|
579
|
+
else:
|
|
580
|
+
var cx := bounds.get_center().x
|
|
581
|
+
cam.limit_left = int(cx - vp.x * 0.5)
|
|
582
|
+
cam.limit_right = int(cx + vp.x * 0.5)
|
|
583
|
+
if bounds.size.y >= vp.y:
|
|
584
|
+
cam.limit_top = int(bounds.position.y - vp.y * 0.25)
|
|
585
|
+
cam.limit_bottom = int(bounds.end.y)
|
|
586
|
+
else:
|
|
587
|
+
var cy := bounds.get_center().y
|
|
588
|
+
cam.limit_top = int(cy - vp.y * 0.5)
|
|
589
|
+
cam.limit_bottom = int(cy + vp.y * 0.5)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
func _own(parent: Node, child: Node, owner_node: Node) -> void:
|
|
593
|
+
parent.add_child(child)
|
|
594
|
+
child.owner = owner_node
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
func _own_tree(node: Node, owner_node: Node) -> void:
|
|
598
|
+
for c in node.get_children():
|
|
599
|
+
c.owner = owner_node
|
|
600
|
+
_own_tree(c, owner_node)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
func _save_scene(root: Node, path: String) -> void:
|
|
604
|
+
var ps := PackedScene.new()
|
|
605
|
+
var err := ps.pack(root)
|
|
606
|
+
if err != OK:
|
|
607
|
+
_errors.append("packing %s failed: %s" % [path, error_string(err)])
|
|
608
|
+
return
|
|
609
|
+
err = ResourceSaver.save(ps, path)
|
|
610
|
+
if err != OK:
|
|
611
|
+
_errors.append("saving %s failed: %s" % [path, error_string(err)])
|
|
612
|
+
root.free()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
extends Node
|
|
2
|
+
## Plays the main level with scripted input and saves screenshots (needs a display, not headless).
|
|
3
|
+
## Run: godot4 --path <project> --fixed-fps 60 res://addons/spawn_point/tools/capture.tscn -- <out_dir> [frames]
|
|
4
|
+
## frames: comma-separated physics frames to capture (default 40,100,160,220).
|
|
5
|
+
|
|
6
|
+
const LEVEL := "res://levels/main.tscn"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
func _ready() -> void:
|
|
10
|
+
var args := OS.get_cmdline_user_args()
|
|
11
|
+
var out_dir := args[0] if args.size() > 0 else "user://captures"
|
|
12
|
+
var shots: Array[int] = []
|
|
13
|
+
for s in (args[1] if args.size() > 1 else "40,100,160,220").split(","):
|
|
14
|
+
shots.append(int(s))
|
|
15
|
+
DirAccess.make_dir_recursive_absolute(out_dir)
|
|
16
|
+
add_child((load(LEVEL) as PackedScene).instantiate())
|
|
17
|
+
var last: int = shots.max()
|
|
18
|
+
for frame in range(1, last + 1):
|
|
19
|
+
_script_input(frame)
|
|
20
|
+
await get_tree().physics_frame
|
|
21
|
+
if frame in shots:
|
|
22
|
+
await RenderingServer.frame_post_draw
|
|
23
|
+
var img := get_viewport().get_texture().get_image()
|
|
24
|
+
img.save_png("%s/frame_%04d.png" % [out_dir, frame])
|
|
25
|
+
get_tree().quit(0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Idle for a moment, then run right with periodic jumps, shooting and a dash.
|
|
29
|
+
func _script_input(frame: int) -> void:
|
|
30
|
+
if frame == 30:
|
|
31
|
+
Input.action_press(&"move_right")
|
|
32
|
+
if frame >= 30 and frame % 35 == 0:
|
|
33
|
+
Input.action_press(&"jump")
|
|
34
|
+
if frame >= 30 and frame % 35 == 14:
|
|
35
|
+
Input.action_release(&"jump")
|
|
36
|
+
if frame == 120:
|
|
37
|
+
Input.action_press(&"shoot")
|
|
38
|
+
Input.action_press(&"dash")
|
|
39
|
+
if frame == 125:
|
|
40
|
+
Input.action_release(&"dash")
|
|
41
|
+
if frame == 170:
|
|
42
|
+
Input.action_release(&"shoot")
|