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,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)
|
spawnpoint/studio/web.py
ADDED
|
@@ -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()
|