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.
- spriteforge/__init__.py +1 -0
- spriteforge/__main__.py +3 -0
- spriteforge/agents/__init__.py +0 -0
- spriteforge/agents/assets.py +109 -0
- spriteforge/agents/cancellation.py +12 -0
- spriteforge/agents/director.py +50 -0
- spriteforge/agents/models.py +63 -0
- spriteforge/agents/roles/art-director.md +15 -0
- spriteforge/agents/roles/art-reviewer.md +14 -0
- spriteforge/agents/roles/director.md +14 -0
- spriteforge/agents/roles/game-designer.md +15 -0
- spriteforge/agents/roles/level-tile-designer.md +16 -0
- spriteforge/agents/roles/librarian.md +13 -0
- spriteforge/agents/roles/menu-designer.md +15 -0
- spriteforge/agents/roles/pixel-artist.md +14 -0
- spriteforge/agents/roles/programmer.md +14 -0
- spriteforge/agents/roles/qa-playtester.md +13 -0
- spriteforge/agents/roles/sound-director.md +14 -0
- spriteforge/agents/roles.py +39 -0
- spriteforge/agents/router.py +233 -0
- spriteforge/agents/router_eval.py +145 -0
- spriteforge/agents/routing_state.py +133 -0
- spriteforge/agents/runtime.py +430 -0
- spriteforge/agents/workflow.py +389 -0
- spriteforge/app/__init__.py +0 -0
- spriteforge/app/agents_view.py +248 -0
- spriteforge/app/chat.py +114 -0
- spriteforge/app/extensions.py +115 -0
- spriteforge/app/index.html +279 -0
- spriteforge/app/local_only.py +49 -0
- spriteforge/app/server.py +358 -0
- spriteforge/app/static/assets/index-L2s1wlcp.css +1 -0
- spriteforge/app/static/assets/index-aBfzgLQl.js +71 -0
- spriteforge/app/static/assets/press-start-2p-latin-400-normal-BKfmdmKK.woff +0 -0
- spriteforge/app/static/assets/press-start-2p-latin-400-normal-xj9lRvVs.woff2 +0 -0
- spriteforge/app/static/index.html +13 -0
- spriteforge/app/static/press-start-2p-LICENSE.txt +93 -0
- spriteforge/app/studio.py +278 -0
- spriteforge/app/workers.py +73 -0
- spriteforge/cli.py +241 -0
- spriteforge/config.py +193 -0
- spriteforge/config.yaml +157 -0
- spriteforge/core/__init__.py +0 -0
- spriteforge/core/acting.py +16 -0
- spriteforge/core/cost.py +236 -0
- spriteforge/core/credentials.py +24 -0
- spriteforge/core/demo.py +79 -0
- spriteforge/core/forms.py +20 -0
- spriteforge/core/gates.py +175 -0
- spriteforge/core/security.py +42 -0
- spriteforge/core/state.py +222 -0
- spriteforge/core/team.py +15 -0
- spriteforge/core/usage.py +68 -0
- spriteforge/game/__init__.py +0 -0
- spriteforge/game/actors.py +377 -0
- spriteforge/game/agent_tools.py +178 -0
- spriteforge/game/art.py +2097 -0
- spriteforge/game/art_checks.py +190 -0
- spriteforge/game/art_review.py +518 -0
- spriteforge/game/build.py +134 -0
- spriteforge/game/bundle.py +1178 -0
- spriteforge/game/cli.py +119 -0
- spriteforge/game/codegen.py +433 -0
- spriteforge/game/codeinfo.py +154 -0
- spriteforge/game/dbsource.py +137 -0
- spriteforge/game/design.py +342 -0
- spriteforge/game/export.py +62 -0
- spriteforge/game/gamedb.py +191 -0
- spriteforge/game/image_cache.py +61 -0
- spriteforge/game/lessons.py +259 -0
- spriteforge/game/level_gen.py +118 -0
- spriteforge/game/level_preview.py +97 -0
- spriteforge/game/levels.py +934 -0
- spriteforge/game/loops.py +38 -0
- spriteforge/game/menus.py +712 -0
- spriteforge/game/mock_game.js +109 -0
- spriteforge/game/mock_platformer.js +58 -0
- spriteforge/game/motion.py +881 -0
- spriteforge/game/pipeline.py +1768 -0
- spriteforge/game/plan.py +502 -0
- spriteforge/game/playtest.py +318 -0
- spriteforge/game/qa.py +664 -0
- spriteforge/game/qa_checks.py +223 -0
- spriteforge/game/qa_runtime.py +565 -0
- spriteforge/game/reach.py +807 -0
- spriteforge/game/record.py +138 -0
- spriteforge/game/research.py +262 -0
- spriteforge/game/revision.py +225 -0
- spriteforge/game/sdk/API.md +104 -0
- spriteforge/game/sdk/FLOW.md +19 -0
- spriteforge/game/sdk/LEVELS.md +65 -0
- spriteforge/game/sdk/sf.js +418 -0
- spriteforge/game/sdk/sfflow.js +442 -0
- spriteforge/game/sdk/sflevel.js +683 -0
- spriteforge/game/sdk/sfsound-editor.js +225 -0
- spriteforge/game/sdk/sfsound.js +260 -0
- spriteforge/game/sdk/sounds.html +67 -0
- spriteforge/game/sounds.py +793 -0
- spriteforge/game/story.py +188 -0
- spriteforge/game/structure.py +98 -0
- spriteforge/game/team_reports.py +394 -0
- spriteforge/game/tilesets.py +70 -0
- spriteforge/game/title_art.py +186 -0
- spriteforge/genres/__init__.py +0 -0
- spriteforge/library/__init__.py +15 -0
- spriteforge/library/cli.py +126 -0
- spriteforge/library/describe.py +242 -0
- spriteforge/library/looks.py +154 -0
- spriteforge/library/models.py +79 -0
- spriteforge/library/store.py +422 -0
- spriteforge/library/tilesets/README.md +32 -0
- spriteforge/library/tilesets/gandalf-seasons.json +111 -0
- spriteforge/library/tilesets/pixel-crawler-dungeon.json +66 -0
- spriteforge/mcp/__init__.py +0 -0
- spriteforge/mcp/cli.py +58 -0
- spriteforge/mcp/client.py +57 -0
- spriteforge/mcp/filesystem_read.py +32 -0
- spriteforge/mcp/gamedb.py +56 -0
- spriteforge/mcp/library.py +179 -0
- spriteforge/mcp/memory.py +37 -0
- spriteforge/mcp/pixel_tools.py +145 -0
- spriteforge/mcp/registry.py +145 -0
- spriteforge/mcp/service.py +224 -0
- spriteforge/mcp/templates/context7.json +6 -0
- spriteforge/mcp/templates/fetch.json +6 -0
- spriteforge/mcp/templates/filesystem-read.json +6 -0
- spriteforge/mcp/templates/pixel-tools.json +6 -0
- spriteforge/memory/README.md +10 -0
- spriteforge/memory/__init__.py +0 -0
- spriteforge/memory/cli.py +131 -0
- spriteforge/memory/okf/__init__.py +1 -0
- spriteforge/memory/okf/catalog.py +331 -0
- spriteforge/memory/okf/codec.py +63 -0
- spriteforge/memory/okf/git.py +104 -0
- spriteforge/memory/okf/indexes.py +50 -0
- spriteforge/memory/okf/models.py +114 -0
- spriteforge/memory/okf/search.py +53 -0
- spriteforge/memory/okf/seed.py +81 -0
- spriteforge/memory/okf/seeds/genres.yaml +121 -0
- spriteforge/memory/okf/seeds/other.yaml +150 -0
- spriteforge/memory/okf/store.py +402 -0
- spriteforge/memory/okf/validate.py +115 -0
- spriteforge/pixel/__init__.py +17 -0
- spriteforge/pixel/atlas.py +110 -0
- spriteforge/pixel/grid.py +174 -0
- spriteforge/pixel/palette.py +156 -0
- spriteforge/pixel/palettes/dungeon.json +9 -0
- spriteforge/pixel/postprocess.py +170 -0
- spriteforge/pixel/preview.py +40 -0
- spriteforge/pixel/schemas.py +139 -0
- spriteforge/pixel/source.py +237 -0
- spriteforge/pixel/terrain.py +48 -0
- spriteforge/pixel/validate.py +138 -0
- spriteforge/prompts/anim_frame.txt +1 -0
- spriteforge/prompts/character.txt +1 -0
- spriteforge/prompts/item.txt +1 -0
- spriteforge/prompts/tile.txt +1 -0
- spriteforge/prompts/ui.txt +1 -0
- spriteforge/providers/__init__.py +0 -0
- spriteforge/providers/base.py +97 -0
- spriteforge/providers/http.py +38 -0
- spriteforge/providers/mock.py +111 -0
- spriteforge/providers/muse_image.py +60 -0
- spriteforge/providers/muse_search.py +91 -0
- spriteforge/providers/muse_spark.py +66 -0
- spriteforge/skills/__init__.py +0 -0
- spriteforge/skills/builtin/LICENSE-awesome-gamedev-agent-skills.txt +201 -0
- spriteforge/skills/builtin/NOTICE-awesome-gamedev-agent-skills.txt +15 -0
- spriteforge/skills/builtin/THIRD_PARTY.md +21 -0
- spriteforge/skills/builtin/audio-design/SKILL.md +190 -0
- spriteforge/skills/builtin/game-feel/SKILL.md +174 -0
- spriteforge/skills/builtin/game-ui-ux/SKILL.md +167 -0
- spriteforge/skills/builtin/level-design/SKILL.md +169 -0
- spriteforge/skills/builtin/phaser-arcade-physics/SKILL.md +17 -0
- spriteforge/skills/builtin/physics-tuning/SKILL.md +164 -0
- spriteforge/skills/builtin/pixel-art-prompting/SKILL.md +12 -0
- spriteforge/skills/builtin/platformer/SKILL.md +153 -0
- spriteforge/skills/builtin/puzzle/SKILL.md +143 -0
- spriteforge/skills/builtin/sprite-animation-frames/SKILL.md +11 -0
- spriteforge/skills/builtin/tileset-design/SKILL.md +10 -0
- spriteforge/skills/catalog.py +200 -0
- spriteforge/skills/cli.py +41 -0
- spriteforge/stages/__init__.py +0 -0
- spriteforge/stages/asset_context.py +301 -0
- spriteforge/stages/asset_models.py +192 -0
- spriteforge/stages/asset_render.py +366 -0
- spriteforge/stages/asset_review.py +140 -0
- spriteforge/stages/asset_store.py +94 -0
- spriteforge/stages/chat_changes.py +236 -0
- spriteforge/stages/concept.py +42 -0
- spriteforge/stages/game.py +1699 -0
- spriteforge/stages/game_confirm.py +112 -0
- spriteforge/stages/game_final.py +220 -0
- spriteforge/stages/game_record.py +246 -0
- spriteforge/stages/gameplay.py +218 -0
- spriteforge/stages/intake.py +372 -0
- spriteforge/stages/preview_assets.py +212 -0
- spriteforge/stages/preview_models.py +167 -0
- spriteforge/stages/preview_render.py +169 -0
- spriteforge/stages/preview_repair.py +61 -0
- spriteforge/stages/preview_review.py +169 -0
- spriteforge/stages/production.py +170 -0
- spriteforge/stages/references.py +105 -0
- spriteforge/stages/rewind.py +128 -0
- spriteforge/stages/workflow.py +440 -0
- spriteforge/templates/phaser/audit.js +45 -0
- spriteforge/templates/phaser/font.png +0 -0
- spriteforge/templates/phaser/font.xml +1 -0
- spriteforge/templates/phaser/preview.html +9 -0
- spriteforge/templates/phaser/room.js +49 -0
- spriteforge/templates/phaser/vendor/LICENSE.md +20 -0
- spriteforge/templates/phaser/vendor/phaser.min.js +1 -0
- spriteforge-0.1.1.dist-info/METADATA +364 -0
- spriteforge-0.1.1.dist-info/RECORD +218 -0
- spriteforge-0.1.1.dist-info/WHEEL +5 -0
- spriteforge-0.1.1.dist-info/entry_points.txt +2 -0
- spriteforge-0.1.1.dist-info/licenses/LICENSE +27 -0
- spriteforge-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""`spriteforge library ...`: ingest downloaded packs and browse the catalog."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from . import store
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(no_args_is_help=True, help="Third-party asset library with license tracking.")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _cell(value: str) -> tuple[int, int] | str | None:
|
|
14
|
+
if not value:
|
|
15
|
+
return None
|
|
16
|
+
if value == "auto":
|
|
17
|
+
return "auto"
|
|
18
|
+
w, _, h = value.lower().partition("x")
|
|
19
|
+
return int(w), int(h or w)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
def add(
|
|
24
|
+
source: Path,
|
|
25
|
+
pack: Annotated[str, typer.Option(help="Pack id, e.g. kenney-tiny-dungeon.")],
|
|
26
|
+
category: Annotated[str, typer.Option(help="character/enemy/tileset/item/...")] = "other",
|
|
27
|
+
subcategory: str = "",
|
|
28
|
+
title: str = "",
|
|
29
|
+
tag: Annotated[list[str] | None, typer.Option(help="Extra tag (repeatable).")] = None,
|
|
30
|
+
cell: Annotated[str, typer.Option(help="Grid cell for sheets, e.g. 16x16.")] = "",
|
|
31
|
+
author: str = "unknown",
|
|
32
|
+
source_url: str = "",
|
|
33
|
+
license: Annotated[str, typer.Option(help="CC0, CC-BY-4.0, ... or UNKNOWN.")] = "UNKNOWN",
|
|
34
|
+
license_url: str = "",
|
|
35
|
+
verified: Annotated[bool, typer.Option(help="License checked on the source page.")] = False,
|
|
36
|
+
notes: str = "",
|
|
37
|
+
):
|
|
38
|
+
"""Add a downloaded .zip, folder or image to assets/library/."""
|
|
39
|
+
result = store.add_pack(
|
|
40
|
+
source, pack, title=title, category=category, subcategory=subcategory, tags=tag or [],
|
|
41
|
+
cell=_cell(cell), author=author, source_url=source_url, license=license,
|
|
42
|
+
license_url=license_url, license_verified=verified, notes=notes,
|
|
43
|
+
) # fmt: skip
|
|
44
|
+
typer.echo(f"{result.id}: {len(result.assets)} assets, usage={result.usage}")
|
|
45
|
+
if result.usage == "reference-only":
|
|
46
|
+
typer.echo(" License not verified: builds will not ship this art. Use `library license`.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command("list")
|
|
50
|
+
def list_cmd():
|
|
51
|
+
"""Show packs with their license status."""
|
|
52
|
+
for p in store.load_packs():
|
|
53
|
+
typer.echo(f"{p.id:32} {len(p.assets):4} assets {p.license:12} {p.usage}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command()
|
|
57
|
+
def search(category: str = "", tag: str = "", cell: str = "", usage: str = ""):
|
|
58
|
+
"""Find assets, e.g. --category equipment --tag chainmail --usage ship."""
|
|
59
|
+
for a in store.search(category=category, tag=tag, size=_cell(cell), usage=usage):
|
|
60
|
+
typer.echo(f"{a['id']:44} {a['cell'][0]}x{a['cell'][1]} {a['usage']:16} {a['file']}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def describe(
|
|
65
|
+
pack: Annotated[list[str] | None, typer.Option(help="Only this pack (repeatable).")] = None,
|
|
66
|
+
force: Annotated[bool, typer.Option(help="Describe again what is already described.")] = False,
|
|
67
|
+
mock: Annotated[bool, typer.Option(help="Offline: measured looks only, no model.")] = False,
|
|
68
|
+
max_calls: Annotated[int, typer.Option(help="Cap on Muse Spark calls.")] = 30,
|
|
69
|
+
max_cost: Annotated[float, typer.Option(help="Cap on the estimated cost, USD.")] = 0.5,
|
|
70
|
+
config: Path | None = None,
|
|
71
|
+
workspace: Path | None = None,
|
|
72
|
+
):
|
|
73
|
+
"""Describe every library image: a measured look (colours, brightness, detail) for all,
|
|
74
|
+
and a Muse Spark (vision) description for packs that allow AI use."""
|
|
75
|
+
from spriteforge.config import BudgetConfig, init_workspace, load_config
|
|
76
|
+
from spriteforge.core.cost import Budget
|
|
77
|
+
from spriteforge.core.credentials import load_credentials
|
|
78
|
+
from spriteforge.core.state import Store
|
|
79
|
+
from spriteforge.providers.muse_spark import MuseSparkProvider
|
|
80
|
+
|
|
81
|
+
from .describe import describe_library
|
|
82
|
+
|
|
83
|
+
cfg = load_config(config)
|
|
84
|
+
provider, budget = None, None
|
|
85
|
+
if not mock:
|
|
86
|
+
load_credentials()
|
|
87
|
+
root = init_workspace(workspace or cfg.workspace)
|
|
88
|
+
store = Store(root / "library.sqlite3")
|
|
89
|
+
cap = BudgetConfig(**{**cfg.budget.model_dump(), "max_calls_per_model": max_calls,
|
|
90
|
+
"max_estimated_cost": max_cost}) # fmt: skip
|
|
91
|
+
run = store.create_run({"workflow": "library-describe", "budget": cap.model_dump()})
|
|
92
|
+
budget = Budget(store, run.id, cap)
|
|
93
|
+
provider = MuseSparkProvider(cfg.llm, budget)
|
|
94
|
+
counts = describe_library(provider=provider, model=cfg.llm.model if provider else "",
|
|
95
|
+
reasoning=cfg.llm.reasoning.get("describe"), packs=pack or None,
|
|
96
|
+
force=force, log=typer.echo) # fmt: skip
|
|
97
|
+
if provider is not None:
|
|
98
|
+
provider.close()
|
|
99
|
+
typer.echo(
|
|
100
|
+
f"{counts['model']} described by the model, {counts['measured']} measured only, "
|
|
101
|
+
f"{counts['kept']} unchanged, {counts['failed']} not described"
|
|
102
|
+
+ (f"; about ${budget.spent():.3f}" if budget else "")
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def mcp():
|
|
108
|
+
"""Expose read-only library tools (packs, search, actors, tile sets) over MCP stdio."""
|
|
109
|
+
from spriteforge.mcp.library import create_server
|
|
110
|
+
|
|
111
|
+
create_server().run(transport="stdio")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command()
|
|
115
|
+
def license(
|
|
116
|
+
pack: str,
|
|
117
|
+
license: str,
|
|
118
|
+
source_url: str = "",
|
|
119
|
+
license_url: str = "",
|
|
120
|
+
author: str = "",
|
|
121
|
+
):
|
|
122
|
+
"""Record a license you checked on the pack's download page (marks it verified)."""
|
|
123
|
+
p = store.set_license(
|
|
124
|
+
pack, license, source_url=source_url, license_url=license_url, author=author
|
|
125
|
+
)
|
|
126
|
+
typer.echo(f"{p.id}: license={p.license} usage={p.usage}")
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Descriptions of every library image, so the harness can choose art by what it shows.
|
|
2
|
+
|
|
3
|
+
`describe_library` gives every asset, and every named tile of an installed tile set, a measured
|
|
4
|
+
look (`looks.measure`: colours, brightness, detail; free). Images from packs that allow AI use
|
|
5
|
+
are also described by Muse Spark (vision): numbered contact sheets of up to 16 images, one
|
|
6
|
+
call each, answering what each image is, one sentence on what it shows and the camera it is
|
|
7
|
+
drawn for. Packs whose license forbids AI use never reach a model: their entry is the file
|
|
8
|
+
name and the measured look.
|
|
9
|
+
|
|
10
|
+
Entries are written to `<library>/descriptions/<pack>.json` (`{"assets": {id: entry},
|
|
11
|
+
"tiles": {tile id: entry}}`) with the image's sha256, so a changed image is described again
|
|
12
|
+
and an unchanged one is not sent twice. Model-written entries are `status: generated`; edit
|
|
13
|
+
the text, set `status: reviewed`, and later runs keep it. `rebuild_index` merges the entries
|
|
14
|
+
into index.json, so `search()` (the art step, the MCP tools, the catalog) returns them.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import io
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Literal
|
|
25
|
+
|
|
26
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
27
|
+
from pydantic import Field
|
|
28
|
+
|
|
29
|
+
from spriteforge.agents.models import Contract
|
|
30
|
+
from spriteforge.core.cost import BudgetExceeded
|
|
31
|
+
from spriteforge.providers.base import ChatRequest, LLMProvider, Message, chat_json
|
|
32
|
+
|
|
33
|
+
from .looks import Look, asset_image, measure
|
|
34
|
+
from .store import library_root, load_descriptions, load_packs, rebuild_index
|
|
35
|
+
|
|
36
|
+
PER_SHEET = 16
|
|
37
|
+
CELL = 128 # px per image on the contact sheet
|
|
38
|
+
LABEL = 18
|
|
39
|
+
SHEET_BACKGROUND = (128, 128, 132, 255)
|
|
40
|
+
View = Literal["top-down", "3/4 top-down", "side", "front", "flat", "unknown"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Seen(Contract):
|
|
44
|
+
n: int = Field(description="the image's number on the sheet")
|
|
45
|
+
what: str = Field(description="what it is, 1-4 words, e.g. 'hooded rogue', 'stone floor tile'")
|
|
46
|
+
description: str = Field(
|
|
47
|
+
description="one sentence, at most 30 words: the subject, its main colours by name and "
|
|
48
|
+
"whether it is light or dark, and the details that matter when choosing game art"
|
|
49
|
+
)
|
|
50
|
+
view: View = Field(description="the camera the art is drawn for")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SheetAnswer(Contract):
|
|
54
|
+
images: list[Seen]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Item:
|
|
59
|
+
key: str # asset id, or tile id (`<tileset>/<tile>`)
|
|
60
|
+
section: Literal["assets", "tiles"]
|
|
61
|
+
pack: str
|
|
62
|
+
name: str
|
|
63
|
+
category: str
|
|
64
|
+
image: Image.Image
|
|
65
|
+
sha256: str
|
|
66
|
+
ai_allowed: bool
|
|
67
|
+
hint: str # size and frames, for the prompt
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def library_items(root: Path, packs: list[str] | None = None) -> list[Item]:
|
|
71
|
+
"""Every asset (an animation sheet as its first frame) and every named tile of the
|
|
72
|
+
installed tile sets, in a stable order."""
|
|
73
|
+
from spriteforge.game.tilesets import load_tilesets, tile_image
|
|
74
|
+
|
|
75
|
+
items = []
|
|
76
|
+
installed = {p.id: p for p in load_packs(root) if not packs or p.id in packs}
|
|
77
|
+
for pack in installed.values():
|
|
78
|
+
for a in pack.assets:
|
|
79
|
+
asset = a.model_dump(mode="json")
|
|
80
|
+
frames = f", {a.frames} frames of {a.cell[0]}x{a.cell[1]}" if a.frames > 1 else ""
|
|
81
|
+
items.append(Item(a.id, "assets", pack.id, a.name, a.category,
|
|
82
|
+
asset_image(asset, root), a.sha256, pack.ai_allowed,
|
|
83
|
+
f"{a.size[0]}x{a.size[1]}{frames}")) # fmt: skip
|
|
84
|
+
for ts in load_tilesets():
|
|
85
|
+
if ts["pack"] not in installed:
|
|
86
|
+
continue
|
|
87
|
+
for name, spec in ts["tiles"].items():
|
|
88
|
+
tile = tile_image(ts, name)
|
|
89
|
+
buf = io.BytesIO()
|
|
90
|
+
tile.save(buf, format="PNG")
|
|
91
|
+
items.append(Item(f"{ts['id']}/{name}", "tiles", ts["pack"], spec["what"], "tile",
|
|
92
|
+
tile, hashlib.sha256(buf.getvalue()).hexdigest(),
|
|
93
|
+
installed[ts["pack"]].ai_allowed,
|
|
94
|
+
f"one {ts['tile_size']} px tile of a {ts['camera']} tile set")) # fmt: skip
|
|
95
|
+
return items
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def measured_entry(item: Item, look: Look) -> dict:
|
|
99
|
+
return {
|
|
100
|
+
"sha256": item.sha256,
|
|
101
|
+
"what": item.name,
|
|
102
|
+
"text": f"{item.name} ({item.category}); {look.text}",
|
|
103
|
+
"view": "unknown",
|
|
104
|
+
"look": look.model_dump(),
|
|
105
|
+
"by": "measured",
|
|
106
|
+
"status": "measured",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def contact_sheet(items: list[Item]) -> bytes:
|
|
111
|
+
"""Numbered cells of CELL px on a neutral grey; pixel art scaled by whole numbers."""
|
|
112
|
+
cols = min(4, len(items))
|
|
113
|
+
rows = math.ceil(len(items) / cols)
|
|
114
|
+
step = CELL + LABEL
|
|
115
|
+
sheet = Image.new("RGBA", (cols * CELL, rows * step), SHEET_BACKGROUND)
|
|
116
|
+
draw = ImageDraw.Draw(sheet)
|
|
117
|
+
font = ImageFont.load_default(size=LABEL - 4)
|
|
118
|
+
for n, item in enumerate(items):
|
|
119
|
+
image = item.image
|
|
120
|
+
if bbox := image.getchannel("A").getbbox():
|
|
121
|
+
image = image.crop(bbox)
|
|
122
|
+
side = max(image.size)
|
|
123
|
+
if side > CELL - 8:
|
|
124
|
+
k = math.ceil(side / (CELL - 8))
|
|
125
|
+
image = image.resize(
|
|
126
|
+
(max(1, image.width // k), max(1, image.height // k)), Image.Resampling.NEAREST
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
k = max(1, (CELL - 8) // side)
|
|
130
|
+
image = image.resize((image.width * k, image.height * k), Image.Resampling.NEAREST)
|
|
131
|
+
x, y = (n % cols) * CELL, (n // cols) * step
|
|
132
|
+
draw.text((x + 4, y + 1), str(n + 1), fill=(255, 255, 255, 255), font=font)
|
|
133
|
+
sheet.alpha_composite(
|
|
134
|
+
image, (x + (CELL - image.width) // 2, y + LABEL + (CELL - image.height) // 2)
|
|
135
|
+
)
|
|
136
|
+
buf = io.BytesIO()
|
|
137
|
+
sheet.save(buf, format="PNG")
|
|
138
|
+
return buf.getvalue()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def sheet_prompt(items: list[Item]) -> str:
|
|
142
|
+
listed = "\n".join(
|
|
143
|
+
f'{n}. "{item.name}" ({item.category}, {item.hint})' for n, item in enumerate(items, 1)
|
|
144
|
+
)
|
|
145
|
+
return (
|
|
146
|
+
f"The image is a contact sheet of {len(items)} numbered pixel-art images from a game "
|
|
147
|
+
"art library, each enlarged with square pixels on a neutral grey (an animation sheet "
|
|
148
|
+
"shows its first frame; a tile set shows its whole sheet). For every number, say what "
|
|
149
|
+
"the image shows, judged from its pixels: `what` in 1-4 words, `description` in one "
|
|
150
|
+
"sentence of at most 30 words (the subject, its main colours by name and whether it is "
|
|
151
|
+
"light or dark, and the details that matter when choosing art for a game), and `view` "
|
|
152
|
+
"(the camera it is drawn for). The names below come from file names: they are hints "
|
|
153
|
+
"and may be wrong or cryptic; trust the pixels.\n\n" + listed
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def role_text() -> str:
|
|
158
|
+
from spriteforge.agents.roles import load_roles
|
|
159
|
+
|
|
160
|
+
r = load_roles()["Librarian"]
|
|
161
|
+
return f"You are the {r.name} of a small game studio. Goal: {r.goal}\n{r.prompt}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def describe_library(
|
|
165
|
+
root: Path | None = None,
|
|
166
|
+
provider: LLMProvider | None = None,
|
|
167
|
+
*,
|
|
168
|
+
model: str = "",
|
|
169
|
+
reasoning: str | None = None,
|
|
170
|
+
packs: list[str] | None = None,
|
|
171
|
+
force: bool = False,
|
|
172
|
+
per_sheet: int = PER_SHEET,
|
|
173
|
+
log: Callable[[str], None] = print,
|
|
174
|
+
) -> dict:
|
|
175
|
+
"""Describe the library (see the module doc). Without a `provider` only measured looks
|
|
176
|
+
are written. A failed sheet keeps measured entries for its images; running out of budget
|
|
177
|
+
stops the model calls. Returns counts."""
|
|
178
|
+
root = library_root(root)
|
|
179
|
+
old = load_descriptions(root)
|
|
180
|
+
items = library_items(root, packs)
|
|
181
|
+
out: dict[str, dict] = {p: {"assets": {}, "tiles": {}} for p in {i.pack for i in items}}
|
|
182
|
+
for pack, data in old.items(): # packs not described this time keep their file as is
|
|
183
|
+
out.setdefault(pack, data)
|
|
184
|
+
todo: list[Item] = []
|
|
185
|
+
counts = {"model": 0, "measured": 0, "kept": 0, "failed": 0, "sheets": 0}
|
|
186
|
+
for item in items:
|
|
187
|
+
look = measure(item.image)
|
|
188
|
+
prev = old.get(item.pack, {}).get(item.section, {}).get(item.key)
|
|
189
|
+
same = prev is not None and prev.get("sha256") == item.sha256
|
|
190
|
+
if same and (prev["status"] == "reviewed" or (prev["by"] != "measured" and not force)):
|
|
191
|
+
out[item.pack][item.section][item.key] = {**prev, "look": look.model_dump()}
|
|
192
|
+
counts["kept"] += 1
|
|
193
|
+
continue
|
|
194
|
+
out[item.pack][item.section][item.key] = measured_entry(item, look)
|
|
195
|
+
if item.ai_allowed and provider is not None:
|
|
196
|
+
todo.append(item)
|
|
197
|
+
else:
|
|
198
|
+
counts["measured"] += 1
|
|
199
|
+
batches = [todo[i : i + per_sheet] for i in range(0, len(todo), per_sheet)]
|
|
200
|
+
for number, batch in enumerate(batches, 1):
|
|
201
|
+
request = ChatRequest(
|
|
202
|
+
messages=[
|
|
203
|
+
Message(role="system", content=role_text()),
|
|
204
|
+
Message(role="user", content=sheet_prompt(batch), images=[contact_sheet(batch)]),
|
|
205
|
+
],
|
|
206
|
+
reasoning_effort=reasoning,
|
|
207
|
+
cache_key="spriteforge-describe",
|
|
208
|
+
)
|
|
209
|
+
try:
|
|
210
|
+
counts["sheets"] += 1
|
|
211
|
+
answer = chat_json(provider, request, SheetAnswer)
|
|
212
|
+
except BudgetExceeded as exc:
|
|
213
|
+
log(f" sheet {number}/{len(batches)}: {exc}; the rest keep measured entries")
|
|
214
|
+
counts["failed"] += sum(len(b) for b in batches[number - 1 :])
|
|
215
|
+
break
|
|
216
|
+
except Exception as exc: # noqa: BLE001 -- one bad sheet must not lose the others
|
|
217
|
+
log(f" sheet {number}/{len(batches)} failed ({type(exc).__name__}: {exc})"[:300])
|
|
218
|
+
counts["failed"] += len(batch)
|
|
219
|
+
continue
|
|
220
|
+
seen = {s.n: s for s in answer.images if 1 <= s.n <= len(batch)}
|
|
221
|
+
for n, item in enumerate(batch, 1):
|
|
222
|
+
if n not in seen:
|
|
223
|
+
counts["failed"] += 1
|
|
224
|
+
continue
|
|
225
|
+
s = seen[n]
|
|
226
|
+
entry = out[item.pack][item.section][item.key]
|
|
227
|
+
entry.update(what=s.what.strip(), text=s.description.strip(), view=s.view,
|
|
228
|
+
by=model or "model", status="generated") # fmt: skip
|
|
229
|
+
counts["model"] += 1
|
|
230
|
+
log(f" sheet {number}/{len(batches)}: {len(seen)} of {len(batch)} described")
|
|
231
|
+
folder = root / "descriptions"
|
|
232
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
for pack, data in sorted(out.items()):
|
|
234
|
+
if not data.get("assets") and not data.get("tiles"):
|
|
235
|
+
continue
|
|
236
|
+
data = {
|
|
237
|
+
"pack": pack,
|
|
238
|
+
**{k: dict(sorted(v.items())) for k, v in data.items() if k != "pack"},
|
|
239
|
+
}
|
|
240
|
+
(folder / f"{pack}.json").write_text(json.dumps(data, indent=1) + "\n", encoding="utf-8")
|
|
241
|
+
rebuild_index(root)
|
|
242
|
+
return counts
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""What a library image looks like, measured from its pixels (free; no model sees it).
|
|
2
|
+
|
|
3
|
+
`measure` names the main colours and gives brightness (mean Rec. 709 luma of the drawn
|
|
4
|
+
pixels, 0-1) and detail (the spread between the 10th and 90th percentile of that luma). The
|
|
5
|
+
library descriptions carry it for every asset, the Art Director's list shows it, and the art
|
|
6
|
+
step compares library picks with the colours a design names (`colour_gap`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import colorsys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from PIL import Image
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
LUMA = np.array([0.2126, 0.7152, 0.0722])
|
|
17
|
+
FLAT = 0.03 # detail under this: one flat colour (e.g. a wall top with no bricks)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Colour(BaseModel):
|
|
21
|
+
name: str
|
|
22
|
+
hex: str
|
|
23
|
+
share: float # of the drawn pixels
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Look(BaseModel):
|
|
27
|
+
colours: list[Colour] # main colours, largest first
|
|
28
|
+
brightness: float
|
|
29
|
+
detail: float
|
|
30
|
+
fill: float # drawn share of the image
|
|
31
|
+
mean: str # mean colour of the drawn pixels
|
|
32
|
+
text: str # "tan and brown; mid (0.49); low-contrast detail"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def hex_of(rgb) -> str:
|
|
36
|
+
return "#" + "".join(f"{int(np.rint(c)):02X}" for c in rgb[:3])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def rgb_of(hex_code: str) -> tuple[int, int, int]:
|
|
40
|
+
return tuple(int(hex_code[i : i + 2], 16) for i in (1, 3, 5))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def colour_name(rgb) -> str:
|
|
44
|
+
"""A plain colour word for an RGB colour: 'tan', 'dark grey', 'grey-teal', 'olive' ..."""
|
|
45
|
+
h, s, v = colorsys.rgb_to_hsv(*(c / 255 for c in rgb[:3]))
|
|
46
|
+
if v < 0.13:
|
|
47
|
+
return "black"
|
|
48
|
+
if s < 0.13 or (s < 0.2 and v < 0.3):
|
|
49
|
+
return (
|
|
50
|
+
"dark grey" if v < 0.4 else "grey" if v < 0.68 else "light grey" if v < 0.9 else "white"
|
|
51
|
+
)
|
|
52
|
+
deg = h * 360
|
|
53
|
+
if deg < 15 or deg >= 345:
|
|
54
|
+
base = "red"
|
|
55
|
+
elif deg < 42:
|
|
56
|
+
base = "tan" if s < 0.45 and v >= 0.45 else "brown" if v < 0.62 else "orange"
|
|
57
|
+
elif deg < 70:
|
|
58
|
+
base = "olive" if v < 0.55 else "cream" if s < 0.4 else "yellow"
|
|
59
|
+
elif deg < 160:
|
|
60
|
+
base = "green"
|
|
61
|
+
elif deg < 195:
|
|
62
|
+
base = "cyan" if v > 0.75 and s > 0.45 else "teal"
|
|
63
|
+
elif deg < 255:
|
|
64
|
+
base = "blue"
|
|
65
|
+
elif deg < 290:
|
|
66
|
+
base = "purple"
|
|
67
|
+
else:
|
|
68
|
+
base = "pink" if v > 0.75 and s < 0.55 else "magenta"
|
|
69
|
+
earthy = base in {"tan", "brown", "olive", "cream"}
|
|
70
|
+
if s < 0.3 and not earthy:
|
|
71
|
+
base = f"grey-{base}"
|
|
72
|
+
if v < 0.38 and base not in {"brown", "olive"}:
|
|
73
|
+
return f"dark {base}"
|
|
74
|
+
if v > 0.88 and s < 0.5 and not earthy:
|
|
75
|
+
return f"light {base}"
|
|
76
|
+
return base
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def brightness_word(value: float) -> str:
|
|
80
|
+
for limit, word in ((0.2, "very dark"), (0.38, "dark"), (0.6, "mid"), (0.8, "light")):
|
|
81
|
+
if value < limit:
|
|
82
|
+
return word
|
|
83
|
+
return "very light"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def detail_word(value: float) -> str:
|
|
87
|
+
if value < FLAT:
|
|
88
|
+
return "flat, no detail"
|
|
89
|
+
return "low-contrast detail" if value < 0.12 else "high-contrast detail"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def measure(image: Image.Image) -> Look:
|
|
93
|
+
a = np.asarray(image.convert("RGBA"))
|
|
94
|
+
drawn = a[..., 3] > 127
|
|
95
|
+
rgb = a[drawn][:, :3]
|
|
96
|
+
if not len(rgb):
|
|
97
|
+
return Look(colours=[], brightness=0, detail=0, fill=0, mean="#000000", text="empty")
|
|
98
|
+
luma = rgb.astype(float) @ LUMA / 255
|
|
99
|
+
colours, counts = np.unique(rgb, axis=0, return_counts=True)
|
|
100
|
+
groups: dict[str, list] = {}
|
|
101
|
+
for colour, count in zip(colours, counts, strict=True):
|
|
102
|
+
groups.setdefault(colour_name(colour), []).append((colour.astype(float), int(count)))
|
|
103
|
+
total = int(counts.sum())
|
|
104
|
+
named = []
|
|
105
|
+
for name, items in groups.items():
|
|
106
|
+
n = sum(k for _, k in items)
|
|
107
|
+
mean = sum(c * k for c, k in items) / n
|
|
108
|
+
named.append(Colour(name=name, hex=hex_of(mean), share=round(n / total, 3)))
|
|
109
|
+
named.sort(key=lambda c: (-c.share, c.name))
|
|
110
|
+
main = [c for c in named if c.share >= 0.08][:4] or named[:1]
|
|
111
|
+
words = [c.name for c in main]
|
|
112
|
+
listed = ", ".join(words[:-1]) + " and " + words[-1] if len(words) > 1 else words[0]
|
|
113
|
+
brightness = round(float(luma.mean()), 3)
|
|
114
|
+
detail = round(float(np.percentile(luma, 90) - np.percentile(luma, 10)), 3)
|
|
115
|
+
return Look(
|
|
116
|
+
colours=main,
|
|
117
|
+
brightness=brightness,
|
|
118
|
+
detail=detail,
|
|
119
|
+
fill=round(float(drawn.mean()), 3),
|
|
120
|
+
mean=hex_of(rgb.astype(float).mean(axis=0)),
|
|
121
|
+
text=f"{listed}; {brightness_word(brightness)} ({brightness:.2f}); {detail_word(detail)}",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def lab(hex_code: str) -> np.ndarray:
|
|
126
|
+
"""CIELAB (D65) of an sRGB colour."""
|
|
127
|
+
rgb = np.array(rgb_of(hex_code)) / 255
|
|
128
|
+
linear = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
|
|
129
|
+
xyz = (
|
|
130
|
+
np.array([[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]])
|
|
131
|
+
@ linear
|
|
132
|
+
/ np.array([0.95047, 1.0, 1.08883])
|
|
133
|
+
)
|
|
134
|
+
f = np.where(xyz > (6 / 29) ** 3, np.cbrt(xyz), xyz / (3 * (6 / 29) ** 2) + 4 / 29)
|
|
135
|
+
return np.array([116 * f[1] - 16, 500 * (f[0] - f[1]), 200 * (f[1] - f[2])])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def colour_gap(a: str, b: str) -> float:
|
|
139
|
+
"""How far apart two colours look (CIE76 delta E; about 25 and up: clearly different)."""
|
|
140
|
+
return float(np.linalg.norm(lab(a) - lab(b)))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def asset_image(asset: dict, root: Path) -> Image.Image:
|
|
144
|
+
"""What an asset shows: its first drawn frame for an animation sheet, else the image."""
|
|
145
|
+
image = Image.open(root / asset["file"]).convert("RGBA")
|
|
146
|
+
w, h = asset["cell"]
|
|
147
|
+
if asset.get("frames", 1) <= 1 or (w, h) == image.size:
|
|
148
|
+
return image
|
|
149
|
+
for y in range(0, image.height - h + 1, h):
|
|
150
|
+
for x in range(0, image.width - w + 1, w):
|
|
151
|
+
frame = image.crop((x, y, x + w, y + h))
|
|
152
|
+
if frame.getchannel("A").getbbox():
|
|
153
|
+
return frame
|
|
154
|
+
return image.crop((0, 0, w, h))
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Asset library records: every third-party file carries its pack's source and license."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
# Licenses that allow shipping the art inside a generated game without extra steps.
|
|
8
|
+
SHIPPABLE = {"CC0", "CC0-1.0", "PUBLIC-DOMAIN", "OWN"}
|
|
9
|
+
# Free-to-use custom licenses: fine inside a built game, but the raw files may not be
|
|
10
|
+
# redistributed (kept out of git via `redistributable: false`).
|
|
11
|
+
FREE_USE = {"FREE-USE"}
|
|
12
|
+
# Licenses that allow shipping only with attribution (credits file is written into the build).
|
|
13
|
+
ATTRIBUTION = {"CC-BY-3.0", "CC-BY-4.0", "OGA-BY-3.0", "MIT"}
|
|
14
|
+
|
|
15
|
+
Category = Literal[
|
|
16
|
+
"character",
|
|
17
|
+
"enemy",
|
|
18
|
+
"tileset",
|
|
19
|
+
"prop",
|
|
20
|
+
"item",
|
|
21
|
+
"equipment",
|
|
22
|
+
"ui",
|
|
23
|
+
"effect",
|
|
24
|
+
"background",
|
|
25
|
+
"other",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Strict(BaseModel):
|
|
30
|
+
model_config = ConfigDict(extra="forbid")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AssetEntry(Strict):
|
|
34
|
+
id: str # "<pack>/<slug>"
|
|
35
|
+
file: str # path relative to the library root
|
|
36
|
+
category: Category
|
|
37
|
+
subcategory: str = ""
|
|
38
|
+
name: str
|
|
39
|
+
tags: list[str] = Field(default_factory=list)
|
|
40
|
+
size: tuple[int, int]
|
|
41
|
+
# Grid cell when the file is a sheet; equals size for a single sprite.
|
|
42
|
+
cell: tuple[int, int]
|
|
43
|
+
frames: int = 1
|
|
44
|
+
colors: int
|
|
45
|
+
opaque_bbox: tuple[int, int, int, int] | None
|
|
46
|
+
transparent_background: bool
|
|
47
|
+
sha256: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Pack(Strict):
|
|
51
|
+
id: str = Field(pattern=r"^[a-z0-9][a-z0-9-]*$")
|
|
52
|
+
title: str
|
|
53
|
+
author: str = "unknown"
|
|
54
|
+
source_url: str = ""
|
|
55
|
+
license: str = "UNKNOWN"
|
|
56
|
+
license_url: str = ""
|
|
57
|
+
license_verified: bool = False
|
|
58
|
+
notes: str = ""
|
|
59
|
+
original_archive: str = ""
|
|
60
|
+
# False: raw files may not be shared (public repo, zip of the library); builds still may.
|
|
61
|
+
redistributable: bool = True
|
|
62
|
+
# False: the license forbids AI use (e.g. "no AI training"); never send to Muse as a
|
|
63
|
+
# reference or training input. Builds may still include the art.
|
|
64
|
+
ai_allowed: bool = True
|
|
65
|
+
# Which way side-facing sprites in this pack look. Kits expect right-facing art and flip
|
|
66
|
+
# it when moving left, so "left" packs are mirrored when they are adapted into a slot.
|
|
67
|
+
faces: Literal["right", "left"] = "right"
|
|
68
|
+
assets: list[AssetEntry] = Field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def usage(self) -> Literal["ship", "ship-with-credit", "reference-only"]:
|
|
72
|
+
"""What a build may do with this pack. Unverified packs are never shipped."""
|
|
73
|
+
if not self.license_verified:
|
|
74
|
+
return "reference-only"
|
|
75
|
+
if self.license.upper() in SHIPPABLE | FREE_USE:
|
|
76
|
+
return "ship"
|
|
77
|
+
if self.license.upper() in ATTRIBUTION:
|
|
78
|
+
return "ship-with-credit"
|
|
79
|
+
return "reference-only"
|