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
spawnpoint/build.py ADDED
@@ -0,0 +1,146 @@
1
+ """The full pipeline: validate -> write project -> Godot builds scenes -> smoke run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from . import godot, manifest as manifest_mod, project, sfx
13
+ from .analyze import Analysis, analyze
14
+ from .diagnostics import Diagnostics
15
+ from .manifest import Manifest
16
+
17
+ BUILD_SCENE = "res://addons/spawn_point/tools/build.tscn"
18
+ SMOKE_SCENE = "res://addons/spawn_point/tools/smoke.tscn"
19
+
20
+
21
+ @dataclass
22
+ class BuildResult:
23
+ out_dir: Path | None
24
+ diagnostics: Diagnostics
25
+ timings: dict[str, float] = field(default_factory=dict)
26
+ smoke: dict[str, Any] | None = None
27
+ log: str = ""
28
+
29
+ @property
30
+ def ok(self) -> bool:
31
+ return self.diagnostics.ok and self.out_dir is not None
32
+
33
+ def to_dict(self) -> dict:
34
+ return {
35
+ "ok": self.ok,
36
+ "out_dir": str(self.out_dir) if self.out_dir else None,
37
+ "diagnostics": [d.to_dict() for d in self.diagnostics],
38
+ "timings": {k: round(v, 3) for k, v in self.timings.items()},
39
+ "smoke": self.smoke,
40
+ }
41
+
42
+
43
+ def load_graph(path: Path) -> Any:
44
+ return json.loads(Path(path).read_text())
45
+
46
+
47
+ def validate(data: Any, manifest: Manifest | None = None, base_dir: Path | None = None) -> Analysis:
48
+ return analyze(data, manifest or manifest_mod.load(), base_dir)
49
+
50
+
51
+ def build(data: Any, out_dir: Path, *, manifest: Manifest | None = None, smoke: bool = True,
52
+ base_dir: Path | None = None) -> BuildResult:
53
+ """`base_dir` is the graph file's folder (for relative sprite paths)."""
54
+ timings: dict[str, float] = {}
55
+ t0 = time.perf_counter()
56
+ if base_dir is not None:
57
+ data = absolutize_sprites(data, base_dir) # the project's spawnpoint.json must stand alone
58
+ analysis = validate(data, manifest, base_dir)
59
+ timings["validate"] = time.perf_counter() - t0
60
+ if not analysis.ok:
61
+ return BuildResult(None, analysis.diagnostics, timings)
62
+ diags = analysis.diagnostics
63
+ plan = analysis.plan
64
+ assert plan is not None and analysis.rules_source is not None
65
+
66
+ t = time.perf_counter()
67
+ feature = _godot_feature()
68
+ project.write(out_dir, data, plan, analysis.rules_source, feature, analysis.sprites, analysis.tiles_image)
69
+ sfx.write_all(out_dir / "sfx", seed=analysis.graph.settings.seed)
70
+ timings["write"] = time.perf_counter() - t
71
+
72
+ log: list[str] = []
73
+ t = time.perf_counter()
74
+ imp = godot.import_project(out_dir)
75
+ log.append(imp.output)
76
+ timings["godot_import"] = time.perf_counter() - t
77
+
78
+ t = time.perf_counter()
79
+ res = godot.run_scene(out_dir, BUILD_SCENE, check=False)
80
+ log.append(res.output)
81
+ timings["godot_build"] = time.perf_counter() - t
82
+ errors = res.script_errors + [ln for ln in res.output.splitlines() if "SPAWNPOINT_BUILD_ERROR" in ln]
83
+ if res.returncode != 0 or "SPAWNPOINT_BUILD_OK" not in res.output or errors:
84
+ for e in errors or [f"Godot build exited with code {res.returncode}"]:
85
+ diags.error("E_GODOT_BUILD", "(build)", e)
86
+ return BuildResult(out_dir, diags, timings, log="\n".join(log))
87
+
88
+ report = None
89
+ if smoke:
90
+ t = time.perf_counter()
91
+ res = godot.run_scene(out_dir, SMOKE_SCENE, fixed_fps=60, check=False, timeout=120)
92
+ log.append(res.output)
93
+ timings["smoke"] = time.perf_counter() - t
94
+ report = _smoke_report(res.output)
95
+ for e in res.script_errors:
96
+ diags.error("E_RUNTIME", "(smoke)", e)
97
+ if report is None:
98
+ diags.error("E_RUNTIME", "(smoke)", "smoke run produced no report (crash?)")
99
+ else:
100
+ _judge_smoke(report, plan, diags)
101
+ timings["total"] = time.perf_counter() - t0
102
+ return BuildResult(out_dir, diags, timings, report, "\n".join(log))
103
+
104
+
105
+ def absolutize_sprites(data: Any, base_dir: Path) -> Any:
106
+ if not isinstance(data, dict) or not isinstance(data.get("entities"), dict):
107
+ return data
108
+ out = json.loads(json.dumps(data))
109
+ for ent in out["entities"].values():
110
+ ref = ent.get("sprite") if isinstance(ent, dict) else None
111
+ if isinstance(ref, str) and not Path(ref).is_absolute():
112
+ ent["sprite"] = str((base_dir / ref).resolve())
113
+ return out
114
+
115
+
116
+ def _godot_feature() -> str:
117
+ try:
118
+ m = re.match(r"(\d+)\.(\d+)", godot.version())
119
+ if m:
120
+ return f"{m.group(1)}.{m.group(2)}"
121
+ except Exception:
122
+ pass
123
+ return "4.6"
124
+
125
+
126
+ def _smoke_report(output: str) -> dict | None:
127
+ for line in output.splitlines():
128
+ if line.startswith("SPAWNPOINT_SMOKE "):
129
+ try:
130
+ return json.loads(line.removeprefix("SPAWNPOINT_SMOKE "))
131
+ except json.JSONDecodeError:
132
+ return None
133
+ return None
134
+
135
+
136
+ def _judge_smoke(report: dict, plan: dict, diags: Diagnostics) -> None:
137
+ if report.get("error"):
138
+ diags.error("E_RUNTIME", "(smoke)", report["error"])
139
+ return
140
+ if not report.get("player_found"):
141
+ diags.error("E_RUNTIME", "(smoke)", "the player entity was not found when the level ran")
142
+ return
143
+ if plan["genre"] == "platformer" and not report.get("on_floor_after_1s"):
144
+ diags.warning("W_SMOKE_FLOOR", "(smoke)", "the player is not standing on a floor after 1 second")
145
+ if report.get("moved_px", 0) < 4:
146
+ diags.warning("W_SMOKE_STUCK", "(smoke)", "the player did not move under scripted input (stuck?)")
spawnpoint/cli.py ADDED
@@ -0,0 +1,290 @@
1
+ """Command line: spawnpoint validate | build | run | components | manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from . import __version__, godot, manifest as manifest_mod
12
+ from .build import build, load_graph, validate
13
+ from .project import slugify
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ ap = argparse.ArgumentParser(prog="spawnpoint", description="Compile JSON game graphs into Godot 4 projects.")
18
+ ap.add_argument("--version", action="version", version=f"spawnpoint {__version__}")
19
+ sub = ap.add_subparsers(dest="cmd", required=True)
20
+
21
+ v = sub.add_parser("validate", help="check a game graph without building")
22
+ v.add_argument("graph", type=Path)
23
+ v.add_argument("--json", action="store_true", help="print diagnostics as JSON")
24
+
25
+ b = sub.add_parser("build", help="build a playable Godot project")
26
+ b.add_argument("graph", type=Path)
27
+ b.add_argument("-o", "--out", type=Path, help="output directory (default: out/<game-name>)")
28
+ b.add_argument("--no-smoke", action="store_true", help="skip the headless smoke run")
29
+ b.add_argument("--json", action="store_true", help="print the result as JSON")
30
+ b.add_argument("--run", action="store_true", help="open the game window after a successful build")
31
+
32
+ r = sub.add_parser("run", help="play a built project")
33
+ r.add_argument("project", type=Path)
34
+
35
+ c = sub.add_parser("components", help="list available components")
36
+ c.add_argument("--json", action="store_true")
37
+
38
+ sub.add_parser("manifest", help="regenerate the component manifest from the Godot library")
39
+
40
+ cr = sub.add_parser("create", help="design and build a game from a text prompt (uses the LLM)")
41
+ cr.add_argument("prompt")
42
+ cr.add_argument("-o", "--out", type=Path, help="output directory (default: out/<title>)")
43
+ cr.add_argument("--seed", type=int, help="level seed (default: derived from the prompt)")
44
+ cr.add_argument("--brief", type=Path, help="skip the LLM and use this GameBrief JSON")
45
+ cr.add_argument("--creative", action="store_true", help="let the LLM add a validated creative patch")
46
+ cr.add_argument("--no-web", action="store_true", help="skip the web export (play with `spawnpoint run`)")
47
+ cr.add_argument("--no-open", action="store_true", help="do not open the browser")
48
+ cr.add_argument("--no-serve", action="store_true", help="exit after building instead of serving")
49
+ cr.add_argument("--port", type=int, default=8060)
50
+ cr.add_argument("--record", action="store_true", help="save LLM calls as local fine-tuning data")
51
+ cr.add_argument("--playtest", action="store_true", help="after layer 2, let bots play and tune the game")
52
+ cr.add_argument("--ai-art", action="store_true",
53
+ help="layer 2 sprites from an image model (SPAWNPOINT_IMAGE_PROVIDER), generated ones as fallback")
54
+
55
+ st = sub.add_parser("studio", help="open the web cockpit (create, tweak, click-to-edit, playtest)")
56
+ st.add_argument("--workspace", type=Path, default=Path("out"), help="folder holding the games (default: out)")
57
+ st.add_argument("--port", type=int, default=8070)
58
+ st.add_argument("--no-open", action="store_true")
59
+
60
+ pt = sub.add_parser("playtest", help="bots play a built game: report, death heatmap, optional tuning")
61
+ pt.add_argument("project", type=Path)
62
+ pt.add_argument("--runs", type=int, default=3, help="runs per profile")
63
+ pt.add_argument("--profiles", default="novice,average,expert")
64
+ pt.add_argument("--tune", action="store_true", help="adjust parameters within bounds and re-test")
65
+ pt.add_argument("--rounds", type=int, default=2)
66
+ pt.add_argument("--seed", type=int, default=1)
67
+ pt.add_argument("--difficulty", type=int, help="target difficulty 1-5 (default: the brief's, else 2)")
68
+ pt.add_argument("--json", action="store_true")
69
+
70
+ tw = sub.add_parser("tweak", help="change a created game with a text request (uses the LLM)")
71
+ tw.add_argument("project", type=Path)
72
+ tw.add_argument("request")
73
+ tw.add_argument("--no-web", action="store_true")
74
+ tw.add_argument("--record", action="store_true", help="save LLM calls as local fine-tuning data")
75
+
76
+ lm = sub.add_parser("llm", help="run SpawnPoint-Coder-7B locally (Docker + NVIDIA GPU)")
77
+ lm.add_argument("action", choices=["up", "down", "status"])
78
+ lm.add_argument("--port", type=int, default=8000)
79
+
80
+ ex = sub.add_parser("examples", help="list the example graphs, or copy one here")
81
+ ex.add_argument("action", nargs="?", choices=["list", "copy"], default="list")
82
+ ex.add_argument("name", nargs="?")
83
+
84
+ gf = sub.add_parser("gif", help="record a gameplay GIF of a built project (autopilot; needs a display)")
85
+ gf.add_argument("project", type=Path)
86
+ gf.add_argument("-o", "--out", type=Path, help="output file (default: <project>/demo.gif)")
87
+ gf.add_argument("--seconds", type=float, default=8.0)
88
+ gf.add_argument("--fps", type=int, default=20)
89
+ gf.add_argument("--width", type=int, default=640)
90
+
91
+ sp = sub.add_parser("sprites", help="import, generate or preview sprites")
92
+ sps = sp.add_subparsers(dest="sprites_cmd", required=True)
93
+ si = sps.add_parser("import", help="turn a sprite sheet or a folder of frames into a SpriteSet")
94
+ si.add_argument("source", type=Path, help="PNG sheet or folder of PNG frames")
95
+ si.add_argument("--name", help="sprite name (default: from the file name)")
96
+ si.add_argument("--grid", help="cell size WxH, e.g. 32x32 (default: auto-detect from transparent gutters)")
97
+ si.add_argument("--spacing", type=int, default=0)
98
+ si.add_argument("--margin", type=int, default=0)
99
+ si.add_argument("--anim", action="append", default=[], metavar="NAME=FRAMES",
100
+ help="e.g. idle=0-3 (repeatable; frame indices in reading order)")
101
+ si.add_argument("--rows", help="comma-separated animation names, one per row, e.g. idle,run,jump")
102
+ si.add_argument("--fps", action="append", default=[], metavar="NAME=FPS")
103
+ si.add_argument("--scale", type=int, default=2)
104
+ si.add_argument("--anchor", choices=["bottom", "center"], default="bottom")
105
+ si.add_argument("-o", "--out", type=Path, default=Path("assets/sprites"))
106
+ sg = sps.add_parser("generate", help="draw a procedural pixel-art SpriteSet")
107
+ sg.add_argument("kind")
108
+ sg.add_argument("--name", required=True)
109
+ sg.add_argument("--color", default="#29adff")
110
+ sg.add_argument("--features", help="comma-separated, e.g. mask,tail (default: from the name)")
111
+ sg.add_argument("--seed", type=int, default=0)
112
+ sg.add_argument("-o", "--out", type=Path, default=Path("assets/sprites"))
113
+ sv2 = sps.add_parser("preview", help="render a contact sheet PNG of a SpriteSet with its collision outline")
114
+ sv2.add_argument("sprite", type=Path)
115
+ sv2.add_argument("-o", "--out", type=Path)
116
+ sps.add_parser("kinds", help="list the procedural sprite kinds")
117
+
118
+ ds = sub.add_parser("dataset", help="recorded fine-tuning data")
119
+ ds.add_argument("action", choices=["stats", "path"])
120
+ ds.add_argument("--json", action="store_true")
121
+
122
+ sv = sub.add_parser("serve", help="serve a project's web build with live reload")
123
+ sv.add_argument("project", type=Path)
124
+ sv.add_argument("--port", type=int, default=8060)
125
+ sv.add_argument("--no-open", action="store_true")
126
+
127
+ args = ap.parse_args(argv)
128
+ try:
129
+ from . import studio_cli
130
+ return {"validate": _validate, "build": _build, "run": _run, "components": _components,
131
+ "manifest": _manifest, "create": studio_cli.create, "tweak": studio_cli.tweak,
132
+ "serve": studio_cli.serve, "dataset": studio_cli.dataset, "playtest": studio_cli.playtest, "studio": studio_cli.studio,
133
+ "sprites": _sprites, "gif": _gif, "llm": _llm, "examples": _examples}[args.cmd](args)
134
+ except godot.GodotError as e:
135
+ print(f"error: {e}", file=sys.stderr)
136
+ if e.output:
137
+ print(e.output[-3000:], file=sys.stderr)
138
+ return 2
139
+
140
+
141
+ def _print_diags(diags) -> None:
142
+ for d in diags:
143
+ print((" ✗ " if d.severity == "error" else " ! ") + str(d))
144
+
145
+
146
+ def _validate(args) -> int:
147
+ analysis = validate(load_graph(args.graph), base_dir=args.graph.resolve().parent)
148
+ if args.json:
149
+ print(json.dumps([d.to_dict() for d in analysis.diagnostics], indent=2))
150
+ else:
151
+ _print_diags(analysis.diagnostics)
152
+ n_err, n_warn = len(analysis.diagnostics.errors), len(analysis.diagnostics.warnings)
153
+ print(f"{'OK' if analysis.ok else 'INVALID'}: {n_err} error(s), {n_warn} warning(s)")
154
+ return 0 if analysis.ok else 1
155
+
156
+
157
+ def _build(args) -> int:
158
+ data = load_graph(args.graph)
159
+ name = data.get("name", args.graph.stem) if isinstance(data, dict) else args.graph.stem
160
+ out = args.out or Path("out") / slugify(str(name))
161
+ result = build(data, out.resolve(), smoke=not args.no_smoke, base_dir=args.graph.resolve().parent)
162
+ if args.json:
163
+ print(json.dumps(result.to_dict(), indent=2))
164
+ else:
165
+ _print_diags(result.diagnostics)
166
+ if result.ok:
167
+ t = result.timings
168
+ print(f"Built {out} in {t.get('total', sum(t.values())):.1f}s "
169
+ f"(validate {t.get('validate', 0) * 1000:.0f}ms, godot {t.get('godot_import', 0) + t.get('godot_build', 0):.1f}s"
170
+ + (f", smoke {t['smoke']:.1f}s" if "smoke" in t else "") + ")")
171
+ if result.smoke:
172
+ print("Smoke:", json.dumps(result.smoke))
173
+ print(f"Play it: spawnpoint run {out} (or open {out}/project.godot in Godot)")
174
+ else:
175
+ print("BUILD FAILED")
176
+ if result.log:
177
+ tail = "\n".join(result.log.splitlines()[-30:])
178
+ print(tail, file=sys.stderr)
179
+ if result.ok and args.run:
180
+ return _run(argparse.Namespace(project=out))
181
+ return 0 if result.ok else 1
182
+
183
+
184
+ def _run(args) -> int:
185
+ return subprocess.call([godot.find_binary(), "--path", str(args.project)])
186
+
187
+
188
+ def _components(args) -> int:
189
+ m = manifest_mod.load()
190
+ if args.json:
191
+ print(manifest_mod.MANIFEST_PATH.read_text())
192
+ return 0
193
+ for c in sorted(m.components.values(), key=lambda c: (c.category, c.name)):
194
+ hosts = f" (body: {', '.join(c.hosts)})" if c.hosts else ""
195
+ print(f"\n{c.name} [{c.category}]{hosts}")
196
+ print(" " + c.doc.split("\n")[0])
197
+ for p in c.params.values():
198
+ print(f" {p.describe()}")
199
+ for s in c.signals.values():
200
+ print(f" signal {s.name}({', '.join(a.name for a in s.args)})")
201
+ for mt in c.methods.values():
202
+ print(f" {mt.name}({', '.join(a.name for a in mt.args)})")
203
+ return 0
204
+
205
+
206
+ def _llm(args) -> int:
207
+ from . import llm_server
208
+
209
+ try:
210
+ return {"up": lambda: llm_server.up(args.port), "down": llm_server.down,
211
+ "status": lambda: llm_server.status(args.port)}[args.action]()
212
+ except (RuntimeError, subprocess.CalledProcessError) as e:
213
+ print(f"error: {e}", file=sys.stderr)
214
+ return 2
215
+
216
+
217
+ def _examples(args) -> int:
218
+ import shutil
219
+
220
+ files = sorted(manifest_mod.EXAMPLES_DIR.glob("*.json"))
221
+ if args.action == "list" or not args.name:
222
+ for f in files:
223
+ data = json.loads(f.read_text())
224
+ print(f"{f.stem:<14} {data.get('genre', ''):<11} {data.get('name', '')}")
225
+ print("Copy one: spawnpoint examples copy pixel_hopper then: spawnpoint build pixel_hopper.json --run")
226
+ return 0
227
+ src = manifest_mod.EXAMPLES_DIR / f"{args.name.removesuffix('.json')}.json"
228
+ if not src.exists():
229
+ print(f"error: no example '{args.name}'", file=sys.stderr)
230
+ return 1
231
+ shutil.copy2(src, Path.cwd() / src.name)
232
+ print(f"Wrote {src.name}")
233
+ return 0
234
+
235
+
236
+ def _gif(args) -> int:
237
+ from .gif import record_gif
238
+
239
+ out = args.out or args.project / "demo.gif"
240
+ path = record_gif(args.project.resolve(), out.resolve(), args.seconds, args.fps, args.width)
241
+ print(f"Wrote {out} ({path.stat().st_size / 1e6:.1f} MB)")
242
+ return 0
243
+
244
+
245
+ def _sprites(args) -> int:
246
+ from .sprites import generate as gen, importer
247
+ from .sprites.spriteset import SpriteError, load
248
+
249
+ try:
250
+ if args.sprites_cmd == "kinds":
251
+ for k, spec in gen.KINDS.items():
252
+ print(f"{k:<8} {spec.size[0]}x{spec.size[1]} px animations: {', '.join(spec.anims)}")
253
+ return 0
254
+ if args.sprites_cmd == "preview":
255
+ s = load(args.sprite)
256
+ out = args.out or args.sprite.with_suffix("").with_suffix(".preview.png")
257
+ importer.preview(s).save(out)
258
+ print(f"Wrote {out}")
259
+ return 0
260
+ if args.sprites_cmd == "generate":
261
+ feats = [f.strip() for f in args.features.split(",")] if args.features else None
262
+ s = gen.generate(args.name, args.kind, args.color, feats, args.seed)
263
+ else:
264
+ fps = {k: float(v) for k, v in (x.split("=", 1) for x in args.fps)}
265
+ if args.source.is_dir():
266
+ s = importer.import_folder(args.source, args.name, fps=fps, scale=args.scale, anchor=args.anchor)
267
+ else:
268
+ grid = tuple(int(v) for v in args.grid.lower().split("x")) if args.grid else None
269
+ anims = {k: importer.parse_ranges(v) for k, v in (x.split("=", 1) for x in args.anim)} or None
270
+ rows = [r.strip() for r in args.rows.split(",")] if args.rows else None
271
+ s = importer.import_sheet(args.source, args.name, grid=grid, spacing=args.spacing, margin=args.margin,
272
+ anims=anims, row_names=rows, fps=fps, scale=args.scale, anchor=args.anchor)
273
+ except (SpriteError, ValueError, OSError) as e:
274
+ print(f"error: {e}", file=sys.stderr)
275
+ return 1
276
+ path = s.save(args.out)
277
+ anims = ", ".join(f"{k} ({len(a.frames)})" for k, a in s.animations.items())
278
+ print(f"Wrote {path}: {s.frame_size[0]}x{s.frame_size[1]} frames, {anims}; outline {len(s.polygon)} points")
279
+ print(f'Use it in a graph: "sprite": "{path}"')
280
+ return 0
281
+
282
+
283
+ def _manifest(args) -> int:
284
+ data = manifest_mod.generate()
285
+ print(f"Wrote {manifest_mod.MANIFEST_PATH} ({len(data['components'])} components)")
286
+ return 0
287
+
288
+
289
+ if __name__ == "__main__":
290
+ sys.exit(main())
File without changes