spawnpoint-studio 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spawnpoint/__init__.py +3 -0
- spawnpoint/_data/examples/coin_hopper.json +228 -0
- spawnpoint/_data/examples/neon_arena.json +251 -0
- spawnpoint/_data/examples/pixel_arena.json +242 -0
- spawnpoint/_data/examples/pixel_hopper.json +221 -0
- spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
- spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
- spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
- spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
- spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
- spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
- spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
- spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
- spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
- spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
- spawnpoint/analyze.py +387 -0
- spawnpoint/build.py +146 -0
- spawnpoint/cli.py +290 -0
- spawnpoint/cockpit/__init__.py +0 -0
- spawnpoint/cockpit/server.py +473 -0
- spawnpoint/cockpit/static/app.css +111 -0
- spawnpoint/cockpit/static/app.js +327 -0
- spawnpoint/cockpit/static/index.html +79 -0
- spawnpoint/diagnostics.py +91 -0
- spawnpoint/expr.py +219 -0
- spawnpoint/gif.py +58 -0
- spawnpoint/godot.py +107 -0
- spawnpoint/graph.py +175 -0
- spawnpoint/level.py +157 -0
- spawnpoint/llm_server.py +77 -0
- spawnpoint/manifest.py +222 -0
- spawnpoint/playtest/__init__.py +104 -0
- spawnpoint/playtest/report.py +148 -0
- spawnpoint/playtest/runner.py +51 -0
- spawnpoint/playtest/tuner.py +151 -0
- spawnpoint/project.py +94 -0
- spawnpoint/py.typed +0 -0
- spawnpoint/rules.py +310 -0
- spawnpoint/sfx.py +352 -0
- spawnpoint/sprites/__init__.py +0 -0
- spawnpoint/sprites/ai.py +222 -0
- spawnpoint/sprites/contour.py +147 -0
- spawnpoint/sprites/generate.py +507 -0
- spawnpoint/sprites/importer.py +212 -0
- spawnpoint/sprites/resolve.py +139 -0
- spawnpoint/sprites/spriteset.py +159 -0
- spawnpoint/sprites/tiles.py +102 -0
- spawnpoint/studio/__init__.py +0 -0
- spawnpoint/studio/assemble.py +528 -0
- spawnpoint/studio/brief.py +197 -0
- spawnpoint/studio/creative.py +124 -0
- spawnpoint/studio/dataset.py +136 -0
- spawnpoint/studio/edits.py +206 -0
- spawnpoint/studio/levelgen.py +352 -0
- spawnpoint/studio/llm.py +145 -0
- spawnpoint/studio/orchestrator.py +341 -0
- spawnpoint/studio/prompts.py +122 -0
- spawnpoint/studio/web.py +123 -0
- spawnpoint/studio/webcheck.py +210 -0
- spawnpoint/studio_cli.py +232 -0
- spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
- spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
- spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
- spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
- spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
- spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
- tests/conftest.py +49 -0
- tests/test_ai_sprites.py +122 -0
- tests/test_analyze.py +116 -0
- tests/test_cockpit.py +148 -0
- tests/test_e2e.py +33 -0
- tests/test_expr.py +59 -0
- tests/test_gif.py +19 -0
- tests/test_godot_library.py +17 -0
- tests/test_graph.py +44 -0
- tests/test_level.py +64 -0
- tests/test_live_llm.py +38 -0
- tests/test_manifest.py +54 -0
- tests/test_playtest.py +129 -0
- tests/test_rules.py +85 -0
- tests/test_sfx.py +30 -0
- tests/test_sprites.py +261 -0
- tests/test_studio.py +335 -0
- tests/test_web.py +41 -0
|
@@ -0,0 +1,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("'", "\\'") + "'"
|