scenario-side-view-game-kit
Use when making the art for a side-scroller or 2D platformer: side-view pixel-art sprite sheets for a hero and ground enemies (run, walk, idle, attack, hurt cycles, air spin, jump and fall poses) and per-level layers (parallax background and midground, seamless wall texture, plat
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/scenario-side-view-game-kit
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
git clone https://github.com/scenario-labs/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.
README
scenario-side-view-game-kit: maintainer notes
Not read at runtime. How the skill was built and why it makes its choices.
Origin
Distilled on 2026-09-30 from a browser side-scroller built over two days in one agent session: one hero, two ground enemies, five levels of one setting. Every generation ran through the Scenario MCP server (recommend, model_schema_get, model_run with dry_run and wait: false, jobs_wait, upload_asset and upload_asset_complete, asset_download); keying, loop search, pixelation, seamless tiles, prop cutting and WebP conversion ran as local Python. The sources were the build journal, the verbatim prompt record and the build's own processing scripts.
The hero route is an earlier isometric sprite-cycle pipeline (two-pose still on flat magenta, reframe, image-to-video clips, loop-window search, one shared palette) adapted to a single side-view facing. The build used a text-to-image model for the stills, the pose sheet and the level layers; an image-to-video model with a last-frame input for the clips; and a pixel-art model for a superseded first pass. The skill names no model and discovers both lanes with recommend (txt2img, and img2video with features: ["endImage"] for pinned clips), because availability differs per team and these generations will be superseded. The record has no credit costs, which is why the body prices with dry_run instead of quoting a figure.
Why audio was dropped
The build also scored every level and gave each action a sound effect. One skill, one output type: the deliverables here are images (sprite strips and level layers; the clips are intermediates), so music and effects stay with scenario-audio and scenario-elevenlabs. The body says in one sentence that they are outside this skill without naming those siblings: a named sibling joins the context fan-out and the install commands, and the image workflow never calls them. The prompt templates and processing settings carry no audio sections for the same reason.
Why the finish is local
Keying, the loop search, the shared-canvas palette and the seamless cross-fade all need the pixels on disk, and the deliverables are game files, not platform assets. scenario-sprite-animation lists "assembling locally" as a mistake for sheets the platform can tile itself; this skill's local steps are the ones no MCP tool performs, so the body says why once and offers upload_asset into a collection for users who want the finished strips on the platform.
Choices the build tested
- Static poses (fall, wall cling, plunge, dash) come from one pose sheet split by connected components. Splitting by column runs failed: the poses overlap horizontally.
- The first attack's third frame keyed a white slash trail into a solid block, and the heavy enemy's impact frame keyed its dust cloud into a blob; both were dropped after processing.
--dropreproduces that, applied after the canvas and palette, which is what matches the shipped strips. - The heavy enemy's walk loop closes at 1.03x baseline. Widening the search range did not help; it was kept after review, which is why the body's threshold (0.6x) is a target, not a rejection rule.
- The first armed-enemy walk swung its weapon mid-stride and could not loop (1.44x). Regenerated with a still-arms sentence and a lower guidance value, it closed at 0.42x. That pair became the "loop that will not close" mistake.
- The first-pass environments were judged too retro after a playtest. The "richly detailed high-resolution pixel art" replacement was validated on level 1 before levels 2 to 5 were generated, which is the order the body teaches.
- Texture renders asked to be "seamless tileable" did not reliably tile, hence the cross-fade in
process_env.pyrather than trusting the prompt.
Scripts
All four run in plain Python 3 with Pillow and NumPy; side_sprites.py also needs ffmpeg.
reframe_stills.py: from the isometric pipeline, pixels unchanged. The output names lost their isometric facings: it now writes<name>_rest.png(left pose, the first frame of every ground clip) and<name>_action.png(right pose: hero jump or enemy attack wind-up). The build ran its own copy with an auto split column and a death cycle; the shared copy reproduced all six of the build's first frames pixel for pixel, so those extras were not ported.sprite_cycles.py: the isometric pipeline's processing core, now a helper module imported byside_sprites.pyonly. Its four-facing command line and anything only that command line used were removed. Two changes after the port's review:picktakes the cycle's mode and windows by it (it used to branch on the nameattack, so a loop namedattackgot one-shot windowing), andread_framesstops on a clip more than 2% off square instead of squashing it, since the figure scale and feet anchor assume a square frame. Square clips process as before.side_sprites.py: the build's one-facing wrapper, generalized. The hard-coded hero cycle table, pose names and file names became--cycles,--pose-namesand<name>_pose_*; loop cycles other than walk, run and idle need a--range;--dropwas added for the frames removed by hand; missing clips are an error instead of a silent skip; overwrites need--force;--webpwrites lossless WebP;sprite_cycles.pyis imported from the script's own folder.process_env.py: the build's script, generalized. Hard-coded input and output folders and the fixed five-level loop became--env,--out,--levelsand--file KIND=PATH; texture size, fade share, ledge height, prop count and height, alpha threshold and the three WebP qualities became flags;--dry-runand--forcewere added. The processing is unchanged.
Comments, docstrings and help text were moved to American spelling and house style in the port; no processing changed.
Script verification
Run on 2026-09-30 (Windows, Python 3.14.2, NumPy 2.5.0, Pillow 12.2.0, ffmpeg 8.1.1) against the build's intermediate files, writing only to a temporary folder, with the scripts as they stood before the port's renames:
--helpprinted usage for every script.reframe_stills.pyon the hero still and both enemy stills: all six first frames pixel-identical to the build's.side_sprites.pyfor the hero (eight cycles, the pose sheet, the jump still,--drop atk1=2), the heavy enemy (--drop attack=5) and the armed enemy (--range walk=12:48): all 19 strips and poses pixel-identical to the build's. Cells, anchors and fps matched the build's meta files: hero 98x81 anchored at 49,75; heavy enemy 137x123 at 68,111; armed enemy 116x93 at 58,92. Seams printed: hero run 0.32, idle 0.282, heavy-enemy walk 1.032 (flagged), armed-enemy walk 0.421, the first armed-enemy walk 1.445 (flagged). Without--dropthe hero's first attack has 7 frames and the heavy enemy's attack 8, as recorded.side_sprites.pyrefused to overwrite without--force, stopped on a missing clip, and asked for--rangeon an unknown loop cycle;--webpwrote lossless WebP.process_env.pyover five levels: all 35 outputs byte-identical to the build's WebP layers. A second run refused to overwrite;--file tex=... --file props=...wroteenv_tex_<stem>.webpandenv_prop_<stem>_<k>.webp; with neither--envnor--fileit stopped with an error. On the five textures the mean wrap-edge difference after the cross-fade sat at the neighboring-pixel level.
Apart from the two sprite_cycles.py fixes above, the port changes names and text only. tests/scenario-side-view-game-kit/ runs on synthetic inputs: the build's own files are not in this repository, so the byte-level reproduction above is not re-run in CI.
What the record does not cover
- The worked example (a fox knight, a mole miner, two levels) is a recommended route built from the shipped build; the build made one hero, two ground enemies and five levels of one setting.
- The pose-sheet prompt is recorded only as a description;
references/prompt-templates.mdgives a template in the style of the other prompts and says so. The attack, lunge, shot, hurt and spin motion sentences are abbreviated in the record; the templates keep their recorded opening and closing words. - The enemy stills are recorded only as "follow the hero still template (standing / attack wind-up)", and their attack and hurt prompts are not recorded. Their heights (78 and 66 px) and the drop indices come from the shipped strips.
- How the two frames were removed from the attack strips is not recorded; the drop indices (hero first attack index 2, heavy-enemy attack index 5) were recovered by matching the shipped strips.
- The ledge was requested at 1536x384 and came back 1536x512. The trim makes the output the same either way.
- The pose-sheet scale (0.122) was set by eye; the skill says to tune it.
- The first-pass pixel-art route (some enemies, pickups and icons that still ship) is mentioned in the record but not taught; its prompts are not recorded.
- Game code (combat, wall runs, parallax, the ledge 3-slice draw) is out of scope beyond a few drawing rules; the skill stops at game-ready assets.
Skill manifest
Scenario Side-View Game Kit
Overview
A side-scroller needs two families of image assets no single generation gives: pixel sprites that animate cleanly on one feet line, and painted level layers that stack into parallax and repeat without seams. This route comes from a browser side-scroller made in one agent session (one hero, two ground enemies, five levels):
- Characters. A text-to-image still with two poses on flat magenta (hero: rest and jump; enemy: rest and attack wind-up), reframed with headroom, then one 3 second image-to-video clip per cycle, the character running or walking "like a treadmill" in a pure side profile facing right. A local script keys the magenta, cuts a loop or one-shot window, and pixelates every cycle of a character onto one shared canvas and palette. Fall, wall cling, plunge and dash come from one pose sheet. Make one facing; the game mirrors it.
- Levels. Per level, five renders in "richly detailed high-resolution pixel art" (background, midground, wall texture, platform strip, props), made into WebP layers locally.
The finish is local on purpose: keying, the loop search, the shared palette and the seamless cross-fade need pixels on disk, and the deliverables are game files. If the user wants the finished strips on the platform too, upload_asset them into a collection.
Connection, scope and the core loop: the scenario skill. Frame tools, GIF previews and engine slicing: scenario-sprite-animation. Pixel cleanup and other statics: scenario-game-assets. Clip model families: scenario-video. Music and sound effects are outside this image skill. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.
The agent runs the scripts locally (Python 3, Pillow, NumPy; side_sprites.py also needs ffmpeg): reframe_stills.py makes the clip first frames, side_sprites.py builds the strips and imports sprite_cycles.py (never run directly), and process_env.py finishes the level layers. Prompts with slots: references/prompt-templates.md. Read references/processing-settings.md before changing a script flag or when --report flags a loop.
Quick reference
| Step | How | Keep when |
|---|---|---|
| Models | recommend with capability: "txt2img" (stills, pose sheet, layers) or "img2video" plus features: ["endImage"] (clips), the user's words as prompt; then model_schema_get |
You know each schema's field names, enums and caps |
| Reframe and upload | reframe_stills.py: figure 58% of a 960 square, feet at 82%; upload_asset + upload_asset_complete the rest frame, and the action frame for the hero's air spin |
Headroom for a raised weapon or a somersault |
| Pose sheet | Text to image, chosen still as reference, 4 poses in a row on magenta | Same design and palette as the strips |
| Price and wait | dry_run: true per distinct payload, then wait: false in waves under the concurrency ceiling and jobs_wait, re-called with pending_job_ids until done |
The user heard the total before spending |
| Strips | side_sprites.py --report, then without --report |
Loop seams (idle aside) under 0.6x baseline; no solid blocks |
| Level layers | Five renders per level, level 1 first | Dark lower middle, empty midground center, props apart |
| Level finish | process_env.py --env env --out assets --levels N |
Texture tiles, ledge trims flat, prop count matches |
| Deliver | Strips and pose images as lossless WebP plus <name>_meta.json (cell, feet anchor, fps); layers as lossy WebP |
One anchor per character; nothing magenta at the edges |
Payload names below (startImage, endImage, referenceImages, background, quality, numOutputs, cfgScale) are the build's schemas' examples: use the fields your pick's model_schema_get lists. The image pick needs strong layout adherence, a reference-image input, a transparent-background option and the template's 1536x1024; if its schema lacks one (a pixel-art specialty pick can cap far below that size), take the next ranked pick, even when recommend asks you to choose between such capped picks.
Worked example
A user asks: "Make the art for my side-scroller: a fox knight hero with a rapier, a mole miner enemy with a pickaxe, and two levels, a mushroom forest and a crystal mine." Pass team_id and project_id on every call (the scenario skill covers picking them).
Hero still.
recommend { "capability": "txt2img", "prompt": "<the user's request>; needs reference image input, transparent output" },model_schema_getthe pick, price the two-pose template (left: standing ready facing right; right: mid-jump facing right) withmodel_run { "model_id": "<pick>", "dry_run": true, "parameters": { "prompt": "...", "width": 1536, "height": 1024, "quality": "high", "background": "opaque", "numOutputs": 2 } }. Tell the user the price, run (jobs_waitwith itsjob_idif it returns in progress),asset_displayboth takes; keep one with both poses on model, in a pure side profile (every clip inherits a three-quarter body), and nothing pink or purple,asset_downloadit andcurl -Lit tofox_two_pose.png.First frames.
python3 scripts/reframe_stills.py fox_two_pose.png --name fox --out frameswritesframes/fox_rest.png(left pose, the first frame of every ground clip) andframes/fox_action.png(the jump). Check both, thenupload_asset { "file_name": "fox_rest.png", "content_type": "image/png", "kind": "image", "file_size": <bytes> }, PUT the parts,upload_asset_complete { "upload_id": "<id>" }; same for the action frame, which only the air spin uses.Hero clips.
recommend { "capability": "img2video", "features": ["endImage"], "prompt": "<the user's request>; square clip, no audio" }, thenmodel_schema_get. One payload per cycle from the motion templates. With the build's schema:run: { "prompt": "<run sentence> <facing and tail>", "negativePrompt": "<negative>, slowing down, stopping, standing still", "startImage": "<fox_rest id>", "duration": "3", "aspectRatio": "1:1", "generateAudio": false } atk1: { "prompt": "<attack: a fast rapier thrust ... returns to the starting ready pose> <facing and tail>", "negativePrompt": "<negative>", "startImage": "<fox_rest id>", "endImage": "<fox_rest id>", "duration": "3", "aspectRatio": "1:1", "generateAudio": false }Idle and hurt pin like the attack; the air spin uses the action id as both frames. Price each distinct payload with
dry_run: true(first frame only, pinned, changed guidance), give the user the total, launch the clips with"wait": falsein waves within the team's concurrency ceiling (theparallel-custom-jobsrow in thescenarioskill: a launch refused with that 429 created no job, so relaunch it in the next wave instead of dropping it), and collect them with onejobs_wait { "job_ids": [...] }, re-called with the returnedpending_job_idsasjob_idswhile any is in progress (never polljob_get).asset_downloadeach (omitformaton video) and save asclips/fox_<cycle>.mp4,<cycle>being exactly the step 5--cyclesname (run,idle,atk1,spin,hurt). Review each as a local contact sheet (ffmpeg -i clips/fox_run.mp4 -vf "fps=6,scale=240:-1,tile=6x3" fox_run_sheet.png), sinceasset_displayshows no frames of a video; re-run only one that turns toward or away from the camera or travels.Pose sheet. Text to image with
referenceImages: ["<chosen still id>"]and the pose-sheet template (fall, wall cling, plunge, dash). Price, tell the user, run, download asfox_poses.png.Strips.
python3 scripts/side_sprites.py --clips clips --name fox --cycles run:loop:8,idle:loop:8,atk1:attack:7,spin:attack:8,hurt:attack:5 --report python3 scripts/side_sprites.py --clips clips --name fox --height 56 --palette 28 \ --cycles run:loop:8,idle:loop:8,atk1:attack:7,spin:attack:8,hurt:attack:5 \ --poses fox_poses.png --jump frames/fox_action.png --webp --out spritesView the strips at 4x, nearest-neighbor. If the slash trail keyed into a white block, re-run with
--drop atk1=<index>(0-based) and--force.Enemy. Same route: two-pose still (rest, and an attack wind-up that keeps the design consistent), reframe, upload only
mole_rest.png; walk with the first frame only, the armed-enemy walk wording and a lower guidance such ascfgScale: 0.7if the schema has one; attack and hurt pinned.side_sprites.py --clips clips --name mole --height 72 --cycles walk:loop:8,attack:attack:8,hurt:attack:5 --report; a walk flagged as a weak loop gets--range walk=12:48before any re-run.Level layers. Fill the five environment templates for the mushroom forest (opaque background and texture, the rest transparent).
dry_runeach distinct setting, tell the user the total, run, download asenv/bg1.png,env/mid1.png,env/tex1.png,env/ledge1.png,env/props1.png, thenpython3 scripts/process_env.py --env env --out assets --levels 1. Put level 1 in the game with the fox on it before generating the crystal mine; process that one alone with--levels 2,(a bare2is a count and redoes level 1),--forceafter a re-render.Check. Play each strip at its fps from
<name>_meta.json, tile the texture across a wall, and draw a long and a short platform.
Common mistakes
- An end frame on run or walk. Pinning the still at both ends of short locomotion spends the clip speeding up and slowing down. First frame only for run and walk; pin first = last for idle and every one-shot so they return to rest.
- An armed enemy's walk using its weapon. The build's first armed walk swung its weapon mid-stride and closed at 1.44x baseline. "His arms stay still ... never lifts, swings or twirls it", the weapon terms in the negative and a guidance of 0.7 closed it at 0.42x.
- Keeping a frame where an effect keyed into a block. A white slash trail and an impact dust cloud came out of the key as solid blobs;
--dropremoves the sampled frame without changing the shared canvas or palette. - Paying to regenerate every weak loop. A heavy walk closed at 1.03x, a wider range found the same window, and it shipped after review. Widen the range, look at the loop, and pay only when it visibly hitches.
- Splitting a pose sheet by columns. Poses overlap horizontally; split by connected components (
--posesdoes) and ask for wide gaps. - Trusting "seamless tileable" in a texture prompt. The renders do not tile reliably;
process_env.pycross-fades opposite edges instead of a re-render. - Stretching a platform strip. Draw left cap + repeated middle + right cap so every length keeps the carved ends.
- Retro-scale level art. Small retro paintings failed the first playtest; 1920x1152 detailed layers drawn at 2x replaced them while sprites kept nearest-neighbor scaling. Validate level 1 in the game before the rest.
- Pink or purple on a character. Everything strongly magenta is keyed away; say "no pink, no purple, no magenta anywhere on the character" and reject takes that show any. A fast weapon swing can pick up a purple motion-blur tint and key away mid-strike, which
--dropcannot fix since it would remove the strike itself: reject that take, or re-run with "no motion blur, the weapon keeps its colors" in the prompt and "purple or magenta tint on the weapon" in the negative (in one validation run this cleared the tint, and one impact frame still needed--drop). - Rewording a blocked combat clip. A thrust or pickaxe swing can come back
moderation_blocked, and the filter is the provider's: read the error, then run the unchanged payload on a pick from another provider (samerecommend,endImagefeature kept) perscenario-moderationbefore softening the beat. - Lossy sprite strips. Lossy WebP shifts the shared palette; only the HD layers ship lossy.
Files (skills)
-
references
-
processing-settings.md 9.6 KB
# Processing settings What the local scripts do, the settings of the build this skill comes from, and the numbers it recorded. Everything here runs in a local shell after the renders and clips are downloaded; no model is involved. Each runnable script prints its usage with `--help`. ## Reframe (`scripts/reframe_stills.py`) - Input: the two-pose still, left pose on the left half, right pose on the right half, flat magenta field. - Each pose is cut out, scaled so the figure is **58%** of a **960 px** square, and pasted centered with the feet on a line at **82%** from the top. The headroom leaves space for a raised weapon, a somersault or a slash trail. - Outputs: the left pose as `<name>_rest.png` (the first frame of every ground clip) and the right pose as `<name>_action.png` (the hero's jump or the enemy's attack wind-up, and both frames of the air spin). - It warns when a figure touches the center line or the image edge; regenerate the still with more space around each figure rather than reframing it, since a figure cut at the center line spoils both first frames. - If you change `--size` or `--figure` here, pass the same values as `--canvas` and `--figure` to `side_sprites.py` (and `--baseline` if you moved the feet line), since the sprite scale and the anchor are computed from them. ## Sprite strips (`scripts/side_sprites.py`) One facing only, one row per cycle. Clips are read as `<clips>/<name>_<cycle>.mp4`; `--cycles` lists them as `NAME:MODE:FRAMES`. Clips must be square, like their first frame: the script stops on one more than 2% off square. Resolution need not match: a clip returned at 1440 px from a 960 first frame is rescaled to `--canvas` before anything else. It imports `sprite_cycles.py` from the same folder for the key, loop search, active window, downscale, palette and outline. Run `--report` first: it prints windows and seam scores and writes nothing. ### Key Magenta to alpha: a pixel is background when red and blue both beat green by a margin and the color is saturated, so the purple ground shadow and pink haze the video model adds go too; a despill pass pulls the tint out of the kept edge. Anything pink or purple on the character goes with it. ### Windows - `loop` cycles (run, walk, idle): every frame is shrunk to 120x120 and compared; the window `[s, s+P)` with the smallest seam (frame `s` against frame `s+P`) relative to the mean frame-to-frame motion inside it wins. Starts before frame 3 are skipped (`--skip`). Default period ranges in clip frames: walk 12 to 34, run 8 to 24, idle 24 to 66; any other loop name needs `--range`. Playback fps is `frames * clip_fps / P`, clamped to 5 to 14. - `attack` cycles (every one-shot: attacks, lunge, shot, hurt, air spin): from the first to the last frame that differs from frame 0 by more than max(1.5, 12% of the largest difference), padded by two frames, sampled end to end, played at 8 to 12 fps. Pinning first = last frame makes the strip end back on the rest pose. - A loop other than idle with a seam over **0.6x** its baseline is flagged. Widen the range first (`--range walk=12:48`); if that does not help, look at it in the game before paying for a new clip. ### Shared canvas and finish - The crop is the union of every kept frame of every cycle, symmetric about the vertical center, padded by 8 px, and always extended down to the feet line (82%), so the air spin and the ground cycles share one anchor. The crop pads with transparency where it passes the frame edge. - Scale: target height (`--height`) divided by the figure height in the first frame (58% of 960 = 557 px). The build's heights (table below) are a starting point: 56 px for the hero, 66 px for an ordinary ground enemy, 78 px for a heavy one. - Area average on premultiplied color, contrast and saturation lift 1.12 each, one median-cut palette of **28** colors per character (no dither) built from every frame and every static pose, then a 1 px inner outline (edge pixels darkened to 38%). - `--drop CYCLE=I` leaves a sampled frame (0-based) out of the strip after the canvas and palette are fixed, which is how the build removed frames where a white slash trail or a dust cloud keyed into a solid block. Add `--force` to overwrite earlier outputs. - Static poses: `--poses` splits a pose sheet by connected components (8-neighbor on a quarter-resolution mask, largest N kept, left to right) and scales each by `--pose-scale` (0.122 in the build, tuned by eye against the strips); `--jump <name>_action.png` adds the reframed jump still at the strip scale. Both join the palette. - Outputs: one strip per cycle (`<name>_<cycle>`), `<name>_pose_<pose>` for each `--poses` figure and `<name>_pose_jump` for `--jump` (same extension as the strips), plus `<name>_meta.json` with the cell size, the feet anchor, each cycle's frame count and fps, and the pose sizes. `--webp` writes lossless WebP instead of PNG. ### The build's numbers | Character | Height | Cell (w x h) | Feet anchor | Cycles (frames, fps) | | ----------- | ------ | ------------ | ----------- | ------------------------------------------------------------------------------------------------------------------- | | hero | 56 px | 98x81 | 49, 75 | run 8 at 10.1, idle 8 at 5.0, attack 1 6 at 8, attack 2 7, air spin 8, shot 7, lunge 6, hurt 5 (one-shots at 8 fps) | | heavy enemy | 78 px | 137x123 | 68, 111 | walk 8 at 6.9, attack 7 at 8, hurt 5 at 8 | | armed enemy | 66 px | 116x93 | 58, 92 | walk 8 at 5.0, attack 8 at 8, hurt 5 at 8 | Loop seams: hero run **0.32x** baseline (period 19 frames), hero idle **0.28x** (63 frames), armed-enemy walk **0.42x** (40 frames; `--range walk=12:48` reproduces it, the build's own argument is not recorded). The first armed-enemy walk, which swung its weapon, closed at 1.44x at best. The heavy-enemy walk closes at 1.03x (28 frames); the wider range found the same window, and it was kept after review. Frames dropped: hero attack 1, index 2 (7 to 6, the slash trail); heavy-enemy attack, index 5 (8 to 7, the dust cloud at impact). Static poses at scale 0.122: fall 45x68, wall cling 37x48, plunge 24x81, air dash 66x30; the jump still at strip scale 53x57. Commands that reproduce strips of that shape: ``` python3 scripts/side_sprites.py --clips clips --name hero --height 56 --palette 28 \ --cycles run:loop:8,idle:loop:8,atk1:attack:7,atk2:attack:7,spin:attack:8,shoot:attack:7,lunge:attack:6,hurt:attack:5 \ --poses hero_poses.png --jump frames/hero_action.png --drop atk1=2 --out sprites python3 scripts/side_sprites.py --clips clips --name heavy --height 78 \ --cycles walk:loop:8,attack:attack:8,hurt:attack:5 --drop attack=5 --out sprites python3 scripts/side_sprites.py --clips clips --name armed --height 66 \ --cycles walk:loop:8,attack:attack:8,hurt:attack:5 --range walk=12:48 --out sprites ``` ### In the game - Anchor each sprite at the feet anchor from `<name>_meta.json`; mirror horizontally when the character faces left. - Reuse before generating more: the build ran its wall run as the run cycle rotated 90 degrees against the wall, and drew the dash trail from the frame being shown, tinted. ## Environment layers (`scripts/process_env.py`) Inputs are `<env>/bg<N>.png`, `mid<N>.png`, `tex<N>.png`, `ledge<N>.png` and `props<N>.png`. `--levels` takes a count (`5` means levels 1 to 5) or a list (`2,4`; `2,` for level 2 alone). `--file KIND=PATH` processes one explicit file instead; `--dry-run` lists outputs without writing; `--force` overwrites. | Layer | Render | Processing | Output | | ---------- | --------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------- | | background | 1920x1152, opaque | WebP quality 86 | `env_bg<N>.webp`, 1920x1152 | | midground | 1920x1152, transparent | WebP quality 86, alpha kept | `env_mid<N>.webp`, 1920x1152 | | texture | 1024x1024, opaque | edges cross-faded over 12% per axis, resized to 384x384, quality 90 | `env_tex<N>.webp`, 384x384 | | ledge | 1536x384 requested (1536x512 returned), transparent | trimmed to alpha above 24, scaled to 120 px high, quality 90 | `env_ledge<N>.webp` | | props | 1536x1024, transparent | split by connected components, 3 largest, each scaled to 180 px, quality 90 | `env_prop<N>_<k>.webp` | - **Seamless texture**: for the first `k` pixels of each axis, `out = tail * (1 - t) + head * t` with `t` from 0 to 1, and the tail is cut off. The wrap edge then joins two pixels that were neighbors in the render. On the build's five textures the mean wrap-edge difference fell to the level of an ordinary neighboring-pixel difference, so no hard line shows. Tile it on a big wall to check before shipping. - **Ledge, 3-slice**: the cap is 12% of the strip's width (at most 30% of the run), drawn 18 to 30 px high. - **Props**: the prompt's "wide empty gaps between them so they never touch" is what makes the split work; props that touch merge into one component and the script warns when it finds fewer than asked for (`--props`). - The superseded first pass was 384x224 paintings and tiles. -
prompt-templates.md 15.3 KB
# Prompt templates The build's prompts with the subject replaced by slots. Fill every `{slot}` for your own game. Parameter names in this file are the build's schemas' examples: read your picks with `model_schema_get` and use the field names listed there, since they differ between models. ## Slots | Slot | Meaning | Example | | ----------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `{character}` | Full design: build, clothing, colors, weapon and where it is held | a small nimble fox knight in a dented steel breastplate and a short teal cape, a slender rapier in ... | | `{silhouette}` | The two or three shapes that must read at sprite size | pointed ears, bushy tail, rapier | | `{pose_a}` | Left pose: the rest pose every ground clip starts from | standing in a light fencing ready stance, facing right, knees slightly bent, rapier held low and ahead | | `{pose_b}` | Right pose: the jump (hero) or the attack wind-up (enemy) | the same character mid-air in a jump, facing right, knees tucked up, rapier arm raised back | | `{who}` | Short name used in the motion prompts | fox knight | | `{he}` | Pronoun, capitalized | He | | `{he_lower}` | Pronoun, lower case | he | | `{his_cap}` | Possessive, capitalized | His | | `{secondary}` | Cloth, hair or tails that should trail during motion | teal cape and bushy tail streaming behind him | | `{weapon}` | The weapon as it should be named in motion prompts | slender steel rapier | | `{strike}` | One attack's swing, one phrase per attack clip | a wide horizontal slash forward at chest height | | `{ranged_weapon}` | The drawn ranged weapon, for the shot clip | a small flintlock pistol from his belt | | `{stride}` | An armed enemy's legs during the walk | short heavy strides on broad clawed feet, knees bending | | `{held}` | Where an armed enemy's weapon stays during the walk | low behind him | | `{n}` | Number of poses on the pose sheet | four | | `{pose_list}` | The pose-sheet poses, left to right, separated by semicolons | falling with the cape billowing up; clinging to an invisible wall on his right; ... | | `{genre}` | One phrase for the game, used by every environment prompt | whimsical action platformer | | `{scene}` | The level's place, with depth layers and light sources | a giant mushroom forest at dusk seen straight on from the side: towering glowing caps, hanging moss ... | | `{palette}` | The level's palette in a few words | moss green, warm amber and soft violet on near-black | | `{mid}` | Framing for the midground: left edge, right edge, top edge | huge dark mushroom stalks rise along the far left and far right edges, a canopy of caps across the top | | `{tex}` | The material of solid walls and floors | packed dark earth with embedded roots and small stones, fine cracks, patches of moss | | `{ledge}` | The platform's material, trim line and what hangs beneath it | a thick shelf fungus with a pale flat walkable top edge, glowing spores and roots hanging beneath it | | `{props}` | Three props separated by semicolons | a crooked lantern post; a tree stump with a small door; a cluster of glowing mushrooms | Keep the key color off every character: the scripts key strongly magenta pixels to transparency, so no pink or purple cloth, flames or gems on a sprite. Environment layers use real transparency instead and can use any color. ## 1. Two-pose still (text to image) One image, two poses of the same character, so the design matches between them. Hero: rest pose and jump. Ground enemy: rest pose and attack wind-up. ``` Pixel art game sprite sheet, two poses of the same character side by side, full body, on a perfectly flat solid magenta (#FF00FF) background, no shadows on the ground, no text. Character: {character} Strong readable silhouette: {silhouette}. Camera: classic 2D side-scrolling platformer, pure side profile view, orthographic, no perspective. Left pose: {pose_a}. Right pose: {pose_b}. Style: crisp hand-placed pixel art like a modern 16-bit indie action platformer, 1px dark outline, limited palette of about 24 colors, soft top-left lighting, no pink, no purple, no magenta anywhere on the character, each figure about 60% of the image height, both figures the same size, generous empty space around each. ``` Settings in the build: 1536x1024 landscape, high quality, opaque background, two outputs to pick from. Keep the take where both poses are on model, the magenta is clean and nothing on the character is pink or purple. Name every pose "facing right": the game mirrors the sprite for the left. `reframe_stills.py` writes the left pose as `<name>_rest.png` and the right pose as `<name>_action.png`. ## 2. Motion prompts (image to video) The first frame is the reframed still. A clip prompt is `MOTION + " " + FACING_AND_TAIL`, where the motion sentence starts with `Pixel art game sprite animation` for locomotion and names the character for the rest. ### Facing and tail (appended to every clip) ``` {he} always faces right in a pure side profile view like a 2D side-scrolling platformer, the same angle as the first frame, never turning toward the camera. {he} stays centered and does not travel across the frame. Locked static camera, flat solid magenta background, no ground shadow, crisp pixel art, same character design as the first frame. ``` ### Negative prompt ``` front view, three-quarter view, facing the camera, turning around, camera movement, zoom, traveling across the frame, ground shadow, drop shadow, background change, extra characters, blur, 3D render ``` Add `, slowing down, stopping, standing still` for a run or walk. For an armed enemy's walk also add `, swinging the weapon, raising the {weapon}, attacking, twirling`. ### Motion sentences Run (first frame only): ``` Pixel art game sprite animation, like a treadmill: the {who} immediately starts running in place and keeps running for the whole clip, a steady fast repeating run cycle (knees high, arms pumping, {weapon} held low and back, torso leaning forward, both feet leave the ground between steps, {secondary}). ``` Idle (first = last frame): ``` The {who} stands idle in a light combat ready stance, a subtle breathing loop: chest rises and falls, a slight weight shift, {secondary} sway gently in a breeze. Feet stay planted. ``` Attack (first = last frame; write one per attack, each a different strike): ``` The {who} performs one fast {weapon} attack: {he_lower} steps into it and swings {strike}, then returns to the starting ready pose. Snappy anticipation, very fast strike, short follow-through. ``` Lunge, shot, hurt (first = last frame), each ending with "then returns to the starting ready pose": ``` The {who} performs one explosive forward lunge in place: {he_lower} drops low and leans far forward into a long horizontal thrust, then returns to the starting ready pose. The {who} quickly draws {ranged_weapon}, fires one shot with a small bright muzzle flash and a sharp recoil kick, then holsters it and returns to the starting ready pose. The {who} gets hit by an invisible blow from the front: {he_lower} flinches and recoils sharply backward, then recovers and returns to the starting ready pose. ``` Air spin (first frame = last frame = the reframed action still, `<name>_action.png`): ``` The {who} is airborne in the middle of a jump and performs one fast full forward somersault spin with the {weapon} held out, then comes back to the same mid-air pose as the first frame. {he} stays in the air in the center of the frame and never lands. ``` ### Armed enemy walk First frame only. The build's first armed walk swung its weapon mid-stride and could not loop; this wording, with a lower guidance scale, fixed it: ``` Pixel art game sprite animation, like a treadmill: the {who} immediately starts walking in place and keeps walking for the whole clip, a steady repeating walk cycle: {stride}, {secondary} swaying with each step. {his_cap} arms stay still: {he_lower} keeps the {weapon} held {held} in the same grip for the entire clip and never lifts, swings or twirls it. ``` The run, idle and armed-walk sentences are the build's own wording; the attack, lunge, shot, hurt and spin sentences keep the build's opening and closing words, with the middle written as slots. ### Clip settings | Clip | First frame | End frame | Duration | Why | | -------------------------------- | ----------- | -------------- | -------- | ------------------------------------------------------------ | | run, enemy walk | rest still | none | 3 s | An end frame on short locomotion adds speed-up and slow-down | | idle, attacks, lunge, shot, hurt | rest still | the same still | 3 s | Comes back to rest, so every action chains into idle | | air spin | jump still | the same still | 3 s | Stays airborne; the game plays it between rising and falling | All clips square (1:1), audio off. In the build's schema these were `startImage`, `endImage`, `duration` (the string `"3"`), `aspectRatio`, `generateAudio` and `negativePrompt`; the regenerated enemy walk also set `cfgScale` to 0.7. Discover a clip model that takes a last frame with `recommend` (`capability: "img2video"`, `features: ["endImage"]`), then confirm the field names with `model_schema_get`. ## 3. Static pose sheet Text to image with the chosen two-pose still as reference, for poses a clip cannot give cheaply: falling, wall cling, plunge, air dash. ``` Pixel art game sprite sheet, {n} poses of the same character in one row, full body, pure side profile view facing right, on a perfectly flat solid magenta (#FF00FF) background, no shadows, no text, same character design, palette and pixel style as the reference image, wide empty gaps between the poses. Poses from left to right: {pose_list}. ``` This wording is a recommended template in the style of the others, not the build's verbatim prompt. The build's list, in order: falling with the cloak billowing up; clinging to an invisible wall on the right; a feet-first plunge with the weapon pointed straight down; a horizontal air dash with the weapon thrust forward. Settings: reference image = the chosen two-pose still, 1536x1024, high quality, opaque background (the key needs the magenta), two outputs. The poses may still overlap horizontally; `side_sprites.py --poses` splits them by connected components, and `--pose-names` (default `fall,wall,plunge,dash`) names them left to right. ## 4. Environment layer set Text to image, five images per level, all at high quality in the build. The record gives no output count: request one output each and re-render only a layer that fails its check. Generate level 1 only, look at it in the game, then run the other levels with the same templates and new slots. Background (1920x1152, opaque): ``` Richly detailed high-resolution pixel art background layer for a premium modern 2D side-scrolling {genre} (the level of detail and lighting of the best contemporary pixel art games). {scene}. Atmospheric depth fog getting darker toward the edges. {palette} palette. Keep the lower third and the middle of the image darker and lower contrast so characters on top of it stay readable. Orthographic side view, no floor in the foreground, no platforms, no characters, no text, no UI. ``` Midground (1920x1152, transparent background): ``` Parallax midground layer for a 2D side-scrolling {genre}, richly detailed high-resolution pixel art, on a fully transparent background. {mid}. Lit softly from the center, very dark shadows. The entire center of the image and the whole lower middle are completely empty and transparent. Orthographic side view, no floor, no characters, no text. ``` Wall texture (1024x1024, opaque): ``` Seamless tileable square texture for the solid walls and floors of a 2D {genre}, richly detailed high-resolution pixel art. {tex}. Flat orthographic front view, even soft lighting from the top-left, no vignette, no strong shadows, no perspective, the pattern must repeat perfectly on all four edges. ``` The render will not tile reliably whatever the prompt says; `process_env.py` cross-fades the edges. Platform strip (1536x384 requested, transparent background; the build's model returned 1536x512, and `process_env.py` trims to the opaque strip, so either size works): ``` Game asset for a 2D side-scrolling {genre}, richly detailed high-resolution pixel art, on a fully transparent background: one long, thin, perfectly horizontal floating platform seen from the side, spanning the entire width of the image edge to edge. {ledge}. The top edge is perfectly straight and flat. The slab is about one quarter of the image height; everything above and below it is transparent. Orthographic side view, no characters, no text. ``` Props (1536x1024, transparent background): ``` Three separate decorative props for a 2D side-scrolling {genre}, richly detailed high-resolution pixel art, on a fully transparent background, evenly spaced in one row with wide empty gaps between them so they never touch, each standing on the same invisible ground line, pure side view: {props}. Each prop about 55% of the image height. No ground, no shadows, no characters, no text. ``` Sizes and the transparent option are the build's; request the nearest your pick's schema allows (`background: "opaque"` or `"transparent"` was the build schema's field). Give each level its own scene and dominant color, and end every palette "on near-black" so the sprites stay readable on all of them.
-
-
scripts
-
process_env.py 10.7 KB
#!/usr/bin/env python3 """Turn generated environment renders into game-ready WebP layers for a side-scroller. One layer set per level, read from --env with these names (N = level number): bg<N>.png opaque background painting -> env_bg<N>.webp (as is, lossy) mid<N>.png transparent parallax midground -> env_mid<N>.webp (as is, lossy, alpha kept) tex<N>.png wall and floor texture -> env_tex<N>.webp (made seamless, resized) ledge<N>.png transparent platform strip -> env_ledge<N>.webp (trimmed, fixed height) props<N>.png transparent sheet of props -> env_prop<N>_<k>.webp (one file per prop) A missing file is skipped with a note. Explicit files can be given instead with --file KIND=PATH (output named after the file stem, e.g. env_tex_stone_wall.webp). Seamless texture: image models asked for a "seamless tileable" texture do not reliably tile, so the opposite edges are cross-faded: the first --fade share of each axis becomes a blend of the tail and the head, and the tail is cut off, so the result wraps on both axes (it is slightly smaller before the resize). Ledge: trimmed to its opaque bounding box and scaled to --ledge-height; draw it in the game as left cap + repeated middle + right cap so runs of any length keep the carved ends. Props: split by connected components (8-neighbor on a 1/4 mask), the --props largest kept, ordered left to right, each scaled to --prop-height. Existing outputs are kept unless --force is given; --dry-run lists what would be written. Examples: python3 process_env.py --env env --out assets --levels 5 python3 process_env.py --env env --out assets --levels 2,4 --tex-size 512 --force python3 process_env.py --out assets --file tex=renders/stone_wall.png --file props=renders/crypt_props.png --props 4 Needs Python 3 with Pillow and NumPy. """ import argparse import sys from collections import deque from pathlib import Path try: import numpy as np from PIL import Image except ImportError: sys.exit('This script needs Pillow and NumPy: python3 -m pip install pillow numpy') KINDS = ('bg', 'mid', 'tex', 'ledge', 'props') def seamless(img, frac): """Cross-fade opposite edges so the texture wraps: out[i] = tail*(1-t) + head*t over the first k px.""" a = np.asarray(img.convert('RGB')).astype(np.float32) for axis in (1, 0): n = a.shape[axis] k = int(n * frac) if k < 1: continue head = np.take(a, range(0, k), axis=axis) tail = np.take(a, range(n - k, n), axis=axis) t = np.linspace(0, 1, k, dtype=np.float32) t = t[None, :, None] if axis == 1 else t[:, None, None] blend = tail * (1 - t) + head * t body = np.take(a, range(k, n - k), axis=axis) a = np.concatenate([blend, body], axis=axis) return Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) def components(img, count, thr, f=4): """The `count` largest connected opaque regions, left to right, each with its own alpha.""" rgba = np.asarray(img.convert('RGBA')) full = rgba[..., 3] > thr h, w = full.shape[0] // f, full.shape[1] // f m = full[:h * f, :w * f].reshape(h, f, w, f).any(axis=(1, 3)) lab = np.zeros((h, w), np.int32) comps, n = [], 0 for y in range(h): for x in range(w): if m[y, x] and not lab[y, x]: n += 1 q = deque([(y, x)]) lab[y, x] = n size = 0 while q: cy, cx = q.popleft() size += 1 for dy in (-1, 0, 1): for dx in (-1, 0, 1): ny, nx = cy + dy, cx + dx if 0 <= ny < h and 0 <= nx < w and m[ny, nx] and not lab[ny, nx]: lab[ny, nx] = n q.append((ny, nx)) comps.append((size, n)) if len(comps) < count: print(f'warning: found {len(comps)} prop(s), expected {count}; props touching each other merge into one', file=sys.stderr) keep = [c[1] for c in sorted(comps, reverse=True)[:count]] out = [] for k in keep: mk = np.kron(lab == k, np.ones((f, f), bool)) mk = np.pad(mk, ((0, full.shape[0] - mk.shape[0]), (0, full.shape[1] - mk.shape[1]))) ys, xs = np.where(mk & full) sub = rgba[ys.min():ys.max() + 1, xs.min():xs.max() + 1].copy() sub[..., 3] = np.where(mk[ys.min():ys.max() + 1, xs.min():xs.max() + 1], sub[..., 3], 0) out.append((xs.min(), Image.fromarray(sub))) return [im for _, im in sorted(out, key=lambda t: t[0])] def fit_h(im, h): return im.resize((max(1, round(im.width * h / im.height)), h), Image.LANCZOS) def parse_levels(spec): if spec.isdigit(): return list(range(1, int(spec) + 1)) try: return sorted({int(v) for v in spec.split(',') if v.strip()}) except ValueError: raise SystemExit(f'error: --levels {spec!r}; use a count (5) or a list (2,4)') def jobs(args): """(kind, source, output stem) for every input to process.""" out = [] if args.file: for item in args.file: kind, sep, path = item.partition('=') if not sep or kind not in KINDS: raise SystemExit(f'error: --file {item!r}; use KIND=PATH with KIND one of {", ".join(KINDS)}') src = Path(path) if not src.is_file(): raise SystemExit(f'error: {src} not found') out.append((kind, src, src.stem)) return out if args.env is None: raise SystemExit('error: give --env (with --levels) or one or more --file KIND=PATH') for level in parse_levels(args.levels): for kind in KINDS: src = args.env / f'{kind}{level}.png' if src.is_file(): out.append((kind, src, str(level))) else: print(f'skip (missing) {src}') return out def outputs(kind, stem, count): sep = '' if stem.isdigit() else '_' # env_tex3.webp for level 3, env_tex_stone_wall.webp for a file if kind == 'props': return [f'env_prop{sep}{stem}_{k}.webp' for k in range(count)] return [f'env_{kind}{sep}{stem}.webp'] def process(kind, src, names, args): """Write the outputs for one input; returns the names actually written.""" im = Image.open(src) if kind == 'bg': im.convert('RGB').save(args.out / names[0], quality=args.bg_quality, method=6) elif kind == 'mid': im.convert('RGBA').save(args.out / names[0], quality=args.mid_quality, method=6) elif kind == 'tex': tex = seamless(im, args.fade).resize((args.tex_size, args.tex_size), Image.LANCZOS) tex.save(args.out / names[0], quality=args.detail_quality, method=6) elif kind == 'ledge': le = im.convert('RGBA') box = le.split()[3].point(lambda v: 255 if v > args.alpha_threshold else 0).getbbox() if box is None: raise SystemExit(f'error: {src} is fully transparent') fit_h(le.crop(box), args.ledge_height).save(args.out / names[0], quality=args.detail_quality, method=6) else: props = components(im, args.props, args.alpha_threshold) for name, pr in zip(names, props): fit_h(pr, args.prop_height).save(args.out / name, quality=args.detail_quality, method=6) missing = names[len(props):] if missing: print(f'warning: {src} held {len(props)} of {args.props} props; not written: ' + ', '.join(missing)) stale = [n for n in missing if (args.out / n).exists()] if stale: print('warning: left from an earlier run, delete them or re-render the sheet: ' + ', '.join(str(args.out / n) for n in stale)) return names[:len(props)] return names[:1] def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('--env', type=Path, help='folder holding bg<N>.png, mid<N>.png, tex<N>.png, ledge<N>.png, props<N>.png') parser.add_argument('--out', type=Path, required=True, help='output folder for the WebP files (created if missing)') parser.add_argument('--levels', default='5', help='level count (5 = levels 1 to 5) or a list (2,4; "2," for level 2 alone); default 5') parser.add_argument('--file', action='append', metavar='KIND=PATH', help=f'process one explicit file instead of --env/--levels (repeatable); KIND is one of {", ".join(KINDS)}') parser.add_argument('--tex-size', type=int, default=384, help='side of the seamless texture in px (default 384)') parser.add_argument('--fade', type=float, default=0.12, help='share of each axis cross-faded for the seamless texture (default 0.12)') parser.add_argument('--ledge-height', type=int, default=120, help='height of the trimmed ledge strip in px (default 120)') parser.add_argument('--props', type=int, default=3, help='props per sheet (default 3)') parser.add_argument('--prop-height', type=int, default=180, help='height of each cut prop in px (default 180)') parser.add_argument('--alpha-threshold', type=int, default=24, help='alpha above which a pixel counts as opaque when trimming and splitting (default 24)') parser.add_argument('--bg-quality', type=int, default=86, help='WebP quality for backgrounds (default 86)') parser.add_argument('--mid-quality', type=int, default=86, help='WebP quality for midgrounds (default 86)') parser.add_argument('--detail-quality', type=int, default=90, help='WebP quality for textures, ledges and props (default 90)') parser.add_argument('--dry-run', action='store_true', help='list the outputs, write nothing') parser.add_argument('--force', action='store_true', help='overwrite existing outputs') args = parser.parse_args() if not 0 < args.fade < 0.5: sys.exit('error: need 0 < --fade < 0.5') if min(args.tex_size, args.ledge_height, args.prop_height, args.props) < 1: sys.exit('error: sizes and --props must be positive') todo = [(kind, src, outputs(kind, stem, args.props)) for kind, src, stem in jobs(args)] if not todo: sys.exit('error: nothing to process') existing = [str(args.out / n) for _, _, names in todo for n in names if (args.out / n).exists()] if existing and not args.force and not args.dry_run: sys.exit('error: would overwrite ' + ', '.join(existing) + ' (pass --force to replace)') if args.dry_run: for kind, src, names in todo: print(f'{src} ({kind}) -> ' + ', '.join(str(args.out / n) for n in names)) return args.out.mkdir(parents=True, exist_ok=True) for kind, src, names in todo: written = process(kind, src, names, args) print(f'{src} ({kind}) -> ' + ', '.join(written)) if __name__ == '__main__': main() -
reframe_stills.py 4.8 KB
#!/usr/bin/env python3 """Split a two-pose still into two square first frames with headroom. The still holds one character twice on a flat magenta (#FF00FF) field, in side profile facing right: the rest pose on the left half, the jump (hero) or attack wind-up (enemy) on the right half. Each pose is cut out, scaled so the figure is a fixed share of the frame height, and pasted centered on a fresh magenta square with its feet on a fixed baseline. The empty space above the head leaves room for a raised weapon or a jump in the animated clip. Writes <out>/<name>_rest.png (the left pose, first frame of every ground clip) and <out>/<name>_action.png (the right pose). Existing files are kept unless --force is given. Examples: python3 reframe_stills.py hero_two_pose.png --name hero --out frames python3 reframe_stills.py hero.png --name hero --out frames --figure 0.55 --baseline 0.84 Needs Python 3 with Pillow and NumPy. """ import argparse import sys from pathlib import Path try: import numpy as np from PIL import Image except ImportError: sys.exit('This script needs Pillow and NumPy: python3 -m pip install pillow numpy') def magenta_mask(rgb, tolerance): """True where a pixel belongs to the flat magenta field.""" r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2] return (r > 255 - tolerance) & (b > 255 - tolerance) & (g < tolerance + 25) def figure_box(keep, min_pixels): """Bounding box of the figure, ignoring rows and columns with only a few stray pixels.""" rows = np.where(keep.sum(1) >= min_pixels)[0] cols = np.where(keep.sum(0) >= min_pixels)[0] if len(rows) == 0 or len(cols) == 0: return None return cols.min(), rows.min(), cols.max() + 1, rows.max() + 1 def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('still', type=Path, help='two-pose image: rest pose on the left half, jump or attack wind-up on the right half') parser.add_argument('--name', required=True, help='character name used in the output file names') parser.add_argument('--out', type=Path, required=True, help='output folder (created if missing)') parser.add_argument('--size', type=int, default=960, help='side of the square first frame in px (default 960)') parser.add_argument('--figure', type=float, default=0.58, help='figure height as a share of the frame (default 0.58)') parser.add_argument('--baseline', type=float, default=0.82, help='feet line as a share of the frame from the top (default 0.82)') parser.add_argument('--tolerance', type=int, default=85, help='how far from pure magenta still counts as background (default 85)') parser.add_argument('--force', action='store_true', help='overwrite existing first frames') args = parser.parse_args() if not args.still.is_file(): sys.exit(f'error: {args.still} not found') if not 0.2 <= args.figure <= 0.9 or not args.figure < args.baseline <= 0.98: sys.exit('error: need 0.2 <= --figure <= 0.9 and --figure < --baseline <= 0.98') targets = {p: args.out / f'{args.name}_{p}.png' for p in ('rest', 'action')} existing = [str(p) for p in targets.values() if p.exists()] if existing and not args.force: sys.exit('error: would overwrite ' + ', '.join(existing) + ' (pass --force to replace)') im = Image.open(args.still).convert('RGB') rgb = np.array(im).astype(int) keep = ~magenta_mask(rgb, args.tolerance) width = im.width size = args.size fig_h = int(size * args.figure) base = int(size * args.baseline) args.out.mkdir(parents=True, exist_ok=True) for pose, x0, x1 in (('rest', 0, width // 2), ('action', width // 2, width)): box = figure_box(keep[:, x0:x1], min_pixels=3) if box is None: sys.exit(f'error: no figure found in the {pose} half; is the background flat magenta?') bx0, by0, bx1, by1 = box[0] + x0, box[1], box[2] + x0, box[3] if bx0 == x0 or bx1 == x1: print(f'warning: the {pose} figure touches the middle or the edge of the image; ' 'check that the two poses do not overlap', file=sys.stderr) fig = im.crop((bx0, by0, bx1, by1)) scale = fig_h / fig.height new_w = round(fig.width * scale) if new_w > size: sys.exit(f'error: the {pose} figure is wider than the frame at --figure {args.figure}; lower it') fig = fig.resize((new_w, fig_h), Image.LANCZOS) alpha = Image.fromarray((keep[by0:by1, bx0:bx1] * 255).astype('uint8')).resize(fig.size, Image.LANCZOS) out = Image.new('RGB', (size, size), (255, 0, 255)) out.paste(fig, ((size - new_w) // 2, base - fig_h), alpha) out.save(targets[pose]) print(f'wrote {targets[pose]} (figure {new_w}x{fig_h}, feet at y={base})') if __name__ == '__main__': main() -
side_sprites.py 16.4 KB
#!/usr/bin/env python3 """Turn image-to-video clips of one side-view character into pixel-art sprite strips. A side-scroller needs one facing only (the game mirrors the sprite when the character turns), so this is a one-facing pipeline built on the helpers in sprite_cycles.py, which must sit in the same folder. Input: one clip per cycle, named <clips>/<name>_<cycle>.mp4, each animated from a square first frame on flat magenta (#FF00FF) with the figure centered (reframe_stills.py makes these). Every clip is keyed to alpha, then windowed by its mode: loop the window whose last frame flows back into its first (run, walk, idle, or any other repeating cycle given a --range); attack the active window of a one-shot action that starts and ends on the rest pose (attack, lunge, shoot, hurt, an air spin). All cycles share one canvas that always includes the feet line, so every strip registers on the same anchor, then an area-average downscale to --height px for the figure, one median-cut palette of --palette colors and a 1 px redrawn outline. Optional static poses join the same palette: a pose sheet split into figures by connected components (--poses) and a reframed jump still (--jump). Writes <out>/<name>_<cycle>.png (one row per cycle), <out>/<name>_pose_<pose>.png and <out>/<name>_meta.json (cell size, feet anchor, frames and fps per cycle, pose sizes). Existing files are kept unless --force is given; --report prints the windows and writes nothing. Cycle spec: comma-separated NAME:MODE:FRAMES, MODE loop or attack. Examples: # hero: eight clips, a pose sheet and the jump still, 56 px, 28 colors python3 side_sprites.py --clips clips --name hero --height 56 --palette 28 \\ --cycles run:loop:8,idle:loop:8,atk1:attack:7,atk2:attack:7,spin:attack:8,shoot:attack:7,lunge:attack:6,hurt:attack:5 \\ --poses hero_poses.png --pose-names fall,wall,plunge,dash --jump frames/hero_action.png \\ --drop atk1=2 --out sprites # a ground enemy python3 side_sprites.py --clips clips --name heavy --height 78 \\ --cycles walk:loop:8,attack:attack:8,hurt:attack:5 --out sprites # look at the loop windows first python3 side_sprites.py --clips clips --name heavy --cycles walk:loop:8 --report Needs Python 3 with Pillow and NumPy, and ffmpeg and ffprobe on the PATH. """ import argparse import json import shutil import sys import types from collections import deque from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) try: import numpy as np from PIL import Image import sprite_cycles as sc except ImportError as error: sys.exit(f'error: {error}. This script needs Pillow and NumPy (python3 -m pip install pillow numpy) ' 'and sprite_cycles.py in the same folder.') DEFAULT_CYCLES = 'run:loop:8,idle:loop:8,attack:attack:7,hurt:attack:5' MODES = ('loop', 'attack') def parse_cycles(spec): cycles = {} for item in spec.split(','): item = item.strip() if not item: continue parts = item.split(':') if len(parts) != 3 or parts[1] not in MODES or not parts[2].isdigit() or int(parts[2]) < 2: raise SystemExit(f'error: --cycles item {item!r}; use NAME:loop:8 or NAME:attack:7 (frames >= 2)') cycles[parts[0]] = (parts[1], int(parts[2])) if not cycles: raise SystemExit('error: --cycles is empty') return cycles def parse_ranges(items, cycles): ranges = dict(sc.DEFAULT_RANGES) for item in items or []: cycle, sep, span = item.partition('=') lo, sep2, hi = span.partition(':') if not sep or not sep2 or not lo.isdigit() or not hi.isdigit() or not 2 <= int(lo) <= int(hi): raise SystemExit(f'error: --range {item!r}; use CYCLE=MIN:MAX in clip frames, 2 <= MIN <= MAX') ranges[cycle] = (int(lo), int(hi)) for name, (mode, _) in cycles.items(): if mode == 'loop' and name not in ranges: raise SystemExit(f'error: loop cycle {name!r} has no default period range; pass --range {name}=MIN:MAX') return ranges def parse_drops(items): drops = {} for item in items or []: cycle, sep, idx = item.partition('=') try: values = sorted({int(v) for v in idx.split(',')}) except ValueError: values = None if not sep or not values or min(values) < 0: raise SystemExit(f'error: --drop {item!r}; use CYCLE=INDEX[,INDEX] with 0-based frame indices') drops.setdefault(cycle, set()).update(values) return drops def pick(name, mode, n, frames, fps, ranges, skip): return sc.pick(name, mode, frames, fps, ranges, types.SimpleNamespace(frames=n, skip=skip)) def split_poses(img, count): """Cut `count` figures out of a pose sheet by connected components (8-neighbor, on a 1/4 mask). Column runs fail when poses overlap horizontally; components do not. Ordered left to right.""" rgba = sc.key(np.array(img.convert('RGB'))) full = rgba[..., 3] > 0 f = 4 h, w = full.shape[0] // f, full.shape[1] // f m = full[:h * f, :w * f].reshape(h, f, w, f).any(axis=(1, 3)) lab = np.zeros((h, w), np.int32) comps, n = [], 0 for y in range(h): for x in range(w): if m[y, x] and not lab[y, x]: n += 1 q = deque([(y, x)]) lab[y, x] = n size = 0 while q: cy, cx = q.popleft() size += 1 for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (-1, -1), (1, -1), (-1, 1)): ny, nx = cy + dy, cx + dx if 0 <= ny < h and 0 <= nx < w and m[ny, nx] and not lab[ny, nx]: lab[ny, nx] = n q.append((ny, nx)) comps.append((size, n)) if len(comps) < count: raise SystemExit(f'error: found {len(comps)} figure(s) on the pose sheet, expected {count}') keep = [c[1] for c in sorted(comps, reverse=True)[:count]] out = [] for k in keep: mk = np.kron(lab == k, np.ones((f, f), bool)) mk = np.pad(mk, ((0, full.shape[0] - mk.shape[0]), (0, full.shape[1] - mk.shape[1]))) ys, xs = np.where(mk & full) sub = rgba[ys.min():ys.max() + 1, xs.min():xs.max() + 1].copy() sub[..., 3] = np.where(mk[ys.min():ys.max() + 1, xs.min():xs.max() + 1], sub[..., 3], 0) out.append((xs.min(), sub)) return [s for _, s in sorted(out, key=lambda t: t[0])] def finish(rgb, mask, pal, args): q = Image.fromarray(sc.punch(rgb, args.contrast, args.saturation)) q = np.array(q.quantize(palette=pal, dither=Image.Dither.NONE).convert('RGB')) return Image.fromarray(sc.outline(np.dstack([q, np.where(mask, 255, 0).astype(np.uint8)]), args.outline)) def save(im, path, webp): if webp: im.save(path, lossless=True, quality=100, method=6) else: im.save(path) def leftovers(meta_path, name, ext, targets): """Strips and poses listed by a previous run's meta file that this run will not rewrite.""" if not meta_path.is_file(): return [] try: old = json.loads(meta_path.read_text()) except ValueError: return [] paths = [meta_path.parent / f'{name}_{c}{ext}' for c in old.get('cycles', {})] paths += [meta_path.parent / f'{name}_pose_{p}{ext}' for p in old.get('poses', {})] return [str(p) for p in paths if p.exists() and p not in targets] def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('--clips', type=Path, required=True, help='folder holding <name>_<cycle>.mp4') parser.add_argument('--name', required=True, help='character name (clip and output file prefix)') parser.add_argument('--out', type=Path, help='output folder (required unless --report)') parser.add_argument('--cycles', default=DEFAULT_CYCLES, help=f'NAME:MODE:FRAMES list (default {DEFAULT_CYCLES})') parser.add_argument('--range', action='append', metavar='CYCLE=MIN:MAX', help='loop period search range in clip frames (repeatable); defaults walk=12:34 run=8:24 idle=24:66') parser.add_argument('--drop', action='append', metavar='CYCLE=I[,I]', help='leave sampled frames (0-based) out of the strip, e.g. a frame where a slash trail keyed into a ' 'solid block; they still count for the shared canvas and palette (repeatable)') parser.add_argument('--height', type=int, default=56, help='sprite height of the standing figure in px (default 56)') parser.add_argument('--palette', type=int, default=28, help='colors in the shared palette (default 28)') parser.add_argument('--poses', type=Path, help='optional pose sheet on flat magenta, one figure per pose, left to right') parser.add_argument('--pose-names', default='fall,wall,plunge,dash', help='names of the poses on the sheet, left to right') parser.add_argument('--pose-scale', type=float, default=0.122, help='downscale factor for pose-sheet figures (default 0.122; tune until they match the strips)') parser.add_argument('--jump', type=Path, help='optional reframed jump still (for example <name>_action.png), written as pose "jump"') parser.add_argument('--skip', type=int, default=3, help='first clip frames never used as a loop start (default 3)') parser.add_argument('--canvas', type=int, default=960, help='side of the square first frame (default 960)') parser.add_argument('--figure', type=float, default=0.58, help='figure height as a share of the first frame (default 0.58)') parser.add_argument('--baseline', type=float, default=0.82, help='feet line as a share of the first frame (default 0.82)') parser.add_argument('--margin', type=int, default=8, help='transparent margin around the shared canvas, in clip px (default 8)') parser.add_argument('--contrast', type=float, default=1.12, help='contrast lift before the palette (default 1.12, 1 = off)') parser.add_argument('--saturation', type=float, default=1.12, help='saturation lift before the palette (default 1.12, 1 = off)') parser.add_argument('--outline', type=float, default=0.38, help='edge pixel brightness factor (default 0.38, 1 = off)') parser.add_argument('--webp', action='store_true', help='write lossless WebP instead of PNG') parser.add_argument('--report', action='store_true', help='print windows and seam scores, write nothing') parser.add_argument('--force', action='store_true', help='overwrite existing outputs') args = parser.parse_args() for tool in ('ffmpeg', 'ffprobe'): if shutil.which(tool) is None: sys.exit(f'error: {tool} not found on the PATH') if args.palette < 2 or args.height < 8: sys.exit('error: need --palette >= 2 and --height >= 8') if not args.report and args.out is None: sys.exit('error: --out is required unless --report is given') cycles = parse_cycles(args.cycles) ranges = parse_ranges(args.range, cycles) drops = parse_drops(args.drop) unknown = [c for c in drops if c not in cycles] if unknown: sys.exit(f'error: --drop names unknown cycle(s) {unknown}') pose_names = [p.strip() for p in args.pose_names.split(',') if p.strip()] for path in (args.poses, args.jump): if path is not None and not path.is_file(): sys.exit(f'error: {path} not found') clips = {c: args.clips / f'{args.name}_{c}.mp4' for c in cycles} missing = [str(p) for p in clips.values() if not p.is_file()] if missing: sys.exit('error: missing clip(s): ' + ', '.join(missing) + ' (or narrow --cycles)') ext = '.webp' if args.webp else '.png' if not args.report: targets = [args.out / f'{args.name}_{c}{ext}' for c in cycles] + [args.out / f'{args.name}_meta.json'] if args.poses: targets += [args.out / f'{args.name}_pose_{p}{ext}' for p in pose_names] if args.jump: targets.append(args.out / f'{args.name}_pose_jump{ext}') existing = [str(p) for p in targets if p.exists()] if existing and not args.force: sys.exit('error: would overwrite ' + ', '.join(existing) + ' (pass --force to replace)') stale = leftovers(args.out / f'{args.name}_meta.json', args.name, ext, set(targets)) if stale: print('warning: left from an earlier run and not in this one; they will not match the new ' f'{args.name}_meta.json, delete them or add them back to --cycles/--poses: ' + ', '.join(stale)) picked, infos = {}, {} for cycle, (mode, n) in cycles.items(): frames, fps = sc.read_frames(clips[cycle], args.canvas) kept, info = pick(cycle, mode, n, frames, fps, ranges, args.skip) drop = drops.get(cycle, set()) if drop and max(drop) >= len(kept): sys.exit(f'error: --drop {cycle} index {max(drop)} is past its {len(kept)} frames') picked[cycle] = kept info['dropped'] = sorted(drop) infos[cycle] = info score = info.get('seam_over_baseline') note = '' if score is not None and score > sc.WEAK_SEAM and cycle != 'idle': note = ' <- weak loop: widen --range for a slow stride, or re-run the clip' detail = f'seam {score}x baseline' if score is not None else 'active window' print(f'{args.name}_{cycle}: start {info["start"]}, length {info["length"]}, {detail}, ' f'{info["fps"]} fps, {len(kept) - len(drop)} frames{note}') if args.report: return # One canvas for every cycle, symmetric about the center and always reaching the feet line. x0, y0, x1, y1 = sc.bbox([f for v in picked.values() for f in v]) mid, m = args.canvas // 2, args.margin half = max(mid - x0, x1 - mid) feet_y = int(args.canvas * args.baseline) box = (mid - half - m, y0 - m, mid + half + m, max(y1, feet_y) + m) scale = args.height / (args.figure * args.canvas) pix = {k: sc.pixelate(v, box, scale) for k, v in picked.items()} pose_pix = {} if args.poses: figs = split_poses(Image.open(args.poses), len(pose_names)) for name, fig in zip(pose_names, figs): pose_pix[name] = sc.pixelate([fig], (0, 0, fig.shape[1], fig.shape[0]), args.pose_scale)[0] if args.jump: jf = sc.key(np.array(Image.open(args.jump).convert('RGB'))) ys, xs = np.where(jf[..., 3] > 0) if len(xs) == 0: sys.exit(f'error: no figure found in {args.jump}; is the background flat magenta?') pose_pix['jump'] = sc.pixelate([jf], (xs.min() - 4, ys.min() - 4, xs.max() + 5, ys.max() + 5), scale)[0] lifted = [sc.punch(rgb, args.contrast, args.saturation)[mask] for v in pix.values() for rgb, mask in v] lifted += [sc.punch(rgb, args.contrast, args.saturation)[mask] for rgb, mask in pose_pix.values()] px = np.concatenate(lifted) pal = Image.fromarray(px.reshape(1, -1, 3)).quantize(colors=args.palette, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE) args.out.mkdir(parents=True, exist_ok=True) ch, cw = next(iter(pix.values()))[0][0].shape[:2] feet = round((feet_y - box[1]) * scale) meta = {'name': args.name, 'cell': [cw, ch], 'anchor': [cw // 2, feet], 'height': args.height, 'palette': args.palette, 'box': list(box), 'scale': round(scale, 4), 'cycles': {}, 'poses': {}} for cycle, frames in pix.items(): frames = [f for i, f in enumerate(frames) if i not in drops.get(cycle, set())] sheet = Image.new('RGBA', (cw * len(frames), ch), (0, 0, 0, 0)) for k, (rgb, mask) in enumerate(frames): sheet.paste(finish(rgb, mask, pal, args), (k * cw, 0)) path = args.out / f'{args.name}_{cycle}{ext}' save(sheet, path, args.webp) meta['cycles'][cycle] = {'frames': len(frames), 'fps': infos[cycle]['fps'], 'mode': cycles[cycle][0], 'window': infos[cycle]} print(f'wrote {path} ({sheet.width}x{sheet.height}, cell {cw}x{ch})') for name, (rgb, mask) in pose_pix.items(): im = finish(rgb, mask, pal, args) path = args.out / f'{args.name}_pose_{name}{ext}' save(im, path, args.webp) meta['poses'][name] = list(im.size) print(f'wrote {path} ({im.width}x{im.height})') meta_path = args.out / f'{args.name}_meta.json' meta_path.write_text(json.dumps(meta, indent=1)) print(f'wrote {meta_path} (feet anchor {cw // 2},{feet})') if __name__ == '__main__': main() -
sprite_cycles.py 7.8 KB
#!/usr/bin/env python3 """Clip-to-sprite helpers imported by side_sprites.py; not run directly. Keying: the flat magenta (#FF00FF) field goes to alpha, the model's purple ground shadow and pink haze with it, then the edges are despilled. Frame picking, per clip: loop: the window [s, s+P) whose seam (frame s against frame s+P) is smallest compared with the mean frame-to-frame motion inside it; it searches every start s >= skip and every period P in the cycle's range. attack: the active window, from the first to the last frame that differs from the rest pose (frame 0), plus two frames either side. Then `frames` evenly spaced frames are sampled from the window. Finishing: an area-average downscale premultiplied by alpha, a small contrast and saturation lift, and a 1 px inner outline redrawn on the silhouette edge. Needs Python 3 with Pillow and NumPy, and ffmpeg and ffprobe on the PATH. """ import subprocess import sys try: import numpy as np from PIL import Image except ImportError: sys.exit('This script needs Pillow and NumPy: python3 -m pip install pillow numpy') DEFAULT_RANGES = {'walk': (12, 34), 'run': (8, 24), 'idle': (24, 66)} WEAK_SEAM = 0.6 def probe(path): out = subprocess.run(['ffprobe', '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height,r_frame_rate', '-of', 'csv=p=0', str(path)], capture_output=True, text=True, check=True).stdout.strip().split(',') num, _, den = out[2].partition('/') return int(out[0]), int(out[1]), float(num) / float(den or 1) def read_frames(path, canvas): w, h, fps = probe(path) if abs(w - h) > 0.02 * max(w, h): raise SystemExit(f'error: {path} is {w}x{h}; clips must be square (1:1) like their first frame, ' 'or the figure scale and feet anchor break') raw = subprocess.run(['ffmpeg', '-v', 'error', '-i', str(path), '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'], capture_output=True, check=True).stdout arr = np.frombuffer(raw, np.uint8).reshape(-1, h, w, 3) if (w, h) != (canvas, canvas): arr = np.stack([np.array(Image.fromarray(f).resize((canvas, canvas), Image.LANCZOS)) for f in arr]) return arr, fps def key(rgb): """Alpha from the magenta field, including the purple ground shadow and pink haze, then despill.""" f = rgb.astype(np.float32) / 255 r, g, b = f[..., 0], f[..., 1], f[..., 2] mx, mn = f.max(-1), f.min(-1) sat = (mx - mn) / (mx + 1e-6) magenta = np.minimum(r, b) - g # > 0 when red and blue both beat green bg = (magenta > 0.18) & (sat > 0.28) & (np.abs(r - b) < 0.45) alpha = np.where(bg, 0, 255).astype(np.uint8) out = rgb.astype(np.float32) spill = np.clip(np.minimum(out[..., 0], out[..., 2]) - out[..., 1], 0, None) * 0.6 out[..., 0] -= spill out[..., 2] -= spill return np.dstack([np.clip(out, 0, 255).astype(np.uint8), alpha]) def small(rgba, s=120): im = Image.fromarray(rgba).resize((s, s), Image.BOX) a = np.array(im).astype(np.float32) return a[..., :3] * (a[..., 3:] / 255), a[..., 3] / 255 def dist(a, b): return float(np.abs(a[0] - b[0]).mean() + 80 * np.abs(a[1] - b[1]).mean()) def loop_window(sm, pmin, pmax, first, min_base): """Tightest closing window [s, s+P): seam delta against the window's own motion baseline.""" n = len(sm) step = [dist(sm[i], sm[i + 1]) for i in range(n - 1)] best = None for period in range(pmin, pmax + 1): for s in range(first, n - period): base = float(np.mean(step[s:s + period])) if base < min_base: # not enough motion to be a cycle continue seam = dist(sm[s], sm[s + period]) score = seam / base if best is None or score < best[0] - 1e-9: best = (score, s, period, seam, base) return best def attack_window(sm): rest = sm[0] d = np.array([dist(f, rest) for f in sm]) thr = max(1.5, 0.12 * d.max()) active = np.where(d > thr)[0] if len(active) == 0: return 0, len(sm) s = max(0, int(active.min()) - 2) e = min(len(sm) - 1, int(active.max()) + 2) return s, e - s + 1 def pick(cycle, mode, frames, clip_fps, ranges, args): """Key every frame, choose the window by mode (loop or attack), sample args.frames frames; args also carries skip.""" rgba = [key(f) for f in frames] sm = [small(x) for x in rgba] n = args.frames if mode == 'attack': s, length = attack_window(sm) idx = [s + round(k * (length - 1) / (n - 1)) for k in range(n)] info = {'start': s, 'length': length, 'mode': 'active-window'} fps = min(max(n * clip_fps / length, 8.0), 12.0) else: pmin, pmax = ranges[cycle] pmax = min(pmax, len(sm) - args.skip - 1) found = loop_window(sm, pmin, pmax, args.skip, 0.05 if cycle == 'idle' else 0.35) if found is None: raise SystemExit(f'error: no loop window with real motion in the {cycle} clip ' f'(range {pmin}:{pmax}); widen --range or check the clip') score, s, length, seam, base = found idx = [s + round(k * length / n) for k in range(n)] info = {'start': s, 'length': length, 'mode': 'loop', 'seam_over_baseline': round(score, 3), 'seam': round(seam, 2), 'baseline': round(base, 2)} fps = min(max(n * clip_fps / length, 5.0), 14.0) info.update(indices=idx, fps=round(fps, 1)) return [rgba[i] for i in idx], info def bbox(frames): a = np.any(np.stack([f[..., 3] > 0 for f in frames]), 0) ys, xs = np.where(a) if len(xs) == 0: raise SystemExit('error: every kept frame is empty after keying; is the background flat magenta?') return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1 def crop_padded(f, box): """Crop that tolerates a box past the frame edge (pads with transparency instead of wrapping).""" x0, y0, x1, y1 = box out = np.zeros((y1 - y0, x1 - x0, 4), np.uint8) sx0, sy0 = max(x0, 0), max(y0, 0) sx1, sy1 = min(x1, f.shape[1]), min(y1, f.shape[0]) out[sy0 - y0:sy1 - y0, sx0 - x0:sx1 - x0] = f[sy0:sy1, sx0:sx1] return out def area_resize(ch, size): return np.array(Image.fromarray(ch.astype(np.float32)).resize(size, Image.BOX)) def pixelate(frames, box, scale): """Area-average to the sprite grid, premultiplied so edges do not pick up the key color.""" x0, y0, x1, y1 = box size = (max(1, round((x1 - x0) * scale)), max(1, round((y1 - y0) * scale))) out = [] for f in frames: crop = crop_padded(f, box).astype(np.float32) a = crop[..., 3] / 255 al = area_resize(a, size) rgb = np.dstack([area_resize(crop[..., i] * a, size) for i in range(3)]) rgb = rgb / np.maximum(al, 1e-6)[..., None] out.append((np.clip(rgb, 0, 255).astype(np.uint8), al > 0.5)) return out def punch(rgb, contrast, sat): """Small contrast and saturation lift: area-averaging flattens pixel art.""" f = rgb.astype(np.float32) gray = f.mean(-1, keepdims=True) f = gray + (f - gray) * sat f = (f - 128) * contrast + 128 return np.clip(f, 0, 255).astype(np.uint8) def outline(rgba, strength): """Redraw the 1 px dark outline the downscale averaged away: darken every opaque pixel that touches transparency (4-neighbor), inside the silhouette.""" if strength >= 1: return rgba a = rgba[..., 3] > 0 pad = np.pad(a, 1) edge = a & ~(pad[:-2, 1:-1] & pad[2:, 1:-1] & pad[1:-1, :-2] & pad[1:-1, 2:]) out = rgba.copy() out[edge, :3] = (out[edge, :3].astype(np.float32) * strength).astype(np.uint8) return out if __name__ == '__main__': print('sprite_cycles.py is a helper module; run side_sprites.py (same folder) instead.')
-
-
README.md 9 KB
# scenario-side-view-game-kit: maintainer notes Not read at runtime. How the skill was built and why it makes its choices. ## Origin Distilled on 2026-09-30 from a browser side-scroller built over two days in one agent session: one hero, two ground enemies, five levels of one setting. Every generation ran through the Scenario MCP server (`recommend`, `model_schema_get`, `model_run` with `dry_run` and `wait: false`, `jobs_wait`, `upload_asset` and `upload_asset_complete`, `asset_download`); keying, loop search, pixelation, seamless tiles, prop cutting and WebP conversion ran as local Python. The sources were the build journal, the verbatim prompt record and the build's own processing scripts. The hero route is an earlier isometric sprite-cycle pipeline (two-pose still on flat magenta, reframe, image-to-video clips, loop-window search, one shared palette) adapted to a single side-view facing. The build used a text-to-image model for the stills, the pose sheet and the level layers; an image-to-video model with a last-frame input for the clips; and a pixel-art model for a superseded first pass. The skill names no model and discovers both lanes with `recommend` (`txt2img`, and `img2video` with `features: ["endImage"]` for pinned clips), because availability differs per team and these generations will be superseded. The record has no credit costs, which is why the body prices with `dry_run` instead of quoting a figure. ## Why audio was dropped The build also scored every level and gave each action a sound effect. One skill, one output type: the deliverables here are images (sprite strips and level layers; the clips are intermediates), so music and effects stay with `scenario-audio` and `scenario-elevenlabs`. The body says in one sentence that they are outside this skill without naming those siblings: a named sibling joins the context fan-out and the install commands, and the image workflow never calls them. The prompt templates and processing settings carry no audio sections for the same reason. ## Why the finish is local Keying, the loop search, the shared-canvas palette and the seamless cross-fade all need the pixels on disk, and the deliverables are game files, not platform assets. `scenario-sprite-animation` lists "assembling locally" as a mistake for sheets the platform can tile itself; this skill's local steps are the ones no MCP tool performs, so the body says why once and offers `upload_asset` into a collection for users who want the finished strips on the platform. ## Choices the build tested - Static poses (fall, wall cling, plunge, dash) come from one pose sheet split by connected components. Splitting by column runs failed: the poses overlap horizontally. - The first attack's third frame keyed a white slash trail into a solid block, and the heavy enemy's impact frame keyed its dust cloud into a blob; both were dropped after processing. `--drop` reproduces that, applied after the canvas and palette, which is what matches the shipped strips. - The heavy enemy's walk loop closes at 1.03x baseline. Widening the search range did not help; it was kept after review, which is why the body's threshold (0.6x) is a target, not a rejection rule. - The first armed-enemy walk swung its weapon mid-stride and could not loop (1.44x). Regenerated with a still-arms sentence and a lower guidance value, it closed at 0.42x. That pair became the "loop that will not close" mistake. - The first-pass environments were judged too retro after a playtest. The "richly detailed high-resolution pixel art" replacement was validated on level 1 before levels 2 to 5 were generated, which is the order the body teaches. - Texture renders asked to be "seamless tileable" did not reliably tile, hence the cross-fade in `process_env.py` rather than trusting the prompt. ## Scripts All four run in plain Python 3 with Pillow and NumPy; `side_sprites.py` also needs ffmpeg. - `reframe_stills.py`: from the isometric pipeline, pixels unchanged. The output names lost their isometric facings: it now writes `<name>_rest.png` (left pose, the first frame of every ground clip) and `<name>_action.png` (right pose: hero jump or enemy attack wind-up). The build ran its own copy with an auto split column and a death cycle; the shared copy reproduced all six of the build's first frames pixel for pixel, so those extras were not ported. - `sprite_cycles.py`: the isometric pipeline's processing core, now a helper module imported by `side_sprites.py` only. Its four-facing command line and anything only that command line used were removed. Two changes after the port's review: `pick` takes the cycle's mode and windows by it (it used to branch on the name `attack`, so a loop named `attack` got one-shot windowing), and `read_frames` stops on a clip more than 2% off square instead of squashing it, since the figure scale and feet anchor assume a square frame. Square clips process as before. - `side_sprites.py`: the build's one-facing wrapper, generalized. The hard-coded hero cycle table, pose names and file names became `--cycles`, `--pose-names` and `<name>_pose_*`; loop cycles other than walk, run and idle need a `--range`; `--drop` was added for the frames removed by hand; missing clips are an error instead of a silent skip; overwrites need `--force`; `--webp` writes lossless WebP; `sprite_cycles.py` is imported from the script's own folder. - `process_env.py`: the build's script, generalized. Hard-coded input and output folders and the fixed five-level loop became `--env`, `--out`, `--levels` and `--file KIND=PATH`; texture size, fade share, ledge height, prop count and height, alpha threshold and the three WebP qualities became flags; `--dry-run` and `--force` were added. The processing is unchanged. Comments, docstrings and help text were moved to American spelling and house style in the port; no processing changed. ## Script verification Run on 2026-09-30 (Windows, Python 3.14.2, NumPy 2.5.0, Pillow 12.2.0, ffmpeg 8.1.1) against the build's intermediate files, writing only to a temporary folder, with the scripts as they stood before the port's renames: - `--help` printed usage for every script. - `reframe_stills.py` on the hero still and both enemy stills: all six first frames pixel-identical to the build's. - `side_sprites.py` for the hero (eight cycles, the pose sheet, the jump still, `--drop atk1=2`), the heavy enemy (`--drop attack=5`) and the armed enemy (`--range walk=12:48`): all 19 strips and poses pixel-identical to the build's. Cells, anchors and fps matched the build's meta files: hero 98x81 anchored at 49,75; heavy enemy 137x123 at 68,111; armed enemy 116x93 at 58,92. Seams printed: hero run 0.32, idle 0.282, heavy-enemy walk 1.032 (flagged), armed-enemy walk 0.421, the first armed-enemy walk 1.445 (flagged). Without `--drop` the hero's first attack has 7 frames and the heavy enemy's attack 8, as recorded. - `side_sprites.py` refused to overwrite without `--force`, stopped on a missing clip, and asked for `--range` on an unknown loop cycle; `--webp` wrote lossless WebP. - `process_env.py` over five levels: all 35 outputs byte-identical to the build's WebP layers. A second run refused to overwrite; `--file tex=... --file props=...` wrote `env_tex_<stem>.webp` and `env_prop_<stem>_<k>.webp`; with neither `--env` nor `--file` it stopped with an error. On the five textures the mean wrap-edge difference after the cross-fade sat at the neighboring-pixel level. Apart from the two `sprite_cycles.py` fixes above, the port changes names and text only. `tests/scenario-side-view-game-kit/` runs on synthetic inputs: the build's own files are not in this repository, so the byte-level reproduction above is not re-run in CI. ## What the record does not cover - The worked example (a fox knight, a mole miner, two levels) is a recommended route built from the shipped build; the build made one hero, two ground enemies and five levels of one setting. - The pose-sheet prompt is recorded only as a description; `references/prompt-templates.md` gives a template in the style of the other prompts and says so. The attack, lunge, shot, hurt and spin motion sentences are abbreviated in the record; the templates keep their recorded opening and closing words. - The enemy stills are recorded only as "follow the hero still template (standing / attack wind-up)", and their attack and hurt prompts are not recorded. Their heights (78 and 66 px) and the drop indices come from the shipped strips. - How the two frames were removed from the attack strips is not recorded; the drop indices (hero first attack index 2, heavy-enemy attack index 5) were recovered by matching the shipped strips. - The ledge was requested at 1536x384 and came back 1536x512. The trim makes the output the same either way. - The pose-sheet scale (0.122) was set by eye; the skill says to tune it. - The first-pass pixel-art route (some enemies, pickups and icons that still ship) is mentioned in the record but not taught; its prompts are not recorded. - Game code (combat, wall runs, parallax, the ledge 3-slice draw) is out of scope beyond a few drawing rules; the skill stops at game-ready assets. -
SKILL.md 14 KB
--- name: scenario-side-view-game-kit description: "Use when making the art for a side-scroller or 2D platformer: side-view pixel-art sprite sheets for a hero and ground enemies (run, walk, idle, attack, hurt cycles, air spin, jump and fall poses) and per-level layers (parallax background and midground, seamless wall texture, platform strip, props). Also use when a magenta key eats part of a sprite, a walk loop will not close, a slash trail keys into a solid block, generated textures do not tile, or poses on one sheet will not split." license: MIT --- # Scenario Side-View Game Kit ## Overview A side-scroller needs two families of image assets no single generation gives: pixel sprites that animate cleanly on one feet line, and painted level layers that stack into parallax and repeat without seams. This route comes from a browser side-scroller made in one agent session (one hero, two ground enemies, five levels): 1. **Characters.** A text-to-image still with two poses on flat magenta (hero: rest and jump; enemy: rest and attack wind-up), reframed with headroom, then one 3 second image-to-video clip per cycle, the character running or walking "like a treadmill" in a pure side profile facing right. A local script keys the magenta, cuts a loop or one-shot window, and pixelates every cycle of a character onto one shared canvas and palette. Fall, wall cling, plunge and dash come from one pose sheet. Make one facing; the game mirrors it. 2. **Levels.** Per level, five renders in "richly detailed high-resolution pixel art" (background, midground, wall texture, platform strip, props), made into WebP layers locally. The finish is local on purpose: keying, the loop search, the shared palette and the seamless cross-fade need pixels on disk, and the deliverables are game files. If the user wants the finished strips on the platform too, `upload_asset` them into a collection. Connection, scope and the core loop: the `scenario` skill. Frame tools, GIF previews and engine slicing: `scenario-sprite-animation`. Pixel cleanup and other statics: `scenario-game-assets`. Clip model families: `scenario-video`. Music and sound effects are outside this image skill. If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap. The agent runs the scripts locally (Python 3, Pillow, NumPy; `side_sprites.py` also needs ffmpeg): [reframe_stills.py](scripts/reframe_stills.py) makes the clip first frames, [side_sprites.py](scripts/side_sprites.py) builds the strips and imports [sprite_cycles.py](scripts/sprite_cycles.py) (never run directly), and [process_env.py](scripts/process_env.py) finishes the level layers. Prompts with slots: [references/prompt-templates.md](references/prompt-templates.md). Read [references/processing-settings.md](references/processing-settings.md) before changing a script flag or when `--report` flags a loop. ## Quick reference | Step | How | Keep when | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | | Models | `recommend` with `capability: "txt2img"` (stills, pose sheet, layers) or `"img2video"` plus `features: ["endImage"]` (clips), the user's words as `prompt`; then `model_schema_get` | You know each schema's field names, enums and caps | | Reframe and upload | `reframe_stills.py`: figure 58% of a 960 square, feet at 82%; `upload_asset` + `upload_asset_complete` the rest frame, and the action frame for the hero's air spin | Headroom for a raised weapon or a somersault | | Pose sheet | Text to image, chosen still as reference, 4 poses in a row on magenta | Same design and palette as the strips | | Price and wait | `dry_run: true` per distinct payload, then `wait: false` in waves under the concurrency ceiling and `jobs_wait`, re-called with `pending_job_ids` until done | The user heard the total before spending | | Strips | `side_sprites.py --report`, then without `--report` | Loop seams (idle aside) under 0.6x baseline; no solid blocks | | Level layers | Five renders per level, level 1 first | Dark lower middle, empty midground center, props apart | | Level finish | `process_env.py --env env --out assets --levels N` | Texture tiles, ledge trims flat, prop count matches | | Deliver | Strips and pose images as lossless WebP plus `<name>_meta.json` (cell, feet anchor, fps); layers as lossy WebP | One anchor per character; nothing magenta at the edges | Payload names below (`startImage`, `endImage`, `referenceImages`, `background`, `quality`, `numOutputs`, `cfgScale`) are the build's schemas' examples: use the fields your pick's `model_schema_get` lists. The image pick needs strong layout adherence, a reference-image input, a transparent-background option and the template's 1536x1024; if its schema lacks one (a pixel-art specialty pick can cap far below that size), take the next ranked pick, even when `recommend` asks you to choose between such capped picks. ## Worked example A user asks: "Make the art for my side-scroller: a fox knight hero with a rapier, a mole miner enemy with a pickaxe, and two levels, a mushroom forest and a crystal mine." Pass `team_id` and `project_id` on every call (the `scenario` skill covers picking them). 1. **Hero still.** `recommend { "capability": "txt2img", "prompt": "<the user's request>; needs reference image input, transparent output" }`, `model_schema_get` the pick, price the [two-pose template](references/prompt-templates.md#1-two-pose-still-text-to-image) (left: standing ready facing right; right: mid-jump facing right) with `model_run { "model_id": "<pick>", "dry_run": true, "parameters": { "prompt": "...", "width": 1536, "height": 1024, "quality": "high", "background": "opaque", "numOutputs": 2 } }`. Tell the user the price, run (`jobs_wait` with its `job_id` if it returns in progress), `asset_display` both takes; keep one with both poses on model, in a pure side profile (every clip inherits a three-quarter body), and nothing pink or purple, `asset_download` it and `curl -L` it to `fox_two_pose.png`. 2. **First frames.** `python3 scripts/reframe_stills.py fox_two_pose.png --name fox --out frames` writes `frames/fox_rest.png` (left pose, the first frame of every ground clip) and `frames/fox_action.png` (the jump). Check both, then `upload_asset { "file_name": "fox_rest.png", "content_type": "image/png", "kind": "image", "file_size": <bytes> }`, PUT the parts, `upload_asset_complete { "upload_id": "<id>" }`; same for the action frame, which only the air spin uses. 3. **Hero clips.** `recommend { "capability": "img2video", "features": ["endImage"], "prompt": "<the user's request>; square clip, no audio" }`, then `model_schema_get`. One payload per cycle from the [motion templates](references/prompt-templates.md#2-motion-prompts-image-to-video). With the build's schema: ``` run: { "prompt": "<run sentence> <facing and tail>", "negativePrompt": "<negative>, slowing down, stopping, standing still", "startImage": "<fox_rest id>", "duration": "3", "aspectRatio": "1:1", "generateAudio": false } atk1: { "prompt": "<attack: a fast rapier thrust ... returns to the starting ready pose> <facing and tail>", "negativePrompt": "<negative>", "startImage": "<fox_rest id>", "endImage": "<fox_rest id>", "duration": "3", "aspectRatio": "1:1", "generateAudio": false } ``` Idle and hurt pin like the attack; the air spin uses the action id as both frames. Price each distinct payload with `dry_run: true` (first frame only, pinned, changed guidance), give the user the total, launch the clips with `"wait": false` in waves within the team's concurrency ceiling (the `parallel-custom-jobs` row in the `scenario` skill: a launch refused with that 429 created no job, so relaunch it in the next wave instead of dropping it), and collect them with one `jobs_wait { "job_ids": [...] }`, re-called with the returned `pending_job_ids` as `job_ids` while any is in progress (never poll `job_get`). `asset_download` each (omit `format` on video) and save as `clips/fox_<cycle>.mp4`, `<cycle>` being exactly the step 5 `--cycles` name (`run`, `idle`, `atk1`, `spin`, `hurt`). Review each as a local contact sheet (`ffmpeg -i clips/fox_run.mp4 -vf "fps=6,scale=240:-1,tile=6x3" fox_run_sheet.png`), since `asset_display` shows no frames of a video; re-run only one that turns toward or away from the camera or travels. 4. **Pose sheet.** Text to image with `referenceImages: ["<chosen still id>"]` and the [pose-sheet template](references/prompt-templates.md#3-static-pose-sheet) (fall, wall cling, plunge, dash). Price, tell the user, run, download as `fox_poses.png`. 5. **Strips.** ``` python3 scripts/side_sprites.py --clips clips --name fox --cycles run:loop:8,idle:loop:8,atk1:attack:7,spin:attack:8,hurt:attack:5 --report python3 scripts/side_sprites.py --clips clips --name fox --height 56 --palette 28 \ --cycles run:loop:8,idle:loop:8,atk1:attack:7,spin:attack:8,hurt:attack:5 \ --poses fox_poses.png --jump frames/fox_action.png --webp --out sprites ``` View the strips at 4x, nearest-neighbor. If the slash trail keyed into a white block, re-run with `--drop atk1=<index>` (0-based) and `--force`. 6. **Enemy.** Same route: two-pose still (rest, and an attack wind-up that keeps the design consistent), reframe, upload only `mole_rest.png`; walk with the first frame only, the [armed-enemy walk wording](references/prompt-templates.md#armed-enemy-walk) and a lower guidance such as `cfgScale: 0.7` if the schema has one; attack and hurt pinned. `side_sprites.py --clips clips --name mole --height 72 --cycles walk:loop:8,attack:attack:8,hurt:attack:5 --report`; a walk flagged as a weak loop gets `--range walk=12:48` before any re-run. 7. **Level layers.** Fill the five [environment templates](references/prompt-templates.md#4-environment-layer-set) for the mushroom forest (opaque background and texture, the rest transparent). `dry_run` each distinct setting, tell the user the total, run, download as `env/bg1.png`, `env/mid1.png`, `env/tex1.png`, `env/ledge1.png`, `env/props1.png`, then `python3 scripts/process_env.py --env env --out assets --levels 1`. Put level 1 in the game with the fox on it before generating the crystal mine; process that one alone with `--levels 2,` (a bare `2` is a count and redoes level 1), `--force` after a re-render. 8. **Check.** Play each strip at its fps from `<name>_meta.json`, tile the texture across a wall, and draw a long and a short platform. ## Common mistakes - **An end frame on run or walk.** Pinning the still at both ends of short locomotion spends the clip speeding up and slowing down. First frame only for run and walk; pin first = last for idle and every one-shot so they return to rest. - **An armed enemy's walk using its weapon.** The build's first armed walk swung its weapon mid-stride and closed at 1.44x baseline. "His arms stay still ... never lifts, swings or twirls it", the weapon terms in the negative and a guidance of 0.7 closed it at 0.42x. - **Keeping a frame where an effect keyed into a block.** A white slash trail and an impact dust cloud came out of the key as solid blobs; `--drop` removes the sampled frame without changing the shared canvas or palette. - **Paying to regenerate every weak loop.** A heavy walk closed at 1.03x, a wider range found the same window, and it shipped after review. Widen the range, look at the loop, and pay only when it visibly hitches. - **Splitting a pose sheet by columns.** Poses overlap horizontally; split by connected components (`--poses` does) and ask for wide gaps. - **Trusting "seamless tileable" in a texture prompt.** The renders do not tile reliably; `process_env.py` cross-fades opposite edges instead of a re-render. - **Stretching a platform strip.** Draw left cap + repeated middle + right cap so every length keeps the carved ends. - **Retro-scale level art.** Small retro paintings failed the first playtest; 1920x1152 detailed layers drawn at 2x replaced them while sprites kept nearest-neighbor scaling. Validate level 1 in the game before the rest. - **Pink or purple on a character.** Everything strongly magenta is keyed away; say "no pink, no purple, no magenta anywhere on the character" and reject takes that show any. A fast weapon swing can pick up a purple motion-blur tint and key away mid-strike, which `--drop` cannot fix since it would remove the strike itself: reject that take, or re-run with "no motion blur, the weapon keeps its colors" in the prompt and "purple or magenta tint on the weapon" in the negative (in one validation run this cleared the tint, and one impact frame still needed `--drop`). - **Rewording a blocked combat clip.** A thrust or pickaxe swing can come back `moderation_blocked`, and the filter is the provider's: read the error, then run the unchanged payload on a pick from another provider (same `recommend`, `endImage` feature kept) per `scenario-moderation` before softening the beat. - **Lossy sprite strips.** Lossy WebP shifts the shared palette; only the HD layers ship lossy.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.