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,528 @@
1
+ """Brief -> game graph, layer by layer. Fully deterministic for a given (brief, seed)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import random
8
+ import re
9
+ from typing import Any
10
+
11
+ from ..level import jump_model
12
+ from ..sprites.generate import features_for
13
+ from . import levelgen
14
+ from .brief import EnemyBrief, GameBrief
15
+
16
+ TILE = 32
17
+ PLATFORMER_GRAVITY = 1600.0
18
+ LAYERS = (0, 1, 2)
19
+
20
+ PALETTES: dict[str, dict[str, Any]] = {
21
+ "night": {"bg": "#1d2b53", "solid": "#5f574f", "top": "#00e436", "oneway": "#ab5236", "player": "#29adff",
22
+ "enemies": ["#ff004d", "#ff77a8", "#ffa300", "#7e2553"], "pickup": "#ffec27", "exit": "#00e436",
23
+ "door": "#ab5236", "hazard": "#c2c3c7", "bullet": "#fff1e8", "enemy_bullet": "#ff77a8", "heart": "#ff77a8"},
24
+ "forest": {"bg": "#12261e", "solid": "#4a3728", "top": "#5fb83d", "oneway": "#8a5a3b", "player": "#f4d35e",
25
+ "enemies": ["#d1495b", "#9b5de5", "#ee964b", "#7e2553"], "pickup": "#ffe66d", "exit": "#a7f070",
26
+ "door": "#8a5a3b", "hazard": "#b8b8b8", "bullet": "#fffbe6", "enemy_bullet": "#ee964b", "heart": "#ff8fab"},
27
+ "desert": {"bg": "#e9a46a", "solid": "#a0522d", "top": "#e6c07b", "oneway": "#8b5a2b", "player": "#2a9d8f",
28
+ "enemies": ["#9b2226", "#005f73", "#ae2012", "#6a040f"], "pickup": "#ffd166", "exit": "#2a9d8f",
29
+ "door": "#6f4518", "hazard": "#5c4033", "bullet": "#fffbe6", "enemy_bullet": "#9b2226", "heart": "#e63946"},
30
+ "neon": {"bg": "#0d0221", "solid": "#241734", "top": "#2de2e6", "oneway": "#ff3864", "player": "#2de2e6",
31
+ "enemies": ["#ff3864", "#f6019d", "#ff6c11", "#9d4edd"], "pickup": "#f9c80e", "exit": "#2de2e6",
32
+ "door": "#ff3864", "hazard": "#f6019d", "bullet": "#ffffff", "enemy_bullet": "#ff6c11", "heart": "#f6019d"},
33
+ "candy": {"bg": "#ffd6e8", "solid": "#c77dff", "top": "#ff9ebb", "oneway": "#f15bb5", "player": "#00bbf9",
34
+ "enemies": ["#f15bb5", "#9b5de5", "#ff595e", "#00f5d4"], "pickup": "#fee440", "exit": "#00f5d4",
35
+ "door": "#9b5de5", "hazard": "#8338ec", "bullet": "#ffffff", "enemy_bullet": "#ff595e", "heart": "#ff595e"},
36
+ "ocean": {"bg": "#03256c", "solid": "#1768ac", "top": "#06bee1", "oneway": "#b1d0e0", "player": "#ffd23f",
37
+ "enemies": ["#ee4266", "#ff9f1c", "#3bceac", "#540d6e"], "pickup": "#f5f5f5", "exit": "#3bceac",
38
+ "door": "#06bee1", "hazard": "#b1d0e0", "bullet": "#ffffff", "enemy_bullet": "#ee4266", "heart": "#ee4266"},
39
+ "lava": {"bg": "#1a0a0a", "solid": "#3d1f1f", "top": "#ff6b35", "oneway": "#8c2f39", "player": "#4cc9f0",
40
+ "enemies": ["#ff4d00", "#ffb703", "#e63946", "#9d0208"], "pickup": "#ffd60a", "exit": "#4cc9f0",
41
+ "door": "#8c2f39", "hazard": "#ffb703", "bullet": "#ffffff", "enemy_bullet": "#ff4d00", "heart": "#ff4d6d"},
42
+ "snow": {"bg": "#a9d6e5", "solid": "#5c7c8a", "top": "#ffffff", "oneway": "#98c1d9", "player": "#ee6c4d",
43
+ "enemies": ["#3d5a80", "#9d4edd", "#c1121f", "#293241"], "pickup": "#ffb703", "exit": "#ee6c4d",
44
+ "door": "#3d5a80", "hazard": "#293241", "bullet": "#ffffff", "enemy_bullet": "#c1121f", "heart": "#c1121f"},
45
+ }
46
+
47
+ SPEED = {"slow": 0.75, "normal": 1.0, "fast": 1.3}
48
+ ENEMY_POINTS = {"walker": 5, "hopper": 10, "chaser": 10, "shooter": 25, "turret": 20}
49
+ ENEMY_SYMBOLS = "1234"
50
+
51
+ FEEL_PLATFORMER = {
52
+ "snappy": {},
53
+ "floaty": {"gravity_scale": 0.8, "fall_gravity_multiplier": 1.25, "apex_gravity_multiplier": 0.45,
54
+ "air_acceleration": 900.0, "air_deceleration": 400.0},
55
+ "heavy": {"gravity_scale": 1.25, "fall_gravity_multiplier": 2.2, "acceleration": 1500.0,
56
+ "deceleration": 1800.0, "apex_gravity_multiplier": 0.85},
57
+ }
58
+ FEEL_TOPDOWN = {
59
+ "snappy": {},
60
+ "floaty": {"acceleration": 900.0, "friction": 700.0},
61
+ "heavy": {"acceleration": 1400.0, "friction": 3200.0},
62
+ }
63
+ JUICE_SCALE = {"subtle": 0.5, "normal": 1.0, "wild": 1.7}
64
+ JUICE_DEFAULTS = {"squash_amount": (0.28, 0.8), "shake_on_hurt": (0.35, 1.0), "shake_on_death": (0.45, 1.0),
65
+ "shake_on_fire": (0.06, 1.0), "hitstop_on_hurt": (0.06, 0.3), "hitstop_on_death": (0.04, 0.3)}
66
+
67
+
68
+ def jump_velocity(jump_tiles: float, gravity_scale: float = 1.0) -> float:
69
+ # Apex hang adds ~8% height, so aim slightly lower than the requested height.
70
+ h = jump_tiles * TILE * 0.92
71
+ return round(math.sqrt(2 * PLATFORMER_GRAVITY * gravity_scale * h), 1)
72
+
73
+
74
+ def jump_reach_tiles(brief: GameBrief) -> int:
75
+ """Whole tiles the player can climb. Same model as the compiler's reachability check
76
+ (ballistic height of jump_velocity, times the number of jumps, without apex hang)."""
77
+ jumps = 2 if "double_jump" in brief.player.abilities else 1
78
+ h = jump_velocity(brief.player.jump_tiles) ** 2 / (2 * PLATFORMER_GRAVITY) * jumps
79
+ return int(h // TILE)
80
+
81
+
82
+ def jump_across_tiles(brief: GameBrief) -> int:
83
+ """Horizontal tiles one jump covers, using the compiler's model; the most conservative of the
84
+ default feel (layers 0-1) and the brief's feel (layer 2)."""
85
+ speed = 230 * SPEED[brief.player.speed]
86
+ jumps = 2 if "double_jump" in brief.player.abilities else 1
87
+ out = []
88
+ for feel in {"snappy", brief.feel}:
89
+ f = FEEL_PLATFORMER[feel]
90
+ gs = f.get("gravity_scale", 1.0)
91
+ jm = jump_model(jump_velocity(brief.player.jump_tiles, gs), PLATFORMER_GRAVITY * gs,
92
+ f.get("fall_gravity_multiplier", 1.7), speed, TILE, 34, jumps - 1)
93
+ out.append(jm.across)
94
+ return min(out)
95
+
96
+
97
+ def _level_args(brief: GameBrief) -> tuple:
98
+ return brief.length, jump_reach_tiles(brief), jump_across_tiles(brief), "pits" in brief.hazards
99
+
100
+
101
+ def plan_chunks(brief: GameBrief, seed: int) -> list[str] | None:
102
+ if brief.genre != "platformer":
103
+ return None
104
+ return levelgen.plan_chunks(seed, *_level_args(brief), brief.difficulty)
105
+
106
+
107
+ def chunks_fit(brief: GameBrief, chunks: list[str] | None) -> bool:
108
+ if brief.genre != "platformer" or chunks is None:
109
+ return False
110
+ return levelgen.chunks_fit(chunks, *_level_args(brief))
111
+
112
+
113
+ def ai_art_from_env() -> bool:
114
+ return os.environ.get("SPAWNPOINT_AI_ART", "").lower() in ("1", "true", "yes", "on")
115
+
116
+
117
+ def assemble(brief: GameBrief, layer: int = 2, seed: int = 0, chunks: list[str] | None = None,
118
+ ai_art: bool = False) -> dict:
119
+ """`chunks` pins the platformer level layout (to keep the map across tweaks); `ai_art` asks for
120
+ image-model sprites in layer 2 (generated ones are the fallback)."""
121
+ return _Assembler(brief, layer, seed, chunks, ai_art).graph()
122
+
123
+
124
+ class _Assembler:
125
+ def __init__(self, brief: GameBrief, layer: int, seed: int, chunks: list[str] | None = None,
126
+ ai_art: bool = False):
127
+ self.ai_art = ai_art
128
+ self.b = brief
129
+ self.layer = layer
130
+ self.seed = seed
131
+ self.chunks = chunks
132
+ self.pal = PALETTES[brief.palette]
133
+ self.platformer = brief.genre == "platformer"
134
+ self.entities: dict[str, dict] = {}
135
+ self.legend: dict[str, str] = {"#": "solid", "=": "oneway"}
136
+ self.rules: list[dict] = []
137
+
138
+ # -- top level ---------------------------------------------------------------------------
139
+ def graph(self) -> dict:
140
+ b = self.b
141
+ lay = self._layout()
142
+ grid: dict[tuple[int, int], str] = {}
143
+ self._player(grid, lay)
144
+ if self.layer >= 1:
145
+ self._goal(grid, lay)
146
+ self._enemies(grid, lay)
147
+ self._pickups(grid, lay)
148
+ self._hazards(grid, lay)
149
+ intro = b.intro if self.layer >= 1 else "Layer 0: try the controls!"
150
+ self.rules.insert(0, {"on": "Game.ready", "do": [f"message({_q(intro)})"]})
151
+ if self.layer >= 2:
152
+ self._polish()
153
+ return {
154
+ "spawnpoint": 1,
155
+ "name": b.title or "Untitled",
156
+ "genre": b.genre,
157
+ "settings": {
158
+ "tile_size": TILE,
159
+ "lives": 3,
160
+ "seed": self.seed,
161
+ "background": self.pal["bg"],
162
+ "zoom": 1.5, # pixel-art sprites read better closer up
163
+ "tiles": {"solid": {"color": self.pal["solid"], "top": self.pal["top"]},
164
+ "oneway": {"color": self.pal["oneway"]}},
165
+ },
166
+ "entities": self.entities,
167
+ "level": {"map": _render(lay, grid), "legend": self.legend},
168
+ "rules": self.rules,
169
+ }
170
+
171
+ def _layout(self) -> levelgen.Layout:
172
+ b = self.b
173
+ if self.platformer:
174
+ length, up, across, pits = _level_args(b)
175
+ return levelgen.platformer(self.seed, length, up, across, pits, b.difficulty,
176
+ door=b.goal == "collect_all_then_exit", chunks=self.chunks)
177
+ return levelgen.arena(self.seed, b.length, b.difficulty)
178
+
179
+ # -- player ------------------------------------------------------------------------------
180
+ def _player(self, grid, lay) -> None:
181
+ p = self.b.player
182
+ abilities = set(p.abilities)
183
+ comps: dict[str, dict] = {}
184
+ if self.platformer:
185
+ feel = FEEL_PLATFORMER[self.b.feel] if self.layer >= 2 else {}
186
+ mv = {"max_speed": round(230 * SPEED[p.speed]),
187
+ "jump_velocity": jump_velocity(p.jump_tiles, feel.get("gravity_scale", 1.0))}
188
+ if "double_jump" in abilities:
189
+ mv["air_jumps"] = 1
190
+ if "dash" in abilities:
191
+ mv["dash_speed"] = 620.0
192
+ mv.update(feel)
193
+ comps["PlatformerMovement"] = mv
194
+ else:
195
+ feel = FEEL_TOPDOWN[self.b.feel] if self.layer >= 2 else {}
196
+ mv = {"max_speed": round(250 * SPEED[p.speed])}
197
+ if "dash" in abilities:
198
+ mv["dash_speed"] = 720.0
199
+ mv.update(feel)
200
+ comps["TopDownMovement"] = mv
201
+ comps["PlayerInput"] = {}
202
+ comps["Health"] = {"max_health": p.health, "invincibility_time": 0.8}
203
+ comps["Hurtbox"] = {}
204
+ if self.platformer and "stomp" in abilities:
205
+ comps["Stomp"] = {}
206
+ if "shoot" in abilities:
207
+ if not self.platformer:
208
+ comps["Aim"] = {"mode": "mouse"}
209
+ comps["Weapon"] = {"projectile": "Bullet", "fire_rate": 8.0 if not self.platformer else 5.0,
210
+ "spread_deg": 5.0}
211
+ self.entities["Bullet"] = {
212
+ "body": "area", "team": "player",
213
+ "shape": {"type": "circle", "size": [8, 8], "color": self.pal["bullet"]},
214
+ "components": {"Projectile": {"speed": 720.0, "lifetime": 0.9},
215
+ "Hitbox": {"damage": 1, "knockback": 160.0}},
216
+ }
217
+ shape = {"type": p.shape, "size": [22, 34] if self.platformer else [24, 24], "color": self.pal["player"]}
218
+ if p.shape == "circle":
219
+ shape["size"] = [26, 26] if self.platformer else [24, 24]
220
+ elif p.shape == "rect":
221
+ shape["size"] = [24, 30] if self.platformer else [24, 24]
222
+ elif not self.platformer:
223
+ shape["type"] = "circle"
224
+ self.entities["Player"] = {"shape": shape, "components": comps}
225
+ self.legend["P"] = "Player"
226
+ grid[lay.player] = "P"
227
+
228
+ # -- goal --------------------------------------------------------------------------------
229
+ def _goal(self, grid, lay) -> None:
230
+ b, pal = self.b, self.pal
231
+ c = b.collectible.name
232
+ needs_exit = b.goal in ("reach_exit", "collect_all_then_exit")
233
+ if needs_exit:
234
+ self.entities["Exit"] = {
235
+ "body": "area",
236
+ "shape": {"type": "rect", "size": [28, 48]} if self.platformer else {"type": "circle", "size": [40, 40]},
237
+ "components": {"TriggerZone": {"once": not (b.goal == "collect_all_then_exit" and not self.platformer)}},
238
+ }
239
+ self.entities["Exit"]["shape"]["color"] = pal["exit"]
240
+ self.legend["E"] = "Exit"
241
+ grid[lay.exit] = "E"
242
+ if b.goal == "reach_exit":
243
+ self.rules.append({"on": "Exit.triggered", "do": ["win()"]})
244
+ elif b.goal == "collect_all":
245
+ self.rules.append({"on": f"{c}.collected", "if": f"count({_q(c)}) == 0", "do": ["win()"]})
246
+ elif b.goal == "collect_all_then_exit":
247
+ if self.platformer and lay.door:
248
+ self.entities["Door"] = {"body": "static",
249
+ "shape": {"type": "rect", "size": [18, 126], "color": pal["door"]},
250
+ "components": {"Gate": {}}}
251
+ self.legend["D"] = "Door"
252
+ grid[lay.door] = "D"
253
+ self.rules.append({"on": f"{c}.collected", "if": f"count({_q(c)}) == 0",
254
+ "do": ["Door.open()", "message('The door is open!')"]})
255
+ self.rules.append({"on": "Exit.triggered", "do": ["win()"]})
256
+ else:
257
+ self.rules.append({"on": "Exit.triggered", "if": f"count({_q(c)}) == 0", "do": ["win()"]})
258
+ self.rules.append({"on": "Exit.triggered", "if": f"count({_q(c)}) > 0",
259
+ "do": [f"message({_q('Collect every ' + c + ' first!')})"]})
260
+ elif b.goal == "defeat_all":
261
+ names = [e.name for e in b.enemies]
262
+ total = " + ".join(f"count({_q(n)})" for n in names)
263
+ for n in names:
264
+ self.rules.append({"on": f"{n}.died", "if": f"{total} == 0", "do": ["win()"]})
265
+ elif b.goal == "score_target":
266
+ self.rules.append({"on": "GameState.score_changed", "if": f"score >= {b.score_target}", "do": ["win()"]})
267
+ elif b.goal == "survive":
268
+ n = b.survive_seconds
269
+ self.rules.append({"on": "GameState.second_passed", "if": f"event.seconds == {max(n - 10, 1)}",
270
+ "do": ["message('10 seconds left!')"]})
271
+ self.rules.append({"on": "GameState.second_passed", "if": f"event.seconds >= {n}", "do": ["win()"]})
272
+ if not self.platformer and b.goal in ("score_target", "survive"):
273
+ self._portals(grid, lay)
274
+
275
+ def _portals(self, grid, lay) -> None:
276
+ spawn = self.b.enemies[0].name if self.b.enemies else self._default_chaser()
277
+ d = self.b.difficulty
278
+ self.entities["Portal"] = {
279
+ "body": "area", "juice": "none",
280
+ "shape": {"type": "circle", "size": [40, 40], "color": self.pal["enemies"][-1]},
281
+ "components": {"Spawner": {"entity": spawn, "interval": max(1.0, 5.0 - d * 0.7),
282
+ "start_delay": 3.0, "max_alive": 2 + d}},
283
+ }
284
+ self.legend["O"] = "Portal"
285
+ for c in lay.portal_slots[: 2 + (1 if d >= 3 else 0)]:
286
+ if c not in grid:
287
+ grid[c] = "O"
288
+
289
+ def _default_chaser(self) -> str:
290
+ e = EnemyBrief(name="Chaser", archetype="chaser", count=0)
291
+ self._enemy_entity(e, 0)
292
+ return "Chaser"
293
+
294
+ # -- enemies -----------------------------------------------------------------------------
295
+ def _enemies(self, grid, lay) -> None:
296
+ rng = random.Random(f"enemies:{self.seed}")
297
+ if self.platformer:
298
+ slots = [c for c in lay.ground_slots if c[0] > lay.player[0] + 6 and c not in grid]
299
+ overflow = levelgen.extra_ground_cells(lay, lay.player[0] + 8)
300
+ else:
301
+ far = [c for c in lay.free_cells
302
+ if abs(c[0] - lay.player[0]) + abs(c[1] - lay.player[1]) >= 10 and c not in grid]
303
+ slots, overflow = far, []
304
+ rng.shuffle(slots)
305
+ rng.shuffle(overflow)
306
+ for i, e in enumerate(self.b.enemies):
307
+ sym = ENEMY_SYMBOLS[i]
308
+ self._enemy_entity(e, i)
309
+ self.legend[sym] = e.name
310
+ for _ in range(e.count):
311
+ pool = slots if slots else overflow
312
+ while pool and pool[-1] in grid:
313
+ pool.pop()
314
+ if not pool:
315
+ break
316
+ grid[pool.pop()] = sym
317
+ self.rules.append({"on": f"{e.name}.died", "do": [f"add_score({ENEMY_POINTS[e.archetype]})"]})
318
+
319
+ def _enemy_entity(self, e: EnemyBrief, index: int) -> None:
320
+ k = SPEED[e.speed]
321
+ d = self.b.difficulty
322
+ color = self.pal["enemies"][index % len(self.pal["enemies"])]
323
+ health = {"max_health": e.health, "invincibility_time": 0.1}
324
+ contact = {"damage": 1, "knockback": 280.0}
325
+ comps: dict[str, dict] = {}
326
+ shape: dict[str, Any] = {"type": "rect", "size": [28, 20]}
327
+ mv_name = "PlatformerMovement" if self.platformer else "TopDownMovement"
328
+ if e.archetype == "walker":
329
+ comps = {mv_name: {"max_speed": round(65 * k)}, "PatrolAI": {}}
330
+ elif e.archetype == "hopper":
331
+ shape = {"type": "circle", "size": [26, 22]}
332
+ comps = {mv_name: {"max_speed": round(90 * k), "jump_velocity": 480.0},
333
+ "PatrolAI": {"jump_interval": round(1.6 / k, 2)}}
334
+ elif e.archetype == "chaser":
335
+ shape = {"type": "circle", "size": [24, 24]} if not self.platformer else {"type": "rect", "size": [24, 24]}
336
+ comps = {mv_name: {"max_speed": round((110 if self.platformer else 130) * k)},
337
+ "ChaseAI": {"detect_radius": 300.0 + d * 30}}
338
+ elif e.archetype == "shooter":
339
+ shape = {"type": "rect", "size": [26, 26]}
340
+ comps = {mv_name: {"max_speed": round(80 * k)},
341
+ "ChaseAI": {"detect_radius": 420.0, "keep_distance": 200.0, "fire_range": 380.0},
342
+ "Weapon": {"projectile": "EnemyBullet", "fire_rate": round(0.6 + 0.25 * d * k, 2),
343
+ "spread_deg": 8.0, "muzzle_distance": 22.0}}
344
+ self._enemy_bullet()
345
+ elif e.archetype == "turret":
346
+ shape = {"type": "triangle", "size": [28, 28]}
347
+ comps = {"ChaseAI": {"wander": False, "detect_radius": 400.0, "fire_range": 400.0},
348
+ "Weapon": {"projectile": "EnemyBullet", "fire_rate": round(0.5 + 0.2 * d * k, 2),
349
+ "spread_deg": 4.0, "muzzle_distance": 22.0}}
350
+ self._enemy_bullet()
351
+ comps.update({"Health": health, "Hurtbox": {"size_scale": 1.0}, "Hitbox": contact})
352
+ shape["color"] = color
353
+ self.entities[e.name] = {"team": "enemy", "shape": shape, "components": comps}
354
+
355
+ def _enemy_bullet(self) -> None:
356
+ if "EnemyBullet" in self.entities:
357
+ return
358
+ self.entities["EnemyBullet"] = {
359
+ "body": "area", "team": "enemy",
360
+ "shape": {"type": "circle", "size": [10, 10], "color": self.pal["enemy_bullet"]},
361
+ "components": {"Projectile": {"speed": 240.0 + self.b.difficulty * 30, "lifetime": 3.0},
362
+ "Hitbox": {"damage": 1, "knockback": 220.0}},
363
+ }
364
+
365
+ # -- pickups & hazards -------------------------------------------------------------------
366
+ def _pickups(self, grid, lay) -> None:
367
+ b = self.b
368
+ rng = random.Random(f"pickups:{self.seed}")
369
+ if self.platformer:
370
+ reach = jump_reach_tiles(b)
371
+ # Only pickups the player can actually jump to.
372
+ slots = [c for c in lay.air_slots
373
+ if c not in grid and (h := levelgen.floor_below(lay, c)) is not None and h <= reach]
374
+ extra = levelgen.extra_ground_cells(lay, lay.player[0] + 3)
375
+ else:
376
+ slots = [c for c in lay.free_cells
377
+ if abs(c[0] - lay.player[0]) + abs(c[1] - lay.player[1]) >= 3 and c not in grid]
378
+ extra = []
379
+ rng.shuffle(slots)
380
+ rng.shuffle(extra)
381
+
382
+ def take() -> tuple[int, int] | None:
383
+ for pool in (slots, extra):
384
+ while pool:
385
+ c = pool.pop()
386
+ if c not in grid:
387
+ return c
388
+ return None
389
+
390
+ name = b.collectible.name
391
+ if b.collectible.count > 0:
392
+ self.entities[name] = {"body": "area",
393
+ "shape": {"type": "circle", "size": [14, 14], "color": self.pal["pickup"]},
394
+ "components": {"Collectible": {"kind": name.lower(), "score": 1}}}
395
+ self.legend["C"] = name
396
+ for _ in range(b.collectible.count):
397
+ c = take()
398
+ if c is None:
399
+ break
400
+ grid[c] = "C"
401
+ if b.health_pickups > 0:
402
+ self.entities["HealthPack"] = {"body": "area",
403
+ "shape": {"type": "rect", "size": [16, 14], "color": self.pal["heart"]},
404
+ "components": {"Collectible": {"kind": "heart", "score": 0, "heal": 1}}}
405
+ self.legend["+"] = "HealthPack"
406
+ for _ in range(b.health_pickups):
407
+ c = take()
408
+ if c is None:
409
+ break
410
+ grid[c] = "+"
411
+
412
+ def _hazards(self, grid, lay) -> None:
413
+ if "spikes" not in self.b.hazards or not self.platformer:
414
+ return
415
+ rng = random.Random(f"hazards:{self.seed}")
416
+ share = 0.4 + 0.15 * self.b.difficulty
417
+ cells = [c for c in lay.hazard_slots if c not in grid and rng.random() < share]
418
+ if not cells:
419
+ return
420
+ # Enemy team: spikes hurt the player only (enemies walking into them must not give free points).
421
+ self.entities["Spikes"] = {"body": "area", "team": "enemy", "juice": "none",
422
+ "shape": {"type": "spikes", "size": [30, 14], "color": self.pal["hazard"]},
423
+ "components": {"Hitbox": {"damage": 1, "knockback": 420.0}}}
424
+ self.legend["^"] = "Spikes"
425
+ for c in cells:
426
+ grid[c] = "^"
427
+
428
+ # -- polish (layer 2) --------------------------------------------------------------------
429
+ def _polish(self) -> None:
430
+ self._sprites()
431
+ k = JUICE_SCALE[self.b.juice]
432
+ if k == 1.0:
433
+ return
434
+ params = {name: round(min(default * k, cap), 3) for name, (default, cap) in JUICE_DEFAULTS.items()}
435
+ for name, ent in self.entities.items():
436
+ if ent.get("juice") == "none":
437
+ continue
438
+ ent["juice"] = dict(params)
439
+
440
+
441
+ def _sprites(self) -> None:
442
+ """Layer 2 art pass: placeholders become generated pixel-art sprites (collision follows)."""
443
+ archetypes = {e.name: e.archetype for e in self.b.enemies}
444
+ ai_art = self.ai_art
445
+ for name, ent in self.entities.items():
446
+ kind = sprite_kind(name, ent, archetypes.get(name), self.b, self.platformer)
447
+ color = ent["shape"]["color"]
448
+ if ai_art:
449
+ subject = self.b.player.character if name == "Player" else name
450
+ words = " ".join(re.findall(r"[A-Z][a-z0-9]*|[a-z0-9]+", subject)).lower()
451
+ ent["sprite"] = {"ai": {"prompt": f"{words}, {self.b.palette} color palette, game: {self.b.title}",
452
+ "kind": kind, "color": color, "seed": self.seed}}
453
+ else:
454
+ gen: dict[str, Any] = {"kind": kind, "color": color, "seed": self.seed}
455
+ if name == "Player":
456
+ gen["features"] = features_for(self.b.player.character)
457
+ ent["sprite"] = {"generate": gen}
458
+ del ent["shape"]
459
+
460
+
461
+ HUMANOID_WORDS = {"goblin", "zombie", "knight", "skeleton", "orc", "ninja", "soldier", "pirate", "bandit",
462
+ "wizard", "witch", "mage", "guard", "thief", "samurai", "elf", "troll", "vampire", "mummy"}
463
+ FLYER_WORDS = {"bat", "bird", "crow", "ghost", "spirit", "wasp", "bee", "fly", "dragon", "owl", "eagle"}
464
+ ROBOT_WORDS = {"robot", "bot", "droid", "mech", "drone", "android", "cyborg", "machine"}
465
+ BUG_WORDS = {"bug", "beetle", "ant", "spider", "scorpion", "roach", "crab", "insect"}
466
+ HOPPER_WORDS = {"frog", "toad", "bunny", "rabbit", "kangaroo"}
467
+ PICKUP_KINDS = (({"coin", "gold", "money", "cash", "token"}, "coin"), ({"star", "starlight"}, "star"),
468
+ ({"gem", "crystal", "diamond", "jewel", "ruby", "emerald", "shard", "sapphire"}, "gem"),
469
+ ({"heart", "life", "health"}, "heart"))
470
+
471
+
472
+ def _words(name: str) -> set[str]:
473
+ import re
474
+ return {w.lower() for w in re.findall(r"[A-Z][a-z0-9]*|[a-z0-9]+", name)}
475
+
476
+
477
+ def sprite_kind(name: str, ent: dict, archetype: str | None, brief: GameBrief, platformer: bool) -> str:
478
+ words = _words(name)
479
+ comps = ent.get("components", {})
480
+ if name == "Player":
481
+ return "hero"
482
+ if name in ("Bullet", "EnemyBullet"):
483
+ return "bullet"
484
+ if name == "Door":
485
+ return "door"
486
+ if name == "Exit":
487
+ return "exit" if platformer else "portal"
488
+ if name == "Portal":
489
+ return "portal"
490
+ if name == "Spikes":
491
+ return "spikes"
492
+ if name == "HealthPack":
493
+ return "heart"
494
+ if "Collectible" in comps:
495
+ for keys, kind in PICKUP_KINDS:
496
+ if words & keys:
497
+ return kind
498
+ return "orb"
499
+ if archetype == "turret":
500
+ return "turret"
501
+ for keys, kind in ((ROBOT_WORDS, "robot"), (FLYER_WORDS, "flyer"), (HUMANOID_WORDS, "hero"),
502
+ (HOPPER_WORDS, "hopper"), (BUG_WORDS, "bug" if not platformer else "slime")):
503
+ if words & keys:
504
+ return kind
505
+ return {"walker": "slime", "hopper": "hopper", "chaser": "slime" if platformer else "bug",
506
+ "shooter": "robot"}.get(archetype or "", "slime")
507
+
508
+
509
+ def _render(lay: levelgen.Layout, grid: dict[tuple[int, int], str]) -> list[str]:
510
+ rows = []
511
+ for y in range(lay.height):
512
+ row = []
513
+ for x in range(lay.width):
514
+ c = (x, y)
515
+ if c in grid:
516
+ row.append(grid[c])
517
+ elif lay.tiles.get(c) == "solid":
518
+ row.append("#")
519
+ elif lay.tiles.get(c) == "oneway":
520
+ row.append("=")
521
+ else:
522
+ row.append(".")
523
+ rows.append("".join(row).rstrip(".") or ".")
524
+ return rows
525
+
526
+
527
+ def _q(s: str) -> str:
528
+ return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"