Claude Skill

scenario-blender-2d-animation

Use when a 2D side-scroller shot, platformer cutscene, game trailer or parallax animation has to be built in Blender from Scenario-generated pictures (flat image cards, an orthographic camera, keyed jumps, rigid-body props), or when a finished shot needs a new theme or look witho

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download scenario-labs-skills-skills_scenario-blender-2d-animation-dc592af.zip · 35 KB
Part of scenario-labs/skills — 97 skills

Install

skills CLI npx skills add https://github.com/scenario-labs/skills/tree/main/skills/scenario-blender-2d-animation
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
Git 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-blender-2d-animation: maintainer notes

Not read at runtime. How the skill was built and why it makes its choices.

Origin

Distilled from the Blender 2D animation exploration on scenario.com: a 7.7 second side-scroller shot in three looks (a red panda in a golden-hour jungle, a black cat on a moonlit Halloween street, a snow leopard cub in a winter valley), ten Scenario generations per look, built and rendered by one Blender script. The page offers the skill as a download; this directory is that skill brought to the repository's authoring contract.

The exploration used one text-to-image model for every picture and one image-to-video model at 768p for the run clips. The body names neither and discovers both lanes with recommend (txt2img, img2video), because availability differs per team and both generations will be superseded. The page's credit figures are left out for the same reason: the body prices with dry_run.

Why a core skill, not a Blender expert tool

Every paid step (ten generations per look) is a Scenario MCP call; Blender only lays out and renders, locally. The expert tier exempts its skills from the MCP rules, which are exactly what this skill must be graded on, and the Blender family's specialists share the lead's toolkit and provenance, neither of which applies here. scenario-patina-retexture set the precedent: MCP generation, then bundled Blender scripts run headless. The name keeps the download's, so the exploration page and the repository agree.

Changes from the download

  • Description cut to triggers only; the body names no generative model; discovery moved from a newest-first search to recommend; payload field names hedged as schema examples.
  • scenario-side-view-game-kit and scenario-sprite-animation named as the sprite-strip alternatives. The download's other related skills are not named: the model-family ones point at a model, and the workflow hands nothing to the rest, whose bodies would only push the fan-out past the re-attach pool.
  • build_scene.py --live used to empty whatever file was open. It now refuses unless the file is new (nothing beyond the startup cube, light and camera) or the script's own output. bpy.data.is_dirty cannot make that call: Blender reports a new file as dirty.
  • The jump still's scale was hard-coded as 768 / 960 (the clip's height over the reframed canvas), which is wrong for any video model that answers at another resolution. run_loop.py now prints run_canvas_px and build_scene.py reads it with the jump still's own height.
  • The common mistakes that the scripts now prevent moved out of the body; references/pipeline.md already records them for anyone changing the scene script.

Scripts

reframe_stills.py is a byte-for-byte copy of the one in scenario-side-view-game-kit, kept so each skill installs alone; tests/scenario-blender-2d-animation/test_reframe_stills.py fails when the two drift, so a fix to one reaches the other. run_loop.py shares that kit's loop-window idea (seam over in-window motion) but adds the border key and writes a Blender image sequence instead of a pixel strip.

The suite runs the five helpers on synthetic inputs. test_build_scene.py builds, bakes and saves a synthetic set in a real Blender and is skipped unless BLENDER names the binary or blender is on the PATH, which CI does not provide; it passed on Blender 5.0.1.

Skill manifest

Scenario Blender 2D animation

Overview

Generate the art once per look; animate it once in Blender. A concept frame (an imagined in-game screenshot) fixes the style, and the eight other pictures (sky, far and mid layers, ground strip, foreground plants, props sheet, tree, the hero in two poses) take it as their reference, so the set holds together. An image-to-video clip of the hero running in place on magenta becomes a looping image sequence. One Blender script lays the pictures out as flat cards in depth, drives their parallax from an orthographic camera that follows the hero, keys the jumps and runs rigid-body physics on the props. A new look is ten new generations and a few measured numbers. The reference result is a 7.7 second, 1080p, 24 fps shot in three looks. The deliverable is the film and its .blend; for sprite strips a game engine plays, use scenario-side-view-game-kit or scenario-sprite-animation.

Discover both lanes with recommend, the user's words as prompt: capability: "txt2img" for the pictures, which need a reference-image input, a transparent background and exact width and height (if the pick lacks one, or is a specialty for another style than the look, such as pixel art for a painted cartoon, take the next ranked pick), and capability: "img2video" for the run clip, which needs a first frame. model_schema_get each pick. Payload names below (referenceImages, background, numOutputs) are examples: use the fields your pick's schema lists. Before generating, tell the user the total for the nine pictures and the clip: dry_run the concept frame and quote the rest from recommend, since a dry_run naming an asset that does not exist yet returns 404. Then dry_run each later payload once its input exists, before running it.

Connection, uploads and the core loop: the scenario 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 scripts run locally and never call Scenario. Blender 5.x runs the scene script, headless or in an open session (through a Blender MCP connection, for example); in a session, start from File > New, since --live empties the open file and refuses any file but a new one or its own output. Python 3 with Pillow, NumPy and SciPy runs the helpers, and ffmpeg reads the clip. Every helper prints its usage when run without arguments and refuses to overwrite an existing output (or its input) unless given --force.

Quick reference

Step How
Pick models recommend (txt2img, img2video), model_schema_get, then model_run with dry_run: true per distinct payload
Concept frame Text to image, 16:9, opaque, two takes: an in-game screenshot, strict side view, layers named in order (prompts)
Layers One call per layer with the concept as reference; transparent except the sky; wide (3072 x 1152) for tiled layers
Sheets Props, tree and foreground on transparent backgrounds with wide gaps, numbered in reading order
Hero Two poses (rest, jump) side by side on flat magenta; reframe_stills.py makes square first frames
Run clip Image to video from the rest pose: "like a treadmill", locked camera, magenta that never changes; 5 s is enough
Run loop run_loop.py: key, find the cleanest one-period loop, write hero_run/run_001.png...
Cut and key prep_assets.py cut (sheets), key (magenta stills), tile (seamless wide layers)
Hulls trace_hulls.py: convex hull of each prop's alpha into shapes.json
Measure measure_set.py: horizon, ground line, island and pedestal tops (example config)
Build build_scene.py -- --set SET_DIR --review, then --final
New look New concept frame, the same eight pictures and clip, same file names, a new config.json; nothing else changes
Wait jobs_wait with up to 32 job_ids; on timeout re-call with the returned pending_job_ids

Worked example: a jungle run, then a snow day

Pass team_id and project_id on every call (the scenario skill covers picking them).

  1. Concept frame. recommend { "capability": "txt2img", "prompt": "<the user's request>; needs reference image input, transparent background, exact width and height" }, model_schema_get the pick, price two takes with model_run { "model_id": "<pick>", "dry_run": true, "parameters": { "prompt": "...", "width": 1536, "height": 864, "background": "opaque", "numOutputs": 2 } } and the screenshot prompt from references/prompts.md section 1. Run it, jobs_wait if it returns a job_id, asset_display both takes and keep the one whose layers you can name one by one. Its asset id is the reference for everything that follows.
  2. Layers, one call each. The same pick with referenceImages: ["<concept id>"]: the sky (2560 x 1440, opaque), the far layer and the mid layer (3072 x 1152, transparent), the ground strip (2048 x 768, transparent, a flat walkable top at about 30 percent from the top, no end caps) and a foreground sheet of five plant clusters (3072 x 1024, transparent). Price them, then run them 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), retiring each wave with jobs_wait.
  3. Props and tree. A props sheet (2048 x 1536, transparent): numbered props in a loose grid with wide gaps (a pot, a tall vessel, a lidded jar, a heavy statue, the fruit that will fall, a coin, a floating island, a flat pedestal), and the tree alone (1024 x 1536) with no fruit in its crown.
  4. Hero. Two poses on flat magenta (1536 x 1024, opaque, two takes): rest on the left, jump on the right, both the same size, pure side profile facing right. asset_download the kept take, then python3 scripts/reframe_stills.py hero.png --name hero --out frames writes hero_rest.png and hero_action.png (lower --figure for a long tail). Upload the rest frame with upload_asset and upload_asset_complete.
  5. Run clip. recommend { "capability": "img2video", "prompt": "<the user's request>; first frame input, 5 seconds" }, model_schema_get, price a five-second clip with the rest frame's asset id in the schema's first-frame field and the treadmill prompt (prompts.md section 5), with audio off and a 1:1 aspect ratio where the schema has those fields (the loop keeps only silent frames, and the jump still's scale assumes the clip is the square first frame resized), run it, jobs_wait (minutes, re-called as needed), asset_download.
  6. Process. Into jungle/game/ (the sky stays one wide card: copy it as sky.png): python3 scripts/prep_assets.py tile raw/sea.png jungle/game/sea_tile.png 384, the same for the mid layer (jungle_tile.png; a fade of one eighth of the width) and the ground (ground_tile.png, 256 for 2048 px); python3 scripts/prep_assets.py cut raw/props.png jungle/game pot_round,amphora,jar,idol,coconut,coin,island,pedestal; CUT_GROW=2 python3 scripts/prep_assets.py cut raw/foreground.png jungle/game fg_bush,fg_fern,fg_hibiscus,fg_boulder,fg_curl; python3 scripts/prep_assets.py key frames/hero_action.png jungle/game/hero_jump.png; python3 scripts/run_loop.py clips/hero_run.mp4 jungle/game/hero_run (it prints run_n, run_fig_px, run_pad_px and run_canvas_px, the clip's height); python3 scripts/trace_hulls.py jungle/game. Copy the tree (keyed or already transparent) as palm.png.
  7. Measure. python3 scripts/measure_set.py jungle/game --ground-h 4.3 (the ground card's height in meters, ground_h in the config) prints the horizon, the walkable line (already moved 10 cm down so the feet sink into the grass lip), the island and pedestal tops and the jump still's feet row. Write them with the run numbers into jungle/game/config.json. Layer heights, the fruit positions and the hero's height in meters are set by eye on the review stills: start from examples/config.json (references/pipeline.md lists every key).
  8. Build and review. blender -b --factory-startup --python-exit-code 1 -P scripts/build_scene.py -- --set jungle --review. Outputs land in the folder holding jungle/ (or --out DIR): platformer_scene<tag>.blend, review<tag>/ and, with --final, platformer<tag>.mp4; a rebuild over them needs --force. Look at the 11 review stills: feet on the ground, the tree standing on the ground, no seam, the tower standing until the hero reaches it, every piece inside the frame. Then --final renders the film. Before delivery, walk references/checks.md.
  9. A new look. Generate a new concept frame (a white winter valley, a snow leopard cub in a red hat), the same eight pictures and clip with it as reference, process into snow/game/ with the same names, measure a new config.json (with "tag": "_snow") and run the same build with --set snow. Two looks line up frame for frame. The snow clip's background drifted, so its loop came from run_loop.py clip.mp4 snow/game/hero_run --key border --from 60 --to 124 --skip 0 --periods 8:16 --clean.
  10. Ledger. One line per Scenario job with model, parameters, inputs, outputs, job id, cost, kept or not, and why.

Common mistakes

  • Trusting the grass tips as the ground line: every look's hero floated 3 to 8 cm plus the run bob until the line moved about 10 cm into the lip, which measure_set.py now applies.
  • Keying a drifting clip on fixed magenta: one run clip went from magenta through red and yellow to green. Use --key border and take the loop from a stretch away from any red or pink on the character.
  • Cutting leafy sheets with the default grow: the five foreground plants came out as four pieces until the cut ran with CUT_GROW=2. Check the printed count against the names.
  • Not checking enclosed pink after keying: prep_assets.py key keeps pink enclosed by the silhouette (a cat's inner ears), but look at ears, tongues and flowers.
  • Hard-coding the run length: the cat and the snow leopard looped in 12 frames, the red panda in 9. Copy run_n from run_loop.py's output.
  • A landmark at the end of a wide layer: the tile seam goes where the silhouette is lowest, so ask for content spread across the width.
  • Passing a local path or URL to model_run: upload the file first and pass the asset id.
  • Hard-coding model ids or prices, or polling job_get instead of re-calling jobs_wait.

Changing build_scene.py itself (depths, parallax factors, physics): read references/pipeline.md first, which records the decisions the reference shot paid for.

Files (skills)
  • examples
    • config.json 423 B
      {
        "tag": "_snow",
        "sea_h": 10.0,
        "sea_horizon": 0.469,
        "sea_base_z": 3.0,
        "jungle_h": 6.0,
        "jungle_z0": -1.4,
        "ground_h": 5.5,
        "ground_top": 0.613,
        "island_top_px": 27,
        "pedestal_top_px": 43,
        "nut_size": 0.42,
        "hero_h": 1.6,
        "run_fig_px": 384,
        "run_pad_px": 8,
        "jump_base_px": 787,
        "run_n": 12,
        "run_canvas_px": 768,
        "nuts": [
          [-0.44, 3.44],
          [0.54, 3.21],
          [0.84, 2.77]
        ]
      }
      
  • references
    • checks.md 2.6 KB
      # Checks
      
      Run these before calling a look finished. The problems named here are the ones the reference shot ran into.
      
      ## Pictures (before any Blender work)
      
      - The concept frame is a strict side view with a flat walkable ground line. Two takes; keep the one whose layers
        you can name one by one.
      - Every layer except the sky has real transparency above its content: check it on a checkerboard.
      - The far and mid layers reach the bottom edge of the image, so the ground strip always covers their lower end.
      - The ground strip's top edge is flat over the whole width. Its height in the image, as a share from the top,
        becomes `ground_top` in `config.json`.
      - Props and foreground sheets: every piece is separated by clear gaps. `prep_assets.py cut` prints one line per
        sprite; a count that does not match the names means two pieces touched or a speck survived.
      - The hero sheet: both poses the same size, side profile facing right, no magenta on the character itself.
      
      ## Run cycle
      
      - The kept loop is one stride period (9 to 12 frames at 24 fps for a small cartoon animal), and its last frame flows
        into the first. Play the frames as a GIF before building.
      - Key the background on each frame's own border color if the clip's background drifts; a fixed magenta key leaves
        colored halos on the frames where it changed.
      - No colored fringe on the outline (check over black and over white), and red or pink parts of the character
        (a scarf, a hat, a tongue) are intact.
      - The run frame's feet line matches the rest pose's: measure `run_fig_px` (figure height in the run frames) and
        `run_pad_px` (empty rows under the feet) and write them into `config.json`.
      
      ## Scene (`build_scene.py --review`)
      
      - The hero's feet sit slightly into the ground lip, not on the grass tips: the reference shot moved `ground_top`
        down by about 10 cm per look after the heroes looked like they floated.
      - The tree stands on the ground: its image can have empty rows under the trunk, which the script reads from
        `shapes.json` (`bbox`) to offset it.
      - The tiled layers show no seam and no repeated landmark cut in half while the camera pans.
      - Physics: the tower stands still until the hero reaches it, the pieces fall inside the picture plane, nothing
        shoots off screen, and the fruit drops when the hero runs under the tree. The script prints `HIT`, `EVENTS` and
        the `FINAL` resting place of every piece; compare them between looks.
      - The baked cache is saved in the .blend, so any frame can be opened directly.
      
      ## Film
      
      - 1920 x 1080, 24 fps, 184 frames (7.7 s) per look, EEVEE.
      - Frame 1 is a full, lit frame (never black).
      - The same frame of two looks lines up when overlaid: only the pictures differ.
      
    • pipeline.md 7 KB
      # Pipeline: the decisions that mattered
      
      The reference shot is a 7.7-second side-scroller take: a hero runs right, jumps onto a floating island for three
      coins, drops back down, runs under a tree that drops its fruit, and jumps into a stacked tower that collapses with
      real physics. It exists in three looks (jungle, Halloween street, snow valley) built by one script.
      
      ## Flat cards in depth, one orthographic camera
      
      Every image is a flat plane in Blender's XZ plane, facing the camera (emission shader mixed with a transparent BSDF
      through the image's alpha, blended render method, so nothing is lit or shaded twice). The camera is orthographic
      (`ortho_scale` 16 for a 16 m wide view), looking along +Y, and follows the hero with a little lag:
      
      | Layer                                      | Depth (Y) | Follows the camera by  | What it means                                   |
      | ------------------------------------------ | --------- | ---------------------- | ----------------------------------------------- |
      | Sky                                        | 60        | 0.97                   | almost glued to the camera: infinitely far      |
      | Far layer                                  | 50        | 0.85                   | tiled; slides slowly                            |
      | Mid layer                                  | 40        | 0.6                    | tiled                                           |
      | Ground                                     | 8         | 0 (fixed in the world) | tiled; the walkable line                        |
      | Tree, island, pedestal, coins, hero, props | 0 to 9    | 0                      | the action plane                                |
      | Foreground                                 | -10       | -0.45                  | moves against the camera: faster than the world |
      
      With an orthographic camera depth alone never moves a layer: the parallax comes from a driver on each layer's X
      location (`c * k`, where `c` is the camera's X). The Y depths only set the draw order and look right when the scene
      is orbited for a breakdown.
      
      ## The numbers that change per look
      
      Everything in `build_scene.py` is shared except the pictures and a short `config.json` (see
      [examples/config.json](../examples/config.json)). Any key it leaves out keeps the script's default:
      
      | Key                                                                            | Meaning                                                                                                                                                                                                                   |
      | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | `tag`                                                                          | suffix of the output files (`_snow` gives `platformer_scene_snow.blend`)                                                                                                                                                  |
      | `sea_h`, `sea_horizon`, `sea_base_z`                                           | far layer height in m, its horizon row as a share from the top, where the horizon sits                                                                                                                                    |
      | `jungle_h`, `jungle_z0`                                                        | mid layer height in m and its bottom                                                                                                                                                                                      |
      | `ground_h`, `ground_top`                                                       | ground strip height in m, and its walkable line as a share from the top                                                                                                                                                   |
      | `island_top_px`, `pedestal_top_px`                                             | pixel row of the walkable top in the island and pedestal sprites                                                                                                                                                          |
      | `palm_h`, `nuts`, `nut_size`                                                   | tree height, where each fruit hangs (x, z offsets from the tree), fruit size                                                                                                                                              |
      | `hero_h`, `run_fig_px`, `run_pad_px`, `run_canvas_px`, `jump_base_px`, `run_n` | hero height in m, the figure's height and the empty rows under the feet in the run frames, the clip's height (it sets the jump still's scale against the run frames), the feet row of the jump still, the run frame count |
      
      `ground_top` deserves care: putting the feet exactly on the grass tips makes the hero float. Set it so the feet sink
      about 10 cm into the lip.
      
      ## Hero: an image sequence and one still
      
      The run is an image sequence on one plane (`hero_run/run_001.png` and on, cyclic, `run_n` frames). Blender reports
      0 x 0 for a sequence until it plays, so the script measures the first frame. In the air the plane swaps to the jump
      still. Squash and stretch are three keyed scales per jump (anticipation, launch, landing). A soft blob shadow follows
      the hero and shrinks with height.
      
      The hero's speed (0.2 m per frame) is matched to the run cycle's stride, so the feet do not slide.
      
      ## Physics in a picture plane
      
      - Every prop is a rigid body whose collision shape is its sprite's own outline: a convex hull traced from the alpha
        (`shapes.json`, normalized 0 to 1 per sprite), extruded through the plane, with the origin at the hull's centroid
        and the mass from its area, so a round pot rolls like a round pot.
      - Two frictionless invisible walls per lane keep bodies in the picture plane (Blender has no 2D lock).
      - The tower starts deactivated, so it stands until touched. The fruit are kinematic in the tree crown and released
        one by one when the hero passes.
      - The hero's collider is a kinematic sphere that lives outside the props lane and slides in sideways just before the
        tower jump, then out once nothing is within reach. A collider that teleports into place flings pieces.
      - `transform_apply` on collision meshes moved origins in the reference shot; build hull meshes directly at their
        centroid instead.
      - The simulation is baked and saved with the file, so any frame can be opened or rendered out of order.
      
      ## A new look
      
      1. A new concept frame, then the same nine roles with it as the reference ([prompts.md](prompts.md)).
      2. Process into `SET_DIR/game/` with the same file names.
      3. Measure the per-look numbers into `SET_DIR/game/config.json`.
      4. `build_scene.py -- --set SET_DIR --review`, check, then `--final`.
      
      Nothing in the animation, camera or physics changes, so two looks line up frame for frame and can be compared with a
      split screen.
      
    • prompts.md 7 KB
      # Prompt patterns
      
      Ten generations make one look: a concept frame, eight pictures that take the concept as their reference image, and
      a run clip whose first frame is the hero's reframed rest pose, never the concept. The patterns below are the ones that worked for the three looks of the reference shot (a red panda in a
      golden-hour jungle, a black cat on a moonlit Halloween street, a snow leopard cub in a white winter valley).
      Replace the bracketed parts; keep the structure and the "no ..." lists, they carry most of the work.
      
      ## 1. Concept frame (text to image, opaque, 16:9, two takes)
      
      The one picture that locks the look. Write it as a screenshot of the finished game, not as an illustration.
      
      > In-game screenshot of a premium polished 2D side-scrolling mobile platformer, the art quality and finish of a
      > top-grossing casual mobile game: bold clean cartoon shapes, glossy soft painterly shading, thick dark outlines on
      > characters and props, [palette and light].
      >
      > Scene, strictly side view, layered for parallax: [sky]; [far layer, e.g. a calm sea with distant islands on the
      > horizon]; [mid layer, e.g. jungle hills with ruins]; a ground strip of [material] with a clear flat walkable top
      > edge running across the lower quarter; [foreground plants] in the foreground corners.
      >
      > Gameplay elements: [hero: species, size, two or three memorable details, a small accessory], running to the
      > right; a floating [platform] with three [coins] above it; [a tree that will drop things]; on the right, a small
      > tower stacked from [props that belong to this world].
      >
      > No text, no UI, no logo, no HUD.
      
      Name the layers in the order the scene will stack them. "Strictly side view" and "flat walkable top edge" are what
      make the frame usable as a game camera. Name the props you want, and say which materials you do not want if a
      generic tower would read as some other game.
      
      ## 2. Layers (image edit with the concept as reference, one layer per call)
      
      Every layer prompt starts the same way and differs only in what it keeps:
      
      > [Role] layer for a 2D side-scrolling mobile platformer, in exactly the same art style, colors and [light] as the
      > reference screenshot, on a fully transparent background. [What is in it, spread across the whole width].
      > [Where it sits in the image]. Everything above [its top] is completely transparent: no [the layers behind it].
      > No [the layers in front of it], no characters, no text. Wide panoramic layer, strictly side view.
      
      | Role       | Background  | Size (px)   | What makes it work                                                                                                      |
      | ---------- | ----------- | ----------- | ----------------------------------------------------------------------------------------------------------------------- |
      | Sky        | opaque      | 2560 x 1440 | "ONLY THE SKY"; no land, sea or trees; the sun placed where the light comes from                                        |
      | Far layer  | transparent | 3072 x 1152 | the horizon line "at about the middle of the image"; content "continues down to the bottom edge"                        |
      | Mid layer  | transparent | 3072 x 1152 | "fills the bottom 55% solidly from edge to edge"; "slightly hazy and softer" to read as far                             |
      | Ground     | transparent | 2048 x 768  | "a perfectly flat, level walkable top edge ... at about 30% from the top"; "no rounded end caps, so it can be repeated" |
      | Foreground | transparent | 3072 x 1024 | five numbered clusters "each standing on its own base, with wide empty transparent gaps"                                |
      
      Wide layers are tiled later ([prep_assets.py](../scripts/prep_assets.py) `tile`), so ask for content "spread across
      the whole width" and keep any single landmark (a big arch, a castle) away from the ends.
      
      ## 3. Props sheet and the tree (image edit, transparent)
      
      > Game props sprite sheet for a 2D side-scrolling mobile physics platformer set in [world], in exactly the same art
      > style, colors and lighting as the reference screenshot, on a fully transparent background. Every prop is drawn
      > flat, strictly side view with no perspective and no shadow, each one separate with wide empty transparent gaps
      > between them so they can be cut apart, arranged in a loose grid: 1) [pot]; 2) [tall vessel]; 3) [short jar with a
      > flat lid]; 4) [small heavy statue]; 5) [round fruit that will fall]; 6) [coin with an emblem], seen face on; 7) a
      > floating chunk of [island], flat walkable top, about three times wider than tall; 8) a flat [pedestal], wide and
      > short. No characters, no ground, no text.
      
      The numbered order is the reading order [prep_assets.py](../scripts/prep_assets.py) `cut` uses for the names.
      Things that will fall from the tree are their own props: draw the tree separately and say "NO [fruit] in the crown".
      
      ## 4. Hero, two poses (image edit, opaque magenta, two takes)
      
      > Game character 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: the [hero] from the reference
      > screenshot, in exactly the same polished cartoon style: [details]. Camera: classic 2D side-scrolling platformer,
      > pure side profile view facing right, no perspective. Left pose: standing ready to run, facing right, weight slightly
      > forward, knees slightly bent. Right pose: the same character mid-air in a jump, facing right, knees tucked up, arms
      > raised. Both figures the same size, each about 60% of the image height, generous empty space around each. No pink,
      > no purple, no magenta anywhere on the character.
      
      The magenta field is for keying; a character with pink or red parts (a scarf, a hat) needs the "no magenta" line and
      care when keying. The left pose becomes the first frame of the run clip
      ([reframe_stills.py](../scripts/reframe_stills.py)).
      
      ## 5. Run cycle (image to video, first frame = the reframed left pose)
      
      > 2D cartoon game sprite animation, like a treadmill: the [hero] immediately starts running in place and keeps
      > running for the whole clip, a steady fast repeating run cycle: [how this body runs: knees high and arms pumping,
      > or a four-legged gallop], both feet leave the ground between steps, body bobbing slightly, [tail, accessory]
      > bouncing. [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, no zoom, flat solid magenta background that never changes, no ground, no ground shadow, same
      > character design, colors and clean cartoon outlines as the first frame, no motion blur.
      
      "Like a treadmill", "stays centered", "locked static camera" and "background that never changes" each closed a
      failure. A five-second clip is enough: only one clean period (9 to 12 frames at 24 fps) is kept.
      
      ## A new look
      
      Same ten roles, same order, a new concept frame. Keep the layer sizes, the hero's share of the frame and the
      ground's walkable line close to the first look, so the measured numbers in `config.json` change little.
      
  • scripts
    • build_scene.py 27.9 KB
      """Side-scrolling platformer shot built in Blender from Scenario-generated assets.
      
      Every image comes from Scenario; this script only lays them out and animates:
      - 5 parallax planes (sky, sea, jungle, ground, foreground plants) driven by the camera
      - hero: a 9-frame run cycle (image sequence from a Scenario video) and a jump pose, one image
        per frame (no body parts), with squash and stretch on take-off and landing
      - a floating island with 3 paw coins to collect
      - coconuts that drop from the palm (rigid bodies released at a frame)
      - a tower of clay pots and a golden idol on a temple pedestal that the hero smashes
        (rigid body physics, constrained to the 2D plane)
      
      Usage (headless):
        blender -b --factory-startup --python-exit-code 1 -P build_scene.py -- --set SET_DIR [--out DIR] [--review] [--final] [--force]
      Inside an open Blender (Python console, or a Blender MCP execute_python call), where a full file reset may be blocked,
      from a new file (File > New) or this script's own output, since --live empties the open file and refuses any other:
        import sys; sys.argv = ["blender", "--", "--set", "/abs/SET_DIR", "--live"]; exec(open("/abs/build_scene.py").read())
      
        --set SET_DIR  the art set: a folder holding game/ (the processed sprites, fixed file names, see below)
        --out DIR      where the .blend, the review stills and the film go (default: the folder that holds SET_DIR)
        --review       render 11 review stills at 40 percent to DIR/review<tag>/
        --final        render the 1920x1080, 24 fps film to DIR/platformer<tag>.mp4
        --live         build in the open file (its objects and data are removed first) instead of a factory reset;
                       refused unless the open file is new or is this script's own output
        --force        overwrite an existing .blend or film
      
      Art sets: the animation, camera, timing and physics are the same for every set; only the pictures change.
      SET_DIR/game/ holds sky.png, sea_tile.png, jungle_tile.png, ground_tile.png, fg_bush.png, fg_fern.png,
      fg_hibiscus.png, fg_boulder.png, fg_curl.png, island.png, palm.png, pedestal.png, coin.png, jar.png,
      pot_round.png, idol.png, amphora.png, coconut.png, hero_jump.png, hero_run/run_001.png..., shapes.json (from
      trace_hulls.py) and config.json, which holds the few numbers measured from its pictures (horizon and ground
      lines, the island and pedestal tops, the hero's size); any number it leaves out keeps the default below.
      """
      import bpy, math, os, sys
      from mathutils import Vector
      
      import json
      ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
      
      
      def usage(msg):
          print(__doc__)
          print("error:", msg)
          sys.exit(1)
      
      
      if "--set" not in ARGS or ARGS.index("--set") + 1 >= len(ARGS):
          usage("pass --set SET_DIR")
      SET = os.path.abspath(ARGS[ARGS.index("--set") + 1])
      A = os.path.join(SET, "game")
      if not os.path.isfile(os.path.join(A, "shapes.json")):
          usage(f"{A}/shapes.json not found (run trace_hulls.py on the props first)")
      ROOT = os.path.abspath(ARGS[ARGS.index("--out") + 1]) if "--out" in ARGS else os.path.dirname(SET)
      os.makedirs(ROOT, exist_ok=True)
      with open(os.path.join(A, "shapes.json"), encoding="utf-8") as f:
          SHAPES = json.load(f)
      CFG = {  # the jungle set's numbers; a set's game/config.json overrides any of them
          "tag": "", "sea_h": 6.0, "sea_horizon": 0.585, "sea_base_z": 2.1, "jungle_h": 7.0, "jungle_z0": -1.6,
          "ground_h": 4.3, "ground_top": 0.501, "island_top_px": 33, "pedestal_top_px": 48, "palm_h": 7.4,
          "nuts": [[-0.35, 6.25], [0.25, 6.15], [0.05, 6.45]], "nut_size": 0.5,
          "hero_h": 1.62, "run_fig_px": 483, "run_pad_px": 8, "jump_base_px": 785, "run_n": 9, "run_canvas_px": 768,
      }
      if os.path.exists(os.path.join(A, "config.json")):
          with open(os.path.join(A, "config.json"), encoding="utf-8") as f:
              CFG.update(json.load(f))
      END, FPS = 184, 24
      out_blend = os.path.join(ROOT, f"platformer_scene{CFG['tag']}.blend")
      out_film = os.path.join(ROOT, f"platformer{CFG['tag']}.mp4")
      for path in (out_blend, out_film if "--final" in ARGS else None):
          if path and os.path.exists(path) and "--force" not in ARGS:
              usage(f"{path} exists (pass --force to replace it)")
      
      if "--live" in ARGS:
          # an open session: empty the current file instead of a factory reset, but never the user's own work. Only a
          # new file (nothing beyond the startup cube, light and camera) or this script's own output qualifies;
          # bpy.data.is_dirty cannot tell, since a new file already reports unsaved changes
          if bpy.data.filepath:
              own = os.path.abspath(bpy.data.filepath) == out_blend
          else:
              own = not {o.name for o in bpy.data.objects} - {"Cube", "Light", "Camera"}
          if not own:
              usage("--live empties the open file: save your work, then start from File > New")
          sc = bpy.context.scene
          if sc.rigidbody_world:
              bpy.ops.rigidbody.world_remove()
          for data in (bpy.data.objects, bpy.data.meshes, bpy.data.materials, bpy.data.images, bpy.data.cameras,
                       bpy.data.lights, bpy.data.worlds, bpy.data.actions):
              for block in list(data):
                  data.remove(block)
          for coll in list(bpy.data.collections):
              bpy.data.collections.remove(coll)
      else:
          bpy.ops.wm.read_factory_settings(use_empty=True)
      sc = bpy.context.scene
      sc.name = "Platformer"
      sc.frame_start, sc.frame_end = 1, END
      r = sc.render
      r.engine = "BLENDER_EEVEE"
      r.resolution_x, r.resolution_y, r.fps = 1920, 1080, FPS
      sc.view_settings.view_transform = "Standard"
      sc.world = bpy.data.worlds.new("World")
      sc.world.color = (0, 0, 0)
      
      
      # ----------------------------------------------------------------- helpers
      def image(path, sequence=False):
          im = bpy.data.images.load(path, check_existing=True)
          im.alpha_mode = "STRAIGHT"
          if sequence:
              im.source = "SEQUENCE"
          return im
      
      
      def sprite_material(name, img, seq_len=0, seq_start=1):
          m = bpy.data.materials.new(name)
          m.use_nodes = True
          m.surface_render_method = "BLENDED"
          m.use_backface_culling = True
          nt = m.node_tree
          nt.nodes.clear()
          tex = nt.nodes.new("ShaderNodeTexImage")
          tex.image = img
          tex.extension = "CLIP"
          if seq_len:
              u = tex.image_user
              u.frame_duration, u.frame_start, u.frame_offset = seq_len, seq_start, 0
              u.use_cyclic = True
              u.use_auto_refresh = True
          em = nt.nodes.new("ShaderNodeEmission")
          tr = nt.nodes.new("ShaderNodeBsdfTransparent")
          mix = nt.nodes.new("ShaderNodeMixShader")
          out = nt.nodes.new("ShaderNodeOutputMaterial")
          L = nt.links
          L.new(tex.outputs["Color"], em.inputs["Color"])
          L.new(tex.outputs["Alpha"], mix.inputs["Fac"])
          L.new(tr.outputs[0], mix.inputs[1])
          L.new(em.outputs[0], mix.inputs[2])
          L.new(mix.outputs[0], out.inputs["Surface"])
          return m
      
      
      def quads_mesh(name, quads):
          """quads: [(x0, z0, x1, z1)] in the XZ plane, each UV-mapped 0..1."""
          me = bpy.data.meshes.new(name)
          verts, faces, uvs = [], [], []
          for x0, z0, x1, z1 in quads:
              i = len(verts)
              verts += [(x0, 0, z0), (x1, 0, z0), (x1, 0, z1), (x0, 0, z1)]
              faces.append((i, i + 1, i + 2, i + 3))
              uvs += [(0, 0), (1, 0), (1, 1), (0, 1)]
          me.from_pydata(verts, [], faces)
          uv = me.uv_layers.new()
          for li, loop in enumerate(me.loops):
              uv.data[li].uv = uvs[loop.vertex_index]
          # normals face -Y (towards the camera)
          for p in me.polygons:
              p.flip() if p.normal.y > 0 else None
          return me
      
      
      def sprite(name, path, height=None, width=None, loc=(0, 0, 0), anchor="bottom", parent=None, img=None, mat=None):
          img = img or image(path)
          w_px, h_px = img.size
          if height:
              width = height * w_px / h_px
          else:
              height = width * h_px / w_px
          x0, x1 = -width / 2, width / 2
          z0, z1 = (0, height) if anchor == "bottom" else (-height / 2, height / 2)
          ob = bpy.data.objects.new(name, quads_mesh(name, [(x0, z0, x1, z1)]))
          ob.data.materials.append(mat or sprite_material(name, img))
          sc.collection.objects.link(ob)
          ob.location = loc
          ob.parent = parent
          return ob
      
      
      def tiled(name, path, height, x_from, x_to, z0, y, img=None):
          img = img or image(path)
          w = height * img.size[0] / img.size[1]
          quads, x = [], x_from
          while x < x_to:
              quads.append((x, z0, x + w - 0.002, z0 + height))     # tiny overlap guard
              x += w - 0.004
          ob = bpy.data.objects.new(name, quads_mesh(name, quads))
          ob.data.materials.append(sprite_material(name, img))
          sc.collection.objects.link(ob)
          ob.location.y = y
          return ob
      
      
      def empty(name, loc=(0, 0, 0)):
          e = bpy.data.objects.new(name, None)
          sc.collection.objects.link(e)
          e.location = loc
          return e
      
      
      def key(ob, path, f, value=None, index=-1, interp=None):
          if value is not None:
              if index >= 0:
                  getattr(ob, path)[index] = value
              else:
                  setattr(ob, path, value)
          ob.keyframe_insert(path, index=index, frame=f)
      
      
      def smooth(t):
          return t * t * (3 - 2 * t)
      
      
      # ----------------------------------------------------------------- hero path (x, z per frame) and events
      SPEED = 0.2                                       # m per frame, matched to the run cycle stride
      ISLAND_X, ISLAND_W, ISLAND_TOP = 9.0, 4.4, 2.2    # island center x, width, walkable top
      JUMPS = [  # (take-off frame, landing frame, x0, x1, z0, z1, peak height above the higher end)
          (46, 61, None, None, 0.0, ISLAND_TOP, 1.1),
          (76, 88, None, None, ISLAND_TOP, 0.0, 0.5),
          (118, 146, None, None, 0.0, 0.0, 2.35),
          (152, 175, None, None, 0.0, 0.0, 2.1),
      ]
      X0 = -4.0
      
      
      def hero_x(f):
          return X0 + SPEED * (f - 1)
      
      
      def hero_z(f):
          for f0, f1, _, _, z0, z1, hpk in JUMPS:
              if f0 <= f <= f1:
                  t = (f - f0) / (f1 - f0)
                  top = max(z0, z1) + hpk
                  # two half-parabolas meeting at the peak (t = 0.5): up from z0, then down to z1
                  if t <= 0.5:
                      return z0 + (top - z0) * (1 - (1 - t * 2) ** 2)
                  return z1 + (top - z1) * (1 - ((t - 0.5) * 2) ** 2)
          if JUMPS[0][1] < f < JUMPS[1][0]:
              return ISLAND_TOP
          return 0.0
      
      
      def airborne(f):
          return any(f0 < f < f1 for f0, f1, *_ in JUMPS)
      
      
      # ----------------------------------------------------------------- camera
      cam = bpy.data.objects.new("Camera", bpy.data.cameras.new("Camera"))
      sc.collection.objects.link(cam)
      sc.camera = cam
      cam.data.type = "ORTHO"
      cam.data.ortho_scale = 16.0
      cam.data.clip_start, cam.data.clip_end = 0.1, 400
      cam.rotation_euler = (math.radians(90), 0, 0)
      cx, cz = hero_x(1) + 2.5, 2.4
      CAM = {}
      for f in range(1, END + 1):
          tx = hero_x(f) + 2.5
          tz = 2.4 + 0.35 * max(0.0, hero_z(f) - 0.4)
          cx += (tx - cx) * 0.25 if f > 1 else 0
          cz += (tz - cz) * 0.08
          cam.location = (cx, -100, cz)
          cam.keyframe_insert("location", frame=f)
          CAM[f] = (cx, cz)
      
      
      def follow(ob, k):
          """Parallax: the layer moves k times as far as the camera (k=1: infinitely far)."""
          fc = ob.driver_add("location", 0)
          d = fc.driver
          d.type = "SCRIPTED"
          v = d.variables.new()
          v.name, v.type = "c", "TRANSFORMS"
          v.targets[0].id = cam
          v.targets[0].transform_type = "LOC_X"
          d.expression = f"c*{k}"
          assert d.is_simple_expression
      
      
      # ----------------------------------------------------------------- parallax layers
      CAM_MIN, CAM_MAX = -2.0, hero_x(END) + 3.0
      
      
      def span(k, pad=9.5):
          return (1 - k) * CAM_MIN - pad, (1 - k) * CAM_MAX + pad
      
      
      sky_img = image(os.path.join(A, "sky.png"))
      sky_h = 10.6
      sky = sprite("L1_Sky", None, height=sky_h, loc=(0, 60, 3.0), anchor="center", img=sky_img)
      sky.location.x = (span(0.97)[0] + span(0.97)[1]) / 2
      follow(sky, 0.97)
      
      SEA_H, SEA_HORIZON = CFG["sea_h"], CFG["sea_horizon"]       # horizon row as a fraction from the top of the image
      x0, x1 = span(0.85)
      sea = tiled("L2_Sea", os.path.join(A, "sea_tile.png"), SEA_H, x0, x1, CFG["sea_base_z"] - SEA_H * (1 - SEA_HORIZON), 50)
      follow(sea, 0.85)
      
      JUN_H = CFG["jungle_h"]
      x0, x1 = span(0.6)
      jungle = tiled("L3_Jungle", os.path.join(A, "jungle_tile.png"), JUN_H, x0, x1, CFG["jungle_z0"], 40)
      follow(jungle, 0.6)
      
      GR_H, GR_TOP = CFG["ground_h"], CFG["ground_top"]       # walkable line as a fraction from the top
      x0, x1 = span(0.0)
      ground = tiled("L4_Ground", os.path.join(A, "ground_tile.png"), GR_H, x0 - 2, x1 + 2, -GR_H * (1 - GR_TOP), 8)
      
      # foreground plants: big, dark, close to the camera, moving faster than the ground
      FG = ["fg_bush", "fg_fern", "fg_hibiscus", "fg_boulder", "fg_curl"]
      fg_root = empty("L5_Foreground", (0, -10, 0))
      follow(fg_root, -0.45)
      x0, x1 = span(-0.45, pad=12)
      x, i = x0, 0
      heights = [2.6, 3.0, 2.3, 2.1, 2.4]
      while x < x1:
          name = FG[i % len(FG)]
          s = sprite(f"FG_{i:02d}_{name}", os.path.join(A, name + ".png"), height=heights[i % 5] * (1.0 + 0.12 * ((i * 7) % 3)),
                     loc=(x, 0, -3.0), parent=fg_root)
          x += 5.5 + 2.5 * ((i * 5) % 3) / 2
          i += 1
      
      # ----------------------------------------------------------------- level set pieces (k = 0, world space)
      island = sprite("Island", os.path.join(A, "island.png"), width=ISLAND_W, loc=(ISLAND_X, 1.0, 0), anchor="bottom")
      iw, ih = island.data.materials[0].node_tree.nodes["Image Texture"].image.size
      isl_h = ISLAND_W * ih / iw
      island.location.z = ISLAND_TOP - isl_h * (1 - CFG["island_top_px"] / ih)
      # gentle bob
      for f, dz in ((1, 0), (48, 0.05), (96, 0), (144, 0.05), (174, 0.02)):
          island.delta_location = (0, 0, dz)
          island.keyframe_insert("delta_location", index=2, frame=f)
      
      PALM_X = 16.0
      PALM_H = CFG["palm_h"]
      pb = SHAPES["palm"]
      palm_gap = PALM_H * (pb["h"] - 1 - pb["bbox"][3]) / pb["h"]       # empty space under the trunk in the image
      palm = sprite("Palm", os.path.join(A, "palm.png"), height=PALM_H, loc=(PALM_X, 9.0, -0.35 - palm_gap))
      # y = 9: just behind the ground strip, so the grass lip overlaps the base of the trunk
      
      PED_X = 22.4
      ped_w = 3.0
      ped = sprite("Pedestal", os.path.join(A, "pedestal.png"), width=ped_w, loc=(PED_X, 0.6, -0.12))
      pw, ph_ = ped.data.materials[0].node_tree.nodes["Image Texture"].image.size
      ped_top = -0.12 + ped_w * ph_ / pw * (1 - CFG["pedestal_top_px"] / ph_)
      
      # coins above the island
      coins = []
      coin_img = image(os.path.join(A, "coin.png"))
      coin_mat = sprite_material("Coin", coin_img)
      for j, cxp in enumerate((ISLAND_X - 1.1, ISLAND_X, ISLAND_X + 1.1)):
          c = sprite(f"Coin_{j}", None, height=0.62, loc=(cxp, 0.5, ISLAND_TOP + 0.95), anchor="center", img=coin_img, mat=coin_mat)
          coins.append(c)
      
      # ----------------------------------------------------------------- hero
      hero = empty("Hero", (hero_x(1), 0, 0))
      RUN_N = CFG["run_n"]
      run_img = image(os.path.join(A, "hero_run", "run_001.png"), sequence=True)
      MPP = CFG["hero_h"] / CFG["run_fig_px"]   # meters per pixel in the run frames
      _probe = bpy.data.images.load(os.path.join(A, "hero_run", "run_001.png"))     # a sequence reports 0x0 until played
      run_w, run_h = _probe.size[0] * MPP, _probe.size[1] * MPP
      bpy.data.images.remove(_probe)
      pad = CFG["run_pad_px"] * MPP
      run_plane = bpy.data.objects.new("Hero_Run", quads_mesh("Hero_Run", [(-run_w / 2, -pad, run_w / 2, run_h - pad)]))
      run_plane.data.materials.append(sprite_material("Hero_Run", run_img, seq_len=RUN_N))
      sc.collection.objects.link(run_plane)
      run_plane.parent = hero
      jump_img = image(os.path.join(A, "hero_jump.png"))
      # the jump still keeps its reframed canvas while the clip comes back at the video model's own resolution, so the
      # two scales differ by the canvas ratio (768 / 960 in the reference shot)
      jump_w_px, jump_h_px = jump_img.size
      JMPP = MPP * CFG["run_canvas_px"] / jump_h_px
      jw, jh = jump_w_px * JMPP, jump_h_px * JMPP
      jb = CFG["jump_base_px"]
      jump_plane = bpy.data.objects.new("Hero_Jump", quads_mesh("Hero_Jump", [(-jw / 2, -(jump_h_px - jb) * JMPP, jw / 2, jh - (jump_h_px - jb) * JMPP)]))
      jump_plane.data.materials.append(sprite_material("Hero_Jump", jump_img))
      sc.collection.objects.link(jump_plane)
      jump_plane.parent = hero
      
      # hero shadow (a soft dark ellipse drawn as an image made here)
      sh_path = os.path.join(A, "blob_shadow.png")
      if not os.path.exists(sh_path):
          S = 128
          simg = bpy.data.images.new("blob", S, S // 4, alpha=True)
          px = []
          for yy in range(S // 4):
              for xx in range(S):
                  d = math.hypot((xx - S / 2) / (S / 2), (yy - S / 8) / (S / 8))
                  a = max(0.0, 1 - d) ** 1.5 * 0.55
                  px += [0.12, 0.08, 0.05, a]
          simg.pixels = px
          simg.filepath_raw = sh_path
          simg.file_format = "PNG"
          simg.save()
      shadow = sprite("Hero_Shadow", sh_path, width=1.5, loc=(0, 0.9, 0), anchor="center")
      
      # per-frame hero animation: position, run/jump swap, squash and stretch, shadow
      prev_air = False
      for f in range(1, END + 1):
          x, z = hero_x(f), hero_z(f)
          hero.location = (x, 0, z)
          hero.keyframe_insert("location", frame=f)
          air = airborne(f)
          run_plane.hide_render = run_plane.hide_viewport = air
          jump_plane.hide_render = jump_plane.hide_viewport = not air
          for ob in (run_plane, jump_plane):
              ob.keyframe_insert("hide_render", frame=f)
              ob.keyframe_insert("hide_viewport", frame=f)
          # squash on landing / take-off, stretch while rising
          sx = sz = 1.0
          for f0, f1, *_ in JUMPS:
              if f0 - 2 <= f < f0:
                  sx, sz = 1.12, 0.88                     # anticipation
              elif f0 <= f < f0 + 4:
                  sx, sz = 0.9, 1.12                      # launch stretch
              elif f1 <= f < f1 + 3:
                  sx, sz = 1.18, 0.84                     # landing squash
          hero.scale = (sx, 1, sz)
          hero.keyframe_insert("scale", frame=f)
          support = ISLAND_TOP if (ISLAND_X - ISLAND_W / 2 + 0.2 < x < ISLAND_X + ISLAND_W / 2 - 0.2 and z >= ISLAND_TOP - 0.05) else 0.0
          h = max(0.0, z - support)
          shadow.location = (x + 0.05, 0.9 if support == 0 else 0.45, support + 0.02)
          s = max(0.35, 1 - 0.25 * h)
          shadow.scale = (s, 1, s)
          shadow.keyframe_insert("location", frame=f)
          shadow.keyframe_insert("scale", frame=f)
      
      # coins: spin while waiting; when collected each one flips and flies forward and upward from where it
      # was (growing as it comes towards the camera), on its own path, and leaves through the top of the screen
      COIN_FLY = 20
      for j, c in enumerate(coins):
          home = (c.location.x, ISLAND_TOP + 0.95)
          got = next(f for f in range(1, END + 1) if hero_x(f) >= c.location.x - 0.25)
          drift = (-0.9, 0.2, 1.3)[j]                                  # each coin fans out a little differently
          for f in range(1, END + 1):
              if f < got:
                  c.location = (home[0], 0.5, home[1] + 0.06 * math.sin(f * 0.2 + home[0]))
                  c.scale = (abs(math.cos(f * 0.18 + home[0])) * 0.9 + 0.1, 1, 1)
              else:
                  t = (f - got) / COIN_FLY
                  if t >= 1:
                      c.scale = (0, 1, 0)
                      c.location = (home[0], -30, home[1])
                  else:
                      cxf, czf = CAM[f]
                      sx0 = home[0] - CAM[got][0]                          # screen position when collected
                      sz0 = home[1] - CAM[got][1]
                      rise = (4.5 + 2.5 - sz0) * t ** 1.6                  # accelerates up and out past the top edge
                      sx_ = sx0 + drift * math.sin(t * math.pi / 2)
                      size = 1 + 3.2 * t ** 0.8                            # comes towards the camera: grows
                      flip = math.cos(t * math.pi * 5)                     # fast flips
                      c.location = (cxf + sx_, -30 + 0.01 * j, czf + sz0 + 0.35 * math.sin(t * math.pi) + rise)
                      c.scale = (size * max(0.08, abs(flip)), 1, size)
              c.keyframe_insert("scale", frame=f)
              c.keyframe_insert("location", frame=f)
      
      # ----------------------------------------------------------------- physics (2D: locked to the XZ plane)
      bpy.ops.rigidbody.world_add()
      rbw = sc.rigidbody_world
      rbw.point_cache.frame_start, rbw.point_cache.frame_end = 1, END
      rbw.substeps_per_frame, rbw.solver_iterations = 20, 30
      rbw.collection = bpy.data.collections.new("RigidBodies")
      sc.collection.children.link(rbw.collection)
      
      
      def collider(name, size, loc, kind="PASSIVE", shape="BOX", mass=1.0):
          bpy.ops.mesh.primitive_cube_add(size=1, location=loc)
          ob = bpy.context.active_object
          ob.name = name
          ob.scale = size
          bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
          bpy.ops.rigidbody.object_add(type=kind)
          rb = ob.rigid_body
          rb.collision_shape = shape
          rb.mass = mass
          rb.friction, rb.restitution = 0.6, 0.1
          rb.collision_margin = 0.005
          ob.hide_render = True
          ob.display_type = "WIRE"
          return ob
      
      
      def lane_walls(y, half=0.36, x0=-20, x1=80):
          """Two invisible frictionless walls that keep a lane of bodies in the picture plane."""
          for side in (-1, 1):
              w = collider(f"LaneWall_{y:+.1f}_{side:+d}", (x1 - x0, 0.05, 30), ((x0 + x1) / 2, y + side * half, 10))
              w.rigid_body.friction = 0.0
              w.rigid_body.restitution = 0.0
      
      
      def hull_body(name, height, y, depth, density=1.0, restitution=0.1, friction=0.6):
          """A rigid body whose collision shape is the sprite's own silhouette (convex hull),
          extruded through the picture plane, with its origin (= center of mass) at the hull centroid.
          Returns (body, sprite, visible width, visible height, bottom offset from origin)."""
          img = image(os.path.join(A, name + ".png"))
          W, H = img.size
          w = height * W / H
          hull = [((u - 0.5) * w, v * height) for u, v in SHAPES[name]["hull"]]
          # polygon area centroid
          A2 = cx_ = cz_ = 0.0
          for (x0, z0), (x1, z1) in zip(hull, hull[1:] + hull[:1]):
              cr = x0 * z1 - x1 * z0
              A2 += cr
              cx_ += (x0 + x1) * cr
              cz_ += (z0 + z1) * cr
          area = abs(A2) / 2
          cx_, cz_ = cx_ / (3 * A2), cz_ / (3 * A2)
          verts = []
          for yy in (-depth / 2, depth / 2):
              verts += [(x - cx_, yy, z - cz_) for x, z in hull]
          me = bpy.data.meshes.new(f"Col_{name}")
          n = len(hull)
          faces = [tuple(range(n))[::-1], tuple(range(n, 2 * n))] + [(i, (i + 1) % n, n + (i + 1) % n, n + i) for i in range(n)]
          me.from_pydata(verts, [], faces)
          me.validate()
          ob = bpy.data.objects.new(f"Col_{name}", me)
          sc.collection.objects.link(ob)
          bpy.context.view_layer.objects.active = ob
          for o in sc.objects:
              o.select_set(o == ob)
          bpy.ops.rigidbody.object_add(type="ACTIVE")
          rb = ob.rigid_body
          rb.collision_shape = "CONVEX_HULL"
          rb.mass = area * density
          rb.friction, rb.restitution = friction, restitution
          rb.collision_margin = 0.004
          rb.linear_damping, rb.angular_damping = 0.04, 0.12          # close to real air drag
          ob.hide_render = True
          ob.display_type = "WIRE"
          spr = sprite(f"Spr_{name}", None, height=height, img=img, anchor="bottom")
          spr.parent = ob
          spr.location = (-cx_ + 0.0, -depth / 2 - 0.01, -cz_)
          zmin = min(z for _, z in hull)
          zmax = max(z for _, z in hull)
          xmin = min(x for x, _ in hull)
          xmax = max(x for x, _ in hull)
          ob["bottom"] = zmin - cz_
          ob["radius"] = max(math.hypot(x - cx_, z - cz_) for x, z in hull)
          return ob, (xmin - cx_, xmax - cx_, zmin - cz_, zmax - cz_)
      
      
      ground_col = collider("Ground_Collider", (200, 30, 2), (30, 0, -1.0))
      ground_col.rigid_body.friction = 0.8
      ground_col.rigid_body.restitution = 0.6
      lane_walls(0.3)
      lane_walls(3.6, half=0.3)
      ped_col = collider("Pedestal_Collider", (ped_w * 0.9, 2.0, ped_top + 0.12), (PED_X, 0.3, (ped_top - 0.12) / 2))
      ped_col.rigid_body.friction = 0.8
      
      # tower: clay pots + gold idol stacked on the pedestal, asleep until something touches them
      pieces = []
      
      
      def place(name, height, x_center, z_bottom, density=1.0, restitution=0.1):
          ob, (x0, x1, z0, z1) = hull_body(name, height, 0.3, 0.56, density, restitution)
          ob.location = (x_center, 0.3, z_bottom - z0 + 0.003)
          ob.rigid_body.use_deactivation = True
          ob.rigid_body.use_start_deactivated = True
          pieces.append(ob)
          return z_bottom + (z1 - z0)
      
      
      # a stable stack: everything rests on a flat surface
      top_jar = place("jar", 1.0, PED_X - 0.62, ped_top, density=1.0)                       # flat base, flat lid
      place("pot_round", 1.02, PED_X + 0.66, ped_top, density=1.0, restitution=0.12)
      place("idol", 1.05, PED_X - 0.62, top_jar, density=2.6)                               # solid gold, on the lid
      place("amphora", 1.35, PED_X + 4.6, 0.0, density=0.9)                                 # on the grass, past the landing
      
      # coconuts: held in the crown (kinematic) until the hero runs under the palm, then they drop
      nuts = []
      drop_f = next(f for f in range(1, END + 1) if hero_x(f) >= PALM_X - 3.2)
      for j, (dx, dz) in enumerate(CFG["nuts"]):
          ob, _ = hull_body("coconut", CFG["nut_size"], 3.6, 0.5, density=1.4, restitution=0.75, friction=0.7)
          ob.name = f"Col_coconut{j}"
          ob.location = (PALM_X + dx, 3.6, dz)
          ob.rigid_body.angular_damping = 0.35                        # husk: rolls, but not forever
          ob.rigid_body.kinematic = True
          ob.keyframe_insert("rigid_body.kinematic", frame=1)
          ob.keyframe_insert("rigid_body.kinematic", frame=drop_f + j * 5)
          ob.rigid_body.kinematic = False
          ob.keyframe_insert("rigid_body.kinematic", frame=drop_f + j * 5 + 1)
          nuts.append(ob)
      
      # the hero's body: a kinematic sphere that follows the hero. It lives outside the props lane (y = 5)
      # and slides in sideways before the tower jump; it slides out only once nothing is within reach
      # (EXIT_F comes from a first probe run), so it never pinches a piece against a lane wall.
      # contact frame: first frame where the hero's body reaches a piece (pieces are static until hit)
      def body_pos(f):
          return Vector((hero_x(f) + 0.2, 0.3, hero_z(f) + 0.78))
      
      
      HIT_F = next(f for f in range(JUMPS[2][0], JUMPS[2][1])
                   if min((p.location - body_pos(f)).length - p["radius"] - 0.62 for p in pieces[:3]) < 0.0)
      KICK = {0: 0.0, 1: 0.04, 2: 0.06, 3: 0.04, 4: 0.0}        # a slight push forward on contact, then back
      EXIT_F = HIT_F + 5
      T0 = JUMPS[2][0] - 2
      bpy.ops.mesh.primitive_uv_sphere_add(radius=0.62, location=(0, 5, 0))
      hb = bpy.context.active_object
      hb.name = "Hero_Collider"
      bpy.ops.rigidbody.object_add(type="ACTIVE")
      hb.rigid_body.kinematic = True
      hb.rigid_body.collision_shape = "SPHERE"
      hb.rigid_body.friction = 0.4
      hb.rigid_body.restitution = 0.5
      hb.hide_render = True
      hb.display_type = "WIRE"
      for f in range(1, END + 1):
          # outside the props lane (y = 5) except from just before the jump until right after the strike
          if f < T0 - 6 or f > EXIT_F + 6:
              y = 5.0
          elif f < T0:
              y = 5.0 + (0.3 - 5.0) * smooth((f - (T0 - 6)) / 6)
          elif f <= EXIT_F:
              y = 0.3
          else:
              y = 0.3 + (5.0 - 0.3) * smooth((f - EXIT_F) / 6)
          pos = body_pos(f)
          hb.location = (pos.x + KICK.get(f - HIT_F, 0.0), y, pos.z)
          hb.keyframe_insert("location", frame=f)
      
      # ----------------------------------------------------------------- simulate in order, save, review, final
      clear = []
      for f in range(1, END + 1):
          sc.frame_set(f)
          if f > JUMPS[2][0]:
              hp = Vector((hero_x(f) + 0.2, 0.3, hero_z(f) + 0.78))
              gap = min((p.matrix_world.translation - hp).length - p["radius"] - 0.62 for p in pieces)
              clear.append((f, gap))
      ok = [f for f, g in clear if f >= JUMPS[2][1] - 6 and all(g2 > 0.35 for f2, g2 in clear if f <= f2 <= f + 8)]
      print("HIT", HIT_F, "EXIT", EXIT_F, "PROBE first clear exit frame", ok[0] if ok else None, "min gaps", [(f, round(g, 2)) for f, g in clear[::4]])
      # bake the simulation into the file, so any frame can be opened or rendered directly (not only in order)
      with bpy.context.temp_override(scene=sc):
          bpy.ops.ptcache.bake_all(bake=True)
      sc.frame_set(1)
      bpy.ops.wm.save_as_mainfile(filepath=out_blend)
      print("EVENTS coins at", [next(f for f in range(1, END + 1) if hero_x(f) >= c.location.x - 0.25) for c in coins],
            "coconut drop", drop_f, "tower jump", JUMPS[2][:2])
      for p in pieces + nuts:
          sc.frame_set(END)
          print("FINAL", p.name, tuple(round(v, 2) for v in p.matrix_world.translation))
      
      rv = os.path.join(ROOT, "review" + CFG["tag"])
      os.makedirs(rv, exist_ok=True)
      if "--review" in ARGS:
          r.resolution_percentage = 40
          for f in (1, 55, 70, 90, 110, 122, 128, 134, 140, 150, 172):
              sc.frame_set(f)
              r.filepath = os.path.join(rv, f"f{f:03d}.png")
              bpy.ops.render.render(write_still=True)
          r.resolution_percentage = 100
      if "--final" in ARGS:
          r.image_settings.media_type = "VIDEO"
          r.image_settings.file_format = "FFMPEG"
          r.ffmpeg.format, r.ffmpeg.codec, r.ffmpeg.constant_rate_factor = "MPEG4", "H264", "HIGH"
          r.filepath = out_film
          bpy.ops.render.render(animation=True)
      print("RESULT ok", out_blend)
      
    • measure_set.py 3.4 KB
      #!/usr/bin/env python3
      """Measure the per-look numbers build_scene.py needs from the processed pictures, and print them for config.json.
      
        sea_horizon      the far layer's horizon: first row where alpha covers more than 90% of the width, as a share
                         of the height (sea_tile.png)
        ground_top       the ground strip's walkable line: its 90% row as a share of the height, moved down by
                         --sink meters (default 0.1) so the feet sit in the lip rather than on the grass tips
        island_top_px    first row where alpha covers more than 60% of island.png's width
        pedestal_top_px  first row where alpha covers more than 80% of pedestal.png's width
        jump_base_px     the feet row of hero_jump.png (lowest opaque row); reframe_stills.py also prints the feet line
                         it placed ("feet at y=..."), which is an equally good value
      
      The layer heights in meters (sea_h, jungle_h, ground_h and their offsets), the fruit positions (nuts) and the hero
      height (hero_h) were set by eye on review renders in the reference shot: start from the example config and adjust.
      The run numbers (run_n, run_fig_px, run_pad_px) come from run_loop.py. Prints a JSON fragment; writes nothing.
      
      Example:
        python3 measure_set.py jungle/game --ground-h 4.3
      
      Needs Python 3 with Pillow and NumPy.
      """
      import argparse
      import json
      import os
      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')
      
      
      def opaque(path):
          if not os.path.isfile(path):
              sys.exit(f'error: {path} not found')
          return np.asarray(Image.open(path).convert('RGBA'))[..., 3] > 128
      
      
      def first_row(a, cover, name):
          rows = a.mean(1) > cover
          if not rows.any():   # argmax of all-False is 0, which would read as a line at the very top
              sys.exit(f'error: no row of {name} is more than {cover:.0%} opaque; check its transparency and cut')
          return int(np.argmax(rows))
      
      
      def main():
          parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
          parser.add_argument('game_dir', help='folder holding sea_tile.png, ground_tile.png, island.png, pedestal.png, hero_jump.png')
          parser.add_argument('--ground-h', type=float, default=4.3, help='height of the ground strip in the scene, meters (default 4.3, the script default)')
          parser.add_argument('--sink', type=float, default=0.1, help='how far the feet sink below the 90%% line, meters (default 0.1)')
          if len(sys.argv) == 1:
              parser.print_help()
              sys.exit(1)
          args = parser.parse_args()
          g = args.game_dir
      
          sea = opaque(os.path.join(g, 'sea_tile.png'))
          ground = opaque(os.path.join(g, 'ground_tile.png'))
          island = opaque(os.path.join(g, 'island.png'))
          pedestal = opaque(os.path.join(g, 'pedestal.png'))
          jump = opaque(os.path.join(g, 'hero_jump.png'))
          ground90 = first_row(ground, 0.9, 'ground_tile.png') / ground.shape[0]
          values = {
              'sea_horizon': round(first_row(sea, 0.9, 'sea_tile.png') / sea.shape[0], 3),
              'ground_h': args.ground_h,
              'ground_top': round(ground90 + args.sink / args.ground_h, 3),
              'island_top_px': first_row(island, 0.6, 'island.png'),
              'pedestal_top_px': first_row(pedestal, 0.8, 'pedestal.png'),
              'jump_base_px': int(np.where(jump.any(1))[0].max()),
          }
          print(json.dumps(values, indent=1))
      
      
      if __name__ == '__main__':
          main()
      
    • prep_assets.py 6 KB
      """Turn the raw Scenario renders into game-ready sprites and layers.
      
      - cut:   split a transparent sprite sheet into one PNG per sprite (connected components),
               reading order = rows top to bottom, then left to right.
      - key:   magenta (#FF00FF) background -> soft alpha + despill (HD cartoon, no pixelation).
      - tile:  make a layer repeat horizontally without a seam (cross-fade its two ends).
      
        python3 prep_assets.py cut sheet.png out_dir name1,name2,... [--force]
        python3 prep_assets.py key in.png out.png [--force]
        python3 prep_assets.py tile in.png out.png [fade_px] [--force]
      
      cut: names are given in reading order; sprites smaller than 4000 opaque px are dropped as specks, and pieces
      closer than CUT_GROW px (environment variable, default 12) are kept together. Ask for wide gaps on the sheet.
      tile: the seam is moved to the stretch where the layer's silhouette is lowest, then fade_px (default 256) of the
      two ends are cross-faded, so the output is fade_px narrower than the input.
      Existing outputs are kept unless --force is given. Needs Python 3 with Pillow, NumPy and SciPy.
      """
      import os, sys
      import numpy as np
      from PIL import Image
      from scipy import ndimage
      
      
      def cut(sheet, out_dir, names, pad=6):
          im = np.asarray(Image.open(sheet).convert("RGBA"))
          a = im[..., 3] > 16
          grown = ndimage.binary_dilation(a, iterations=int(os.environ.get("CUT_GROW", "12")))
          lab, n = ndimage.label(grown)
          boxes = []
          for i, sl in enumerate(ndimage.find_objects(lab), 1):
              ys, xs = sl
              area = int((a[sl] & (lab[sl] == i)).sum())
              if area < 4000:                      # stray specks
                  continue
              boxes.append((ys.start, ys.stop, xs.start, xs.stop, i))
          # rows: group by vertical center, then sort by x
          boxes.sort(key=lambda b: (b[0] + b[1]) / 2)
          rows, cur = [], []
          for b in boxes:
              if cur and (b[0] + b[1]) / 2 - (cur[-1][0] + cur[-1][1]) / 2 > 0.5 * (b[1] - b[0]):
                  rows.append(cur); cur = []
              cur.append(b)
          rows.append(cur)
          ordered = [b for r in rows for b in sorted(r, key=lambda b: b[2])]
          os.makedirs(out_dir, exist_ok=True)
          for name, (y0, y1, x0, x1, i) in zip(names, ordered):
              if os.path.exists(os.path.join(out_dir, name + ".png")) and not FORCE:
                  sys.exit(f"error: {os.path.join(out_dir, name + '.png')} exists (pass --force to replace it)")
              y0, x0 = max(0, y0 - pad), max(0, x0 - pad)
              y1, x1 = min(im.shape[0], y1 + pad), min(im.shape[1], x1 + pad)
              crop = im[y0:y1, x0:x1].copy()
              crop[..., 3] = np.where(lab[y0:y1, x0:x1] == i, crop[..., 3], 0)
              Image.fromarray(crop).save(os.path.join(out_dir, name + ".png"))
              print(name, crop.shape[1], "x", crop.shape[0])
          if len(ordered) != len(names):
              print("WARNING: found", len(ordered), "sprites for", len(names), "names")
      
      
      def key_rgb(rgb, protect=True):
          f = rgb.astype(np.float32) / 255
          r, g, b = f[..., 0], f[..., 1], f[..., 2]
          mag = np.minimum(r, b) - g                       # > 0 where red and blue both beat green
          close = np.abs(r - b) < 0.45
          t = np.clip((mag - 0.10) / (0.30 - 0.10), 0, 1) * close
          alpha = (1 - t)
          alpha = ndimage.grey_erosion(alpha, size=(3, 3))   # eat the 1px magenta fringe
          out = rgb.astype(np.float32)
          spill = np.clip(np.minimum(out[..., 0], out[..., 2]) - out[..., 1], 0, None) * 0.7
          out[..., 0] -= spill
          out[..., 2] -= spill
          if protect:
              # pinkish colors enclosed by the figure (inner ears, tongues) are part of the character, not the
              # background: inside the filled silhouette, anything that is not near-pure magenta keeps its color
              solid = alpha > 0.5
              inside = ndimage.binary_fill_holes(solid) & ~solid
              keep = ndimage.binary_dilation(inside & (mag < 0.35), iterations=1) & ndimage.binary_fill_holes(solid)
              alpha = np.where(keep, 1.0, alpha)
              out = np.where(keep[..., None], rgb.astype(np.float32), out)
          return np.dstack([np.clip(out, 0, 255), alpha * 255]).astype(np.uint8)
      
      
      def key(src, dst):
          Image.fromarray(key_rgb(np.asarray(Image.open(src).convert("RGB")))).save(dst)
      
      
      def tile(src, dst, fade=256):
          im = np.asarray(Image.open(src).convert("RGBA")).astype(np.float32)
          w = im.shape[1]
          # put the repeat seam where the silhouette is lowest (plain foliage, open sea), never through a
          # landmark: roll the image so that stretch sits at the edges, then cross-fade only there
          a = im[..., 3] > 128
          top = np.where(a.any(0), a.argmax(0), a.shape[0])          # first opaque row per column (bigger = lower)
          win = 2 * fade
          score = np.array([top[np.arange(x, x + win) % w].min() for x in range(0, w, 8)])
          best = int(np.argmax(score)) * 8                            # window whose tallest feature is lowest
          im = np.roll(im, -(best + fade), axis=1)                    # its center becomes the left/right edge
          out = im[:, :w - fade].copy()
          t = np.linspace(0, 1, fade)[None, :, None]
          out[:, :fade] = im[:, w - fade:] * (1 - t) + im[:, :fade] * t   # left edge continues the right edge
          Image.fromarray(np.clip(out, 0, 255).astype(np.uint8)).save(dst)
      
      
      FORCE = "--force" in sys.argv
      
      if __name__ == "__main__":
          argv = [a for a in sys.argv if a != "--force"]
          need = {"cut": 5, "key": 4, "tile": 4}
          if len(argv) < 2 or argv[1] not in need or len(argv) < need[argv[1]]:
              sys.exit(__doc__)
          sys.argv = argv
          cmd = sys.argv[1]
          if not os.path.isfile(sys.argv[2]):
              sys.exit(f"error: {sys.argv[2]} not found")
          if cmd in ("key", "tile"):
              if os.path.abspath(sys.argv[3]) == os.path.abspath(sys.argv[2]):
                  sys.exit("error: the output would overwrite the input")
              if os.path.exists(sys.argv[3]) and not FORCE:
                  sys.exit(f"error: {sys.argv[3]} exists (pass --force to replace it)")
          if cmd == "cut":
              cut(sys.argv[2], sys.argv[3], sys.argv[4].split(","))
          elif cmd == "key":
              key(sys.argv[2], sys.argv[3])
          elif cmd == "tile":
              tile(sys.argv[2], sys.argv[3], int(sys.argv[4]) if len(sys.argv) > 4 else 256)
      
    • 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()
      
    • run_loop.py 7.5 KB
      #!/usr/bin/env python3
      """Turn a run-in-place clip on a flat background into a keyed, looping image sequence for Blender.
      
      The clip is an image-to-video result: the hero runs like on a treadmill, centered, on a flat color (magenta).
      Every frame is keyed to alpha, then the loop is 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; every start s in the search range and every
      period P in --periods is tried. The kept frames are cropped to their common bounds plus --pad px (no scaling:
      Blender sizes them from config.json) and written as <out>/run_001.png, run_002.png, ...
      
      Keys:
        magenta  a soft magenta key with despill; pink or purple parts enclosed by the figure (inner ears) are kept.
                 Needs prep_assets.py in the same folder.
        border   each frame is keyed against its own border color (the median of an 8 px border). Use it when the
                 clip's background drifts (one reference clip went from magenta through red and yellow to green), and
                 search only the frames whose background is far from the character's colors (--from/--to).
      --clean tightens the alpha edge by 1 px and pulls a 3 px edge band toward gray, except strongly red pixels
      (a red scarf or hat), to remove a colored fringe.
      
      Prints the numbers config.json needs: run_n (frames), run_fig_px (mean figure height) and run_pad_px.
      Existing frames are kept unless --force is given.
      
      Examples:
        python3 run_loop.py clips/hero_run.mp4 game/hero_run
        python3 run_loop.py clips/hero_run.mp4 game/hero_run --key border --from 60 --to 124 --skip 0 --periods 8:16 --clean
      
      Needs Python 3 with Pillow, NumPy and SciPy, and ffmpeg on the PATH.
      """
      import argparse
      import os
      import subprocess
      import sys
      import tempfile
      
      try:
          import numpy as np
          from PIL import Image, ImageFilter
          from scipy import ndimage
      except ImportError:
          sys.exit('This script needs Pillow, NumPy and SciPy: python3 -m pip install pillow numpy scipy')
      
      
      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 key_border(rgb):
          a = rgb.astype(float)
          b = np.concatenate([a[:8].reshape(-1, 3), a[-8:].reshape(-1, 3), a[:, :8].reshape(-1, 3), a[:, -8:].reshape(-1, 3)])
          bg = np.median(b, 0)
          d = np.sqrt(((a - bg) ** 2).sum(-1))
          al = np.clip((d - 45) / (110 - 45), 0, 1)
          # keep only the figure (largest blob), drop specks
          lab, n = ndimage.label(al > 0.5)
          if n > 1:
              sz = ndimage.sum(al > 0.5, lab, range(1, n + 1))
              keep = lab == (np.argmax(sz) + 1)
              keep = ndimage.binary_dilation(keep, iterations=3)
              al = al * keep
          # unmix the background out of soft edges
          aa = np.maximum(al, 1e-3)[..., None]
          fg = np.clip((a - (1 - aa) * bg) / aa, 0, 255)
          return np.dstack([np.where(al[..., None] > 0, fg, 0), al * 255]).astype(np.uint8)
      
      
      def clean(im):
          a = np.asarray(im).astype(float)
          al = np.asarray(im.getchannel('A').filter(ImageFilter.MinFilter(3))).astype(float)   # 1 px tighter edge
          solid = al > 250
          band = ~ndimage.binary_erosion(solid, iterations=3) & (al > 0)
          r, g, b = a[..., 0], a[..., 1], a[..., 2]
          red = (r > g + 50) & (r > b + 50)
          lum = 0.3 * r + 0.55 * g + 0.15 * b
          fix = band & ~red
          for c in range(3):
              a[..., c] = np.where(fix, np.minimum(a[..., c], lum) * 0.92, a[..., c])
          a[..., 3] = al
          return Image.fromarray(a.astype(np.uint8))
      
      
      def main():
          parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
          parser.add_argument('clip', help='the run clip (mp4)')
          parser.add_argument('out', help='output folder for run_NNN.png (created if missing)')
          parser.add_argument('--key', choices=('magenta', 'border'), default='magenta', help='how to key the background (default magenta)')
          parser.add_argument('--periods', default='7:15', help='loop lengths to try, MIN:MAX frames (default 7:15)')
          parser.add_argument('--from', dest='first', type=int, default=0, help='first frame of the search range, 0-based (default 0)')
          parser.add_argument('--to', dest='last', type=int, default=None, help='end of the search range, exclusive (default: the last frame)')
          parser.add_argument('--skip', type=int, default=6, help='earliest loop start inside the range (default 6: the clip settles first)')
          parser.add_argument('--pad', type=int, default=8, help='empty px kept around the figure (default 8; this is run_pad_px)')
          parser.add_argument('--clean', action='store_true', help='tighten the edge by 1 px and remove a colored fringe (keeps red)')
          parser.add_argument('--force', action='store_true', help='replace existing run frames in the output folder')
          if len(sys.argv) == 1:
              parser.print_help()
              sys.exit(1)
          args = parser.parse_args()
      
          if not os.path.isfile(args.clip):
              sys.exit(f'error: {args.clip} not found')
          pmin, pmax = (int(v) for v in args.periods.split(':'))
          old = [f for f in os.listdir(args.out) if f.startswith('run_') and f.endswith('.png')] if os.path.isdir(args.out) else []
          if old and not args.force:
              sys.exit(f'error: {args.out} already holds {len(old)} run frames (pass --force to replace them)')
      
          if args.key == 'magenta':
              sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
              try:
                  from prep_assets import key_rgb
              except ImportError:
                  sys.exit('error: --key magenta needs prep_assets.py next to this script')
              key = key_rgb
          else:
              key = key_border
      
          with tempfile.TemporaryDirectory() as tmp:
              subprocess.run(['ffmpeg', '-v', 'error', '-y', '-i', args.clip, os.path.join(tmp, 'f_%04d.png')], check=True)
              files = sorted(os.listdir(tmp))[args.first:args.last]
              rgba = [key(np.asarray(Image.open(os.path.join(tmp, f)).convert('RGB'))) for f in files]
          sm = [small(a) for a in rgba]
          best = None
          for period in range(pmin, pmax + 1):
              for s in range(args.skip, len(sm) - period):
                  step = np.mean([dist(sm[i], sm[i + 1]) for i in range(s, s + period)])
                  seam = dist(sm[s], sm[s + period])
                  score = seam / step
                  if best is None or score < best[0]:
                      best = (score, s, period, seam, step)
          if best is None:
              sys.exit('error: the search range is shorter than the shortest period')
          score, s, period, seam, step = best
          print(f'loop score {score:.3f} (seam / motion, lower is better; the reference loops scored 0.10 to 0.27) start {args.first + s} period {period}')
          win = rgba[s:s + period]
          al = np.stack([w[..., 3] for w in win]).max(0) > 20
          ys, xs = np.where(al)
          y0, y1, x0, x1 = ys.min() - args.pad, ys.max() + args.pad, xs.min() - args.pad, xs.max() + args.pad
          y0, x0 = max(0, y0), max(0, x0)
          os.makedirs(args.out, exist_ok=True)
          for f in old:
              os.remove(os.path.join(args.out, f))
          for i, w in enumerate(win):
              im = Image.fromarray(w[y0:y1, x0:x1])
              if args.clean:
                  im = clean(im.convert('RGBA'))
              im.save(os.path.join(args.out, f'run_{i + 1:03d}.png'))
          figs = [int(np.ptp(np.where((w[..., 3] > 128).any(1))[0])) for w in win]
          print(f'wrote {period} frames of {x1 - x0}x{y1 - y0} px to {args.out}')
          print(f'config.json: "run_n": {period}, "run_fig_px": {round(sum(figs) / len(figs))}, "run_pad_px": {args.pad}, '
                f'"run_canvas_px": {rgba[0].shape[0]}')
      
      
      if __name__ == '__main__':
          main()
      
    • trace_hulls.py 2.9 KB
      #!/usr/bin/env python3
      """Trace each prop sprite's outline into shapes.json, the collision shapes build_scene.py reads.
      
      For every physics prop: the opaque pixels (alpha > 128), every 7th one fed to a convex hull, the hull thinned to
      about 20 points and normalized (u 0..1 from left to right, v 0..1 from bottom to top). For the tree and the
      pedestal only the size and the opaque bounds are written: build_scene.py uses the tree's bounds to plant its trunk
      on the ground when the image has empty rows under it.
      
      Writes <game_dir>/shapes.json. An existing file is kept unless --force is given.
      
      Examples:
        python3 trace_hulls.py jungle/game
        python3 trace_hulls.py snow/game --props jar,pot_round,amphora,idol,coconut --bounds palm,pedestal --force
      
      Needs Python 3 with Pillow, NumPy and SciPy.
      """
      import argparse
      import json
      import os
      import sys
      
      try:
          import numpy as np
          from PIL import Image
          from scipy.spatial import ConvexHull
      except ImportError:
          sys.exit('This script needs Pillow, NumPy and SciPy: python3 -m pip install pillow numpy scipy')
      
      
      def main():
          parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
          parser.add_argument('game_dir', help='folder holding the sprites (<name>.png)')
          parser.add_argument('--props', default='jar,pot_round,amphora,idol,coconut', help='sprites that get a hull (comma-separated)')
          parser.add_argument('--bounds', default='palm,pedestal', help='sprites that get only size and bounds')
          parser.add_argument('--force', action='store_true', help='replace an existing shapes.json')
          if len(sys.argv) == 1:
              parser.print_help()
              sys.exit(1)
          args = parser.parse_args()
      
          target = os.path.join(args.game_dir, 'shapes.json')
          if os.path.exists(target) and not args.force:
              sys.exit(f'error: {target} exists (pass --force to replace it)')
          props = [n for n in args.props.split(',') if n]
          bounds = [n for n in args.bounds.split(',') if n]
          out = {}
          for name in props + bounds:
              path = os.path.join(args.game_dir, name + '.png')
              if not os.path.isfile(path):
                  sys.exit(f'error: {path} not found')
              a = np.asarray(Image.open(path).convert('RGBA'))[..., 3] > 128
              H, W = a.shape
              ys, xs = np.where(a)
              entry = {'w': W, 'h': H, 'bbox': [int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())]}
              if name in props:
                  pts = np.stack([xs, ys], 1)[::7]
                  hp = pts[ConvexHull(pts).vertices]
                  # simplify to about 20 points, normalized: u in 0..1 left->right, v in 0..1 bottom->top
                  step = max(1, len(hp) // 20)
                  hp = hp[::step]
                  entry['hull'] = [[float(x) / W, 1 - float(y) / H] for x, y in hp]
              out[name] = entry
              print(name, entry['bbox'], (W, H), len(entry.get('hull', [])), 'hull points')
          with open(target, 'w') as f:
              json.dump(out, f)
          print('wrote', target)
      
      
      if __name__ == '__main__':
          main()
      
  • README.md 3.4 KB
    # scenario-blender-2d-animation: maintainer notes
    
    Not read at runtime. How the skill was built and why it makes its choices.
    
    ## Origin
    
    Distilled from the [Blender 2D animation exploration](https://www.scenario.com/explorations/blender-2d-animation/) on scenario.com: a 7.7 second side-scroller shot in three looks (a red panda in a golden-hour jungle, a black cat on a moonlit Halloween street, a snow leopard cub in a winter valley), ten Scenario generations per look, built and rendered by one Blender script. The page offers the skill as a download; this directory is that skill brought to the repository's authoring contract.
    
    The exploration used one text-to-image model for every picture and one image-to-video model at 768p for the run clips. The body names neither and discovers both lanes with `recommend` (`txt2img`, `img2video`), because availability differs per team and both generations will be superseded. The page's credit figures are left out for the same reason: the body prices with `dry_run`.
    
    ## Why a core skill, not a Blender expert tool
    
    Every paid step (ten generations per look) is a Scenario MCP call; Blender only lays out and renders, locally. The expert tier exempts its skills from the MCP rules, which are exactly what this skill must be graded on, and the Blender family's specialists share the lead's toolkit and provenance, neither of which applies here. `scenario-patina-retexture` set the precedent: MCP generation, then bundled Blender scripts run headless. The name keeps the download's, so the exploration page and the repository agree.
    
    ## Changes from the download
    
    - Description cut to triggers only; the body names no generative model; discovery moved from a newest-first `search` to `recommend`; payload field names hedged as schema examples.
    - `scenario-side-view-game-kit` and `scenario-sprite-animation` named as the sprite-strip alternatives. The download's other related skills are not named: the model-family ones point at a model, and the workflow hands nothing to the rest, whose bodies would only push the fan-out past the re-attach pool.
    - `build_scene.py --live` used to empty whatever file was open. It now refuses unless the file is new (nothing beyond the startup cube, light and camera) or the script's own output. `bpy.data.is_dirty` cannot make that call: Blender reports a new file as dirty.
    - The jump still's scale was hard-coded as 768 / 960 (the clip's height over the reframed canvas), which is wrong for any video model that answers at another resolution. `run_loop.py` now prints `run_canvas_px` and `build_scene.py` reads it with the jump still's own height.
    - The common mistakes that the scripts now prevent moved out of the body; `references/pipeline.md` already records them for anyone changing the scene script.
    
    ## Scripts
    
    `reframe_stills.py` is a byte-for-byte copy of the one in `scenario-side-view-game-kit`, kept so each skill installs alone; `tests/scenario-blender-2d-animation/test_reframe_stills.py` fails when the two drift, so a fix to one reaches the other. `run_loop.py` shares that kit's loop-window idea (seam over in-window motion) but adds the border key and writes a Blender image sequence instead of a pixel strip.
    
    The suite runs the five helpers on synthetic inputs. `test_build_scene.py` builds, bakes and saves a synthetic set in a real Blender and is skipped unless `BLENDER` names the binary or `blender` is on the `PATH`, which CI does not provide; it passed on Blender 5.0.1.
    
  • SKILL.md 12 KB
    ---
    name: scenario-blender-2d-animation
    description: "Use when a 2D side-scroller shot, platformer cutscene, game trailer or parallax animation has to be built in Blender from Scenario-generated pictures (flat image cards, an orthographic camera, keyed jumps, rigid-body props), or when a finished shot needs a new theme or look without re-animating. Also use when image planes in Blender float above the ground, a tiled parallax layer shows a seam, or physics props leave the picture plane."
    license: MIT
    ---
    
    # Scenario Blender 2D animation
    
    ## Overview
    
    Generate the art once per look; animate it once in Blender. A concept frame (an imagined in-game screenshot) fixes the style, and the eight other pictures (sky, far and mid layers, ground strip, foreground plants, props sheet, tree, the hero in two poses) take it as their reference, so the set holds together. An image-to-video clip of the hero running in place on magenta becomes a looping image sequence. One Blender script lays the pictures out as flat cards in depth, drives their parallax from an orthographic camera that follows the hero, keys the jumps and runs rigid-body physics on the props. A new look is ten new generations and a few measured numbers. The reference result is a 7.7 second, 1080p, 24 fps shot in three looks. The deliverable is the film and its `.blend`; for sprite strips a game engine plays, use `scenario-side-view-game-kit` or `scenario-sprite-animation`.
    
    Discover both lanes with `recommend`, the user's words as `prompt`: `capability: "txt2img"` for the pictures, which need a reference-image input, a transparent background and exact width and height (if the pick lacks one, or is a specialty for another style than the look, such as pixel art for a painted cartoon, take the next ranked pick), and `capability: "img2video"` for the run clip, which needs a first frame. `model_schema_get` each pick. Payload names below (`referenceImages`, `background`, `numOutputs`) are examples: use the fields your pick's schema lists. Before generating, tell the user the total for the nine pictures and the clip: `dry_run` the concept frame and quote the rest from `recommend`, since a `dry_run` naming an asset that does not exist yet returns 404. Then `dry_run` each later payload once its input exists, before running it.
    
    Connection, uploads and the core loop: the `scenario` 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 scripts run locally and never call Scenario. Blender 5.x runs the scene script, headless or in an open session (through a Blender MCP connection, for example); in a session, start from File > New, since `--live` empties the open file and refuses any file but a new one or its own output. Python 3 with Pillow, NumPy and SciPy runs the helpers, and ffmpeg reads the clip. Every helper prints its usage when run without arguments and refuses to overwrite an existing output (or its input) unless given `--force`.
    
    ## Quick reference
    
    | Step          | How                                                                                                                                       |
    | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
    | Pick models   | `recommend` (`txt2img`, `img2video`), `model_schema_get`, then `model_run` with `dry_run: true` per distinct payload                      |
    | Concept frame | Text to image, 16:9, opaque, two takes: an in-game screenshot, strict side view, layers named in order ([prompts](references/prompts.md)) |
    | Layers        | One call per layer with the concept as reference; transparent except the sky; wide (3072 x 1152) for tiled layers                         |
    | Sheets        | Props, tree and foreground on transparent backgrounds with wide gaps, numbered in reading order                                           |
    | Hero          | Two poses (rest, jump) side by side on flat magenta; [reframe_stills.py](scripts/reframe_stills.py) makes square first frames             |
    | Run clip      | Image to video from the rest pose: "like a treadmill", locked camera, magenta that never changes; 5 s is enough                           |
    | Run loop      | [run_loop.py](scripts/run_loop.py): key, find the cleanest one-period loop, write `hero_run/run_001.png`...                               |
    | Cut and key   | [prep_assets.py](scripts/prep_assets.py) `cut` (sheets), `key` (magenta stills), `tile` (seamless wide layers)                            |
    | Hulls         | [trace_hulls.py](scripts/trace_hulls.py): convex hull of each prop's alpha into `shapes.json`                                             |
    | Measure       | [measure_set.py](scripts/measure_set.py): horizon, ground line, island and pedestal tops ([example config](examples/config.json))         |
    | Build         | [build_scene.py](scripts/build_scene.py) `-- --set SET_DIR --review`, then `--final`                                                      |
    | New look      | New concept frame, the same eight pictures and clip, same file names, a new `config.json`; nothing else changes                           |
    | Wait          | `jobs_wait` with up to 32 `job_ids`; on timeout re-call with the returned `pending_job_ids`                                               |
    
    ## Worked example: a jungle run, then a snow day
    
    Pass `team_id` and `project_id` on every call (the `scenario` skill covers picking them).
    
    1. **Concept frame.** `recommend { "capability": "txt2img", "prompt": "<the user's request>; needs reference image input, transparent background, exact width and height" }`, `model_schema_get` the pick, price two takes with `model_run { "model_id": "<pick>", "dry_run": true, "parameters": { "prompt": "...", "width": 1536, "height": 864, "background": "opaque", "numOutputs": 2 } }` and the screenshot prompt from [references/prompts.md](references/prompts.md) section 1. Run it, `jobs_wait` if it returns a `job_id`, `asset_display` both takes and keep the one whose layers you can name one by one. Its asset id is the reference for everything that follows.
    2. **Layers, one call each.** The same pick with `referenceImages: ["<concept id>"]`: the sky (2560 x 1440, opaque), the far layer and the mid layer (3072 x 1152, transparent), the ground strip (2048 x 768, transparent, a flat walkable top at about 30 percent from the top, no end caps) and a foreground sheet of five plant clusters (3072 x 1024, transparent). Price them, then run them 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), retiring each wave with `jobs_wait`.
    3. **Props and tree.** A props sheet (2048 x 1536, transparent): numbered props in a loose grid with wide gaps (a pot, a tall vessel, a lidded jar, a heavy statue, the fruit that will fall, a coin, a floating island, a flat pedestal), and the tree alone (1024 x 1536) with no fruit in its crown.
    4. **Hero.** Two poses on flat magenta (1536 x 1024, opaque, two takes): rest on the left, jump on the right, both the same size, pure side profile facing right. `asset_download` the kept take, then `python3 scripts/reframe_stills.py hero.png --name hero --out frames` writes `hero_rest.png` and `hero_action.png` (lower `--figure` for a long tail). Upload the rest frame with `upload_asset` and `upload_asset_complete`.
    5. **Run clip.** `recommend { "capability": "img2video", "prompt": "<the user's request>; first frame input, 5 seconds" }`, `model_schema_get`, price a five-second clip with the rest frame's asset id in the schema's first-frame field and the treadmill prompt ([prompts.md](references/prompts.md) section 5), with audio off and a 1:1 aspect ratio where the schema has those fields (the loop keeps only silent frames, and the jump still's scale assumes the clip is the square first frame resized), run it, `jobs_wait` (minutes, re-called as needed), `asset_download`.
    6. **Process.** Into `jungle/game/` (the sky stays one wide card: copy it as `sky.png`):
       `python3 scripts/prep_assets.py tile raw/sea.png jungle/game/sea_tile.png 384`, the same for the mid layer (`jungle_tile.png`; a fade of one eighth of the width) and the ground (`ground_tile.png`, `256` for 2048 px);
       `python3 scripts/prep_assets.py cut raw/props.png jungle/game pot_round,amphora,jar,idol,coconut,coin,island,pedestal`;
       `CUT_GROW=2 python3 scripts/prep_assets.py cut raw/foreground.png jungle/game fg_bush,fg_fern,fg_hibiscus,fg_boulder,fg_curl`;
       `python3 scripts/prep_assets.py key frames/hero_action.png jungle/game/hero_jump.png`;
       `python3 scripts/run_loop.py clips/hero_run.mp4 jungle/game/hero_run` (it prints `run_n`, `run_fig_px`, `run_pad_px` and `run_canvas_px`, the clip's height);
       `python3 scripts/trace_hulls.py jungle/game`. Copy the tree (keyed or already transparent) as `palm.png`.
    7. **Measure.** `python3 scripts/measure_set.py jungle/game --ground-h 4.3` (the ground card's height in meters, `ground_h` in the config) prints the horizon, the walkable line (already moved 10 cm down so the feet sink into the grass lip), the island and pedestal tops and the jump still's feet row. Write them with the run numbers into `jungle/game/config.json`. Layer heights, the fruit positions and the hero's height in meters are set by eye on the review stills: start from [examples/config.json](examples/config.json) ([references/pipeline.md](references/pipeline.md) lists every key).
    8. **Build and review.** `blender -b --factory-startup --python-exit-code 1 -P scripts/build_scene.py -- --set jungle --review`. Outputs land in the folder holding `jungle/` (or `--out DIR`): `platformer_scene<tag>.blend`, `review<tag>/` and, with `--final`, `platformer<tag>.mp4`; a rebuild over them needs `--force`. Look at the 11 review stills: feet on the ground, the tree standing on the ground, no seam, the tower standing until the hero reaches it, every piece inside the frame. Then `--final` renders the film. Before delivery, walk [references/checks.md](references/checks.md).
    9. **A new look.** Generate a new concept frame (a white winter valley, a snow leopard cub in a red hat), the same eight pictures and clip with it as reference, process into `snow/game/` with the same names, measure a new `config.json` (with `"tag": "_snow"`) and run the same build with `--set snow`. Two looks line up frame for frame. The snow clip's background drifted, so its loop came from `run_loop.py clip.mp4 snow/game/hero_run --key border --from 60 --to 124 --skip 0 --periods 8:16 --clean`.
    10. **Ledger.** One line per Scenario job with model, parameters, inputs, outputs, job id, cost, kept or not, and why.
    
    ## Common mistakes
    
    - Trusting the grass tips as the ground line: every look's hero floated 3 to 8 cm plus the run bob until the line moved about 10 cm into the lip, which `measure_set.py` now applies.
    - Keying a drifting clip on fixed magenta: one run clip went from magenta through red and yellow to green. Use `--key border` and take the loop from a stretch away from any red or pink on the character.
    - Cutting leafy sheets with the default grow: the five foreground plants came out as four pieces until the cut ran with `CUT_GROW=2`. Check the printed count against the names.
    - Not checking enclosed pink after keying: `prep_assets.py key` keeps pink enclosed by the silhouette (a cat's inner ears), but look at ears, tongues and flowers.
    - Hard-coding the run length: the cat and the snow leopard looped in 12 frames, the red panda in 9. Copy `run_n` from `run_loop.py`'s output.
    - A landmark at the end of a wide layer: the tile seam goes where the silhouette is lowest, so ask for content spread across the width.
    - Passing a local path or URL to `model_run`: upload the file first and pass the asset id.
    - Hard-coding model ids or prices, or polling `job_get` instead of re-calling `jobs_wait`.
    
    Changing `build_scene.py` itself (depths, parallax factors, physics): read [references/pipeline.md](references/pipeline.md) first, which records the decisions the reference shot paid for.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related