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,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
|