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,242 @@
1
+ {
2
+ "spawnpoint": 1,
3
+ "name": "Pixel Arena",
4
+ "genre": "topdown",
5
+ "settings": {
6
+ "tile_size": 32,
7
+ "lives": 3,
8
+ "seed": 21,
9
+ "zoom": 1.5
10
+ },
11
+ "entities": {
12
+ "Player": {
13
+ "components": {
14
+ "TopDownMovement": {
15
+ "max_speed": 250,
16
+ "dash_speed": 720
17
+ },
18
+ "PlayerInput": {},
19
+ "Aim": {
20
+ "mode": "mouse"
21
+ },
22
+ "Weapon": {
23
+ "projectile": "Bullet",
24
+ "fire_rate": 9,
25
+ "spread_deg": 5
26
+ },
27
+ "Health": {
28
+ "max_health": 5,
29
+ "invincibility_time": 0.8
30
+ },
31
+ "Hurtbox": {}
32
+ },
33
+ "sprite": {
34
+ "generate": {
35
+ "kind": "hero",
36
+ "color": "#29adff",
37
+ "features": [
38
+ "helmet"
39
+ ]
40
+ }
41
+ }
42
+ },
43
+ "Bullet": {
44
+ "body": "area",
45
+ "components": {
46
+ "Projectile": {
47
+ "speed": 760,
48
+ "lifetime": 1.0
49
+ },
50
+ "Hitbox": {
51
+ "damage": 1,
52
+ "knockback": 160
53
+ }
54
+ },
55
+ "sprite": {
56
+ "generate": {
57
+ "kind": "bullet",
58
+ "color": "#fff1e8"
59
+ }
60
+ }
61
+ },
62
+ "Chaser": {
63
+ "components": {
64
+ "TopDownMovement": {
65
+ "max_speed": 130
66
+ },
67
+ "ChaseAI": {
68
+ "detect_radius": 380
69
+ },
70
+ "Health": {
71
+ "max_health": 2,
72
+ "invincibility_time": 0.05
73
+ },
74
+ "Hurtbox": {
75
+ "size_scale": 1.0
76
+ },
77
+ "Hitbox": {
78
+ "damage": 1,
79
+ "knockback": 300
80
+ }
81
+ },
82
+ "sprite": {
83
+ "generate": {
84
+ "kind": "bug",
85
+ "color": "#ff004d"
86
+ }
87
+ }
88
+ },
89
+ "Shooter": {
90
+ "team": "enemy",
91
+ "components": {
92
+ "TopDownMovement": {
93
+ "max_speed": 90
94
+ },
95
+ "ChaseAI": {
96
+ "detect_radius": 420,
97
+ "keep_distance": 220,
98
+ "fire_range": 380
99
+ },
100
+ "Weapon": {
101
+ "projectile": "EnemyBullet",
102
+ "fire_rate": 1.3,
103
+ "spread_deg": 8,
104
+ "muzzle_distance": 22
105
+ },
106
+ "Health": {
107
+ "max_health": 3,
108
+ "invincibility_time": 0.05
109
+ },
110
+ "Hurtbox": {
111
+ "size_scale": 1.0
112
+ }
113
+ },
114
+ "sprite": {
115
+ "generate": {
116
+ "kind": "robot",
117
+ "color": "#ff77a8"
118
+ }
119
+ }
120
+ },
121
+ "EnemyBullet": {
122
+ "body": "area",
123
+ "team": "enemy",
124
+ "components": {
125
+ "Projectile": {
126
+ "speed": 300,
127
+ "lifetime": 3.0
128
+ },
129
+ "Hitbox": {
130
+ "damage": 1,
131
+ "knockback": 200
132
+ }
133
+ },
134
+ "sprite": {
135
+ "generate": {
136
+ "kind": "bullet",
137
+ "color": "#ff77a8"
138
+ }
139
+ }
140
+ },
141
+ "Portal": {
142
+ "body": "area",
143
+ "components": {
144
+ "Spawner": {
145
+ "entity": "Chaser",
146
+ "interval": 4.0,
147
+ "start_delay": 3.0,
148
+ "max_alive": 3,
149
+ "total": 6
150
+ }
151
+ },
152
+ "juice": "none",
153
+ "sprite": {
154
+ "generate": {
155
+ "kind": "portal",
156
+ "color": "#83769c"
157
+ }
158
+ }
159
+ },
160
+ "HealthPack": {
161
+ "body": "area",
162
+ "components": {
163
+ "Collectible": {
164
+ "kind": "heart",
165
+ "score": 0,
166
+ "heal": 2
167
+ }
168
+ },
169
+ "sprite": {
170
+ "generate": {
171
+ "kind": "heart",
172
+ "color": "#00e436"
173
+ }
174
+ }
175
+ }
176
+ },
177
+ "level": {
178
+ "map": [
179
+ "########################################",
180
+ "#......................................#",
181
+ "#..O...................................#",
182
+ "#...................X..................#",
183
+ "#....Z............####............Z....#",
184
+ "#......................................#",
185
+ "#.......##....................##.......#",
186
+ "#.......##....................##.......#",
187
+ "#...................+..................#",
188
+ "#......................................#",
189
+ "#......................................#",
190
+ "#...#..............................#...#",
191
+ "#...#.......X.......P.......X......#...#",
192
+ "#...#..............................#...#",
193
+ "#......................................#",
194
+ "#......................................#",
195
+ "#.......##..........+.........##.......#",
196
+ "#.......##....................##.......#",
197
+ "#......................................#",
198
+ "#....Z............####............Z....#",
199
+ "#......................................#",
200
+ "#...................................O..#",
201
+ "#......................................#",
202
+ "########################################"
203
+ ],
204
+ "legend": {
205
+ "#": "solid",
206
+ "P": "Player",
207
+ "Z": "Chaser",
208
+ "X": "Shooter",
209
+ "O": "Portal",
210
+ "+": "HealthPack"
211
+ }
212
+ },
213
+ "rules": [
214
+ {
215
+ "on": "Game.ready",
216
+ "do": [
217
+ "message('Score 200 to clear the arena!')"
218
+ ]
219
+ },
220
+ {
221
+ "on": "Chaser.died",
222
+ "do": [
223
+ "add_score(10)"
224
+ ]
225
+ },
226
+ {
227
+ "on": "Shooter.died",
228
+ "do": [
229
+ "add_score(25)",
230
+ "shake(0.3)"
231
+ ]
232
+ },
233
+ {
234
+ "on": "GameState.score_changed",
235
+ "if": "score >= 200",
236
+ "do": [
237
+ "message('Arena cleared!')",
238
+ "win()"
239
+ ]
240
+ }
241
+ ]
242
+ }
@@ -0,0 +1,221 @@
1
+ {
2
+ "spawnpoint": 1,
3
+ "name": "Pixel Hopper",
4
+ "genre": "platformer",
5
+ "settings": {
6
+ "tile_size": 32,
7
+ "lives": 3,
8
+ "seed": 7,
9
+ "zoom": 1.5
10
+ },
11
+ "entities": {
12
+ "Player": {
13
+ "components": {
14
+ "PlatformerMovement": {
15
+ "air_jumps": 1,
16
+ "dash_speed": 620
17
+ },
18
+ "PlayerInput": {},
19
+ "Health": {
20
+ "max_health": 3
21
+ },
22
+ "Hurtbox": {},
23
+ "Stomp": {}
24
+ },
25
+ "sprite": {
26
+ "generate": {
27
+ "kind": "hero",
28
+ "color": "#29adff",
29
+ "features": [
30
+ "mask"
31
+ ]
32
+ }
33
+ }
34
+ },
35
+ "Slime": {
36
+ "components": {
37
+ "PlatformerMovement": {
38
+ "max_speed": 60
39
+ },
40
+ "PatrolAI": {},
41
+ "Health": {
42
+ "max_health": 1
43
+ },
44
+ "Hurtbox": {
45
+ "size_scale": 1.0
46
+ },
47
+ "Hitbox": {
48
+ "damage": 1
49
+ }
50
+ },
51
+ "sprite": {
52
+ "generate": {
53
+ "kind": "slime",
54
+ "color": "#ff004d"
55
+ }
56
+ }
57
+ },
58
+ "Frog": {
59
+ "team": "enemy",
60
+ "components": {
61
+ "PlatformerMovement": {
62
+ "max_speed": 90,
63
+ "jump_velocity": 480
64
+ },
65
+ "PatrolAI": {
66
+ "jump_interval": 1.4
67
+ },
68
+ "Health": {
69
+ "max_health": 2
70
+ },
71
+ "Hurtbox": {
72
+ "size_scale": 1.0
73
+ },
74
+ "Hitbox": {
75
+ "damage": 1
76
+ }
77
+ },
78
+ "sprite": {
79
+ "generate": {
80
+ "kind": "hopper",
81
+ "color": "#00e436"
82
+ }
83
+ }
84
+ },
85
+ "Spikes": {
86
+ "body": "area",
87
+ "team": "enemy",
88
+ "components": {
89
+ "Hitbox": {
90
+ "damage": 1,
91
+ "knockback": 420
92
+ }
93
+ },
94
+ "juice": "none",
95
+ "sprite": {
96
+ "generate": {
97
+ "kind": "spikes",
98
+ "color": "#c2c3c7"
99
+ }
100
+ }
101
+ },
102
+ "Coin": {
103
+ "body": "area",
104
+ "components": {
105
+ "Collectible": {
106
+ "score": 1
107
+ }
108
+ },
109
+ "sprite": {
110
+ "generate": {
111
+ "kind": "coin",
112
+ "color": "#ffec27"
113
+ }
114
+ }
115
+ },
116
+ "Heart": {
117
+ "body": "area",
118
+ "components": {
119
+ "Collectible": {
120
+ "kind": "heart",
121
+ "score": 0,
122
+ "heal": 1
123
+ }
124
+ },
125
+ "sprite": {
126
+ "generate": {
127
+ "kind": "heart",
128
+ "color": "#ff77a8"
129
+ }
130
+ }
131
+ },
132
+ "Door": {
133
+ "body": "static",
134
+ "components": {
135
+ "Gate": {}
136
+ },
137
+ "sprite": {
138
+ "generate": {
139
+ "kind": "door",
140
+ "color": "#ab5236"
141
+ }
142
+ }
143
+ },
144
+ "Exit": {
145
+ "body": "area",
146
+ "components": {
147
+ "TriggerZone": {}
148
+ },
149
+ "sprite": {
150
+ "generate": {
151
+ "kind": "exit",
152
+ "color": "#00e436"
153
+ }
154
+ }
155
+ }
156
+ },
157
+ "level": {
158
+ "map": [
159
+ "....................................................",
160
+ "....................................................",
161
+ "....................................................",
162
+ "....................................................",
163
+ "............................................C.......",
164
+ "............CC.............................===......",
165
+ "...........====.....................................",
166
+ ".............................CC........CC...........",
167
+ ".......CC...............C...====......====..........",
168
+ "......====.........C................................",
169
+ ".......................###..........................",
170
+ "..P............S.......###.S...^^...CS....F.H...D.E.",
171
+ "##################...############..#################",
172
+ "##################...############..#################"
173
+ ],
174
+ "legend": {
175
+ "#": "solid",
176
+ "=": "oneway",
177
+ "P": "Player",
178
+ "S": "Slime",
179
+ "F": "Frog",
180
+ "^": "Spikes",
181
+ "C": "Coin",
182
+ "H": "Heart",
183
+ "D": "Door",
184
+ "E": "Exit"
185
+ }
186
+ },
187
+ "rules": [
188
+ {
189
+ "on": "Game.ready",
190
+ "do": [
191
+ "message('Collect every coin to open the door!')"
192
+ ]
193
+ },
194
+ {
195
+ "on": "Coin.collected",
196
+ "if": "count('Coin') == 0",
197
+ "do": [
198
+ "Door.open()",
199
+ "message('The door is open!')"
200
+ ]
201
+ },
202
+ {
203
+ "on": "Slime.died",
204
+ "do": [
205
+ "add_score(5)"
206
+ ]
207
+ },
208
+ {
209
+ "on": "Frog.died",
210
+ "do": [
211
+ "add_score(10)"
212
+ ]
213
+ },
214
+ {
215
+ "on": "Exit.triggered",
216
+ "do": [
217
+ "win()"
218
+ ]
219
+ }
220
+ ]
221
+ }
@@ -0,0 +1,63 @@
1
+ class_name AimComponent
2
+ extends Node
3
+ ## Points the entity's Weapon (and visual) at the mouse, the nearest enemy, or the movement
4
+ ## direction.
5
+
6
+ const SP_NAME := "Aim"
7
+ const SP_HOSTS: Array[String] = []
8
+ const SP_REQUIRES: Array[String] = ["Weapon"]
9
+ const SP_INTERNAL: Array[String] = ["aim_direction"]
10
+ const SP_CATEGORY := "controller"
11
+
12
+ ## "mouse", "nearest_enemy" or "movement".
13
+ @export_enum("mouse", "nearest_enemy", "movement") var mode: String = "mouse"
14
+ ## Rotate the visual to face the aim direction (top-down games).
15
+ @export var rotate_visual := true
16
+ ## Max distance for nearest_enemy targeting (px).
17
+ @export_range(0.0, 5000.0, 1.0) var max_range: float = 600.0
18
+
19
+ var _entity: Node2D
20
+
21
+
22
+ func _ready() -> void:
23
+ _entity = SP.entity_of(get_parent()) as Node2D
24
+ process_physics_priority = -5
25
+
26
+
27
+ func _physics_process(_delta: float) -> void:
28
+ var dir := aim_direction()
29
+ var weapon := SP.component(_entity, &"Weapon") as WeaponComponent
30
+ if weapon:
31
+ weapon.aim_direction = dir
32
+ if rotate_visual and dir != Vector2.ZERO:
33
+ SP.face(_entity, dir, true)
34
+
35
+
36
+ ## Current aim direction (normalized, or zero if there is nothing to aim at).
37
+ func aim_direction() -> Vector2:
38
+ match mode:
39
+ "mouse":
40
+ var vp := _entity.get_viewport()
41
+ if vp:
42
+ return (_entity.get_global_mouse_position() - _entity.global_position).normalized()
43
+ "nearest_enemy":
44
+ var best: Node2D = null
45
+ var best_d := max_range
46
+ var my_team := SP.team_of(_entity)
47
+ for e in _entity.get_tree().get_nodes_in_group(SP.ENTITY_GROUP):
48
+ var n := e as Node2D
49
+ if n == null or n == _entity or SP.team_of(n) == my_team or SP.team_of(n) == SP.TEAM_NEUTRAL:
50
+ continue
51
+ if SP.component(n, &"Hurtbox") == null:
52
+ continue
53
+ var d := n.global_position.distance_to(_entity.global_position)
54
+ if d < best_d:
55
+ best_d = d
56
+ best = n
57
+ if best:
58
+ return (best.global_position - _entity.global_position).normalized()
59
+ "movement":
60
+ var mv := SP.movement(_entity)
61
+ if mv is TopDownMovementComponent:
62
+ return (mv as TopDownMovementComponent).facing_dir
63
+ return Vector2.ZERO
@@ -0,0 +1,66 @@
1
+ class_name AnimatorComponent
2
+ extends Node
3
+ ## Plays the entity sprite's animations from what the entity is doing: idle, run, jump, fall and
4
+ ## hurt. Run speed follows movement speed. Attached automatically to entities that have a sprite.
5
+
6
+ const SP_NAME := "Animator"
7
+ const SP_HOSTS: Array[String] = []
8
+ const SP_REQUIRES: Array[String] = []
9
+ const SP_CATEGORY := "juice"
10
+
11
+ ## Seconds the hurt animation is shown after taking damage.
12
+ @export_range(0.0, 2.0, 0.01) var hurt_time: float = 0.25
13
+ ## Speed (px/s) above which the entity counts as moving.
14
+ @export_range(0.0, 200.0, 1.0) var move_threshold: float = 15.0
15
+
16
+ ## Animation currently requested (may fall back to "idle" if the sprite lacks it).
17
+ var state := "idle"
18
+
19
+ var _entity: Node2D
20
+ var _sprite: AnimatedSprite2D
21
+ var _hurt_left := 0.0
22
+
23
+
24
+ func _ready() -> void:
25
+ _entity = SP.entity_of(get_parent()) as Node2D
26
+ _sprite = _entity.get_node_or_null(^"Visual/Shape/Sprite") as AnimatedSprite2D
27
+ var health := SP.component(_entity, &"Health") as HealthComponent
28
+ if health:
29
+ health.damaged.connect(func(_a: int) -> void: _hurt_left = hurt_time)
30
+
31
+
32
+ func _process(delta: float) -> void:
33
+ if _sprite == null:
34
+ return
35
+ _hurt_left -= delta
36
+ state = pick_state()
37
+ var anim := state if _sprite.sprite_frames.has_animation(state) else &"idle"
38
+ if _sprite.animation != anim or not _sprite.is_playing():
39
+ _sprite.play(anim)
40
+ if state == "run":
41
+ var mv := SP.movement(_entity)
42
+ var top: float = mv.max_speed if mv else 200.0
43
+ _sprite.speed_scale = clampf(_speed() / maxf(top, 1.0) * 1.2, 0.5, 1.6)
44
+ else:
45
+ _sprite.speed_scale = 1.0
46
+
47
+
48
+ ## The state the entity is in right now.
49
+ func pick_state() -> String:
50
+ if _hurt_left > 0.0:
51
+ return "hurt"
52
+ var body := _entity as CharacterBody2D
53
+ if body == null:
54
+ return "idle"
55
+ if SP.movement(_entity) is PlatformerMovementComponent and not body.is_on_floor():
56
+ return "jump" if body.velocity.y < 0.0 else "fall"
57
+ return "run" if _speed() > move_threshold else "idle"
58
+
59
+
60
+ func _speed() -> float:
61
+ var body := _entity as CharacterBody2D
62
+ if body == null:
63
+ return 0.0
64
+ if SP.movement(_entity) is PlatformerMovementComponent:
65
+ return absf(body.velocity.x)
66
+ return body.velocity.length()
@@ -0,0 +1,107 @@
1
+ class_name ChaseAIComponent
2
+ extends Node
3
+ ## Enemy brain: wanders until the player comes close, then chases (and shoots, if the entity
4
+ ## has a Weapon and a clear line of sight). Works top-down; in platformers it only moves
5
+ ## horizontally.
6
+
7
+ const SP_NAME := "ChaseAI"
8
+ const SP_HOSTS: Array[String] = ["CharacterBody2D"]
9
+ const SP_REQUIRES: Array[String] = []
10
+ const SP_CATEGORY := "controller"
11
+
12
+ enum State { WANDER, CHASE }
13
+
14
+ ## Distance at which the player is noticed (px).
15
+ @export_range(0.0, 5000.0, 1.0) var detect_radius: float = 300.0
16
+ ## Distance at which the chase is abandoned (px).
17
+ @export_range(0.0, 5000.0, 1.0) var lose_radius: float = 480.0
18
+ ## Stop approaching inside this distance (ranged enemies keep their distance).
19
+ @export_range(0.0, 2000.0, 1.0) var keep_distance: float = 0.0
20
+ ## Weapon fires when the player is within this distance (px).
21
+ @export_range(0.0, 5000.0, 1.0) var fire_range: float = 380.0
22
+ ## Wander around while idle.
23
+ @export var wander := true
24
+ ## Fraction of max speed used while wandering.
25
+ @export_range(0.0, 1.0, 0.05) var wander_speed: float = 0.35
26
+ ## Require an unobstructed line to the player to chase/shoot.
27
+ @export var needs_line_of_sight := true
28
+
29
+ var state := State.WANDER
30
+
31
+ var _body: CharacterBody2D
32
+ var _wander_dir := Vector2.ZERO
33
+ var _wander_timer := 0.0
34
+
35
+
36
+ func _ready() -> void:
37
+ _body = get_parent() as CharacterBody2D
38
+ process_physics_priority = -10
39
+
40
+
41
+ func _physics_process(delta: float) -> void:
42
+ var mv := SP.movement(_body)
43
+ var weapon := SP.component(_body, &"Weapon") as WeaponComponent
44
+ var player := World.player()
45
+ var to_player := Vector2.ZERO
46
+ var dist := INF
47
+ if player and player.visible and GameState.is_playing():
48
+ to_player = player.global_position - _body.global_position
49
+ dist = to_player.length()
50
+
51
+ match state:
52
+ State.WANDER:
53
+ if dist < detect_radius and _can_see(player):
54
+ state = State.CHASE
55
+ State.CHASE:
56
+ if dist > lose_radius or player == null or not player.visible:
57
+ state = State.WANDER
58
+
59
+ var desired := Vector2.ZERO
60
+ if state == State.CHASE:
61
+ if dist > keep_distance:
62
+ desired = to_player.normalized()
63
+ elif keep_distance > 0.0 and dist < keep_distance * 0.7:
64
+ desired = -to_player.normalized()
65
+ elif wander:
66
+ _wander_timer -= delta
67
+ if _wander_timer <= 0.0:
68
+ _wander_timer = randf_range(1.0, 2.5)
69
+ _wander_dir = Vector2.ZERO if randf() < 0.3 else Vector2.from_angle(randf() * TAU)
70
+ desired = _wander_dir * wander_speed
71
+ desired += _separation()
72
+
73
+ if mv is PlatformerMovementComponent:
74
+ mv.move_vector = Vector2(signf(desired.x) if absf(desired.x) > 0.1 else 0.0, 0)
75
+ elif mv:
76
+ mv.move_vector = desired.limit_length(1.0)
77
+
78
+ if weapon:
79
+ var shooting := state == State.CHASE and dist <= fire_range and _can_see(player)
80
+ weapon.trigger_held = shooting
81
+ if shooting:
82
+ weapon.aim_direction = to_player.normalized()
83
+
84
+
85
+ func _can_see(target: Node2D) -> bool:
86
+ if target == null:
87
+ return false
88
+ if not needs_line_of_sight:
89
+ return true
90
+ var q := PhysicsRayQueryParameters2D.create(_body.global_position, target.global_position, SP.LAYER_WORLD, [_body.get_rid()])
91
+ return _body.get_world_2d().direct_space_state.intersect_ray(q).is_empty()
92
+
93
+
94
+ # Keeps a crowd of chasers from collapsing into one blob.
95
+ func _separation() -> Vector2:
96
+ var push := Vector2.ZERO
97
+ var half := SP.half_size(_body)
98
+ var radius := maxf(half.x, half.y) * 2.5
99
+ for other in World.instances(StringName(SP.core(_body).entity_type)):
100
+ var o := other as Node2D
101
+ if o == _body:
102
+ continue
103
+ var d := _body.global_position - o.global_position
104
+ var l := d.length()
105
+ if l > 0.01 and l < radius:
106
+ push += d / l * (1.0 - l / radius)
107
+ return push