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,341 @@
1
+ """Studio orchestrator: prompt -> brief -> layered builds -> web; and tweaks of an existing game."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import zlib
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Callable
11
+
12
+ from .. import manifest as manifest_mod
13
+ from ..analyze import analyze
14
+ from ..build import BuildResult, build
15
+ from ..manifest import Manifest
16
+ from . import web
17
+ from .assemble import LAYERS, ai_art_from_env, assemble, chunks_fit, plan_chunks
18
+ from .brief import GameBrief, json_schema, normalize
19
+ from .creative import CreativeResult, creative_pass
20
+ from .dataset import Recorder
21
+ from .llm import ChatModel, LLMError, Usage
22
+ from .prompts import (TRANSLATE_SCHEMA, brief_messages, needs_translation, translate_messages,
23
+ tweak_messages)
24
+
25
+ SESSION_FILE = "studio.json"
26
+ SEED_ATTEMPTS = 8
27
+
28
+ TWEAK_SCHEMA_NAME = "BriefUpdate"
29
+
30
+
31
+ class StudioError(RuntimeError):
32
+ pass
33
+
34
+
35
+ @dataclass
36
+ class LayerReport:
37
+ layer: int
38
+ build: BuildResult
39
+ web_seconds: float | None = None
40
+
41
+
42
+ @dataclass
43
+ class StudioResult:
44
+ out_dir: Path
45
+ brief: GameBrief
46
+ notes: list[str]
47
+ seed: int
48
+ layers: list[LayerReport] = field(default_factory=list)
49
+ creative: CreativeResult | None = None
50
+ changes: list[str] = field(default_factory=list)
51
+ usage: Usage = field(default_factory=Usage)
52
+ timings: dict[str, float] = field(default_factory=dict)
53
+ playtest: object | None = None # playtest.PlaytestResult when --playtest ran
54
+
55
+ @property
56
+ def ok(self) -> bool:
57
+ return bool(self.layers) and all(r.build.ok for r in self.layers)
58
+
59
+
60
+ LayerCallback = Callable[[LayerReport], None]
61
+
62
+
63
+ # --- brief ------------------------------------------------------------------------------------
64
+
65
+
66
+ def design_brief(prompt: str, llm: ChatModel) -> tuple[GameBrief, list[str], str]:
67
+ """Returns (normalized brief, notes, the English prompt actually used)."""
68
+ english = prompt
69
+ if needs_translation(prompt):
70
+ english = llm.chat_json(translate_messages(prompt), TRANSLATE_SCHEMA, "Translation",
71
+ temperature=0.1, max_tokens=300).get("english") or prompt
72
+ raw = llm.chat_json(brief_messages(english), json_schema(), "GameBrief", temperature=0.4)
73
+ try:
74
+ brief = GameBrief.model_validate(raw)
75
+ except Exception as e: # guided decoding makes this rare; retry once with the error
76
+ raise StudioError(f"the LLM produced an invalid brief: {e}") from e
77
+ brief, notes = normalize(brief)
78
+ return brief, notes, english
79
+
80
+
81
+ def _playable(brief: GameBrief, seed: int, chunks: list[str] | None, manifest: Manifest) -> bool:
82
+ analysis = analyze(assemble(brief, LAYERS[-1], seed, chunks), manifest)
83
+ return analysis.ok and not any(d.code == "W_UNREACHABLE" for d in analysis.diagnostics)
84
+
85
+
86
+ def choose_seed(brief: GameBrief, seed: int, manifest: Manifest) -> tuple[int, list[str] | None]:
87
+ """First seed (from `seed`) whose full game has no reachability warnings, and its chunk plan."""
88
+ for s in range(seed, seed + SEED_ATTEMPTS):
89
+ chunks = plan_chunks(brief, s)
90
+ if _playable(brief, s, chunks, manifest):
91
+ return s, chunks
92
+ return seed, plan_chunks(brief, seed)
93
+
94
+
95
+ def default_seed(prompt: str) -> int:
96
+ return zlib.crc32(prompt.encode()) % 100_000
97
+
98
+
99
+ # --- create -----------------------------------------------------------------------------------
100
+
101
+
102
+ def create(prompt: str, out_dir: Path | Callable[[GameBrief], Path], llm: ChatModel, *, seed: int | None = None, creative: bool = False,
103
+ brief: GameBrief | None = None, web_export: bool = True,
104
+ on_layer: LayerCallback | None = None, on_brief: Callable[[GameBrief, list[str]], None] | None = None,
105
+ manifest: Manifest | None = None, recorder: Recorder | None = None, playtest: bool = False,
106
+ on_progress: Callable[[str], None] | None = None) -> StudioResult:
107
+ result = None
108
+ try:
109
+ result = _create(prompt, out_dir, recorder.wrap(llm) if recorder else llm, seed, creative, brief, web_export,
110
+ on_layer, on_brief, manifest, recorder)
111
+ if playtest and result.ok:
112
+ result.playtest = studio_playtest(result.out_dir, tune=True, web_export=web_export, manifest=manifest,
113
+ on_progress=on_progress)
114
+ return result
115
+ finally:
116
+ if recorder:
117
+ recorder.flush(accepted=bool(result and result.ok),
118
+ meta={"op": "create", "prompt": prompt, "seed": result.seed if result else None})
119
+
120
+
121
+ def _create(prompt, out_dir, llm, seed, creative, brief, web_export, on_layer, on_brief, manifest,
122
+ recorder: Recorder | None) -> StudioResult:
123
+ manifest = manifest or manifest_mod.load()
124
+ t0 = time.perf_counter()
125
+ timings: dict[str, float] = {}
126
+ english = prompt
127
+ if brief is None:
128
+ brief, notes, english = design_brief(prompt, llm)
129
+ if recorder:
130
+ _annotate_design(recorder, brief, notes)
131
+ else:
132
+ brief, notes = normalize(brief)
133
+ timings["design"] = time.perf_counter() - t0
134
+ if on_brief:
135
+ on_brief(brief, notes)
136
+ if callable(out_dir): # e.g. a folder named after the designed title
137
+ out_dir = out_dir(brief)
138
+
139
+ seed, chunks = choose_seed(brief, default_seed(prompt) if seed is None else seed, manifest)
140
+ ai_art = ai_art_from_env()
141
+ result = StudioResult(out_dir, brief, notes, seed, usage=llm.usage)
142
+ creative_result = None
143
+ for layer in LAYERS:
144
+ graph = assemble(brief, layer, seed, chunks, ai_art=ai_art)
145
+ if layer == LAYERS[-1] and creative:
146
+ t = time.perf_counter()
147
+ creative_result = creative_pass(graph, english, llm, manifest)
148
+ timings["creative"] = time.perf_counter() - t
149
+ graph = creative_result.graph
150
+ if recorder:
151
+ for rec, h in zip(recorder.all("creative"), creative_result.history):
152
+ rec.errors = h["errors"]
153
+ rec.accepted = not h["errors"]
154
+ rec.target = h["patch"] if not h["errors"] else None
155
+ rec.corrected = bool(h["errors"])
156
+ report = _build_layer(layer, graph, out_dir, manifest, web_export)
157
+ result.layers.append(report)
158
+ if on_layer:
159
+ on_layer(report)
160
+ if not report.build.ok:
161
+ break
162
+ result.creative = creative_result
163
+ timings["total"] = time.perf_counter() - t0
164
+ result.timings = timings
165
+ _save_session(out_dir, {"prompt": prompt, "english": english, "brief": brief.model_dump(), "seed": seed,
166
+ "chunks": chunks, "ai_art": ai_art,
167
+ "creative_patch": creative_result.patch if creative_result else None,
168
+ "model": llm.name, "history": []})
169
+ return result
170
+
171
+
172
+ def _annotate_design(recorder: Recorder, brief: GameBrief, notes: list[str]) -> None:
173
+ tr = recorder.last("translate")
174
+ if tr:
175
+ tr.target, tr.corrected = tr.raw, False
176
+ rec = recorder.last("brief")
177
+ if rec:
178
+ rec.target = brief.model_dump() # the normalized brief is the answer the model should have given
179
+ rec.notes = notes
180
+ rec.corrected = rec.target != GameBrief.model_validate(rec.raw).model_dump()
181
+
182
+
183
+ def finalize_graph(graph: dict, session: dict, manifest: Manifest, tuning: bool = True) -> dict:
184
+ """Re-applies the session's creative patch, playtest tuning and cockpit overrides (in that order,
185
+ so a person's edit always wins over the tuner) to a freshly assembled graph. Each is kept only
186
+ while it still fits the game (a tweak may have removed an entity)."""
187
+ from ..analyze import analyze
188
+ from .creative import apply_patch
189
+
190
+ def try_apply(g: dict, patch: dict) -> dict:
191
+ candidate = apply_patch(g, patch)
192
+ return candidate if analyze(candidate, manifest).ok else g
193
+
194
+ if session.get("creative_patch"):
195
+ graph = try_apply(graph, session["creative_patch"])
196
+ entries = [t for t in (session.get("tuning") or []) if tuning
197
+ and t["component"] in graph["entities"].get(t["entity"], {}).get("components", {})]
198
+ if entries:
199
+ graph = try_apply(graph, {"set_params": entries, "add_rules": []})
200
+ for override in session.get("overrides") or []: # cockpit edits, oldest first
201
+ graph = try_apply(graph, override)
202
+ return graph
203
+
204
+
205
+ def studio_playtest(out_dir: Path, *, runs: int = 3, tune: bool = True, rounds: int = 2, web_export: bool = True,
206
+ manifest: Manifest | None = None, on_progress: Callable[[str], None] | None = None):
207
+ """Playtests a studio game; accepted tuning is stored in the session so tweaks keep it."""
208
+ from .. import playtest as pt
209
+
210
+ manifest = manifest or manifest_mod.load()
211
+ session = load_session(out_dir)
212
+ brief = GameBrief.model_validate(session["brief"])
213
+ original = finalize_graph(assemble(brief, LAYERS[-1], session["seed"], session.get("chunks"),
214
+ ai_art=bool(session.get("ai_art"))), session,
215
+ manifest, tuning=False)
216
+ res = pt.playtest(out_dir, runs=runs, tune=tune, rounds=rounds, manifest=manifest, original=original,
217
+ difficulty=brief.difficulty, brief=session["brief"], on_progress=on_progress)
218
+ if res.changes:
219
+ session["tuning"] = pt.merge_tuning(session.get("tuning") or [], res.changes)
220
+ _save_session(out_dir, session)
221
+ if web_export and (out_dir / web.WEB_DIR / "index.html").exists():
222
+ web.export(out_dir)
223
+ return res
224
+
225
+
226
+ def _build_layer(layer: int, graph: dict, out_dir: Path, manifest: Manifest, web_export: bool) -> LayerReport:
227
+ res = build(graph, out_dir, manifest=manifest, smoke=True)
228
+ report = LayerReport(layer, res)
229
+ if res.ok and web_export:
230
+ report.web_seconds = web.export(out_dir)
231
+ return report
232
+
233
+
234
+ # --- tweak ------------------------------------------------------------------------------------
235
+
236
+
237
+ def tweak_schema() -> dict:
238
+ schema = json_schema()
239
+ defs = schema.pop("$defs", {})
240
+ return {"type": "object", "$defs": defs,
241
+ "properties": {"brief": schema, "new_level": {"type": "boolean"}},
242
+ "required": ["brief", "new_level"], "additionalProperties": False}
243
+
244
+
245
+ def tweak(out_dir: Path, request: str, llm: ChatModel, *, web_export: bool = True,
246
+ on_layer: LayerCallback | None = None, manifest: Manifest | None = None,
247
+ recorder: Recorder | None = None) -> StudioResult:
248
+ if recorder is None:
249
+ return _tweak(out_dir, request, llm, web_export, on_layer, manifest, None)
250
+ result = None
251
+ try:
252
+ result = _tweak(out_dir, request, recorder.wrap(llm), web_export, on_layer, manifest, recorder)
253
+ return result
254
+ finally:
255
+ recorder.flush(accepted=bool(result and result.ok),
256
+ meta={"op": "tweak", "request": request, "changes": result.changes if result else None})
257
+
258
+
259
+ def _tweak(out_dir, request, llm, web_export, on_layer, manifest, recorder: Recorder | None) -> StudioResult:
260
+ manifest = manifest or manifest_mod.load()
261
+ session = load_session(out_dir)
262
+ old = GameBrief.model_validate(session["brief"])
263
+ t0 = time.perf_counter()
264
+ english = request
265
+ if needs_translation(request):
266
+ english = llm.chat_json(translate_messages(request), TRANSLATE_SCHEMA, "Translation",
267
+ temperature=0.1, max_tokens=300).get("english") or request
268
+ raw = llm.chat_json(tweak_messages(old.model_dump(), english), tweak_schema(), TWEAK_SCHEMA_NAME,
269
+ temperature=0.1)
270
+ try:
271
+ new = GameBrief.model_validate(raw["brief"])
272
+ except Exception as e:
273
+ raise StudioError(f"the LLM produced an invalid brief update: {e}") from e
274
+ new, notes = normalize(new)
275
+ if recorder:
276
+ tr = recorder.last("translate")
277
+ if tr:
278
+ tr.target, tr.corrected = tr.raw, False
279
+ rec = recorder.last("tweak")
280
+ if rec:
281
+ rec.target = {"brief": new.model_dump(), "new_level": bool(raw.get("new_level"))}
282
+ rec.notes = notes
283
+ rec.corrected = rec.target["brief"] != GameBrief.model_validate(raw["brief"]).model_dump()
284
+ seed, chunks = session["seed"], session.get("chunks")
285
+ if raw.get("new_level"):
286
+ seed, chunks = choose_seed(new, seed + 1000, manifest)
287
+ elif new.genre != "platformer":
288
+ if new.genre != old.genre or not _playable(new, seed, None, manifest):
289
+ seed, chunks = choose_seed(new, seed, manifest)
290
+ elif not (chunks_fit(new, chunks) and _playable(new, seed, chunks, manifest)):
291
+ # The kept layout no longer works for the changed player (e.g. lower jump): re-plan.
292
+ seed, chunks = choose_seed(new, seed, manifest)
293
+ changes = diff_briefs(old.model_dump(), new.model_dump())
294
+ if raw.get("new_level"):
295
+ changes.append("new level layout")
296
+ timings = {"design": time.perf_counter() - t0}
297
+
298
+ result = StudioResult(out_dir, new, notes, seed, changes=changes, usage=llm.usage)
299
+ graph = finalize_graph(assemble(new, LAYERS[-1], seed, chunks, ai_art=bool(session.get("ai_art"))), session,
300
+ manifest)
301
+ report = _build_layer(LAYERS[-1], graph, out_dir, manifest, web_export)
302
+ result.layers.append(report)
303
+ if on_layer:
304
+ on_layer(report)
305
+ timings["total"] = time.perf_counter() - t0
306
+ result.timings = timings
307
+ if report.build.ok:
308
+ session["history"].append({"request": request, "english": english, "changes": changes})
309
+ session.update(brief=new.model_dump(), seed=seed, chunks=chunks)
310
+ _save_session(out_dir, session)
311
+ return result
312
+
313
+
314
+ def diff_briefs(old: dict, new: dict, prefix: str = "") -> list[str]:
315
+ out = []
316
+ for key in sorted(set(old) | set(new)):
317
+ a, b = old.get(key), new.get(key)
318
+ path = f"{prefix}{key}"
319
+ if isinstance(a, dict) and isinstance(b, dict):
320
+ out += diff_briefs(a, b, path + ".")
321
+ elif a != b:
322
+ out.append(f"{path}: {json.dumps(a, ensure_ascii=False)} -> {json.dumps(b, ensure_ascii=False)}")
323
+ return out
324
+
325
+
326
+ # --- session ----------------------------------------------------------------------------------
327
+
328
+
329
+ def load_session(out_dir: Path) -> dict:
330
+ path = out_dir / SESSION_FILE
331
+ if not path.exists():
332
+ raise StudioError(f"{out_dir} was not created by `spawnpoint create` (no {SESSION_FILE})")
333
+ return json.loads(path.read_text())
334
+
335
+
336
+ def _save_session(out_dir: Path, data: dict) -> None:
337
+ out_dir.mkdir(parents=True, exist_ok=True)
338
+ (out_dir / SESSION_FILE).write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
339
+
340
+
341
+ __all__ = ["create", "tweak", "design_brief", "StudioError", "StudioResult", "LLMError"]
@@ -0,0 +1,122 @@
1
+ """Prompts. Written for small local models: explicit rules, one worked example, common mistakes named."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ BRIEF_SYSTEM = """You are the lead designer of a tiny 2D game studio. Turn the user's game idea into a GameBrief JSON.
8
+
9
+ Fields:
10
+ - title: short catchy game title.
11
+ - genre: "platformer" (side view, gravity, jumping between platforms) or "topdown" (seen from above, moves in 8 directions, usually shooting).
12
+ - palette: night | forest | desert | neon | candy | ocean | lava | snow. Pick the one matching the theme.
13
+ - player.character: what the player is, one word in PascalCase (e.g. "Ninja", "Fox", "Astronaut").
14
+ - player.abilities: any of double_jump, dash, stomp (jump on enemies to defeat them), shoot. Top-down games usually have shoot and dash.
15
+ - player.jump_tiles: jump height in tiles, 3-4 is normal. player.speed: slow | normal | fast. player.health: 3 is normal.
16
+ - enemies: 1 to 3 enemy types. Each has
17
+ name: one singular noun in PascalCase, e.g. "Slime", "FireBat", "Robot".
18
+ archetype: walker (patrols back and forth; platformer), hopper (bounces around; platformer),
19
+ chaser (runs at the player), shooter (keeps distance and shoots), turret (stands still and shoots).
20
+ count: how many (2-6 each), health: 1-3, speed: slow | normal | fast.
21
+ - collectible: the item the player picks up. name: singular noun in PascalCase (e.g. "Star"); count: 5-20.
22
+ - health_pickups: 0-3 hearts that heal.
23
+ - goal: reach_exit | collect_all | collect_all_then_exit | defeat_all | score_target | survive.
24
+ score_target and survive_seconds are only used by those goals.
25
+ - hazards: any of spikes, pits (platformer only).
26
+ - length: short | medium | long. difficulty: 1-5 (2 is normal).
27
+ - feel: snappy (tight, default) | floaty (moon-like, slow falls) | heavy (weighty).
28
+ - juice: subtle | normal | wild (how strong screen shake and impacts are).
29
+ - intro: one short sentence shown when the game starts, telling the player the goal.
30
+
31
+ Common mistakes to avoid:
32
+ - The player is never an enemy or a collectible.
33
+ - Platforms, walls, floors and the level itself are NOT enemies or collectibles; the level is generated automatically.
34
+ - Names are singular ("Star", not "Stars").
35
+ - Only use the archetypes listed above."""
36
+
37
+ BRIEF_EXAMPLE_USER = "a space game where I blast alien bugs in an arena"
38
+ BRIEF_EXAMPLE = {
39
+ "title": "Bug Blaster", "genre": "topdown", "palette": "neon",
40
+ "player": {"character": "Astronaut", "shape": "circle", "abilities": ["shoot", "dash"], "jump_tiles": 3.5, "speed": "normal", "health": 5},
41
+ "enemies": [{"name": "Beetle", "archetype": "chaser", "count": 5, "health": 2, "speed": "normal"},
42
+ {"name": "Spitter", "archetype": "shooter", "count": 2, "health": 3, "speed": "slow"}],
43
+ "collectible": {"name": "Crystal", "count": 8}, "health_pickups": 2, "goal": "score_target",
44
+ "score_target": 150, "survive_seconds": 60, "hazards": [], "length": "medium", "difficulty": 2,
45
+ "feel": "snappy", "juice": "wild", "intro": "Blast 150 points of bugs!",
46
+ }
47
+
48
+
49
+ def brief_messages(prompt: str) -> list[dict]:
50
+ return [
51
+ {"role": "system", "content": BRIEF_SYSTEM},
52
+ {"role": "user", "content": BRIEF_EXAMPLE_USER},
53
+ {"role": "assistant", "content": json.dumps(BRIEF_EXAMPLE)},
54
+ {"role": "user", "content": prompt},
55
+ ]
56
+
57
+
58
+ TWEAK_SYSTEM = """You edit GameBrief JSON for a tiny 2D game studio.
59
+ You get the current brief and a change request from the player. Return the updated brief.
60
+
61
+ Rules:
62
+ - Change ONLY the fields the request is about. Copy every other field exactly as it is.
63
+ - Percentages scale numbers: "jump 20% higher" with jump_tiles 3.5 -> 4.2. "a bit" means about 15%.
64
+ - "more enemies" increases counts; "harder" raises difficulty by 1; "add dash" adds "dash" to player.abilities.
65
+ - Allowed values are the same as in the brief: archetypes walker, hopper, chaser, shooter, turret; abilities
66
+ double_jump, dash, stomp, shoot; palettes night, forest, desert, neon, candy, ocean, lava, snow.
67
+ - Set new_level to true only if the player asks for a different level, map or layout."""
68
+
69
+
70
+ def tweak_messages(brief: dict, request: str) -> list[dict]:
71
+ return [
72
+ {"role": "system", "content": TWEAK_SYSTEM},
73
+ {"role": "user", "content": f"Current brief:\n{json.dumps(brief)}\n\nChange request: {request}"},
74
+ ]
75
+
76
+
77
+ CREATIVE_SYSTEM = """You are a game designer polishing a 2D game made of components.
78
+ Propose a small patch that makes the game more fun and closer to the player's idea:
79
+ - set_params: change component parameters of existing entities (only parameters listed in the reference, within range).
80
+ - add_rules: new rules {"on": event, "if": condition or "", "do": [actions]}.
81
+ Keep it small: at most 4 set_params and 3 add_rules. Do not repeat existing rules.
82
+
83
+ Rule syntax:
84
+ - events: Entity.signal (e.g. "Slime.died", "Star.collected"), "GameState.score_changed", "GameState.second_passed", "Game.ready".
85
+ - conditions: Python-like expressions using score, lives, time, vars.NAME, event.ARG, count("Entity").
86
+ - actions: add_score(n), set_var("name", v), add_var("name", d), win(), lose(), spawn("Entity") (at the emitting entity),
87
+ spawn_at("Entity", tile_x, tile_y), destroy(self), shake(0..1), freeze(seconds), sound("coin"), message("text"),
88
+ Entity.method(args) using methods from the reference (e.g. Door.open())."""
89
+
90
+
91
+ def creative_messages(prompt: str, graph_summary: str, reference: str) -> list[dict]:
92
+ return [
93
+ {"role": "system", "content": CREATIVE_SYSTEM},
94
+ {"role": "user", "content": f"Player's idea: {prompt}\n\nGame:\n{graph_summary}\n\nComponent reference:\n{reference}"},
95
+ ]
96
+
97
+
98
+ def repair_message(errors: list[str]) -> dict:
99
+ return {"role": "user", "content": "The compiler rejected the patch:\n- " + "\n- ".join(errors)
100
+ + "\nReturn a corrected patch (drop anything you cannot fix)."}
101
+
102
+
103
+ TRANSLATE_SYSTEM = """Translate the user's game idea into clear English for a game designer.
104
+ Keep every detail: characters, enemies, items, goals, numbers. Output JSON.
105
+ Vietnamese game terms: "sao"/"ngôi sao" = star, "ăn"/"nhặt" an item = collect it, "qua màn" = finish the level,
106
+ "quái"/"quái vật" = enemies/monsters, "né" = avoid/dodge, "bắn" = shoot, "nhảy" = jump, "bục" = platform,
107
+ "sống sót" = survive, "tiêu diệt hết" = defeat all, "cửa" = door/exit, "gai" = spikes, "hố" = pits."""
108
+
109
+ TRANSLATE_SCHEMA = {
110
+ "type": "object",
111
+ "properties": {"english": {"type": "string"}},
112
+ "required": ["english"],
113
+ "additionalProperties": False,
114
+ }
115
+
116
+
117
+ def translate_messages(prompt: str) -> list[dict]:
118
+ return [{"role": "system", "content": TRANSLATE_SYSTEM}, {"role": "user", "content": prompt}]
119
+
120
+
121
+ def needs_translation(prompt: str) -> bool:
122
+ return any(ord(ch) > 127 for ch in prompt)
@@ -0,0 +1,123 @@
1
+ """Web export of a built project and a tiny live-reloading static server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import json
7
+ import socket
8
+ import threading
9
+ import time
10
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
11
+ from pathlib import Path
12
+
13
+ from .. import godot
14
+
15
+ WEB_DIR = "web"
16
+ VERSION_FILE = "version.json"
17
+
18
+ # Polls version.json and reloads the page when a new build lands.
19
+ LIVE_RELOAD = (
20
+ "<script>(function(){var v=null;setInterval(function(){"
21
+ "fetch('version.json?'+Date.now(),{cache:'no-store'}).then(function(r){return r.ok?r.json():null})"
22
+ ".then(function(j){if(!j)return;if(v!==null&&j.version!==v)location.reload();v=j.version;})"
23
+ ".catch(function(){});},1000);})();</script>"
24
+ )
25
+
26
+
27
+ def export_preset(head_include: str = LIVE_RELOAD) -> str:
28
+ return "\n".join([
29
+ "[preset.0]",
30
+ "",
31
+ 'name="Web"',
32
+ 'platform="Web"',
33
+ "runnable=true",
34
+ "dedicated_server=false",
35
+ 'custom_features=""',
36
+ 'export_filter="all_resources"',
37
+ 'include_filter=""',
38
+ 'exclude_filter="build_plan.json,spawnpoint.json,studio.json"',
39
+ f'export_path="{WEB_DIR}/index.html"',
40
+ "",
41
+ "[preset.0.options]",
42
+ "",
43
+ 'custom_template/debug=""',
44
+ 'custom_template/release=""',
45
+ "variant/extensions_support=false",
46
+ # Single-threaded build: plays from any static server, no COOP/COEP headers needed.
47
+ "variant/thread_support=false",
48
+ "vram_texture_compression/for_desktop=true",
49
+ "vram_texture_compression/for_mobile=false",
50
+ "html/export_icon=true",
51
+ 'html/custom_html_shell=""',
52
+ f"html/head_include={json.dumps(head_include)}",
53
+ "html/canvas_resize_policy=2",
54
+ "html/focus_canvas_on_start=true",
55
+ "html/experimental_virtual_keyboard=false",
56
+ "progressive_web_app/enabled=false",
57
+ "",
58
+ ])
59
+
60
+
61
+ def export(project: Path) -> float:
62
+ """Exports the project to <project>/web and bumps the live-reload version. Returns seconds."""
63
+ t0 = time.perf_counter()
64
+ web = project / WEB_DIR
65
+ web.mkdir(exist_ok=True)
66
+ (web / ".gdignore").write_text("") # keep Godot from importing the build output
67
+ (project / "export_presets.cfg").write_text(export_preset())
68
+ result = godot.run(project, ["--export-release", "Web", f"{WEB_DIR}/index.html"], timeout=300)
69
+ if result.returncode != 0 or not (web / "index.html").exists():
70
+ raise godot.GodotError("web export failed (are the Godot web export templates installed?)", result.output)
71
+ bump_version(project)
72
+ return time.perf_counter() - t0
73
+
74
+
75
+ def bump_version(project: Path) -> int:
76
+ path = project / WEB_DIR / VERSION_FILE
77
+ try:
78
+ version = json.loads(path.read_text())["version"] + 1
79
+ except (OSError, ValueError, KeyError):
80
+ version = 1
81
+ path.write_text(json.dumps({"version": version}))
82
+ return version
83
+
84
+
85
+ class _Handler(SimpleHTTPRequestHandler):
86
+ extensions_map = {**SimpleHTTPRequestHandler.extensions_map, ".wasm": "application/wasm",
87
+ ".pck": "application/octet-stream", ".js": "text/javascript"}
88
+
89
+ def end_headers(self) -> None:
90
+ self.send_header("Cache-Control", "no-store")
91
+ super().end_headers()
92
+
93
+ def log_message(self, format: str, *args) -> None: # quiet
94
+ pass
95
+
96
+
97
+ def free_port(start: int) -> int:
98
+ for port in range(start, start + 50):
99
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
100
+ if s.connect_ex(("127.0.0.1", port)) != 0:
101
+ return port
102
+ raise OSError(f"no free port in {start}..{start + 49}")
103
+
104
+
105
+ class Server:
106
+ """Serves <project>/web on localhost in a background thread."""
107
+
108
+ def __init__(self, project: Path, port: int = 8060):
109
+ self.port = free_port(port)
110
+ handler = functools.partial(_Handler, directory=str(project / WEB_DIR))
111
+ self.httpd = ThreadingHTTPServer(("127.0.0.1", self.port), handler)
112
+ self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
113
+
114
+ @property
115
+ def url(self) -> str:
116
+ return f"http://localhost:{self.port}/"
117
+
118
+ def start(self) -> "Server":
119
+ self.thread.start()
120
+ return self
121
+
122
+ def stop(self) -> None:
123
+ self.httpd.shutdown()