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,212 @@
1
+ """Import existing art into a SpriteSet: grid sheets, auto-sliced sheets, or folders of frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ from PIL import Image
10
+
11
+ from .spriteset import ANIM_ORDER, SpriteError, SpriteSet, build_sheet, finalize
12
+
13
+ DEFAULT_FPS = {"idle": 4.0, "run": 10.0, "jump": 8.0, "fall": 8.0, "hurt": 8.0}
14
+ ANIM_SYNONYMS = {
15
+ "idle": "idle", "stand": "idle", "rest": "idle", "breath": "idle",
16
+ "run": "run", "walk": "run", "move": "run",
17
+ "jump": "jump", "up": "jump", "fall": "fall", "down": "fall",
18
+ "hurt": "hurt", "hit": "hurt", "damage": "hurt", "pain": "hurt",
19
+ }
20
+
21
+
22
+ def parse_ranges(spec: str) -> list[int]:
23
+ """'0-3,7,9-10' -> [0, 1, 2, 3, 7, 9, 10]."""
24
+ out: list[int] = []
25
+ for part in spec.split(","):
26
+ part = part.strip()
27
+ if not part:
28
+ continue
29
+ if "-" in part:
30
+ a, b = part.split("-", 1)
31
+ out.extend(range(int(a), int(b) + 1))
32
+ else:
33
+ out.append(int(part))
34
+ return out
35
+
36
+
37
+ def _runs(occupied: np.ndarray) -> list[tuple[int, int]]:
38
+ """[start, end) runs of True values."""
39
+ runs, start = [], None
40
+ for i, v in enumerate(occupied):
41
+ if v and start is None:
42
+ start = i
43
+ elif not v and start is not None:
44
+ runs.append((start, i))
45
+ start = None
46
+ if start is not None:
47
+ runs.append((start, len(occupied)))
48
+ return runs
49
+
50
+
51
+ def _pad_to(frame: np.ndarray, w: int, h: int, anchor: str) -> np.ndarray:
52
+ out = np.zeros((h, w, 4), dtype=np.uint8)
53
+ fh, fw = frame.shape[:2]
54
+ x = (w - fw) // 2
55
+ y = h - fh if anchor == "bottom" else (h - fh) // 2
56
+ out[y:y + fh, x:x + fw] = frame
57
+ return out
58
+
59
+
60
+ def _trim(frame: np.ndarray) -> np.ndarray:
61
+ ys, xs = np.nonzero(frame[..., 3] > 0)
62
+ if len(xs) == 0:
63
+ return frame[:1, :1]
64
+ return frame[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
65
+
66
+
67
+ def slice_grid(img: np.ndarray, cell: tuple[int, int], spacing: int = 0, margin: int = 0) -> list[list[np.ndarray]]:
68
+ """Rows of cells (empty cells included, so indices match what the artist sees)."""
69
+ cw, ch = cell
70
+ h, w = img.shape[:2]
71
+ rows = []
72
+ y = margin
73
+ while y + ch <= h:
74
+ row = []
75
+ x = margin
76
+ while x + cw <= w:
77
+ row.append(img[y:y + ch, x:x + cw])
78
+ x += cw + spacing
79
+ rows.append(row)
80
+ y += ch + spacing
81
+ if not rows or not rows[0]:
82
+ raise SpriteError(f"a {cw}x{ch} grid does not fit a {w}x{h} sheet")
83
+ return rows
84
+
85
+
86
+ def slice_auto(img: np.ndarray) -> list[list[np.ndarray]]:
87
+ """Frames separated by fully transparent gutters. Returns rows of trimmed frames."""
88
+ alpha = img[..., 3] > 0
89
+ row_runs = _runs(alpha.any(axis=1))
90
+ if not row_runs:
91
+ raise SpriteError("the image is fully transparent")
92
+ rows = []
93
+ for y0, y1 in row_runs:
94
+ band = img[y0:y1]
95
+ cols = _runs((band[..., 3] > 0).any(axis=0))
96
+ rows.append([_trim(band[:, x0:x1]) for x0, x1 in cols])
97
+ return rows
98
+
99
+
100
+ def _empty(frame: np.ndarray) -> bool:
101
+ return not (frame[..., 3] > 0).any()
102
+
103
+
104
+ def from_rows(name: str, rows: list[list[np.ndarray]], *, anims: dict[str, list[int]] | None = None,
105
+ row_names: list[str] | None = None, fps: dict[str, float] | None = None, scale: int = 2,
106
+ anchor: str = "bottom", trim: bool = False) -> SpriteSet:
107
+ fps = {**DEFAULT_FPS, **(fps or {})}
108
+ flat = [f for row in rows for f in row]
109
+ if anims:
110
+ chosen = {}
111
+ for anim, idx in anims.items():
112
+ bad = [i for i in idx if not 0 <= i < len(flat)]
113
+ if bad:
114
+ raise SpriteError(f"animation '{anim}' uses frame {bad[0]}, but the sheet has {len(flat)} frames (0-{len(flat) - 1})")
115
+ empty = [i for i in idx if _empty(flat[i])]
116
+ if empty:
117
+ raise SpriteError(f"animation '{anim}' uses empty frame(s) {empty}; non-empty frames are "
118
+ f"{[i for i, f in enumerate(flat) if not _empty(f)]}")
119
+ chosen[anim] = [flat[i] for i in idx]
120
+ else:
121
+ nonempty_rows = [[f for f in row if not _empty(f)] for row in rows]
122
+ nonempty_rows = [r for r in nonempty_rows if r]
123
+ if not nonempty_rows:
124
+ raise SpriteError("no non-empty frames found")
125
+ if row_names:
126
+ if len(row_names) > len(nonempty_rows):
127
+ raise SpriteError(f"{len(row_names)} row names given but the sheet has {len(nonempty_rows)} rows")
128
+ chosen = {n: nonempty_rows[i] for i, n in enumerate(row_names)}
129
+ elif len(nonempty_rows) > 1:
130
+ chosen = {(ANIM_ORDER[i] if i < len(ANIM_ORDER) else f"row{i}"): r for i, r in enumerate(nonempty_rows)}
131
+ else:
132
+ chosen = {"idle": nonempty_rows[0]}
133
+ if trim:
134
+ chosen = {k: [_trim(f) for f in v] for k, v in chosen.items()}
135
+ w = max(f.shape[1] for fs in chosen.values() for f in fs)
136
+ h = max(f.shape[0] for fs in chosen.values() for f in fs)
137
+ frames = {k: [_pad_to(f, w, h, anchor) for f in v] for k, v in chosen.items()}
138
+ sheet, animations = build_sheet(frames, {k: fps.get(k, 8.0) for k in frames}, {k: k not in ("jump", "fall", "hurt") for k in frames})
139
+ return finalize(name, sheet, (w, h), animations, scale, anchor)
140
+
141
+
142
+ def import_sheet(path: Path, name: str | None = None, *, grid: tuple[int, int] | None = None, spacing: int = 0,
143
+ margin: int = 0, anims: dict[str, list[int]] | None = None, row_names: list[str] | None = None,
144
+ fps: dict[str, float] | None = None, scale: int = 2, anchor: str = "bottom") -> SpriteSet:
145
+ img = np.asarray(Image.open(path).convert("RGBA"))
146
+ name = name or _clean_name(Path(path).stem)
147
+ if grid:
148
+ rows = slice_grid(img, grid, spacing, margin)
149
+ return from_rows(name, rows, anims=anims, row_names=row_names, fps=fps, scale=scale, anchor=anchor)
150
+ rows = slice_auto(img)
151
+ return from_rows(name, rows, anims=anims, row_names=row_names, fps=fps, scale=scale, anchor=anchor)
152
+
153
+
154
+ _FRAME_FILE = re.compile(r"^(?P<stem>.*?)[\s_\-.]*(?P<num>\d+)$")
155
+
156
+
157
+ def import_folder(directory: Path, name: str | None = None, *, fps: dict[str, float] | None = None,
158
+ scale: int = 2, anchor: str = "bottom") -> SpriteSet:
159
+ """Frames named like idle_0.png, hero-walk-03.png, run1.png; files without a number are idle."""
160
+ files = sorted(p for p in Path(directory).iterdir() if p.suffix.lower() == ".png")
161
+ if not files:
162
+ raise SpriteError(f"no PNG files in {directory}")
163
+ groups: dict[str, list[tuple[int, np.ndarray]]] = {}
164
+ for p in files:
165
+ m = _FRAME_FILE.match(p.stem)
166
+ stem, num = (m.group("stem"), int(m.group("num"))) if m else (p.stem, 0)
167
+ anim = _anim_name(stem)
168
+ groups.setdefault(anim, []).append((num, np.asarray(Image.open(p).convert("RGBA"))))
169
+ rows_by_anim = {k: [f for _, f in sorted(v, key=lambda t: t[0])] for k, v in groups.items()}
170
+ if "idle" not in rows_by_anim:
171
+ first = next(iter(rows_by_anim))
172
+ rows_by_anim["idle"] = rows_by_anim.pop(first)
173
+ names = list(rows_by_anim)
174
+ return from_rows(name or _clean_name(Path(directory).name), [rows_by_anim[n] for n in names],
175
+ row_names=names, fps=fps, scale=scale, anchor=anchor, trim=True)
176
+
177
+
178
+ def _anim_name(stem: str) -> str:
179
+ tokens = [t.lower() for t in re.split(r"[\s_\-.]+", stem) if t]
180
+ for t in reversed(tokens):
181
+ if t in ANIM_SYNONYMS:
182
+ return ANIM_SYNONYMS[t]
183
+ return tokens[-1] if tokens else "idle"
184
+
185
+
186
+ def _clean_name(raw: str) -> str:
187
+ words = re.findall(r"[A-Za-z0-9]+", raw)
188
+ return "".join(w[:1].upper() + w[1:] for w in words) or "Sprite"
189
+
190
+
191
+ def preview(s: SpriteSet, zoom: int = 6, show_polygon: bool = True) -> Image.Image:
192
+ """Contact sheet: one row per animation, plus the collision outline on the first idle frame."""
193
+ from PIL import ImageDraw
194
+
195
+ w, h = s.frame_size
196
+ cols = max(len(a.frames) for a in s.animations.values())
197
+ out = Image.new("RGBA", ((cols + 1) * (w * zoom + 8) + 120, len(s.animations) * (h * zoom + 8) + 8), (40, 44, 60, 255))
198
+ d = ImageDraw.Draw(out)
199
+ for r, (anim, a) in enumerate(s.animations.items()):
200
+ y = 4 + r * (h * zoom + 8)
201
+ d.text((4, y + 4), f"{anim} ({len(a.frames)}f @{a.fps:g})", fill=(255, 241, 232, 255))
202
+ for c, (col, row) in enumerate(a.frames):
203
+ f = Image.fromarray(s.frame(col, row)).resize((w * zoom, h * zoom), Image.NEAREST)
204
+ out.alpha_composite(f, (120 + c * (w * zoom + 8), y))
205
+ if show_polygon and s.polygon:
206
+ idle = s.animations.get("idle") or next(iter(s.animations.values()))
207
+ x0 = 120 + cols * (w * zoom + 8)
208
+ f = Image.fromarray(s.frame(*idle.frames[0])).resize((w * zoom, h * zoom), Image.NEAREST)
209
+ out.alpha_composite(f, (x0, 4))
210
+ pts = [(x0 + px * zoom, 4 + py * zoom) for px, py in s.polygon]
211
+ d.line(pts + pts[:1], fill=(0, 228, 54, 255), width=2)
212
+ return out
@@ -0,0 +1,139 @@
1
+ """Connects sprites to the compiler: resolve an entity's sprite, derive its collision, plan entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..graph import Entity, Shape
8
+ from .generate import KINDS, generate
9
+ from .spriteset import SpriteError, SpriteSet, load
10
+
11
+ SPRITE_DIR = "res://sprites"
12
+
13
+
14
+ def resolve(name: str, ent: Entity, base_dir: Path | None, default_color: str,
15
+ warn=lambda msg: None) -> SpriteSet:
16
+ """`warn(message)` reports a non-fatal problem (an AI sprite falling back to a generated one)."""
17
+ ref = ent.sprite
18
+ if isinstance(ref, str):
19
+ path = Path(ref)
20
+ if not path.is_absolute():
21
+ if base_dir is None:
22
+ raise SpriteError(f"relative sprite path '{ref}' needs the graph's folder (build from a file)")
23
+ path = base_dir / path
24
+ if not path.exists():
25
+ raise SpriteError(f"sprite file '{ref}' not found")
26
+ return load(path)
27
+ if ref.ai is not None:
28
+ from .ai import ai_sprite, provider_from_env
29
+
30
+ spec = ref.ai
31
+ if spec.kind not in KINDS:
32
+ raise SpriteError(f"unknown sprite kind '{spec.kind}'")
33
+ provider = provider_from_env()
34
+ if provider is None:
35
+ warn("no image provider configured (SPAWNPOINT_IMAGE_PROVIDER); using a generated sprite")
36
+ else:
37
+ try:
38
+ return ai_sprite(name, spec.kind, spec.prompt, provider, spec.seed)
39
+ except SpriteError as e:
40
+ warn(f"AI sprite failed ({e}); using a generated sprite")
41
+ return generate(name, spec.kind, spec.color or default_color, None, spec.seed)
42
+ gen = ref.generate
43
+ if gen.kind not in KINDS:
44
+ raise SpriteError(f"unknown sprite kind '{gen.kind}'")
45
+ return generate(name, gen.kind, gen.color or default_color, gen.features, gen.seed)
46
+
47
+
48
+ def derived_shape(ent: Entity, s: SpriteSet, genre: str) -> Shape:
49
+ """Body collision fitted to the sprite's opaque bounds (smooth shapes for moving bodies)."""
50
+ _, _, bw, bh = s.bounds
51
+ w, h = bw * s.scale, bh * s.scale
52
+ color = ent.shape.color
53
+ if ent.body == "character":
54
+ if genre == "topdown":
55
+ d = min(w, h) * 0.9
56
+ return Shape(type="circle", size=(d, d), color=color)
57
+ return Shape(type="capsule", size=(max(w * 0.8, 4), h), color=color)
58
+ return Shape(type="rect", size=(w, h), color=color)
59
+
60
+
61
+ def plan_entry(entity: str, s: SpriteSet) -> dict:
62
+ fw, fh = s.frame_size
63
+ bx, by, bw, bh = s.bounds
64
+ cx, cy = bx + bw / 2, by + bh / 2 # the entity origin sits at the center of the opaque box
65
+ k = s.scale
66
+ return {
67
+ "texture": f"{SPRITE_DIR}/{entity}.png",
68
+ "frame_size": [fw, fh],
69
+ "scale": k,
70
+ "offset": [round((fw / 2 - cx) * k, 3), round((fh / 2 - cy) * k, 3)],
71
+ "animations": {n: {"frames": [list(f) for f in a.frames], "fps": a.fps, "loop": a.loop}
72
+ for n, a in s.animations.items()},
73
+ "polygon": [[round((x - cx) * k, 2), round((y - cy) * k, 2)] for x, y in safe_polygon(s.polygon)],
74
+ "color": s.color,
75
+ }
76
+
77
+
78
+ def safe_polygon(poly: list[tuple[float, float]]) -> list[tuple[float, float]]:
79
+ """A simple (non self-intersecting) polygon Godot can decompose; falls back to the convex hull."""
80
+ if len(poly) >= 3 and _is_simple(poly) and abs(_area(poly)) > 0.5:
81
+ return poly
82
+ return convex_hull(poly)
83
+
84
+
85
+ def convex_hull(pts: list[tuple[float, float]]) -> list[tuple[float, float]]:
86
+ p = sorted(set(pts))
87
+ if len(p) < 3:
88
+ return p
89
+
90
+ def cross(o, a, b):
91
+ return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
92
+
93
+ lower: list = []
94
+ for q in p:
95
+ while len(lower) >= 2 and cross(lower[-2], lower[-1], q) <= 0:
96
+ lower.pop()
97
+ lower.append(q)
98
+ upper: list = []
99
+ for q in reversed(p):
100
+ while len(upper) >= 2 and cross(upper[-2], upper[-1], q) <= 0:
101
+ upper.pop()
102
+ upper.append(q)
103
+ return lower[:-1] + upper[:-1]
104
+
105
+
106
+ def _area(pts) -> float:
107
+ return sum(x1 * y2 - x2 * y1 for (x1, y1), (x2, y2) in zip(pts, pts[1:] + pts[:1])) / 2
108
+
109
+
110
+ def _is_simple(pts: list[tuple[float, float]]) -> bool:
111
+ n = len(pts)
112
+ if len(set(pts)) != n:
113
+ return False
114
+ segs = [(pts[i], pts[(i + 1) % n]) for i in range(n)]
115
+ for i in range(n):
116
+ for j in range(i + 1, n):
117
+ if j == i + 1 or (i == 0 and j == n - 1):
118
+ continue
119
+ if _intersect(*segs[i], *segs[j]):
120
+ return False
121
+ return True
122
+
123
+
124
+ def _intersect(a, b, c, d) -> bool:
125
+ def orient(p, q, r):
126
+ v = (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
127
+ return 0 if abs(v) < 1e-9 else (1 if v > 0 else -1)
128
+
129
+ o1, o2, o3, o4 = orient(a, b, c), orient(a, b, d), orient(c, d, a), orient(c, d, b)
130
+ if o1 != o2 and o3 != o4:
131
+ return True
132
+
133
+ def on(p, q, r):
134
+ return min(p[0], r[0]) <= q[0] <= max(p[0], r[0]) and min(p[1], r[1]) <= q[1] <= max(p[1], r[1])
135
+
136
+ return (o1 == 0 and on(a, c, b)) or (o2 == 0 and on(a, d, b)) or (o3 == 0 and on(c, a, d)) or (o4 == 0 and on(c, b, d))
137
+
138
+
139
+ __all__ = ["resolve", "derived_shape", "plan_entry", "safe_polygon", "SpriteError", "SPRITE_DIR"]
@@ -0,0 +1,159 @@
1
+ """SpriteSet: a uniform-grid sprite sheet + animations + bounds/outline, shared by generator and importer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import Counter
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ from PIL import Image
12
+
13
+ from . import contour
14
+
15
+ FORMAT = 1
16
+ ANIM_ORDER = ("idle", "run", "jump", "fall", "hurt")
17
+
18
+
19
+ class SpriteError(ValueError):
20
+ pass
21
+
22
+
23
+ @dataclass
24
+ class Animation:
25
+ frames: list[tuple[int, int]] # (column, row) grid cells
26
+ fps: float = 8.0
27
+ loop: bool = True
28
+
29
+ def to_dict(self) -> dict:
30
+ return {"frames": [list(f) for f in self.frames], "fps": self.fps, "loop": self.loop}
31
+
32
+
33
+ @dataclass
34
+ class SpriteSet:
35
+ name: str
36
+ sheet: Image.Image
37
+ frame_size: tuple[int, int]
38
+ animations: dict[str, Animation]
39
+ scale: int = 2
40
+ anchor: str = "center" # center | bottom
41
+ bounds: tuple[int, int, int, int] = (0, 0, 0, 0) # opaque box of the idle pose, frame pixels
42
+ polygon: list[tuple[float, float]] = field(default_factory=list) # outline of the idle pose
43
+ color: str = "#ffffff"
44
+
45
+ @property
46
+ def columns(self) -> int:
47
+ return self.sheet.width // self.frame_size[0]
48
+
49
+ @property
50
+ def rows(self) -> int:
51
+ return self.sheet.height // self.frame_size[1]
52
+
53
+ def frame(self, col: int, row: int) -> np.ndarray:
54
+ w, h = self.frame_size
55
+ return np.asarray(self.sheet.crop((col * w, row * h, col * w + w, row * h + h)).convert("RGBA"))
56
+
57
+ def to_dict(self, image_name: str) -> dict:
58
+ return {
59
+ "format": FORMAT, "name": self.name, "image": image_name, "frame_size": list(self.frame_size),
60
+ "scale": self.scale, "anchor": self.anchor,
61
+ "animations": {k: a.to_dict() for k, a in self.animations.items()},
62
+ "bounds": list(self.bounds), "polygon": [list(p) for p in self.polygon], "color": self.color,
63
+ }
64
+
65
+ def save(self, directory: Path, name: str | None = None) -> Path:
66
+ directory.mkdir(parents=True, exist_ok=True)
67
+ base = name or self.name
68
+ self.sheet.save(directory / f"{base}.png")
69
+ path = directory / f"{base}.sprite.json"
70
+ path.write_text(json.dumps(self.to_dict(f"{base}.png"), indent=1) + "\n")
71
+ return path
72
+
73
+
74
+ def finalize(name: str, sheet: Image.Image, frame_size: tuple[int, int], animations: dict[str, Animation],
75
+ scale: int = 2, anchor: str = "center") -> SpriteSet:
76
+ """Validates the grid and derives bounds, outline and dominant color from the idle pose."""
77
+ sheet = sheet.convert("RGBA")
78
+ s = SpriteSet(name, sheet, tuple(frame_size), dict(animations), scale, anchor)
79
+ _check(s)
80
+ idle = s.animations.get("idle") or next(iter(s.animations.values()))
81
+ union = np.zeros((frame_size[1], frame_size[0], 4), dtype=np.uint8)
82
+ for col, row in idle.frames:
83
+ f = s.frame(col, row)
84
+ union = np.where(f[..., 3:4] > union[..., 3:4], f, union)
85
+ mask = contour.alpha_mask(union)
86
+ b = contour.bounds(mask)
87
+ if b is None:
88
+ raise SpriteError(f"sprite '{name}': the idle frames are fully transparent")
89
+ s.bounds = b
90
+ s.polygon = contour.outline(union)
91
+ s.color = dominant_color(union)
92
+ return s
93
+
94
+
95
+ def _check(s: SpriteSet) -> None:
96
+ w, h = s.frame_size
97
+ if w <= 0 or h <= 0:
98
+ raise SpriteError(f"sprite '{s.name}': bad frame size {w}x{h}")
99
+ if s.sheet.width % w or s.sheet.height % h:
100
+ raise SpriteError(f"sprite '{s.name}': sheet {s.sheet.width}x{s.sheet.height} is not a multiple of {w}x{h}")
101
+ if not s.animations:
102
+ raise SpriteError(f"sprite '{s.name}': no animations")
103
+ for anim_name, a in s.animations.items():
104
+ if not a.frames:
105
+ raise SpriteError(f"sprite '{s.name}': animation '{anim_name}' has no frames")
106
+ for col, row in a.frames:
107
+ if not (0 <= col < s.columns and 0 <= row < s.rows):
108
+ raise SpriteError(f"sprite '{s.name}': animation '{anim_name}' uses frame ({col},{row}) "
109
+ f"outside the {s.columns}x{s.rows} grid")
110
+
111
+
112
+ def dominant_color(rgba: np.ndarray) -> str:
113
+ px = rgba[rgba[..., 3] >= 128][:, :3]
114
+ if len(px) == 0:
115
+ return "#ffffff"
116
+ # Ignore near-black outline pixels, quantize, take the most common.
117
+ lum = px.astype(int).sum(axis=1)
118
+ px = px[lum > 120] if (lum > 120).any() else px
119
+ q = (px // 16) * 16 + 8
120
+ (r, g, b), _ = Counter(map(tuple, q.tolist())).most_common(1)[0]
121
+ return f"#{int(r):02x}{int(g):02x}{int(b):02x}"
122
+
123
+
124
+ def load(path: Path) -> SpriteSet:
125
+ path = Path(path)
126
+ try:
127
+ data = json.loads(path.read_text())
128
+ except (OSError, json.JSONDecodeError) as e:
129
+ raise SpriteError(f"cannot read sprite file {path}: {e}") from e
130
+ try:
131
+ image = Image.open(path.parent / data["image"]).convert("RGBA")
132
+ anims = {k: Animation([tuple(f) for f in v["frames"]], float(v.get("fps", 8)), bool(v.get("loop", True)))
133
+ for k, v in data["animations"].items()}
134
+ s = SpriteSet(data.get("name", path.stem), image, tuple(data["frame_size"]), anims,
135
+ int(data.get("scale", 2)), data.get("anchor", "center"))
136
+ except (KeyError, OSError, TypeError, ValueError) as e:
137
+ raise SpriteError(f"invalid sprite file {path}: {e}") from e
138
+ _check(s)
139
+ if data.get("bounds") and data.get("polygon"):
140
+ s.bounds = tuple(data["bounds"])
141
+ s.polygon = [tuple(p) for p in data["polygon"]]
142
+ s.color = data.get("color", "#ffffff")
143
+ return s
144
+ return finalize(s.name, s.sheet, s.frame_size, s.animations, s.scale, s.anchor)
145
+
146
+
147
+ def build_sheet(frames: dict[str, list[np.ndarray]], fps: dict[str, float], loop: dict[str, bool]
148
+ ) -> tuple[Image.Image, dict[str, Animation]]:
149
+ """Lays out frames row-per-animation in ANIM_ORDER (then any others)."""
150
+ names = [n for n in ANIM_ORDER if n in frames] + [n for n in frames if n not in ANIM_ORDER]
151
+ h, w = next(iter(frames.values()))[0].shape[:2]
152
+ cols = max(len(frames[n]) for n in names)
153
+ sheet = np.zeros((h * len(names), w * cols, 4), dtype=np.uint8)
154
+ anims = {}
155
+ for row, n in enumerate(names):
156
+ for col, f in enumerate(frames[n]):
157
+ sheet[row * h:(row + 1) * h, col * w:(col + 1) * w] = f
158
+ anims[n] = Animation([(c, row) for c in range(len(frames[n]))], fps.get(n, 8.0), loop.get(n, True))
159
+ return Image.fromarray(sheet, "RGBA"), anims
@@ -0,0 +1,102 @@
1
+ """Pixel-art tile atlas: [solid with exposed top, solid inner, one-way] in one row.
2
+
3
+ Drawn on a half-resolution grid and scaled 2x (same look as the sprites). Platformers get dirt with
4
+ a grassy top and wooden planks; top-down games get stone-brick walls with a lit top edge.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import random
10
+
11
+ import numpy as np
12
+ from PIL import Image
13
+
14
+ from .generate import _hex, _rgba, ramp
15
+
16
+ TILES = ("solid_top", "solid", "oneway")
17
+
18
+
19
+ def _shade(color: str, k: float) -> tuple:
20
+ r, g, b = _hex(color)
21
+ return _rgba(r * k, g * k, b * k)
22
+
23
+
24
+ def tileset(genre: str, solid: str, top: str, oneway: str, tile_size: int = 32, seed: int = 0) -> Image.Image:
25
+ n = max(tile_size // 2, 4)
26
+ canvas = np.zeros((n, n * 3, 4), dtype=np.uint8)
27
+ rng = random.Random(f"tiles:{seed}")
28
+ if genre == "topdown":
29
+ _bricks(canvas[:, 0:n], solid, top, rng, lit_top=True)
30
+ _bricks(canvas[:, n:2 * n], solid, top, rng, lit_top=False)
31
+ else:
32
+ _dirt(canvas[:, n:2 * n], solid, rng)
33
+ _dirt(canvas[:, 0:n], solid, rng)
34
+ _grass(canvas[:, 0:n], top, rng)
35
+ _plank(canvas[:, 2 * n:3 * n], oneway, rng)
36
+ img = Image.fromarray(canvas, "RGBA")
37
+ return img.resize((tile_size * 3, tile_size), Image.NEAREST)
38
+
39
+
40
+ def _dirt(px: np.ndarray, color: str, rng: random.Random) -> None:
41
+ n = px.shape[0]
42
+ r = ramp(color)
43
+ px[:, :] = r.base
44
+ for _ in range(n * n // 10): # pebbles and specks
45
+ x, y = rng.randrange(n), rng.randrange(n)
46
+ px[y, x] = r.shadow if rng.random() < 0.7 else r.light
47
+ if rng.random() < 0.3 and x + 1 < n:
48
+ px[y, x + 1] = r.shadow
49
+ px[n - 1, :] = _shade(color, 0.6) # seam between tiles reads as depth
50
+ px[:, n - 1] = _shade(color, 0.7)
51
+
52
+
53
+ def _grass(px: np.ndarray, color: str, rng: random.Random) -> None:
54
+ n = px.shape[0]
55
+ r = ramp(color)
56
+ depth = max(2, n // 4)
57
+ for x in range(n):
58
+ d = depth + (1 if rng.random() < 0.45 else 0)
59
+ px[0:d, x] = r.base
60
+ px[0, x] = r.light
61
+ px[d, x] = r.shadow # drop shadow under the turf
62
+ if rng.random() < 0.25:
63
+ px[d + 1 if d + 1 < n else d, x] = r.shadow
64
+ for _ in range(n // 3): # a few bright blades
65
+ px[rng.randrange(1, depth), rng.randrange(n)] = r.light
66
+
67
+
68
+ def _bricks(px: np.ndarray, color: str, cap: str, rng: random.Random, lit_top: bool) -> None:
69
+ n = px.shape[0]
70
+ r = ramp(color)
71
+ mortar = _shade(color, 0.55)
72
+ px[:, :] = r.base
73
+ row_h = max(3, n // 4)
74
+ for row, y0 in enumerate(range(0, n, row_h)):
75
+ px[y0, :] = mortar
76
+ offset = (n // 4) if row % 2 else 0
77
+ for x in range(offset, n + offset, max(4, n // 2)):
78
+ px[y0:y0 + row_h, x % n] = mortar
79
+ for x in range(n): # light the top row of each brick
80
+ if y0 + 1 < n and px[y0 + 1, x].tolist() != list(mortar):
81
+ px[y0 + 1, x] = r.light
82
+ for _ in range(n * n // 16):
83
+ x, y = rng.randrange(n), rng.randrange(n)
84
+ if px[y, x].tolist() == list(r.base):
85
+ px[y, x] = r.shadow
86
+ if lit_top:
87
+ c = ramp(cap)
88
+ px[0:2, :] = c.base
89
+ px[0, :] = c.light
90
+
91
+
92
+ def _plank(px: np.ndarray, color: str, rng: random.Random) -> None:
93
+ n = px.shape[0]
94
+ r = ramp(color)
95
+ h = max(3, int(n * 0.3))
96
+ px[0:h, :] = r.base
97
+ px[0, :] = r.light
98
+ px[h - 1, :] = r.shadow
99
+ for x in range(0, n, max(3, n // 3)): # grain and joints
100
+ px[1:h - 1, x] = r.shadow
101
+ for x in (1, n - 2):
102
+ px[h // 2, x] = (230, 220, 200, 255) # nails
File without changes