@fabi-play/avatars 0.3.0 → 0.5.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,9 @@
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: 12 for each age band, the T-rex and the fairy,
4
+ and Fabi the fox (the fox of the app icon, the mascot). Fabi is the host, not a player: no band offers it to the
5
+ children, but the TV and games draw it like any other avatar. The lion, the T-rex and the fairy
6
+ are offered to both 3–5 and 6–8. The TV shows them in place
5
7
  of the plain silhouette when the phone's video is not on screen. Each child picks one on the phone, among the avatars
6
8
  of their age band. The phone stores only the avatar's id; any valid id is accepted everywhere, whatever the band.
7
9
 
@@ -10,9 +12,9 @@ varied skin tones and gender-neutral looks, and are told apart by their gear.
10
12
 
11
13
  | Band | Style | id · en · it (the first four are the slot defaults) |
12
14
  | --- | --- | --- |
13
- | 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
+ | all | the app icon's fox, the host: in no band's list (pickers don't offer it), `special: true`, drawn with the 6–8 proportions; never a slot default; a child saved with it keeps it | `mascot` Fabi · Fabi |
16
+ | 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` |
17
+ | 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
18
  | 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
19
 
18
20
  ## Install
@@ -41,27 +43,50 @@ el.innerHTML = avatarIconSvg('panda', 96) // web DOM
41
43
  <img src={avatarIconDataUri('panda', 96)} /> // data URI
42
44
  ```
43
45
 
44
- - `AVATARS: readonly AvatarDef[]` (all 37), `AVATAR_IDS`, `DEFAULT_AVATAR` (the fox), `avatarById(id)`, `isAvatarId(x)`.
45
- - `AVATAR_BANDS: Record<AgeRange, readonly AvatarId[]>` (picker order: the mascot, `MASCOT_ID`, first, then the
46
- 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
46
+ - `AVATARS: readonly AvatarDef[]` (all 39), `AVATAR_IDS`, `DEFAULT_AVATAR` (the fox), `avatarById(id)`, `isAvatarId(x)`.
47
+ - `AVATAR_BANDS: Record<AgeRange, readonly AvatarId[]>` (picker order: the band's own 12, whose first four are the
48
+ slot defaults, see `defaultAvatarFor(slot, ageRange?)` in `@fabi-play/protocol`, then the avatars also offered
49
+ there; the lists are not all the same length: 15, 14, 12). The mascot (`MASCOT_ID`) is in none: Fabi is the host,
50
+ not a player. It stays in `AVATARS`, `isAvatarId('mascot')` is true and `drawAvatar` draws it,
51
+ `avatarsFor(ageRange)` (the definitions, in that order), `bandOf(id)` (the home band: `'all'` for the mascot, 6–8
52
+ for an unknown id), `bandsOf(id)` (every band whose list offers it: none for the mascot). Every definition has its `band`
53
+ (`AvatarBand`: an age range or `'all'`) and, if it is offered elsewhere too, `alsoIn` (see "Avatars of two bands"
54
+ below); `special: true` marks the mascot. `bodyBand(def, shown?)` gives the proportions
55
+ it is drawn with (6–8 for `'all'`).
56
+ - `drawAvatar(ctx, body, def | id, box, { alpha?, mirrored?, band? })`. Unknown ids draw the default avatar. `band`:
57
+ the child's age band, when the caller knows it (see "Avatars of two bands"). Pass
52
58
  `mirrored` when the body is in camera space rather than player space. It draws nothing when the shoulders are not
53
59
  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
60
+ - `drawAvatarIcon(ctx, def | id, x, y, size, { band? })`: a head-and-shoulders portrait in a neutral pose, clipped to
61
+ its square. A picker passes the band it shows.
62
+ - `avatarIconSvg(def | id, size, { background?, radius?, band? })`: the same portrait as an SVG document string. Its
56
63
  clip-path id is unique per call (avatar, size and a counter), so many portraits can sit inline in one page.
57
64
  `avatarIconDataUri` returns the same as a data URI.
58
65
  - `smoothBody(prev, next, amount = 0.5)`: optional per-player smoothing. Points ease towards the new frame. A point
59
66
  that was seen stays visible until its visibility drops below 0.3, so limbs don't blink. Keep the result per player.
60
67
  - Poses for previews and tests: `neutralPose()`, `armsUpPose()`, `starJumpPose()`, `squatPose()`, `wavePose()`,
61
68
  `TEST_POSES`, `poseBody(spec)`.
62
- - Lower level: `avatarShapes(body, def, box)` returns the shape list, `drawShapes(ctx, shapes)` draws it on a canvas,
69
+ - Lower level: `avatarShapes(body, def, box, { mirrored?, portrait?, band? })` returns the shape list,
70
+ `iconShapes(def, size, band?)` the portrait's, `drawShapes(ctx, shapes)` draws it on a canvas,
63
71
  and `shapesToSvg(shapes)` turns it into SVG. `frameOf(body, box)` gives the rig's torso frame.
64
72
 
73
+ ### Avatars of two bands
74
+
75
+ An avatar can be offered in more than one band: its `band` is its home, and `alsoIn` lists the others (today the
76
+ 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
77
+ the band's own, so the slot defaults never change.
78
+
79
+ - **Proportions follow the band shown.** Pass the band (`drawAvatar`/`avatarShapes` option `band`, the third argument
80
+ of `iconShapes`, the `band` option of `drawAvatarIcon`/`avatarIconSvg`): an avatar offered in that band takes its
81
+ proportions (`bodyBand(def, band)`), so the lion in a 3–5 picker or game has the toddler build. The band is the
82
+ child's (`Player.ageRange` on the phone and the TV) or the picker's.
83
+ - **Without a band, or with a band it is not offered in, it keeps its home band's** proportions: exactly what it drew
84
+ before. Avatars of one band, and the mascot (always 6–8), ignore `band` entirely, so passing it is always safe.
85
+ - **Its style stays its home band's** (outline colour, palette): one look, whatever the band. `bandOf(id)` still
86
+ returns the home band (one value, as before); `bandsOf(id)` returns them all.
87
+ - The SDK's and the engine's `drawAvatar` don't pass a band yet: in games these avatars are drawn with their home
88
+ band's proportions, as are their name tags.
89
+
65
90
  ## How it is drawn
66
91
 
67
92
  One source of truth: the rig and the portrait build a small **shape list** (`circle`, `ellipse`, `rect`, `path`,
@@ -83,8 +108,8 @@ a change that alters them by accident fails the tests (`UPDATE_SNAPSHOT=1` after
83
108
  - **Limbs**: thick round strokes from shoulder to elbow to wrist (0.22 U) and from hip to knee to ankle (0.28 U),
84
109
  each with an outline. Arms start just below the shoulder. Legs start inside the torso, so nothing looks detached.
85
110
  Hands are round caps. Feet are ovals, flippers or boots.
86
- - **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.
111
+ - **Proportions by band** (`BAND_BODY`): the joints are always the child's, only sizes change. An avatar of no
112
+ band (`'all'`, the mascot) takes the 6–8 row. An avatar of two bands takes the row of the band shown (above).
88
113
 
89
114
  | band | head radius | head centre above the shoulders | arm / leg thickness | hand radius | torso below the hips | torso min half width (shoulders / hips) |
90
115
  | --- | --- | --- | --- | --- | --- | --- |
@@ -95,9 +120,14 @@ a change that alters them by accident fails the tests (`UPDATE_SNAPSHOT=1` after
95
120
  In 3–5 an arm raised well above the shoulders is drawn over the big head, which would hide it.
96
121
  - **Head**: radius by band (above), centred above the shoulder midpoint along the torso axis. It follows the
97
122
  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.
123
+ clamped to ±0.5 rad from the torso, and a swapped pair is treated as a child facing away. The `snout-long` head
124
+ (the T-rex) is turned three-quarters, its snout on the left, away from the tail; its portrait is centred on the
125
+ head's width, snout included.
99
126
  - **Tail, wings, backpack, tentacles**: these hang off the hips or the back, in torso coordinates, and are drawn
100
127
  behind the body. The tail goes on the child's right. Portraits leave it out.
128
+ - **Held things**: the fairy's `wand` is in the hand on the right, following it (up and out from the body, leaning
129
+ with the forearm), under the hand; no wrist, no wand. Portraits leave it out. `tiny-arms` only makes the arms
130
+ slimmer, with small clawed hands: they still reach the child's hands.
101
131
  - **Draw order**: back extras, legs, torso, arms, head, then (3–5 only) raised arms.
102
132
  - **Degrading**: a point counts as seen at visibility ≥ 0.5. If the elbow or knee is missing, that limb is hidden.
103
133
  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 +136,8 @@ a change that alters them by accident fails the tests (`UPDATE_SNAPSHOT=1` after
106
136
  ## Adding an avatar
107
137
 
108
138
  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
139
+ of band `'all'` goes in no band (only the mascot, the host, is one); one offered in another band too lists it in `alsoIn` and goes
140
+ in that band's list as well, after the band's own). Choose a new
110
141
  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
142
  `outline`, plus optional `hands`, `feet` and `ears`. Then choose a `head` shape, `ears`, the `face` (eyes, mouth,
112
143
  muzzle, nose, cheeks), a `tail`, `headExtras` and `bodyExtras`. `extraColors` covers stripes, spots, manes and
@@ -115,7 +146,8 @@ a change that alters them by accident fails the tests (`UPDATE_SNAPSHOT=1` after
115
146
  body colour that reads on both the night background (#1f1813) and a light one. Keep the dark outline.
116
147
  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
148
  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
149
+ 4. Run `pnpm -F @fabi-play/avatars preview <dir> --details` and look at the PNGs (an avatar of two bands is shown
150
+ in each, with that band's proportions): the portrait, every pose, and
119
151
  both backgrounds. Then run `pnpm -F @fabi-play/avatars test`.
120
152
 
121
153
  ## 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. */
@@ -152,7 +152,7 @@ export interface FaceDef {
152
152
  /** Eyebrows of this colour (people): a little attitude. */
153
153
  brows?: string;
154
154
  }
155
- /** The age band an avatar is for, or 'all': offered in every band (Fabi the fox, the app's mascot). */
155
+ /** The age band an avatar is for, or 'all': of no band (Fabi the fox, the app's mascot: the host, in no picker). */
156
156
  export type AvatarBand = AgeRange | "all";
157
157
  /** An avatar, as data: one renderer draws any definition. */
158
158
  export interface AvatarDef {
@@ -164,11 +164,17 @@ 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
+ * no band's list (the mascot), drawn with the 6–8 proportions. See alsoIn for an avatar offered in other bands too.
169
169
  */
170
170
  band: AvatarBand;
171
- /** A special avatar (the mascot): first in every band's list; pickers may mark it. */
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[];
177
+ /** A special avatar (the mascot): the host, not a player; in no band's list (AVATAR_BANDS). */
172
178
  special?: boolean;
173
179
  /** Its colours. */
174
180
  palette: Palette;
@@ -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 the mascot ('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. */
@@ -360,13 +375,17 @@ export declare class BodyInterpolator {
360
375
  export declare function blend(a: Body$1, b: Body$1, u: number): Body$1;
361
376
  export declare const AVATAR_IDS: readonly string[];
362
377
  export type AvatarId = string;
363
- /** Fabi the fox, the app's mascot: an avatar of every band ('all'), first in each band's list. */
378
+ /**
379
+ * Fabi the fox, the app's mascot ('all': no band of its own). Fabi is the host, not a player: in no band's list
380
+ * (AVATAR_BANDS), so pickers never offer it, but it is a valid avatar (isAvatarId, avatarById, drawAvatar) for the TV
381
+ * and for games, and a child saved with it before keeps it.
382
+ */
364
383
  export declare const MASCOT_ID: AvatarId;
365
384
  /**
366
- * 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.
385
+ * The avatars of each age band, in the order the pickers show them: the band's own 12, then (3–5) the ones also
386
+ * offered there. The first four are the defaults of the four player slots (defaultAvatarFor in @fabi-play/protocol).
387
+ * Every avatar is in its own band and in those of its alsoIn; those of band 'all' (the mascot, MASCOT_ID) are in
388
+ * none: Fabi hosts, the children play. The lists are not all the same length.
370
389
  */
371
390
  export declare const AVATAR_BANDS: Readonly<Record<AgeRange, readonly AvatarId[]>>;
372
391
  /** The avatar of a child who has not picked one yet: the fox (6–8's first). */
@@ -376,13 +395,27 @@ export declare const isAvatarId: (v: unknown) => v is AvatarId;
376
395
  export declare const avatarById: (id: string) => AvatarDef | undefined;
377
396
  /** The avatars of an age band, in picker order (6–8 for anything that is not an age band). */
378
397
  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. */
398
+ /**
399
+ * The age band of an avatar, its home: 'all' for the mascot (in no band's list), 6–8 (the default avatar's) for an
400
+ * unknown id. An avatar offered in more than one band has one home band (its style, its proportions when the band
401
+ * shown is not known); bandsOf gives every band it is offered in.
402
+ */
380
403
  export declare const bandOf: (id: AvatarId) => AvatarBand;
404
+ /**
405
+ * Every age band whose list (AVATAR_BANDS) offers the avatar, youngest first; the default avatar's for an unknown id;
406
+ * none for the mascot (offered in no band).
407
+ */
408
+ export declare const bandsOf: (id: AvatarId) => readonly AgeRange[];
381
409
  export interface DrawOptions {
382
410
  /** 0–1, for the whole avatar. */
383
411
  alpha?: number;
384
412
  /** The body is in camera space (not mirrored like player space): flip it left to right. */
385
413
  mirrored?: boolean;
414
+ /**
415
+ * The age band the avatar is shown in (the child's), when known: an avatar offered in that band too (alsoIn) takes
416
+ * its proportions; any other avatar keeps its own band's (see bodyBand).
417
+ */
418
+ band?: AgeRange;
386
419
  }
387
420
  /**
388
421
  * Draws the avatar posed like the body. box is where player space (0–1) lands on the canvas, as for the silhouette
@@ -390,11 +423,18 @@ export interface DrawOptions {
390
423
  * Returns whether it drew.
391
424
  */
392
425
  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[];
426
+ /**
427
+ * The portrait: head and shoulders of the neutral pose, framed in a size × size square at (0, 0). band: the band the
428
+ * picker shows (see DrawOptions.band).
429
+ */
430
+ export declare function iconShapes(def: AvatarDef | string, size: number, band?: AgeRange): Shape[];
431
+ export interface IconOptions {
432
+ /** The band the picker shows (see DrawOptions.band). */
433
+ band?: AgeRange;
434
+ }
395
435
  /** 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 {
436
+ export declare function drawAvatarIcon(ctx: CanvasRenderingContext2D | Ctx2D, def: AvatarDef | string, x: number, y: number, size: number, opts?: IconOptions): void;
437
+ export interface IconSvgOptions extends IconOptions {
398
438
  /** A background colour for the square; transparent by default. */
399
439
  background?: string;
400
440
  /** 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,9 +2248,9 @@ 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"],
2021
- "9-12": [MASCOT_ID, "robot", "astronaut", "ninja", "skater", "alien", "explorer", "wizard", "knight", "racer", "pirate", "diver", "hero"]
2251
+ "3-5": ["bunny", "bear", "panda", "penguin", "koala", "frog", "cat", "owl", "chick", "puppy", "lamb", "duckling", "lion", "trex", "fairy"],
2252
+ "6-8": ["fox", "lion", "dragon", "unicorn", "tiger", "monkey", "dinosaur", "trex", "octopus", "ghost", "shark", "wolf", "parrot", "fairy"],
2253
+ "9-12": ["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]));
2024
2256
  var DEFAULT_AVATAR = BY_ID.get("fox");
@@ -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.5.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.5.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "24.10.1",