pi-sprite 1.0.0
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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/NOTICE.md +7 -0
- package/README.md +353 -0
- package/examples/README.md +15 -0
- package/examples/custom-pets/wumpus-template/README.md +21 -0
- package/examples/custom-pets/wumpus-template/pet.json +14 -0
- package/examples/petdex-downloads/.gitkeep +0 -0
- package/extensions/index.ts +104 -0
- package/package.json +77 -0
- package/skills/pi-sprite-authoring/SKILL.md +330 -0
- package/skills/pi-sprite-authoring/assets/wumpus-template/pet.json +14 -0
- package/skills/pi-sprite-authoring/references/character-cohesion-review.md +65 -0
- package/skills/pi-sprite-authoring/references/gpt-image-sprite-workflow.md +181 -0
- package/skills/pi-sprite-authoring/references/petdex-reference-to-custom-pet.md +155 -0
- package/skills/pi-sprite-authoring/references/wumpus-sprite-prompts.md +54 -0
- package/skills/pi-sprite-authoring/scripts/assemble_sprite_strip.py +98 -0
- package/skills/pi-sprite-authoring/scripts/create-pet-template.mjs +51 -0
- package/skills/pi-sprite-authoring/scripts/create_motion_strip.py +110 -0
- package/skills/pi-sprite-authoring/scripts/download-petdex-examples.mjs +79 -0
- package/skills/pi-sprite-authoring/scripts/openai_sprite_image.py +223 -0
- package/skills/pi-sprite-authoring/scripts/remove_sprite_background.py +207 -0
- package/src/agent/session-entries.ts +28 -0
- package/src/agent/side-completion.ts +94 -0
- package/src/agent/side-session-text.ts +20 -0
- package/src/agent/side-session-types.ts +12 -0
- package/src/agent/side-session.ts +107 -0
- package/src/btw/completion.ts +15 -0
- package/src/btw/format.ts +30 -0
- package/src/btw/index.ts +306 -0
- package/src/btw/prompt.ts +35 -0
- package/src/btw/recap.ts +56 -0
- package/src/btw/session.ts +37 -0
- package/src/btw/thread-store.ts +37 -0
- package/src/context/index.ts +343 -0
- package/src/recap/conversation.ts +22 -0
- package/src/recap/direct.ts +15 -0
- package/src/recap/format.ts +25 -0
- package/src/recap/generation.ts +58 -0
- package/src/recap/index.ts +86 -0
- package/src/sprite/commands.ts +205 -0
- package/src/sprite/download.ts +75 -0
- package/src/sprite/kitty-placeholder.ts +441 -0
- package/src/sprite/live-status-format.ts +67 -0
- package/src/sprite/live-status.ts +48 -0
- package/src/sprite/loader.ts +134 -0
- package/src/sprite/manifest.ts +87 -0
- package/src/sprite/paths.ts +8 -0
- package/src/sprite/petdex.ts +133 -0
- package/src/sprite/renderer.ts +491 -0
- package/src/sprite/runtime.ts +524 -0
- package/src/sprite/turn-status-format.ts +112 -0
- package/src/sprite/turn-status.ts +88 -0
- package/src/ui/overlay.ts +308 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Petdex Reference to Custom Pet
|
|
2
|
+
|
|
3
|
+
Use this workflow when the user wants a custom pet inspired by Boba, Petdex, Sprite Mart, or another existing sprite.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- Discover reference pets
|
|
8
|
+
- Reference-image binding
|
|
9
|
+
- Direction-card stage
|
|
10
|
+
- Character lock
|
|
11
|
+
- State set
|
|
12
|
+
- Character cohesion review
|
|
13
|
+
- Packaging loop
|
|
14
|
+
|
|
15
|
+
## Discover reference pets
|
|
16
|
+
|
|
17
|
+
In Pi:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
/pet gallery
|
|
21
|
+
/pet search boba
|
|
22
|
+
/pet preview <slug>
|
|
23
|
+
/pet install <slug>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For local reference files without installing in the active session:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
node skills/pi-sprite-authoring/scripts/download-petdex-examples.mjs \
|
|
30
|
+
--query boba \
|
|
31
|
+
--limit 5 \
|
|
32
|
+
--out /tmp/pi-sprite-petdex-refs
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Downloaded Petdex files are local reference material. Do not commit or redistribute third-party assets unless their licenses are verified.
|
|
36
|
+
|
|
37
|
+
## Reference-image binding
|
|
38
|
+
|
|
39
|
+
Before generating images, bind each reference to an explicit role and instruction. Do not pass vague instructions like "use this image".
|
|
40
|
+
|
|
41
|
+
Good binding:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"id": "boba-petdex",
|
|
46
|
+
"path": "/tmp/pi-sprite-petdex-refs/boba/spritesheet.webp",
|
|
47
|
+
"role": "style_reference",
|
|
48
|
+
"instruction": "Use for compact pixel-art scale, outline thickness, simple pose language, and terminal readability. Do not copy the exact character identity, costume, or sprite frames."
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Reference roles:
|
|
53
|
+
|
|
54
|
+
- `style_reference` — palette, outline, pixel density, pose simplicity.
|
|
55
|
+
- `scale_reference` — canvas size, silhouette size, padding.
|
|
56
|
+
- `character_reference` — user-owned character identity that should be preserved.
|
|
57
|
+
- `mood_reference` — emotional target only.
|
|
58
|
+
- `negative_reference` — what to avoid.
|
|
59
|
+
|
|
60
|
+
For unclear-license Petdex assets, use `style_reference` or `scale_reference`, not `character_reference`.
|
|
61
|
+
|
|
62
|
+
## Direction-card stage
|
|
63
|
+
|
|
64
|
+
Turn the user brief and references into 3-5 direction cards before generating. Each card should be a concrete pet concept, not just a style adjective.
|
|
65
|
+
|
|
66
|
+
```markdown
|
|
67
|
+
1. **Sleepy Tapioca Familiar**
|
|
68
|
+
- Character: round boba-pearl creature with a tiny straw sprout
|
|
69
|
+
- Mood: cozy, drowsy, lightly mischievous
|
|
70
|
+
- Visual lock: cream/caramel/brown palette, dark chunky outline, dot eyes, centered 128x128 canvas
|
|
71
|
+
- Why it fits: round silhouette stays readable in terminal size
|
|
72
|
+
- Risk: could look like a generic boba mascot unless the straw/ears are distinctive
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Ask the user to choose, combine, or revise directions. If the user says to proceed autonomously, state the chosen direction and why.
|
|
76
|
+
|
|
77
|
+
## Character lock
|
|
78
|
+
|
|
79
|
+
After direction selection, write a stable character lock and repeat it in every state prompt.
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
Canonical character lock:
|
|
83
|
+
A tiny original boba-tea terminal companion. Rounded milk-tea body, two large tapioca-pearl ear shapes, small straw sprout tilted left, sleepy dot eyes, tiny paws, cream/brown/caramel palette, bold dark pixel outline. Always centered on a transparent 128x128 canvas with 8px padding. No text, logo, cup branding, background, shadow, or watermark.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## State set
|
|
87
|
+
|
|
88
|
+
Do not generate all states independently from only a style reference. First generate 3-6 `idle` candidates, choose one canonical anchor, and then generate the remaining states as edits that preserve that anchor's character identity.
|
|
89
|
+
|
|
90
|
+
Generate or select one image for each `pi-sprite` state:
|
|
91
|
+
|
|
92
|
+
- `idle` — neutral/resting pose.
|
|
93
|
+
- `thinking` — pondering, curious, tilted, or looking upward; no question-mark text.
|
|
94
|
+
- `working` — focused/active/typing; keep props large and simple.
|
|
95
|
+
- `success` — celebratory/happy; no text.
|
|
96
|
+
- `error` — confused/worried but still cute; no text.
|
|
97
|
+
|
|
98
|
+
Prefer five separate state images for first-time custom pets. Use spritesheets only after the five-state identity works.
|
|
99
|
+
|
|
100
|
+
## Character cohesion review
|
|
101
|
+
|
|
102
|
+
After generating state images, compare each state to the canonical anchor before packaging.
|
|
103
|
+
|
|
104
|
+
```markdown
|
|
105
|
+
## Character cohesion review
|
|
106
|
+
|
|
107
|
+
Canonical anchor: /tmp/boba-inspired/anchor/idle.png
|
|
108
|
+
|
|
109
|
+
| State | Identity | Silhouette | Face | Ears/props | Palette/outline | State readability | Verdict |
|
|
110
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
111
|
+
| thinking | same | same | minor drift | same | same | clear | accept |
|
|
112
|
+
| working | major drift | changed body | changed face | missing straw | same | clear | regenerate |
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Reject states with major drift in identity, silhouette, face, signature props, palette, or outline. Regeneration prompts should say exactly what to preserve from the anchor and only change pose/expression. Use `character-cohesion-review.md` for a reusable review prompt and table format.
|
|
116
|
+
|
|
117
|
+
If accepted images lack alpha, run `scripts/remove_sprite_background.py` and package the cleaned images, not the raw generated files.
|
|
118
|
+
|
|
119
|
+
## Packaging loop
|
|
120
|
+
|
|
121
|
+
Create a pet template:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
node skills/pi-sprite-authoring/scripts/create-pet-template.mjs \
|
|
125
|
+
--id boba-inspired \
|
|
126
|
+
--name "Boba Inspired" \
|
|
127
|
+
--out /tmp/boba-inspired-pet
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Copy or rename selected outputs:
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
/tmp/boba-inspired-pet/idle.png
|
|
134
|
+
/tmp/boba-inspired-pet/thinking.png
|
|
135
|
+
/tmp/boba-inspired-pet/working.png
|
|
136
|
+
/tmp/boba-inspired-pet/success.png
|
|
137
|
+
/tmp/boba-inspired-pet/error.png
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Import and test:
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
/pet import /tmp/boba-inspired-pet
|
|
144
|
+
/pet choose boba-inspired
|
|
145
|
+
/pet size small
|
|
146
|
+
/pet label off
|
|
147
|
+
/pet show
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
If native images look stale or stuck:
|
|
151
|
+
|
|
152
|
+
```text
|
|
153
|
+
/pet clear-native
|
|
154
|
+
/pet show
|
|
155
|
+
```
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Wumpus Sprite Prompts
|
|
2
|
+
|
|
3
|
+
Use these prompts with an image generator plus 2-5 reference sprites for scale and pixel-art treatment.
|
|
4
|
+
|
|
5
|
+
## Single idle sprite
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
Create a transparent PNG pixel-art sprite of Wumpus, Discord's friendly mascot creature, for a terminal companion.
|
|
9
|
+
|
|
10
|
+
Canvas and style:
|
|
11
|
+
- 64x64 or 128x128 square canvas
|
|
12
|
+
- transparent background
|
|
13
|
+
- cute front-facing or slight 3/4 idle pose
|
|
14
|
+
- rounded body, small horns, friendly face
|
|
15
|
+
- bold dark outline
|
|
16
|
+
- limited Discord-inspired palette
|
|
17
|
+
- readable when scaled down to tiny terminal size
|
|
18
|
+
- no text, no logo, no background, no shadow
|
|
19
|
+
- leave 4-8px transparent padding around the character
|
|
20
|
+
|
|
21
|
+
Use attached reference sprites only for scale, outline thickness, pose simplicity, and pixel-art treatment. Do not copy their character design.
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Full pi-sprite state set
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
Create five matching transparent PNG pixel-art sprites of the same Wumpus character for a terminal companion.
|
|
28
|
+
|
|
29
|
+
Required files/states:
|
|
30
|
+
1. idle — relaxed neutral smile
|
|
31
|
+
2. thinking — curious head tilt or small question-mark mood, no text
|
|
32
|
+
3. working — focused typing or tiny laptop pose
|
|
33
|
+
4. success — happy celebratory sparkle, no text
|
|
34
|
+
5. error — confused or worried, no text
|
|
35
|
+
|
|
36
|
+
Consistency requirements:
|
|
37
|
+
- same canvas size for every image, preferably 64x64 or 128x128
|
|
38
|
+
- same character proportions
|
|
39
|
+
- same outline thickness
|
|
40
|
+
- same color palette
|
|
41
|
+
- transparent background
|
|
42
|
+
- centered with consistent feet/baseline
|
|
43
|
+
- simple readable silhouette
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## If the model struggles with pixel-perfect output
|
|
47
|
+
|
|
48
|
+
Ask for a larger clean pixel-art source, then downscale with nearest-neighbor:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
Create this as crisp pixel art on a 512x512 transparent canvas, with large blocky pixels and no anti-aliased painterly texture. Keep the character centered and simple so it can be downscaled to 64x64.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then crop/downscale to 64x64 or 128x128 with nearest-neighbor in an image editor.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Assemble normalized pi-sprite frames into a horizontal animation strip."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_args() -> argparse.Namespace:
|
|
14
|
+
parser = argparse.ArgumentParser(description="Assemble pi-sprite frame PNGs into a horizontal animation strip.")
|
|
15
|
+
parser.add_argument("--frame", type=Path, action="append", required=True, help="Input frame image path; repeat in animation order")
|
|
16
|
+
parser.add_argument("--output", type=Path, required=True, help="Output strip PNG path")
|
|
17
|
+
parser.add_argument("--metadata", type=Path, help="Optional metadata JSON path")
|
|
18
|
+
parser.add_argument("--frame-width", type=int, default=128, help="Frame width in pixels")
|
|
19
|
+
parser.add_argument("--frame-height", type=int, default=128, help="Frame height in pixels")
|
|
20
|
+
parser.add_argument("--fit", choices=["contain", "cover"], default="contain", help="Resize mode for each input frame")
|
|
21
|
+
parser.add_argument("--dry-run", action="store_true", help="Write metadata only; do not write the strip")
|
|
22
|
+
return parser.parse_args()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def process(args: argparse.Namespace) -> dict[str, Any]:
|
|
26
|
+
if args.frame_width <= 0 or args.frame_height <= 0:
|
|
27
|
+
raise ValueError("--frame-width and --frame-height must be positive")
|
|
28
|
+
if not args.frame:
|
|
29
|
+
raise ValueError("at least one --frame is required")
|
|
30
|
+
for frame in args.frame:
|
|
31
|
+
if not frame.exists():
|
|
32
|
+
raise ValueError(f"frame image missing: {frame}")
|
|
33
|
+
if not frame.is_file():
|
|
34
|
+
raise ValueError(f"frame image is not a file: {frame}")
|
|
35
|
+
|
|
36
|
+
from PIL import Image
|
|
37
|
+
|
|
38
|
+
normalized: list[Image.Image] = []
|
|
39
|
+
input_sizes: list[list[int]] = []
|
|
40
|
+
for frame in args.frame:
|
|
41
|
+
image = Image.open(frame).convert("RGBA")
|
|
42
|
+
input_sizes.append([image.width, image.height])
|
|
43
|
+
if args.fit == "contain":
|
|
44
|
+
image.thumbnail((args.frame_width, args.frame_height), Image.Resampling.LANCZOS)
|
|
45
|
+
canvas = Image.new("RGBA", (args.frame_width, args.frame_height), (0, 0, 0, 0))
|
|
46
|
+
canvas.alpha_composite(image, ((args.frame_width - image.width) // 2, (args.frame_height - image.height) // 2))
|
|
47
|
+
else:
|
|
48
|
+
scale = max(args.frame_width / image.width, args.frame_height / image.height)
|
|
49
|
+
resized = image.resize((round(image.width * scale), round(image.height * scale)), Image.Resampling.LANCZOS)
|
|
50
|
+
left = (resized.width - args.frame_width) // 2
|
|
51
|
+
top = (resized.height - args.frame_height) // 2
|
|
52
|
+
canvas = resized.crop((left, top, left + args.frame_width, top + args.frame_height))
|
|
53
|
+
normalized.append(canvas)
|
|
54
|
+
|
|
55
|
+
output_size = [args.frame_width * len(normalized), args.frame_height]
|
|
56
|
+
if not args.dry_run:
|
|
57
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
strip = Image.new("RGBA", tuple(output_size), (0, 0, 0, 0))
|
|
59
|
+
for index, frame in enumerate(normalized):
|
|
60
|
+
strip.alpha_composite(frame, (index * args.frame_width, 0))
|
|
61
|
+
strip.save(args.output, format="PNG")
|
|
62
|
+
|
|
63
|
+
metadata = {
|
|
64
|
+
"dry_run": args.dry_run,
|
|
65
|
+
"output": str(args.output),
|
|
66
|
+
"frames": [str(frame) for frame in args.frame],
|
|
67
|
+
"frame_count": len(args.frame),
|
|
68
|
+
"frame_width": args.frame_width,
|
|
69
|
+
"frame_height": args.frame_height,
|
|
70
|
+
"fit": args.fit,
|
|
71
|
+
"input_sizes": input_sizes,
|
|
72
|
+
"output_size": output_size,
|
|
73
|
+
"manifest_frame": {"width": args.frame_width, "height": args.frame_height},
|
|
74
|
+
}
|
|
75
|
+
if args.metadata:
|
|
76
|
+
args.metadata.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
args.metadata.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
78
|
+
return metadata
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main() -> int:
|
|
82
|
+
try:
|
|
83
|
+
args = parse_args()
|
|
84
|
+
metadata = process(args)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
87
|
+
return 2
|
|
88
|
+
|
|
89
|
+
print(f"output: {metadata['output']}")
|
|
90
|
+
print(f"dry_run: {metadata['dry_run']}")
|
|
91
|
+
print(f"frame_count: {metadata['frame_count']}")
|
|
92
|
+
print(f"frame_size: {metadata['frame_width']}x{metadata['frame_height']}")
|
|
93
|
+
print(f"output_size: {metadata['output_size']}")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
function arg(name, fallback = "") {
|
|
6
|
+
const index = process.argv.indexOf(`--${name}`);
|
|
7
|
+
return index >= 0 ? (process.argv[index + 1] ?? fallback) : fallback;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeId(value) {
|
|
11
|
+
return value
|
|
12
|
+
.trim()
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[^a-z0-9]+/gu, "-")
|
|
15
|
+
.replace(/^-+|-+$/gu, "")
|
|
16
|
+
.slice(0, 80);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const id = normalizeId(arg("id", "wumpus"));
|
|
20
|
+
const name = arg("name", "Wumpus").trim() || id;
|
|
21
|
+
const author = arg("author", "").trim();
|
|
22
|
+
const personality = arg("personality", "").trim();
|
|
23
|
+
const out = arg("out", `./${id}-sprite`);
|
|
24
|
+
|
|
25
|
+
if (!id) {
|
|
26
|
+
console.error("Missing valid --id");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
mkdirSync(out, { recursive: true });
|
|
31
|
+
const manifest = {
|
|
32
|
+
id,
|
|
33
|
+
name,
|
|
34
|
+
...(author ? { author } : {}),
|
|
35
|
+
description: `A tiny ${name} companion for pi-sprite.`,
|
|
36
|
+
...(personality ? { personality } : {}),
|
|
37
|
+
sprites: {
|
|
38
|
+
idle: "idle.png",
|
|
39
|
+
thinking: "thinking.png",
|
|
40
|
+
working: "working.png",
|
|
41
|
+
success: "success.png",
|
|
42
|
+
error: "error.png",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
writeFileSync(join(out, "pet.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
46
|
+
writeFileSync(
|
|
47
|
+
join(out, "README.md"),
|
|
48
|
+
`# ${name} pi-sprite pet\n\nAdd these transparent PNG/WebP files next to \`pet.json\`:\n\n- idle.png\n- thinking.png\n- working.png\n- success.png\n- error.png\n\nImport with:\n\n\`\`\`text\n/pet import ${out}\n/pet choose ${id}\n/pet show\n\`\`\`\n`,
|
|
49
|
+
);
|
|
50
|
+
console.log(`Created ${out}`);
|
|
51
|
+
console.log(`Next: add sprite images, then run /pet import ${out}`);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a simple motion-loop strip from one pi-sprite state image."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
PRESETS: dict[str, list[tuple[int, int]]] = {
|
|
13
|
+
"bob": [(0, 1), (0, -2), (0, -1), (0, 1)],
|
|
14
|
+
"thinking-bob": [(0, 1), (-1, -2), (0, -1), (1, 1)],
|
|
15
|
+
"working-tap": [(0, 1), (0, -1), (1, 0), (0, -1), (-1, 0), (0, 1)],
|
|
16
|
+
"success-bounce": [(0, 1), (0, -3), (0, -1), (0, -3), (0, 1)],
|
|
17
|
+
"error-droop": [(0, 0), (0, 2), (-1, 1), (0, 2)],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_offset(value: str) -> tuple[int, int]:
|
|
22
|
+
try:
|
|
23
|
+
x_text, y_text = value.split(",", 1)
|
|
24
|
+
return int(x_text), int(y_text)
|
|
25
|
+
except ValueError as exc:
|
|
26
|
+
raise argparse.ArgumentTypeError("offsets must be X,Y pixel pairs, e.g. 0,-2") from exc
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_args() -> argparse.Namespace:
|
|
30
|
+
parser = argparse.ArgumentParser(description="Create a simple pi-sprite motion strip from a single frame image.")
|
|
31
|
+
parser.add_argument("--input", type=Path, required=True, help="Input state image")
|
|
32
|
+
parser.add_argument("--output", type=Path, required=True, help="Output strip PNG path")
|
|
33
|
+
parser.add_argument("--metadata", type=Path, help="Optional metadata JSON path")
|
|
34
|
+
parser.add_argument("--frame-width", type=int, default=128, help="Frame width in pixels")
|
|
35
|
+
parser.add_argument("--frame-height", type=int, default=128, help="Frame height in pixels")
|
|
36
|
+
parser.add_argument("--preset", choices=sorted(PRESETS), default="bob", help="Built-in motion preset")
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--offset",
|
|
39
|
+
type=parse_offset,
|
|
40
|
+
action="append",
|
|
41
|
+
default=[],
|
|
42
|
+
help="Custom X,Y pixel offset; repeat to override --preset",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument("--dry-run", action="store_true", help="Write metadata only; do not write the strip")
|
|
45
|
+
return parser.parse_args()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def process(args: argparse.Namespace) -> dict[str, Any]:
|
|
49
|
+
if args.frame_width <= 0 or args.frame_height <= 0:
|
|
50
|
+
raise ValueError("--frame-width and --frame-height must be positive")
|
|
51
|
+
if not args.input.exists():
|
|
52
|
+
raise ValueError(f"input image missing: {args.input}")
|
|
53
|
+
if not args.input.is_file():
|
|
54
|
+
raise ValueError(f"input image is not a file: {args.input}")
|
|
55
|
+
|
|
56
|
+
from PIL import Image
|
|
57
|
+
|
|
58
|
+
offsets = args.offset or PRESETS[args.preset]
|
|
59
|
+
source = Image.open(args.input).convert("RGBA")
|
|
60
|
+
source.thumbnail((args.frame_width, args.frame_height), Image.Resampling.LANCZOS)
|
|
61
|
+
centered = Image.new("RGBA", (args.frame_width, args.frame_height), (0, 0, 0, 0))
|
|
62
|
+
centered.alpha_composite(source, ((args.frame_width - source.width) // 2, (args.frame_height - source.height) // 2))
|
|
63
|
+
|
|
64
|
+
output_size = [args.frame_width * len(offsets), args.frame_height]
|
|
65
|
+
if not args.dry_run:
|
|
66
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
strip = Image.new("RGBA", tuple(output_size), (0, 0, 0, 0))
|
|
68
|
+
for index, (dx, dy) in enumerate(offsets):
|
|
69
|
+
frame = Image.new("RGBA", (args.frame_width, args.frame_height), (0, 0, 0, 0))
|
|
70
|
+
frame.alpha_composite(centered, (dx, dy))
|
|
71
|
+
strip.alpha_composite(frame, (index * args.frame_width, 0))
|
|
72
|
+
strip.save(args.output, format="PNG")
|
|
73
|
+
|
|
74
|
+
metadata = {
|
|
75
|
+
"dry_run": args.dry_run,
|
|
76
|
+
"input": str(args.input),
|
|
77
|
+
"output": str(args.output),
|
|
78
|
+
"preset": args.preset,
|
|
79
|
+
"offsets": [[x, y] for x, y in offsets],
|
|
80
|
+
"frame_count": len(offsets),
|
|
81
|
+
"frame_width": args.frame_width,
|
|
82
|
+
"frame_height": args.frame_height,
|
|
83
|
+
"output_size": output_size,
|
|
84
|
+
"manifest_frame": {"width": args.frame_width, "height": args.frame_height},
|
|
85
|
+
}
|
|
86
|
+
if args.metadata:
|
|
87
|
+
args.metadata.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
args.metadata.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
89
|
+
return metadata
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main() -> int:
|
|
93
|
+
try:
|
|
94
|
+
args = parse_args()
|
|
95
|
+
metadata = process(args)
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
98
|
+
return 2
|
|
99
|
+
|
|
100
|
+
print(f"input: {metadata['input']}")
|
|
101
|
+
print(f"output: {metadata['output']}")
|
|
102
|
+
print(f"dry_run: {metadata['dry_run']}")
|
|
103
|
+
print(f"preset: {metadata['preset']}")
|
|
104
|
+
print(f"frame_count: {metadata['frame_count']}")
|
|
105
|
+
print(f"output_size: {metadata['output_size']}")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { extname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
function arg(name, fallback = "") {
|
|
6
|
+
const index = process.argv.indexOf(`--${name}`);
|
|
7
|
+
return index >= 0 ? (process.argv[index + 1] ?? fallback) : fallback;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const manifestUrl = arg(
|
|
11
|
+
"manifest",
|
|
12
|
+
process.env.PI_SPRITE_PETDEX_MANIFEST_URL || "https://petdex.crafter.run/api/manifest",
|
|
13
|
+
);
|
|
14
|
+
const out = arg("out", "examples/petdex-downloads");
|
|
15
|
+
const query = arg("query", "").toLowerCase();
|
|
16
|
+
const limit = Math.max(1, Math.min(50, Number.parseInt(arg("limit", "12"), 10) || 12));
|
|
17
|
+
function safeSlug(value) {
|
|
18
|
+
const slug = String(value ?? "")
|
|
19
|
+
.trim()
|
|
20
|
+
.toLowerCase();
|
|
21
|
+
return /^[a-z0-9][a-z0-9-]{0,79}$/u.test(slug) ? slug : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const slugs = arg("slugs", "")
|
|
25
|
+
.split(",")
|
|
26
|
+
.map((slug) => safeSlug(slug))
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
|
|
29
|
+
async function download(url) {
|
|
30
|
+
const res = await fetch(url, { headers: { accept: "*/*", "user-agent": "pi-sprite-authoring" } });
|
|
31
|
+
if (!res.ok) throw new Error(`download failed for ${url} (${res.status})`);
|
|
32
|
+
return Buffer.from(await res.arrayBuffer());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function spriteNameFromUrl(url) {
|
|
36
|
+
const ext = extname(new URL(url).pathname).toLowerCase();
|
|
37
|
+
return ext === ".png" ? "spritesheet.png" : "spritesheet.webp";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const manifestRes = await fetch(manifestUrl, {
|
|
41
|
+
headers: { accept: "application/json", "user-agent": "pi-sprite-authoring" },
|
|
42
|
+
});
|
|
43
|
+
if (!manifestRes.ok) throw new Error(`manifest fetch failed (${manifestRes.status})`);
|
|
44
|
+
const manifest = await manifestRes.json();
|
|
45
|
+
const pets = (Array.isArray(manifest.pets) ? manifest.pets : [])
|
|
46
|
+
.map((pet) => ({ ...pet, safeSlug: safeSlug(pet.slug) }))
|
|
47
|
+
.filter((pet) => pet.safeSlug && pet.displayName && pet.petJsonUrl && pet.spritesheetUrl)
|
|
48
|
+
.filter((pet) => !slugs.length || slugs.includes(pet.safeSlug))
|
|
49
|
+
.filter((pet) => !query || `${pet.safeSlug} ${pet.displayName} ${pet.kind ?? ""}`.toLowerCase().includes(query))
|
|
50
|
+
.slice(0, limit);
|
|
51
|
+
|
|
52
|
+
mkdirSync(out, { recursive: true });
|
|
53
|
+
const provenance = [
|
|
54
|
+
"# Petdex example downloads",
|
|
55
|
+
"",
|
|
56
|
+
`Downloaded from: ${manifestUrl}`,
|
|
57
|
+
`Downloaded at: ${new Date().toISOString()}`,
|
|
58
|
+
"",
|
|
59
|
+
"These files are local reference material. Do not commit or redistribute third-party sprite assets unless their licenses are verified.",
|
|
60
|
+
"",
|
|
61
|
+
"## Assets",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
for (const pet of pets) {
|
|
65
|
+
const dir = join(out, pet.safeSlug);
|
|
66
|
+
mkdirSync(dir, { recursive: true });
|
|
67
|
+
const [petJsonBytes, spriteBytes] = await Promise.all([download(pet.petJsonUrl), download(pet.spritesheetUrl)]);
|
|
68
|
+
const spriteName = spriteNameFromUrl(pet.spritesheetUrl);
|
|
69
|
+
writeFileSync(join(dir, "pet.json"), petJsonBytes);
|
|
70
|
+
writeFileSync(join(dir, spriteName), spriteBytes);
|
|
71
|
+
writeFileSync(
|
|
72
|
+
join(dir, "PROVENANCE.md"),
|
|
73
|
+
`# ${pet.displayName}\n\n- slug: ${pet.safeSlug}\n- kind: ${pet.kind ?? "unknown"}\n- submittedBy: ${pet.submittedBy ?? "unknown"}\n- petJsonUrl: ${pet.petJsonUrl}\n- spritesheetUrl: ${pet.spritesheetUrl}\n\nLicense was not verified by this script. Use as temporary reference only unless separately cleared.\n`,
|
|
74
|
+
);
|
|
75
|
+
provenance.push(`- ${pet.safeSlug} — ${pet.displayName} (${pet.kind ?? "unknown"})`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
writeFileSync(join(out, "THIRD_PARTY_ASSETS.md"), `${provenance.join("\n")}\n`);
|
|
79
|
+
console.log(`Downloaded ${pets.length} Petdex example(s) into ${out}`);
|