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,91 @@
1
+ extends Node
2
+ ## Introspects every component script and writes raw metadata as JSON.
3
+ ## Run: godot4 --headless --path <project> res://addons/spawn_point/tools/dump_manifest.tscn -- <out.json>
4
+ ## The Python side merges this with doc comments into manifest.json.
5
+
6
+ const COMPONENT_DIR := "res://addons/spawn_point/components"
7
+
8
+
9
+ func _ready() -> void:
10
+ var args := OS.get_cmdline_user_args()
11
+ var out_path := args[0] if args.size() > 0 else "user://manifest_raw.json"
12
+ var components := []
13
+ var files := DirAccess.get_files_at(COMPONENT_DIR)
14
+ files.sort()
15
+ for f in files:
16
+ if f.ends_with("_component.gd"):
17
+ components.append(_describe(COMPONENT_DIR + "/" + f))
18
+ var file := FileAccess.open(out_path, FileAccess.WRITE)
19
+ if file == null:
20
+ push_error("cannot write %s" % out_path)
21
+ get_tree().quit(1)
22
+ return
23
+ file.store_string(JSON.stringify({"godot": Engine.get_version_info().string, "components": components}, " ", false))
24
+ file.close()
25
+ get_tree().quit(0)
26
+
27
+
28
+ func _describe(path: String) -> Dictionary:
29
+ var s := load(path) as GDScript
30
+ var consts := s.get_script_constant_map()
31
+ var params := []
32
+ var state := []
33
+ for p in s.get_script_property_list():
34
+ var pname: String = p.name
35
+ if not (p.usage & PROPERTY_USAGE_SCRIPT_VARIABLE) or pname.begins_with("_"):
36
+ continue
37
+ if not (p.usage & PROPERTY_USAGE_EDITOR):
38
+ state.append({"name": pname, "type": type_string(p.type)})
39
+ continue
40
+ params.append({
41
+ "name": p.name,
42
+ "type": type_string(p.type),
43
+ "hint": _hint_name(p.hint),
44
+ "hint_string": p.hint_string,
45
+ "default": s.get_property_default_value(p.name),
46
+ })
47
+ var signals := []
48
+ for sig in s.get_script_signal_list():
49
+ signals.append({"name": sig.name, "args": _args(sig.args)})
50
+ var methods := []
51
+ var internal: Array = Array(consts.get("SP_INTERNAL", []))
52
+ for m in s.get_script_method_list():
53
+ var mname: String = m.name
54
+ if mname.begins_with("_") or mname.begins_with("@") or mname in internal:
55
+ continue
56
+ methods.append({
57
+ "name": mname,
58
+ "args": _args(m.args),
59
+ "default_count": m.default_args.size(),
60
+ "returns": type_string(m.return.type),
61
+ })
62
+ return {
63
+ "name": consts.get("SP_NAME", ""),
64
+ "class": s.get_global_name(),
65
+ "script": path,
66
+ "base": s.get_instance_base_type(),
67
+ "hosts": Array(consts.get("SP_HOSTS", [])),
68
+ "requires": Array(consts.get("SP_REQUIRES", [])),
69
+ "category": consts.get("SP_CATEGORY", ""),
70
+ "entity_params": Array(consts.get("SP_ENTITY_PARAMS", [])),
71
+ "params": params,
72
+ "state": state,
73
+ "signals": signals,
74
+ "methods": methods,
75
+ }
76
+
77
+
78
+ func _args(list: Array) -> Array:
79
+ var out := []
80
+ for a in list:
81
+ out.append({"name": a.name, "type": type_string(a.type) if a.type != TYPE_OBJECT else String(a.class_name) if a.class_name != &"" else "Object"})
82
+ return out
83
+
84
+
85
+ func _hint_name(hint: int) -> String:
86
+ match hint:
87
+ PROPERTY_HINT_RANGE:
88
+ return "range"
89
+ PROPERTY_HINT_ENUM:
90
+ return "enum"
91
+ return ""
@@ -0,0 +1,6 @@
1
+ [gd_scene format=3]
2
+
3
+ [ext_resource type="Script" path="res://addons/spawn_point/tools/dump_manifest.gd" id="1_dump"]
4
+
5
+ [node name="DumpManifest" type="Node"]
6
+ script = ExtResource("1_dump")
@@ -0,0 +1,428 @@
1
+ extends Node
2
+ ## Headless playtest: bots of different skill play the level and report telemetry as JSON.
3
+ ## Run: godot4 --headless --path <project> --fixed-fps 60 res://addons/spawn_point/tools/playtest.tscn \
4
+ ## -- <out.json> <profiles csv> <runs> <seed> <time_limit_s>
5
+ ##
6
+ ## Bots press the same input actions as a human, so coyote time, jump buffering, knockback and
7
+ ## invincibility are exercised for real. Every run is seeded and therefore reproducible.
8
+
9
+ const LEVEL := "res://levels/main.tscn"
10
+
11
+ const PROFILES := {
12
+ "novice": {"reaction": 12, "late": 10, "full_hop": 0.6, "panic": 0.25, "trigger": 0.6, "dodge": 0.2},
13
+ "average": {"reaction": 6, "late": 5, "full_hop": 0.85, "panic": 0.08, "trigger": 0.85, "dodge": 0.55},
14
+ "expert": {"reaction": 2, "late": 1, "full_hop": 1.0, "panic": 0.0, "trigger": 1.0, "dodge": 0.9},
15
+ }
16
+ const MOVE_ACTIONS := [&"move_left", &"move_right", &"move_up", &"move_down"]
17
+ const ALL_ACTIONS := [&"move_left", &"move_right", &"move_up", &"move_down", &"jump", &"dash", &"shoot"]
18
+ const STUCK_FRAMES := 240
19
+ const TARGET_PATIENCE := 20 * 60 # a person moves on after ~20 s of failing at one thing
20
+ const NAV := preload("res://addons/spawn_point/tools/playtest_nav.gd")
21
+
22
+ var _rng := RandomNumberGenerator.new()
23
+ var _p: Dictionary # current profile
24
+ var _frame := 0
25
+ var _queue: Array[Dictionary] = [] # delayed actions: {at, action, hold}
26
+ var _holds := {} # action -> frames left
27
+ var _panic_left := 0
28
+ var _double_used := false
29
+ var _nav: NAV
30
+ var _path: Array[Vector2i] = []
31
+ var _target: Node2D
32
+ var _blocked_targets := {}
33
+ var _unreachable: Array = []
34
+ var _target_since := 0
35
+ var _wants_move := false
36
+
37
+ # Telemetry of the current run.
38
+ var _tile := 32.0
39
+ var _deaths: Array = []
40
+ var _damage: Array = []
41
+ var _missed: Array = []
42
+ var _stuck: Array = []
43
+ var _intended := 0
44
+ var _performed := 0
45
+ var _pending_jump := 0
46
+ var _last_jump_frame := -9999
47
+ var _anchor := Vector2.ZERO
48
+ var _anchor_frame := 0
49
+
50
+
51
+ func _ready() -> void:
52
+ var args := OS.get_cmdline_user_args()
53
+ var out_path := args[0] if args.size() > 0 else "user://playtest.json"
54
+ var profiles := (args[1] if args.size() > 1 else "novice,average,expert").split(",")
55
+ var runs := int(args[2]) if args.size() > 2 else 3
56
+ var base_seed := int(args[3]) if args.size() > 3 else 1
57
+ var limit_s := float(args[4]) if args.size() > 4 else 90.0
58
+ var results := []
59
+ for prof in profiles:
60
+ for r in runs:
61
+ results.append(await _play(prof.strip_edges(), r, base_seed * 1000 + r, limit_s))
62
+ var f := FileAccess.open(out_path, FileAccess.WRITE)
63
+ f.store_string(JSON.stringify({"runs": results}, " "))
64
+ f.close()
65
+ print("SPAWNPOINT_PLAYTEST_OK ", results.size())
66
+ get_tree().quit(0)
67
+
68
+
69
+ # --- one run --------------------------------------------------------------------------------
70
+
71
+ func _play(profile: String, run: int, run_seed: int, limit_s: float) -> Dictionary:
72
+ _p = PROFILES.get(profile, PROFILES["average"])
73
+ seed(run_seed)
74
+ _rng.seed = run_seed
75
+ _reset_bot()
76
+ var level: Node2D = (load(LEVEL) as PackedScene).instantiate()
77
+ GameState.reset_run(int(level.get(&"lives")))
78
+ add_child(level)
79
+ _tile = float(level.get(&"tile_size"))
80
+ await get_tree().physics_frame
81
+ var player := World.player() as CharacterBody2D
82
+ var pickups_total := _count_pickups()
83
+ _setup_nav(level, player)
84
+ _hook(player)
85
+ _anchor = player.global_position
86
+ var limit := int(limit_s * 60.0)
87
+ var outcome := "timeout"
88
+ for i in limit:
89
+ _frame = i
90
+ _drive(player)
91
+ await get_tree().physics_frame
92
+ _observe(player)
93
+ if GameState.phase == GameState.Phase.WON:
94
+ outcome = "won"
95
+ break
96
+ if GameState.phase == GameState.Phase.LOST:
97
+ outcome = "lost"
98
+ break
99
+ var result := {
100
+ "profile": profile, "run": run, "seed": run_seed, "outcome": outcome,
101
+ "time": snappedf(GameState.time, 0.01), "score": GameState.score, "lives_left": GameState.lives,
102
+ "deaths": _deaths, "damage": _damage, "stuck": _stuck, "unreachable": _unreachable,
103
+ "jumps": {"intended": _intended, "performed": _performed, "missed": _missed},
104
+ "pickups": {"total": pickups_total, "collected": pickups_total - _count_pickups()},
105
+ }
106
+ for a in ALL_ACTIONS:
107
+ Input.action_release(a)
108
+ level.queue_free()
109
+ await get_tree().physics_frame
110
+ await get_tree().physics_frame
111
+ Engine.time_scale = 1.0
112
+ return result
113
+
114
+
115
+ func _reset_bot() -> void:
116
+ _queue.clear()
117
+ _holds.clear()
118
+ _panic_left = 0
119
+ _double_used = false
120
+ _deaths = []
121
+ _damage = []
122
+ _missed = []
123
+ _stuck = []
124
+ _intended = 0
125
+ _performed = 0
126
+ _pending_jump = 0
127
+ _last_jump_frame = -9999
128
+ _anchor_frame = 0
129
+ _target = null
130
+
131
+
132
+ func _hook(player: Node) -> void:
133
+ var health := SP.component(player, &"Health") as HealthComponent
134
+ if health:
135
+ health.died.connect(_on_player_died.bind(player))
136
+ health.damaged.connect(_on_player_damaged.bind(player))
137
+ var mv := SP.movement(player)
138
+ if mv and mv.has_signal(&"jumped"):
139
+ mv.jumped.connect(_on_jumped)
140
+ if mv and mv.has_signal(&"landed"):
141
+ mv.landed.connect(func(_s: float) -> void: _double_used = false)
142
+
143
+
144
+ func _on_player_died(player: Node2D) -> void:
145
+ var c := SP.core(player)
146
+ _deaths.append({"x": _tx(player), "y": _ty(player), "cause": c.death_cause if c else "unknown",
147
+ "t": snappedf(GameState.time, 0.01), "after_jump": _frame - _last_jump_frame < 150})
148
+
149
+
150
+ func _on_player_damaged(_amount: int, player: Node2D) -> void:
151
+ var hb := SP.component(player, &"Hurtbox") as HurtboxComponent
152
+ var cause := "unknown"
153
+ if hb and is_instance_valid(hb.last_hit_by) and SP.core(hb.last_hit_by):
154
+ cause = SP.core(hb.last_hit_by).entity_type
155
+ _damage.append({"x": _tx(player), "y": _ty(player), "cause": cause, "t": snappedf(GameState.time, 0.01)})
156
+
157
+
158
+ func _on_jumped() -> void:
159
+ _performed += 1
160
+ _last_jump_frame = _frame
161
+ if _pending_jump > 0:
162
+ _pending_jump = 0
163
+
164
+
165
+ func _tx(n: Node2D) -> int:
166
+ return int(floor(n.global_position.x / _tile))
167
+
168
+
169
+ func _ty(n: Node2D) -> int:
170
+ return int(floor(n.global_position.y / _tile))
171
+
172
+
173
+ func _observe(player: CharacterBody2D) -> void:
174
+ # A ground jump the bot asked for that never happened (e.g. pressed after leaving the ledge
175
+ # and after coyote time ran out).
176
+ if _pending_jump > 0:
177
+ _pending_jump -= 1
178
+ if _pending_jump == 0:
179
+ _missed.append({"x": _tx(player), "y": _ty(player)})
180
+ if not player.visible:
181
+ _anchor_frame = _frame
182
+ _anchor = player.global_position
183
+ return
184
+ if player.global_position.distance_to(_anchor) > 24.0 or not _wants_move:
185
+ _anchor = player.global_position
186
+ _anchor_frame = _frame
187
+ elif _frame - _anchor_frame >= STUCK_FRAMES:
188
+ _stuck.append({"x": _tx(player), "y": _ty(player), "t": snappedf(GameState.time, 0.01)})
189
+ _anchor_frame = _frame
190
+
191
+
192
+ func _count_pickups() -> int:
193
+ var n := 0
194
+ for e in get_tree().get_nodes_in_group(SP.ENTITY_GROUP):
195
+ if SP.component(e, &"Collectible") and not e.is_queued_for_deletion():
196
+ n += 1
197
+ return n
198
+
199
+
200
+ # --- input plumbing -------------------------------------------------------------------------
201
+
202
+ func _press(action: StringName, frames: int) -> void:
203
+ Input.action_press(action)
204
+ _holds[action] = maxi(int(_holds.get(action, 0)), frames)
205
+
206
+
207
+ func _later(action: StringName, hold: int, extra_delay := 0) -> void:
208
+ var delay := int(_p.reaction) + extra_delay
209
+ _queue.append({"at": _frame + delay, "action": action, "hold": hold})
210
+
211
+
212
+ func _tick_inputs() -> void:
213
+ for a in _holds.keys():
214
+ _holds[a] -= 1
215
+ if _holds[a] <= 0:
216
+ Input.action_release(a)
217
+ _holds.erase(a)
218
+ var keep: Array[Dictionary] = []
219
+ for q in _queue:
220
+ if q.at <= _frame:
221
+ _press(q.action, q.hold)
222
+ else:
223
+ keep.append(q)
224
+ _queue = keep
225
+
226
+
227
+ # --- bots -----------------------------------------------------------------------------------
228
+
229
+ func _setup_nav(level: Node2D, player: CharacterBody2D) -> void:
230
+ _nav = NAV.new()
231
+ var tiles := level.get_node_or_null(^"Tiles") as TileMapLayer
232
+ var mv := SP.movement(player)
233
+ var platformer := mv is PlatformerMovementComponent
234
+ var up := 3
235
+ var across := 4
236
+ if platformer:
237
+ var m := mv as PlatformerMovementComponent
238
+ var g: float = ProjectSettings.get_setting("physics/2d/default_gravity", 1600.0) * m.gravity_scale
239
+ var jumps := 1 + m.air_jumps
240
+ var h := m.jump_velocity * m.jump_velocity / (2.0 * g)
241
+ var t_air := m.jump_velocity / g + sqrt(2.0 * h / (g * m.fall_gravity_multiplier))
242
+ up = int(h * jumps / _tile)
243
+ across = maxi(1, int(m.max_speed * t_air * jumps / _tile))
244
+ if tiles:
245
+ _nav.setup(tiles, _tile, platformer, SP.half_size(player).y * 2.0, up, across)
246
+ for e in get_tree().get_nodes_in_group(SP.ENTITY_GROUP):
247
+ # Static things that hurt (spikes): a damaging hitbox on something that never moves.
248
+ if SP.component(e, &"Hitbox") and SP.movement(e) == null and SP.component(e, &"Projectile") == null:
249
+ _nav.mark_hazard((e as Node2D).global_position)
250
+ _path = []
251
+ _blocked_targets.clear()
252
+ _unreachable = []
253
+
254
+
255
+ func _drive(p: CharacterBody2D) -> void:
256
+ _tick_inputs()
257
+ if p == null or not p.visible or _frame < 10:
258
+ return
259
+ if _panic_left > 0:
260
+ _panic_left -= 1
261
+ for a in MOVE_ACTIONS:
262
+ Input.action_release(a)
263
+ return
264
+ if _rng.randf() < float(_p.panic) / 60.0:
265
+ _panic_left = 30
266
+ var mv := SP.movement(p)
267
+ if mv is PlatformerMovementComponent:
268
+ _platformer(p, mv as PlatformerMovementComponent)
269
+ elif mv is TopDownMovementComponent:
270
+ _topdown(p)
271
+
272
+
273
+ ## Picks the next target node (pickup, exit, or enemy), skipping ones known to be unreachable.
274
+ func _pick_target(p: CharacterBody2D) -> Node2D:
275
+ var best: Node2D = null
276
+ var best_d := INF
277
+ var health := SP.component(p, &"Health") as HealthComponent
278
+ var hurt := health != null and health.current < health.max_health
279
+ for e in get_tree().get_nodes_in_group(SP.ENTITY_GROUP):
280
+ var n := e as Node2D
281
+ if n == p or n.is_queued_for_deletion() or _blocked_targets.has(n.get_instance_id()):
282
+ continue
283
+ var weight := INF
284
+ var c := SP.component(n, &"Collectible")
285
+ if c:
286
+ weight = 1.0 if (int(c.get(&"score")) > 0 or hurt) else INF
287
+ elif SP.component(n, &"TriggerZone"):
288
+ weight = 4.0
289
+ elif SP.team_of(n) == SP.TEAM_ENEMY and SP.component(n, &"Hurtbox") and SP.component(n, &"Projectile") == null:
290
+ weight = 2.5
291
+ if weight == INF:
292
+ continue
293
+ var d := n.global_position.distance_to(p.global_position) * weight
294
+ if d < best_d:
295
+ best_d = d
296
+ best = n
297
+ return best
298
+
299
+
300
+ func _give_up(target: Node2D, reason := "no_path") -> void:
301
+ _blocked_targets[target.get_instance_id()] = true
302
+ var c := Vector2i(floori(target.global_position.x / _tile), floori(target.global_position.y / _tile))
303
+ var entry := {"x": c.x, "y": c.y, "entity": SP.core(target).entity_type if SP.core(target) else "?",
304
+ "reason": reason}
305
+ if entry not in _unreachable:
306
+ _unreachable.append(entry)
307
+ _path = []
308
+
309
+
310
+ func _platformer(p: CharacterBody2D, mv: PlatformerMovementComponent) -> void:
311
+ var half := SP.half_size(p)
312
+ var cur := _nav.cell_of(p.global_position, half.y)
313
+ var target := _pick_target(p)
314
+ if target != _target:
315
+ _target = target
316
+ _target_since = _frame
317
+ _path = []
318
+ if target and _frame - _target_since > TARGET_PATIENCE:
319
+ _give_up(target, "too_hard")
320
+ return
321
+ _wants_move = true
322
+ if p.is_on_floor() and target and (_path.is_empty() or _frame % 20 == 0 or cur not in _path):
323
+ if _nav.standable(cur):
324
+ _path = _nav.path(cur, _nav.reaches(target.global_position))
325
+ if _path.is_empty():
326
+ _give_up(target)
327
+ return
328
+ if OS.has_environment("SPAWNPOINT_PLAYTEST_DEBUG") and _frame % 120 == 0:
329
+ print("f=%d cur=%s target=%s path=%s" % [_frame, cur, target.global_position.round() if target else null, _path.slice(0, 4)])
330
+ # Advance along the path.
331
+ while _path.size() > 1 and _path[0] != cur and _path[1] == cur:
332
+ _path.pop_front()
333
+ var idx := _path.find(cur)
334
+ if idx > 0:
335
+ _path = _path.slice(idx)
336
+ var next: Vector2i = _path[1] if _path.size() > 1 else cur
337
+ var want_x: float = _nav.center(next).x if _path.size() > 1 else (target.global_position.x if target else p.global_position.x + 64.0)
338
+ var dx := want_x - p.global_position.x
339
+ var dir := 0
340
+ if absf(dx) > 4.0:
341
+ dir = 1 if dx > 0.0 else -1
342
+ Input.action_release(&"move_left")
343
+ Input.action_release(&"move_right")
344
+ if dir != 0:
345
+ Input.action_press(&"move_right" if dir > 0 else &"move_left")
346
+
347
+ var enemy_ahead := false
348
+ for e in get_tree().get_nodes_in_group(&"team_enemy"):
349
+ var d := (e as Node2D).global_position - p.global_position
350
+ if SP.component(e, &"Hurtbox") and d.x * (dir if dir != 0 else 1) > 0 and absf(d.x) < 70 and absf(d.y) < 40:
351
+ enemy_ahead = true
352
+ var step := next - cur
353
+ var needs_jump := _path.size() > 1 and (step.y < 0 or absi(step.x) > 1)
354
+ var at_edge := dir != 0 and not _nav.standable(cur + Vector2i(dir, 0))
355
+ var target_above := _path.size() <= 1 and target != null and target.global_position.y < p.global_position.y - half.y
356
+ var busy := _pending_jump > 0 or _queue.any(func(q: Dictionary) -> bool: return q.action == &"jump")
357
+ if p.is_on_floor() and not busy and ((needs_jump and (absi(step.x) <= 1 or at_edge)) or enemy_ahead or target_above):
358
+ # People hold the button when they need height; short hops happen on flat ground.
359
+ var hold := 18 if (step.y < 0 or target_above or _rng.randf() < float(_p.full_hop)) else 4
360
+ _later(&"jump", hold, _rng.randi_range(0, int(_p.late)))
361
+ _intended += 1
362
+ _pending_jump = int(_p.reaction) + int(_p.late) + 14
363
+ elif not p.is_on_floor() and p.velocity.y > 40 and mv.air_jumps > 0 and not _double_used \
364
+ and (step.y < 0 or not _ray(p.get_world_2d().direct_space_state, p, p.global_position, p.global_position + Vector2(0, 200))):
365
+ _double_used = true
366
+ _later(&"jump", 16)
367
+ if SP.component(p, &"Weapon") and enemy_ahead and _rng.randf() < float(_p.trigger):
368
+ _press(&"shoot", 2)
369
+
370
+
371
+ func _topdown(p: CharacterBody2D) -> void:
372
+ var aim := SP.component(p, &"Aim")
373
+ if aim and aim.get(&"mode") == "mouse":
374
+ aim.set(&"mode", "nearest_enemy") # bots have no mouse
375
+ var pos := p.global_position
376
+ var cur := _nav.cell_of(pos, 0.0)
377
+ var target := _pick_target(p)
378
+ if target != _target:
379
+ _target = target
380
+ _target_since = _frame
381
+ _path = []
382
+ var steer := Vector2.ZERO
383
+ if target:
384
+ var is_enemy := SP.team_of(target) == SP.TEAM_ENEMY
385
+ if _path.is_empty() or _frame % 30 == 0:
386
+ _path = _nav.path(cur, _nav.reaches(target.global_position))
387
+ if _path.is_empty() and not is_enemy:
388
+ _give_up(target)
389
+ var idx := _path.find(cur)
390
+ if idx > 0:
391
+ _path = _path.slice(idx)
392
+ var goal := _nav.center(_path[1]) if _path.size() > 1 else target.global_position
393
+ var to := goal - pos
394
+ var keep := 150.0 if is_enemy and _path.size() <= 6 else 0.0
395
+ var dist := target.global_position.distance_to(pos)
396
+ if dist > keep:
397
+ steer += to.normalized()
398
+ elif keep > 0.0:
399
+ steer -= (target.global_position - pos).normalized() * 0.6
400
+ var bullet_close := false
401
+ for e in get_tree().get_nodes_in_group(&"team_enemy"):
402
+ var n := e as Node2D
403
+ var d := pos - n.global_position
404
+ var is_bullet := SP.component(n, &"Projectile") != null
405
+ var radius := 110.0 if is_bullet else 80.0
406
+ if d.length() < radius and d.length() > 0.1:
407
+ steer += d.normalized() * (1.0 - d.length() / radius) * (1.8 if is_bullet else 1.2)
408
+ if is_bullet and d.length() < 70.0:
409
+ bullet_close = true
410
+ _wants_move = steer.length() > 0.5
411
+ for a in MOVE_ACTIONS:
412
+ Input.action_release(a)
413
+ if steer.x < -0.25:
414
+ Input.action_press(&"move_left")
415
+ elif steer.x > 0.25:
416
+ Input.action_press(&"move_right")
417
+ if steer.y < -0.25:
418
+ Input.action_press(&"move_up")
419
+ elif steer.y > 0.25:
420
+ Input.action_press(&"move_down")
421
+ if SP.component(p, &"Weapon") and _rng.randf() < float(_p.trigger):
422
+ _press(&"shoot", 2)
423
+ if bullet_close and _rng.randf() < float(_p.dodge) * 0.1:
424
+ _later(&"dash", 3)
425
+
426
+
427
+ func _ray(space: PhysicsDirectSpaceState2D, p: CharacterBody2D, from: Vector2, to: Vector2) -> bool:
428
+ return not space.intersect_ray(PhysicsRayQueryParameters2D.create(from, to, SP.LAYER_WORLD, [p.get_rid()])).is_empty()
@@ -0,0 +1,6 @@
1
+ [gd_scene format=3]
2
+
3
+ [ext_resource type="Script" path="res://addons/spawn_point/tools/playtest.gd" id="1_playtest"]
4
+
5
+ [node name="Playtest" type="Node"]
6
+ script = ExtResource("1_playtest")
@@ -0,0 +1,152 @@
1
+ extends RefCounted
2
+ ## Grid navigation for playtest bots, built from the level's TileMapLayer.
3
+ ## Platformer: graph of standable cells linked by walks, drops and jumps within the player's jump
4
+ ## reach. Top-down: 4-neighbour grid of free cells. Both use BFS (levels are small).
5
+
6
+ const DOWN := Vector2i(0, 1)
7
+
8
+ var platformer := true
9
+ var ts := 32.0
10
+ var cells := {} # Vector2i -> 1 solid, 2 one-way
11
+ var hazards := {} # Vector2i -> true: static damaging entities (spikes) — never stand there
12
+ var rect := Rect2i()
13
+ var ph := 1 # player height in tiles
14
+ var up := 3
15
+ var across := 4
16
+
17
+
18
+ func setup(tiles: TileMapLayer, tile_size: float, is_platformer: bool, player_h: float, jump_up: int, jump_across: int) -> void:
19
+ ts = tile_size
20
+ platformer = is_platformer
21
+ ph = maxi(1, ceili(player_h / ts))
22
+ up = jump_up
23
+ across = jump_across
24
+ rect = tiles.get_used_rect()
25
+ for c in tiles.get_used_cells():
26
+ cells[c] = 2 if tiles.get_cell_atlas_coords(c) == Vector2i(2, 0) else 1
27
+
28
+
29
+ func blocked(c: Vector2i) -> bool:
30
+ return cells.get(c, 0) == 1
31
+
32
+
33
+ func floor_at(c: Vector2i) -> bool:
34
+ return cells.has(c)
35
+
36
+
37
+ func inside(c: Vector2i) -> bool:
38
+ return c.x >= rect.position.x and c.x < rect.end.x and c.y >= rect.position.y - 4 and c.y < rect.end.y
39
+
40
+
41
+ func standable(c: Vector2i) -> bool:
42
+ if not inside(c) or not floor_at(c + DOWN) or hazards.has(c):
43
+ return false
44
+ for k in ph:
45
+ if blocked(c - Vector2i(0, k)):
46
+ return false
47
+ return true
48
+
49
+
50
+ func open_cell(c: Vector2i) -> bool:
51
+ return inside(c) and not blocked(c) and (platformer or not hazards.has(c))
52
+
53
+
54
+ func mark_hazard(pos: Vector2) -> void:
55
+ hazards[Vector2i(floori(pos.x / ts), floori(pos.y / ts))] = true
56
+
57
+
58
+ ## Cell containing the feet (platformer) or the center (top-down) of a body.
59
+ func cell_of(pos: Vector2, half_h: float) -> Vector2i:
60
+ if platformer:
61
+ return Vector2i(floori(pos.x / ts), floori((pos.y + half_h - 2.0) / ts))
62
+ return Vector2i(floori(pos.x / ts), floori(pos.y / ts))
63
+
64
+
65
+ func center(c: Vector2i) -> Vector2:
66
+ return Vector2((c.x + 0.5) * ts, (c.y + 0.5) * ts)
67
+
68
+
69
+ func _drop(c: Vector2i) -> Vector2i:
70
+ var d := c
71
+ while inside(d) and not standable(d):
72
+ if blocked(d):
73
+ return Vector2i(-99999, 0)
74
+ d += DOWN
75
+ return d if inside(d) else Vector2i(-99999, 0)
76
+
77
+
78
+ func neighbors(c: Vector2i) -> Array[Vector2i]:
79
+ var out: Array[Vector2i] = []
80
+ if not platformer:
81
+ for d in [Vector2i.LEFT, Vector2i.RIGHT, Vector2i.UP, Vector2i.DOWN]:
82
+ if open_cell(c + d):
83
+ out.append(c + d)
84
+ return out
85
+ for dx in [-1, 1]:
86
+ var side := c + Vector2i(dx, 0)
87
+ if standable(side):
88
+ out.append(side)
89
+ elif open_cell(side):
90
+ var land := _drop(side)
91
+ if land.x != -99999:
92
+ out.append(land)
93
+ for dx in range(-across, across + 1):
94
+ for dy in range(-up, up + 3):
95
+ if absi(dx) <= 1 and dy == 0:
96
+ continue
97
+ var t := c + Vector2i(dx, dy)
98
+ if dy > 0 and absi(dx) > across - 1 + dy:
99
+ continue
100
+ if standable(t) and not blocked(c - Vector2i(0, ph)):
101
+ out.append(t)
102
+ return out
103
+
104
+
105
+ ## Cheapest path (Dijkstra) from `from` to the first cell satisfying `goal`; walking is cheap,
106
+ ## jumps cost more the longer they are, so bots move like people instead of leaping everywhere.
107
+ ## Returns the path (both ends included) or [].
108
+ func path(from: Vector2i, goal: Callable, max_nodes := 6000) -> Array[Vector2i]:
109
+ var prev := {from: from}
110
+ var cost := {from: 0.0}
111
+ var frontier: Array = [[0.0, from]] # sorted by cost
112
+ var done := {}
113
+ var expanded := 0
114
+ while not frontier.is_empty() and expanded < max_nodes:
115
+ var item: Array = frontier.pop_front()
116
+ var c: Vector2i = item[1]
117
+ if done.has(c):
118
+ continue
119
+ done[c] = true
120
+ expanded += 1
121
+ if goal.call(c):
122
+ var out: Array[Vector2i] = [c]
123
+ while c != from:
124
+ c = prev[c]
125
+ out.push_front(c)
126
+ return out
127
+ for n in neighbors(c):
128
+ var d: Vector2i = n - c
129
+ var step := 1.0
130
+ if platformer and (absi(d.x) > 1 or d.y < 0):
131
+ step = 2.0 + absi(d.x) * 0.6 + maxi(0, -d.y) * 0.5
132
+ elif d.y > 0:
133
+ step = 1.0 + d.y * 0.2
134
+ var nc: float = cost[c] + step
135
+ if nc < cost.get(n, INF):
136
+ cost[n] = nc
137
+ prev[n] = c
138
+ var i := frontier.bsearch_custom([nc, n], func(a: Array, b: Array) -> bool: return a[0] < b[0])
139
+ frontier.insert(i, [nc, n])
140
+ return []
141
+
142
+
143
+ ## Goal predicate: a stand cell from which a target at `pos` can be touched.
144
+ func reaches(pos: Vector2) -> Callable:
145
+ var tc := Vector2i(floori(pos.x / ts), floori(pos.y / ts))
146
+ if not platformer:
147
+ return func(c: Vector2i) -> bool: return absi(c.x - tc.x) <= 1 and absi(c.y - tc.y) <= 1
148
+ # Pickups floating over gaps are grabbed mid-jump, so the horizontal reach grows with height.
149
+ var reach_x := 1 if tc.y >= rect.end.y - 3 else maxi(1, across / 2)
150
+ return func(c: Vector2i) -> bool:
151
+ var dx := absi(c.x - tc.x)
152
+ return (dx <= 1 or (dx <= reach_x and tc.y < c.y)) and tc.y <= c.y + 1 and tc.y > c.y - up - ph