@fabi-play/avatars 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @fabi-play/avatars
2
2
 
3
- Thirty-seven original, child-friendly avatars, drawn from a body pose: Fabi the fox (the fox of the app icon, the
4
- mascot), offered in every age band, and 12 for each band. The TV shows them in place
3
+ Thirty-nine original, child-friendly avatars, drawn from a body pose: Fabi the fox (the fox of the app icon, the
4
+ mascot), offered in every age band, 12 for each band, and the T-rex and the fairy. The lion, the T-rex and the fairy
5
+ are offered to both 3–5 and 6–8. The TV shows them in place
5
6
  of the plain silhouette when the phone's video is not on screen. Each child picks one on the phone, among the avatars
6
7
  of their age band. The phone stores only the avatar's id; any valid id is accepted everywhere, whatever the band.
7
8
 
@@ -11,8 +12,8 @@ varied skin tones and gender-neutral looks, and are told apart by their gear.
11
12
  | Band | Style | id · en · it (the first four are the slot defaults) |
12
13
  | --- | --- | --- |
13
14
  | all | the app icon's fox: first in every band's list, `special: true`, drawn with the 6–8 proportions; never a slot default | `mascot` Fabi · Fabi |
14
- | 3–5 | cuddly baby animals: big round heads, chubby limbs, soft colours, a soft brown outline | `bunny` Bunny · Coniglietto, `bear` Bear · Orsetto, `panda` Panda · Panda, `penguin` Penguin · Pinguino, `koala` Koala · Koala, `frog` Frog · Ranocchio, `cat` Cat · Gattino, `owl` Owl · Gufetto, `chick` Chick · Pulcino, `puppy` Puppy · Cucciolo, `lamb` Lamb · Agnellino, `duckling` Duckling · Anatroccolo |
15
- | 6–8 | animals and adventure | `fox` Fox · Volpe, `lion` Lion · Leoncino, `dragon` Little Dragon · Draghetto, `unicorn` Unicorn · Unicorno, `tiger` Tiger · Tigrotto, `monkey` Monkey · Scimmietta, `dinosaur` Dinosaur · Dinosauro, `octopus` Octopus · Polpetto, `ghost` Friendly Ghost · Fantasmino, `shark` Shark · Squalo, `wolf` Wolf · Lupo, `parrot` Parrot · Pappagallo |
15
+ | 3–5 | cuddly baby animals: big round heads, chubby limbs, soft colours, a soft brown outline | `bunny` Bunny · Coniglietto, `bear` Bear · Orsetto, `panda` Panda · Panda, `penguin` Penguin · Pinguino, `koala` Koala · Koala, `frog` Frog · Ranocchio, `cat` Cat · Gattino, `owl` Owl · Gufetto, `chick` Chick · Pulcino, `puppy` Puppy · Cucciolo, `lamb` Lamb · Agnellino, `duckling` Duckling · Anatroccolo; then, also offered here (home 6–8): `lion`, `trex`, `fairy` |
16
+ | 6–8 | animals and adventure | `fox` Fox · Volpe, `lion` Lion · Leoncino, `dragon` Little Dragon · Draghetto, `unicorn` Unicorn · Unicorno, `tiger` Tiger · Tigrotto, `monkey` Monkey · Scimmietta, `dinosaur` Dinosaur · Dinosauro, `trex` T-Rex · T-Rex, `octopus` Octopus · Polpetto, `ghost` Friendly Ghost · Fantasmino, `shark` Shark · Squalo, `wolf` Wolf · Lupo, `parrot` Parrot · Pappagallo, `fairy` Fairy · Fatina |
16
17
  | 9–12 | older kids: smaller heads, slimmer longer limbs, gear, cooler colours, no blush, a cool outline | `robot` Robot · Robot, `astronaut` Astronaut · Astronauta, `ninja` Ninja · Ninja, `skater` Skater · Skater, `alien` Alien · Alieno, `explorer` Explorer · Giramondo, `wizard` Wizard · Mago, `knight` Knight · Cavaliere, `racer` Racer · Pilota, `pirate` Pirate · Pirata, `diver` Diver · Sub, `hero` Hero · Supereroe |
17
18
 
18
19
  ## Install
@@ -41,27 +42,49 @@ el.innerHTML = avatarIconSvg('panda', 96) // web DOM
41
42
  <img src={avatarIconDataUri('panda', 96)} /> // data URI
42
43
  ```
43
44
 
44
- - `AVATARS: readonly AvatarDef[]` (all 37), `AVATAR_IDS`, `DEFAULT_AVATAR` (the fox), `avatarById(id)`, `isAvatarId(x)`.
45
+ - `AVATARS: readonly AvatarDef[]` (all 39), `AVATAR_IDS`, `DEFAULT_AVATAR` (the fox), `avatarById(id)`, `isAvatarId(x)`.
45
46
  - `AVATAR_BANDS: Record<AgeRange, readonly AvatarId[]>` (picker order: the mascot, `MASCOT_ID`, first, then the
46
47
  band's own 12, whose first four are the slot defaults, see `defaultAvatarFor(slot, ageRange?)` in
47
- `@fabi-play/protocol`), `avatarsFor(ageRange)` (the definitions, in that order), `bandOf(id)` (`'all'` for the
48
- mascot, 6–8 for an unknown id). Every definition has its `band` (`AvatarBand`: an age range or `'all'`);
49
- `special: true` marks the mascot, so a picker can mark it. `bodyBand(def)` gives the proportions it is drawn with
50
- (6–8 for `'all'`).
51
- - `drawAvatar(ctx, body, def | id, box, { alpha?, mirrored? })`. Unknown ids draw the default avatar. Pass
48
+ `@fabi-play/protocol`, then the avatars also offered there; the lists are not all the same length: 16, 15, 13),
49
+ `avatarsFor(ageRange)` (the definitions, in that order), `bandOf(id)` (the home band: `'all'` for the mascot, 6–8
50
+ for an unknown id), `bandsOf(id)` (every band whose list offers it). Every definition has its `band`
51
+ (`AvatarBand`: an age range or `'all'`) and, if it is offered elsewhere too, `alsoIn` (see "Avatars of two bands"
52
+ below); `special: true` marks the mascot, so a picker can mark it. `bodyBand(def, shown?)` gives the proportions
53
+ it is drawn with (6–8 for `'all'`).
54
+ - `drawAvatar(ctx, body, def | id, box, { alpha?, mirrored?, band? })`. Unknown ids draw the default avatar. `band`:
55
+ the child's age band, when the caller knows it (see "Avatars of two bands"). Pass
52
56
  `mirrored` when the body is in camera space rather than player space. It draws nothing when the shoulders are not
53
57
  seen (it returns whether it drew), and it never throws on missing or broken points.
54
- - `drawAvatarIcon(ctx, def | id, x, y, size)`: a head-and-shoulders portrait in a neutral pose, clipped to its square.
55
- - `avatarIconSvg(def | id, size, { background?, radius? })`: the same portrait as an SVG document string. Its
58
+ - `drawAvatarIcon(ctx, def | id, x, y, size, { band? })`: a head-and-shoulders portrait in a neutral pose, clipped to
59
+ its square. A picker passes the band it shows.
60
+ - `avatarIconSvg(def | id, size, { background?, radius?, band? })`: the same portrait as an SVG document string. Its
56
61
  clip-path id is unique per call (avatar, size and a counter), so many portraits can sit inline in one page.
57
62
  `avatarIconDataUri` returns the same as a data URI.
58
63
  - `smoothBody(prev, next, amount = 0.5)`: optional per-player smoothing. Points ease towards the new frame. A point
59
64
  that was seen stays visible until its visibility drops below 0.3, so limbs don't blink. Keep the result per player.
60
65
  - Poses for previews and tests: `neutralPose()`, `armsUpPose()`, `starJumpPose()`, `squatPose()`, `wavePose()`,
61
66
  `TEST_POSES`, `poseBody(spec)`.
62
- - Lower level: `avatarShapes(body, def, box)` returns the shape list, `drawShapes(ctx, shapes)` draws it on a canvas,
67
+ - Lower level: `avatarShapes(body, def, box, { mirrored?, portrait?, band? })` returns the shape list,
68
+ `iconShapes(def, size, band?)` the portrait's, `drawShapes(ctx, shapes)` draws it on a canvas,
63
69
  and `shapesToSvg(shapes)` turns it into SVG. `frameOf(body, box)` gives the rig's torso frame.
64
70
 
71
+ ### Avatars of two bands
72
+
73
+ An avatar can be offered in more than one band: its `band` is its home, and `alsoIn` lists the others (today the
74
+ lion, the T-rex and the fairy: home 6–8, also in 3–5). It is listed in each of those bands' `AVATAR_BANDS`, after
75
+ the band's own, so the slot defaults never change.
76
+
77
+ - **Proportions follow the band shown.** Pass the band (`drawAvatar`/`avatarShapes` option `band`, the third argument
78
+ of `iconShapes`, the `band` option of `drawAvatarIcon`/`avatarIconSvg`): an avatar offered in that band takes its
79
+ proportions (`bodyBand(def, band)`), so the lion in a 3–5 picker or game has the toddler build. The band is the
80
+ child's (`Player.ageRange` on the phone and the TV) or the picker's.
81
+ - **Without a band, or with a band it is not offered in, it keeps its home band's** proportions: exactly what it drew
82
+ before. Avatars of one band, and the mascot (always 6–8), ignore `band` entirely, so passing it is always safe.
83
+ - **Its style stays its home band's** (outline colour, palette): one look, whatever the band. `bandOf(id)` still
84
+ returns the home band (one value, as before); `bandsOf(id)` returns them all.
85
+ - The SDK's and the engine's `drawAvatar` don't pass a band yet: in games these avatars are drawn with their home
86
+ band's proportions, as are their name tags.
87
+
65
88
  ## How it is drawn
66
89
 
67
90
  One source of truth: the rig and the portrait build a small **shape list** (`circle`, `ellipse`, `rect`, `path`,
@@ -84,7 +107,7 @@ a change that alters them by accident fails the tests (`UPDATE_SNAPSHOT=1` after
84
107
  each with an outline. Arms start just below the shoulder. Legs start inside the torso, so nothing looks detached.
85
108
  Hands are round caps. Feet are ovals, flippers or boots.
86
109
  - **Proportions by band** (`BAND_BODY`): the joints are always the child's, only sizes change. An avatar of every
87
- band (`'all'`, the mascot) takes the 6–8 row.
110
+ band (`'all'`, the mascot) takes the 6–8 row. An avatar of two bands takes the row of the band shown (above).
88
111
 
89
112
  | band | head radius | head centre above the shoulders | arm / leg thickness | hand radius | torso below the hips | torso min half width (shoulders / hips) |
90
113
  | --- | --- | --- | --- | --- | --- | --- |
@@ -95,9 +118,14 @@ a change that alters them by accident fails the tests (`UPDATE_SNAPSHOT=1` after
95
118
  In 3–5 an arm raised well above the shoulders is drawn over the big head, which would hide it.
96
119
  - **Head**: radius by band (above), centred above the shoulder midpoint along the torso axis. It follows the
97
120
  nose a little (at most 0.35 radii) and tilts with the ear line, or the eye line if the ears are missing. The tilt is
98
- clamped to ±0.5 rad from the torso, and a swapped pair is treated as a child facing away.
121
+ clamped to ±0.5 rad from the torso, and a swapped pair is treated as a child facing away. The `snout-long` head
122
+ (the T-rex) is turned three-quarters, its snout on the left, away from the tail; its portrait is centred on the
123
+ head's width, snout included.
99
124
  - **Tail, wings, backpack, tentacles**: these hang off the hips or the back, in torso coordinates, and are drawn
100
125
  behind the body. The tail goes on the child's right. Portraits leave it out.
126
+ - **Held things**: the fairy's `wand` is in the hand on the right, following it (up and out from the body, leaning
127
+ with the forearm), under the hand; no wrist, no wand. Portraits leave it out. `tiny-arms` only makes the arms
128
+ slimmer, with small clawed hands: they still reach the child's hands.
101
129
  - **Draw order**: back extras, legs, torso, arms, head, then (3–5 only) raised arms.
102
130
  - **Degrading**: a point counts as seen at visibility ≥ 0.5. If the elbow or knee is missing, that limb is hidden.
103
131
  If the wrist or ankle is missing, the limb ends at the elbow or knee. If the hips are missing, the torso is one U
@@ -106,7 +134,8 @@ a change that alters them by accident fails the tests (`UPDATE_SNAPSHOT=1` after
106
134
  ## Adding an avatar
107
135
 
108
136
  1. Add an entry to `AVATARS` in `src/defs.ts` and its id to its band in `AVATAR_BANDS` (`src/index.ts`) (an avatar
109
- of band `'all'` goes in every band, after the mascot). Choose a new
137
+ of band `'all'` goes in every band, after the mascot; one offered in another band too lists it in `alsoIn` and goes
138
+ in that band's list as well, after the band's own). Choose a new
110
139
  kebab-case `id` and never rename it later, because phones store it. Fill in `name.en`, `name.it` and `band`. Pick a `palette` with `body`, `limbs`, `belly`, `accent` and
111
140
  `outline`, plus optional `hands`, `feet` and `ears`. Then choose a `head` shape, `ears`, the `face` (eyes, mouth,
112
141
  muzzle, nose, cheeks), a `tail`, `headExtras` and `bodyExtras`. `extraColors` covers stripes, spots, manes and
@@ -115,7 +144,8 @@ a change that alters them by accident fails the tests (`UPDATE_SNAPSHOT=1` after
115
144
  body colour that reads on both the night background (#1f1813) and a light one. Keep the dark outline.
116
145
  3. If a part does not exist yet, add a kind to the union in `src/types.ts` and draw it in `src/head.ts` (head units:
117
146
  radius 1, y down) or in `src/rig.ts` (torso units of U). Don't write per-id special cases.
118
- 4. Run `pnpm -F @fabi-play/avatars preview <dir> --details` and look at the PNGs: the portrait, every pose, and
147
+ 4. Run `pnpm -F @fabi-play/avatars preview <dir> --details` and look at the PNGs (an avatar of two bands is shown
148
+ in each, with that band's proportions): the portrait, every pose, and
119
149
  both backgrounds. Then run `pnpm -F @fabi-play/avatars test`.
120
150
 
121
151
  ## Scripts
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @fabi-play/avatars: 37 child-friendly avatars: Fabi the fox in every age band and 12 per band, drawn from a body pose, on canvas or as SVG portraits for the pickers. See https://docs.fabiplay.app.
1
+ // @fabi-play/avatars: 39 child-friendly avatars: Fabi the fox in every age band, 12 per band, a T-rex and a fairy, drawn from a body pose, on canvas or as SVG portraits for the pickers. See https://docs.fabiplay.app.
2
2
  // Generated by scripts/build-lib.mjs (fabiplay-kit) from the sources: do not edit.
3
3
 
4
4
  import { AgeRange, Body as Body$1 } from '@fabi-play/protocol';
@@ -116,21 +116,21 @@ export interface Palette {
116
116
  hair?: string;
117
117
  }
118
118
  /** The head's outline. */
119
- export type HeadShape = "round" | "wide" | "tall" | "square" | "bulb" | "bucket";
119
+ export type HeadShape = "round" | "wide" | "tall" | "square" | "bulb" | "bucket" | "snout-long";
120
120
  /** The ears (or what is in their place: horns, antennae, a fin). */
121
- export type EarKind = "none" | "pointy" | "round" | "big-round" | "long" | "side" | "tufts" | "antenna" | "antennae" | "horns" | "unicorn" | "floppy" | "droopy" | "fin" | "human";
121
+ export type EarKind = "none" | "pointy" | "round" | "big-round" | "long" | "side" | "tufts" | "antenna" | "antennae" | "horns" | "unicorn" | "floppy" | "droopy" | "fin" | "human" | "pixie";
122
122
  /** How the eyes are drawn. */
123
123
  export type EyeKind = "dot" | "big" | "mask" | "screen" | "almond" | "visor" | "calm" | "slit" | "goggle" | "bead";
124
124
  /** How the mouth (or beak) is drawn. */
125
- export type MouthKind = "none" | "smile" | "open" | "cat" | "beak" | "teeth" | "grill" | "tiny" | "smirk" | "bill" | "hook" | "jaws";
125
+ export type MouthKind = "none" | "smile" | "open" | "cat" | "beak" | "teeth" | "grill" | "tiny" | "smirk" | "bill" | "hook" | "jaws" | "toothy";
126
126
  /** The patch around the nose and mouth. */
127
127
  export type MuzzleKind = "none" | "oval" | "heart" | "monkey" | "disc" | "cheeks" | "band";
128
128
  /** The tail, behind the body. */
129
- export type TailKind = "none" | "fluffy" | "brush" | "pom" | "long" | "curl" | "tuft" | "striped" | "spiky" | "arrow" | "fish" | "plume";
129
+ export type TailKind = "none" | "fluffy" | "brush" | "pom" | "long" | "curl" | "tuft" | "striped" | "spiky" | "arrow" | "fish" | "plume" | "thick";
130
130
  /** Things on the head. */
131
- export type HeadExtra = "mane" | "rainbow-mane" | "stripes" | "spikes" | "spots" | "curl" | "helmet" | "bolts" | "whiskers" | "tuft" | "brows" | "eggshell" | "wool" | "eye-spot" | "crest" | "forehead" | "gills" | "ruff" | "hair" | "curls" | "locks" | "ninja-hood" | "cap" | "safari-hat" | "wizard-hat" | "plume" | "stripe" | "bandana" | "eye-patch" | "dive-mask" | "domino";
131
+ export type HeadExtra = "mane" | "rainbow-mane" | "stripes" | "spikes" | "spots" | "curl" | "helmet" | "bolts" | "whiskers" | "tuft" | "brows" | "eggshell" | "wool" | "eye-spot" | "crest" | "forehead" | "gills" | "ruff" | "hair" | "curls" | "locks" | "ninja-hood" | "cap" | "safari-hat" | "wizard-hat" | "plume" | "stripe" | "bandana" | "eye-patch" | "dive-mask" | "domino" | "bun" | "flower-crown";
132
132
  /** Things on the body. */
133
- export type BodyExtra = "wings" | "backpack" | "sheet" | "tentacles" | "panel" | "badge" | "joints" | "feathers" | "flippers" | "boots" | "scarf" | "spots" | "wool" | "robe" | "cape" | "fins" | "tank" | "straps" | "belt" | "stripes" | "pauldrons" | "emblem" | "pocket" | "racing-stripe" | "star";
133
+ export type BodyExtra = "wings" | "backpack" | "sheet" | "tentacles" | "panel" | "badge" | "joints" | "feathers" | "flippers" | "boots" | "scarf" | "spots" | "wool" | "robe" | "cape" | "fins" | "tank" | "straps" | "belt" | "stripes" | "pauldrons" | "emblem" | "pocket" | "racing-stripe" | "star" | "tiny-arms" | "strong-legs" | "wings-fairy" | "wand" | "petals";
134
134
  /** An avatar's face. */
135
135
  export interface FaceDef {
136
136
  /** How the eyes are drawn. */
@@ -164,10 +164,16 @@ export interface AvatarDef {
164
164
  it: string;
165
165
  };
166
166
  /**
167
- * The age band the avatar is for: its style and its body proportions (see BAND_BODY in rig.ts). 'all': in every
168
- * band's list, drawn with the 6–8 proportions.
167
+ * The age band the avatar is for, its home: its style and its body proportions (see BAND_BODY in rig.ts). 'all': in
168
+ * every band's list, drawn with the 6–8 proportions. See alsoIn for an avatar offered in other bands too.
169
169
  */
170
170
  band: AvatarBand;
171
+ /**
172
+ * Other age bands the avatar is also offered in (listed in their AVATAR_BANDS too), besides its own band. Shown in
173
+ * one of them, it is drawn with that band's proportions (bodyBand(def, band)); its band stays its home: its style
174
+ * (outline), bandOf, and its proportions when the band shown is not known. Not for 'all' (the mascot).
175
+ */
176
+ alsoIn?: readonly AgeRange[];
171
177
  /** A special avatar (the mascot): first in every band's list; pickers may mark it. */
172
178
  special?: boolean;
173
179
  /** Its colours. */
@@ -285,8 +291,14 @@ export interface BandBody {
285
291
  armsOverHead: boolean;
286
292
  }
287
293
  export declare const BAND_BODY: Record<AgeRange, BandBody>;
288
- /** The proportions an avatar is drawn with: its band's, 6–8's for an avatar of every band ('all') or anything else. */
289
- export declare const bodyBand: (def: Pick<AvatarDef, "band">) => AgeRange;
294
+ /**
295
+ * The proportions an avatar is drawn with: its band's, 6–8's for an avatar of every band ('all') or anything else.
296
+ * shown: the band it is shown in (the child's, the picker's), when known; an avatar offered in more than one band
297
+ * (alsoIn) takes the proportions of the one shown, any other keeps its own (the mascot always 6–8's).
298
+ */
299
+ export declare const bodyBand: (def: Pick<AvatarDef, "band"> & {
300
+ alsoIn?: readonly AgeRange[];
301
+ }, shown?: AgeRange) => AgeRange;
290
302
  /** Head radius in torso units (6–8; see BAND_BODY for the others). */
291
303
  export declare const HEAD: number;
292
304
  /** The head tilts at most this much (rad) from the shoulders' line. */
@@ -298,9 +310,12 @@ export declare const MAX_TILT = 0.3;
298
310
  export declare const TILT_DEAD_ZONE = 0.12;
299
311
  export interface RigOptions {
300
312
  mirrored?: boolean;
301
- /** Portraits leave the tail out: in a head-and-shoulders crop it would only peek in as a stray shape. */
313
+ /** Portraits leave the tail and the wand out: in a head-and-shoulders crop they would only peek in as stray shapes. */
302
314
  portrait?: boolean;
303
- /** Body proportions (see BAND_BODY); 6–8 by default. avatarShapes takes the avatar's own band. */
315
+ /**
316
+ * Body proportions (see BAND_BODY); 6–8 by default. For avatarShapes, the band the avatar is shown in: it follows
317
+ * it only for an avatar offered there too (alsoIn, see bodyBand); otherwise the avatar's own band.
318
+ */
304
319
  band?: AgeRange;
305
320
  }
306
321
  /** The torso frame: origin between the shoulders, r to the right, d down along the torso; lengths in pixels. */
@@ -364,9 +379,10 @@ export type AvatarId = string;
364
379
  export declare const MASCOT_ID: AvatarId;
365
380
  /**
366
381
  * The avatars of each age band, in the order the pickers show them: the mascot first (special: pickers may mark
367
- * it), then the band's own 12. The first four of the band's own are the defaults of the four player slots
368
- * (defaultAvatarFor in @fabi-play/protocol), not the mascot. Every avatar is in exactly one band, except those of
369
- * band 'all' (the mascot), which are in every band.
382
+ * it), then the band's own 12, then (3–5) the ones also offered there. The first four of the band's own are the
383
+ * defaults of the four player slots (defaultAvatarFor in @fabi-play/protocol), not the mascot. Every avatar is in
384
+ * its own band and in those of its alsoIn; those of band 'all' (the mascot) are in every band. The lists are not
385
+ * all the same length.
370
386
  */
371
387
  export declare const AVATAR_BANDS: Readonly<Record<AgeRange, readonly AvatarId[]>>;
372
388
  /** The avatar of a child who has not picked one yet: the fox (6–8's first). */
@@ -376,13 +392,24 @@ export declare const isAvatarId: (v: unknown) => v is AvatarId;
376
392
  export declare const avatarById: (id: string) => AvatarDef | undefined;
377
393
  /** The avatars of an age band, in picker order (6–8 for anything that is not an age band). */
378
394
  export declare function avatarsFor(ageRange: AgeRange): readonly AvatarDef[];
379
- /** The age band of an avatar: 'all' for the mascot (in every band), 6–8 (the default avatar's) for an unknown id. */
395
+ /**
396
+ * The age band of an avatar, its home: 'all' for the mascot (in every band), 6–8 (the default avatar's) for an
397
+ * unknown id. An avatar offered in more than one band has one home band (its style, its proportions when the band
398
+ * shown is not known); bandsOf gives every band it is offered in.
399
+ */
380
400
  export declare const bandOf: (id: AvatarId) => AvatarBand;
401
+ /** Every age band whose list (AVATAR_BANDS) offers the avatar, youngest first; the default avatar's for an unknown id. */
402
+ export declare const bandsOf: (id: AvatarId) => readonly AgeRange[];
381
403
  export interface DrawOptions {
382
404
  /** 0–1, for the whole avatar. */
383
405
  alpha?: number;
384
406
  /** The body is in camera space (not mirrored like player space): flip it left to right. */
385
407
  mirrored?: boolean;
408
+ /**
409
+ * The age band the avatar is shown in (the child's), when known: an avatar offered in that band too (alsoIn) takes
410
+ * its proportions; any other avatar keeps its own band's (see bodyBand).
411
+ */
412
+ band?: AgeRange;
386
413
  }
387
414
  /**
388
415
  * Draws the avatar posed like the body. box is where player space (0–1) lands on the canvas, as for the silhouette
@@ -390,11 +417,18 @@ export interface DrawOptions {
390
417
  * Returns whether it drew.
391
418
  */
392
419
  export declare function drawAvatar(ctx: CanvasRenderingContext2D | Ctx2D, body: Body$1, def: AvatarDef | string, box: Box, opts?: DrawOptions): boolean;
393
- /** The portrait: head and shoulders of the neutral pose, framed in a size × size square at (0, 0). */
394
- export declare function iconShapes(def: AvatarDef | string, size: number): Shape[];
420
+ /**
421
+ * The portrait: head and shoulders of the neutral pose, framed in a size × size square at (0, 0). band: the band the
422
+ * picker shows (see DrawOptions.band).
423
+ */
424
+ export declare function iconShapes(def: AvatarDef | string, size: number, band?: AgeRange): Shape[];
425
+ export interface IconOptions {
426
+ /** The band the picker shows (see DrawOptions.band). */
427
+ band?: AgeRange;
428
+ }
395
429
  /** Draws the portrait in the square (x, y, size), clipped to it. */
396
- export declare function drawAvatarIcon(ctx: CanvasRenderingContext2D | Ctx2D, def: AvatarDef | string, x: number, y: number, size: number): void;
397
- export interface IconSvgOptions {
430
+ export declare function drawAvatarIcon(ctx: CanvasRenderingContext2D | Ctx2D, def: AvatarDef | string, x: number, y: number, size: number, opts?: IconOptions): void;
431
+ export interface IconSvgOptions extends IconOptions {
398
432
  /** A background colour for the square; transparent by default. */
399
433
  background?: string;
400
434
  /** Corner radius of the background, in pixels. */
package/dist/index.js CHANGED
@@ -120,6 +120,7 @@ var AVATARS = [
120
120
  id: "lion",
121
121
  name: { en: "Lion", it: "Leoncino" },
122
122
  band: "6-8",
123
+ alsoIn: ["3-5"],
123
124
  palette: { body: "#f6c454", limbs: "#f6c454", belly: "#fde7b0", accent: "#fde7b0", outline: INK },
124
125
  head: "round",
125
126
  ears: "round",
@@ -486,6 +487,39 @@ var AVATARS = [
486
487
  headExtras: ["hair", "domino"],
487
488
  bodyExtras: ["cape", "emblem", "boots"],
488
489
  extraColors: ["#ffd84a"]
490
+ },
491
+ // ── In both 3–5 and 6–8 (home 6–8, alsoIn 3–5: drawn with the proportions of the band shown) ──
492
+ {
493
+ // Next to the round 'dinosaur': a T-rex at a glance, a big head turned three-quarters with a long snout and a
494
+ // toothy grin, tiny arms, strong legs, a thick striped tail.
495
+ id: "trex",
496
+ name: { en: "T-Rex", it: "T-Rex" },
497
+ band: "6-8",
498
+ alsoIn: ["3-5"],
499
+ palette: { body: "#6cb84e", limbs: "#6cb84e", belly: "#f4e8a8", accent: "#f4e8a8", outline: INK },
500
+ head: "snout-long",
501
+ ears: "none",
502
+ face: { eyes: "dot", mouth: "toothy", muzzle: "none", cheeks: "#f59b8f" },
503
+ tail: "thick",
504
+ headExtras: ["stripes"],
505
+ bodyExtras: ["tiny-arms", "strong-legs"],
506
+ extraColors: ["#4a9235", "#58a23f"]
507
+ },
508
+ {
509
+ // An original child fairy: a bun with a flower crown, little pointed ears, a petal tunic, see-through wings and
510
+ // a star wand in one hand.
511
+ id: "fairy",
512
+ name: { en: "Fairy", it: "Fatina" },
513
+ band: "6-8",
514
+ alsoIn: ["3-5"],
515
+ palette: { body: "#b99af0", limbs: "#a8693f", belly: "#b99af0", accent: "#ffd84a", outline: INK, skin: "#a8693f", hair: "#2e1d17", hands: "#a8693f", feet: "#f58cbc" },
516
+ head: "round",
517
+ ears: "pixie",
518
+ face: { eyes: "dot", mouth: "smile", muzzle: "none", cheeks: "#f0857a" },
519
+ tail: "none",
520
+ headExtras: ["bun", "flower-crown"],
521
+ bodyExtras: ["wings-fairy", "petals", "wand"],
522
+ extraColors: ["#c4ecff", "#f7a8cf", "#fff3a0", "#ffffff"]
489
523
  }
490
524
  ];
491
525
 
@@ -780,6 +814,7 @@ function headShapes(def, ow) {
780
814
  if (has("locks")) {
781
815
  out.push({ k: "path", d: smoothClosed(pts(-1.14, 0.58, -1.24, -0.2, -0.86, -0.98, 0, -1.2, 0.86, -0.98, 1.24, -0.2, 1.14, 0.58, 0.8, 0.66, 0, 0.2, -0.8, 0.66)), fill: p.hair ?? p.outline, ...ol });
782
816
  }
817
+ if (has("bun")) out.push({ k: "circle", x: 0.04, y: -1.14, r: 0.4, fill: p.hair ?? p.outline, ...ol });
783
818
  if (has("ninja-hood")) {
784
819
  for (const [rot, len2] of [
785
820
  [0.35, 0.5],
@@ -817,7 +852,15 @@ function headShapes(def, ow) {
817
852
  if (muzzle === "disc") for (const s of SIDES) out.push({ k: "circle", x: s * 0.4, y: 0.02, r: 0.44, fill: mc });
818
853
  if (muzzle === "cheeks") for (const s of SIDES) out.push({ k: "ellipse", x: s * 0.4, y: 0.44, rx: 0.44, ry: 0.32, fill: mc });
819
854
  if (muzzle === "band") out.push({ k: "path", d: lowerFace(def, ow), fill: mc });
820
- if (has("stripes")) {
855
+ if (has("stripes") && def.head === "snout-long") {
856
+ const c = extra(0, p.outline);
857
+ for (const [x0, y0, x1, y1] of [
858
+ [0.46, -0.94, 0.36, -0.66],
859
+ [0.8, -0.72, 0.62, -0.5],
860
+ [1, -0.36, 0.78, -0.24]
861
+ ])
862
+ out.push({ k: "line", pts: [x0, y0, x1, y1], stroke: c, lw: 0.11 });
863
+ } else if (has("stripes")) {
821
864
  const c = extra(0, p.outline);
822
865
  out.push({ k: "line", pts: [0, -0.97, 0, -0.66], stroke: c, lw: 0.13 });
823
866
  for (const s of SIDES) {
@@ -832,6 +875,13 @@ function headShapes(def, ow) {
832
875
  out.push({ k: "circle", x: 0.58, y: -0.42, r: 0.11, fill: c });
833
876
  out.push({ k: "circle", x: 0.22, y: -0.76, r: 0.09, fill: c });
834
877
  }
878
+ if (def.head === "snout-long") {
879
+ for (const [x, y, r] of [
880
+ [-1.38, -0.14, 0.06],
881
+ [-1.14, -0.22, 0.055]
882
+ ])
883
+ out.push({ k: "ellipse", x, y, rx: r, ry: r * 0.8, rot: -0.3, fill: p.outline, alpha: 0.85 });
884
+ }
835
885
  if (has("forehead")) out.push({ k: "path", d: smoothClosed(pts(-0.62, -0.66, 0, -0.86, 0.62, -0.66, 0.26, -0.36, 0, -0.1, -0.26, -0.36), 0.9), fill: extra(0, p.accent) });
836
886
  if (has("eye-spot")) out.push({ k: "ellipse", x: 0.42, y: -0.06, rx: 0.3, ry: 0.33, rot: 0.3, fill: p.ears ?? p.accent });
837
887
  if (has("gills")) {
@@ -840,6 +890,7 @@ function headShapes(def, ow) {
840
890
  const hair = p.hair ?? p.outline;
841
891
  if (has("hair")) out.push({ k: "path", d: smoothClosed(pts(-1.02, -0.06, -1.02, -0.58, -0.62, -1.04, 0.08, -1.14, 0.76, -0.92, 1.05, -0.42, 1, -0.06, 0.84, -0.4, 0.42, -0.5, 0.06, -0.4, -0.34, -0.56, -0.74, -0.38)), fill: hair, ...ol });
842
892
  if (has("locks")) out.push({ k: "path", d: smoothClosed(pts(-1, -0.08, -0.98, -0.62, -0.56, -1.02, 0, -1.1, 0.56, -1.02, 0.98, -0.62, 1, -0.08, 0.8, -0.38, 0.36, -0.44, 0, -0.4, -0.36, -0.44, -0.8, -0.38)), fill: hair, ...ol });
893
+ if (has("bun")) out.push({ k: "path", d: smoothClosed(pts(-1.02, -0.1, -0.98, -0.62, -0.56, -1.02, 0, -1.1, 0.56, -1.02, 0.98, -0.62, 1.02, -0.1, 0.84, -0.36, 0.5, -0.56, 0.12, -0.48, -0.22, -0.6, -0.6, -0.5, -0.86, -0.36)), fill: hair, ...ol });
843
894
  if (has("curls")) {
844
895
  for (let i = 0; i < 7; i++) {
845
896
  const c = polar(-1.3 + i / 6 * 2.6, 0.86);
@@ -861,7 +912,7 @@ function headShapes(def, ow) {
861
912
  }
862
913
  if (def.face.brows) for (const s of SIDES) out.push({ k: "line", pts: [s * 0.2, -0.2, s * 0.54, -0.28], stroke: def.face.brows, lw: 0.09 });
863
914
  out.push(...mouth(def, ow));
864
- if (def.face.cheeks) for (const s of SIDES) out.push({ k: "ellipse", x: s * 0.64, y: 0.3, rx: 0.15, ry: 0.1, fill: def.face.cheeks, alpha: 0.8 });
915
+ if (def.face.cheeks) for (const c of cheekSpots(def)) out.push({ k: "ellipse", x: c.x, y: c.y, rx: 0.15, ry: 0.1, fill: def.face.cheeks, alpha: 0.8 });
865
916
  if (has("whiskers")) {
866
917
  for (const s of SIDES)
867
918
  for (const dy of [-0.1, 0.06]) out.push({ k: "line", pts: [s * 0.62, 0.38 + dy, s * 1.06, 0.3 + dy * 2.2], stroke: p.outline, lw: ow * 0.7, alpha: 0.75 });
@@ -883,6 +934,24 @@ function headShapes(def, ow) {
883
934
  out.push({ k: "line", pts: [-w, y + 0.04, w, y - 0.04], stroke: p.outline, lw: ow * 0.7, alpha: 0.6 });
884
935
  }
885
936
  }
937
+ if (has("flower-crown")) {
938
+ const cols = [extra(1, "#f7a8cf"), extra(2, "#fff3a0"), extra(3, WHITE)];
939
+ for (const a of [-0.84, -0.28, 0.28, 0.84]) {
940
+ const c = polar(a, 0.9);
941
+ out.push({ k: "ellipse", x: c.x, y: c.y, rx: 0.14, ry: 0.07, rot: a + 0.5, fill: "#7cc76a", stroke: p.outline, lw: ow * 0.7 });
942
+ }
943
+ ;
944
+ [-1.12, -0.56, 0, 0.56, 1.12].forEach((a, i) => {
945
+ const c = polar(a, 0.9);
946
+ const r = i === 2 ? 0.13 : 0.11;
947
+ for (let k = 0; k < 5; k++) {
948
+ const t = a + k / 5 * Math.PI * 2;
949
+ out.push({ k: "circle", x: c.x + Math.sin(t) * r, y: c.y - Math.cos(t) * r, r: r * 0.82, fill: cols[i % cols.length], stroke: p.outline, lw: ow * 0.7 });
950
+ }
951
+ out.push({ k: "circle", x: c.x, y: c.y, r: r * 0.62, fill: cols[i % cols.length] });
952
+ out.push({ k: "circle", x: c.x, y: c.y, r: r * 0.42, fill: p.accent });
953
+ });
954
+ }
886
955
  if (has("helmet")) {
887
956
  for (const s of SIDES) out.push({ k: "rect", x: s * 1, y: 0.05, w: 0.26, h: 0.56, r: 0.12, fill: p.accent, ...ol });
888
957
  out.push({ k: "path", d: "M-0.62 -0.62 Q-0.3 -0.92 0.12 -0.9", stroke: WHITE, lw: 0.1, alpha: 0.9 });
@@ -947,6 +1016,8 @@ function headShape(def, ol) {
947
1016
  return { k: "rect", x: 0, y: 0, w: 2, h: 1.8, r: 0.46, fill, ...ol };
948
1017
  case "bucket":
949
1018
  return { k: "path", d: roundedPolygon(pts(-0.94, 0.96, -0.98, -0.36, -0.62, -1.04, 0.62, -1.04, 0.98, -0.36, 0.94, 0.96), 0.42), fill, ...ol };
1019
+ case "snout-long":
1020
+ return { k: "path", d: smoothClosed(SNOUT_HEAD, 0.95), fill, ...ol };
950
1021
  case "bulb":
951
1022
  return {
952
1023
  k: "path",
@@ -965,6 +1036,19 @@ function headShape(def, ol) {
965
1036
  };
966
1037
  }
967
1038
  }
1039
+ var SNOUT_HEAD = pts(0.7, -0.88, 0.12, -1.04, -0.3, -0.98, -0.72, -0.74, -1.2, -0.56, -1.5, -0.32, -1.6, 0.04, -1.44, 0.42, -0.9, 0.72, -0.2, 0.92, 0.5, 0.88, 0.96, 0.56, 1.08, 0, 0.98, -0.5);
1040
+ function eyeSpots(def, ex, ey) {
1041
+ if (def.head === "snout-long")
1042
+ return [
1043
+ { x: -0.36, y: -0.5, k: 0.95 },
1044
+ { x: 0.26, y: -0.48, k: 1.1 }
1045
+ ];
1046
+ return SIDES.map((s) => ({ x: s * ex, y: ey, k: 1 }));
1047
+ }
1048
+ function cheekSpots(def) {
1049
+ if (def.head === "snout-long") return pts(-0.66, -0.14, 0.66, 0.1);
1050
+ return SIDES.map((s) => ({ x: s * 0.64, y: 0.3 }));
1051
+ }
968
1052
  function headEllipse(def) {
969
1053
  if (def.head === "round") return { cy: 0, rx: 1, ry: 1 };
970
1054
  if (def.head === "tall") return { cy: -0.02, rx: 0.94, ry: 1.04 };
@@ -1045,6 +1129,10 @@ function ears(def, ow) {
1045
1129
  out.push({ k: "ellipse", x: s * 1.04, y: -0.22, rx: 0.4, ry: 0.18, rot: s * 0.32, fill: p.skin ?? p.body, ...ol });
1046
1130
  out.push({ k: "ellipse", x: s * 1.08, y: -0.2, rx: 0.24, ry: 0.08, rot: s * 0.32, fill: p.accent });
1047
1131
  break;
1132
+ case "pixie":
1133
+ out.push({ k: "path", d: roundedPolygon(pts(s * 0.86, -0.22, s * 1.36, -0.5, s * 1.02, 0.26), 0.28), fill: p.skin ?? p.body, ...ol });
1134
+ out.push({ k: "line", pts: [s * 0.98, -0.12, s * 1.16, -0.3], stroke: p.accent, lw: ow * 0.8, alpha: 0.5 });
1135
+ break;
1048
1136
  case "human":
1049
1137
  out.push({ k: "ellipse", x: s * 0.98, y: 0.06, rx: 0.17, ry: 0.23, fill: p.skin ?? p.body, ...ol });
1050
1138
  break;
@@ -1077,15 +1165,15 @@ function eyes(def, ow) {
1077
1165
  const shine = (x, y, r) => ({ k: "circle", x, y, r, fill: WHITE });
1078
1166
  switch (f2.eyes) {
1079
1167
  case "dot":
1080
- for (const s of SIDES) {
1081
- out.push({ k: "ellipse", x: s * ex, y: ey, rx: 0.12, ry: 0.155, fill: ink });
1082
- out.push(shine(s * ex + 0.04, ey - 0.06, 0.045));
1168
+ for (const e of eyeSpots(def, ex, ey)) {
1169
+ out.push({ k: "ellipse", x: e.x, y: e.y, rx: 0.12 * e.k, ry: 0.155 * e.k, fill: ink });
1170
+ out.push(shine(e.x + 0.04 * e.k, e.y - 0.06 * e.k, 0.045 * e.k));
1083
1171
  }
1084
1172
  break;
1085
1173
  case "bead":
1086
- for (const s of SIDES) {
1087
- out.push({ k: "ellipse", x: s * ex, y: ey, rx: 0.16, ry: 0.22, fill: ink });
1088
- out.push(shine(s * ex + 0.05, ey - 0.08, 0.065));
1174
+ for (const e of eyeSpots(def, ex, ey)) {
1175
+ out.push({ k: "ellipse", x: e.x, y: e.y, rx: 0.16 * e.k, ry: 0.22 * e.k, fill: ink });
1176
+ out.push(shine(e.x + 0.05 * e.k, e.y - 0.08 * e.k, 0.065 * e.k));
1089
1177
  }
1090
1178
  break;
1091
1179
  case "big":
@@ -1194,6 +1282,21 @@ function mouth(def, ow) {
1194
1282
  out.push({ k: "ellipse", x: 0, y: 0.48, rx: 0.16, ry: 0.11, fill: MOUTH });
1195
1283
  out.push({ k: "path", d: "M-0.3 0.12 Q0 -0.02 0.3 0.12 Q0.36 0.52 0.04 0.76 Q0.08 0.52 -0.08 0.46 Q-0.28 0.38 -0.3 0.12 Z", fill: p.accent, stroke: p.outline, lw: ow * 0.8 });
1196
1284
  break;
1285
+ case "toothy": {
1286
+ out.push({ k: "path", d: "M-1.4 0.1 Q-0.5 0.36 0.5 0.2 Q0.64 0.16 0.68 0.02 Q0.78 0.28 0.52 0.38 Q-0.4 0.78 -1.3 0.26 Z", fill: dark, stroke: dark, lw: ow * 0.6 });
1287
+ out.push({ k: "ellipse", x: -0.38, y: 0.5, rx: 0.24, ry: 0.08, fill: TONGUE });
1288
+ for (const t of [0.12, 0.27, 0.42, 0.57, 0.72, 0.86]) {
1289
+ const x = (1 - t) * (1 - t) * -1.4 + 2 * (1 - t) * t * -0.5 + t * t * 0.5;
1290
+ const y = (1 - t) * (1 - t) * 0.1 + 2 * (1 - t) * t * 0.36 + t * t * 0.2 - 0.01;
1291
+ out.push({ k: "path", d: roundedPolygon(pts(x - 0.08, y, x + 0.08, y, x, y + 0.14), 0.3), fill: WHITE });
1292
+ }
1293
+ for (const t of [0.3, 0.7]) {
1294
+ const x = (1 - t) * (1 - t) * 0.52 + 2 * (1 - t) * t * -0.4 + t * t * -1.3;
1295
+ const y = (1 - t) * (1 - t) * 0.38 + 2 * (1 - t) * t * 0.78 + t * t * 0.26 + 0.01;
1296
+ out.push({ k: "path", d: roundedPolygon(pts(x - 0.07, y, x + 0.07, y, x, y - 0.12), 0.3), fill: WHITE });
1297
+ }
1298
+ break;
1299
+ }
1197
1300
  case "jaws": {
1198
1301
  out.push({ k: "path", d: "M-0.5 0.3 Q0 0.5 0.5 0.3 Q0.38 0.72 0 0.72 Q-0.38 0.72 -0.5 0.3 Z", fill: dark, stroke: dark, lw: ow * 0.5 });
1199
1302
  for (const x of [-0.34, -0.17, 0, 0.17, 0.34]) {
@@ -1220,7 +1323,10 @@ var BAND_BODY = {
1220
1323
  "9-12": { head: 0.5, neck: 0.92, arm: 0.17, leg: 0.21, hand: 0.125, foot: 0.9, shoulderMin: 0.36, shoulderPad: 0.07, hipMin: 0.32, hipPad: 0.08, bottom: 0.1, bulge: 1, armsOverHead: false }
1221
1324
  };
1222
1325
  var DEFAULT_BAND = "6-8";
1223
- var bodyBand = (def) => def.band in BAND_BODY ? def.band : DEFAULT_BAND;
1326
+ var bodyBand = (def, shown) => {
1327
+ if (shown !== void 0 && def.band !== "all" && def.alsoIn?.includes(shown) && shown in BAND_BODY) return shown;
1328
+ return def.band in BAND_BODY ? def.band : DEFAULT_BAND;
1329
+ };
1224
1330
  var HEAD = BAND_BODY[DEFAULT_BAND].head;
1225
1331
  var MAX_TILT = 0.3;
1226
1332
  var TILT_DEAD_ZONE = 0.12;
@@ -1314,7 +1420,7 @@ function headTilt(body, at, angle, U) {
1314
1420
  return 0;
1315
1421
  }
1316
1422
  function avatarShapes(body, def, box, opts = {}) {
1317
- const band = bodyBand(def);
1423
+ const band = bodyBand(def, opts.band);
1318
1424
  const bb = BAND_BODY[band];
1319
1425
  const F = frameOf(body, box, { ...opts, band });
1320
1426
  if (!F) return [];
@@ -1346,8 +1452,8 @@ function avatarShapes(body, def, box, opts = {}) {
1346
1452
  out.push({ k: "line", pts: flat, stroke: p.outline, lw: w + ow * 2 });
1347
1453
  out.push({ k: "line", pts: flat, stroke: color, lw: w });
1348
1454
  };
1349
- const legW = bb.leg * U;
1350
- const armW = bb.arm * U;
1455
+ const legW = bb.leg * U * (has("strong-legs") ? 1.3 : 1);
1456
+ const armW = bb.arm * U * (has("tiny-arms") ? 0.6 : 1);
1351
1457
  const pad = Math.max(bb.arm / 0.22, 0.9);
1352
1458
  if (F.hipsOk) {
1353
1459
  for (const [hip, kneeI, ankleI, side] of [
@@ -1361,7 +1467,7 @@ function avatarShapes(body, def, box, opts = {}) {
1361
1467
  const pts2 = ankle.ok ? [top, knee, ankle] : [top, knee];
1362
1468
  limb(legs, pts2, legW, p.limbs);
1363
1469
  if (has("joints")) legs.push({ k: "circle", x: knee.x, y: knee.y, r: 0.15 * U * pad, fill: p.accent, ...ol });
1364
- if (ankle.ok) legs.push(foot(def, ankle, F, side, ow, bb.foot));
1470
+ if (ankle.ok) legs.push(...feet(def, ankle, F, side, ow, bb.foot));
1365
1471
  }
1366
1472
  }
1367
1473
  for (const [sh, elbowI, wristI, side] of [
@@ -1394,7 +1500,20 @@ function avatarShapes(body, def, box, opts = {}) {
1394
1500
  out.push({ k: "ellipse", x: q.x, y: q.y, rx: 0.2 * U, ry: 0.09 * U, rot: Math.atan2(fd.y, fd.x), fill: p.hands ?? p.accent, ...ol });
1395
1501
  }
1396
1502
  }
1397
- out.push({ k: "circle", x: hand.x, y: hand.y, r: bb.hand * U, fill: p.hands ?? p.limbs, ...ol });
1503
+ if (has("wand") && side === 1 && !opts.portrait) out.push(...wand(def, hand, norm(add(mul(dir, 0.4), sub(mul(r, 0.6), d))), U, ow));
1504
+ const handR = bb.hand * U * (has("tiny-arms") ? 0.72 : 1);
1505
+ if (has("tiny-arms")) {
1506
+ for (const a of [-0.45, 0.45]) {
1507
+ const c = Math.cos(a);
1508
+ const s = Math.sin(a);
1509
+ const cd = { x: dir.x * c - dir.y * s, y: dir.x * s + dir.y * c };
1510
+ const base = add(hand, mul(cd, handR * 0.75));
1511
+ const tip = add(hand, mul(cd, handR * 1.6));
1512
+ const nrm = { x: -cd.y * handR * 0.32, y: cd.x * handR * 0.32 };
1513
+ out.push({ k: "path", d: roundedPolygon([add(base, nrm), tip, sub(base, nrm)], 0.2), fill: p.belly, ...ol });
1514
+ }
1515
+ }
1516
+ out.push({ k: "circle", x: hand.x, y: hand.y, r: handR, fill: p.hands ?? p.limbs, ...ol });
1398
1517
  }
1399
1518
  if (has("wings")) {
1400
1519
  for (const s of [-1, 1]) {
@@ -1409,6 +1528,7 @@ function avatarShapes(body, def, box, opts = {}) {
1409
1528
  back.push({ k: "path", d: roundedPolygon(w, 0.18), fill: p.accent, ...ol });
1410
1529
  }
1411
1530
  }
1531
+ if (has("wings-fairy")) back.push(...fairyWings(def, T, ht, U, ow));
1412
1532
  if (has("cape")) {
1413
1533
  const yb = L + 0.8 * U;
1414
1534
  const wb = hb + 0.36 * U;
@@ -1626,6 +1746,7 @@ function avatarShapes(body, def, box, opts = {}) {
1626
1746
  const k = 0.15 * U;
1627
1747
  torso.push({ k: "group", x: c.x, y: c.y, rot: F.angle - 0.2, s: k, items: [{ k: "path", d: STAR, fill: def.extraColors?.[0] ?? "#ffd84a", stroke: p.outline, lw: ow * 0.8 / k }] });
1628
1748
  }
1749
+ if (has("petals")) torso.push(...petals(def, T, ht, hb, bulge, L, y1, U, ow, bb));
1629
1750
  if (has("scarf")) {
1630
1751
  const y = Math.max(0.04, bb.head * (1 - bb.neck) - 0.06) * U;
1631
1752
  const c = T(0, y);
@@ -1650,6 +1771,18 @@ function cachedHead(def, ow) {
1650
1771
  }
1651
1772
  return shapes;
1652
1773
  }
1774
+ function feet(def, ankle, F, side, ow, k) {
1775
+ const shoe = foot(def, ankle, F, side, ow, k);
1776
+ if (!def.bodyExtras.includes("strong-legs")) return [shoe];
1777
+ const U = F.U * k;
1778
+ const c = add(add(ankle, mul(F.d, 0.07 * U)), mul(F.r, side * 0.06 * U));
1779
+ const out = [shoe];
1780
+ for (const t of [-1, 0, 1]) {
1781
+ const q = add(add(c, mul(F.r, t * 0.12 * U)), mul(F.d, 0.09 * U));
1782
+ out.push({ k: "ellipse", x: q.x, y: q.y, rx: 0.05 * U, ry: 0.045 * U, rot: F.angle, fill: def.palette.belly, stroke: def.palette.outline, lw: ow * 0.8 });
1783
+ }
1784
+ return out;
1785
+ }
1653
1786
  function foot(def, ankle, F, side, ow, k) {
1654
1787
  const p = def.palette;
1655
1788
  const { d, r } = F;
@@ -1803,6 +1936,30 @@ function tail(def, F, hb, ow) {
1803
1936
  });
1804
1937
  break;
1805
1938
  }
1939
+ case "thick": {
1940
+ const pts2 = TP([
1941
+ [hb * 0.3, L * 0.42],
1942
+ [hb + 0.42 * U, L * 0.62],
1943
+ [hb + 0.92 * U, L * 0.96],
1944
+ [hb + 1.32 * U, L * 1.08],
1945
+ [hb + 1.44 * U, L * 0.98],
1946
+ [hb + 0.98 * U, L * 1.2],
1947
+ [hb + 0.4 * U, L * 1.22],
1948
+ [hb * 0.3, L * 1.12]
1949
+ ]);
1950
+ out.push({ k: "path", d: smoothClosed(pts2, 0.8), fill: p.body, ...ol });
1951
+ const c = def.extraColors?.[0] ?? p.accent;
1952
+ for (const [x, y, l] of [
1953
+ [0.28, 0.58, 0.26],
1954
+ [0.62, 0.8, 0.22],
1955
+ [0.96, 1, 0.16]
1956
+ ]) {
1957
+ const a = T(hb + x * U, L * y + 0.03 * U);
1958
+ const b = T(hb + (x - 0.1) * U, L * y + l * U);
1959
+ out.push({ k: "line", pts: [a.x, a.y, b.x, b.y], stroke: c, lw: 0.09 * U });
1960
+ }
1961
+ break;
1962
+ }
1806
1963
  case "spiky":
1807
1964
  case "arrow": {
1808
1965
  const pts2 = TP([
@@ -1835,6 +1992,81 @@ function tail(def, F, hb, ow) {
1835
1992
  }
1836
1993
  return out;
1837
1994
  }
1995
+ function wand(def, hand, wd, U, ow) {
1996
+ const p = def.palette;
1997
+ const from = add(hand, mul(wd, -0.08 * U));
1998
+ const tip = add(hand, mul(wd, 0.52 * U));
1999
+ const k = 0.2 * U;
2000
+ return [
2001
+ { k: "line", pts: [from.x, from.y, tip.x, tip.y], stroke: p.outline, lw: 0.06 * U + ow * 2 },
2002
+ { k: "line", pts: [from.x, from.y, tip.x, tip.y], stroke: "#fff3d6", lw: 0.06 * U },
2003
+ { k: "circle", x: tip.x, y: tip.y, r: 0.24 * U, fill: p.accent, alpha: 0.28 },
2004
+ { k: "group", x: tip.x, y: tip.y, rot: Math.atan2(wd.y, wd.x) + Math.PI / 2, s: k, items: [{ k: "path", d: STAR, fill: p.accent, stroke: p.outline, lw: ow * 0.9 / k }] }
2005
+ ];
2006
+ }
2007
+ function fairyWings(def, T, ht, U, ow) {
2008
+ const p = def.palette;
2009
+ const fill = def.extraColors?.[0] ?? "#cdeeff";
2010
+ const out = [];
2011
+ for (const s of [-1, 1]) {
2012
+ const lobes = [
2013
+ // Upper: big, up and out.
2014
+ [
2015
+ [s * ht * 0.3, 0.12 * U],
2016
+ [s * (ht + 0.42 * U), -0.66 * U],
2017
+ [s * (ht + 1 * U), -0.74 * U],
2018
+ [s * (ht + 1.02 * U), -0.18 * U],
2019
+ [s * (ht + 0.5 * U), 0.26 * U]
2020
+ ],
2021
+ // Lower: smaller, down and out.
2022
+ [
2023
+ [s * ht * 0.4, 0.3 * U],
2024
+ [s * (ht + 0.6 * U), 0.36 * U],
2025
+ [s * (ht + 0.74 * U), 0.78 * U],
2026
+ [s * (ht + 0.3 * U), 0.9 * U]
2027
+ ]
2028
+ ];
2029
+ for (const lobe of lobes) {
2030
+ const d = smoothClosed(lobe.map(([x, y]) => T(x, y)), 0.9);
2031
+ out.push({ k: "path", d, fill, alpha: 0.6 });
2032
+ out.push({ k: "path", d, stroke: p.outline, lw: ow * 0.9 });
2033
+ const inner = lobe[Math.floor(lobe.length / 2)];
2034
+ const a = T(lobe[0][0], lobe[0][1]);
2035
+ const b = T(lobe[0][0] + (inner[0] - lobe[0][0]) * 0.75, lobe[0][1] + (inner[1] - lobe[0][1]) * 0.75);
2036
+ out.push({ k: "line", pts: [a.x, a.y, b.x, b.y], stroke: "#ffffff", lw: ow * 0.9, alpha: 0.85 });
2037
+ }
2038
+ for (const [x, y, r] of [
2039
+ [ht + 0.72 * U, -0.42 * U, 0.075 * U],
2040
+ [ht + 0.44 * U, -0.16 * U, 0.05 * U],
2041
+ [ht + 0.5 * U, 0.6 * U, 0.055 * U]
2042
+ ]) {
2043
+ const q = T(s * x, y);
2044
+ out.push({ k: "group", x: q.x, y: q.y, s: r, items: [{ k: "path", d: STAR, fill: "#ffffff" }] });
2045
+ }
2046
+ }
2047
+ return out;
2048
+ }
2049
+ function petals(def, T, ht, hb, bulge, L, y1, U, ow, bb) {
2050
+ const p = def.palette;
2051
+ const second = def.extraColors?.[1] ?? p.belly;
2052
+ const out = [];
2053
+ const ang = Math.atan2(T(1, 0).y - T(0, 0).y, T(1, 0).x - T(0, 0).x);
2054
+ const n = 5;
2055
+ const w = Math.max(hb, bulge * 0.92) * 1.08;
2056
+ const y = (L * 0.8 + y1) / 2;
2057
+ const order = [0, 4, 1, 3, 2];
2058
+ for (const i of order) {
2059
+ const t = i / (n - 1) - 0.5;
2060
+ const c = T(t * 2 * w, y + (0.5 - Math.abs(t)) * 0.1 * U);
2061
+ out.push({ k: "ellipse", x: c.x, y: c.y, rx: w * 0.3, ry: 0.34 * U, rot: ang - t * 0.6, fill: i % 2 ? second : p.body, stroke: p.outline, lw: ow });
2062
+ }
2063
+ const cy = Math.max(0.04, bb.head * (1 - bb.neck) - 0.04) * U;
2064
+ for (const t of [-1, 1, 0]) {
2065
+ const c = T(t * ht * 0.32, cy + (t ? 0 : 0.05 * U));
2066
+ out.push({ k: "ellipse", x: c.x, y: c.y, rx: ht * 0.26, ry: 0.12 * U, rot: ang + t * 0.5, fill: t ? second : p.body, stroke: p.outline, lw: ow * 0.9 });
2067
+ }
2068
+ return out;
2069
+ }
1838
2070
 
1839
2071
  // src/poses.ts
1840
2072
  var STAND = {
@@ -2016,8 +2248,8 @@ function blend(a, b, u) {
2016
2248
  var AVATAR_IDS = AVATARS.map((a) => a.id);
2017
2249
  var MASCOT_ID = "mascot";
2018
2250
  var AVATAR_BANDS = {
2019
- "3-5": [MASCOT_ID, "bunny", "bear", "panda", "penguin", "koala", "frog", "cat", "owl", "chick", "puppy", "lamb", "duckling"],
2020
- "6-8": [MASCOT_ID, "fox", "lion", "dragon", "unicorn", "tiger", "monkey", "dinosaur", "octopus", "ghost", "shark", "wolf", "parrot"],
2251
+ "3-5": [MASCOT_ID, "bunny", "bear", "panda", "penguin", "koala", "frog", "cat", "owl", "chick", "puppy", "lamb", "duckling", "lion", "trex", "fairy"],
2252
+ "6-8": [MASCOT_ID, "fox", "lion", "dragon", "unicorn", "tiger", "monkey", "dinosaur", "trex", "octopus", "ghost", "shark", "wolf", "parrot", "fairy"],
2021
2253
  "9-12": [MASCOT_ID, "robot", "astronaut", "ninja", "skater", "alien", "explorer", "wizard", "knight", "racer", "pirate", "diver", "hero"]
2022
2254
  };
2023
2255
  var BY_ID = new Map(AVATARS.map((a) => [a.id, a]));
@@ -2029,9 +2261,13 @@ function avatarsFor(ageRange) {
2029
2261
  return ids.map((id) => BY_ID.get(id));
2030
2262
  }
2031
2263
  var bandOf = (id) => BY_ID.get(id)?.band ?? DEFAULT_AVATAR.band;
2264
+ var bandsOf = (id) => {
2265
+ const known = BY_ID.has(id) ? id : DEFAULT_AVATAR.id;
2266
+ return AGE_RANGES.filter((r) => AVATAR_BANDS[r].includes(known));
2267
+ };
2032
2268
  var resolve = (def) => typeof def === "string" ? BY_ID.get(def) ?? DEFAULT_AVATAR : def;
2033
2269
  function drawAvatar(ctx, body, def, box, opts = {}) {
2034
- const shapes = avatarShapes(body, resolve(def), box, { mirrored: opts.mirrored });
2270
+ const shapes = avatarShapes(body, resolve(def), box, { mirrored: opts.mirrored, band: opts.band });
2035
2271
  if (!shapes.length) return false;
2036
2272
  ctx.save();
2037
2273
  if (opts.alpha !== void 0) ctx.globalAlpha *= Math.min(Math.max(opts.alpha, 0), 1);
@@ -2039,25 +2275,27 @@ function drawAvatar(ctx, body, def, box, opts = {}) {
2039
2275
  ctx.restore();
2040
2276
  return true;
2041
2277
  }
2042
- function iconShapes(def, size) {
2278
+ function iconShapes(def, size, band) {
2043
2279
  const d = resolve(def);
2044
2280
  const body = neutralPose();
2045
- const band = bodyBand(d);
2046
- const unit = frameOf(body, { x: 0, y: 0, w: 1, h: 1 }, { band });
2047
- const top = -bounds(headShapes(d, 0.05)).minY;
2281
+ const shown = bodyBand(d, band);
2282
+ const unit = frameOf(body, { x: 0, y: 0, w: 1, h: 1 }, { band: shown });
2283
+ const hb = bounds(headShapes(d, 0.05));
2284
+ const top = -hb.minY;
2048
2285
  const headR = Math.min(0.27, 0.43 / Math.max(top, 1)) * size;
2049
- const k = headR / BAND_BODY[band].head / unit.U;
2286
+ const dx = d.head === "snout-long" ? (hb.minX + hb.maxX) / 2 * headR : 0;
2287
+ const k = headR / BAND_BODY[shown].head / unit.U;
2050
2288
  const cy = 0.5 * size + Math.min(0, (top - 1.6) * 0.06 * size);
2051
- const box = { x: size / 2 - unit.head.x * k, y: cy - unit.head.y * k, w: k, h: k };
2052
- return avatarShapes(body, d, box, { portrait: true });
2289
+ const box = { x: size / 2 - dx - unit.head.x * k, y: cy - unit.head.y * k, w: k, h: k };
2290
+ return avatarShapes(body, d, box, { portrait: true, band: shown });
2053
2291
  }
2054
- function drawAvatarIcon(ctx, def, x, y, size) {
2292
+ function drawAvatarIcon(ctx, def, x, y, size, opts = {}) {
2055
2293
  ctx.save();
2056
2294
  ctx.beginPath();
2057
2295
  ctx.rect(x, y, size, size);
2058
2296
  ctx.clip();
2059
2297
  ctx.translate(x, y);
2060
- drawShapes(ctx, iconShapes(def, size));
2298
+ drawShapes(ctx, iconShapes(def, size, opts.band));
2061
2299
  ctx.restore();
2062
2300
  }
2063
2301
  var clipCount = 0;
@@ -2066,7 +2304,7 @@ function avatarIconSvg(def, size, opts = {}) {
2066
2304
  const s = Math.round(size * 100) / 100;
2067
2305
  const bg = opts.background ? `<rect width="${s}" height="${s}" rx="${opts.radius ?? 0}" fill="${opts.background}"/>` : "";
2068
2306
  const clip = `av-${d.id}-${String(s).replace(".", "_")}-${++clipCount}`;
2069
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}"><title>${d.name.en}</title><defs><clipPath id="${clip}"><rect width="${s}" height="${s}" rx="${opts.radius ?? 0}"/></clipPath></defs>` + bg + `<g clip-path="url(#${clip})">${shapesToSvg(iconShapes(d, size))}</g></svg>`;
2307
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}"><title>${d.name.en}</title><defs><clipPath id="${clip}"><rect width="${s}" height="${s}" rx="${opts.radius ?? 0}"/></clipPath></defs>` + bg + `<g clip-path="url(#${clip})">${shapesToSvg(iconShapes(d, size, opts.band))}</g></svg>`;
2070
2308
  }
2071
2309
  var avatarIconDataUri = (def, size, opts) => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(avatarIconSvg(def, size, opts))}`;
2072
2310
  function smoothBody(prev, next, amount = 0.5) {
@@ -2188,6 +2426,7 @@ export {
2188
2426
  avatarShapes,
2189
2427
  avatarsFor,
2190
2428
  bandOf,
2429
+ bandsOf,
2191
2430
  blend,
2192
2431
  bodyBand,
2193
2432
  bounds,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fabi-play/avatars",
3
- "version": "0.3.0",
4
- "description": "37 child-friendly avatars: Fabi the fox in every age band and 12 per band, drawn from a body pose, on canvas or as SVG portraits for the pickers.",
3
+ "version": "0.4.0",
4
+ "description": "39 child-friendly avatars: Fabi the fox in every age band, 12 per band, a T-rex and a fairy, drawn from a body pose, on canvas or as SVG portraits for the pickers.",
5
5
  "keywords": [
6
6
  "fabi-play",
7
7
  "avatars",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@fabi-play/protocol": "^0.3.0"
35
+ "@fabi-play/protocol": "^0.4.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "24.10.1",