spriteforge 0.1.1__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 (218) hide show
  1. spriteforge/__init__.py +1 -0
  2. spriteforge/__main__.py +3 -0
  3. spriteforge/agents/__init__.py +0 -0
  4. spriteforge/agents/assets.py +109 -0
  5. spriteforge/agents/cancellation.py +12 -0
  6. spriteforge/agents/director.py +50 -0
  7. spriteforge/agents/models.py +63 -0
  8. spriteforge/agents/roles/art-director.md +15 -0
  9. spriteforge/agents/roles/art-reviewer.md +14 -0
  10. spriteforge/agents/roles/director.md +14 -0
  11. spriteforge/agents/roles/game-designer.md +15 -0
  12. spriteforge/agents/roles/level-tile-designer.md +16 -0
  13. spriteforge/agents/roles/librarian.md +13 -0
  14. spriteforge/agents/roles/menu-designer.md +15 -0
  15. spriteforge/agents/roles/pixel-artist.md +14 -0
  16. spriteforge/agents/roles/programmer.md +14 -0
  17. spriteforge/agents/roles/qa-playtester.md +13 -0
  18. spriteforge/agents/roles/sound-director.md +14 -0
  19. spriteforge/agents/roles.py +39 -0
  20. spriteforge/agents/router.py +233 -0
  21. spriteforge/agents/router_eval.py +145 -0
  22. spriteforge/agents/routing_state.py +133 -0
  23. spriteforge/agents/runtime.py +430 -0
  24. spriteforge/agents/workflow.py +389 -0
  25. spriteforge/app/__init__.py +0 -0
  26. spriteforge/app/agents_view.py +248 -0
  27. spriteforge/app/chat.py +114 -0
  28. spriteforge/app/extensions.py +115 -0
  29. spriteforge/app/index.html +279 -0
  30. spriteforge/app/local_only.py +49 -0
  31. spriteforge/app/server.py +358 -0
  32. spriteforge/app/static/assets/index-L2s1wlcp.css +1 -0
  33. spriteforge/app/static/assets/index-aBfzgLQl.js +71 -0
  34. spriteforge/app/static/assets/press-start-2p-latin-400-normal-BKfmdmKK.woff +0 -0
  35. spriteforge/app/static/assets/press-start-2p-latin-400-normal-xj9lRvVs.woff2 +0 -0
  36. spriteforge/app/static/index.html +13 -0
  37. spriteforge/app/static/press-start-2p-LICENSE.txt +93 -0
  38. spriteforge/app/studio.py +278 -0
  39. spriteforge/app/workers.py +73 -0
  40. spriteforge/cli.py +241 -0
  41. spriteforge/config.py +193 -0
  42. spriteforge/config.yaml +157 -0
  43. spriteforge/core/__init__.py +0 -0
  44. spriteforge/core/acting.py +16 -0
  45. spriteforge/core/cost.py +236 -0
  46. spriteforge/core/credentials.py +24 -0
  47. spriteforge/core/demo.py +79 -0
  48. spriteforge/core/forms.py +20 -0
  49. spriteforge/core/gates.py +175 -0
  50. spriteforge/core/security.py +42 -0
  51. spriteforge/core/state.py +222 -0
  52. spriteforge/core/team.py +15 -0
  53. spriteforge/core/usage.py +68 -0
  54. spriteforge/game/__init__.py +0 -0
  55. spriteforge/game/actors.py +377 -0
  56. spriteforge/game/agent_tools.py +178 -0
  57. spriteforge/game/art.py +2097 -0
  58. spriteforge/game/art_checks.py +190 -0
  59. spriteforge/game/art_review.py +518 -0
  60. spriteforge/game/build.py +134 -0
  61. spriteforge/game/bundle.py +1178 -0
  62. spriteforge/game/cli.py +119 -0
  63. spriteforge/game/codegen.py +433 -0
  64. spriteforge/game/codeinfo.py +154 -0
  65. spriteforge/game/dbsource.py +137 -0
  66. spriteforge/game/design.py +342 -0
  67. spriteforge/game/export.py +62 -0
  68. spriteforge/game/gamedb.py +191 -0
  69. spriteforge/game/image_cache.py +61 -0
  70. spriteforge/game/lessons.py +259 -0
  71. spriteforge/game/level_gen.py +118 -0
  72. spriteforge/game/level_preview.py +97 -0
  73. spriteforge/game/levels.py +934 -0
  74. spriteforge/game/loops.py +38 -0
  75. spriteforge/game/menus.py +712 -0
  76. spriteforge/game/mock_game.js +109 -0
  77. spriteforge/game/mock_platformer.js +58 -0
  78. spriteforge/game/motion.py +881 -0
  79. spriteforge/game/pipeline.py +1768 -0
  80. spriteforge/game/plan.py +502 -0
  81. spriteforge/game/playtest.py +318 -0
  82. spriteforge/game/qa.py +664 -0
  83. spriteforge/game/qa_checks.py +223 -0
  84. spriteforge/game/qa_runtime.py +565 -0
  85. spriteforge/game/reach.py +807 -0
  86. spriteforge/game/record.py +138 -0
  87. spriteforge/game/research.py +262 -0
  88. spriteforge/game/revision.py +225 -0
  89. spriteforge/game/sdk/API.md +104 -0
  90. spriteforge/game/sdk/FLOW.md +19 -0
  91. spriteforge/game/sdk/LEVELS.md +65 -0
  92. spriteforge/game/sdk/sf.js +418 -0
  93. spriteforge/game/sdk/sfflow.js +442 -0
  94. spriteforge/game/sdk/sflevel.js +683 -0
  95. spriteforge/game/sdk/sfsound-editor.js +225 -0
  96. spriteforge/game/sdk/sfsound.js +260 -0
  97. spriteforge/game/sdk/sounds.html +67 -0
  98. spriteforge/game/sounds.py +793 -0
  99. spriteforge/game/story.py +188 -0
  100. spriteforge/game/structure.py +98 -0
  101. spriteforge/game/team_reports.py +394 -0
  102. spriteforge/game/tilesets.py +70 -0
  103. spriteforge/game/title_art.py +186 -0
  104. spriteforge/genres/__init__.py +0 -0
  105. spriteforge/library/__init__.py +15 -0
  106. spriteforge/library/cli.py +126 -0
  107. spriteforge/library/describe.py +242 -0
  108. spriteforge/library/looks.py +154 -0
  109. spriteforge/library/models.py +79 -0
  110. spriteforge/library/store.py +422 -0
  111. spriteforge/library/tilesets/README.md +32 -0
  112. spriteforge/library/tilesets/gandalf-seasons.json +111 -0
  113. spriteforge/library/tilesets/pixel-crawler-dungeon.json +66 -0
  114. spriteforge/mcp/__init__.py +0 -0
  115. spriteforge/mcp/cli.py +58 -0
  116. spriteforge/mcp/client.py +57 -0
  117. spriteforge/mcp/filesystem_read.py +32 -0
  118. spriteforge/mcp/gamedb.py +56 -0
  119. spriteforge/mcp/library.py +179 -0
  120. spriteforge/mcp/memory.py +37 -0
  121. spriteforge/mcp/pixel_tools.py +145 -0
  122. spriteforge/mcp/registry.py +145 -0
  123. spriteforge/mcp/service.py +224 -0
  124. spriteforge/mcp/templates/context7.json +6 -0
  125. spriteforge/mcp/templates/fetch.json +6 -0
  126. spriteforge/mcp/templates/filesystem-read.json +6 -0
  127. spriteforge/mcp/templates/pixel-tools.json +6 -0
  128. spriteforge/memory/README.md +10 -0
  129. spriteforge/memory/__init__.py +0 -0
  130. spriteforge/memory/cli.py +131 -0
  131. spriteforge/memory/okf/__init__.py +1 -0
  132. spriteforge/memory/okf/catalog.py +331 -0
  133. spriteforge/memory/okf/codec.py +63 -0
  134. spriteforge/memory/okf/git.py +104 -0
  135. spriteforge/memory/okf/indexes.py +50 -0
  136. spriteforge/memory/okf/models.py +114 -0
  137. spriteforge/memory/okf/search.py +53 -0
  138. spriteforge/memory/okf/seed.py +81 -0
  139. spriteforge/memory/okf/seeds/genres.yaml +121 -0
  140. spriteforge/memory/okf/seeds/other.yaml +150 -0
  141. spriteforge/memory/okf/store.py +402 -0
  142. spriteforge/memory/okf/validate.py +115 -0
  143. spriteforge/pixel/__init__.py +17 -0
  144. spriteforge/pixel/atlas.py +110 -0
  145. spriteforge/pixel/grid.py +174 -0
  146. spriteforge/pixel/palette.py +156 -0
  147. spriteforge/pixel/palettes/dungeon.json +9 -0
  148. spriteforge/pixel/postprocess.py +170 -0
  149. spriteforge/pixel/preview.py +40 -0
  150. spriteforge/pixel/schemas.py +139 -0
  151. spriteforge/pixel/source.py +237 -0
  152. spriteforge/pixel/terrain.py +48 -0
  153. spriteforge/pixel/validate.py +138 -0
  154. spriteforge/prompts/anim_frame.txt +1 -0
  155. spriteforge/prompts/character.txt +1 -0
  156. spriteforge/prompts/item.txt +1 -0
  157. spriteforge/prompts/tile.txt +1 -0
  158. spriteforge/prompts/ui.txt +1 -0
  159. spriteforge/providers/__init__.py +0 -0
  160. spriteforge/providers/base.py +97 -0
  161. spriteforge/providers/http.py +38 -0
  162. spriteforge/providers/mock.py +111 -0
  163. spriteforge/providers/muse_image.py +60 -0
  164. spriteforge/providers/muse_search.py +91 -0
  165. spriteforge/providers/muse_spark.py +66 -0
  166. spriteforge/skills/__init__.py +0 -0
  167. spriteforge/skills/builtin/LICENSE-awesome-gamedev-agent-skills.txt +201 -0
  168. spriteforge/skills/builtin/NOTICE-awesome-gamedev-agent-skills.txt +15 -0
  169. spriteforge/skills/builtin/THIRD_PARTY.md +21 -0
  170. spriteforge/skills/builtin/audio-design/SKILL.md +190 -0
  171. spriteforge/skills/builtin/game-feel/SKILL.md +174 -0
  172. spriteforge/skills/builtin/game-ui-ux/SKILL.md +167 -0
  173. spriteforge/skills/builtin/level-design/SKILL.md +169 -0
  174. spriteforge/skills/builtin/phaser-arcade-physics/SKILL.md +17 -0
  175. spriteforge/skills/builtin/physics-tuning/SKILL.md +164 -0
  176. spriteforge/skills/builtin/pixel-art-prompting/SKILL.md +12 -0
  177. spriteforge/skills/builtin/platformer/SKILL.md +153 -0
  178. spriteforge/skills/builtin/puzzle/SKILL.md +143 -0
  179. spriteforge/skills/builtin/sprite-animation-frames/SKILL.md +11 -0
  180. spriteforge/skills/builtin/tileset-design/SKILL.md +10 -0
  181. spriteforge/skills/catalog.py +200 -0
  182. spriteforge/skills/cli.py +41 -0
  183. spriteforge/stages/__init__.py +0 -0
  184. spriteforge/stages/asset_context.py +301 -0
  185. spriteforge/stages/asset_models.py +192 -0
  186. spriteforge/stages/asset_render.py +366 -0
  187. spriteforge/stages/asset_review.py +140 -0
  188. spriteforge/stages/asset_store.py +94 -0
  189. spriteforge/stages/chat_changes.py +236 -0
  190. spriteforge/stages/concept.py +42 -0
  191. spriteforge/stages/game.py +1699 -0
  192. spriteforge/stages/game_confirm.py +112 -0
  193. spriteforge/stages/game_final.py +220 -0
  194. spriteforge/stages/game_record.py +246 -0
  195. spriteforge/stages/gameplay.py +218 -0
  196. spriteforge/stages/intake.py +372 -0
  197. spriteforge/stages/preview_assets.py +212 -0
  198. spriteforge/stages/preview_models.py +167 -0
  199. spriteforge/stages/preview_render.py +169 -0
  200. spriteforge/stages/preview_repair.py +61 -0
  201. spriteforge/stages/preview_review.py +169 -0
  202. spriteforge/stages/production.py +170 -0
  203. spriteforge/stages/references.py +105 -0
  204. spriteforge/stages/rewind.py +128 -0
  205. spriteforge/stages/workflow.py +440 -0
  206. spriteforge/templates/phaser/audit.js +45 -0
  207. spriteforge/templates/phaser/font.png +0 -0
  208. spriteforge/templates/phaser/font.xml +1 -0
  209. spriteforge/templates/phaser/preview.html +9 -0
  210. spriteforge/templates/phaser/room.js +49 -0
  211. spriteforge/templates/phaser/vendor/LICENSE.md +20 -0
  212. spriteforge/templates/phaser/vendor/phaser.min.js +1 -0
  213. spriteforge-0.1.1.dist-info/METADATA +364 -0
  214. spriteforge-0.1.1.dist-info/RECORD +218 -0
  215. spriteforge-0.1.1.dist-info/WHEEL +5 -0
  216. spriteforge-0.1.1.dist-info/entry_points.txt +2 -0
  217. spriteforge-0.1.1.dist-info/licenses/LICENSE +27 -0
  218. spriteforge-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,394 @@
1
+ """Each agent's report, in simple words and point by point.
2
+
3
+ Entry (at G2): the Game Designer writes the team's task list. Every agent that will work on
4
+ the build gets a plan: what must be ready before it starts (its entry gate), the steps it will
5
+ take for this game, and how we know it is done (its exit gate). The human approves the plans
6
+ with the design.
7
+
8
+ Exit (after each build, before G3/G4): every agent that worked says what it did, and code
9
+ checks its exit gate from the build report (the model never marks its own work as done).
10
+
11
+ Muse Spark writes the task list; code keeps it to the agents that really work on this build,
12
+ in their working order, fills what is missing from a plan made from the design, and keeps each
13
+ point short.
14
+ """
15
+
16
+ from collections import Counter
17
+
18
+ from pydantic import Field
19
+
20
+ from spriteforge.agents.models import Contract
21
+
22
+ from .design import GameDesign
23
+ from .plan import PlanRecord
24
+
25
+ POINT = 140 # the longest point, in characters
26
+ MAX_TASKS = 8
27
+
28
+ # What each agent really does in a build: the task list may only promise these.
29
+ JOBS = {
30
+ "Level/Tile Designer": "lays out each level as tiles from the plan, checks that every jump "
31
+ "can be made, draws a preview of each level",
32
+ "Art Director": "plans the art of every thing in the design (look, size, animation rows), "
33
+ "keeps one palette and style, makes the art sheet",
34
+ "Pixel Artist": "takes art from the library first, asks Muse Image for the rest, cleans "
35
+ "each image to the palette and size, draws the animation frames",
36
+ "Menu Designer": "writes every menu screen (title, pause, game over ...), the theme colours "
37
+ "and the title art, takes a screenshot of each screen",
38
+ "Sound Director": "picks a sound for every moment of the game (jump, hit, pick up, win ...)",
39
+ "Programmer": "writes the game's code (Phaser) from the design and the art, then fixes it "
40
+ "until the design's checks pass",
41
+ "QA Playtester": "lets a bot play the game, checks every design check, writes its own code "
42
+ "tests, records a video of the autoplay",
43
+ "Art Reviewer": "looks at the art inside the running game and fixes art that is hard to "
44
+ "read before you see it",
45
+ }
46
+
47
+
48
+ class AgentPlan(Contract):
49
+ agent: str = Field(description="the agent's name, exactly as listed")
50
+ starts_when: list[str] = Field(description="1-3 points: what must be ready before it starts")
51
+ tasks: list[str] = Field(description="3-8 steps it will do for THIS game, in order")
52
+ done_when: list[str] = Field(description="1-3 points: how we know it is finished")
53
+
54
+
55
+ class TaskList(Contract):
56
+ agents: list[AgentPlan]
57
+
58
+
59
+ class AgentReport(Contract):
60
+ agent: str
61
+ did: list[str]
62
+ checks: list[tuple[str, bool]] # an exit gate point and whether the build meets it
63
+
64
+
65
+ def clip(text: str) -> str:
66
+ text = " ".join(str(text).split())
67
+ return text if len(text) <= POINT else text[: POINT - 1].rstrip() + "…"
68
+
69
+
70
+ def names(items, n: int = 4) -> str:
71
+ items = [str(i) for i in items if str(i).strip()]
72
+ more = len(items) - n
73
+ return ", ".join(items[:n]) + (f" and {more} more" if more > 0 else "")
74
+
75
+
76
+ def build_agents(design: GameDesign, plan: PlanRecord | None, levels: bool, menus: bool,
77
+ sounds: bool, review: bool) -> list[str]: # fmt: skip
78
+ """The agents that work on a build, in their working order."""
79
+ on = {"Level/Tile Designer": levels, "Menu Designer": menus, "Sound Director": sounds,
80
+ "Art Reviewer": review} # fmt: skip
81
+ return [a for a in JOBS if on.get(a, True)]
82
+
83
+
84
+ # --- entry: the task list -----------------------------------------------------------------------
85
+
86
+
87
+ def default_tasks(design: GameDesign, plan: PlanRecord | None, agents: list[str]) -> TaskList:
88
+ """A plain plan made from the design (offline runs, and whatever the model left out)."""
89
+ things = names(e.id.replace("_", " ") for e in design.entities)
90
+ player = next((e for e in design.entities if e.role == "player"), None)
91
+ moving = [e for e in design.entities if e.animations]
92
+ levels = plan.plan.levels if plan else []
93
+ k = plan.kinematics if plan else None
94
+ approved = "You approve the game design and this plan (G2)"
95
+ plans = {
96
+ "Level/Tile Designer": AgentPlan(
97
+ agent="Level/Tile Designer",
98
+ starts_when=[approved, "The game plan lists the levels and the jump numbers"],
99
+ tasks=[
100
+ f"Lay out {len(levels)} levels: {names(lv.name for lv in levels)}",
101
+ *(f"Level {lv.n} teaches: {lv.teaching_goal}" for lv in levels[:3]),
102
+ (f"Keep every gap under {k.max_gap} tiles and every ledge under {k.max_rise} "
103
+ "tiles so each jump can be made") if k else "Check that every jump can be made",
104
+ "Draw a preview picture of each level",
105
+ ],
106
+ done_when=["Every level can be finished", "The bot clears every level"],
107
+ ),
108
+ "Art Director": AgentPlan(
109
+ agent="Art Director",
110
+ starts_when=[approved],
111
+ tasks=[
112
+ f"Plan the art for {len(design.entities)} things: {things}",
113
+ f"Keep one look for all of it: {design.look}",
114
+ f"Plan the animations of {len(moving)} moving things" if moving
115
+ else "Keep every thing still (no animations in the design)",
116
+ "Make one sheet that shows all the art together",
117
+ ],
118
+ done_when=["Every thing in the design has an art plan", "The art sheet is ready"],
119
+ ),
120
+ "Pixel Artist": AgentPlan(
121
+ agent="Pixel Artist",
122
+ starts_when=["The Art Director's art plan is ready"],
123
+ tasks=[
124
+ "Take art from the art library first",
125
+ "Ask Muse Image to draw the things the library does not have",
126
+ *(f"Draw the {e.id.replace('_', ' ')}: {e.look}" for e in design.entities[:4]),
127
+ "Clean every image to the game's palette and size",
128
+ ],
129
+ done_when=["Every thing has its art", "No art is missing from the game"],
130
+ ),
131
+ "Menu Designer": AgentPlan(
132
+ agent="Menu Designer",
133
+ starts_when=[approved, "The art is drawn (the menus use its colours)"],
134
+ tasks=[
135
+ "Write the title, pause and game over screens",
136
+ f"Make the title picture for '{design.title}' with Muse Image",
137
+ "Take theme colours from the game's art",
138
+ "Take a screenshot of every screen for you to check",
139
+ ],
140
+ done_when=["Every menu screen opens", "The title art is ready"],
141
+ ),
142
+ "Sound Director": AgentPlan(
143
+ agent="Sound Director",
144
+ starts_when=[approved],
145
+ tasks=[
146
+ "List every moment that needs a sound",
147
+ *[f"When the player sees '{f.event}', play: {f.hear}"
148
+ for f in (plan.plan.feel if plan else []) if f.hear.strip()][:4],
149
+ "Pick a sound for each moment",
150
+ ],
151
+ done_when=["Every moment has a sound", "The bot hears each sound in play"],
152
+ ),
153
+ "Programmer": AgentPlan(
154
+ agent="Programmer",
155
+ starts_when=[approved, "The art is ready"],
156
+ tasks=[
157
+ f"Write the game '{design.title}' in Phaser",
158
+ f"Controls: {design.controls}",
159
+ *(f"Rule: {r}" for r in design.rules[:3]),
160
+ f"Show on screen: {names(design.hud, 3)}" if design.hud else "Keep the screen clean",
161
+ f"How you win: {design.win}",
162
+ "Fix the code until every check passes",
163
+ ],
164
+ done_when=["The game starts with no errors",
165
+ f"All {len(design.checks)} checks of the design pass"],
166
+ ),
167
+ "QA Playtester": AgentPlan(
168
+ agent="QA Playtester",
169
+ starts_when=["The Programmer's game runs"],
170
+ tasks=[
171
+ "Let a bot play the game",
172
+ *(f"Check: {c.description}" for c in design.checks[:3]),
173
+ "Write my own tests and play them with real key presses",
174
+ "Record a video of the autoplay for you",
175
+ ],
176
+ done_when=["Every check is marked pass or fail", "A video or picture of play is ready"],
177
+ ),
178
+ "Art Reviewer": AgentPlan(
179
+ agent="Art Reviewer",
180
+ starts_when=["The game runs with its art"],
181
+ tasks=[
182
+ "Look at the art inside the running game",
183
+ f"Check that the {player.id.replace('_', ' ') if player else 'player'} is easy to see",
184
+ "Fix art that is hard to read before you see it",
185
+ "List smaller ideas for you as suggestions",
186
+ ],
187
+ done_when=["No big art problem is left"],
188
+ ),
189
+ }
190
+ return TaskList(agents=[plans[a] for a in agents])
191
+
192
+
193
+ def tasks_prompt(design: GameDesign, plan: PlanRecord | None, agents: list[str]) -> str:
194
+ jobs = "\n".join(f"- {a}: {JOBS[a]}" for a in agents)
195
+ return (
196
+ "Write the team's task list for building this game. One entry per agent below, in this "
197
+ "order. Write in simple, short words that someone who is not a programmer understands; "
198
+ "each point is one short sentence (under 20 words).\n"
199
+ "For each agent: starts_when (1-3 points: what must be ready before it starts), tasks "
200
+ "(3-8 steps it will do for THIS game, in order, naming the real things of the design: "
201
+ "its characters, levels, rules and checks), done_when (1-3 points: how we know it is "
202
+ "finished). Only promise what the agent's job below covers.\n"
203
+ f"Agents and their jobs:\n{jobs}\n"
204
+ f"The design:\n{design.model_dump_json()}\n"
205
+ + (f"The game plan:\n{plan.plan.model_dump_json()}\n" if plan else "")
206
+ )
207
+
208
+
209
+ def fit_tasks(answer: TaskList, fallback: TaskList) -> TaskList:
210
+ """Only the agents of this build, in working order; what the model left empty comes from
211
+ the plain plan; every point short."""
212
+ said = {p.agent.strip().lower(): p for p in answer.agents}
213
+
214
+ def pick(got: AgentPlan | None, base: AgentPlan, field: str, most: int) -> list[str]:
215
+ points = [clip(p) for p in getattr(got, field) if str(p).strip()] if got else []
216
+ return (points or [clip(p) for p in getattr(base, field)])[:most]
217
+
218
+ out = []
219
+ for base in fallback.agents:
220
+ got = said.get(base.agent.lower())
221
+ out.append(AgentPlan(agent=base.agent, starts_when=pick(got, base, "starts_when", 3),
222
+ tasks=pick(got, base, "tasks", MAX_TASKS),
223
+ done_when=pick(got, base, "done_when", 3)))
224
+ return TaskList(agents=out)
225
+
226
+
227
+ def entry_sections(p: AgentPlan, tools: list[str] = ()) -> list[dict]:
228
+ """`tools`: the MCP tools and skills the agent can use to look things up."""
229
+ return [
230
+ {"title": "Starts when (entry gate)", "points": p.starts_when},
231
+ {"title": "What I will do", "points": p.tasks, "numbered": True},
232
+ {"title": "Done when (exit gate)", "points": p.done_when},
233
+ *([{"title": "Tools and skills I can use", "points": list(tools)}] if tools else []),
234
+ ]
235
+
236
+
237
+ def tasks_markdown(tasks: TaskList, version: int, title: str) -> str:
238
+ out = [f"# Team plan for design v{version}: {title}",
239
+ "Each agent's plan. Approve the design (G2) to let them start."] # fmt: skip
240
+ for p in tasks.agents:
241
+ out.append(f"\n## {p.agent}")
242
+ out += sections_markdown(entry_sections(p))
243
+ return "\n".join(out) + "\n"
244
+
245
+
246
+ def sections_markdown(sections: list[dict]) -> list[str]:
247
+ out = []
248
+ for s in sections:
249
+ out.append(f"\n**{s['title']}**")
250
+ out += [f"{i + 1}. {p}" if s.get("numbered") else f"- {p}"
251
+ for i, p in enumerate(s["points"])] # fmt: skip
252
+ return out
253
+
254
+
255
+ def designer_report(design: GameDesign, plan: PlanRecord | None, research: bool,
256
+ tasks: TaskList, version: int) -> AgentReport: # fmt: skip
257
+ """The Game Designer's own exit report at G2: what it wrote."""
258
+ did = []
259
+ if research:
260
+ did.append("Studied similar games on the web")
261
+ if plan:
262
+ did.append(f"Wrote the game plan with {len(plan.plan.requirements)} of your wishes "
263
+ f"and {len(plan.plan.levels)} levels") # fmt: skip
264
+ did += [
265
+ f"Wrote design v{version}: '{design.title}'",
266
+ f"Listed {len(design.entities)} things: {names(e.id.replace('_', ' ') for e in design.entities)}",
267
+ f"Wrote {len(design.rules)} rules and {len(design.checks)} checks the game must pass",
268
+ f"Made a plan for {len(tasks.agents)} agents: {names([p.agent for p in tasks.agents], 8)}",
269
+ ]
270
+ checks = [("Every part of the design is filled in", bool(design.entities and design.checks))]
271
+ if plan:
272
+ checks.append(("The plan has no problem code could not fix", not plan.problems))
273
+ return AgentReport(agent="Game Designer", did=[clip(d) for d in did], checks=checks)
274
+
275
+
276
+ # --- exit: what each agent did --------------------------------------------------------------------
277
+
278
+
279
+ def art_counts(art: list[dict]) -> str:
280
+ def kind(source: str) -> str:
281
+ s = source.lower()
282
+ if "review" in s:
283
+ return "redrawn by the Art Reviewer"
284
+ if "reuse" in s or "kept" in s or "locked" in s:
285
+ return "kept from the last build"
286
+ if "librar" in s or "tileset" in s:
287
+ return "from the library"
288
+ if "placeholder" in s or "greybox" in s:
289
+ return "greybox (Muse unavailable)"
290
+ if "muse" in s:
291
+ return "drawn by Muse Image"
292
+ return "drawn by code"
293
+
294
+ counts = Counter(kind(a.get("source", "")) for a in art)
295
+ return ", ".join(f"{n} {k}" for k, n in counts.most_common())
296
+
297
+
298
+ def exit_reports(report: dict, design: GameDesign) -> list[AgentReport]:
299
+ """What each agent of the build did, and its exit gate checked by code from report.json."""
300
+ art = report.get("art", [])
301
+ checks = report.get("checks", [])
302
+ ok = sum(1 for c in checks if c.get("ok"))
303
+ out = []
304
+ if levels := report.get("levels"):
305
+ cleared = sum(1 for lv in levels if lv.get("cleared"))
306
+ out.append(AgentReport(agent="Level/Tile Designer", did=[
307
+ f"Laid out {len(levels)} levels: {names(lv['name'] for lv in levels)}",
308
+ (f"Code checked the jumps: {sum(1 for lv in levels if lv.get('solved'))} of "
309
+ f"{len(levels)} levels can be finished"),
310
+ "Drew a preview of each level",
311
+ ], checks=[("Every level can be finished", all(lv.get("solved") for lv in levels)),
312
+ (f"The bot clears every level ({cleared}/{len(levels)})", cleared == len(levels))])) # fmt: skip
313
+ motion = report.get("motion") or {}
314
+ out.append(AgentReport(agent="Art Director", did=[
315
+ f"Planned the art for {len(art)} things",
316
+ *([f"Planned {len(motion['rows'])} animation rows"] if motion.get("rows") else []),
317
+ "Made the art sheet that shows all the art together",
318
+ ], checks=[("Every thing in the design has art",
319
+ {a["entity"] for a in art} >= {e.id for e in design.entities})])) # fmt: skip
320
+ warnings = report.get("warnings", [])
321
+ out.append(AgentReport(agent="Pixel Artist", did=[
322
+ f"Made art for {len(art)} things: {art_counts(art)}" if art else "No art was needed",
323
+ "Cleaned every image to the game's palette and size",
324
+ *([f"{len(warnings)} warning(s) about the art (see the report)"] if warnings else []),
325
+ ], checks=[("No art is missing", bool(art)), ("No art warnings", not warnings)]))
326
+ if menus := report.get("menus"):
327
+ title = menus.get("title_art") or {}
328
+ out.append(AgentReport(agent="Menu Designer", did=[
329
+ f"Made {len(menus.get('screens', []))} menu screens",
330
+ f"Made the title art ({title.get('source', 'none')})",
331
+ f"Took the theme colours from the art ({(menus.get('theme') or {}).get('style', 'plain')} style)",
332
+ ], checks=[("Every menu screen opens", not menus.get("errors")),
333
+ ("No menu problem left", not menus.get("problems"))])) # fmt: skip
334
+ if sounds := report.get("sounds"):
335
+ cues, missed = sounds.get("cues", []), sounds.get("never_heard", [])
336
+ out.append(AgentReport(agent="Sound Director", did=[
337
+ f"Picked {len(cues)} sounds: {names(c['name'] for c in cues)}",
338
+ f"The bot heard {len(cues) - len(missed)} of them while playing",
339
+ ], checks=[("Every moment has a sound", bool(cues)),
340
+ ("The bot heard every sound", not missed)])) # fmt: skip
341
+ attempts = report.get("attempts", [])
342
+ out.append(AgentReport(agent="Programmer", did=[
343
+ f"Wrote the game '{report.get('title', design.title)}'",
344
+ f"Ran it {len(attempts)} time(s) and fixed it after each failed try" if len(attempts) > 1
345
+ else "It ran on the first try",
346
+ f"{ok} of {len(checks)} checks of the design pass",
347
+ *([f"Still open: {clip(p)}" for p in report.get("problems", [])[:2]]),
348
+ ], checks=[("The game starts with no errors", bool(report.get("play"))),
349
+ (f"All checks of the design pass ({ok}/{len(checks)})", bool(report.get("passed")))])) # fmt: skip
350
+ qa = report.get("qa") or {}
351
+ out.append(AgentReport(agent="QA Playtester", did=[
352
+ "Let a bot play the game",
353
+ f"Checked {len(checks)} checks: {ok} pass, {len(checks) - ok} fail",
354
+ *([f"Ran {qa['passed'] + qa['failed']} of my own tests: {qa['passed']} pass"] if qa else []),
355
+ "Recorded the autoplay for you",
356
+ ], checks=[("Every check is marked pass or fail", bool(checks)),
357
+ *([("All my tests pass", not qa.get("failed"))] if qa else [])])) # fmt: skip
358
+ if review := report.get("art_review"):
359
+ issues = review.get("issues", [])
360
+ fixed = sum(1 for i in issues if i.get("status") == "fixed")
361
+ blocking = sum(1 for i in issues if i.get("severity") == "blocking" and i.get("status") != "fixed")
362
+ out.append(AgentReport(agent="Art Reviewer", did=[
363
+ "Looked at the art inside the running game",
364
+ f"Found {len(issues)} issue(s) and fixed {fixed}",
365
+ *([f"{len(issues) - fixed} smaller idea(s) are suggestions for you"] if len(issues) > fixed else []),
366
+ ], checks=[(f"No big art problem left ({blocking} left)", not blocking)])) # fmt: skip
367
+ return [r.model_copy(update={"did": [clip(d) for d in r.did]}) for r in out]
368
+
369
+
370
+ def looked_up(tools: list[str]) -> str:
371
+ counts = Counter(tools)
372
+ return clip(f"Looked things up {len(tools)} time(s) with my tools: "
373
+ + ", ".join(f"{t} ×{n}" if n > 1 else t for t, n in counts.most_common()))
374
+
375
+
376
+ def exit_sections(r: AgentReport) -> list[dict]:
377
+ return [
378
+ {"title": "What I did", "points": r.did},
379
+ {"title": "Exit gate (checked by code)",
380
+ "points": [("✅ " if met else "⚠️ ") + text for text, met in r.checks]},
381
+ ]
382
+
383
+
384
+ def team_markdown(reports: list[AgentReport], version: int, title: str) -> str:
385
+ out = [f"# Team report for build v{version}: {title}", "What each agent did, point by point."]
386
+ for r in reports:
387
+ out.append(f"\n## {r.agent}")
388
+ out += sections_markdown(exit_sections(r))
389
+ return "\n".join(out) + "\n"
390
+
391
+
392
+ def payload(stage: str, title: str, sections: list[dict], key: str) -> dict:
393
+ """The `agent.report` event an agent's chat shows as a report card."""
394
+ return {"stage": stage, "title": title, "sections": sections, "key": key}
@@ -0,0 +1,70 @@
1
+ """Tile sets: named tiles cut from installed tileset sheets, with seasonal variants and parallax.
2
+
3
+ One JSON per tile set in `library/tilesets/` (see its README): which pack and sheet, the camera
4
+ it is drawn for, its tile size, and named tiles (`floor`, `wall`, `ground_top`, ...) at grid
5
+ cells. A tile set is offered only when its pack is installed. Tiles are referred to as
6
+ `<tileset>/<tile>` (e.g. `pixel-crawler-dungeon/floor`).
7
+ """
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from PIL import Image
13
+
14
+ from spriteforge.library.store import library_root, search
15
+
16
+ TILESETS_DIR = Path(__file__).parents[1] / "library" / "tilesets"
17
+
18
+
19
+ def load_tilesets(camera: str = "") -> list[dict]:
20
+ """Installed tile sets, optionally only those drawn for `camera` (top-down/side-view)."""
21
+ out = []
22
+ for path in sorted(TILESETS_DIR.glob("*.json")):
23
+ ts = json.loads(path.read_text(encoding="utf-8"))
24
+ if (library_root() / ts["pack"]).exists() and (not camera or ts["camera"] == camera):
25
+ out.append(ts)
26
+ return out
27
+
28
+
29
+ def tile_ids(tilesets: list[dict]) -> dict[str, tuple[dict, str]]:
30
+ return {f"{ts['id']}/{name}": (ts, name) for ts in tilesets for name in ts["tiles"]}
31
+
32
+
33
+ def pick_variant(ts: dict, text_words: set[str]) -> str:
34
+ return next(
35
+ (v for v, spec in ts["variants"].items() if text_words & set(spec["words"])), "normal"
36
+ )
37
+
38
+
39
+ def _asset_file(asset_id: str) -> Path:
40
+ return library_root() / next(a["file"] for a in search() if a["id"] == asset_id)
41
+
42
+
43
+ def tile_image(ts: dict, name: str, variant: str = "normal") -> Image.Image:
44
+ """One tile at the tile set's native size."""
45
+ spec = ts["tiles"][name]
46
+ size = ts["tile_size"]
47
+ sheet = Image.open(_asset_file(spec["asset"])).convert("RGBA")
48
+ cx, cy = spec["cell"]
49
+ if spec.get("seasonal"):
50
+ cy += ts["variants"].get(variant, ts["variants"]["normal"])["row_offset"]
51
+ tile = sheet.crop((cx * size, cy * size, cx * size + size, cy * size + size))
52
+ if spec.get("flip_y"):
53
+ tile = tile.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
54
+ return tile
55
+
56
+
57
+ def backdrop_layers(ts: dict, variant: str = "normal") -> list[tuple[str, Image.Image, float]]:
58
+ """Parallax layers back to front as (name, image, scroll factor); [] without a backdrop."""
59
+ spec = ts.get("backdrop")
60
+ if not spec:
61
+ return []
62
+ files = {a["id"]: a["file"] for a in search()}
63
+ tag = ts["variants"].get(variant, {}).get("backdrop", "normal-bg")
64
+ layers = []
65
+ for i, (layer, factor) in enumerate(zip(spec["layers"], spec["factors"], strict=True)):
66
+ asset = spec["asset_pattern"].format(variant=tag, layer=layer)
67
+ if asset in files:
68
+ image = Image.open(library_root() / files[asset]).convert("RGBA")
69
+ layers.append((f"backdrop_{i}", image, factor))
70
+ return layers
@@ -0,0 +1,186 @@
1
+ """The title art (L2.5): a background picture behind the title and main screens and an emblem
2
+ over the game's title, drawn by Muse Image in the style of the game's locked art (two calls),
3
+ cleaned and checked by the pixel tools. Muse never draws text: the SDK draws every word.
4
+
5
+ Style references are only art Muse drew for this game (library art, whatever its licence,
6
+ is never sent). Accepted title art is locked: a later build reuses it unless the menus'
7
+ descriptions of it change. Offline, when the calls fail or a picture fails its checks, code
8
+ draws it from the game's own art: a panel in the theme's colours with the ground tile along
9
+ the bottom, and the player sprite enlarged as the emblem.
10
+ """
11
+
12
+ import io
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass, field
15
+
16
+ import numpy as np
17
+ from PIL import Image
18
+
19
+ from spriteforge.pixel.schemas import ProcessingOptions, ProcessingReport
20
+ from spriteforge.pixel.source import key_background, mode_resize
21
+ from spriteforge.providers.base import ImageRequest
22
+
23
+ from .art import ArtResult, limit_palette, png
24
+ from .design import GameDesign
25
+ from .menus import MenuSpec, mix
26
+
27
+ BACKGROUND_SIZE, EMBLEM_SIZE = (1792, 1024), (1024, 1024)
28
+ EMBLEM_BOX = 56 # px at scale 1: the emblem's longest side
29
+
30
+
31
+ @dataclass
32
+ class TitleArt:
33
+ title: bytes # PNG at the screen's size
34
+ emblem: bytes # PNG with transparency
35
+ source: str # "muse", "muse (locked, from vN)", "code-drawn: <why>"
36
+ prompts: dict = field(default_factory=dict) # {"title": ..., "emblem": ...}
37
+ notes: list[str] = field(default_factory=list)
38
+ raw: dict = field(default_factory=dict) # Muse's pictures before cleaning
39
+ version: int = 0 # the build that made it (0: not built yet)
40
+
41
+ def record(self) -> dict:
42
+ return {"source": self.source, "prompts": self.prompts, "notes": self.notes,
43
+ "version": self.version}
44
+
45
+
46
+ def prompts(spec: MenuSpec, design: GameDesign) -> dict:
47
+ style = (f"Match the pixel size, outlines, shading and palette of the reference sprites "
48
+ f"exactly. Art direction: {design.look}.") # fmt: skip
49
+ return {
50
+ "title": (f"Pixel art title-screen background for the 2D {design.camera} game "
51
+ f"'{design.title}': {spec.title_art}. Wide landscape scene, chunky readable "
52
+ f"pixels, a calm darker area across the upper middle where a title will be "
53
+ f"placed. {style} No text, no letters, no numbers, no logo, no UI, no border."),
54
+ "emblem": (f"A single pixel art emblem for the game '{design.title}': {spec.emblem}. "
55
+ f"One centred subject, bold outline, on a plain flat magenta background "
56
+ f"(#FF00FF) that touches nothing. {style} No text, no letters, no numbers."),
57
+ } # fmt: skip
58
+
59
+
60
+ def references(art: ArtResult) -> list[bytes]:
61
+ """Up to two of the game's own Muse sprites (the player first), enlarged for Muse."""
62
+ muse = [(eid, f) for eid, f in art.filled.items() if f.source.startswith("muse")]
63
+ muse.sort(key=lambda kv: kv[1].meta.get("role") != "player")
64
+ out = []
65
+ for _, f in muse[:2]:
66
+ img = Image.open(io.BytesIO(f.png)).convert("RGBA")
67
+ frame = f.meta.get("frame") if f.kind == "sprite" else None
68
+ if frame:
69
+ img = img.crop((0, 0, int(frame[0]), int(frame[1])))
70
+ k = max(1, 256 // max(img.size))
71
+ out.append(png(img.resize((img.width * k, img.height * k), Image.NEAREST)))
72
+ return out
73
+
74
+
75
+ def clean_background(raw: bytes, screen: tuple[int, int]) -> tuple[Image.Image, list[str]]:
76
+ img = Image.open(io.BytesIO(raw)).convert("RGBA")
77
+ w, h = screen
78
+ scale = max(w / img.width, h / img.height)
79
+ size = (max(w, round(img.width * scale)), max(h, round(img.height * scale)))
80
+ img = mode_resize(img, size)
81
+ left, top = (img.width - w) // 2, (img.height - h) // 2
82
+ img = limit_palette(img.crop((left, top, left + w, top + h)), 32)
83
+ problems = []
84
+ luma = np.asarray(img.convert("L"), dtype=float) / 255
85
+ if luma.std() < 0.03:
86
+ problems.append("the background is one flat colour")
87
+ return img, problems
88
+
89
+
90
+ def clean_emblem(raw: bytes, box: int) -> tuple[Image.Image, list[str]]:
91
+ img = key_background(Image.open(io.BytesIO(raw)), ProcessingOptions(), ProcessingReport())
92
+ alpha = np.asarray(img)[..., 3]
93
+ problems = []
94
+ if not alpha.any():
95
+ return img, ["the emblem came back empty after removing its background"]
96
+ if (alpha > 0).mean() > 0.9: # nothing was keyed: the subject is on a picture, not a plain ground
97
+ problems.append("the emblem kept its background (no plain ground to remove)")
98
+ ys, xs = np.nonzero(alpha)
99
+ img = img.crop((int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1))
100
+ k = box / max(img.size)
101
+ img = mode_resize(img, (max(1, round(img.width * k)), max(1, round(img.height * k))))
102
+ img = limit_palette(img, 16)
103
+ filled = float((np.asarray(img)[..., 3] > 0).mean())
104
+ if filled < 0.08:
105
+ problems.append("the emblem is mostly empty")
106
+ return img, problems
107
+
108
+
109
+ def code_drawn(spec: MenuSpec, art: ArtResult, why: str) -> TitleArt:
110
+ """The title art drawn by code from the game's art and the theme's colours."""
111
+ w, h = art.screen
112
+ top, bottom = spec.theme.panel, mix(spec.theme.panel, spec.theme.border, 0.35)
113
+ rows = [np.array([int(mix(top, bottom, y / max(1, h - 1))[i:i + 2], 16) for i in (1, 3, 5)] + [255],
114
+ dtype=np.uint8) for y in range(h)] # fmt: skip
115
+ canvas = Image.fromarray(np.repeat(np.stack(rows)[:, None, :], w, axis=1))
116
+ tile = next((f for f in art.filled.values() if f.meta.get("role") == "tile" and f.kind == "image"), None)
117
+ if tile:
118
+ t = Image.open(io.BytesIO(tile.png)).convert("RGBA")
119
+ ground = h - t.height
120
+ for x in range(0, w, t.width):
121
+ canvas.alpha_composite(t, (x, ground))
122
+ player = next((f for f in art.filled.values() if f.meta.get("role") == "player"), None)
123
+ if player:
124
+ img = Image.open(io.BytesIO(player.png)).convert("RGBA")
125
+ frame = player.meta.get("frame") if player.kind == "sprite" else None
126
+ if frame:
127
+ img = img.crop((0, 0, int(frame[0]), int(frame[1])))
128
+ k = max(1, (EMBLEM_BOX * art.scale) // max(img.size))
129
+ emblem = img.resize((img.width * k, img.height * k), Image.NEAREST)
130
+ else:
131
+ emblem = Image.new("RGBA", (8, 8), (0, 0, 0, 0))
132
+ return TitleArt(png(canvas), png(emblem), f"code-drawn: {why}")
133
+
134
+
135
+ Generate = Callable[[ImageRequest, bool], bytes]
136
+
137
+
138
+ def make_title_art(spec: MenuSpec, design: GameDesign, art: ArtResult,
139
+ generate: Generate | None, cache=None,
140
+ previous: TitleArt | None = None) -> TitleArt: # fmt: skip
141
+ """Muse's title art for `spec`, or the locked `previous` when its descriptions are the
142
+ same, or code's when there is no image model (`generate` None) or the pictures fail."""
143
+ asked = prompts(spec, design)
144
+ if previous is not None and previous.prompts == asked and previous.source.startswith("muse"):
145
+ return previous
146
+ if generate is None:
147
+ offline = code_drawn(spec, art, "offline (no image model)")
148
+ offline.prompts = asked # what an image model would be asked (a later build may draw it)
149
+ return offline
150
+ refs = references(art)
151
+ raw, notes = {}, []
152
+ for part, size in (("title", BACKGROUND_SIZE), ("emblem", EMBLEM_SIZE)):
153
+ request = ImageRequest(prompt=asked[part], size=size, references=refs)
154
+ edit = bool(refs)
155
+ try:
156
+ hit = cache.get(request, edit) if cache else None
157
+ raw[part] = hit if hit is not None else generate(request, edit)
158
+ if hit is None and cache:
159
+ cache.put(request, edit, raw[part])
160
+ except Exception as exc: # noqa: BLE001 -- the title art falls back to code's
161
+ notes.append(f"Muse could not draw the {part} art ({type(exc).__name__}: {str(exc)[:160]})")
162
+ drawn = code_drawn(spec, art, "the Muse pictures failed their checks")
163
+ title, emblem = None, None
164
+ if "title" in raw:
165
+ img, problems = clean_background(raw["title"], art.screen)
166
+ notes += [f"title background: {p}" for p in problems]
167
+ title = None if problems else png(img)
168
+ if "emblem" in raw:
169
+ img, problems = clean_emblem(raw["emblem"], EMBLEM_BOX * art.scale)
170
+ notes += [f"emblem: {p}" for p in problems]
171
+ emblem = None if problems else png(img)
172
+ if title is None and emblem is None:
173
+ return TitleArt(drawn.title, drawn.emblem, drawn.source, asked, notes, raw)
174
+ source = "muse" if title and emblem else "muse (" + ("background" if title else "emblem") + "; the other code-drawn)"
175
+ return TitleArt(title or drawn.title, emblem or drawn.emblem, source, asked, notes, raw)
176
+
177
+
178
+ def manifest_images(t: TitleArt) -> tuple[dict, dict[str, bytes]]:
179
+ """assets.json image entries (role "menu": the SDK's, not the Programmer's) and files."""
180
+ out, files = {}, {}
181
+ for ident, data in (("sf_title_art", t.title), ("sf_emblem", t.emblem)):
182
+ w, h = Image.open(io.BytesIO(data)).size
183
+ out[ident] = {"file": f"assets/{ident}.png", "role": "menu", "size": [w, h],
184
+ "source": t.source} # fmt: skip
185
+ files[f"assets/{ident}.png"] = data
186
+ return out, files
File without changes
@@ -0,0 +1,15 @@
1
+ """Third-party pixel-art library: ingested packs with per-pack license tracking."""
2
+
3
+ from .models import AssetEntry, Pack
4
+ from .store import add_pack, asset_path, library_root, load_packs, search, set_license
5
+
6
+ __all__ = [
7
+ "AssetEntry",
8
+ "Pack",
9
+ "add_pack",
10
+ "asset_path",
11
+ "library_root",
12
+ "load_packs",
13
+ "search",
14
+ "set_license",
15
+ ]