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.
Files changed (118) hide show
  1. spawnpoint/__init__.py +3 -0
  2. spawnpoint/_data/examples/coin_hopper.json +228 -0
  3. spawnpoint/_data/examples/neon_arena.json +251 -0
  4. spawnpoint/_data/examples/pixel_arena.json +242 -0
  5. spawnpoint/_data/examples/pixel_hopper.json +221 -0
  6. spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
  7. spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
  8. spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
  9. spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
  10. spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
  11. spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
  12. spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
  13. spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
  14. spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
  15. spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
  16. spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
  17. spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
  18. spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
  19. spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
  20. spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
  21. spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
  22. spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
  23. spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
  24. spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
  25. spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
  26. spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
  27. spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
  28. spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
  29. spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
  30. spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
  31. spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
  32. spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
  33. spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
  34. spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
  35. spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
  36. spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
  37. spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
  38. spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
  39. spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
  40. spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
  41. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
  42. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
  43. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
  44. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
  45. spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
  46. spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
  47. spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
  48. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
  49. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
  50. spawnpoint/analyze.py +387 -0
  51. spawnpoint/build.py +146 -0
  52. spawnpoint/cli.py +290 -0
  53. spawnpoint/cockpit/__init__.py +0 -0
  54. spawnpoint/cockpit/server.py +473 -0
  55. spawnpoint/cockpit/static/app.css +111 -0
  56. spawnpoint/cockpit/static/app.js +327 -0
  57. spawnpoint/cockpit/static/index.html +79 -0
  58. spawnpoint/diagnostics.py +91 -0
  59. spawnpoint/expr.py +219 -0
  60. spawnpoint/gif.py +58 -0
  61. spawnpoint/godot.py +107 -0
  62. spawnpoint/graph.py +175 -0
  63. spawnpoint/level.py +157 -0
  64. spawnpoint/llm_server.py +77 -0
  65. spawnpoint/manifest.py +222 -0
  66. spawnpoint/playtest/__init__.py +104 -0
  67. spawnpoint/playtest/report.py +148 -0
  68. spawnpoint/playtest/runner.py +51 -0
  69. spawnpoint/playtest/tuner.py +151 -0
  70. spawnpoint/project.py +94 -0
  71. spawnpoint/py.typed +0 -0
  72. spawnpoint/rules.py +310 -0
  73. spawnpoint/sfx.py +352 -0
  74. spawnpoint/sprites/__init__.py +0 -0
  75. spawnpoint/sprites/ai.py +222 -0
  76. spawnpoint/sprites/contour.py +147 -0
  77. spawnpoint/sprites/generate.py +507 -0
  78. spawnpoint/sprites/importer.py +212 -0
  79. spawnpoint/sprites/resolve.py +139 -0
  80. spawnpoint/sprites/spriteset.py +159 -0
  81. spawnpoint/sprites/tiles.py +102 -0
  82. spawnpoint/studio/__init__.py +0 -0
  83. spawnpoint/studio/assemble.py +528 -0
  84. spawnpoint/studio/brief.py +197 -0
  85. spawnpoint/studio/creative.py +124 -0
  86. spawnpoint/studio/dataset.py +136 -0
  87. spawnpoint/studio/edits.py +206 -0
  88. spawnpoint/studio/levelgen.py +352 -0
  89. spawnpoint/studio/llm.py +145 -0
  90. spawnpoint/studio/orchestrator.py +341 -0
  91. spawnpoint/studio/prompts.py +122 -0
  92. spawnpoint/studio/web.py +123 -0
  93. spawnpoint/studio/webcheck.py +210 -0
  94. spawnpoint/studio_cli.py +232 -0
  95. spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
  96. spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
  97. spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
  98. spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
  99. spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
  100. spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
  101. tests/conftest.py +49 -0
  102. tests/test_ai_sprites.py +122 -0
  103. tests/test_analyze.py +116 -0
  104. tests/test_cockpit.py +148 -0
  105. tests/test_e2e.py +33 -0
  106. tests/test_expr.py +59 -0
  107. tests/test_gif.py +19 -0
  108. tests/test_godot_library.py +17 -0
  109. tests/test_graph.py +44 -0
  110. tests/test_level.py +64 -0
  111. tests/test_live_llm.py +38 -0
  112. tests/test_manifest.py +54 -0
  113. tests/test_playtest.py +129 -0
  114. tests/test_rules.py +85 -0
  115. tests/test_sfx.py +30 -0
  116. tests/test_sprites.py +261 -0
  117. tests/test_studio.py +335 -0
  118. 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,6 @@
1
+ [gd_scene format=3]
2
+
3
+ [ext_resource type="Script" path="res://addons/spawn_point/tools/build.gd" id="1_build"]
4
+
5
+ [node name="Build" type="Node"]
6
+ script = ExtResource("1_build")
@@ -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")
@@ -0,0 +1,6 @@
1
+ [gd_scene format=3]
2
+
3
+ [ext_resource type="Script" path="res://addons/spawn_point/tools/capture.gd" id="1_capture"]
4
+
5
+ [node name="Capture" type="Node"]
6
+ script = ExtResource("1_capture")