Paperdoll - SuIT-pub/Mind-the-School GitHub Wiki
Audience: Developers writing Mind the School scenes who need a layered, live sprite on screen β a character built from a body + head that changes pose, outfit, mood and mouth mid-conversation, moves, zooms, blurs, shakes, flips, desaturates or tints, sitting over a (blurred) background.
Scope: The paperdoll system (
paperdoll.rpy). It is the compositor the game uses for talking characters, driven by thePerson.register_paperdoll()/Person.display()helpers incharacter.rpy. But the engine itself knows nothing about characters: it stacks any set of pattern-resolved image layers. Give it sprites and it will render anything β props, machines, multi-part scenery.
Inside an event, hand a character off from a static scene image to a live paperdoll:
$ emiko.register_paperdoll() # build the layered object
$ paperdoll_manager.set_background(
"images/background/office building/secretary 6 1 0.webp",
blur = True) # blurred backdrop behind her
$ emiko.display( # first frame + how she enters
PDAImage(pose = "1", outfit = "uniform", level = 6, mood = "neutral", mouth = "closed"),
"upper_body",
"outside")
$ emiko.display(PDAPreset("upper_body_center", duration = 0.4)) # slide her in (string form has no duration)
$ emiko.display(PDAImage(mood = "suspicious", mouth = "open")) # change expression
emiko.say "Oh. You noticed."
$ emiko.display(PDAImage(mood = "neutral", mouth = "closed"))
# no teardown: the next image.show(...) or end_event already paperdoll_manager.clear()sMore copy-paste recipes (two people, flip/walk-off, temp presets, split backdrop, β¦) are in Β§8 Worked examples.
The three moving parts:
-
paperdoll_managerβ the global compositor. Holds the registered objects and the background. Auto-created bybegin_eventin normal events (you rarely touch its lifecycle yourself). -
A paperdoll object β a named stack of layers, each layer a pattern that
resolves to an image from the current
values(pose,outfit,mood, β¦). -
Actions (
PDAβ¦) β the verbs you pass to.display(): change the image, move, blur, pause, shake, flip, desaturate, tint, or expand a preset. A bare string is treated as that preset's name ("close_body_left"β‘PDAPreset("close_body_left")).
- What the paperdoll system is
- Manager lifecycle
- Layers & pattern resolution
- Registering an object
- Values & the config model
- Displaying: the action pipeline
- The action catalog
- Worked examples
- Presets
- Backgrounds
- display_size & high-resolution assets
- Overrides: per-layer conditional nudges
- Beyond characters: displaying anything
- Paperdoll editors (tuning tools)
- Conventions
- Troubleshooting
- Reference tables
A paperdoll is a picture assembled from stacked image layers and shown as a
single, animatable actor. Each layer is drawn from a pattern β a path with
<placeholders> β that is filled in from the object's current values and
resolved to a concrete file at display time.
For a character the stack is two layers:
-
bottom β the body:
β¦ <char_var> <pose> <outfit> <level> <state> <extra1>.png -
top β the head:
β¦ <char_var> <pose> <mood> <mouth> <look> <extra2>.png
look is "follow" (gazing at the player, house default) or "avert" (looking
away). extra1 / extra2 are empty unless a scene needs a special sprite that
doesn't fit the other keys β a one-off body or head file. They are in alt_keys, so
a missing or empty extra falls back to $ on disk (the normal sprite). look is
not an alternative: both gaze files have to exist.
Changing outfit re-resolves only what the pattern references; changing mood,
mouth or look swaps the head without touching the body. Because both layers share
the same position/zoom/blur transforms, they move as one figure.
The engine is deliberately generic. It has no concept of "character", "mood" or "outfit" β those are just keys in the pattern. A paperdoll can have one layer or many, and its patterns can point at anything. See Β§13.
The compositor is a single global, paperdoll_manager (a PaperdollManager).
-
init_paperdoll_manager()β creates a fresh manager. -
unload_paperdoll_manager()β clears every object + background, then drops it.
In events you normally do neither. begin_event calls
init_paperdoll_manager() and end_event calls unload_paperdoll_manager()
(event.rpy), so the manager already exists inside a scene and is gone afterwards.
$ image.show(n) (Image_Series.show) also runs paperdoll_manager.clear() before
it puts up the still. Cutting to a scene image or ending the event is therefore
self-cleaning β you do not need a trailing clear_display().
Call Person.clear_display() / paperdoll_manager.clear() only on demand: a
blank beat before something that does not auto-clear (call show_image, a custom
renpy.show, an empty pause). It hides every object plus the backdrop, not one
character. You only init/unload the manager by hand outside the event flow (the
debug editor does exactly that β see Β§14).
Manager surface you use directly:
| Method | Purpose |
|---|---|
register_obj(key, *patterns, **kwargs) |
register a paperdoll object under key
|
get_obj(key) |
fetch the Paperdoll_Obj
|
display(key, *actions) |
run the action pipeline for that object; a str is wrapped as PDAPreset
|
set_background(pattern, β¦) |
set/replace the backdrop (zorder -100) |
set_background_split(left, right, β¦) |
split backdrop (left half + right half) |
hide_background() |
drop the backdrop |
clear() |
hide all objects and the background |
Each layer's pattern is resolved the same way image paths are resolved everywhere in the game (see Images and Selectors Β§7):
- Every
<key>in the pattern is replaced with the object's matching value. -
refine_image_with_alternatives(pattern, alt_keys, **values)generates the path plus fallbacks: for each key listed inalt_keys, a variant is produced where that key is replaced by the wildcard$(so a missing per-level/mouth/state file can fall back to a generic one). Analt_keysvalue that is empty orNoneis the wildcard β it becomes$immediately, not a blank filename token. -
find_available_images(...)walks the candidates in priority order and returns the first one that actually exists (renpy.loadable). If none exist, the layer resolves to""and shows nothing.
So alt_keys decides which values are allowed to be "smoothed over" by a wildcard
when a specific asset is missing β you list the fine-grained keys (level, mouth,
state, char_var, extra1, extra2) and keep the structural ones (pose, outfit, look)
exact.
Resolution is lazy and cached per layer: display_paperdoll_image only resolves
a layer whose image[index] is still "". PDAImage changes the values and forces
re-resolution; pure movement/blur/flip actions reuse the already-resolved image.
Person.register_paperdoll(*overrides, **kwargs) (in character.rpy) is the wrapper
you use 99% of the time. It registers a two-layer object under the person's name with
the body/head patterns and sensible default values:
$ emiko.register_paperdoll() # house defaults, then Person.paperdollDefaults
$ luna.register_paperdoll(level = 10, mood = "happy", mouth = "closed") # call-time kwargs win
$ emiko.register_paperdoll(color = "#00000088") # start 50% darkened (not a pattern value)House defaults it seeds: alt_keys = ["level", "mouth", "state", "char_var", "extra1", "extra2"], mood="happy", pose=1, outfit="uniform", level=1, mouth="closed",
state="", blur=0.0, char_var=1, look="follow", extra1="", extra2="", and
display_size=(600, 1080).
Character pattern keys (filled by those defaults, then PDAImage):
| Key | Layer | Default |
$ fallback |
Meaning |
|---|---|---|---|---|
char_var |
both | 1 |
yes | body/head variant |
pose |
both | 1 |
no | shared pose index |
outfit |
bottom | "uniform" |
no | clothes set |
level |
bottom | 1 |
yes | campus-level / nude step |
state |
bottom | "" |
yes | optional body state |
extra1 |
bottom | "" |
yes | optional special body sprite |
mood |
top | "happy" |
no | expression |
mouth |
top | "closed" |
yes | mouth shape |
look |
top | "follow" |
no |
"follow" (at the player) or "avert" (looking away) |
extra2 |
top | "" |
yes | optional special head sprite |
extra1 / extra2 stay empty for the normal pair. Set them only when you have a
file whose token doesn't fit pose/outfit/mood/β¦ (PDAImage(extra1 = "wet") picks
the body file with that extra token). Empty or missing extras resolve via $.
look does not: ship both follow and avert heads, then switch with
PDAImage(look = "avert").
Per-person defaults. A Person can carry paperdollDefaults β a dict of value
keys that replace the matching house seeds at registration. Use this when a
character's art set doesn't start at the house defaults (Emiko has no level-1
uniform, so she seeds level=5). Merge order is house defaults β
Person.paperdollDefaults β register_paperdoll(**kwargs). Call-time kwargs
always win, so a scene can still force a different level.
# on the Person definition in character.rpy:
paperdollDefaults = {"level": 5}
$ emiko.register_paperdoll() # seeds level 5 instead of 1
$ emiko.register_paperdoll(level = 7) # call-time kwargs still winAny PaperdollOverrides and PaperdollPresets declared on the person
(Β§12, Β§9) are merged in
automatically.
Under the hood that calls the manager directly. This is also how you'd register a non-character paperdoll:
paperdoll_manager.register_obj(
key, # unique name; also the show tag prefix
pattern_layer0, # one pattern string per layer, bottom β top
pattern_layer1,
...,
alt_keys = [...], # keys allowed to fall back to the `$` wildcard
display_size = (w, h), # logical on-screen size (see Β§11); omit for native px
display_sizes = [...], # per-layer size overrides (optional)
overrides = [...], # list[PaperdollOverride] (optional)
presets = [...], # list[PaperdollPreset] β temp "{key}:{preset}" (optional)
parent = "aona", # parent object key; must already be registered (optional)
local = {"alignX": 0.08}, # relative transform when parented (optional)
space = "screen", # "screen" offsets, or "parent" = 0β1 on parent box
behind = False, # True β zorder -1 (still above the background)
config = {...}, # initial alignX/alignY/rotation/zoom/blur/bw/color (optional)
color = "#00000000", # RGBA tint; alpha = mix amount (optional; not a pattern value)
**initial_values, # the values that fill the <keys> in the patterns
)Layer order is draw order: pattern 0 is drawn first (bottom), later patterns on top.
A paperdoll can take another registered object as parent. The child keeps its own
values, presets, and tags; only its transform follows the parent.
-
configis always world space (what the show transforms read). - When parented,
PDAMove/PDAFlipwritelocal(relative to the parent), then compose intoconfig. -
spacechooses howlocal.alignX/alignYare interpreted:-
"screen"(default) β screen-unit offsets added to the parent (alignXflips with parent facing).0.0= same position as the parent, not βleft of the spriteβ. -
"parent"β0β1on the parent'sdisplay_sizebox (0left/top,0.5centre,1right/bottom). X mirrors around the box centre when the parent flips. Needs a parentdisplay_size(characters already have(600, 1080)).
-
- Compose folds the full ancestor chain (nested parents are allowed:
mugβhandβaona). - Move / flip / shake on a parent fan out to the whole subtree with the same duration. Register ancestors first; cycles are rejected with a log error.
See Β§8 Parenting a prop for a recipe.
Two distinct dictionaries drive a paperdoll:
Values β fill the <keys> in the patterns (pose, outfit, mood, mouth,
look, level, state, char_var, extra1, extra2, blur, β¦). Set at
registration, updated by PDAImage. This is what image each layer shows.
Config β the transform state, shared by all layers. This is where/how the stack is drawn:
| Config key | Meaning | Default |
|---|---|---|
alignX |
horizontal alignment: 0.0 left edge at the left of the screen, 1.0 right edge at the right, at any zoom. Values < 0 / > 1 offset that nearest edge by screen-fractions (-0.5 = left edge half a screen off the left; -1.5 fully off). Zoom grows around the sprite edge, not around a point beside it. |
-0.5 |
alignY |
vertical position (ypos) |
0.0 |
zoom |
scale multiplier (on top of the display-size base scale) | 1.0 |
rotation |
rotation | 0.0 |
blur |
gaussian blur radius | 0.0 |
bw |
grayscale when True
|
False |
flip |
horizontal mirror (xzoom; 1.0 unflipped, -1.0 mirrored) |
1.0 |
color |
Mix color. Accepts #rgb / #rgba / #rrggbb / #rrggbbaa, names (green, blue, β¦), and name:0.5 / name:50% for mix amount. Alpha is the mix: 00 leaves the sprite, ff replaces visible pixels with the RGB (sprite alpha kept). #00000088 or black:0.5 is a 50% darken. Stored as #rrggbbaa. Not added via get_config. |
#00000000 |
Each layer additionally has a config override (config_override[index]) β a
small per-layer delta added on top of the shared config. get_config(key, index)
returns config[key] + config_override[index][key]. Use that for the numeric keys
(alignX, alignY, zoom, rotation, blur). bw and color live on the
shared config but are not floats β read them with is_bw() / get_color(). The
shared config is what your actions move; the override is where
PaperdollOverride
nudges an individual layer (e.g. shifting only the body for a bulky outfit).
.display(*actions) (β paperdoll_manager.display β the display_paperdoll_image
label) does two things:
-
Wraps bare strings. Any
strin*actionsbecomesPDAPreset(that_string)before the pipeline runs.$ emiko.display("close_body_left")is$ emiko.display(PDAPreset("close_body_left")). Overrides (duration, β¦) still need the constructor:PDAPreset("upper_body_center", duration = 0.4). -
Ensures every layer is shown. Any layer still unresolved (
image == "") is resolved now and shown with the current position, blur, bw and color transforms. -
Runs the actions in order.
run_paperdoll_actionspops each action and calls the labelpaperdoll_action_<key>(e.g.paperdoll_action_move). APDAPresetis expanded first and its actions run recursively.
Each .display() call is one step. A conversation is a sequence of .display()
calls interleaved with dialogue β change the expression, say a line, change it again:
$ emiko.display(PDAImage(mood = "happy", mouth = "open"))
emiko.say "Gladly."
$ emiko.display(PDAImage(mood = "happy", mouth = "closed"))Motion (PDAMove, PDABlur, PDABw, PDAColor, PDAFlip) with a duration > 0 animates; the transition
is scaled by the player's transition-speed preference automatically. Duration does not
block the pipeline β follow a timed action with PDAPause of the same length if the next
action would replace the transform. Teardown is automatic: $ image.show(β¦) and
end_event both paperdoll_manager.clear(). Use clear_display() only for a
mid-scene blank that those two will not cover.
All actions are PDAction subclasses; pass any number to a single .display(). A
bare string is accepted too and wrapped as PDAPreset.
| Action | Constructor | Effect |
|---|---|---|
| Image | PDAImage(**values) |
merge new values, re-resolve every layer (change pose/outfit/mood/mouth/look/extra1/β¦) |
| Move | PDAMove(alignX=-100, alignY=-100, zoom=-100, duration=0.0) |
reposition/scale; omitted args keep the current value (sentinel < -10); duration eases |
| Blur | PDABlur(blur, duration=0.0) |
set blur radius, optionally eased |
| Bw | PDABw(bw=True, duration=0.0) |
toggle grayscale (saturation), optionally eased |
| Color | PDAColor(color, duration=0.0) |
mix toward a color; alpha / name:0.5 = mix amount; optionally eased |
| Flip | PDAFlip(flip=False, duration=0.0) |
mirror horizontally (xzoom); duration eases, 0.0 snaps |
| Shake | PDAShake(duration=1.0, max_distance=15) |
deterministic shake; all layers share one seed so they shake together |
| Pause | PDAPause(duration=0.0, transition=True) |
wait; transition scales the wait by transition-speed preference |
| Preset |
PDAPreset(preset, **overrides) or "preset"
|
expand a named preset into its actions (see Β§9) |
Notes:
-
PDAMovesentinels. Leaving an argument at its default (a large negative number) means "keep current"; only the values you pass change. That's whyPDAMove(alignX = 0.68, duration = 0.4)slides horizontally without altering zoom. -
Delta strings. Any numeric action field accepts
"+0.5"/"-0.1"(string must start with+or-). At apply time that delta is added to the current value β e.g.PDAMove(alignX = "+0.1")nudges right from wherever they are;PDABlur("+5")adds to the current blur. Plain"0.5"(no sign) is absolute. Preset overrides resolve duration/max_distance deltas against the action's prior number immediately (PDAPreset("x", duration = "+0.4")). -
alignXis zoom-independent.0.0is always left-aligned,1.0always right-aligned; extra zoom grows around that sprite edge (or the interpolated point in between). Values< 0/> 1keep that nearest edge as the zoom pivot and shift it by screen-fractions β-0.5parks the left edge half a screen off the left,-1.5(theoutsidepreset) is fully off at any zoom. Do not use nativexalignfor this: it zooms around a point beside the sprite, which pulls a zoomed figure back on-screen. -
Zoom and position ease together.
alignX,alignYandzoominterpolate in the same move βPDAMove(zoom = 2.0, alignX = 1.0, alignY = -0.1, duration = 1.0)is one ease, not two sequential actions. -
Sequential timed actions need a pause.
durationonly drives the ATL ease; the next action in the same.display()starts immediately and replaces the transform. Chain withPDAPauseof the same length:PDAFlip(True, duration = 1.0), PDAPause(1.0), PDAMove(alignX = 1.0, duration = 1.0), PDAPause(1.0). -
Flip is stored.
PDAFlipwritesconfig["flip"](1.0/-1.0), so later moves and image swaps keep the facing.duration = 0.0(the default) snaps as before. The ease pivots around the sprite's horizontal center, not thexalignpoint β otherwise a right-aligned figure would swing off-frame asxzoompasses through 0. -
PDAImageis the only value-changing action. Move/blur/flip/bw/color operate on the already resolved images and don't re-read the filesystem. Gaze and extras are values too:PDAImage(look = "avert"),PDAImage(extra1 = "wet"). -
Bare preset strings.
$ emiko.display("close_body_left")isPDAPreset("close_body_left"). Mix them with real actions:$ emiko.display(PDAImage(mouth = "open"), "upper_body_center"). A string cannot carry overrides βPDAPreset("upper_body_center", duration = 0.4)still needs the constructor. -
Tint mix, not multiply.
PDAColor("#00000088")orPDAColor("black:0.5")lerps each visible pixel 50% toward black (a darken).#ff0000aa,#f00:0.67,#f00a, and"red:0.67"are a red wash. Short hex (#0ac,#fff8) expands the usual way (#0ac=#00aaccff).#00000000,"transparent", orNoneclears it. Passcolor="#00000088"toregister_paperdoll/register_obj(orconfig={"color": ...}) to start already tinted β it is stripped before values, so it is not a pattern key. Tint and grayscale share one color matrix, so aPDAMovekeeps both. Color is per-object (like bw) and does not fan out to parented children. -
Named colors.
black,white,gray/grey,silver,lightgray,red,darkred,maroon,crimson,salmon,coral,tomato,pink,hotpink,orange,orangered,gold,yellow,khaki,brown,chocolate,tan,beige,green/lime,darkgreen,olive,teal,cyan/aqua,turquoise,blue,navy,darkblue,indigo,azure,purple,violet,magenta/fuchsia,ivory,transparent.green/limeare bright#00ff00;darkgreenis the darker one. Other CSS names go through Ren'PyColor. Mix amount:"green:0.5"or"green:50%". -
Order matters. Actions in one
.display()apply left-to-right; e.g. an image swap followed by a move.
Copy-paste recipes. Each assumes begin_event already created paperdoll_manager.
You do not need a trailing clear_display(): $ image.show(n) and end_event
already paperdoll_manager.clear(). Person.clear_display() is the same call β
it hides every object plus the backdrop β so use it only for a mid-scene blank
that those two will not cover, and never once per character.
| I want to⦠| Jump |
|---|---|
| Hand off a still to a talking sprite | Scene image β talking figure |
| Open/close the mouth around dialogue | Expression while they talk |
| Gaze away / special sprite token | Gaze and special sprites |
| Two characters, one each side | Two people, left and right |
| Enter from off-screen / leave | Slide in from off-screen |
| Zoom and slide in one ease | Zoom and slide in one move |
| Flip, then walk off, then return | Timed sequence |
| Repeat a framing only in this event | Scene-local presets |
| Character-specific framing / pose pack | Object presets |
| Prop that follows a character | Parenting a prop |
Nested parents (mug β hand β character) |
Nested parenting |
| Two locations on one screen | Split backdrop |
| Shake / blur / grayscale / tint | Emphasis |
| Hide one person, keep the other | Hide one person |
| Change location behind them | Change the backdrop |
| Swap outfit or nude level | Outfit or level swap |
| Stack something that isn't a character | Non-character stack |
The house hand-off: show the establishing shot, then blur that location behind a live
sprite. lab_intro does this with an Image_Series step as the backdrop.
$ image = Image_Series("images/events/lab_intro/lab_intro <step>.webp")
$ image.show(2)
headmaster "How do you feel?"
$ image.hide()
$ secretary.register_paperdoll()
$ paperdoll_manager.set_background(image[2], blur = True)
$ secretary.display(
PDAImage(pose = "21", mood = "neutral", mouth = "open"),
"close_body_center")
secretary "Hmm. A little warmer."
$ secretary.display(PDAImage(mood = "happy", mouth = "closed"))
...
$ image.show(3) # still; also paperdoll_manager.clear()set_background(image[step], blur = True) takes the already-resolved series path; see
Β§10.
PDAImage is the only action that re-resolves files. Change mouth (and mood /
look) around each line; leave pose/outfit/zoom alone so the figure doesn't jump.
$ emiko.display(PDAImage(mood = "happy", mouth = "open"))
emiko.say "Gladly."
$ emiko.display(PDAImage(mood = "happy", mouth = "closed"))A pose swap is the same call β current alignX / zoom / flip stay in config:
$ aona.display(PDAImage(pose = "23", mood = "happy", mouth = "closed"))look is "follow" (at the player, the house default) or "avert" (looking away).
It lives on the head layer and is not in alt_keys, so both files need to exist.
extra1 (body) and extra2 (head) are optional filename tokens for a one-off sprite
that the other keys can't describe. They default to "" and are in alt_keys, so
the normal $ file is used until you set one.
$ emiko.display(PDAImage(look = "avert")) # looking away
$ emiko.display(PDAImage(look = "follow")) # looking at you again
$ emiko.display(PDAImage(extra1 = "wet")) # special body file
$ emiko.display(PDAImage(extra2 = "glasses")) # special head file
$ emiko.display(PDAImage(extra1 = "", extra2 = "")) # back to the normal pairThe extra token is whatever you put in the filename (β¦ wet.png / β¦ glasses.png).
Leave them unset for every ordinary beat.
alignX is zoom-independent: 0.0 left edge, 1.0 right edge, at any zoom. Built-in
close_body_left / _right already encode that.
$ aona.register_paperdoll()
$ ikushi.register_paperdoll()
$ paperdoll_manager.set_background(image[1], blur = True)
$ aona.display(
PDAImage(pose = "7", outfit = "uniform", level = 1, mood = "suspicious", mouth = "closed"),
"close_body_left")
$ ikushi.display(
PDAImage(pose = "7", mood = "suprised", mouth = "open"),
PDAFlip(True),
"close_body_right")
aona.say "Trust me."
$ aona.display(PDAImage(mouth = "closed"))
$ ikushi.display(PDAImage(mood = "sad", mouth = "open"))
ikushi.say "...if you say so."
# next image.show(...) or end_event clears both figures and the backdropPDAFlip(True) is stored on the object, so later PDAImage / PDAMove keep the
facing. Nudge further inward with a second move if the preset sits too tight on the
edge: "close_body_right", PDAMove(alignX = 0.85).
Park them in outside (alignX = -1.5) at duration 0, then ease a positioned preset:
$ emiko.display(
PDAImage(pose = "1", outfit = "uniform", level = 6, mood = "neutral", mouth = "closed"),
"upper_body",
"outside")
$ emiko.display(PDAPreset("upper_body_center", duration = 0.4))Same idea to leave: ease outside (or alignX = 2.5 off the right) and pause so
the ease can finish before the next show replaces it.
$ secretary.display(
PDAImage(mood = "happy", mouth = "closed"),
PDAPreset("outside", duration = 1.0),
PDAPause(duration = 1.0))alignX, alignY and zoom ease together in a single PDAMove. Do not split
them into two PDAMoves unless you want two sequential steps (and then you still
need a PDAPause between them).
$ ikushi.display(
PDAImage(pose = "1", mood = "neutral", mouth = "closed"),
PDAFlip(True),
PDAMove(zoom = 2.0, alignX = 1.0, alignY = -0.1, duration = 1.0))0.0 / 1.0 stay left / right at zoom 2 or 3 β you no longer compensate with 1.3
or 6.0. Values < 0 / > 1 shift that nearest edge by screen-fractions (-1.5
is fully off the left at any zoom).
duration only drives the ATL ease. The next action in the same .display() starts
immediately and replaces the transform. Chain with PDAPause of the same length.
Flip eases around the sprite's horizontal centre.
$ ikushi.display(
PDAFlip(False, duration = 0.5),
PDAPause(0.5),
PDAMove(alignX = 2.5, duration = 1.0),
PDAPause(1.0))
# she is off-frame; Aona can react
$ aona.display(PDAImage(pose = "23", mood = "happy", mouth = "closed"))
# return, still flipped from config β set facing again if she turned
$ ikushi.display(
PDAFlip(True),
PDAImage(mood = "sad"),
PDAMove(alignX = 1.0, duration = 1.0),
PDAPause(1.0))Two .display() calls with dialogue between them also wait β you only need
PDAPause when nothing else holds the beat.
register_temp_preset is the same as register_preset but is discarded when the
manager unloads (end_event). Use it for framings you will repeat in this event
and don't want in the global table. You cannot overwrite a permanent name.
$ register_temp_preset("cbl", PDAMove(alignX = 0.0, alignY = -0.1, zoom = 2.0))
$ register_temp_preset("cbr", PDAMove(alignX = 1.0, alignY = -0.1, zoom = 2.0))
$ aona.display(PDAImage(pose = "7", mood = "suspicious"), PDAPreset("cbl", duration = 0.0))
$ ikushi.display(PDAImage(pose = "7", mood = "suprised"), PDAFlip(True), PDAPreset("cbr"))Left half of one source, right half of another, white divider. Each side accepts the
same sources as set_background (path, pattern, image[step], β¦).
$ paperdoll_manager.set_background_split(
"images/background/school building/1 0 1.webp",
"images/background/office building/secretary 6 1 0.webp",
blur = True)
$ emiko.display(PDAImage(...), PDAPreset("close_body_left"))
$ luna.display(PDAImage(...), PDAPreset("close_body_right"))$ emiko.display(PDAShake(duration = 0.6, max_distance = 20))
$ emiko.display(PDABlur(10.0, duration = 0.4), PDAPause(0.4))
$ emiko.display(PDABw(True, duration = 0.5), PDAPause(0.5)) # flashback / memory
$ emiko.display(PDABw(False, duration = 0.5), PDAPause(0.5)) # restore colour
$ emiko.display(PDAColor("#00000088", duration = 0.4), PDAPause(0.4)) # 50% darken
$ emiko.display(PDAColor("black:0.5")) # same, by name
$ emiko.display(PDAColor("#fff8")) # white wash, short hex
$ emiko.display(PDAColor("green:0.5", duration = 0.3), PDAPause(0.3)) # 50% green
$ emiko.display(PDAColor("#00000000", duration = 0.4), PDAPause(0.4)) # clear tintShake uses one seed per object so body and head move together. Blur/bw/color, like move,
need a pause if the next action would replace the transform. Alpha in the hex (or
name:0.5 / name:50%) is the mix amount (88 β half, ff = solid RGB on
visible pixels). #rgb / #rgba work: #0ac β #00aacc, #fff8 β white at
mix 88.
Person.clear_display() is paperdoll_manager.clear(): it hides everyone and
the backdrop. To drop only one figure mid-scene, hide that object's layers and
leave the other registered:
$ paperdoll_manager.get_obj(ikushi.name).hide_all_images()
# Aona stays; Ikushi is gone. Bring her back with a normal display:
$ ikushi.display(
PDAImage(mood = "sad", mouth = "open"),
PDAFlip(True),
PDAPreset("close_body_right", duration = 0.0))Walking her off-screen (PDAMove(alignX = 2.5, duration = 1.0) + PDAPause) is
the animated version of the same idea.
set_background replaces the manager's one backdrop; figures already on screen
stay. Same source forms as the first call (path, pattern, image[step], β¦).
$ paperdoll_manager.set_background(image[5], blur = True) # new location, still blurred
$ paperdoll_manager.hide_background() # figures only, no backdropTo cut to a full-screen still, just $ image.show(n) β Image_Series.show
already paperdoll_manager.clear()s. No extra hide.
Same action as a mood change β PDAImage re-resolves layers whose patterns use
those keys. Framing (alignX / zoom / flip) is unchanged.
$ emiko.display(PDAImage(outfit = "casual", level = 1, mouth = "closed"))
$ emiko.display(PDAImage(level = 3)) # nude-level step; body layer onlyOn students, level= is the school-level outfit step
(uniform getting shorter, then disappearing). Staff/parents follow their own
tracks (teacher / parent / secretary). Do not put a level-1 uniform on a
level-8 campus unless the scene is specifically about someone who has not
changed.
If a bulky outfit sits a few pixels off, that is a PaperdollOverride,
not a new PDAMove.
Declare named action packs on the Person (or pass presets= to register_obj).
On register they become temp keys "{name}:{key}". Bare preset names must not
contain : β that character is the cross-object scope separator.
# on the Person definition:
paperdollPresets = [
PaperdollPreset(
"intro",
PDAImage(pose = "12", mood = "neutral", mouth = "closed"),
PDAMove(alignX = 0.85, alignY = -0.1, zoom = 2.0),
),
]
$ aona.register_paperdoll()
$ aona.display(PDAPreset("intro"), PDAImage(mouth = "open"))
# another object can borrow it:
$ emiko.display(PDAPreset("aona:intro"))Lookup order for PDAPreset(arg) while displaying obj: try "{obj.key}:{arg}",
then arg as-is. See Β§9.
Register the parent first, then the child with parent= and a local offset.
Moves and flips on the parent ease the child with them.
Screen offsets (space="screen", default) β local.alignX is added in screen
units:
$ paperdoll_manager.register_obj(
"mug",
"images/props/mug/mug <state>.png",
parent = aona.name,
local = {"alignX": 0.08, "alignY": 0.02, "zoom": 0.4},
state = "full",
)Parent box (space="parent") β local.alignX/Y are 0β1 on the parent's
display_size box (0.5 = centre of Aona's 600Γ1080 box):
$ aona.register_paperdoll()
$ paperdoll_manager.register_obj(
"mug",
"images/props/mug/mug <state>.png",
parent = aona.name,
space = "parent",
local = {"alignX": 0.72, "alignY": 0.45, "zoom": 0.35},
state = "full",
)
$ aona.display(PDAImage(pose = "7"), PDAPreset("close_body_right"))
$ paperdoll_manager.display("mug", PDAImage(state = "full"))
$ aona.display(PDAMove(alignX = 0.5, duration = 0.5), PDAPause(0.5))
# mug stays on that point of her box while she moves / flips$ aona.register_paperdoll()
$ paperdoll_manager.register_obj("hand", "...", parent = aona.name, local = {"alignX": 0.1})
$ paperdoll_manager.register_obj("mug", "...", parent = "hand", local = {"alignX": 0.02, "zoom": 0.5})A move on aona updates hand and mug. A move on hand only changes hand.local
(relative to Aona) and re-composes mug.
Skip Person.register_paperdoll and register patterns yourself β Β§13.
A preset is a reusable named list of actions β the standard framings, so scenes
don't hand-tune the same alignX/zoom over and over.
register_preset("upper_body", PDAMove(alignY = -0.1, zoom = 3.0))
register_preset("upper_body_center", PDAPreset("upper_body"), PDAMove(alignX = 0.5))Use them via PDAPreset("name", **overrides), or pass the name as a string to
.display() ("upper_body_center" β‘ PDAPreset("upper_body_center")). Overrides
are applied to every action in the preset that supports them (so
PDAPreset("upper_body", duration = 0.4) adds a 0.4s ease to the preset's move). A
string cannot carry overrides. Presets can reference other presets, as
upper_body_center does.
Built-in presets (paperdoll.rpy):
| Preset | Framing |
|---|---|
outside |
alignX = -1.5 β off the left edge |
close_body |
alignY = -0.1, zoom = 2.0 (no X yet) |
close_body_center |
close_body + alignX = 0.5
|
close_body_right |
close_body + alignX = 1.0
|
close_body_left |
close_body + alignX = 0.0
|
upper_body |
alignY = -0.1, zoom = 3.0
|
upper_body_center |
upper_body + alignX = 0.5
|
upper_body_right |
upper_body + alignX = 1.0
|
upper_body_left |
upper_body + alignX = 0.0
|
_left / _right use 0.0 / 1.0 so they stay on the correct edge at any zoom.
Add duration via override: PDAPreset("close_body_right", duration = 0.4).
Temporary presets. register_temp_preset(key, *actions) is the same lookup
(PDAPreset("key")) but is wiped when the manager unloads. Permanent names cannot
be overwritten. See Β§8 Scene-local presets.
Object presets. A third scope lives on the paperdoll object / Person:
PaperdollPreset("intro", PDAImage(pose = "12"), PDAMove(alignX = 0.85, zoom = 2.0))register_obj(..., presets=[...]) (and Person.paperdollPresets via
register_paperdoll) stores each as a temp key "{object_key}:{preset_key}".
Preset keys must not contain :.
When PDAPreset(arg) expands during .display() on object obj:
- look up
"{obj.key}:{arg}"(e.g.aona:intro, oremiko:aona:introwhen the argument is already scoped) - if missing, look up
argas-is (global / temp / already-scopedaona:intro)
So PDAPreset("intro") on Aona hits aona:intro; PDAPreset("aona:intro") on Emiko
misses emiko:aona:intro and finds aona:intro. Nested PDAPreset("close_body")
inside an object preset uses the same rule β an object can locally sharpen a built-in.
Expand deep-copies the action list before applying **overrides, so
PDAPreset("intro", duration = 0.4) does not mutate the stored preset.
Registry API: register_preset(key, *actions), register_temp_preset(key, *actions),
get_preset(key, paperdoll_obj=None),
get_preset_with_overrides(key, paperdoll_obj=None, **kwargs),
clear_temp_presets(), clear_presets().
The manager owns one backdrop, drawn behind all paperdoll objects
(zorder = -100; figures use 0, or -1 when behind=True). Order of
set_background vs .display() does not matter β a late background cannot cover
a figure.
$ paperdoll_manager.set_background("images/background/office building/secretary 6 1 0.webp", blur = True)set_background (and each side of set_background_split) accepts any of these
for its source argument β all resolved by _resolve_background_path:
| Source form | Example | How it resolves |
|---|---|---|
| Pattern string | "images/background/school building/<level> 0 1.webp" |
<key>/alt-key resolution like a layer (refine_image_with_alternatives β find_available_images) |
| Concrete path | "images/background/office building/secretary 6 1 0.webp" |
returned as-is if renpy.loadable
|
Image_Series step |
image_series[step] |
Image_Series.__getitem__ yields a concrete loadable path (or None); the path is used directly |
Event path with <nude> |
a raw get_image result still holding <nude>
|
resolved via get_image, preferring the clothed level 0, else the highest available nude level |
None / "" |
set_background(None) |
resolves to no background (empty) |
The order matters: a loadable concrete path wins immediately, so an
Image_Series[step] (which already returns a resolved file) is taken verbatim; only
an unresolved pattern goes through <key> substitution. A non-string source logs a
paperdoll error and yields no background.
# Drive the backdrop from an image series (e.g. the current scene's step)
$ image = Image_Series("images/events/intro/intro <step>.webp")
$ paperdoll_manager.set_background(image[2], blur = True)-
set_background(pattern=None, blur=False, blur_duration=0.0, bw=False, alt_keys=[], **kwargs)β resolvepattern(any source form above) and show it.blur=Truemaps to radius10.0,Falseto0.0, or pass a float for a custom radius;blur_durationeases the blur in.bw=Truedesaturates it. -
set_background_split(pattern_left=None, pattern_right=None, blur=False, blur_duration=0.0, separator_width=8, bw_left=False, bw_right=False, alt_keys=[], **kwargs)β composes the left half of one source and the right half of another (each source is any of the forms above) with a white divider strip, for two-location / two-character framings. If only one side resolves, it falls back to showing that side full-frame. -
hide_background()β remove it.
The blurred background is the standard "hand off from scene image to conversation" move: show the establishing scene, then blur its location behind the paperdoll so the character reads as the subject.
Source sprites are often authored much larger than their intended on-screen size.
display_size = (width, height) declares the logical size a layer should occupy.
Each layer is fitted to that box before config zoom, so zoom = 1.0 means "the
intended on-screen size" and PDAMove/preset zooms are multipliers on top β
independent of the raw pixel dimensions, and independent of swapping one asset for
another (PDAImage mouth/mood/pose). Folding the base scale into ATL zoom used to
make a mouth swap jump closer: Ren'Py take_state kept the old Transform zoom and
the new image's native size re-baked it.
-
display_sizeapplies to all layers;display_sizes = [...]overrides it per-layer (passNonein a slot to keep native sizing for that layer). - Character paperdolls use
display_size = (600, 1080). - Omit both to draw layers at native pixel size (base scale
1.0).
This is what lets you drop in a 4K sprite and a 1K sprite side by side and have them render at consistent, art-directed sizes.
A PaperdollOverride adjusts a single layer's position/rotation/blur/zoom, but
only when its conditions match the current values. Use it when one asset variant
needs a small correction the others don't β e.g. a bulky outfit whose body layer
needs to shift a few thousandths.
PaperdollOverride(
1, # layer index this applies to (here: the body)
{"outfit": "bunny"}, # active only while values["outfit"] == "bunny"
x_override = -0.0014,
y_override = -0.026132,
# rot_override, blur_override, zoom_override also available
)- The condition dict is matched against the object's current values (equality, or
membership via
check_in_value); a non-match contributes zero. - All matching overrides for a layer are summed into that layer's
config_override, which is then added on top of the shared config. - Overrides are passed at registration (
overrides=[...]), and aPersoncan carry a permanentpaperdollOverrideslist thatregister_paperdollmerges in automatically.
Overrides are recomputed on every re-resolution, so they track value changes as the conversation swaps outfits/poses.
Nothing in the engine is character-specific. A paperdoll object is just N pattern layers + values + a transform, so you can register one to display any stacked imagery β a machine that gains parts, a signboard whose text layer changes, a creature assembled from body/wings/overlay β as long as you supply the sprites.
To do so, skip Person.register_paperdoll and call the manager directly:
paperdoll_manager.register_obj(
"reactor",
"images/props/reactor/core <power> <state>.png", # layer 0
"images/props/reactor/shield <shield>.png", # layer 1
"images/props/reactor/sparks <power>.png", # layer 2 (top)
alt_keys = ["state", "shield"], # allow these to wildcard-fallback
display_size = (400, 700),
power = "1", state = "idle", shield = "up",
)
paperdoll_manager.display("reactor",
PDAMove(alignX = 0.5, alignY = 0.1, zoom = 1.2, duration = 0.5))
paperdoll_manager.display("reactor", PDAImage(power = "3", state = "active"))
paperdoll_manager.display("reactor", PDAShake(duration = 0.6, max_distance = 20))Everything else β presets, backgrounds, blur, bw, color, flip, overrides, per-layer
display_sizes β works identically. The character wrapper is simply the most-used
preset of this general capability.
The only requirements are: a unique key, one pattern per layer, and matching
values so each pattern resolves to a file that exists (or is covered by an
alt_keys fallback).
Two companion tools let you assemble a paperdoll and read off what it takes β one inside the game, one on the desktop. Neither ships in a scene; both exist to find the right combination/numbers, which you then hard-code into your event.
show_paperdoll_test (debug menu, debug.rpy) is a live editor for finding the
right config values for an asset set. It manages its own manager (it calls
init_paperdoll_manager / unload_paperdoll_manager around the session), lets you
pick a character and cycle char_var / pose / outfit / level / state /
mood / mouth / look / extra1 / extra2 (discovered by scanning the actual
files on disk). Each of those is its own column: look is not nested under
Mouths, and extra1 / extra2 are not nested under char_var. A column with only
one option is hidden and that token is used (pout / suprised typically only
have mouth $, so Mouths hides while Gaze still shows follow / avert).
It also exposes live alignX / alignY / rotation / zoom / blur / flip
sliders plus the presets.
Use it to dial in the position/zoom for a new pose or outfit, then copy those numbers
into your event's PDAMove/preset or into a PaperdollOverride. It is a tuning tool,
not something shipped in a scene.
A self-contained desktop app (Tkinter + Pillow) that reproduces the combination side
of the editor outside Ren'Py. It scans game/images/paperdoll directly and lets
you cascade character / char_var / pose / outfit / level / state / mood /
mouth β offering only combinations that actually resolve to a file, exactly like the
in-game selector β and previews the composed body + head live in a large,
portrait-oriented panel. It can swap the backdrop (checkerboard / dark / light / green)
and export the composited PNG.
Copy-ready PDAImage(...). Each field has a checkbox, and a Copy PDAImage
button puts a ready-to-paste call on the clipboard containing exactly the checked
fields β e.g. PDAImage(pose = "1", outfit = "uniform", level = 6, mood = "neutral", mouth = "open"). Defaults mirror the house style (pose/outfit/level/mood/mouth on,
char_var/state off); tick those extra boxes only when a scene needs them. So you
dial in the look and paste the exact line into your event β no transcribing values by
hand.
Why it exists alongside the in-game editor: while writing an event you can keep the
game paused on that beat to watch your scene edits, and compose/preview the
paperdoll's pose/outfit/mood in the external window at the same time β no jumping
back and forth between the running event and show_paperdoll_test. Settle on the
pose / outfit / mood / mouth combination you want here, copy the PDAImage(...), and
paste it straight into your event.
Run it with Python 3.9+ (needs pillow β pip install pillow):
python tools/paperdoll_viewer.py
or double-click tools/Paperdoll Viewer.bat on Windows. It auto-detects the
game/images/paperdoll folder relative to the repo (pass --root <path> to point it
elsewhere) and reads the assets read-only β it never modifies game files.
Division of labour: the standalone viewer nails the image combination (which
pose/outfit/mood/mouth), while the in-game editor is still where you tune the
transform (alignX / zoom / blur / β¦ over the live background).
-
Teardown is automatic.
$ image.show(n)andend_eventbothpaperdoll_manager.clear(). Reach forclear_display()only when you need a blank before something that does not auto-clear. It is not a per-character hide (get_obj(name).hide_all_images()is). -
Blur the background, don't remove it. The house look is a blurred location
behind the character; use
set_background(..., blur=True). - Don't reach for paperdolls by default β that's an authorial call, not a rule. Technically you can use paperdolls however you like: begin an event with them, run a whole event on them, mix them freely with scene images. But they aren't a substitute for art. Interesting, spicy or atmosphere-carrying beats want a properly rendered image; the paperdoll shines for lightweight, talky moments where a live, reactive figure beats a static shot. Deciding when a scene deserves real art vs. a paperdoll is the author's editorial judgement β the caution is only against using paperdolls inflationarily, for everything.
-
Let
alt_keysabsorb missing variants. List the fine-grained keys (level, mouth, state, char_var, extra1, extra2) so a missing specific asset falls back to a generic$file instead of vanishing. Do not listlookβ both gaze files are required. -
Change images with
PDAImage, move with the others. Don't re-register an object to change a mood; that's whatPDAImageis for. Gaze and extras are the same call:PDAImage(look = "avert"),PDAImage(extra2 = "glasses"). Re-registration is for a genuinely new object. -
Preset names as strings.
$ emiko.display("close_body_left")is enough when you don't need overrides. KeepPDAPreset("name", duration = 0.4)for timed eases. -
Tune with the editor, ship the numbers. Find
alignX/zoominshow_paperdoll_test, then hard-code them β don't guess. -
PDAImagekeeps the current transform. A pose/mood swap does not resetalignX/zoom/flip. Pass aPDAMoveonly when you actually want to reframe. -
Normalize sizes with
display_size. Declare the intended on-screen size sozoom = 1.0is meaningful regardless of the source resolution.
| Symptom | Likely cause | Fix |
|---|---|---|
| Nothing appears | No layer resolved to an existing file | Check the pattern's <keys> match your values and the file exists; add alt_keys for the missing dimension. |
| Only one layer shows | The other layer's pattern resolves to ""
|
Verify that layer's file for the current values; check the $ fallback path. |
| Figure is the wrong size |
display_size missing or wrong |
Set display_size to the intended on-screen size; then use zoom as a multiplier. |
PDAImage (mouth/mood swap) suddenly closer |
Old Transform zoom was taken onto the new layer, or base scale was folded into ATL zoom | Layers are fitted to display_size first; renpy.hide before re-show drops the old Transform state. Config zoom stays a pure multiplier. |
PDAImage (pose swap) keeps a large framing |
Current zoom is kept from the last PDAMove/preset |
Pass PDAMove(zoom = β¦) only if you want a new scale; don't re-register. |
| Position/zoom won't change | Passing sentinels or wrong action |
PDAMove keeps any arg you omit; pass the ones you want changed. Only PDAMove/presets move. |
Right-align needs alignX = 1.3 at zoom 2 |
Old compensation; alignX is zoom-independent |
Use 0.0 left / 1.0 right at any zoom (close_body_left / _right). |
alignX = -0.5 / -1.5 still on-screen at high zoom |
Native xalign zooms around a point beside the sprite |
Engine now clamps the zoom pivot to the sprite edge; -0.5 is half a screen off the left, -1.5 (outside) is fully off. |
| Timed move/flip is skipped | Next action in the same .display() replaced the transform |
Insert PDAPause of the same length; see Β§8. |
| Flip swings off-frame while easing | Flip must pivot on the sprite centre (engine does this) | Don't animate xzoom yourself; use PDAFlip(..., duration = β¦). |
| Expression won't update | Not using PDAImage
|
Change mood/mouth/look via PDAImage; move/blur don't re-resolve images. |
Head missing after look = "avert"
|
No avert file; look is not in alt_keys
|
Ship both follow and avert heads. Extras (extra1/extra2) do fall back to $. |
Layer blank; log path has a gap / double space before .png
|
Empty extra1/extra2/state was interpolated as a blank token instead of $
|
Empty alt-key values are coerced to $ in refine_image_with_alternatives. Restart after that fix; do not pass " " as an extra. |
| Two layers drift apart on shake | (shouldn't happen) all layers share the shake seed | Confirm you're on PDAShake (seeded by the object key), not per-layer motion. |
| One outfit sits a few pixels off | Needs a per-layer correction | Add a PaperdollOverride on that layer gated on the outfit value. |
PDAPreset("intro") does nothing |
Object preset not registered / wrong key | Ensure PaperdollPreset is on the Person or presets=; bare keys have no :. |
| Child prop does not follow | Not parented, or never shown |
register_obj(..., parent=...); .display the child once so layers resolve. |
| Parenting fails silently | Parent missing or cycle | Register ancestors first; check the paperdoll log for cycle / missing parent. |
| Background covers the figure | (fixed) late set_background used to win z-order |
Background is zorder -100; figures stay above. |
| Campus map still visible behind the figure | Map overview leaves the map image (tag map_image) on the master layer; paperdoll bg is zorder -100
|
begin_event clears the master layer; set_background also hides map_image. Paperdoll-only events do not need a dummy image.show. |
| Paperdoll persists into the next still | The still was not Image_Series.show
|
$ image.show(n) clears; call show_image / a raw renpy.show does not β then clear_display() first. end_event always unloads. |
paperdoll_manager is None outside an event |
No manager (event flow creates it) | Call init_paperdoll_manager() yourself (as the debug editor does). |
Save/quicksave throws PicklingError (_image_at_list / _blur_at_list / _bw_at_list) |
A def inside a label python: block is bound to store and rebound every PDAImage/PDABlur/PDABw |
Keep those callables in init python (paperdoll_show_idle_layers / paperdoll_show_shake_layers). Restart the game after the fix β a Shift+R reload can leave the old store function around. |
register_obj(key, *patterns, **kwargs) Β· get_obj(key) Β· display(key, *actions) (a str is PDAPreset) Β·
set_background(pattern=None, blur, blur_duration, bw, alt_keys, **kwargs) Β·
set_background_split(left=None, right=None, blur, blur_duration, separator_width, bw_left, bw_right, alt_keys, **kwargs) Β·
hide_background() Β· clear(). Per object: get_obj(key).hide_all_images() hides
that stack only (clear() hides every object plus the background). Background source = pattern string Β· concrete/loadable
path Β· Image_Series[step] Β· <nude> event path Β· None. Globals:
init_paperdoll_manager() / unload_paperdoll_manager().
Constructed as (key, *patterns, **kwargs). kwargs: overrides, presets,
parent, local, space ("screen"|"parent"), behind, alt_keys, config,
display_size, display_sizes, plus initial values. Config (world): alignX -0.5,
alignY 0.0, rotation 0.0, zoom 1.0, blur 0.0, bw False, flip 1.0,
color #00000000. Top-level color= is the same tint (stripped before values).
Color forms: #rgb / #rgba / #rrggbb / #rrggbbaa, names (green, blue,
black, β¦), name:0.5 / name:50%. green is #00ff00.
When parented, local holds the relative transform; config is composed along the
ancestor chain (space="parent" maps local align onto the parent's display box).
get_config(key, index) = shared config + layer override. Show zorder: 0, or
-1 if behind=True (background is -100).
PDAImage(**values) Β· PDAMove(alignX, alignY, zoom, duration) Β·
PDABlur(blur, duration) Β· PDABw(bw, duration) Β· PDAColor(color, duration) Β· PDAFlip(flip, duration) Β·
PDAShake(duration, max_distance) Β· PDAPause(duration, transition) Β·
PDAPreset(preset, **overrides) Β· a str in .display() is PDAPreset(that_string). Each runs via label paperdoll_action_<key>.
Move/flip/shake fan out to parented descendants.
register_preset(key, *actions) Β· register_temp_preset(key, *actions) Β·
get_preset(key, paperdoll_obj=None) Β·
get_preset_with_overrides(key, paperdoll_obj=None, **kwargs) Β·
clear_temp_presets() Β· clear_presets(). Built-ins:
outside, close_body(_center/_right/_left), upper_body(_center/_right/_left).
Object presets: PaperdollPreset(key, *actions) β temp "{obj}:{key}"; lookup
prefers scoped then bare (copy-on-expand).
person.register_paperdoll(*overrides, **kwargs) (2 layers, display_size=(600,1080),
body β¦ <char_var> <pose> <outfit> <level> <state> <extra1>.png, head
β¦ <char_var> <pose> <mood> <mouth> <look> <extra2>.png,
alt_keys=["level","mouth","state","char_var","extra1","extra2"], house values
look="follow" / empty extras then paperdollDefaults then kwargs, merges
paperdollOverrides + paperdollPresets; color= tints at register) Β·
person.display(*actions) (strings are presets) Β·
person.clear_display() Β· PaperdollOverride(...) Β· PaperdollPreset(key, *actions).
-
game/scripts/paperdoll.rpyβ manager, object, actions, presets, transforms, labels -
game/scripts/character.rpyβregister_paperdoll/display/clear_display,PaperdollOverride/PaperdollPreset -
game/scripts/images.rpyβrefine_image_with_alternatives/find_available_images(<key>+$resolution);Image_Series(backgroundimage[step]source) - Images β full path-resolution guide (PNG/WebP, mod prefixes, which helper to call)
-
game/scripts/event.rpyβ creates/unloads the manager around each event -
game/scripts/debug.rpyβshow_paperdoll_test, the in-game editor -
tools/paperdoll_viewer.pyβ standalone desktop paperdoll viewer (out-of-game combination/preview;Paperdoll Viewer.batlauncher) -
game/scripts/events/new_management.rpyβ worked examples (register β display β clear) -
Selectors β how
<key>values are produced and substituted into paths