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
spawnpoint/expr.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Rule expressions: a whitelisted subset of Python syntax translated to GDScript.
|
|
2
|
+
|
|
3
|
+
Supported: numbers, strings, true/false, `score`, `lives`, `time`, `vars.<name>`,
|
|
4
|
+
`event.<arg>`, `self`, `self.<Component>.<property>`, functions count("Entity"), randf(),
|
|
5
|
+
min, max, abs, str; operators + - * / %, comparisons (chainable), and/or/not.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from .diagnostics import Diagnostics, did_you_mean
|
|
16
|
+
from .manifest import Manifest
|
|
17
|
+
|
|
18
|
+
FUNCS = {"count": (1, 1), "randf": (0, 0), "min": (2, 2), "max": (2, 2), "abs": (1, 1), "str": (1, 1)}
|
|
19
|
+
GLOBALS = {"score": "GameState.score", "lives": "GameState.lives", "time": "GameState.time"}
|
|
20
|
+
CONSTS = {"true": "true", "false": "false", "True": "true", "False": "false", "null": "null", "None": "null"}
|
|
21
|
+
|
|
22
|
+
_BIN = {ast.Add: "+", ast.Sub: "-", ast.Mult: "*"}
|
|
23
|
+
_CMP = {ast.Eq: "==", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "<=", ast.Gt: ">", ast.GtE: ">="}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ExprError(Exception):
|
|
27
|
+
def __init__(self, message: str, hint: str | None = None):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.hint = hint
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Scope:
|
|
34
|
+
"""What an expression may reference."""
|
|
35
|
+
|
|
36
|
+
manifest: Manifest
|
|
37
|
+
entities: dict[str, list[str]] # entity name -> component names
|
|
38
|
+
event_args: dict[str, str] = field(default_factory=dict) # arg name -> GDScript param name
|
|
39
|
+
self_entity: str | None = None # entity type of `self`, None if no source entity
|
|
40
|
+
used: set[str] = field(default_factory=set) # param names referenced (for unused-arg naming)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_STRING = re.compile(r"(\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*')")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize(src: str) -> str:
|
|
47
|
+
"""Accept common C-style operators from LLM output (outside string literals)."""
|
|
48
|
+
parts = _STRING.split(src)
|
|
49
|
+
for i in range(0, len(parts), 2):
|
|
50
|
+
s = parts[i].replace("&&", " and ").replace("||", " or ")
|
|
51
|
+
parts[i] = re.sub(r"!(?!=)", " not ", s)
|
|
52
|
+
return "".join(parts)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse(src: str) -> ast.expr:
|
|
56
|
+
try:
|
|
57
|
+
return ast.parse(normalize(src).strip(), mode="eval").body
|
|
58
|
+
except SyntaxError as e:
|
|
59
|
+
raise ExprError(f"syntax error in '{src}': {e.msg}") from e
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def translate(src: str, scope: Scope) -> str:
|
|
63
|
+
return _Translator(scope).visit(parse(src))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check(src: str, scope: Scope, diags: Diagnostics, path: str) -> str | None:
|
|
67
|
+
try:
|
|
68
|
+
return translate(src, scope)
|
|
69
|
+
except ExprError as e:
|
|
70
|
+
diags.error("E_EXPR", path, str(e), e.hint)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def gd_string(s: str) -> str:
|
|
75
|
+
return json.dumps(s, ensure_ascii=False)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Translator:
|
|
79
|
+
def __init__(self, scope: Scope):
|
|
80
|
+
self.s = scope
|
|
81
|
+
|
|
82
|
+
def visit(self, node: ast.AST) -> str:
|
|
83
|
+
method = getattr(self, "v_" + type(node).__name__, None)
|
|
84
|
+
if method is None:
|
|
85
|
+
raise ExprError(f"'{ast.unparse(node)}' is not allowed in rule expressions")
|
|
86
|
+
return method(node)
|
|
87
|
+
|
|
88
|
+
def v_Constant(self, n: ast.Constant) -> str:
|
|
89
|
+
v = n.value
|
|
90
|
+
if isinstance(v, bool):
|
|
91
|
+
return "true" if v else "false"
|
|
92
|
+
if v is None:
|
|
93
|
+
return "null"
|
|
94
|
+
if isinstance(v, (int, float)):
|
|
95
|
+
return repr(v)
|
|
96
|
+
if isinstance(v, str):
|
|
97
|
+
return gd_string(v)
|
|
98
|
+
raise ExprError(f"unsupported literal {v!r}")
|
|
99
|
+
|
|
100
|
+
def v_Name(self, n: ast.Name) -> str:
|
|
101
|
+
if n.id in CONSTS:
|
|
102
|
+
return CONSTS[n.id]
|
|
103
|
+
if n.id in GLOBALS:
|
|
104
|
+
return GLOBALS[n.id]
|
|
105
|
+
if n.id == "self":
|
|
106
|
+
if self.s.self_entity is None:
|
|
107
|
+
raise ExprError("'self' is not available for this event (no emitting entity)")
|
|
108
|
+
self.s.used.add("source")
|
|
109
|
+
return "source"
|
|
110
|
+
if n.id in self.s.entities:
|
|
111
|
+
raise ExprError(f"'{n.id}' is an entity type, not a value", f'Use count("{n.id}") for how many exist')
|
|
112
|
+
if n.id in ("event", "vars"):
|
|
113
|
+
raise ExprError(f"'{n.id}' needs a field, e.g. {n.id}.something")
|
|
114
|
+
raise ExprError(f"unknown name '{n.id}'", did_you_mean(n.id, [*GLOBALS, "self", "event", "vars"]))
|
|
115
|
+
|
|
116
|
+
def v_Attribute(self, n: ast.Attribute) -> str:
|
|
117
|
+
chain = _attr_chain(n)
|
|
118
|
+
if chain is None:
|
|
119
|
+
raise ExprError(f"'{ast.unparse(n)}' is not allowed")
|
|
120
|
+
head, *rest = chain
|
|
121
|
+
if head == "vars" and len(rest) == 1:
|
|
122
|
+
return f"GameState.get_var({gd_string(rest[0])})"
|
|
123
|
+
if head == "event" and len(rest) == 1:
|
|
124
|
+
arg = rest[0]
|
|
125
|
+
if arg not in self.s.event_args:
|
|
126
|
+
opts = list(self.s.event_args)
|
|
127
|
+
raise ExprError(f"event has no argument '{arg}'",
|
|
128
|
+
did_you_mean(arg, opts) if opts else "This event has no arguments")
|
|
129
|
+
gd = self.s.event_args[arg]
|
|
130
|
+
self.s.used.add(gd)
|
|
131
|
+
return gd
|
|
132
|
+
if head == "self" and len(rest) == 2:
|
|
133
|
+
if self.s.self_entity is None:
|
|
134
|
+
raise ExprError("'self' is not available for this event (no emitting entity)")
|
|
135
|
+
comp_name, prop = rest
|
|
136
|
+
comps = self.s.entities.get(self.s.self_entity, [])
|
|
137
|
+
if comp_name not in comps:
|
|
138
|
+
raise ExprError(f"{self.s.self_entity} has no component '{comp_name}'", did_you_mean(comp_name, comps))
|
|
139
|
+
comp = self.s.manifest.get(comp_name)
|
|
140
|
+
props = [*comp.params, *comp.state] if comp else []
|
|
141
|
+
if prop not in props:
|
|
142
|
+
raise ExprError(f"{comp_name} has no property '{prop}'", did_you_mean(prop, props))
|
|
143
|
+
self.s.used.add("source")
|
|
144
|
+
return f'SP.component(source, &"{comp_name}").{prop}'
|
|
145
|
+
raise ExprError(f"'{ast.unparse(n)}' is not allowed",
|
|
146
|
+
"Use vars.<name>, event.<arg> or self.<Component>.<property>")
|
|
147
|
+
|
|
148
|
+
def v_Call(self, n: ast.Call) -> str:
|
|
149
|
+
if not isinstance(n.func, ast.Name) or n.func.id not in FUNCS:
|
|
150
|
+
name = ast.unparse(n.func)
|
|
151
|
+
raise ExprError(f"function '{name}' is not allowed in expressions", did_you_mean(name, FUNCS))
|
|
152
|
+
if n.keywords:
|
|
153
|
+
raise ExprError("keyword arguments are not supported")
|
|
154
|
+
lo, hi = FUNCS[n.func.id]
|
|
155
|
+
if not lo <= len(n.args) <= hi:
|
|
156
|
+
raise ExprError(f"{n.func.id}() takes {lo if lo == hi else f'{lo}-{hi}'} argument(s), got {len(n.args)}")
|
|
157
|
+
if n.func.id == "count":
|
|
158
|
+
name = entity_literal(n.args[0], self.s.entities)
|
|
159
|
+
return f'World.count(&"{name}")'
|
|
160
|
+
args = ", ".join(self.visit(a) for a in n.args)
|
|
161
|
+
return f"{n.func.id}({args})"
|
|
162
|
+
|
|
163
|
+
def v_BinOp(self, n: ast.BinOp) -> str:
|
|
164
|
+
a, b = self.visit(n.left), self.visit(n.right)
|
|
165
|
+
if isinstance(n.op, ast.Div):
|
|
166
|
+
return f"(float({a}) / {b})"
|
|
167
|
+
if isinstance(n.op, ast.Mod):
|
|
168
|
+
return f"fposmod({a}, {b})"
|
|
169
|
+
op = _BIN.get(type(n.op))
|
|
170
|
+
if op is None:
|
|
171
|
+
raise ExprError(f"operator in '{ast.unparse(n)}' is not supported")
|
|
172
|
+
return f"({a} {op} {b})"
|
|
173
|
+
|
|
174
|
+
def v_UnaryOp(self, n: ast.UnaryOp) -> str:
|
|
175
|
+
v = self.visit(n.operand)
|
|
176
|
+
if isinstance(n.op, ast.Not):
|
|
177
|
+
return f"(not {v})"
|
|
178
|
+
if isinstance(n.op, ast.USub):
|
|
179
|
+
return f"(-{v})"
|
|
180
|
+
if isinstance(n.op, ast.UAdd):
|
|
181
|
+
return v
|
|
182
|
+
raise ExprError(f"operator in '{ast.unparse(n)}' is not supported")
|
|
183
|
+
|
|
184
|
+
def v_BoolOp(self, n: ast.BoolOp) -> str:
|
|
185
|
+
op = " and " if isinstance(n.op, ast.And) else " or "
|
|
186
|
+
return "(" + op.join(self.visit(v) for v in n.values) + ")"
|
|
187
|
+
|
|
188
|
+
def v_Compare(self, n: ast.Compare) -> str:
|
|
189
|
+
parts = []
|
|
190
|
+
left = n.left
|
|
191
|
+
for op, right in zip(n.ops, n.comparators):
|
|
192
|
+
sym = _CMP.get(type(op))
|
|
193
|
+
if sym is None:
|
|
194
|
+
raise ExprError(f"comparison in '{ast.unparse(n)}' is not supported")
|
|
195
|
+
parts.append(f"{self.visit(left)} {sym} {self.visit(right)}")
|
|
196
|
+
left = right
|
|
197
|
+
return "(" + " and ".join(parts) + ")"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _attr_chain(n: ast.AST) -> list[str] | None:
|
|
201
|
+
out: list[str] = []
|
|
202
|
+
while isinstance(n, ast.Attribute):
|
|
203
|
+
out.append(n.attr)
|
|
204
|
+
n = n.value
|
|
205
|
+
if not isinstance(n, ast.Name):
|
|
206
|
+
return None
|
|
207
|
+
out.append(n.id)
|
|
208
|
+
return out[::-1]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def entity_literal(node: ast.AST, entities: dict[str, list[str]]) -> str:
|
|
212
|
+
"""An argument that must be a string literal naming an entity type."""
|
|
213
|
+
if isinstance(node, ast.Name) and node.id in entities:
|
|
214
|
+
return node.id # be lenient: count(Slime) == count("Slime")
|
|
215
|
+
if not (isinstance(node, ast.Constant) and isinstance(node.value, str)):
|
|
216
|
+
raise ExprError(f"expected an entity name in quotes, got '{ast.unparse(node)}'")
|
|
217
|
+
if node.value not in entities:
|
|
218
|
+
raise ExprError(f"unknown entity '{node.value}'", did_you_mean(node.value, entities))
|
|
219
|
+
return node.value
|
spawnpoint/gif.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Record a gameplay GIF of a built project (autopilot + screenshots -> optimized GIF).
|
|
2
|
+
|
|
3
|
+
Needs a display (the game renders in a real window for a few seconds).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from PIL import Image
|
|
14
|
+
|
|
15
|
+
from . import godot
|
|
16
|
+
from .manifest import ADDON_DIR
|
|
17
|
+
|
|
18
|
+
RECORD_SCENE = "res://addons/spawn_point/tools/record.tscn"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def record_frames(project: Path, out_dir: Path, seconds: float, fps: int) -> list[Path]:
|
|
22
|
+
# Projects built before the recorder existed get the current tool copied in.
|
|
23
|
+
tools = project / "addons" / "spawn_point" / "tools"
|
|
24
|
+
for name in ("record.gd", "record.tscn"):
|
|
25
|
+
shutil.copy2(ADDON_DIR / "tools" / name, tools / name)
|
|
26
|
+
step = max(1, round(60 / fps))
|
|
27
|
+
total = int(seconds * 60)
|
|
28
|
+
cmd = [godot.find_binary(), "--path", str(project), "--fixed-fps", "60", RECORD_SCENE,
|
|
29
|
+
"--", str(out_dir), str(total), str(step)]
|
|
30
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=seconds * 20 + 60)
|
|
31
|
+
frames = sorted(out_dir.glob("frame_*.png"))
|
|
32
|
+
if not frames:
|
|
33
|
+
raise godot.GodotError("recording produced no frames (is a display available?)", proc.stdout + proc.stderr)
|
|
34
|
+
return frames
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def make_gif(frames: list[Path], out: Path, fps: int, width: int, colors: int = 128) -> Path:
|
|
38
|
+
images = [Image.open(f).convert("RGB") for f in frames]
|
|
39
|
+
w, h = images[0].size
|
|
40
|
+
size = (width, round(h * width / w))
|
|
41
|
+
resample = Image.NEAREST if w % width == 0 else Image.LANCZOS # exact halves keep pixel art crisp
|
|
42
|
+
images = [im.resize(size, resample) for im in images]
|
|
43
|
+
# One shared palette (sampled across the clip) so colors don't flicker between frames.
|
|
44
|
+
sample = Image.new("RGB", (size[0], size[1] * min(6, len(images))))
|
|
45
|
+
for i, im in enumerate(images[:: max(1, len(images) // 6)][:6]):
|
|
46
|
+
sample.paste(im, (0, i * size[1]))
|
|
47
|
+
palette = sample.quantize(colors=colors, method=Image.Quantize.MEDIANCUT)
|
|
48
|
+
quantized = [im.quantize(palette=palette, dither=Image.Dither.NONE) for im in images]
|
|
49
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
quantized[0].save(out, save_all=True, append_images=quantized[1:], duration=round(1000 / fps), loop=0,
|
|
51
|
+
optimize=True, disposal=1)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def record_gif(project: Path, out: Path, seconds: float = 8.0, fps: int = 20, width: int = 640) -> Path:
|
|
56
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
57
|
+
frames = record_frames(project, Path(tmp), seconds, fps)
|
|
58
|
+
return make_gif(frames, out, fps, width)
|
spawnpoint/godot.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Locating and running the Godot binary headless."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
CANDIDATES = ("godot4", "godot", "Godot", "godot-4")
|
|
13
|
+
|
|
14
|
+
# Lines Godot prints at exit in headless mode that are not problems of the game.
|
|
15
|
+
_NOISE = (
|
|
16
|
+
"RID allocations of type",
|
|
17
|
+
"were leaked at exit",
|
|
18
|
+
"ObjectDB instances leaked at exit",
|
|
19
|
+
"resources still in use at exit",
|
|
20
|
+
"at: cleanup",
|
|
21
|
+
"at: clear",
|
|
22
|
+
"at: finalize",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GodotError(RuntimeError):
|
|
27
|
+
def __init__(self, message: str, output: str = ""):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.output = output
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class RunResult:
|
|
34
|
+
returncode: int
|
|
35
|
+
output: str
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def script_errors(self) -> list[str]:
|
|
39
|
+
return find_errors(self.output)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_binary() -> str:
|
|
43
|
+
env = os.environ.get("GODOT_BIN") or os.environ.get("GODOT")
|
|
44
|
+
if env:
|
|
45
|
+
return env
|
|
46
|
+
for c in CANDIDATES:
|
|
47
|
+
path = shutil.which(c)
|
|
48
|
+
if path:
|
|
49
|
+
return path
|
|
50
|
+
raise GodotError("Godot 4 not found. Install it or set GODOT_BIN=/path/to/godot.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def available() -> bool:
|
|
54
|
+
try:
|
|
55
|
+
find_binary()
|
|
56
|
+
return True
|
|
57
|
+
except GodotError:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def version() -> str:
|
|
62
|
+
out = subprocess.run([find_binary(), "--version"], capture_output=True, text=True, timeout=30)
|
|
63
|
+
return out.stdout.strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run(project: Path, args: list[str], timeout: float = 180) -> RunResult:
|
|
67
|
+
cmd = [find_binary(), "--headless", "--path", str(project), *args]
|
|
68
|
+
try:
|
|
69
|
+
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
70
|
+
except subprocess.TimeoutExpired as e:
|
|
71
|
+
out = (e.stdout or b"").decode() if isinstance(e.stdout, bytes) else (e.stdout or "")
|
|
72
|
+
raise GodotError(f"Godot timed out after {timeout}s: {' '.join(cmd)}", out) from e
|
|
73
|
+
return RunResult(p.returncode, (p.stdout or "") + (p.stderr or ""))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def import_project(project: Path) -> RunResult:
|
|
77
|
+
"""Imports resources and refreshes the global class cache (required before running scripts)."""
|
|
78
|
+
return run(project, ["--import"], timeout=300)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def run_scene(project: Path, scene: str, user_args: list[str] | None = None, *, import_first: bool = False,
|
|
82
|
+
fixed_fps: int | None = None, timeout: float = 180, check: bool = True) -> RunResult:
|
|
83
|
+
if import_first:
|
|
84
|
+
import_project(project)
|
|
85
|
+
args: list[str] = []
|
|
86
|
+
if fixed_fps:
|
|
87
|
+
args += ["--fixed-fps", str(fixed_fps)]
|
|
88
|
+
args.append(scene)
|
|
89
|
+
if user_args:
|
|
90
|
+
args += ["--", *user_args]
|
|
91
|
+
result = run(project, args, timeout=timeout)
|
|
92
|
+
if check and (result.returncode != 0 or result.script_errors):
|
|
93
|
+
raise GodotError(f"Godot failed running {scene} (exit {result.returncode})", result.output)
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_ERR = re.compile(r"^(SCRIPT ERROR|ERROR|USER ERROR|Parse Error|Compile Error)\b.*", re.MULTILINE)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def find_errors(output: str) -> list[str]:
|
|
101
|
+
errors = []
|
|
102
|
+
for m in _ERR.finditer(output):
|
|
103
|
+
line = m.group(0)
|
|
104
|
+
if any(n in line for n in _NOISE):
|
|
105
|
+
continue
|
|
106
|
+
errors.append(line.strip())
|
|
107
|
+
return errors
|
spawnpoint/graph.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Game graph schema (v1). Structural validation only; semantics live in analyze.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Literal, Union
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
from .diagnostics import Diagnostics, did_you_mean, join_path
|
|
11
|
+
|
|
12
|
+
HEX_COLOR = re.compile(r"^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Model(BaseModel):
|
|
16
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _check_color(v: str | None) -> str | None:
|
|
20
|
+
if v is not None and not HEX_COLOR.match(v):
|
|
21
|
+
raise ValueError(f"'{v}' is not a hex color like '#29adff'")
|
|
22
|
+
return v
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Shape(_Model):
|
|
26
|
+
type: Literal["rect", "circle", "capsule", "triangle", "spikes", "none"] = "rect"
|
|
27
|
+
size: tuple[float, float] = (28.0, 28.0)
|
|
28
|
+
color: str | None = None
|
|
29
|
+
|
|
30
|
+
_color = field_validator("color")(_check_color)
|
|
31
|
+
|
|
32
|
+
@field_validator("size")
|
|
33
|
+
@classmethod
|
|
34
|
+
def _positive(cls, v: tuple[float, float]) -> tuple[float, float]:
|
|
35
|
+
if v[0] <= 0 or v[1] <= 0 or v[0] > 1024 or v[1] > 1024:
|
|
36
|
+
raise ValueError("size must be within 1..1024 px")
|
|
37
|
+
return v
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SpriteGenerate(_Model):
|
|
41
|
+
kind: str
|
|
42
|
+
color: str | None = None
|
|
43
|
+
features: list[str] | None = None
|
|
44
|
+
seed: int = 0
|
|
45
|
+
|
|
46
|
+
_color = field_validator("color")(_check_color)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SpriteAI(_Model):
|
|
50
|
+
prompt: str = Field(min_length=1, max_length=200)
|
|
51
|
+
kind: str # also the pixel size, animations and the fallback when no image provider is available
|
|
52
|
+
color: str | None = None
|
|
53
|
+
seed: int = 0
|
|
54
|
+
|
|
55
|
+
_color = field_validator("color")(_check_color)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SpriteRef(_Model):
|
|
59
|
+
generate: SpriteGenerate | None = None
|
|
60
|
+
ai: SpriteAI | None = None
|
|
61
|
+
|
|
62
|
+
@model_validator(mode="after")
|
|
63
|
+
def _one(self) -> "SpriteRef":
|
|
64
|
+
if (self.generate is None) == (self.ai is None):
|
|
65
|
+
raise ValueError('use exactly one of "generate" or "ai"')
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Entity(_Model):
|
|
70
|
+
body: Literal["character", "static", "area"] = "character"
|
|
71
|
+
team: Literal["player", "enemy", "neutral"] | None = None
|
|
72
|
+
shape: Shape = Field(default_factory=Shape)
|
|
73
|
+
# Path to a .sprite.json (relative to the graph file) or {"generate": {...}}.
|
|
74
|
+
sprite: Union[str, SpriteRef, None] = None
|
|
75
|
+
components: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
|
76
|
+
juice: Union[Literal["none"], dict[str, Any], None] = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Level(_Model):
|
|
80
|
+
map: list[str]
|
|
81
|
+
legend: dict[str, str]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Rule(_Model):
|
|
85
|
+
on: str
|
|
86
|
+
if_: str | None = Field(default=None, alias="if")
|
|
87
|
+
do: list[str] | str
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def actions(self) -> list[str]:
|
|
91
|
+
return [self.do] if isinstance(self.do, str) else list(self.do)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TileStyle(_Model):
|
|
95
|
+
color: str | None = None
|
|
96
|
+
top: str | None = None
|
|
97
|
+
_color = field_validator("color", "top")(_check_color)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Settings(_Model):
|
|
101
|
+
tile_size: int = Field(default=32, ge=8, le=128)
|
|
102
|
+
viewport: tuple[int, int] = (960, 540)
|
|
103
|
+
lives: int = Field(default=3, ge=1, le=99)
|
|
104
|
+
juice: Literal["default", "none"] = "default"
|
|
105
|
+
background: str | None = None
|
|
106
|
+
gravity: float | None = Field(default=None, ge=0, le=10000)
|
|
107
|
+
seed: int = 0
|
|
108
|
+
tiles: dict[Literal["solid", "oneway"], TileStyle] = Field(default_factory=dict)
|
|
109
|
+
tile_style: Literal["pixel", "flat"] = "pixel"
|
|
110
|
+
zoom: float = Field(default=1.0, ge=0.5, le=4.0)
|
|
111
|
+
|
|
112
|
+
_bg = field_validator("background")(_check_color)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Graph(_Model):
|
|
116
|
+
spawnpoint: Literal[1] = 1
|
|
117
|
+
name: str = Field(min_length=1, max_length=80)
|
|
118
|
+
genre: Literal["platformer", "topdown"]
|
|
119
|
+
settings: Settings = Field(default_factory=Settings)
|
|
120
|
+
entities: dict[str, Entity]
|
|
121
|
+
level: Level
|
|
122
|
+
rules: list[Rule] = Field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse(data: Any) -> tuple[Graph | None, Diagnostics]:
|
|
126
|
+
"""Parse raw JSON data into a Graph, converting pydantic errors into diagnostics."""
|
|
127
|
+
diags = Diagnostics()
|
|
128
|
+
try:
|
|
129
|
+
return Graph.model_validate(data), diags
|
|
130
|
+
except ValidationError as e:
|
|
131
|
+
found = []
|
|
132
|
+
for err in e.errors():
|
|
133
|
+
# Drop pydantic's union-branch tags ("str", "SpriteRef", "literal['none']", ...).
|
|
134
|
+
loc = [p for p in err["loc"] if not (isinstance(p, str) and ("[" in p or "-" in p or p in _TYPE_TAGS))]
|
|
135
|
+
path = join_path(*loc) if loc else "(root)"
|
|
136
|
+
msg = err["msg"]
|
|
137
|
+
hint = None
|
|
138
|
+
if err["type"] == "extra_forbidden":
|
|
139
|
+
field = str(loc[-1])
|
|
140
|
+
hint = did_you_mean(field, _allowed_fields(loc[:-1]))
|
|
141
|
+
msg = f"unknown field '{field}'"
|
|
142
|
+
elif err["type"] == "missing":
|
|
143
|
+
msg = f"missing required field '{loc[-1]}'"
|
|
144
|
+
found.append((path, msg, hint))
|
|
145
|
+
# A union reports one error per branch; keep only the most specific ones.
|
|
146
|
+
paths = {p for p, _, _ in found}
|
|
147
|
+
for path, msg, hint in dict.fromkeys(found):
|
|
148
|
+
if not any(o != path and o.startswith(path + ".") for o in paths):
|
|
149
|
+
diags.error("E_SCHEMA", path, msg, hint)
|
|
150
|
+
return None, diags
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_TYPE_TAGS = {"str", "int", "float", "bool", "SpriteRef", "SpriteGenerate", "SpriteAI", "Shape", "Entity"}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# Which model lives under a field name (dict/list containers skip their key/index).
|
|
157
|
+
_CHILD = {"settings": (Settings, 1), "shape": (Shape, 1), "level": (Level, 1), "generate": (SpriteGenerate, 1),
|
|
158
|
+
"ai": (SpriteAI, 1),
|
|
159
|
+
"sprite": (SpriteRef, 1),
|
|
160
|
+
"entities": (Entity, 2), "rules": (Rule, 2)}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _allowed_fields(parent_loc: list) -> list[str]:
|
|
164
|
+
"""Valid field names at a location (for typo hints)."""
|
|
165
|
+
model: type[BaseModel] | None = Graph
|
|
166
|
+
i = 0
|
|
167
|
+
while model is not None and i < len(parent_loc):
|
|
168
|
+
child = _CHILD.get(str(parent_loc[i]))
|
|
169
|
+
if child is None:
|
|
170
|
+
return []
|
|
171
|
+
model, step = child
|
|
172
|
+
i += step
|
|
173
|
+
if model is None:
|
|
174
|
+
return []
|
|
175
|
+
return [f.alias or name for name, f in model.model_fields.items()]
|