@idosgames/mcp 0.1.37 → 0.1.38

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.
Files changed (43) hide show
  1. package/README.md +6 -0
  2. package/dist/cli.js +2204 -27
  3. package/package.json +4 -1
  4. package/registry/host.json +3 -3
  5. package/registry/index.json +310 -156
  6. package/registry/modules/ai.json +7 -7
  7. package/registry/modules/board-game.json +14 -14
  8. package/registry/modules/boosts.json +3 -3
  9. package/registry/modules/character.json +32 -15
  10. package/registry/modules/chat.json +3 -3
  11. package/registry/modules/collections.json +7 -7
  12. package/registry/modules/coop-events.json +7 -7
  13. package/registry/modules/craft.json +3 -3
  14. package/registry/modules/deals.json +7 -7
  15. package/registry/modules/events.json +8 -8
  16. package/registry/modules/friends.json +9 -9
  17. package/registry/modules/hero-run.json +498 -0
  18. package/registry/modules/idle-rpg.json +69 -17
  19. package/registry/modules/inventory.json +3 -3
  20. package/registry/modules/invite.json +7 -7
  21. package/registry/modules/leaderboard.json +10 -10
  22. package/registry/modules/lootboxes.json +7 -7
  23. package/registry/modules/mailbox.json +8 -8
  24. package/registry/modules/marketplace.json +8 -8
  25. package/registry/modules/mini-game.json +8 -8
  26. package/registry/modules/multiplayer.json +7 -7
  27. package/registry/modules/premium.json +7 -7
  28. package/registry/modules/profile.json +7 -7
  29. package/registry/modules/pvp.json +7 -7
  30. package/registry/modules/quests.json +8 -8
  31. package/registry/modules/rewards.json +8 -8
  32. package/registry/modules/season.json +7 -7
  33. package/registry/modules/store.json +7 -7
  34. package/registry/modules/tutorials.json +7 -7
  35. package/registry/modules/video-studio.json +228 -0
  36. package/registry/modules/voxelcraft.json +42 -125
  37. package/registry/modules/workshop.json +3 -3
  38. package/registry/skills/hero-run.json +6 -0
  39. package/registry/skills/idosgames-module-contract.json +2 -2
  40. package/registry/skills/idosgames-project-structure.json +1 -1
  41. package/registry/skills/idosgames-ui-kit.json +1 -1
  42. package/registry/skills/video-studio.json +6 -0
  43. package/registry/video.json +9800 -0
@@ -0,0 +1,498 @@
1
+ {
2
+ "id": "hero-run",
3
+ "meta": {
4
+ "name": "Hero Run",
5
+ "type": "game",
6
+ "engine": "phaser",
7
+ "genre": "roguelike-rpg"
8
+ },
9
+ "catalog": {
10
+ "type": "template",
11
+ "uses": [
12
+ "character",
13
+ "lootboxes",
14
+ "store",
15
+ "inventory"
16
+ ],
17
+ "summary": "Roguelike runs in the style of Obsidian Knight with Warcraft III's damage: the hero runs a level on its own, rage fills and each run level is a choice of one of three cards; a weapon's attack type meets a troop's armor type.",
18
+ "description": "A roguelike auto-runner on Phaser with a React screen. The campaign is islands of six levels; a level is ONE run along a road (Obsidian Knight style: the hero runs, its feet in step with the road, the world scrolls in painted parallax per island theme): waves of troops standing in line (brawlers one at a time face to face, shooters fire once the hero is in their range), a camp (health or rage, both for gems), fountains, a chest, a boss at the end. Kills fill the RAGE; every threshold (3, 7, 14, 22, … as in Obsidian Knight) is a run level and a pause to take one of three cards — 58 upgrades in Obsidian Knight's form, a trigger and an effect (passive; on hit, crit, kill, dodge, counter, being hit, heal, overheal, the active skill, rest; every N-th attack; a new run level) throwing Lightning, Axes, Piercing spears and Arrows, stunning, healing, repairing the armor, giving charges (Focus, Divine shield, lightning); one Refresh a run. Death ends the run. Damage is Warcraft III's: seven attack types (normal, pierce, siege, magic, chaos, spells, hero) × six armor types (light, medium, heavy, fortified, hero, unarmored) by the Frozen Throne table, and armor points by the WC3 formula 0.06·A/(1+0.06·A) — negative armor amplifies. Types come from the gear and read off the picture: troops (people on the hero's rig) are dressed in what gives their types, the hero's weapon and top give its own (an item may name them in CustomData AttackType / ArmorType), a shield is a blue pool that takes the blows first. The types come in island by island (the first is casual), with a scouting screen and one tip per new type. One ultimate slot (meteor or frenzy) used once a run — only a card's chance (at a kill, at a new run level) brings it back; potions taken into the run; no health regen. A run costs ENERGY (Recharge) converted into a RUN_TICKET and ends in a reward chest opened with that ticket (lootboxes run_chest_defeat / victory / treasure); XP is a currency, the player's level its balance. Without these currencies the runs are free and pay nothing. The hero's fight numbers are the server's character stats (a mirror of PvPBattleEngine.CalculateStats) and are NOT sold: between runs they grow from gear (item levels) and rank on the Heroes screen, in a run from the cards; during a run the panel under the road shows the hero's sheet — every number with what the cards add (+15), the cards taken and their effects. Progress is saved in the player's private custom data (hero_run_progress); cleared levels and finished runs are reported to quests that count level_cleared / run_finished.",
19
+ "provides": [
20
+ "roguelike runs of levels: waves, camp, fountains, chest, boss",
21
+ "rage and run levels with a choice of 1 of 3 cards (58 trigger-and-effect upgrades, a refresh)",
22
+ "Warcraft III attack types × armor types and the armor formula",
23
+ "types from the gear: troops and the hero read off their look",
24
+ "scouting of a level and type tips introduced island by island",
25
+ "one ultimate a run (cards may bring it back) and potions",
26
+ "energy → run ticket → reward chest (server-side through currency and lootboxes)",
27
+ "2D procedural art (Phaser) with a layered 2D character",
28
+ "the run's hero sheet: numbers with the cards' bonuses, card effects"
29
+ ],
30
+ "tags": [
31
+ "roguelike",
32
+ "rpg",
33
+ "auto-battler",
34
+ "obsidian-knight",
35
+ "warcraft",
36
+ "cards",
37
+ "phaser",
38
+ "2d"
39
+ ],
40
+ "author": {
41
+ "name": "iDos Games",
42
+ "url": "https://idosgames.com"
43
+ },
44
+ "version": "0.1.0",
45
+ "events": {
46
+ "emits": [
47
+ {
48
+ "topic": "hero-run:character-upgraded@1",
49
+ "when": "a hero's rank went up (client.character.upgradeCharacterLevel, from any screen)",
50
+ "payload": {
51
+ "characterId": "string",
52
+ "level": "number"
53
+ }
54
+ },
55
+ {
56
+ "topic": "hero-run:level-cleared@1",
57
+ "when": "the hero beat a level's boss; island-level is the level just cleared",
58
+ "payload": {
59
+ "island": "number",
60
+ "level": "number"
61
+ }
62
+ }
63
+ ],
64
+ "listens": [
65
+ {
66
+ "topic": "character:hero-selected@1",
67
+ "why": "fields the hero picked on the Heroes screen (optional — without it the strongest hero fights)"
68
+ }
69
+ ]
70
+ }
71
+ },
72
+ "dependencies": {
73
+ "@idosgames/core": "0.23.0",
74
+ "@idosgames/module-sdk": "0.13.0",
75
+ "@idosgames/react": "0.6.0",
76
+ "phaser": "4.2.1",
77
+ "react": "19.2.7"
78
+ },
79
+ "contentHash": "2f0ebb048c12b5d9b6970cde73504c96385f5fea653fce5860622d1b2cde8762",
80
+ "files": [
81
+ {
82
+ "path": "character/art/svg.ts",
83
+ "content": "import type { ArtDef, ArtStyle } from \"../core/catalog\";\n\n// Helpers for drawing the art as SVG — one thick dark outline, flat fills, a few highlights. Each\n// image is drawn on its own canvas, the attachment's width × height, (0, 0) at the top-left, y down;\n// a mesh's UVs map straight onto it.\n// Art for a painted type (skin, hair, beard, brows) is drawn white and grey: the colour multiplies.\n\n/** The outline colour. */\nexport const INK = \"#1f1a21\";\n/** The outline width, px. */\nexport const LINE = 3.6;\n/** Inner detail lines. */\nexport const THIN = 2.2;\n\n/** The colour drawn where the art says `currentColor` (an iris painted by the eye colour). */\nexport const PAINT = \"currentColor\";\n\n/** A whole SVG document of the art on its canvas, ×scale (rasterized once per image), drawn in a\n * style: the colour of its painted part, the light on it. */\nexport function svgDocument(\n art: ArtDef,\n size: { w: number; h: number },\n scale = 1,\n style: ArtStyle = {},\n): string {\n const w = Math.max(1, Math.ceil(size.w * scale));\n const h = Math.max(1, Math.ceil(size.h * scale));\n const color = style.paint ?? art.paint;\n const lit = style.light ? celShade(size, style.light) : \"\";\n return (\n `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${w}\" height=\"${h}\" viewBox=\"0 0 ${size.w} ${size.h}\"` +\n `${color ? ` color=\"${color}\"` : \"\"}>${lit}` +\n `<g stroke-linejoin=\"round\" stroke-linecap=\"round\"${lit ? ` filter=\"url(#cel)\"` : \"\"}>${art.svg}</g></svg>`\n );\n}\n\n/** How deep the shade and the light go — the share of an image's smaller side, within bounds (px) —\n * and how strong they are. */\nexport const SHADE = { depth: 0.18, min: 2.6, max: 11, dark: 0.3, shine: 0.36 };\n\n/** The shading as an SVG filter over the whole image: inside its outline, the side away\n * from the light gets a dark crescent (the shape minus itself moved towards the light), the side\n * towards it a thin light rim. Nothing is drawn outside the art's own pixels. */\nfunction celShade(\n size: { w: number; h: number },\n light: { x: number; y: number },\n): string {\n const d = Math.max(\n SHADE.min,\n Math.min(SHADE.max, Math.min(size.w, size.h) * SHADE.depth),\n );\n const r = (v: number) => +v.toFixed(2);\n return (\n `<defs><filter id=\"cel\" filterUnits=\"userSpaceOnUse\" x=\"0\" y=\"0\" width=\"${size.w}\" height=\"${size.h}\" color-interpolation-filters=\"sRGB\">` +\n `<feMorphology in=\"SourceAlpha\" operator=\"erode\" radius=\"${r(LINE * 0.7)}\" result=\"body\"/>` +\n `<feOffset in=\"body\" dx=\"${r(light.x * d)}\" dy=\"${r(light.y * d)}\" result=\"moved\"/>` +\n `<feComposite in=\"body\" in2=\"moved\" operator=\"out\" result=\"far\"/>` +\n `<feFlood flood-color=\"#000000\" flood-opacity=\"${SHADE.dark}\"/><feComposite in2=\"far\" operator=\"in\" result=\"dark\"/>` +\n `<feMorphology in=\"SourceAlpha\" operator=\"erode\" radius=\"${r(LINE * 1.05)}\" result=\"inner\"/>` +\n `<feOffset in=\"inner\" dx=\"${r(-light.x * d * 0.42)}\" dy=\"${r(-light.y * d * 0.42)}\" result=\"back\"/>` +\n `<feComposite in=\"inner\" in2=\"back\" operator=\"out\" result=\"near\"/>` +\n `<feFlood flood-color=\"#ffffff\" flood-opacity=\"${SHADE.shine}\"/><feComposite in2=\"near\" operator=\"in\" result=\"shine\"/>` +\n `<feMerge><feMergeNode in=\"SourceGraphic\"/><feMergeNode in=\"dark\"/><feMergeNode in=\"shine\"/></feMerge>` +\n `</filter></defs>`\n );\n}\n\n// ── Primitives (every one outlined unless said) ─────────────────────────────────────────────────\n\ninterface Stroke {\n /** Outline colour; \"none\" for a flat shape (highlights, shading). Default INK. */\n stroke?: string;\n width?: number;\n opacity?: number;\n /** Inner sub-paths cut holes (fill-rule evenodd): a cage, a ring, a frame. */\n holes?: boolean;\n}\n\nfunction strokeAttrs(o: Stroke = {}): string {\n const stroke = o.stroke ?? INK;\n const op =\n (o.opacity !== undefined ? ` opacity=\"${o.opacity}\"` : \"\") +\n (o.holes ? ` fill-rule=\"evenodd\"` : \"\");\n return stroke === \"none\"\n ? ` stroke=\"none\"${op}`\n : ` stroke=\"${stroke}\" stroke-width=\"${o.width ?? LINE}\"${op}`;\n}\n\nexport const path = (d: string, fill: string, o?: Stroke): string =>\n `<path d=\"${d}\" fill=\"${fill}\"${strokeAttrs(o)}/>`;\n\nexport const ellipse = (\n cx: number,\n cy: number,\n rx: number,\n ry: number,\n fill: string,\n o?: Stroke,\n): string =>\n `<ellipse cx=\"${cx}\" cy=\"${cy}\" rx=\"${rx}\" ry=\"${ry}\" fill=\"${fill}\"${strokeAttrs(o)}/>`;\n\nexport const circle = (\n cx: number,\n cy: number,\n r: number,\n fill: string,\n o?: Stroke,\n): string => ellipse(cx, cy, r, r, fill, o);\n\nexport const rect = (\n x: number,\n y: number,\n w: number,\n h: number,\n r: number,\n fill: string,\n o?: Stroke,\n): string =>\n `<rect x=\"${x}\" y=\"${y}\" width=\"${w}\" height=\"${h}\" rx=\"${r}\" fill=\"${fill}\"${strokeAttrs(o)}/>`;\n\n/** An open line (a crease, a string). */\nexport const line = (d: string, color = INK, width = THIN): string =>\n `<path d=\"${d}\" fill=\"none\" stroke=\"${color}\" stroke-width=\"${width}\"/>`;\n\n/** A shape without an outline (a highlight, a shadow). */\nexport const flat = (d: string, fill: string, opacity?: number): string =>\n path(d, fill, {\n stroke: \"none\",\n ...(opacity !== undefined ? { opacity } : {}),\n });\n\n/** Children cut to a shape (patterns that must not cross its outline). The id is the image's own. */\nexport const clipped = (id: string, d: string, ...children: string[]): string =>\n `<clipPath id=\"${id}\"><path d=\"${d}\" clip-rule=\"evenodd\"/></clipPath><g clip-path=\"url(#${id})\">${children.join(\"\")}</g>`;\n\n/** A group turned / moved as one. */\nexport const group = (transform: string, ...children: string[]): string =>\n `<g transform=\"${transform}\">${children.join(\"\")}</g>`;\n\n/** A colour made darker (k < 0) or lighter (k > 0), k in -1..1 — for shades and highlights. */\nexport function tone(hex: string, k: number): string {\n const n = parseInt(hex.replace(\"#\", \"\"), 16);\n const f = (v: number) =>\n Math.max(\n 0,\n Math.min(255, Math.round(k < 0 ? v * (1 + k) : v + (255 - v) * k)),\n );\n const r = f((n >> 16) & 255);\n const g = f((n >> 8) & 255);\n const b = f(n & 255);\n return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, \"0\")}`;\n}\n"
84
+ },
85
+ {
86
+ "path": "character/core/animation.ts",
87
+ "content": "import { IkConstraint } from \"./constraints\";\nimport { Keys } from \"./curves\";\nimport type { SkeletonData } from \"./data\";\nimport { colorOf } from \"./types\";\nimport type { Skeleton } from \"./skeleton\";\n\n// An animation: timelines of bone, slot and IK values plus events, read from the skeleton data's\n// format (core/data) and applied over the setup pose at full weight: a bone's rotation and\n// translation add to its setup values, its scale multiplies them, a slot's colour and attachment and\n// an IK constraint's mix are set outright. Before a timeline's first key the setup value stays.\n\nexport interface AnimationEventKey {\n time: number;\n name: string;\n}\n\ninterface Timeline {\n /** The bone or slot it writes (for overlays that take only some timelines). */\n readonly bone?: number;\n readonly slot?: number;\n apply(sk: Skeleton, time: number): void;\n}\n\n/** Which bones and slots (by index) an overlay takes from an animation. */\nexport interface TimelineFilter {\n bones: ReadonlySet<number>;\n slots: ReadonlySet<number>;\n}\n\nconst passes = (t: Timeline, f: TimelineFilter) =>\n (t.bone !== undefined && f.bones.has(t.bone)) ||\n (t.slot !== undefined && f.slots.has(t.slot));\n\nexport class Animation {\n constructor(\n readonly name: string,\n readonly duration: number,\n private readonly timelines: Timeline[],\n readonly events: AnimationEventKey[],\n ) {}\n\n /** Writes the animation at `time` into the skeleton's pose (call after skeleton.setupPose()). */\n apply(sk: Skeleton, time: number): void {\n for (const t of this.timelines) t.apply(sk, time);\n }\n\n /** Only the timelines of the filter's bones and slots (an overlay, e.g. the blink). */\n applyOnly(sk: Skeleton, time: number, filter: TimelineFilter): void {\n for (const t of this.timelines) if (passes(t, filter)) t.apply(sk, time);\n }\n\n /** Does it animate any of the filter's bones or slots. */\n touches(filter: TimelineFilter): boolean {\n return this.timelines.some((t) => passes(t, filter));\n }\n}\n\n// ── Timelines ────────────────────────────────────────────────────────────────────────────────────\n\ntype BoneProp = \"rotate\" | \"x\" | \"y\" | \"scaleX\" | \"scaleY\";\n\nclass BoneTimeline implements Timeline {\n constructor(\n readonly bone: number,\n private readonly keys: Keys,\n private readonly props: BoneProp[],\n ) {}\n\n apply(sk: Skeleton, time: number): void {\n if (this.keys.keyAt(time) < 0) return;\n const bone = sk.bones[this.bone]!;\n const pose = bone.pose;\n const d = bone.data;\n this.props.forEach((prop, ch) => {\n const v = this.keys.value(time, ch);\n switch (prop) {\n case \"rotate\":\n pose.rotation = d.rotation + v;\n break;\n case \"x\":\n pose.x = d.x + v;\n break;\n case \"y\":\n pose.y = d.y + v;\n break;\n case \"scaleX\":\n pose.scaleX = d.scaleX * v;\n break;\n case \"scaleY\":\n pose.scaleY = d.scaleY * v;\n break;\n }\n });\n }\n}\n\nclass SlotColorTimeline implements Timeline {\n /** channels: r, g, b, a — or a subset (rgb, alpha). */\n constructor(\n readonly slot: number,\n private readonly keys: Keys,\n private readonly channels: Array<\"r\" | \"g\" | \"b\" | \"a\">,\n ) {}\n\n apply(sk: Skeleton, time: number): void {\n if (this.keys.keyAt(time) < 0) return;\n const color = sk.slots[this.slot]!.color;\n this.channels.forEach((c, ch) => (color[c] = this.keys.value(time, ch)));\n }\n}\n\nclass AttachmentTimeline implements Timeline {\n constructor(\n readonly slot: number,\n private readonly times: number[],\n private readonly names: Array<string | null>,\n ) {}\n\n apply(sk: Skeleton, time: number): void {\n if (time < this.times[0]!) return;\n let k = 0;\n while (k + 1 < this.times.length && this.times[k + 1]! <= time) k++;\n sk.slots[this.slot]!.attachmentKey = this.names[k]!;\n }\n}\n\nclass IkTimeline implements Timeline {\n constructor(\n private readonly constraint: number,\n private readonly keys: Keys,\n private readonly bends: Array<1 | -1>,\n ) {}\n\n apply(sk: Skeleton, time: number): void {\n const k = this.keys.keyAt(time);\n if (k < 0) return;\n const c = sk.constraints[this.constraint];\n if (!(c instanceof IkConstraint)) return;\n c.mix = this.keys.value(time, 0);\n c.bendDirection = this.bends[k]!;\n }\n}\n\n// ── Reading ──────────────────────────────────────────────────────────────────────────────────────\n\n/* eslint-disable @typescript-eslint/no-explicit-any -- the data's JSON, read field by field */\ntype Json = any;\n\nfunction keysOf(\n frames: Json[],\n channels: number,\n read: (f: Json) => number[],\n): Keys {\n const keys = new Keys(channels);\n for (const f of frames) keys.add(f.time ?? 0, read(f));\n frames.forEach((f, k) => keys.setCurve(k, f.curve));\n return keys;\n}\n\nconst BONE_TIMELINES: Record<\n string,\n { props: BoneProp[]; read: (f: Json) => number[] }\n> = {\n rotate: { props: [\"rotate\"], read: (f) => [f.value ?? 0] },\n translate: { props: [\"x\", \"y\"], read: (f) => [f.x ?? 0, f.y ?? 0] },\n translatex: { props: [\"x\"], read: (f) => [f.value ?? 0] },\n translatey: { props: [\"y\"], read: (f) => [f.value ?? 0] },\n scale: { props: [\"scaleX\", \"scaleY\"], read: (f) => [f.x ?? 1, f.y ?? 1] },\n scalex: { props: [\"scaleX\"], read: (f) => [f.value ?? 1] },\n scaley: { props: [\"scaleY\"], read: (f) => [f.value ?? 1] },\n};\n\nexport function readAnimation(\n name: string,\n json: Json,\n data: SkeletonData,\n): Animation {\n const timelines: Timeline[] = [];\n let duration = 0;\n const track = (frames: Json[]) => {\n for (const f of frames) duration = Math.max(duration, f.time ?? 0);\n };\n\n for (const [boneName, tls] of Object.entries<Json>(json.bones ?? {})) {\n const bone = data.bone(boneName).index;\n for (const [kind, frames] of Object.entries<Json[]>(tls)) {\n const def = BONE_TIMELINES[kind];\n if (!def || !frames.length) continue;\n track(frames);\n timelines.push(\n new BoneTimeline(\n bone,\n keysOf(frames, def.props.length, def.read),\n def.props,\n ),\n );\n }\n }\n\n for (const [slotName, tls] of Object.entries<Json>(json.slots ?? {})) {\n const slot = data.slot(slotName).index;\n for (const [kind, frames] of Object.entries<Json[]>(tls)) {\n if (!frames.length) continue;\n track(frames);\n if (kind === \"attachment\") {\n timelines.push(\n new AttachmentTimeline(\n slot,\n frames.map((f) => f.time ?? 0),\n frames.map((f) => f.name ?? null),\n ),\n );\n } else if (kind === \"rgba\" || kind === \"rgb\") {\n const ch =\n kind === \"rgba\"\n ? ([\"r\", \"g\", \"b\", \"a\"] as const)\n : ([\"r\", \"g\", \"b\"] as const);\n const keys = keysOf(frames, ch.length, (f) => {\n const c = colorOf(f.color);\n return ch.map((k) => c[k]);\n });\n timelines.push(new SlotColorTimeline(slot, keys, [...ch]));\n } else if (kind === \"alpha\") {\n timelines.push(\n new SlotColorTimeline(\n slot,\n keysOf(frames, 1, (f) => [f.value ?? 0]),\n [\"a\"],\n ),\n );\n }\n }\n }\n\n for (const [ikName, frames] of Object.entries<Json[]>(json.ik ?? {})) {\n const c = data.constraints.find(\n (x) => x.type === \"ik\" && x.name === ikName,\n );\n if (!c || !frames.length) continue;\n track(frames);\n timelines.push(\n new IkTimeline(\n c.index,\n keysOf(frames, 1, (f) => [f.mix ?? 1]),\n frames.map((f) => (f.bendPositive === false ? -1 : 1)),\n ),\n );\n }\n\n const events: AnimationEventKey[] = ((json.events ?? []) as Json[]).map(\n (e) => ({\n time: e.time ?? 0,\n name: e.name,\n }),\n );\n for (const e of events) duration = Math.max(duration, e.time);\n return new Animation(name, duration, timelines, events);\n}\n/* eslint-enable @typescript-eslint/no-explicit-any */\n"
88
+ },
89
+ {
90
+ "path": "character/core/animator.ts",
91
+ "content": "import type { Animation, TimelineFilter } from \"./animation\";\nimport type { SkeletonData } from \"./data\";\nimport { IkConstraint } from \"./constraints\";\nimport type { Skeleton } from \"./skeleton\";\n\n// Plays animations on a skeleton — one track: a new animation fades in over `mix` seconds (the rig's\n// mix, 0.2) from the pose the old one was in; events fire from the playing animation only. Each update the\n// pose is rebuilt: setup pose → animation(s) → blink → (the view then paints colours and computes the\n// world).\n//\n// The blink: an overlay takes the eye timelines of a window of one animation (the hero's own Blink)\n// and plays them every few seconds over any animation that does not move the eyes itself.\n\nexport interface PlayOptions {\n /** Default true (loops). */\n loop?: boolean;\n speed?: number;\n /** Cross-fade seconds; default the rig's mix. 0 — cut. */\n mix?: number;\n /** Start this many seconds in. */\n from?: number;\n}\n\nexport interface AnimationEvent {\n /** An event of the animation (\"Attack\", \"Step\", \"Hit\", \"Die\") or \"end\" when a non-looping one finishes. */\n name: string;\n animation: string;\n}\n\ntype Listener = (e: AnimationEvent) => void;\n\nexport interface BlinkOptions {\n /** The animation the blink is cut from, and the window of it. */\n animation: string;\n from: number;\n to: number;\n /** The bones and slots it takes (by name): the eyes. */\n bones: readonly string[];\n slots: readonly string[];\n /** Seconds between blinks, a random pick in [min, max]. */\n every: readonly [number, number];\n}\n\ninterface BlinkState {\n source: Animation;\n filter: TimelineFilter;\n options: BlinkOptions;\n wait: number;\n /** Seconds into the window while blinking, -1 — not blinking. */\n at: number;\n}\n\ninterface Track {\n animation: Animation;\n time: number;\n speed: number;\n loop: boolean;\n done: boolean;\n}\n\nexport class Animator {\n private track: Track | null = null;\n private previous: Track | null = null;\n private mixTime = 0;\n private mixDuration = 0;\n private readonly listeners = new Map<string, Set<Listener>>();\n private readonly snapshot: Float32Array;\n private readonly slotSnapshot: Float32Array;\n private readonly ikSnapshot: Float32Array;\n private blink: BlinkState | null = null;\n\n constructor(\n private readonly skeleton: Skeleton,\n private readonly data: SkeletonData,\n readonly defaultMix = 0.2,\n ) {\n this.snapshot = new Float32Array(skeleton.bones.length * 5);\n this.slotSnapshot = new Float32Array(skeleton.slots.length * 4);\n this.ikSnapshot = new Float32Array(skeleton.constraints.length);\n }\n\n /** Turns the blink overlay on (null — off). */\n setBlink(options: BlinkOptions | null): void {\n const source = options\n ? this.data.animations.get(options.animation)\n : undefined;\n if (!options || !source) {\n this.blink = null;\n return;\n }\n const filter: TimelineFilter = {\n bones: new Set(options.bones.map((b) => this.data.bone(b).index)),\n slots: new Set(options.slots.map((s) => this.data.slot(s).index)),\n };\n this.blink = {\n source,\n filter,\n options,\n wait: this.nextBlink(options),\n at: -1,\n };\n }\n\n /** Blink now (the overlay must be on). */\n blinkNow(): void {\n if (this.blink) this.blink.at = 0;\n }\n\n /** Is the overlay mid-blink. */\n blinking(): boolean {\n return (this.blink?.at ?? -1) >= 0;\n }\n\n private nextBlink(o: BlinkOptions): number {\n return o.every[0] + Math.random() * (o.every[1] - o.every[0]);\n }\n\n names(): string[] {\n return [...this.data.animations.keys()];\n }\n\n has(name: string): boolean {\n return this.data.animations.has(name);\n }\n\n play(name: string, options: PlayOptions = {}): boolean {\n const animation = this.data.animations.get(name);\n if (!animation) return false;\n const mix = options.mix ?? this.defaultMix;\n this.previous = this.track && mix > 0 ? this.track : null;\n this.mixTime = 0;\n this.mixDuration = this.previous ? mix : 0;\n this.track = {\n animation,\n time: Math.max(0, options.from ?? 0),\n speed: options.speed ?? 1,\n loop: options.loop ?? true,\n done: false,\n };\n return true;\n }\n\n current(): string | null {\n return this.track?.animation.name ?? null;\n }\n\n /** Seconds into the current animation. */\n time(): number {\n return this.track ? this.local(this.track) : 0;\n }\n\n duration(): number {\n return this.track?.animation.duration ?? 0;\n }\n\n finished(): boolean {\n return this.track?.done ?? false;\n }\n\n setSpeed(speed: number): void {\n if (this.track) this.track.speed = speed;\n }\n\n /** An event by name, \"end\", or \"*\" for all. Returns the unsubscribe. */\n on(name: string, listener: Listener): () => void {\n const set = this.listeners.get(name) ?? new Set();\n set.add(listener);\n this.listeners.set(name, set);\n return () => set.delete(listener);\n }\n\n /** Advances by dt seconds, fires the events passed, and writes the pose into the skeleton. */\n update(dt: number): void {\n const fired: AnimationEvent[] = [];\n const t = this.track;\n if (t && !t.done) {\n const from = t.time;\n t.time += Math.max(0, dt) * t.speed;\n this.collect(t, from, t.time, fired);\n if (!t.loop && t.time >= t.animation.duration) {\n t.time = t.animation.duration;\n t.done = true;\n fired.push({ name: \"end\", animation: t.animation.name });\n }\n }\n if (this.previous) {\n const p = this.previous;\n p.time += Math.max(0, dt) * p.speed;\n this.mixTime += Math.max(0, dt);\n if (this.mixTime >= this.mixDuration) this.previous = null;\n }\n this.advanceBlink(Math.max(0, dt));\n this.pose();\n for (const e of fired) {\n for (const l of this.listeners.get(e.name) ?? []) l(e);\n for (const l of this.listeners.get(\"*\") ?? []) l(e);\n }\n }\n\n /** Rebuilds the pose for the current times (no time passes). */\n pose(): void {\n const sk = this.skeleton;\n const prev = this.previous;\n if (prev && this.mixDuration > 0) {\n sk.setupPose();\n prev.animation.apply(sk, this.local(prev));\n this.save();\n }\n sk.setupPose();\n if (this.track) this.track.animation.apply(sk, this.local(this.track));\n if (prev && this.mixDuration > 0)\n this.blendFromSaved(Math.min(1, this.mixTime / this.mixDuration));\n const b = this.blink;\n if (b && b.at >= 0) b.source.applyOnly(sk, b.options.from + b.at, b.filter);\n sk.resolveAttachments();\n }\n\n private advanceBlink(dt: number): void {\n const b = this.blink;\n if (!b) return;\n // An animation that moves the eyes itself (Idle, Walk, Hit, Die) owns them: no overlay.\n if (this.track?.animation.touches(b.filter)) {\n b.at = -1;\n return;\n }\n if (b.at >= 0) {\n b.at += dt;\n if (b.at >= b.options.to - b.options.from) {\n b.at = -1;\n b.wait = this.nextBlink(b.options);\n }\n return;\n }\n b.wait -= dt;\n if (b.wait <= 0) b.at = 0;\n }\n\n private local(t: Track): number {\n const d = t.animation.duration;\n if (d <= 0) return 0;\n return t.loop ? t.time % d : Math.min(t.time, d);\n }\n\n /** Events in [from, to) of the track's time, unrolled over loops. */\n private collect(\n t: Track,\n from: number,\n to: number,\n out: AnimationEvent[],\n ): void {\n const d = t.animation.duration;\n if (d <= 0 || to <= from) return;\n const startLoop = Math.floor(from / d);\n const endLoop = t.loop ? Math.floor(to / d) : 0;\n for (let loop = startLoop; loop <= endLoop; loop++)\n for (const e of t.animation.events) {\n const at = loop * d + e.time;\n const inRange = at >= from && (at < to || (!t.loop && at <= to));\n if (inRange) out.push({ name: e.name, animation: t.animation.name });\n }\n }\n\n private save(): void {\n const sk = this.skeleton;\n sk.bones.forEach((b, i) => {\n const p = b.pose;\n this.snapshot.set([p.x, p.y, p.rotation, p.scaleX, p.scaleY], i * 5);\n });\n sk.slots.forEach((s, i) =>\n this.slotSnapshot.set(\n [s.color.r, s.color.g, s.color.b, s.color.a],\n i * 4,\n ),\n );\n sk.constraints.forEach((c, i) => {\n if (c instanceof IkConstraint) this.ikSnapshot[i] = c.mix;\n });\n }\n\n /** current = saved + (current − saved) × alpha; rotation the short way round. */\n private blendFromSaved(alpha: number): void {\n const sk = this.skeleton;\n const lerp = (a: number, b: number) => a + (b - a) * alpha;\n sk.bones.forEach((b, i) => {\n const s = this.snapshot;\n const p = b.pose;\n const o = i * 5;\n p.x = lerp(s[o]!, p.x);\n p.y = lerp(s[o + 1]!, p.y);\n let dr = p.rotation - s[o + 2]!;\n dr -= Math.round(dr / 360) * 360;\n p.rotation = s[o + 2]! + dr * alpha;\n p.scaleX = lerp(s[o + 3]!, p.scaleX);\n p.scaleY = lerp(s[o + 4]!, p.scaleY);\n });\n sk.slots.forEach((slot, i) => {\n const s = this.slotSnapshot;\n slot.color.r = lerp(s[i * 4]!, slot.color.r);\n slot.color.g = lerp(s[i * 4 + 1]!, slot.color.g);\n slot.color.b = lerp(s[i * 4 + 2]!, slot.color.b);\n slot.color.a = lerp(s[i * 4 + 3]!, slot.color.a);\n });\n sk.constraints.forEach((c, i) => {\n if (c instanceof IkConstraint) c.mix = lerp(this.ikSnapshot[i]!, c.mix);\n });\n }\n}\n"
92
+ },
93
+ {
94
+ "path": "character/core/attachments.ts",
95
+ "content": "import type { Attachment, MeshAttachment, RegionAttachment } from \"./data\";\nimport type { Skeleton, Slot } from \"./skeleton\";\n\n// Where an attachment's vertices are in the world this frame (y up). A region is a quad\n// of its image placed by x / y / rotation / scale on its slot's bone; a mesh's vertices are either in\n// the slot bone's space or weighted over several bones (that is what bends a sleeve at the elbow).\n// UVs are 0..1 over the attachment's own image, v down — our art draws each image on its own canvas.\n\nconst DEG = Math.PI / 180;\n\n/** Corner order bottom-left, top-left, top-right, bottom-right, two triangles. */\nexport const REGION_UVS = new Float32Array([0, 1, 0, 0, 1, 0, 1, 1]);\nexport const REGION_TRIANGLES = new Uint16Array([0, 1, 2, 2, 3, 0]);\n\nexport interface Geometry {\n /** x, y per vertex, world space. */\n positions: Float32Array;\n uvs: Float32Array;\n triangles: Uint16Array;\n}\n\nconst regionLocal = new WeakMap<RegionAttachment, Float32Array>();\n\n/** A region's four corners in its bone's space (computed once per attachment). */\nfunction regionOffsets(r: RegionAttachment): Float32Array {\n let o = regionLocal.get(r);\n if (o) return o;\n const x1 = (-r.width / 2) * r.scaleX;\n const y1 = (-r.height / 2) * r.scaleY;\n const x2 = (r.width / 2) * r.scaleX;\n const y2 = (r.height / 2) * r.scaleY;\n const cos = Math.cos(r.rotation * DEG);\n const sin = Math.sin(r.rotation * DEG);\n const corner = (x: number, y: number) => [\n x * cos - y * sin + r.x,\n x * sin + y * cos + r.y,\n ];\n o = Float32Array.from([\n ...corner(x1, y1),\n ...corner(x1, y2),\n ...corner(x2, y2),\n ...corner(x2, y1),\n ]);\n regionLocal.set(r, o);\n return o;\n}\n\n/** Writes the attachment's world vertices into `out` (resized as needed); returns the geometry. */\nexport function worldGeometry(\n sk: Skeleton,\n slot: Slot,\n attachment: Attachment,\n out: Float32Array | null,\n): Geometry {\n if (attachment.type === \"region\") {\n const positions = out && out.length === 8 ? out : new Float32Array(8);\n const o = regionOffsets(attachment);\n const b = slot.bone.applied;\n for (let i = 0; i < 8; i += 2) {\n const x = o[i]!;\n const y = o[i + 1]!;\n positions[i] = x * b.a + y * b.b + b.worldX;\n positions[i + 1] = x * b.c + y * b.d + b.worldY;\n }\n return { positions, uvs: REGION_UVS, triangles: REGION_TRIANGLES };\n }\n return meshGeometry(sk, slot, attachment, out);\n}\n\nfunction meshGeometry(\n sk: Skeleton,\n slot: Slot,\n m: MeshAttachment,\n out: Float32Array | null,\n): Geometry {\n const n = m.vertexCount * 2;\n const positions = out && out.length === n ? out : new Float32Array(n);\n const v = m.vertices;\n if (!m.weighted) {\n const b = slot.bone.applied;\n for (let i = 0; i < n; i += 2) {\n const x = v[i]!;\n const y = v[i + 1]!;\n positions[i] = x * b.a + y * b.b + b.worldX;\n positions[i + 1] = x * b.c + y * b.d + b.worldY;\n }\n } else {\n let i = 0;\n for (let w = 0; w < n; w += 2) {\n const count = v[i++]!;\n let wx = 0;\n let wy = 0;\n for (let k = 0; k < count; k++, i += 4) {\n const b = sk.bones[v[i]!]!.applied;\n const x = v[i + 1]!;\n const y = v[i + 2]!;\n const weight = v[i + 3]!;\n wx += (x * b.a + y * b.b + b.worldX) * weight;\n wy += (x * b.c + y * b.d + b.worldY) * weight;\n }\n positions[w] = wx;\n positions[w + 1] = wy;\n }\n }\n return { positions, uvs: m.uvs, triangles: m.triangles };\n}\n"
96
+ },
97
+ {
98
+ "path": "character/core/catalog.ts",
99
+ "content": "import type { ArtId } from \"./types\";\n\n// The art: one SVG per image the skeleton's attachments name (\"top/top_12\", \"arm_l\"), drawn on a\n// canvas of that attachment's width × height, (0, 0) at the top-left like the image it stands for —\n// meshes map their UVs straight onto it. Packs (packs/*) register art; a part (a skin) is drawable\n// when every image it uses has art.\n\nexport interface ArtDef {\n /** SVG markup (no <svg> wrapper), in the image's px: 0..width × 0..height, y down. */\n svg: string;\n /** The art paints part of itself with `currentColor` (an iris): this is its colour unless a colour\n * target drawn into the art (ColorTargetDef.into \"art\") gives another. */\n paint?: string;\n}\n\n/** How an image is drawn this time: the colour of its painted part, the light on it. */\nexport interface ArtStyle {\n /** The colour of its `currentColor` part (an iris); omitted — art.paint. */\n paint?: string | null;\n /** Towards the light in its pixels (core/shading); omitted — not shaded. */\n light?: { x: number; y: number } | null;\n}\n\nexport interface ArtPack {\n id: string;\n art: Readonly<Record<ArtId, ArtDef>>;\n}\n\nexport class ArtCatalog {\n private readonly art = new Map<ArtId, ArtDef>();\n\n constructor(packs: readonly ArtPack[] = []) {\n for (const p of packs) this.register(p);\n }\n\n /** Adds a pack; an image already present is replaced (a later pack overrides). */\n register(pack: ArtPack): this {\n for (const [id, a] of Object.entries(pack.art)) this.art.set(id, a);\n return this;\n }\n\n get(path: ArtId): ArtDef | undefined {\n return this.art.get(path);\n }\n\n has(path: ArtId): boolean {\n return this.art.has(path);\n }\n\n ids(): ArtId[] {\n return [...this.art.keys()];\n }\n}\n"
100
+ },
101
+ {
102
+ "path": "character/core/constraints.ts",
103
+ "content": "import type {\n IkData,\n TransformData,\n TransformProp,\n ConstraintData,\n} from \"./data\";\nimport type { Bone, BonePose, Skeleton } from \"./skeleton\";\n\n// The two constraint kinds a character may use:\n// - IK: one or two bones reach for a target bone (legs to the feet targets, arms to the hands);\n// - transform: properties of a source bone drive properties of other bones (a mapping:\n// from-property → to-properties with offset and scale), in world space.\n// Each constraint also sorts itself into the skeleton's update cache.\n\nconst DEG = Math.PI / 180;\nconst EPS = 0.00001;\n\nexport interface Constraint {\n readonly data: ConstraintData;\n setupPose(): void;\n sort(sk: Skeleton): void;\n update(sk: Skeleton): void;\n}\n\nexport function createConstraint(\n data: ConstraintData,\n sk: Skeleton,\n): Constraint {\n return data.type === \"ik\"\n ? new IkConstraint(data, sk)\n : new TransformConstraint(data, sk);\n}\n\nconst wrap180 = (a: number) => (a > 180 ? a - 360 : a <= -180 ? a + 360 : a);\n\n// ── IK ───────────────────────────────────────────────────────────────────────────────────────────\n\nexport class IkConstraint implements Constraint {\n mix: number;\n bendDirection: 1 | -1;\n private readonly bones: Bone[];\n private readonly target: Bone;\n\n constructor(\n readonly data: IkData,\n sk: Skeleton,\n ) {\n this.bones = data.bones.map((b) => sk.bones[b.index]!);\n this.target = sk.bones[data.target.index]!;\n this.mix = data.mix;\n this.bendDirection = data.bendDirection;\n }\n\n setupPose(): void {\n this.mix = this.data.mix;\n this.bendDirection = this.data.bendDirection;\n }\n\n sort(sk: Skeleton): void {\n sk.sortBone(this.target);\n const parent = this.bones[0]!;\n sk.sortBone(parent);\n sk.pushConstraint(this);\n parent.sorted = false;\n sk.sortReset(parent.children);\n }\n\n update(sk: Skeleton): void {\n if (this.mix === 0) return;\n const t = this.target.applied;\n if (this.bones.length === 1)\n ik1(sk, this.bones[0]!.applied, t.worldX, t.worldY, this.mix);\n else\n ik2(\n sk,\n this.bones[0]!.applied,\n this.bones[1]!.applied,\n t.worldX,\n t.worldY,\n this.bendDirection,\n this.mix,\n );\n }\n}\n\n/** One bone points at the target. */\nfunction ik1(\n sk: Skeleton,\n bone: BonePose,\n targetX: number,\n targetY: number,\n mix: number,\n): void {\n const parent = bone.bone.parent;\n if (!parent) return;\n bone.modifyLocal(sk);\n const p = parent.applied;\n const pa = p.a;\n const pb = p.b;\n const pc = p.c;\n const pd = p.d;\n let rotationIK = -bone.shearX - bone.rotation;\n const det = pa * pd - pb * pc;\n const x = targetX - p.worldX;\n const y = targetY - p.worldY;\n const tx = (x * pd - y * pb) / det - bone.x;\n const ty = (y * pa - x * pc) / det - bone.y;\n rotationIK += Math.atan2(ty, tx) / DEG;\n if (bone.scaleX < 0) rotationIK += 180;\n bone.rotation += wrap180(rotationIK) * mix;\n}\n\n/** Two bones (thigh + shin, upper arm + forearm) bend to reach the target. */\nfunction ik2(\n sk: Skeleton,\n parent: BonePose,\n child: BonePose,\n targetX: number,\n targetY: number,\n bendDir: number,\n mix: number,\n): void {\n parent.modifyLocal(sk);\n child.modifyLocal(sk);\n const px = parent.x;\n const py = parent.y;\n let psx = parent.scaleX;\n let psy = parent.scaleY;\n let csx = child.scaleX;\n let os1: number;\n let os2: number;\n let s2: number;\n if (psx < 0) {\n psx = -psx;\n os1 = 180;\n s2 = -1;\n } else {\n os1 = 0;\n s2 = 1;\n }\n if (psy < 0) {\n psy = -psy;\n s2 = -s2;\n }\n if (csx < 0) {\n csx = -csx;\n os2 = 180;\n } else os2 = 0;\n const u = Math.abs(psx - psy) <= EPS;\n let cwx: number;\n let cwy: number;\n if (!u) {\n child.y = 0;\n cwx = parent.a * child.x + parent.worldX;\n cwy = parent.c * child.x + parent.worldY;\n } else {\n cwx = parent.a * child.x + parent.b * child.y + parent.worldX;\n cwy = parent.c * child.x + parent.d * child.y + parent.worldY;\n }\n const grand = parent.bone.parent;\n if (!grand) return;\n const pp = grand.applied;\n const ga = pp.a;\n const gb = pp.b;\n const gc = pp.c;\n const gd = pp.d;\n let id = ga * gd - gb * gc;\n let x = cwx - pp.worldX;\n let y = cwy - pp.worldY;\n id = Math.abs(id) <= EPS ? 0 : 1 / id;\n const dx = (x * gd - y * gb) * id - px;\n const dy = (y * ga - x * gc) * id - py;\n const l1 = Math.sqrt(dx * dx + dy * dy);\n let l2 = child.bone.data.length * csx;\n if (l1 < EPS) {\n ik1(sk, parent, targetX, targetY, mix);\n child.rotation = 0;\n return;\n }\n x = targetX - pp.worldX;\n y = targetY - pp.worldY;\n const tx = (x * gd - y * gb) * id - px;\n const ty = (y * ga - x * gc) * id - py;\n const dd = tx * tx + ty * ty;\n let a1 = 0;\n let a2 = 0;\n if (u) {\n l2 *= psx;\n let cos = (dd - l1 * l1 - l2 * l2) / (2 * l1 * l2);\n if (cos < -1) {\n cos = -1;\n a2 = Math.PI * bendDir;\n } else if (cos > 1) {\n cos = 1;\n a2 = 0;\n } else a2 = Math.acos(cos) * bendDir;\n const a = l1 + l2 * cos;\n const b = l2 * Math.sin(a2);\n a1 = Math.atan2(ty * a - tx * b, tx * a + ty * b);\n } else {\n const a = psx * l2;\n const b = psy * l2;\n const aa = a * a;\n const bb = b * b;\n const ta = Math.atan2(ty, tx);\n let c = bb * l1 * l1 + aa * dd - aa * bb;\n const c1 = -2 * bb * l1;\n const c2 = bb - aa;\n const d = c1 * c1 - 4 * c2 * c;\n let solved = false;\n if (d >= 0) {\n let q = Math.sqrt(d);\n if (c1 < 0) q = -q;\n q = -(c1 + q) * 0.5;\n const r0 = q / c2;\n const r1 = c / q;\n const r = Math.abs(r0) < Math.abs(r1) ? r0 : r1;\n const r2 = dd - r * r;\n if (r2 >= 0) {\n const yy = Math.sqrt(r2) * bendDir;\n a1 = ta - Math.atan2(yy, r);\n a2 = Math.atan2(yy / psy, (r - l1) / psx);\n solved = true;\n }\n }\n if (!solved) {\n let minAngle = Math.PI;\n let minX = l1 - a;\n let minDist = minX * minX;\n let minY = 0;\n let maxAngle = 0;\n let maxX = l1 + a;\n let maxDist = maxX * maxX;\n let maxY = 0;\n c = (-a * l1) / (aa - bb);\n if (c >= -1 && c <= 1) {\n c = Math.acos(c);\n const xx = a * Math.cos(c) + l1;\n const yy = b * Math.sin(c);\n const dist = xx * xx + yy * yy;\n if (dist < minDist) {\n minAngle = c;\n minDist = dist;\n minX = xx;\n minY = yy;\n }\n if (dist > maxDist) {\n maxAngle = c;\n maxDist = dist;\n maxX = xx;\n maxY = yy;\n }\n }\n if (dd <= (minDist + maxDist) * 0.5) {\n a1 = ta - Math.atan2(minY * bendDir, minX);\n a2 = minAngle * bendDir;\n } else {\n a1 = ta - Math.atan2(maxY * bendDir, maxX);\n a2 = maxAngle * bendDir;\n }\n }\n }\n const os = Math.atan2(child.y, child.x) * s2;\n a1 = wrap180((a1 - os) / DEG + os1 - parent.rotation);\n a2 = wrap180(((a2 + os) / DEG - child.shearX) * s2 + os2 - child.rotation);\n parent.rotation += a1 * mix;\n child.rotation += a2 * mix;\n}\n\n// ── Transform ────────────────────────────────────────────────────────────────────────────────────\n\nexport class TransformConstraint implements Constraint {\n readonly mix: Record<TransformProp, number>;\n private readonly source: Bone;\n private readonly bones: Bone[];\n\n constructor(\n readonly data: TransformData,\n sk: Skeleton,\n ) {\n this.source = sk.bones[data.source.index]!;\n this.bones = data.bones.map((b) => sk.bones[b.index]!);\n this.mix = { ...data.mix };\n }\n\n setupPose(): void {\n Object.assign(this.mix, this.data.mix);\n }\n\n sort(sk: Skeleton): void {\n sk.sortBone(this.source);\n for (const b of this.bones) sk.sortBone(b);\n sk.pushConstraint(this);\n for (const b of this.bones) sk.sortReset(b.children);\n for (const b of this.bones) b.sorted = true;\n }\n\n update(sk: Skeleton): void {\n const m = this.mix;\n if (!m.rotate && !m.x && !m.y && !m.scaleX && !m.scaleY && !m.shearY)\n return;\n const source = this.source.applied;\n const offsets = this.data.offsets;\n for (const b of this.bones) {\n const bone = b.applied;\n bone.modifyWorld(sk);\n for (const from of this.data.properties) {\n const value = fromValue(sk, source, from.prop, offsets) - from.offset;\n for (const to of from.to) {\n const mix = m[to.prop];\n if (mix === 0) continue;\n toApply(sk, bone, to.prop, to.offset + value * to.scale, mix);\n }\n }\n }\n }\n}\n\n/** A source property in world space (rotate, x, …). */\nfunction fromValue(\n sk: Skeleton,\n s: BonePose,\n prop: TransformProp,\n offsets: Record<TransformProp, number>,\n): number {\n const sx = sk.scaleX;\n const sy = sk.scaleY;\n switch (prop) {\n case \"rotate\": {\n const sign = (s.a * s.d - s.b * s.c) * sx * sy > 0 ? 1 : -1;\n let r = Math.atan2(s.c / sy, s.a / sx) / DEG + sign * offsets.rotate;\n if (r < 0) r += 360;\n return r;\n }\n case \"x\":\n return (offsets.x * s.a + offsets.y * s.b + s.worldX) / sx;\n case \"y\":\n return (offsets.x * s.c + offsets.y * s.d + s.worldY) / sy;\n case \"scaleX\":\n return Math.hypot(s.a / sx, s.c / sy) + offsets.scaleX;\n case \"scaleY\":\n return Math.hypot(s.b / sx, s.d / sy) + offsets.scaleY;\n case \"shearY\":\n return (\n (Math.atan2(s.d / sy, s.b / sx) - Math.atan2(s.c / sy, s.a / sx)) /\n DEG -\n 90 +\n offsets.shearY\n );\n }\n}\n\n/** Moves a bone's world transform toward a value (rotate, x, …; world, absolute). */\nfunction toApply(\n sk: Skeleton,\n b: BonePose,\n prop: TransformProp,\n value: number,\n mix: number,\n): void {\n const sx = sk.scaleX;\n const sy = sk.scaleY;\n switch (prop) {\n case \"rotate\": {\n const a = b.a / sx;\n const bb = b.b / sx;\n const c = b.c / sy;\n const d = b.d / sy;\n let r = value * DEG - Math.atan2(c, a);\n if (r > Math.PI) r -= 2 * Math.PI;\n else if (r < -Math.PI) r += 2 * Math.PI;\n r *= mix;\n const cos = Math.cos(r);\n const sin = Math.sin(r);\n b.a = (cos * a - sin * c) * sx;\n b.b = (cos * bb - sin * d) * sx;\n b.c = (sin * a + cos * c) * sy;\n b.d = (sin * bb + cos * d) * sy;\n return;\n }\n case \"x\":\n b.worldX += (value - b.worldX / sx) * mix * sx;\n return;\n case \"y\":\n b.worldY += (value - b.worldY / sy) * mix * sy;\n return;\n case \"scaleX\": {\n let s = Math.hypot(b.a / sx, b.c / sy);\n if (s !== 0) {\n s = 1 + ((value - s) * mix) / s;\n b.a *= s;\n b.c *= s;\n }\n return;\n }\n case \"scaleY\": {\n let s = Math.hypot(b.b / sx, b.d / sy);\n if (s !== 0) {\n s = 1 + ((value - s) * mix) / s;\n b.b *= s;\n b.d *= s;\n }\n return;\n }\n case \"shearY\": {\n const bx = b.b / sx;\n const dy = b.d / sy;\n const by = Math.atan2(dy, bx);\n let v = (value + 90) * DEG;\n v -= by - Math.atan2(b.c / sy, b.a / sx);\n if (v > Math.PI) v -= 2 * Math.PI;\n else if (v < -Math.PI) v += 2 * Math.PI;\n v = by + v * mix;\n const s = Math.hypot(bx, dy);\n b.b = Math.cos(v) * s * sx;\n b.d = Math.sin(v) * s * sy;\n return;\n }\n }\n}\n"
104
+ },
105
+ {
106
+ "path": "character/core/curves.ts",
107
+ "content": "// Keyframed values. Each key may carry a curve to the next key: none\n// (linear), \"stepped\" (hold), or a cubic bezier per channel [cx1, cy1, cx2, cy2] in the timeline's own\n// units (seconds × value). The bezier is not solved: it is sampled at 9 points, t = 0.1 … 0.9, and\n// interpolated linearly between them.\n\nconst LINEAR = 0;\nconst STEPPED = 1;\nconst BEZIER = 2;\nconst SAMPLES = 9;\n\nexport class Keys {\n /** Key times, ascending. */\n readonly times: number[] = [];\n /** values[key * channels + channel]. */\n readonly values: number[] = [];\n private readonly kinds: number[] = [];\n /** Bezier samples per (key, channel): x, y × 9, or empty. */\n private readonly samples: Array<Float32Array | null> = [];\n\n constructor(readonly channels: number) {}\n\n get count(): number {\n return this.times.length;\n }\n\n /** Adds a key; its curve is set by the next key (the curve is stored on the earlier key). */\n add(time: number, values: readonly number[]): void {\n this.times.push(time);\n for (let c = 0; c < this.channels; c++) this.values.push(values[c] ?? 0);\n this.kinds.push(LINEAR);\n for (let c = 0; c < this.channels; c++) this.samples.push(null);\n }\n\n /** The curve from key `k` to key `k + 1`: \"stepped\", 4 numbers per channel, or undefined (linear). */\n setCurve(k: number, curve: unknown): void {\n if (curve === \"stepped\") {\n this.kinds[k] = STEPPED;\n return;\n }\n if (!Array.isArray(curve) || k + 1 >= this.count) return;\n this.kinds[k] = BEZIER;\n const t1 = this.times[k]!;\n const t2 = this.times[k + 1]!;\n for (let c = 0; c < this.channels; c++) {\n const i = c * 4;\n if (curve.length < i + 4) continue;\n const v1 = this.values[k * this.channels + c]!;\n const v2 = this.values[(k + 1) * this.channels + c]!;\n this.samples[k * this.channels + c] = sampleBezier(\n t1,\n v1,\n Number(curve[i]),\n Number(curve[i + 1]),\n Number(curve[i + 2]),\n Number(curve[i + 3]),\n t2,\n v2,\n );\n }\n }\n\n /** The key at or before `time` (-1 — before the first key). */\n keyAt(time: number): number {\n const t = this.times;\n if (!t.length || time < t[0]!) return -1;\n let lo = 0;\n let hi = t.length - 1;\n while (lo < hi) {\n const mid = (lo + hi + 1) >> 1;\n if (t[mid]! <= time) lo = mid;\n else hi = mid - 1;\n }\n return lo;\n }\n\n /** A channel's value at `time` (callers handle time before the first key). */\n value(time: number, channel = 0): number {\n const k = Math.max(0, this.keyAt(time));\n const ch = this.channels;\n const v1 = this.values[k * ch + channel]!;\n if (k + 1 >= this.count) return v1;\n const kind = this.kinds[k]!;\n if (kind === STEPPED) return v1;\n const t1 = this.times[k]!;\n const t2 = this.times[k + 1]!;\n const v2 = this.values[(k + 1) * ch + channel]!;\n const s = this.samples[k * ch + channel];\n if (kind !== BEZIER || !s)\n return v1 + ((time - t1) / (t2 - t1)) * (v2 - v1);\n // walk the samples, interpolate linearly within the one holding `time`\n if (s[0]! > time) return v1 + ((time - t1) / (s[0]! - t1)) * (s[1]! - v1);\n for (let i = 2; i < SAMPLES * 2; i += 2)\n if (s[i]! >= time) {\n const x = s[i - 2]!;\n const y = s[i - 1]!;\n return y + ((time - x) / (s[i]! - x)) * (s[i + 1]! - y);\n }\n const x = s[SAMPLES * 2 - 2]!;\n const y = s[SAMPLES * 2 - 1]!;\n return y + ((time - x) / (t2 - x)) * (v2 - y);\n }\n}\n\n/** The cubic bezier from (t1, v1) to (t2, v2) at t = 0.1 … 0.9. */\nfunction sampleBezier(\n t1: number,\n v1: number,\n cx1: number,\n cy1: number,\n cx2: number,\n cy2: number,\n t2: number,\n v2: number,\n): Float32Array {\n const out = new Float32Array(SAMPLES * 2);\n for (let n = 1; n <= SAMPLES; n++) {\n const u = n / 10;\n const m = 1 - u;\n const b0 = m * m * m;\n const b1 = 3 * m * m * u;\n const b2 = 3 * m * u * u;\n const b3 = u * u * u;\n out[(n - 1) * 2] = b0 * t1 + b1 * cx1 + b2 * cx2 + b3 * t2;\n out[(n - 1) * 2 + 1] = b0 * v1 + b1 * cy1 + b2 * cy2 + b3 * v2;\n }\n return out;\n}\n"
108
+ },
109
+ {
110
+ "path": "character/core/data.ts",
111
+ "content": "import { Animation, readAnimation } from \"./animation\";\nimport { colorOf, type Color } from \"./types\";\n\nexport type { Color };\n\n// The data of one kind of character, read from JSON a rig builds (rigs/hero/index.ts): bones (normal\n// inherit, no shear), slots, IK and transform constraints, region / mesh / linked-mesh attachments in\n// skins, bone / slot / IK timelines and events. Coordinates: px, y up, angles counter-clockwise.\n\nexport interface BoneData {\n index: number;\n name: string;\n parent: BoneData | null;\n children: BoneData[];\n length: number;\n x: number;\n y: number;\n rotation: number;\n scaleX: number;\n scaleY: number;\n}\n\nexport interface SlotData {\n index: number;\n name: string;\n bone: BoneData;\n /** The attachment key shown in the setup pose, null — none. */\n attachment: string | null;\n color: Color;\n}\n\nexport type TransformProp =\n \"rotate\" | \"x\" | \"y\" | \"scaleX\" | \"scaleY\" | \"shearY\";\n\nexport interface ToProperty {\n prop: TransformProp;\n offset: number;\n max: number;\n scale: number;\n}\n\nexport interface FromProperty {\n prop: TransformProp;\n offset: number;\n to: ToProperty[];\n}\n\nexport interface IkData {\n type: \"ik\";\n index: number;\n name: string;\n bones: BoneData[];\n target: BoneData;\n mix: number;\n bendDirection: 1 | -1;\n}\n\nexport interface TransformData {\n type: \"transform\";\n index: number;\n name: string;\n source: BoneData;\n bones: BoneData[];\n offsets: Record<TransformProp, number>;\n mix: Record<TransformProp, number>;\n properties: FromProperty[];\n}\n\nexport type ConstraintData = IkData | TransformData;\n\nexport interface RegionAttachment {\n type: \"region\";\n /** The key slots and timelines use (\"top\", \"weapon_shot\"). */\n key: string;\n /** The image it draws (\"top/top_12\") — the art is looked up by it. */\n path: string;\n x: number;\n y: number;\n rotation: number;\n scaleX: number;\n scaleY: number;\n width: number;\n height: number;\n color: Color;\n}\n\nexport interface MeshAttachment {\n type: \"mesh\";\n key: string;\n path: string;\n /** u, v per vertex, 0..1 over the image, v down. */\n uvs: Float32Array;\n triangles: Uint16Array;\n /** Plain: x, y per vertex in the slot bone's space. Weighted: per vertex n, then n × (bone, x, y, weight). */\n vertices: Float32Array;\n weighted: boolean;\n vertexCount: number;\n width: number;\n height: number;\n color: Color;\n}\n\nexport type Attachment = RegionAttachment | MeshAttachment;\n\nexport interface Skin {\n name: string;\n /** Per slot index: attachment key → attachment. */\n attachments: Map<number, Map<string, Attachment>>;\n}\n\nexport interface SkeletonData {\n width: number;\n height: number;\n x: number;\n y: number;\n bones: BoneData[];\n slots: SlotData[];\n constraints: ConstraintData[];\n skins: Map<string, Skin>;\n /** Skin names in the order they were given (the order ‹ › lists them). */\n skinOrder: string[];\n animations: Map<string, Animation>;\n events: string[];\n bone(name: string): BoneData;\n slot(name: string): SlotData;\n}\n\n// ── Reading ──────────────────────────────────────────────────────────────────────────────────────\n\n/* eslint-disable @typescript-eslint/no-explicit-any -- the JSON is read field by field here */\ntype Json = any;\n\nexport function readSkeletonData(json: Json): SkeletonData {\n const bones: BoneData[] = [];\n const boneByName = new Map<string, BoneData>();\n for (const b of json.bones as Json[]) {\n const parent = b.parent ? (boneByName.get(b.parent) ?? null) : null;\n const bone: BoneData = {\n index: bones.length,\n name: b.name,\n parent,\n children: [],\n length: b.length ?? 0,\n x: b.x ?? 0,\n y: b.y ?? 0,\n rotation: b.rotation ?? 0,\n scaleX: b.scaleX ?? 1,\n scaleY: b.scaleY ?? 1,\n };\n parent?.children.push(bone);\n bones.push(bone);\n boneByName.set(bone.name, bone);\n }\n const bone = (name: string): BoneData => {\n const b = boneByName.get(name);\n if (!b) throw new Error(`no bone ${name}`);\n return b;\n };\n\n const slots: SlotData[] = [];\n const slotByName = new Map<string, SlotData>();\n for (const s of json.slots as Json[]) {\n const slot: SlotData = {\n index: slots.length,\n name: s.name,\n bone: bone(s.bone),\n attachment: s.attachment ?? null,\n color: colorOf(s.color),\n };\n slots.push(slot);\n slotByName.set(slot.name, slot);\n }\n const slot = (name: string): SlotData => {\n const s = slotByName.get(name);\n if (!s) throw new Error(`no slot ${name}`);\n return s;\n };\n\n const constraints: ConstraintData[] = [];\n for (const c of (json.constraints ?? []) as Json[]) {\n if (c.type === \"ik\") {\n constraints.push({\n type: \"ik\",\n index: constraints.length,\n name: c.name,\n bones: (c.bones as string[]).map(bone),\n target: bone(c.target),\n mix: c.mix ?? 1,\n bendDirection: c.bendPositive === false ? -1 : 1,\n });\n } else if (c.type === \"transform\") {\n const properties: FromProperty[] = [];\n const present = new Set<TransformProp>();\n for (const [from, fp] of Object.entries<Json>(c.properties ?? {})) {\n const to: ToProperty[] = [];\n for (const [prop, tp] of Object.entries<Json>(fp.to ?? {})) {\n present.add(prop as TransformProp);\n to.push({\n prop: prop as TransformProp,\n offset: tp.offset ?? 0,\n max: tp.max ?? 1,\n scale: tp.scale ?? 1,\n });\n }\n if (to.length)\n properties.push({\n prop: from as TransformProp,\n offset: fp.offset ?? 0,\n to,\n });\n }\n const mixX = c.mixX ?? 1;\n const mixScaleX = c.mixScaleX ?? 1;\n constraints.push({\n type: \"transform\",\n index: constraints.length,\n name: c.name,\n source: bone(c.source),\n bones: (c.bones as string[]).map(bone),\n offsets: {\n rotate: c.rotation ?? 0,\n x: c.x ?? 0,\n y: c.y ?? 0,\n scaleX: c.scaleX ?? 0,\n scaleY: c.scaleY ?? 0,\n shearY: c.shearY ?? 0,\n },\n // A mix exists only for the properties the constraint drives.\n mix: {\n rotate: present.has(\"rotate\") ? (c.mixRotate ?? 1) : 0,\n x: present.has(\"x\") ? mixX : 0,\n y: present.has(\"y\") ? (c.mixY ?? mixX) : 0,\n scaleX: present.has(\"scaleX\") ? mixScaleX : 0,\n scaleY: present.has(\"scaleY\") ? (c.mixScaleY ?? mixScaleX) : 0,\n shearY: present.has(\"shearY\") ? (c.mixShearY ?? 1) : 0,\n },\n properties,\n });\n }\n }\n\n // Skins: meshes first, then linked meshes (they borrow a mesh's geometry, maybe from another skin).\n const skins = new Map<string, Skin>();\n const skinOrder: string[] = [];\n const linked: Array<{ skin: Skin; slot: number; key: string; a: Json }> = [];\n for (const s of json.skins as Json[]) {\n const skin: Skin = { name: s.name, attachments: new Map() };\n for (const [slotName, atts] of Object.entries<Json>(s.attachments ?? {})) {\n const slotIndex = slot(slotName).index;\n const map = new Map<string, Attachment>();\n skin.attachments.set(slotIndex, map);\n for (const [key, a] of Object.entries<Json>(atts)) {\n const type = a.type ?? \"region\";\n if (type === \"linkedmesh\")\n linked.push({ skin, slot: slotIndex, key, a });\n else map.set(key, readAttachment(key, a, type));\n }\n }\n skins.set(skin.name, skin);\n skinOrder.push(skin.name);\n }\n for (const { skin, slot: slotIndex, key, a } of linked) {\n const sourceSkin = skins.get(a.skin ?? \"default\") ?? skin;\n const source = sourceSkin.attachments\n .get(slotIndex)\n ?.get(a.source ?? a.parent);\n if (!source || source.type !== \"mesh\")\n throw new Error(`linked mesh ${skin.name}/${key}: no source mesh`);\n skin.attachments.get(slotIndex)!.set(key, {\n ...source,\n key,\n path: a.path ?? a.name ?? key,\n width: a.width ?? source.width,\n height: a.height ?? source.height,\n color: colorOf(a.color),\n });\n }\n\n const data: SkeletonData = {\n width: json.skeleton?.width ?? 0,\n height: json.skeleton?.height ?? 0,\n x: json.skeleton?.x ?? 0,\n y: json.skeleton?.y ?? 0,\n bones,\n slots,\n constraints,\n skins,\n skinOrder,\n animations: new Map(),\n events: Object.keys(json.events ?? {}),\n bone,\n slot,\n };\n for (const [name, a] of Object.entries<Json>(json.animations ?? {}))\n data.animations.set(name, readAnimation(name, a, data));\n return data;\n}\n\nfunction readAttachment(key: string, a: Json, type: string): Attachment {\n const path: string = a.path ?? a.name ?? key;\n if (type === \"mesh\") {\n const uvs = Float32Array.from(a.uvs as number[]);\n const vertexCount = uvs.length / 2;\n const raw = a.vertices as number[];\n return {\n type: \"mesh\",\n key,\n path,\n uvs,\n triangles: Uint16Array.from(a.triangles as number[]),\n vertices: Float32Array.from(raw),\n weighted: raw.length > uvs.length,\n vertexCount,\n width: a.width ?? 0,\n height: a.height ?? 0,\n color: colorOf(a.color),\n };\n }\n return {\n type: \"region\",\n key,\n path,\n x: a.x ?? 0,\n y: a.y ?? 0,\n rotation: a.rotation ?? 0,\n scaleX: a.scaleX ?? 1,\n scaleY: a.scaleY ?? 1,\n width: a.width ?? 0,\n height: a.height ?? 0,\n color: colorOf(a.color),\n };\n}\n/* eslint-enable @typescript-eslint/no-explicit-any */\n\n// ── Images ───────────────────────────────────────────────────────────────────────────────────────\n\nconst imageCache = new WeakMap<\n SkeletonData,\n Map<string, { w: number; h: number }>\n>();\n\n/** Every image the skins use, with its size (the attachment's width × height) — what art is drawn on. */\nexport function imageSizes(\n data: SkeletonData,\n): ReadonlyMap<string, { w: number; h: number }> {\n let sizes = imageCache.get(data);\n if (sizes) return sizes;\n sizes = new Map();\n for (const skin of data.skins.values())\n for (const atts of skin.attachments.values())\n for (const a of atts.values())\n if (!sizes.has(a.path)) sizes.set(a.path, { w: a.width, h: a.height });\n imageCache.set(data, sizes);\n return sizes;\n}\n\n/** The images a skin draws. */\nexport function skinImages(data: SkeletonData, skinName: string): string[] {\n const skin = data.skins.get(skinName);\n if (!skin) return [];\n const out = new Set<string>();\n for (const atts of skin.attachments.values())\n for (const a of atts.values()) out.add(a.path);\n return [...out];\n}\n"
112
+ },
113
+ {
114
+ "path": "character/core/limbMesh.ts",
115
+ "content": "// Meshes for pictures that bend — an arm, a sleeve, a leg, a trouser leg, a boot, a skirt: a grid over\n// the picture's rectangle where it stands in the setup pose, each vertex bound to the bones it moves\n// with. Along a limb a vertex follows the bone it lies on and blends into the next one near the joint,\n// so an elbow or a knee bends smoothly; a skirt follows the body above the hips and parts over the two\n// legs below. Output is the export format's weighted mesh (core/data reads it).\n\n/** A bone standing in the setup pose: its index, where it starts, its direction, its length. */\nexport interface SetupBone {\n index: number;\n x: number;\n y: number;\n /** World rotation, degrees. */\n rotation: number;\n length: number;\n}\n\n/** The picture's place in the setup pose: its centre and turn in the world, the size it is shown at\n * (`width` × `height`) and the size of its canvas (`canvasWidth` × `canvasHeight` — what the art is\n * drawn on; a picture may be shown squeezed). */\nexport interface Placed {\n x: number;\n y: number;\n rotation: number;\n width: number;\n height: number;\n canvasWidth: number;\n canvasHeight: number;\n}\n\n/** Grid steps across and along the picture, px. */\nconst STEP_ACROSS = 8;\nconst STEP_ALONG = 5;\n/** How far from a joint both bones share a vertex, px. */\nconst BLEND = 5;\n/** A skirt follows the body down to this far below where the legs start, then the legs alone. */\nconst SKIRT_FALL = 12;\n/** Across the middle between the legs a skirt vertex is shared over this width, px. */\nconst SKIRT_PART = 6;\n\ntype Weights = Array<[bone: SetupBone, weight: number]>;\n\n/** How far above the ankle a leg's picture starts to go with the foot, px (half each side). */\nconst ANKLE = 4;\n\n/** The mesh of a picture bound along a chain of bones (top to bottom). With a `foot` (standing at the\n * chain's end, the ankle) what is drawn below the ankle — a foot, a shoe's sole — goes with the foot, as\n * a shoe worn there does, not with the shin. */\nexport function limbMesh(\n path: string,\n at: Placed,\n chain: readonly SetupBone[],\n foot?: SetupBone,\n) {\n return mesh(path, at, (x, y) => {\n const along = alongChain(x, y, chain);\n if (!foot) return along;\n const toFoot = smooth(clamp01((foot.y + ANKLE / 2 - y) / ANKLE));\n const w: Weights = along.map(([b, v]) => [b, v * (1 - toFoot)]);\n w.push([foot, toFoot]);\n return w.filter(([, v]) => v > 1e-4);\n });\n}\n\n/** The mesh of a picture that follows `body` above the legs and the near or far leg below. */\nexport function skirtMesh(\n path: string,\n at: Placed,\n body: SetupBone,\n near: SetupBone,\n far: SetupBone,\n) {\n const top = Math.max(near.y, far.y);\n const middle = (near.x + far.x) / 2;\n const side = near.x > far.x ? 1 : -1;\n return mesh(path, at, (x, y) => {\n const down = clamp01((top - y) / SKIRT_FALL);\n const toNear = smooth(clamp01(0.5 + (side * (x - middle)) / SKIRT_PART));\n const w: Weights = [\n [body, 1 - down],\n [near, down * toNear],\n [far, down * (1 - toNear)],\n ];\n return w.filter(([, v]) => v > 1e-4);\n });\n}\n\n/* eslint-disable @typescript-eslint/no-explicit-any -- the export format's mesh */\nfunction mesh(\n path: string,\n at: Placed,\n weigh: (x: number, y: number) => Weights,\n): any {\n const cols = Math.max(2, Math.ceil(at.width / STEP_ACROSS) + 1);\n const rows = Math.max(2, Math.ceil(at.height / STEP_ALONG) + 1);\n const r = (at.rotation * Math.PI) / 180;\n const cos = Math.cos(r);\n const sin = Math.sin(r);\n const uvs: number[] = [];\n const vertices: number[] = [];\n for (let j = 0; j < rows; j++)\n for (let i = 0; i < cols; i++) {\n const u = i / (cols - 1);\n const v = j / (rows - 1);\n uvs.push(round(u), round(v));\n // the picture's px from its centre (y up), turned and placed in the world\n const px = (u - 0.5) * at.width;\n const py = (0.5 - v) * at.height;\n const x = at.x + px * cos - py * sin;\n const y = at.y + px * sin + py * cos;\n const w = weigh(x, y);\n const total = w.reduce((s, [, v]) => s + v, 0);\n vertices.push(w.length);\n for (const [b, weight] of w) {\n const [lx, ly] = toBone(b, x, y);\n vertices.push(b.index, round(lx), round(ly), round(weight / total));\n }\n }\n const triangles: number[] = [];\n for (let j = 0; j + 1 < rows; j++)\n for (let i = 0; i + 1 < cols; i++) {\n const a = j * cols + i;\n triangles.push(a, a + cols, a + 1, a + 1, a + cols, a + cols + 1);\n }\n return {\n type: \"mesh\",\n path,\n uvs,\n triangles,\n vertices,\n width: at.canvasWidth,\n height: at.canvasHeight,\n };\n}\n/* eslint-enable @typescript-eslint/no-explicit-any */\n\n/** A point's bones along a chain: the nearest bone, shared with its neighbour within BLEND of a joint. */\nfunction alongChain(\n x: number,\n y: number,\n chain: readonly SetupBone[],\n): Weights {\n let best = 0;\n let bestD = Infinity;\n let bestT = 0;\n chain.forEach((b, k) => {\n const [t, d] = project(b, x, y);\n if (d < bestD) [best, bestD, bestT] = [k, d, t];\n });\n const b = chain[best]!;\n const len = Math.max(b.length, 1e-3);\n const fromStart = bestT * len;\n const toEnd = (1 - bestT) * len;\n if (best > 0 && fromStart < BLEND) {\n const own = 0.5 + 0.5 * smooth(fromStart / BLEND);\n return [\n [b, own],\n [chain[best - 1]!, 1 - own],\n ];\n }\n if (best + 1 < chain.length && toEnd < BLEND) {\n const own = 0.5 + 0.5 * smooth(toEnd / BLEND);\n return [\n [b, own],\n [chain[best + 1]!, 1 - own],\n ];\n }\n return [[b, 1]];\n}\n\n/** Where a point falls along a bone (0..1, clamped) and how far it is from it. */\nfunction project(b: SetupBone, x: number, y: number): [number, number] {\n const r = (b.rotation * Math.PI) / 180;\n const dx = Math.cos(r) * b.length;\n const dy = Math.sin(r) * b.length;\n const l2 = dx * dx + dy * dy || 1e-6;\n const t = clamp01(((x - b.x) * dx + (y - b.y) * dy) / l2);\n return [t, Math.hypot(x - (b.x + t * dx), y - (b.y + t * dy))];\n}\n\n/** A world point in a bone's own space (setup pose, scale 1). */\nfunction toBone(b: SetupBone, x: number, y: number): [number, number] {\n const r = (-b.rotation * Math.PI) / 180;\n const dx = x - b.x;\n const dy = y - b.y;\n return [\n dx * Math.cos(r) - dy * Math.sin(r),\n dx * Math.sin(r) + dy * Math.cos(r),\n ];\n}\n\nconst clamp01 = (v: number) => Math.min(1, Math.max(0, v));\nconst smooth = (t: number) => t * t * (3 - 2 * t);\nconst round = (v: number) => Math.round(v * 1000) / 1000;\n"
116
+ },
117
+ {
118
+ "path": "character/core/look.ts",
119
+ "content": "import type { ColorTargetId, Hex, PartTypeId } from \"./types\";\n\n// A look — what a character wears and its colours — as plain JSON: part ids, not indices, so a pack\n// that gains parts does not shift saved looks. It is what a preset, a class, an item, the player's\n// own choice or the server hands over; `v` is there for the day the format changes.\n\nexport const LOOK_VERSION = 1;\n\nexport interface CharacterLook {\n v: typeof LOOK_VERSION;\n /** Type → part id; null — taken off. A missing type is left as it is. */\n parts?: Partial<Record<PartTypeId, string | null>>;\n colors?: Partial<Record<ColorTargetId, Hex>>;\n}\n\n/** Later looks win, type by type and colour by colour (a sword over a class's default weapon). */\nexport function mergeLooks(\n ...looks: ReadonlyArray<CharacterLook | null | undefined>\n): CharacterLook {\n const out: CharacterLook = { v: LOOK_VERSION, parts: {}, colors: {} };\n for (const l of looks) {\n if (!l) continue;\n Object.assign(out.parts!, l.parts);\n Object.assign(out.colors!, l.colors);\n }\n return out;\n}\n\n/** A look from untrusted JSON (custom data, a pasted preset); null when it is not one. */\nexport function parseLook(value: unknown): CharacterLook | null {\n if (!value || typeof value !== \"object\") return null;\n const v = value as Record<string, unknown>;\n if (v[\"v\"] !== LOOK_VERSION) return null;\n const parts: Record<string, string | null> = {};\n const colors: Record<string, Hex> = {};\n if (v[\"parts\"] && typeof v[\"parts\"] === \"object\")\n for (const [k, p] of Object.entries(v[\"parts\"] as object))\n if (p === null || typeof p === \"string\") parts[k] = p;\n if (v[\"colors\"] && typeof v[\"colors\"] === \"object\")\n for (const [k, c] of Object.entries(v[\"colors\"] as object))\n if (typeof c === \"string\" && /^#[0-9a-f]{6}$/i.test(c)) colors[k] = c;\n return { v: LOOK_VERSION, parts, colors };\n}\n"
120
+ },
121
+ {
122
+ "path": "character/core/PartsManager.ts",
123
+ "content": "import { LOOK_VERSION, type CharacterLook } from \"./look\";\nimport type { Rng } from \"./random\";\nimport type { PartTypeDef, RigDef } from \"./rig\";\nimport type { RuleView } from \"./rules\";\nimport type { ColorTargetId, Hex, PartTypeId } from \"./types\";\n\n// What a character wears: per type one\n// skin or none, a hidden flag, colours per target, Random, Reset, looks. Parts are kept by skin name\n// (not by index), so adding art for more skins never shifts a choice. No drawing here: a renderer asks\n// for the worn skins (`wornSkins`), a slot's colour (`slotColor`) and the colour drawn into its art\n// (`artColor`). An expression (`setExpression`, a face put on by an animation) swaps face parts for a while.\n//\n// Which skins a type offers: the rig data's skins with the type's prefix that are drawable — every\n// image they use has art (`drawable`, given by whoever draws: our vector packs, or a reference atlas).\n\nexport type PartsChange =\n | { kind: \"part\"; type: PartTypeId; skin: string | null }\n | { kind: \"hidden\"; type: PartTypeId; hidden: boolean }\n | { kind: \"color\"; target: ColorTargetId; color: Hex | null }\n | { kind: \"expression\"; name: string | null };\n\ntype Listener = (change: PartsChange) => void;\n\nexport class PartsManager {\n private readonly chosen = new Map<PartTypeId, string | null>();\n private readonly hidden = new Map<PartTypeId, boolean>();\n private readonly colors = new Map<ColorTargetId, Hex | null>();\n private readonly lists = new Map<PartTypeId, string[]>();\n private readonly listeners = new Set<Listener>();\n private readonly slotTarget: Array<ColorTargetId | null>;\n private readonly slotArt: Array<ColorTargetId | null>;\n private face: string | null = null;\n\n constructor(\n readonly rig: RigDef,\n private drawable: (skin: string) => boolean = () => true,\n ) {\n const targetOf = (name: string, into: \"tint\" | \"art\") =>\n rig.colorTargets.find(\n (t) =>\n (t.into ?? \"tint\") === into &&\n t.slotPrefixes.some((p) => name.startsWith(p)),\n )?.id ?? null;\n this.slotTarget = rig.data.slots.map((s) => targetOf(s.name, \"tint\"));\n this.slotArt = rig.data.slots.map((s) => targetOf(s.name, \"art\"));\n this.rebuildLists();\n this.init();\n }\n\n /** The art changed (a pack added, another art source): recompute what each type offers. */\n setDrawable(drawable: (skin: string) => boolean): void {\n const hadNone = new Set(\n this.rig.partTypes.filter((t) => !this.count(t.id)).map((t) => t.id),\n );\n this.drawable = drawable;\n this.rebuildLists();\n for (const t of this.rig.partTypes) {\n if (t.auto) continue;\n const c = this.chosen.get(t.id);\n // A choice the new art cannot draw, or a type that had nothing to offer, starts at its first.\n if (\n (c && !this.lists.get(t.id)!.includes(c)) ||\n (!c && hadNone.has(t.id))\n )\n this.chosen.set(t.id, this.first(t));\n }\n this.emit({ kind: \"part\", type: \"*\", skin: null });\n }\n\n // ── Events ─────────────────────────────────────────────────────────────────────────────────\n\n on(listener: Listener): () => void {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n }\n\n private emit(change: PartsChange): void {\n for (const l of [...this.listeners]) l(change);\n }\n\n // ── Queries ────────────────────────────────────────────────────────────────────────────────\n\n typeDef(type: PartTypeId): PartTypeDef | undefined {\n return this.rig.partTypes.find((t) => t.id === type);\n }\n\n /** The skins a type offers, in the data's order. */\n list(type: PartTypeId): readonly string[] {\n return this.lists.get(type) ?? [];\n }\n\n count(type: PartTypeId): number {\n return this.list(type).length;\n }\n\n /** The chosen skin (null — none), whether hidden or not. */\n part(type: PartTypeId): string | null {\n return this.chosen.get(type) ?? null;\n }\n\n /** Its index in `list`, -1 — none. */\n index(type: PartTypeId): number {\n const p = this.part(type);\n return p ? this.list(type).indexOf(p) : -1;\n }\n\n isHidden(type: PartTypeId): boolean {\n return this.hidden.get(type) ?? false;\n }\n\n /** Chosen and not hidden — what a game asks (\"is a weapon in hand?\"). */\n isWorn(type: PartTypeId): boolean {\n return this.part(type) !== null && !this.isHidden(type);\n }\n\n color(target: ColorTargetId): Hex | null {\n return this.colors.get(target) ?? null;\n }\n\n // ── What a renderer reads ──────────────────────────────────────────────────────────────────\n\n /** The skins to combine, in the rig's type order (a later one wins on the same attachment). */\n wornSkins(): string[] {\n const worn = new Map<PartTypeId, string | null>();\n for (const t of this.rig.partTypes)\n worn.set(t.id, !t.auto && this.isWorn(t.id) ? this.part(t.id) : null);\n const view: RuleView = {\n worn: (t) => worn.get(t) ?? null,\n setWorn: (t, skin) => worn.set(t, skin),\n exists: (skin) => this.rig.data.skins.has(skin) && this.drawable(skin),\n };\n for (const r of this.rig.rules) r.apply(view);\n const face = this.face ? this.rig.expressions?.faces[this.face] : undefined;\n for (const [type, skin] of Object.entries(face ?? {}))\n if (worn.get(type) && view.exists(skin)) worn.set(type, skin);\n const out: string[] = [];\n for (const t of this.rig.partTypes) {\n const s = worn.get(t.id);\n if (s) out.push(s);\n }\n return out;\n }\n\n /** The colour a slot is painted with (replacing its animated colour), null — its own. */\n slotColor(slotIndex: number): Hex | null {\n const target = this.slotTarget[slotIndex];\n return target ? this.color(target) : null;\n }\n\n /** The colour drawn into a slot's art (its `currentColor`), null — the art's own. */\n artColor(slotIndex: number): Hex | null {\n const target = this.slotArt[slotIndex];\n return target ? this.color(target) : null;\n }\n\n /** The expression on now (null — the chosen face). */\n expression(): string | null {\n return this.face;\n }\n\n // ── Changes ────────────────────────────────────────────────────────────────────────────────\n\n /** Puts on a skin by index in `list` or by name; -1 / null — none. Unknown names are ignored. */\n equip(type: PartTypeId, part: number | string | null): void {\n const list = this.lists.get(type);\n if (!list) return;\n let skin: string | null;\n if (part === null || part === -1) skin = null;\n else if (typeof part === \"number\")\n skin = list[Math.max(0, Math.min(list.length - 1, part))] ?? null;\n else if (list.includes(part)) skin = part;\n else return;\n if (skin === null && !this.typeDef(type)?.canHide) return;\n this.chosen.set(type, skin);\n this.emit({ kind: \"part\", type, skin });\n }\n\n unequip(type: PartTypeId): void {\n this.equip(type, null);\n }\n\n /** Hides or shows a part, keeping which one. */\n setHidden(type: PartTypeId, hidden: boolean): void {\n if (!this.typeDef(type)?.canHide) return;\n this.hidden.set(type, hidden);\n this.emit({ kind: \"hidden\", type, hidden });\n }\n\n /** The next part: a type that can be taken off goes … → last → none → first → … */\n next(type: PartTypeId): void {\n const n = this.count(type);\n if (!n) return;\n const i = this.index(type);\n if (this.typeDef(type)?.canHide) this.equip(type, i >= n - 1 ? -1 : i + 1);\n else this.equip(type, (i + 1) % n);\n }\n\n prev(type: PartTypeId): void {\n const n = this.count(type);\n if (!n) return;\n const i = this.index(type);\n if (this.typeDef(type)?.canHide) this.equip(type, i <= -1 ? n - 1 : i - 1);\n else this.equip(type, (i - 1 + n) % n);\n }\n\n /** Paints a target (null — back to the art's own colours). */\n setColor(target: ColorTargetId, color: Hex | null): void {\n this.colors.set(target, color);\n this.emit({ kind: \"color\", target, color });\n }\n\n /** Puts on an expression of the rig (null — back to the chosen face). Not part of a look. */\n setExpression(name: string | null): void {\n const next = name && this.rig.expressions?.faces[name] ? name : null;\n if (next === this.face) return;\n this.face = next;\n this.emit({ kind: \"expression\", name: next });\n }\n\n /** Every type a random part, one that can be taken off — none 30% of the time. */\n randomize(rng: Rng = Math.random): void {\n for (const t of this.rig.partTypes) {\n if (t.auto) continue;\n const n = this.count(t.id);\n if (!n) continue;\n this.equip(t.id, t.canHide && rng() < 0.3 ? -1 : Math.floor(rng() * n));\n }\n }\n\n /** The first skin of every type, nothing hidden, the art's own colours. */\n reset(): void {\n this.init();\n for (const t of this.rig.partTypes)\n this.emit({ kind: \"part\", type: t.id, skin: this.part(t.id) });\n }\n\n // ── Looks ──────────────────────────────────────────────────────────────────────────────────\n\n toLook(): CharacterLook {\n const parts: Record<PartTypeId, string | null> = {};\n for (const t of this.rig.partTypes)\n if (!t.auto) parts[t.id] = this.isWorn(t.id) ? this.part(t.id) : null;\n const colors: Record<ColorTargetId, Hex> = {};\n for (const [k, c] of this.colors) if (c) colors[k] = c;\n return { v: LOOK_VERSION, parts, colors };\n }\n\n /** Puts a look on. A skin the art cannot draw is skipped (what is worn stays). */\n applyLook(look: CharacterLook): void {\n for (const [type, skin] of Object.entries(look.parts ?? {})) {\n if (skin === undefined || this.typeDef(type)?.auto) continue;\n this.equip(type, skin);\n if (skin !== null && this.part(type) === skin)\n this.hidden.set(type, false);\n }\n for (const [target, color] of Object.entries(look.colors ?? {}))\n if (color) this.setColor(target, color);\n }\n\n // ── Inside ─────────────────────────────────────────────────────────────────────────────────\n\n private rebuildLists(): void {\n for (const t of this.rig.partTypes)\n this.lists.set(\n t.id,\n this.rig.data.skinOrder.filter(\n (s) => s.startsWith(t.skinPrefix) && this.drawable(s),\n ),\n );\n }\n\n private first(t: PartTypeDef): string | null {\n return this.lists.get(t.id)?.[0] ?? null;\n }\n\n private init(): void {\n this.chosen.clear();\n this.hidden.clear();\n this.colors.clear();\n for (const t of this.rig.partTypes)\n this.chosen.set(t.id, t.auto ? null : this.first(t));\n }\n}\n"
124
+ },
125
+ {
126
+ "path": "character/core/random.ts",
127
+ "content": "// A small seeded PRNG (mulberry32): a hero's random look is the same on every device and every run.\n\nexport type Rng = () => number;\n\nexport function seededRandom(seed: number): Rng {\n let a = seed >>> 0;\n return () => {\n a = (a + 0x6d2b79f5) >>> 0;\n let t = a;\n t = Math.imul(t ^ (t >>> 15), t | 1);\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n}\n\n/** A seed from a string (an id). */\nexport function seedOf(text: string): number {\n let h = 2166136261;\n for (let i = 0; i < text.length; i++)\n h = Math.imul(h ^ text.charCodeAt(i), 16777619);\n return h >>> 0;\n}\n\nexport function pick<T>(rng: Rng, list: readonly T[]): T | undefined {\n return list.length ? list[Math.floor(rng() * list.length)] : undefined;\n}\n"
128
+ },
129
+ {
130
+ "path": "character/core/rig.ts",
131
+ "content": "import type { BlinkOptions } from \"./animator\";\nimport type { SkeletonData } from \"./data\";\nimport type { PartsRule } from \"./rules\";\nimport type { ShadingDef } from \"./shading\";\nimport type { BoneId, ColorTargetId, PartTypeId } from \"./types\";\n\n// A rig = one kind of character: its skeleton data, what parts it wears (skins grouped by name\n// prefix), the colours it takes, how parts affect each other and which bones a game reads. rigs/hero\n// is the chibi hero; another body is another folder.\n\nexport interface PartTypeDef {\n id: PartTypeId;\n label: string;\n /** Its skins are the data's skins whose name starts with this (\"hair/\"). */\n skinPrefix: string;\n /** Can be taken off — ‹ › then pass through \"none\". */\n canHide: boolean;\n /** The colour that paints it (multiplied over its slots), when it takes one. */\n colorTarget?: ColorTargetId;\n /** Owned by the rules (hat hair): never chosen directly, not part of a look. */\n auto?: boolean;\n}\n\nexport interface ColorTargetDef {\n id: ColorTargetId;\n label: string;\n /** Slots whose name starts with one of these take the colour. */\n slotPrefixes: readonly string[];\n /** How it paints: \"tint\" (default) multiplies over the whole slot; \"art\" is drawn into the image\n * where the art says `currentColor` — an iris takes the eye colour, the whites stay white. */\n into?: \"tint\" | \"art\";\n}\n\n/** Faces a character makes by itself: an animation puts one on, its\n * skins replace the chosen ones of those types while it plays. */\nexport interface ExpressionsDef {\n /** Expression → skin per part type (\"Angry\": brows down, teeth bared). */\n faces: Readonly<Record<string, Readonly<Record<PartTypeId, string>>>>;\n /** Animation → expression; an animation not listed shows the chosen face. */\n byAnimation: Readonly<Record<string, string>>;\n}\n\n/** A group of part types in a character editor (the demo's FACE and BODY panels). */\nexport interface EditorGroup {\n id: string;\n label: string;\n /** Its tiles, in order. */\n types: readonly PartTypeId[];\n /** How its tiles picture a part. */\n thumbnail?: {\n /** Only these slots are drawn (the face, not the whole body the skin part also covers). */\n slots?: readonly string[];\n /** Drawn behind as a dark silhouette, and the picture framed on it (a head under the eyes). */\n backdrop?: { skin: string; slots: readonly string[] };\n };\n}\n\nexport interface RigDef {\n id: string;\n data: SkeletonData;\n partTypes: readonly PartTypeDef[];\n colorTargets: readonly ColorTargetDef[];\n rules: readonly PartsRule[];\n /** The animation played when nothing else is asked. */\n defaultAnimation: string;\n /** Cross-fade between animations, seconds. */\n mix: number;\n /** Bones a game reads by role — where a shot leaves, what a bar sits over. */\n anchors: Readonly<Record<string, BoneId>>;\n /** The blink played over animations that do not move the eyes (none — no overlay). */\n blink?: BlinkOptions;\n /** The animation actually played for one asked while these parts are worn — a stance of its own\n * for what is held (an idle with a rifle in both hands); omitted — always the one asked. */\n stance?: (\n animation: string,\n worn: (type: PartTypeId) => string | null,\n ) => string;\n /** Shading over the art (a dark side, a light rim); omitted — flat colours. */\n shading?: ShadingDef;\n /** Faces put on by animations; omitted — the chosen face always. */\n expressions?: ExpressionsDef;\n /** How an editor lays the parts out; omitted — one group of every type. */\n editor?: { groups: readonly EditorGroup[] };\n}\n\n/** Checks a rig's references once, at load: a typo fails loudly here. */\nexport function defineRig(rig: RigDef): RigDef {\n for (const bone of Object.values(rig.anchors)) rig.data.bone(bone);\n if (!rig.data.animations.has(rig.defaultAnimation))\n throw new Error(`rig ${rig.id}: no animation ${rig.defaultAnimation}`);\n if (rig.blink) {\n if (!rig.data.animations.has(rig.blink.animation))\n throw new Error(\n `rig ${rig.id}: blink — no animation ${rig.blink.animation}`,\n );\n for (const b of rig.blink.bones) rig.data.bone(b);\n for (const s of rig.blink.slots) rig.data.slot(s);\n }\n for (const g of rig.editor?.groups ?? []) {\n for (const id of g.types)\n if (!rig.partTypes.some((t) => t.id === id))\n throw new Error(`rig ${rig.id}: editor ${g.id} — no type ${id}`);\n for (const s of [\n ...(g.thumbnail?.slots ?? []),\n ...(g.thumbnail?.backdrop?.slots ?? []),\n ])\n rig.data.slot(s);\n const b = g.thumbnail?.backdrop?.skin;\n if (b && !rig.data.skins.has(b))\n throw new Error(`rig ${rig.id}: editor ${g.id} — no skin ${b}`);\n }\n for (const [name, face] of Object.entries(rig.expressions?.faces ?? {}))\n for (const [type, skin] of Object.entries(face)) {\n if (!rig.partTypes.some((t) => t.id === type))\n throw new Error(`rig ${rig.id}: expression ${name} — no type ${type}`);\n if (!rig.data.skins.has(skin))\n throw new Error(`rig ${rig.id}: expression ${name} — no skin ${skin}`);\n }\n for (const [anim, name] of Object.entries(\n rig.expressions?.byAnimation ?? {},\n )) {\n if (!rig.data.animations.has(anim))\n throw new Error(`rig ${rig.id}: expressions — no animation ${anim}`);\n if (!rig.expressions!.faces[name])\n throw new Error(`rig ${rig.id}: expressions — no face ${name}`);\n }\n for (const t of rig.partTypes)\n if (!rig.data.skinOrder.some((s) => s.startsWith(t.skinPrefix)))\n throw new Error(\n `rig ${rig.id}: type ${t.id} has no skins (${t.skinPrefix})`,\n );\n return rig;\n}\n"
132
+ },
133
+ {
134
+ "path": "character/core/rules.ts",
135
+ "content": "import type { PartTypeId } from \"./types\";\n\n// Rules: what one part does to another, applied whenever the worn set is resolved. A rig lists the\n// rules it needs (rigs/{rig}/index.ts); a new interplay of parts is a new function here, not a change\n// to PartsManager.\n\nexport interface RuleView {\n /** The worn skin of a type (null — none), after the player's choice and the hidden flag. */\n worn(type: PartTypeId): string | null;\n /** Replaces what a type contributes to the character (null — nothing). */\n setWorn(type: PartTypeId, skin: string | null): void;\n /** Does the data have this skin. */\n exists(skin: string): boolean;\n}\n\nexport interface PartsRule {\n id: string;\n apply(view: RuleView): void;\n}\n\n/**\n * Under a covering part a part is replaced by its variant of the same number: hair under a\n * hat becomes its tucked-in twin. The variant is found by name — \"hair/5\" → \"hair_under/5\".\n */\nexport function swapUnder(options: {\n type: PartTypeId;\n under: PartTypeId;\n variant: PartTypeId;\n /** Turns the type's skin name into the variant's. */\n rename: (skin: string) => string;\n}): PartsRule {\n const { type, under, variant, rename } = options;\n return {\n id: `swapUnder:${type}/${under}`,\n apply(v) {\n const hair = v.worn(type);\n const covered = v.worn(under) !== null;\n const alt = hair ? rename(hair) : null;\n v.setWorn(variant, covered && alt && v.exists(alt) ? alt : null);\n if (covered) v.setWorn(type, null);\n },\n };\n}\n\n/** A part that takes both hands puts the other hand's part away while it is worn (a bow, a musket:\n * no shield). The other part stays chosen — it is back as soon as a one-handed part is. */\nexport function bothHands(options: {\n type: PartTypeId;\n other: PartTypeId;\n skins: ReadonlySet<string>;\n}): PartsRule {\n const { type, other, skins } = options;\n return {\n id: `bothHands:${type}/${other}`,\n apply(v) {\n const held = v.worn(type);\n if (held && skins.has(held)) v.setWorn(other, null);\n },\n };\n}\n"
136
+ },
137
+ {
138
+ "path": "character/core/shading.ts",
139
+ "content": "import { worldGeometry } from \"./attachments\";\nimport type { SkeletonData } from \"./data\";\nimport { Skeleton } from \"./skeleton\";\nimport type { ArtId } from \"./types\";\n\n// Where the light comes from in each image (the shading: a dark crescent on the side away\n// from the light, a light rim on the side towards it). The light is one direction for the whole\n// character, in its world; every image lies on the skeleton turned its own way (a torso on its side,\n// an arm hanging, a sword pointing out), so the direction is brought into each image's own pixels\n// through how that image sits in the setup pose — the light then falls the same way on every part.\n\nexport interface ShadingDef {\n /** Towards the light, in the character's world (y up) — (-0.5, 1) is from the upper left. */\n light: readonly [number, number];\n /** Images whose path starts with one of these are not shaded (the face). */\n skip?: readonly string[];\n}\n\n/** A unit vector in an image's pixels (y down) pointing towards the light. */\nexport interface ImageLight {\n x: number;\n y: number;\n}\n\nconst cache = new WeakMap<SkeletonData, Map<ArtId, ImageLight>>();\n\n/** The light per image of the data (images under `skip` are left out). */\nexport function imageLights(\n data: SkeletonData,\n shading: ShadingDef,\n): ReadonlyMap<ArtId, ImageLight> {\n let out = cache.get(data);\n if (out) return out;\n out = new Map();\n const sk = new Skeleton(data);\n sk.setupPose();\n sk.updateWorldTransform();\n const [lx, ly] = shading.light;\n for (const skin of data.skins.values())\n for (const [slotIndex, atts] of skin.attachments)\n for (const a of atts.values()) {\n if (out.has(a.path) || shading.skip?.some((p) => a.path.startsWith(p)))\n continue;\n const g = worldGeometry(sk, sk.slots[slotIndex]!, a, null);\n const m = imageToWorld(g.positions, g.uvs, a.width, a.height);\n if (!m) continue;\n // image px → world is [a b; c d]; the light in image px is its inverse applied to the world light\n const det = m.a * m.d - m.b * m.c;\n if (Math.abs(det) < 1e-9) continue;\n const x = (m.d * lx - m.b * ly) / det;\n const y = (-m.c * lx + m.a * ly) / det;\n const n = Math.hypot(x, y) || 1;\n out.set(a.path, { x: x / n, y: y / n });\n }\n cache.set(data, out);\n return out;\n}\n\n/** The linear part of the affine map image px (u·w, v·h) → world, fitted to the vertices. */\nfunction imageToWorld(\n positions: Float32Array,\n uvs: Float32Array,\n w: number,\n h: number,\n): { a: number; b: number; c: number; d: number } | null {\n const n = uvs.length / 2;\n if (n < 3) return null;\n let qx = 0,\n qy = 0,\n px = 0,\n py = 0;\n for (let i = 0; i < n; i++) {\n qx += uvs[i * 2]! * w;\n qy += uvs[i * 2 + 1]! * h;\n px += positions[i * 2]!;\n py += positions[i * 2 + 1]!;\n }\n qx /= n;\n qy /= n;\n px /= n;\n py /= n;\n let sxx = 0,\n sxy = 0,\n syy = 0,\n pxqx = 0,\n pxqy = 0,\n pyqx = 0,\n pyqy = 0;\n for (let i = 0; i < n; i++) {\n const u = uvs[i * 2]! * w - qx;\n const v = uvs[i * 2 + 1]! * h - qy;\n const X = positions[i * 2]! - px;\n const Y = positions[i * 2 + 1]! - py;\n sxx += u * u;\n sxy += u * v;\n syy += v * v;\n pxqx += X * u;\n pxqy += X * v;\n pyqx += Y * u;\n pyqy += Y * v;\n }\n const det = sxx * syy - sxy * sxy;\n if (Math.abs(det) < 1e-9) return null;\n // [a b; c d] = Spq · Sqq⁻¹\n return {\n a: (pxqx * syy - pxqy * sxy) / det,\n b: (pxqy * sxx - pxqx * sxy) / det,\n c: (pyqx * syy - pyqy * sxy) / det,\n d: (pyqy * sxx - pyqx * sxy) / det,\n };\n}\n"
140
+ },
141
+ {
142
+ "path": "character/core/skeleton.ts",
143
+ "content": "import { createConstraint, type Constraint } from \"./constraints\";\nimport type {\n Attachment,\n BoneData,\n Color,\n SkeletonData,\n Skin,\n SlotData,\n} from \"./data\";\n\n// A live skeleton: every bone has its pose (what animation writes) and an\n// applied pose (what constraints may change this frame, reset from the pose each frame); the applied\n// pose carries the world matrix. An update cache orders bone updates and constraints, so a bone moved\n// by a constraint is updated before its children.\n\nconst DEG = Math.PI / 180;\nconst EPS2 = 0.00001;\n\nexport class BonePose {\n x = 0;\n y = 0;\n rotation = 0;\n scaleX = 1;\n scaleY = 1;\n shearX = 0;\n shearY = 0;\n // World transform (applied pose only).\n a = 1;\n b = 0;\n c = 0;\n d = 1;\n worldX = 0;\n worldY = 0;\n /** == the skeleton's update counter: the world is current / the local must be re-derived. */\n world = 0;\n local = 0;\n\n constructor(readonly bone: Bone) {}\n\n copyLocal(from: BonePose): void {\n this.x = from.x;\n this.y = from.y;\n this.rotation = from.rotation;\n this.scaleX = from.scaleX;\n this.scaleY = from.scaleY;\n this.shearX = from.shearX;\n this.shearY = from.shearY;\n }\n\n update(sk: Skeleton): void {\n if (this.world !== sk.updateId) this.updateWorldTransform(sk);\n }\n\n updateWorldTransform(sk: Skeleton): void {\n if (this.local === sk.updateId) this.updateLocalTransform(sk);\n else this.world = sk.updateId;\n const rx = (this.rotation + this.shearX) * DEG;\n const ry = (this.rotation + 90 + this.shearY) * DEG;\n const la = Math.cos(rx) * this.scaleX;\n const lb = Math.cos(ry) * this.scaleY;\n const lc = Math.sin(rx) * this.scaleX;\n const ld = Math.sin(ry) * this.scaleY;\n const parent = this.bone.parent;\n if (!parent) {\n const sx = sk.scaleX;\n const sy = sk.scaleY;\n this.a = la * sx;\n this.b = lb * sx;\n this.c = lc * sy;\n this.d = ld * sy;\n this.worldX = this.x * sx + sk.x;\n this.worldY = this.y * sy + sk.y;\n return;\n }\n const p = parent.applied;\n this.worldX = p.a * this.x + p.b * this.y + p.worldX;\n this.worldY = p.c * this.x + p.d * this.y + p.worldY;\n this.a = p.a * la + p.b * lc;\n this.b = p.a * lb + p.b * ld;\n this.c = p.c * la + p.d * lc;\n this.d = p.c * lb + p.d * ld;\n }\n\n /** World → local (after a constraint moved the world matrix). */\n updateLocalTransform(sk: Skeleton): void {\n this.local = 0;\n this.world = sk.updateId;\n const parent = this.bone.parent;\n if (!parent) {\n this.x = (this.worldX - sk.x) / sk.scaleX;\n this.y = (this.worldY - sk.y) / sk.scaleY;\n this.set5(\n this.a / sk.scaleX,\n this.b / sk.scaleX,\n this.c / sk.scaleY,\n this.d / sk.scaleY,\n );\n return;\n }\n const p = parent.applied;\n const pid = 1 / (p.a * p.d - p.b * p.c);\n const ia = p.d * pid;\n const ib = p.b * pid;\n const ic = p.c * pid;\n const id = p.a * pid;\n const dx = this.worldX - p.worldX;\n const dy = this.worldY - p.worldY;\n this.x = dx * ia - dy * ib;\n this.y = dy * id - dx * ic;\n this.set5(\n ia * this.a - ib * this.c,\n ia * this.b - ib * this.d,\n id * this.c - ic * this.a,\n id * this.d - ic * this.b,\n );\n }\n\n private set5(ra: number, rb: number, rc: number, rd: number): void {\n this.shearX = 0;\n const x = ra * ra + rc * rc;\n const y = rb * rb + rd * rd;\n if (x > EPS2) {\n const r = Math.atan2(rc, ra) / DEG;\n this.rotation = r;\n this.scaleX = Math.sqrt(x);\n this.scaleY = Math.sqrt(y);\n if (y > EPS2) {\n this.shearY = Math.atan2(rd, rb) / DEG;\n if (ra * rd - rb * rc < 0) {\n this.scaleY = -this.scaleY;\n this.shearY += 90 - r;\n } else this.shearY -= 90 + r;\n if (this.shearY > 180) this.shearY -= 360;\n else if (this.shearY <= -180) this.shearY += 360;\n } else this.shearY = 0;\n } else {\n this.scaleX = 0;\n this.scaleY = Math.sqrt(y);\n this.shearY = 0;\n this.rotation = y > EPS2 ? Math.atan2(rd, rb) / DEG - 90 : 0;\n }\n }\n\n /** The local pose is about to be edited (IK): the world becomes stale. */\n modifyLocal(sk: Skeleton): void {\n if (this.local === sk.updateId) this.updateLocalTransform(sk);\n this.world = 0;\n this.resetWorld(sk);\n }\n\n /** The world is about to be edited (a transform constraint): the local is re-derived on demand. */\n modifyWorld(sk: Skeleton): void {\n this.local = sk.updateId;\n this.world = sk.updateId;\n this.resetWorld(sk);\n }\n\n private resetWorld(sk: Skeleton): void {\n for (const child of this.bone.children) {\n const c = child.applied;\n if (c.world === sk.updateId) {\n if (c.local === sk.updateId) c.updateLocalTransform(sk);\n c.world = 0;\n c.resetWorld(sk);\n }\n }\n }\n}\n\nexport class Bone {\n readonly pose: BonePose;\n readonly applied: BonePose;\n parent: Bone | null = null;\n readonly children: Bone[] = [];\n sorted = false;\n\n constructor(readonly data: BoneData) {\n this.pose = new BonePose(this);\n this.applied = new BonePose(this);\n this.setupPose();\n }\n\n setupPose(): void {\n const d = this.data;\n const p = this.pose;\n p.x = d.x;\n p.y = d.y;\n p.rotation = d.rotation;\n p.scaleX = d.scaleX;\n p.scaleY = d.scaleY;\n p.shearX = 0;\n p.shearY = 0;\n }\n}\n\nexport class Slot {\n readonly color: Color;\n /** The key of the shown attachment (what timelines switch), null — none. */\n attachmentKey: string | null;\n attachment: Attachment | null = null;\n\n constructor(\n readonly data: SlotData,\n readonly bone: Bone,\n ) {\n this.color = { ...data.color };\n this.attachmentKey = data.attachment;\n }\n}\n\nexport type UpdateEntry = Bone | Constraint;\n\nexport class Skeleton {\n readonly bones: Bone[];\n readonly slots: Slot[];\n readonly constraints: Constraint[];\n /** The skins worn, combined: a later skin's attachment wins for the same slot and key. */\n private skins: Skin[] = [];\n scaleX = 1;\n scaleY = 1;\n x = 0;\n y = 0;\n updateId = 0;\n private cache: UpdateEntry[] = [];\n\n constructor(readonly data: SkeletonData) {\n this.bones = data.bones.map((b) => new Bone(b));\n for (const b of this.bones) {\n if (b.data.parent) {\n b.parent = this.bones[b.data.parent.index]!;\n b.parent.children.push(b);\n }\n }\n this.slots = data.slots.map((s) => new Slot(s, this.bones[s.bone.index]!));\n this.constraints = data.constraints.map((c) => createConstraint(c, this));\n this.updateCache();\n }\n\n bone(name: string): Bone {\n return this.bones[this.data.bone(name).index]!;\n }\n\n slot(name: string): Slot {\n return this.slots[this.data.slot(name).index]!;\n }\n\n // ── Skins and attachments ──────────────────────────────────────────────────────────────────\n\n setSkins(skins: readonly Skin[]): void {\n this.skins = [...skins];\n this.resolveAttachments();\n }\n\n getAttachment(slotIndex: number, key: string): Attachment | null {\n for (let i = this.skins.length - 1; i >= 0; i--) {\n const a = this.skins[i]!.attachments.get(slotIndex)?.get(key);\n if (a) return a;\n }\n return null;\n }\n\n /** Re-reads every slot's attachment from its key (after a skin or key change). */\n resolveAttachments(): void {\n for (const s of this.slots)\n s.attachment = s.attachmentKey\n ? this.getAttachment(s.data.index, s.attachmentKey)\n : null;\n }\n\n // ── Setup pose ─────────────────────────────────────────────────────────────────────────────\n\n /** Bones, constraints and slots back to the setup pose (what animation then writes over). */\n setupPose(): void {\n for (const b of this.bones) b.setupPose();\n for (const c of this.constraints) c.setupPose();\n for (const s of this.slots) {\n Object.assign(s.color, s.data.color);\n s.attachmentKey = s.data.attachment;\n }\n }\n\n // ── World transform ────────────────────────────────────────────────────────────────────────\n\n updateWorldTransform(): void {\n this.updateId++;\n for (const b of this.bones) {\n b.applied.copyLocal(b.pose);\n b.applied.world = 0;\n b.applied.local = 0;\n }\n for (const entry of this.cache) {\n if (entry instanceof Bone) entry.applied.update(this);\n else entry.update(this);\n }\n }\n\n /** The update order: parents before children, constraints where they read and write. */\n private updateCache(): void {\n this.cache = [];\n for (const b of this.bones) b.sorted = false;\n for (const c of this.constraints) c.sort(this);\n for (const b of this.bones) this.sortBone(b);\n }\n\n sortBone(bone: Bone): void {\n if (bone.sorted) return;\n if (bone.parent) this.sortBone(bone.parent);\n bone.sorted = true;\n this.cache.push(bone);\n }\n\n sortReset(bones: readonly Bone[]): void {\n for (const b of bones) {\n if (b.sorted) this.sortReset(b.children);\n b.sorted = false;\n }\n }\n\n pushConstraint(c: Constraint): void {\n this.cache.push(c);\n }\n}\n"
144
+ },
145
+ {
146
+ "path": "character/core/types.ts",
147
+ "content": "// The vocabulary of the character system. Every id is a plain string, not an enum: a rig, a parts\n// pack or a game adds its own types, slots and colour targets without touching this file.\n\n/** A kind of part — \"Hair\", \"Hat\", \"Weapon\". Declared by a rig (rigs/{rig}/parts.ts). */\nexport type PartTypeId = string;\n\n/** A bone or slot by its name in the skeleton data (\"head\", \"hand_far\"). */\nexport type BoneId = string;\n\n/** What a colour paints — \"Skin\", \"Hair\", \"Beard\", \"Brow\". Declared by a rig. */\nexport type ColorTargetId = string;\n\n/** An image of the art, by the path attachments name it (\"top/top_12\", \"arm_l\"). */\nexport type ArtId = string;\n\n/** A colour as \"#rrggbb\". */\nexport type Hex = string;\n\nexport const WHITE: Hex = \"#ffffff\";\n\n/** \"#rrggbb\" → 0xrrggbb (what Phaser's tint takes). Anything unreadable is white. */\nexport function hexToNumber(hex: Hex): number {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n return m ? parseInt(m[1]!, 16) : 0xffffff;\n}\n\nexport function numberToHex(color: number): Hex {\n return `#${(color & 0xffffff).toString(16).padStart(6, \"0\")}`;\n}\n\n/** An RGBA colour, 0..1 per channel (slot and attachment colours). */\nexport interface Color {\n r: number;\n g: number;\n b: number;\n a: number;\n}\n\n/** \"rrggbbaa\" (or \"rrggbb\") → Color. */\nexport function colorOf(hex: string | undefined): Color {\n const h = (hex ?? \"ffffffff\").replace(\"#\", \"\").padEnd(8, \"f\");\n const c = (i: number) => parseInt(h.slice(i, i + 2), 16) / 255;\n return { r: c(0), g: c(2), b: c(4), a: c(6) };\n}\n"
148
+ },
149
+ {
150
+ "path": "character/editor/CharacterEditor.tsx",
151
+ "content": "import { useEffect, useMemo, useReducer, useRef, useState } from \"react\";\nimport type { ArtCatalog } from \"../core/catalog\";\nimport { skinImages } from \"../core/data\";\nimport type { CharacterLook } from \"../core/look\";\nimport { PartsManager } from \"../core/PartsManager\";\nimport type { EditorGroup, RigDef } from \"../core/rig\";\nimport type { Hex, PartTypeId } from \"../core/types\";\nimport type { LookPreset } from \"../looks/presets\";\nimport {\n createThumbnailer,\n vectorPictures,\n type PictureArt,\n type ThumbnailRequest,\n type Thumbnailer,\n} from \"../render/canvas/thumbnail\";\nimport {\n createCharacterStage,\n type CharacterStage,\n} from \"../render/phaser/stage\";\nimport { ColorPicker } from \"./ColorPicker\";\nimport { EDITOR_CSS } from \"./styles\";\n\n// A character editor: presets on the left, the character\n// in the middle (Random / Reset under it), the part types on the right in the rig's groups (face,\n// body) — a tile per type showing what is worn, an eye to hide it — and, for the type opened, every\n// part it offers as pictures plus its colour. The animation runs below the groups. Works for any rig:\n// the layout comes from `rig.editor`, the pictures from the thumbnailer, the art from the catalog.\n\nexport interface CharacterEditorProps {\n rig: RigDef;\n catalog: ArtCatalog;\n presets?: readonly LookPreset[];\n /** The look to start with; omitted — the first preset. */\n look?: CharacterLook;\n /** Every change of what is worn or its colours. */\n onChange?: (look: CharacterLook) => void;\n /** The live stage once it exists (a page that swaps the art, plays from code). */\n onStage?: (stage: CharacterStage) => void;\n /** The character's scale on the scene (default 1.6). */\n scale?: number;\n /** Where the tiles' pictures come from (default our vector art) — swapped with the stage's art. */\n pictures?: PictureArt;\n}\n\nconst STAGE = { width: 460, height: 500 };\nconst SILHOUETTE: Hex = \"#26334a\";\n\nexport function CharacterEditor(props: CharacterEditorProps) {\n const { rig, catalog, presets = [] } = props;\n const host = useRef<HTMLDivElement>(null);\n const [stage, setStage] = useState<CharacterStage | null>(null);\n const [, redraw] = useReducer((n: number) => n + 1, 0);\n const [open, setOpen] = useState<PartTypeId | null>(null);\n const [presetId, setPresetId] = useState<string | null>(\n props.look ? null : (presets[0]?.id ?? null),\n );\n const vector = useMemo(() => vectorPictures(rig, catalog, 2), [rig, catalog]);\n const thumbs = useMemo(\n () => createThumbnailer(rig, props.pictures ?? vector),\n [rig, props.pictures, vector],\n );\n const onChange = useRef(props.onChange);\n onChange.current = props.onChange;\n const onStage = useRef(props.onStage);\n onStage.current = props.onStage;\n const startLook = useRef(props.look ?? presets[0]?.look);\n\n useEffect(() => {\n if (!host.current) return;\n let alive = true;\n let made: CharacterStage | null = null;\n void createCharacterStage(host.current, {\n rig,\n catalog,\n ...STAGE,\n scale: props.scale ?? 1.6,\n ...(startLook.current ? { look: startLook.current } : {}),\n }).then((s) => {\n if (!alive) return s.destroy();\n made = s;\n s.view.parts.on(() => {\n redraw();\n onChange.current?.(s.view.parts.toLook());\n });\n s.view.on(\"*\", () => redraw());\n setStage(s);\n onStage.current?.(s);\n });\n return () => {\n alive = false;\n made?.destroy();\n };\n }, [rig, catalog, props.scale]);\n\n const parts = stage?.view.parts;\n const groups: readonly EditorGroup[] = rig.editor?.groups ?? [\n {\n id: \"parts\",\n label: \"Parts\",\n types: rig.partTypes.filter((t) => !t.auto).map((t) => t.id),\n },\n ];\n // Thumbnails repaint when a colour changes: their key carries the colours.\n const paint = parts ? JSON.stringify(parts.toLook().colors) : \"\";\n\n const pictureOf = (type: PartTypeId, skin: string): ThumbnailRequest => {\n const g = groups.find((x) => x.types.includes(type))?.thumbnail;\n return {\n skins: [skin],\n ...(parts\n ? {\n slotColor: (i: number) => parts.slotColor(i),\n artColor: (i: number) => parts.artColor(i),\n }\n : {}),\n ...(g?.slots ? { slots: g.slots } : {}),\n ...(g?.backdrop\n ? {\n backdrop: {\n skins: [g.backdrop.skin],\n slots: g.backdrop.slots,\n color: SILHOUETTE,\n },\n }\n : {}),\n frame: g?.backdrop ? \"backdrop\" : \"content\",\n };\n };\n\n const applyPreset = (p: LookPreset) => {\n if (!parts) return;\n parts.reset();\n parts.applyLook(p.look);\n setPresetId(p.id);\n };\n\n // The stances (\"Walk@rifle\") are played for their animation by the rig itself, the blink over any\n // animation — not listed.\n const names = (stage?.view.animator.names() ?? []).filter(\n (n) => !n.includes(\"@\") && n !== rig.blink?.animation,\n );\n const current = stage?.view.animation() ?? \"\";\n const stepAnimation = (d: number) => {\n if (!stage || !names.length) return;\n const i = (names.indexOf(current) + d + names.length) % names.length;\n stage.view.play(names[i]!, { loop: true });\n redraw();\n };\n\n return (\n <div className=\"ce-root\">\n <style>{EDITOR_CSS}</style>\n\n {presets.length > 0 && (\n <div className=\"ce-presets\">\n <div className=\"ce-title\">Preset</div>\n <div className=\"ce-preset-grid\">\n {presets.map((p) => (\n <button\n key={p.id}\n className={`ce-preset${p.id === presetId ? \" ce-on\" : \"\"}`}\n onClick={() => applyPreset(p)}\n aria-label={p.label}\n >\n <PresetPicture\n rig={rig}\n catalog={catalog}\n thumbs={thumbs}\n preset={p}\n />\n </button>\n ))}\n </div>\n </div>\n )}\n\n <div className=\"ce-scene\">\n <div\n ref={host}\n className=\"ce-stage\"\n style={{\n width: STAGE.width,\n aspectRatio: `${STAGE.width} / ${STAGE.height}`,\n }}\n />\n <div className=\"ce-actions\">\n <button\n className=\"ce-big ce-random\"\n onClick={() => {\n parts?.randomize();\n setPresetId(null);\n }}\n >\n Random parts\n </button>\n <button\n className=\"ce-big ce-reset\"\n onClick={() => {\n const p = presets.find((x) => x.id === presetId) ?? presets[0];\n if (p) applyPreset(p);\n else parts?.reset();\n }}\n >\n Reset\n </button>\n </div>\n </div>\n\n {parts && open && (\n <div className=\"ce-options\">\n <div className=\"ce-options-head\">\n <div className=\"ce-title\">\n {parts.typeDef(open)?.label ?? open}{\" \"}\n {numberOf(parts.isWorn(open) ? parts.part(open) : null)}\n </div>\n <button\n className=\"ce-close\"\n onClick={() => setOpen(null)}\n aria-label=\"Close\"\n >\n ×\n </button>\n </div>\n {(() => {\n const target = parts.typeDef(open)?.colorTarget;\n if (!target) return null;\n return (\n <div>\n <ColorPicker\n value={parts.color(target) ?? \"#ffffff\"}\n onChange={(c) => parts.setColor(target, c)}\n />\n <div style={{ padding: \"0 18px\" }}>\n <button\n className=\"ce-paint-reset\"\n onClick={() => parts.setColor(target, null)}\n >\n Original colour\n </button>\n </div>\n </div>\n );\n })()}\n <div className=\"ce-option-grid\">\n {parts.typeDef(open)?.canHide && (\n <button\n className={`ce-option${!parts.isWorn(open) ? \" ce-on\" : \"\"}`}\n onClick={() => parts.equip(open, null)}\n aria-label=\"None\"\n >\n <span className=\"ce-none\" />\n </button>\n )}\n {parts.list(open).map((skin) => (\n <button\n key={skin}\n className={`ce-option${parts.isWorn(open) && parts.part(open) === skin ? \" ce-on\" : \"\"}`}\n onClick={() => {\n parts.equip(open, skin);\n parts.setHidden(open, false);\n setPresetId(null);\n }}\n aria-label={skin}\n >\n <Picture\n thumbs={thumbs}\n request={pictureOf(open, skin)}\n refresh={paint}\n />\n </button>\n ))}\n </div>\n </div>\n )}\n\n {parts && (\n <div className=\"ce-parts\">\n {groups.map((g) => (\n <div key={g.id}>\n <div className=\"ce-title\">{g.label}</div>\n <div className=\"ce-tile-grid\">\n {g.types.map((type) => {\n const def = parts.typeDef(type);\n const skin = parts.part(type);\n const worn = parts.isWorn(type);\n const hidden = parts.isHidden(type);\n return (\n <div\n key={type}\n role=\"button\"\n tabIndex={0}\n className={`ce-tile${worn ? \" ce-worn\" : \"\"}${open === type ? \" ce-open\" : \"\"}${hidden ? \" ce-hidden\" : \"\"}`}\n onClick={() => setOpen(open === type ? null : type)}\n onKeyDown={(e) =>\n e.key === \"Enter\" &&\n setOpen(open === type ? null : type)\n }\n aria-label={def?.label ?? type}\n >\n {skin && (\n <Picture\n thumbs={thumbs}\n request={pictureOf(type, skin)}\n refresh={paint}\n />\n )}\n {def?.canHide && skin && (\n <button\n className={`ce-eye${hidden ? \" ce-off\" : \"\"}`}\n onClick={(e) => {\n e.stopPropagation();\n parts.setHidden(type, !hidden);\n }}\n aria-label={hidden ? \"Show\" : \"Hide\"}\n >\n {hidden ? \"✕\" : \"●\"}\n </button>\n )}\n </div>\n );\n })}\n </div>\n </div>\n ))}\n <div className=\"ce-anim\">\n <div className=\"ce-title\">Animation</div>\n <div className=\"ce-anim-row\">\n <button\n className=\"ce-arrow\"\n onClick={() => stepAnimation(-1)}\n aria-label=\"Previous animation\"\n >\n ◀\n </button>\n <div className=\"ce-anim-name\">\n {current.replace(/_/g, \" \").replace(/(\\d+)$/, \" $1\")}\n </div>\n <button\n className=\"ce-arrow\"\n onClick={() => stepAnimation(1)}\n aria-label=\"Next animation\"\n >\n ▶\n </button>\n </div>\n </div>\n </div>\n )}\n </div>\n );\n}\n\n/** \"hair/30\" → 30 (a part's number, as the tiles show it). */\nfunction numberOf(skin: string | null): string {\n const m = skin ? /(\\d+)$/.exec(skin) : null;\n return m ? m[1]! : \"\";\n}\n\nfunction Picture(props: {\n thumbs: Thumbnailer;\n request: ThumbnailRequest;\n refresh: string;\n}) {\n const ref = useRef<HTMLCanvasElement>(null);\n const { thumbs, request } = props;\n const key = JSON.stringify([\n request.skins,\n request.slots,\n request.backdrop,\n request.frame,\n props.refresh,\n ]);\n useEffect(() => {\n const c = ref.current;\n if (!c) return;\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n const r = c.getBoundingClientRect();\n c.width = Math.max(32, Math.round(r.width * dpr));\n c.height = Math.max(32, Math.round(r.height * dpr));\n void thumbs.draw(c, request);\n // The request object is rebuilt every render; what it draws is in `key`.\n }, [thumbs, key]);\n return <canvas ref={ref} />;\n}\n\n/** A preset's portrait: its look put on a parts manager of its own, drawn whole. */\nfunction PresetPicture(props: {\n rig: RigDef;\n catalog: ArtCatalog;\n thumbs: Thumbnailer;\n preset: LookPreset;\n}) {\n const { rig, catalog, preset } = props;\n const request = useMemo<ThumbnailRequest>(() => {\n const pm = new PartsManager(rig, (skin) =>\n skinImages(rig.data, skin).every((p) => catalog.has(p)),\n );\n pm.applyLook(preset.look);\n return {\n skins: pm.wornSkins(),\n slotColor: (i) => pm.slotColor(i),\n artColor: (i) => pm.artColor(i),\n frame: \"content\",\n padding: 0.05,\n };\n }, [rig, catalog, preset]);\n return (\n <Picture thumbs={props.thumbs} request={request} refresh={preset.id} />\n );\n}\n"
152
+ },
153
+ {
154
+ "path": "character/editor/ColorPicker.tsx",
155
+ "content": "import {\n useEffect,\n useRef,\n useState,\n type PointerEvent as ReactPointerEvent,\n type RefObject,\n} from \"react\";\nimport type { Hex } from \"../core/types\";\n\n// The editor's colour picker, the demo's kind: a saturation × brightness square for the hue chosen on\n// the rainbow bar under it. Works in HSV, gives \"#rrggbb\".\n\nexport interface Hsv {\n h: number;\n s: number;\n v: number;\n}\n\nexport function hexToHsv(hex: Hex): Hsv {\n const n = parseInt(hex.replace(\"#\", \"\"), 16);\n const r = ((n >> 16) & 255) / 255;\n const g = ((n >> 8) & 255) / 255;\n const b = (n & 255) / 255;\n const max = Math.max(r, g, b);\n const d = max - Math.min(r, g, b);\n let h = 0;\n if (d) {\n if (max === r) h = ((g - b) / d) % 6;\n else if (max === g) h = (b - r) / d + 2;\n else h = (r - g) / d + 4;\n h = (h * 60 + 360) % 360;\n }\n return { h, s: max ? d / max : 0, v: max };\n}\n\nexport function hsvToHex({ h, s, v }: Hsv): Hex {\n const f = (n: number) => {\n const k = (n + h / 60) % 6;\n return Math.round((v - v * s * Math.max(0, Math.min(k, 4 - k, 1))) * 255);\n };\n return `#${((f(5) << 16) | (f(3) << 8) | f(1)).toString(16).padStart(6, \"0\")}`;\n}\n\nexport function ColorPicker(props: {\n value: Hex;\n onChange: (color: Hex) => void;\n}) {\n const hsv = hexToHsv(props.value);\n // Black and grey have no hue of their own: the bar's hue is kept, not read back from the colour.\n const [hue, setHue] = useState(hsv.h);\n useEffect(() => {\n if (hsv.s > 0.02 && hsv.v > 0.02) setHue(hsv.h);\n }, [hsv.h, hsv.s, hsv.v]);\n const square = useRef<HTMLDivElement>(null);\n const bar = useRef<HTMLDivElement>(null);\n\n const drag =\n (\n ref: RefObject<HTMLDivElement | null>,\n apply: (x: number, y: number) => void,\n ) =>\n (e: ReactPointerEvent) => {\n const el = ref.current;\n if (!el) return;\n const move = (ev: { clientX: number; clientY: number }) => {\n const r = el.getBoundingClientRect();\n apply(\n Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width)),\n Math.max(0, Math.min(1, (ev.clientY - r.top) / r.height)),\n );\n };\n move(e);\n const up = () => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n };\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n };\n\n return (\n <div className=\"ce-picker\">\n <div\n ref={square}\n className=\"ce-sv\"\n style={{\n background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${hue} 100% 50%))`,\n }}\n onPointerDown={drag(square, (x, y) =>\n props.onChange(hsvToHex({ h: hue, s: x, v: 1 - y })),\n )}\n >\n <span\n className=\"ce-knob\"\n style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%` }}\n />\n </div>\n <div\n ref={bar}\n className=\"ce-hue\"\n onPointerDown={drag(bar, (x) => {\n setHue(x * 359.9);\n props.onChange(\n hsvToHex({\n h: x * 359.9,\n s: Math.max(hsv.s, 0.05),\n v: Math.max(hsv.v, 0.05),\n }),\n );\n })}\n >\n <span\n className=\"ce-knob\"\n style={{ left: `${(hue / 360) * 100}%`, top: \"50%\" }}\n />\n </div>\n </div>\n );\n}\n"
156
+ },
157
+ {
158
+ "path": "character/editor/styles.ts",
159
+ "content": "// The editor's look — dark slate side panels, a brown parts panel whose worn parts\n// are blue tiles, chunky white capitals. One stylesheet, every class prefixed \"ce-\".\n\nexport const EDITOR_CSS = `\n.ce-root { display: flex; gap: 0; align-items: stretch; font-family: \"Trebuchet MS\", \"Segoe UI\", sans-serif;\n color: #fff; background: #1f2a22; border-radius: 14px; overflow: hidden; min-height: 600px; user-select: none; }\n.ce-title { font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; text-align: center;\n font-size: 14px; margin: 14px 0 10px; text-shadow: 0 2px 0 #0006; }\n.ce-presets { width: 200px; background: #3a3d45; padding: 0 10px 12px; flex: none; }\n.ce-preset-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }\n.ce-preset { background: transparent; border: 2px solid transparent; border-radius: 8px; padding: 0;\n cursor: pointer; aspect-ratio: 3 / 4; }\n.ce-preset:hover { background: #ffffff12; }\n.ce-preset.ce-on { border-color: #fff; background: #ffffff1a; }\n.ce-preset canvas { width: 100%; height: 100%; display: block; }\n.ce-scene { position: relative; flex: 1; min-width: 360px; display: flex; flex-direction: column;\n align-items: center; justify-content: flex-end;\n background: radial-gradient(ellipse 34% 9% at 50% 84%, #6d4a35 0 70%, transparent 72%),\n radial-gradient(ellipse 60% 30% at 50% 82%, #7a5a40 0 40%, transparent 70%),\n linear-gradient(#3f6e45, #4f7f4c 55%, #446f43); }\n.ce-stage { flex: none; max-width: 100%; }\n.ce-stage canvas { width: 100% !important; height: auto !important; display: block; }\n.ce-actions { display: flex; gap: 14px; margin: 6px 0 22px; }\n.ce-big { font: inherit; font-weight: 800; font-size: 17px; letter-spacing: 0.05em; text-transform: uppercase;\n color: #fff; border: 3px solid #1d1d2a; border-radius: 6px; padding: 12px 22px; cursor: pointer;\n box-shadow: inset 0 -5px 0 #0003; text-shadow: 0 2px 0 #0007; }\n.ce-big:active { transform: translateY(2px); box-shadow: inset 0 -2px 0 #0003; }\n.ce-random { background: #5b55d9; }\n.ce-reset { background: #f28a3a; }\n.ce-options { width: 290px; background: #2d2f37; flex: none; display: flex; flex-direction: column; }\n.ce-options-head { display: flex; align-items: center; justify-content: center; position: relative;\n min-height: 46px; }\n.ce-close { position: absolute; right: 10px; top: 9px; width: 28px; height: 28px; border-radius: 6px;\n border: 2px solid #1d1d2a; background: #e2433f; color: #fff; font-weight: 800; cursor: pointer; }\n.ce-option-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 10px; overflow-y: auto;\n flex: 1; align-content: start; }\n.ce-option { background: #3a3d48; border: 2px solid transparent; border-radius: 6px; aspect-ratio: 1; padding: 0;\n cursor: pointer; position: relative; }\n.ce-option:hover { background: #474b58; }\n.ce-option.ce-on { background: #56607c; border-color: #dfe6ff; }\n.ce-option canvas { width: 100%; height: 100%; display: block; }\n.ce-none { position: absolute; inset: 22%; border: 3px solid #8a8fa0; border-radius: 50%; }\n.ce-none::after { content: \"\"; position: absolute; left: 50%; top: -10%; bottom: -10%; width: 3px;\n background: #8a8fa0; transform: rotate(45deg); }\n.ce-picker { padding: 4px 18px 12px; }\n.ce-sv { position: relative; height: 130px; border-radius: 4px; border: 2px solid #1d1d2a; cursor: crosshair;\n touch-action: none; }\n.ce-hue { position: relative; height: 12px; margin-top: 12px; border-radius: 6px; cursor: pointer; touch-action: none;\n background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00); }\n.ce-knob { position: absolute; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%;\n border: 2px solid #fff; box-shadow: 0 0 0 1px #0008; pointer-events: none; }\n.ce-sv .ce-knob { margin: -6px 0 0 -6px; }\n.ce-paint-reset { font: inherit; font-size: 11px; margin-top: 8px; background: none; color: #c8cbe0;\n border: 1px solid #555a6c; border-radius: 4px; padding: 2px 8px; cursor: pointer; }\n.ce-parts { width: 300px; background: #6b4532; padding: 0 12px 12px; flex: none; display: flex; flex-direction: column; }\n.ce-tile-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }\n.ce-tile { position: relative; aspect-ratio: 1.1; background: #8a5a3e; border: 3px solid #4a2c20; border-radius: 4px;\n padding: 0; cursor: pointer; }\n.ce-tile.ce-worn { background: #2f73b5; }\n.ce-tile.ce-open { border-color: #fff; }\n.ce-tile canvas { width: 100%; height: 100%; display: block; }\n.ce-tile.ce-hidden canvas { opacity: 0.35; }\n.ce-eye { position: absolute; right: 3px; top: 3px; width: 20px; height: 20px; border-radius: 50%; border: none;\n background: #fff; color: #2b2b38; font-size: 11px; line-height: 20px; padding: 0; cursor: pointer; }\n.ce-eye.ce-off { background: #9a9aa8; }\n.ce-anim { margin-top: auto; padding-top: 14px; }\n.ce-anim-row { display: flex; align-items: stretch; gap: 0; }\n.ce-arrow { width: 38px; border: 3px solid #1d1d2a; background: #f0a03a; color: #1d1d2a; font-weight: 900;\n font-size: 16px; cursor: pointer; border-radius: 4px; }\n.ce-anim-name { flex: 1; background: #111; display: flex; align-items: center; justify-content: center;\n font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase; font-size: 13px; min-height: 36px; }\n@media (max-width: 1200px) {\n .ce-root { flex-wrap: wrap; }\n .ce-presets { width: 100%; }\n .ce-preset-grid { grid-template-columns: repeat(8, 1fr); }\n .ce-preset { aspect-ratio: 1; }\n .ce-scene { min-width: 300px; }\n .ce-options { width: 240px; }\n .ce-parts { width: 260px; }\n}\n@media (max-width: 760px) {\n .ce-scene, .ce-options, .ce-parts { width: 100%; min-width: 100%; }\n}\n`;\n"
160
+ },
161
+ {
162
+ "path": "character/index.ts",
163
+ "content": "// The character system: layered 2D characters on a skeleton — parts (skins) worn on its slots,\n// colours, and animations that move it, on a small runtime of its own. This folder stands on its own:\n// it imports nothing from the game around it, the game imports only this file. Layout:\n//\n// core/ no engine: skeleton data, skeleton + constraints, curves, animations, Animator, parts, looks\n// rigs/ kinds of character (hero — the chibi hero); a new body is a new folder\n// packs/ the vector art (base — the starter set); a new pack is a new folder\n// art/ SVG drawing helpers shared by the packs\n// render/ drawing on an engine (phaser) or on a plain page (canvas: still pictures, a live hero\n// for the UI's screens); another engine is another folder\n// looks/ where a character's look comes from (class, gear, presets, …) — a chain of sources\n// editor/ the character editor (React), laid out by the rig's `editor` groups\n//\n// docs/feature-history/idle-rpg-character-maker.md says where each kind of addition goes.\n\nimport { ArtCatalog } from \"./core/catalog\";\nimport { allPacks } from \"./packs\";\n\nexport * from \"./core/types\";\nexport {\n readSkeletonData,\n imageSizes,\n skinImages,\n type Attachment,\n type SkeletonData,\n type Skin,\n} from \"./core/data\";\nexport { Skeleton, Bone, Slot } from \"./core/skeleton\";\nexport { Animation } from \"./core/animation\";\nexport {\n Animator,\n type AnimationEvent,\n type BlinkOptions,\n type PlayOptions,\n} from \"./core/animator\";\nexport { worldGeometry } from \"./core/attachments\";\nexport { ArtCatalog, type ArtDef, type ArtPack } from \"./core/catalog\";\nexport {\n bothHands,\n swapUnder,\n type PartsRule,\n type RuleView,\n} from \"./core/rules\";\nexport {\n defineRig,\n type ColorTargetDef,\n type EditorGroup,\n type ExpressionsDef,\n type PartTypeDef,\n type RigDef,\n} from \"./core/rig\";\nexport { PartsManager, type PartsChange } from \"./core/PartsManager\";\nexport {\n LOOK_VERSION,\n mergeLooks,\n parseLook,\n type CharacterLook,\n} from \"./core/look\";\nexport { seededRandom, seedOf, type Rng } from \"./core/random\";\n\nexport {\n heroAttack,\n heroRig,\n heroShot,\n heroIsMelee,\n GAIT_GROUND_SPEED,\n HERO_ANIMATIONS,\n RANGED_ANIMATIONS,\n type ShotKind,\n} from \"./rigs/hero\";\nexport { allPacks } from \"./packs\";\nexport { basePack } from \"./packs/base\";\nexport { svgDocument } from \"./art/svg\";\n\nexport {\n CharacterView,\n type CharacterViewOptions,\n} from \"./render/phaser/CharacterView\";\nexport { vectorArt, type ArtSource } from \"./render/phaser/artSource\";\nexport {\n createCharacterStage,\n type CharacterStage,\n type CharacterStageOptions,\n} from \"./render/phaser/stage\";\n\nexport {\n createThumbnailer,\n vectorPictures,\n type PictureArt,\n type ThumbnailBackdrop,\n type ThumbnailRequest,\n type Thumbnailer,\n} from \"./render/canvas/thumbnail\";\nexport {\n CharacterCanvas,\n type CharacterCanvasOptions,\n} from \"./render/canvas/live\";\nexport {\n CharacterEditor,\n type CharacterEditorProps,\n} from \"./editor/CharacterEditor\";\n\nexport type { LookSource, LookSubject } from \"./looks/sources\";\nexport {\n DEFAULT_LOOK_CHAIN,\n classLook,\n equipmentLook,\n resolveLook,\n varietyLook,\n} from \"./looks/resolve\";\nexport { CLASS_LOOKS, VARIETY } from \"./looks/classLooks\";\nexport { ITEM_LOOKS } from \"./looks/itemLooks\";\nexport { HERO_PRESETS, type LookPreset } from \"./looks/presets\";\n\n/** Every pack the game ships, in one catalog. */\nexport function createCatalog(): ArtCatalog {\n return new ArtCatalog(allPacks);\n}\n"
164
+ },
165
+ {
166
+ "path": "character/looks/classLooks.ts",
167
+ "content": "import { LOOK_VERSION, type CharacterLook } from \"../core/look\";\n\n// What each hero class looks like by default — edit freely: a look is part ids (skin names of the\n// rig, \"top/5\") and colours; a type left out keeps what the base gives, null takes it off.\n// The maker page (playground/maker.html → \"Copy look\") makes these.\n\nexport const CLASS_LOOKS: Readonly<Record<string, CharacterLook>> = {\n Warrior: {\n v: LOOK_VERSION,\n parts: {\n Hat: \"hat/knight_helm\",\n Hair: \"hair/3\",\n Beard: \"beard/4\",\n Eyes: \"eyes/4\",\n Brows: \"brows/4\",\n Mouth: \"mouth/1\",\n Top: \"top/plate_armor\",\n Pants: \"pants/26\",\n Boots: \"boots/plate_boots\",\n Gloves: \"gloves/gauntlets\",\n Weapon: \"weapon/knight_sword\",\n Offhand: \"offhand/kite_shield\",\n Back: null,\n Glasses: null,\n },\n colors: {\n Skin: \"#f6d2b0\",\n Hair: \"#b5532f\",\n Beard: \"#b5532f\",\n Brows: \"#7a3418\",\n },\n },\n Ranger: {\n v: LOOK_VERSION,\n parts: {\n Hat: \"hat/ranger_hood\",\n Hair: \"hair/2\",\n Beard: null,\n Eyes: \"eyes/10\",\n Brows: \"brows/1\",\n Mouth: \"mouth/3\",\n Top: \"top/leather_armor\",\n Pants: \"pants/42\",\n Boots: \"boots/leather_boots\",\n Gloves: \"gloves/bracers\",\n Weapon: \"weapon/hunting_bow\",\n Offhand: null,\n Back: null,\n Glasses: null,\n },\n colors: { Skin: \"#e8b48a\", Hair: \"#e8c060\", Brows: \"#a8742a\" },\n },\n Mage: {\n v: LOOK_VERSION,\n parts: {\n Hat: \"hat/wizard_hat\",\n Hair: \"hair/10\",\n Beard: \"beard/10\",\n Eyes: \"eyes/1\",\n Brows: \"brows/2\",\n Mouth: \"mouth/1\",\n Top: \"top/mage_robe\",\n Pants: \"pants/3\",\n Boots: \"boots/leather_boots\",\n Gloves: \"gloves/bracers\",\n Weapon: \"weapon/wizard_staff\",\n Offhand: null,\n Back: \"back/mage_cape\",\n Glasses: null,\n },\n colors: {\n Skin: \"#f8dcc4\",\n Hair: \"#e8e8f0\",\n Beard: \"#e8e8f0\",\n Brows: \"#d0d0dc\",\n },\n },\n};\n\n/** Faces, hair and colours a hero with no class look is dressed in — picked by its id. */\nexport const VARIETY = {\n hair: [\"hair/2\", \"hair/3\", \"hair/5\", \"hair/10\", \"hair/13\"],\n eyes: [\"eyes/1\", \"eyes/2\", \"eyes/4\", \"eyes/10\"],\n tops: [\"top/1\", \"top/5\", \"top/19\", \"top/25\", \"top/40\"],\n skin: [\"#f8dcc4\", \"#f6d2b0\", \"#e8b48a\", \"#c98a5e\", \"#8d5a3a\"],\n hairColor: [\"#2a2026\", \"#6b3f2a\", \"#b5532f\", \"#e8c060\", \"#e8e8f0\", \"#3f6fb5\"],\n} as const;\n"
168
+ },
169
+ {
170
+ "path": "character/looks/itemLooks.ts",
171
+ "content": "import { LOOK_VERSION, type CharacterLook } from \"../core/look\";\n\n// What a worn item changes on the character — by item id (the demo world's items, title-seed.json);\n// an item not listed changes nothing (a ring). Edit freely; a title with its own items adds lines here.\n\nexport const ITEM_LOOKS: Readonly<Record<string, CharacterLook>> = {\n wooden_sword: {\n v: LOOK_VERSION,\n parts: { Weapon: \"weapon/14\" },\n },\n iron_sword: {\n v: LOOK_VERSION,\n parts: { Weapon: \"weapon/knight_sword\" },\n },\n dragon_blade: {\n v: LOOK_VERSION,\n parts: { Weapon: \"weapon/flame_sword\" },\n },\n crystal_staff: {\n v: LOOK_VERSION,\n parts: { Weapon: \"weapon/wizard_staff\" },\n },\n leather_vest: {\n v: LOOK_VERSION,\n parts: { Top: \"top/leather_armor\" },\n },\n chainmail: { v: LOOK_VERSION, parts: { Top: \"top/chainmail\" } },\n knight_plate: {\n v: LOOK_VERSION,\n parts: {\n Top: \"top/plate_armor\",\n Hat: \"hat/great_helm\",\n },\n },\n dragon_scale: {\n v: LOOK_VERSION,\n parts: { Top: \"top/25\", Back: \"back/17\" },\n },\n // hero-run's own: a siege weapon, a bow, the shields of the Offhand slot\n war_hammer: { v: LOOK_VERSION, parts: { Weapon: \"weapon/warhammer\" } },\n hunting_bow: { v: LOOK_VERSION, parts: { Weapon: \"weapon/hunting_bow\" } },\n round_shield: { v: LOOK_VERSION, parts: { Offhand: \"offhand/round_shield\" } },\n tower_shield: { v: LOOK_VERSION, parts: { Offhand: \"offhand/tower_shield\" } },\n};\n"
172
+ },
173
+ {
174
+ "path": "character/looks/presets.ts",
175
+ "content": "import type { CharacterLook } from \"../core/look\";\n\n// Ready looks for the hero — the editor's preset column and\n// a starting point for new looks. Built from the parts the base pack draws; a preset naming a part\n// without art keeps what is worn there. Add one = add an entry (\"Copy look\" in the maker gives it).\n\nexport interface LookPreset {\n id: string;\n label: string;\n look: CharacterLook;\n}\n\nconst look = (\n parts: CharacterLook[\"parts\"],\n colors: CharacterLook[\"colors\"],\n): CharacterLook => ({\n v: 1,\n parts: {\n Back: null,\n Beard: null,\n Boots: null,\n Pants: null,\n Gloves: null,\n Hat: null,\n Glasses: null,\n Offhand: null,\n Weapon: null,\n Skin: \"skin/1\",\n ...parts,\n },\n colors,\n});\n\n/** The fantasy set (packs/fantasy) on a few heroes. */\nconst fantasy = (\n id: string,\n label: string,\n parts: CharacterLook[\"parts\"],\n colors: CharacterLook[\"colors\"],\n): LookPreset => ({\n id,\n label,\n look: look(\n {\n Eyes: \"eyes/1\",\n Brows: \"brows/1\",\n Mouth: \"mouth/1\",\n Pants: \"pants/26\",\n ...parts,\n },\n colors,\n ),\n});\n\nexport const HERO_PRESETS: readonly LookPreset[] = [\n fantasy(\n \"knight\",\n \"Knight\",\n {\n Hat: \"hat/knight_helm\",\n Hair: \"hair/2\",\n Top: \"top/plate_armor\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n Back: \"back/knight_cape\",\n Weapon: \"weapon/knight_sword\",\n Offhand: \"offhand/kite_shield\",\n },\n { Skin: \"#f6d2b0\", Hair: \"#6b3f2a\", Brows: \"#4a2a1a\" },\n ),\n fantasy(\n \"dark_knight\",\n \"Dark knight\",\n {\n Hat: \"hat/great_helm\",\n Top: \"top/chainmail\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n Back: \"back/knight_cape\",\n Weapon: \"weapon/dark_blade\",\n Offhand: \"offhand/tower_shield\",\n },\n { Skin: \"#e8b48a\" },\n ),\n fantasy(\n \"archmage\",\n \"Archmage\",\n {\n Hat: \"hat/wizard_hat\",\n Hair: \"hair/10\",\n Beard: \"beard/10\",\n Top: \"top/mage_robe\",\n Gloves: \"gloves/bracers\",\n Boots: \"boots/leather_boots\",\n Back: \"back/mage_cape\",\n Weapon: \"weapon/wizard_staff\",\n },\n { Skin: \"#f8dcc4\", Hair: \"#e8e8f0\", Beard: \"#e8e8f0\", Brows: \"#d0d0dc\" },\n ),\n fantasy(\n \"berserker\",\n \"Berserker\",\n {\n Hat: \"hat/winged_helm\",\n Hair: \"hair/3\",\n Beard: \"beard/4\",\n Brows: \"brows/4\",\n Top: \"top/leather_armor\",\n Gloves: \"gloves/bracers\",\n Boots: \"boots/leather_boots\",\n Weapon: \"weapon/double_axe\",\n Offhand: \"offhand/round_shield\",\n },\n { Skin: \"#f0c09a\", Hair: \"#c0602a\", Beard: \"#c0602a\", Brows: \"#8a3a18\" },\n ),\n fantasy(\n \"hunter\",\n \"Hunter\",\n {\n Hat: \"hat/ranger_hood\",\n Hair: \"hair/5\",\n Eyes: \"eyes/10\",\n Top: \"top/leather_armor\",\n Gloves: \"gloves/bracers\",\n Boots: \"boots/leather_boots\",\n Weapon: \"weapon/elven_bow\",\n },\n { Skin: \"#e8b48a\", Hair: \"#e8c060\", Brows: \"#a8742a\" },\n ),\n fantasy(\n \"crossbowman\",\n \"Crossbowman\",\n {\n Hat: \"hat/iron_helm\",\n Hair: \"hair/3\",\n Top: \"top/chainmail\",\n Gloves: \"gloves/bracers\",\n Boots: \"boots/leather_boots\",\n Weapon: \"weapon/crossbow\",\n },\n { Skin: \"#f0c09a\", Hair: \"#6b3f2a\", Brows: \"#4a2a1a\" },\n ),\n fantasy(\n \"gunslinger\",\n \"Gunslinger\",\n {\n Hair: \"hair/13\",\n Top: \"top/leather_armor\",\n Gloves: \"gloves/bracers\",\n Boots: \"boots/leather_boots\",\n Back: \"back/knight_cape\",\n Weapon: \"weapon/pistol\",\n },\n { Skin: \"#c98a5e\", Hair: \"#2a2026\", Brows: \"#2a2026\" },\n ),\n fantasy(\n \"musketeer\",\n \"Musketeer\",\n {\n Hat: \"hat/wizard_hat\",\n Hair: \"hair/5\",\n Top: \"top/mage_robe\",\n Boots: \"boots/leather_boots\",\n Weapon: \"weapon/musket\",\n },\n { Skin: \"#f8dcc4\", Hair: \"#b5532f\", Brows: \"#7a3418\" },\n ),\n fantasy(\n \"soldier\",\n \"Soldier\",\n {\n Hat: \"hat/iron_helm\",\n Hair: \"hair/2\",\n Top: \"top/leather_armor\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n Weapon: \"weapon/rifle\",\n },\n { Skin: \"#e8b48a\", Hair: \"#3a2a22\", Brows: \"#3a2a22\" },\n ),\n {\n id: \"quarterback\",\n label: \"Quarterback\",\n look: look(\n {\n Hat: \"hat/1\",\n Hair: \"hair/2\",\n Eyes: \"eyes/1\",\n Brows: \"brows/1\",\n Mouth: \"mouth/1\",\n Top: \"top/1\",\n Pants: \"pants/26\",\n Boots: \"boots/1\",\n Back: \"back/14\",\n Weapon: \"weapon/3\",\n },\n { Skin: \"#f0c09a\", Hair: \"#3a2a22\", Brows: \"#3a2a22\" },\n ),\n },\n {\n id: \"lumberjack\",\n label: \"Lumberjack\",\n look: look(\n {\n Hair: \"hair/3\",\n Beard: \"beard/4\",\n Eyes: \"eyes/2\",\n Brows: \"brows/4\",\n Mouth: \"mouth/1\",\n Top: \"top/5\",\n Pants: \"pants/3\",\n Boots: \"boots/3\",\n Back: \"back/1\",\n Gloves: \"gloves/10\",\n Weapon: \"weapon/3\",\n },\n { Skin: \"#d2a582\", Hair: \"#6b3f2a\", Beard: \"#6b3f2a\", Brows: \"#4a2a1a\" },\n ),\n },\n {\n id: \"viking\",\n label: \"Viking\",\n look: look(\n {\n Hat: \"hat/27\",\n Hair: \"hair/5\",\n Beard: \"beard/10\",\n Eyes: \"eyes/4\",\n Brows: \"brows/2\",\n Mouth: \"mouth/3\",\n Top: \"top/19\",\n Pants: \"pants/42\",\n Boots: \"boots/10\",\n Weapon: \"weapon/27\",\n Offhand: \"offhand/1\",\n },\n { Skin: \"#fadfb3\", Hair: \"#e0a040\", Beard: \"#e0a040\", Brows: \"#e0a040\" },\n ),\n },\n {\n id: \"wizard\",\n label: \"Wizard\",\n look: look(\n {\n Hat: \"hat/15\",\n Hair: \"hair/10\",\n Eyes: \"eyes/10\",\n Brows: \"brows/1\",\n Mouth: \"mouth/1\",\n Glasses: \"glasses/4\",\n Top: \"top/40\",\n Pants: \"pants/1\",\n Boots: \"boots/1\",\n Weapon: \"weapon/28\",\n },\n { Skin: \"#c98d6a\", Hair: \"#f081e1\", Brows: \"#a04090\" },\n ),\n },\n {\n id: \"ranger\",\n label: \"Ranger\",\n look: look(\n {\n Hat: \"hat/12\",\n Hair: \"hair/5\",\n Eyes: \"eyes/1\",\n Brows: \"brows/4\",\n Mouth: \"mouth/3\",\n Top: \"top/25\",\n Pants: \"pants/26\",\n Boots: \"boots/3\",\n Back: \"back/17\",\n Gloves: \"gloves/1\",\n Weapon: \"weapon/33\",\n },\n { Skin: \"#9b5b3a\", Hair: \"#323232\", Brows: \"#323232\" },\n ),\n },\n {\n id: \"king\",\n label: \"King\",\n look: look(\n {\n Hat: \"hat/9\",\n Hair: \"hair/13\",\n Beard: \"beard/3\",\n Eyes: \"eyes/2\",\n Brows: \"brows/2\",\n Mouth: \"mouth/1\",\n Top: \"top/40\",\n Pants: \"pants/42\",\n Boots: \"boots/10\",\n Weapon: \"weapon/36\",\n Offhand: \"offhand/8\",\n },\n { Skin: \"#fadfb3\", Hair: \"#f2f2f2\", Beard: \"#f2f2f2\", Brows: \"#d0d0d0\" },\n ),\n },\n {\n id: \"camper\",\n label: \"Camper\",\n look: look(\n {\n Hat: \"hat/2\",\n Hair: \"hair/2\",\n Eyes: \"eyes/4\",\n Brows: \"brows/1\",\n Mouth: \"mouth/5\",\n Glasses: \"glasses/2\",\n Top: \"top/25\",\n Pants: \"pants/1\",\n Boots: \"boots/3\",\n Back: \"back/1\",\n Weapon: \"weapon/14\",\n },\n { Skin: \"#e8b48a\", Hair: \"#8a4a2a\", Brows: \"#5a2a1a\" },\n ),\n },\n {\n id: \"crusader\",\n label: \"Crusader\",\n look: look(\n {\n Hair: \"hair/10\",\n Eyes: \"eyes/10\",\n Brows: \"brows/2\",\n Mouth: \"mouth/5\",\n Top: \"top/19\",\n Pants: \"pants/3\",\n Boots: \"boots/1\",\n Gloves: \"gloves/1\",\n Weapon: \"weapon/5\",\n Offhand: \"offhand/4\",\n },\n { Skin: \"#fadfb3\", Hair: \"#c0402a\", Brows: \"#902a1a\" },\n ),\n },\n];\n"
176
+ },
177
+ {
178
+ "path": "character/looks/resolve.ts",
179
+ "content": "import { LOOK_VERSION, mergeLooks, type CharacterLook } from \"../core/look\";\nimport { pick, seededRandom, seedOf } from \"../core/random\";\nimport { CLASS_LOOKS, VARIETY } from \"./classLooks\";\nimport { ITEM_LOOKS } from \"./itemLooks\";\nimport type { LookSource, LookSubject } from \"./sources\";\n\n// The chain that dresses a hero: variety by its id ← its class ← what it wears. A later source wins\n// type by type. Pass your own chain to add a source (the player's saved choice, a server's look).\n\n/** A face, hair, top and colours picked by the hero's id — the same on every device. */\nexport const varietyLook: LookSource = (s) => {\n const rng = seededRandom(seedOf(s.id));\n const hairColor = pick(rng, VARIETY.hairColor)!;\n return {\n v: LOOK_VERSION,\n parts: {\n Hair: pick(rng, VARIETY.hair)!,\n Eyes: pick(rng, VARIETY.eyes)!,\n Top: pick(rng, VARIETY.tops)!,\n Hat: null,\n Beard: null,\n Glasses: null,\n Back: null,\n Offhand: null,\n Gloves: null,\n },\n colors: {\n Skin: pick(rng, VARIETY.skin)!,\n Hair: hairColor,\n Beard: hairColor,\n Brows: hairColor,\n },\n };\n};\n\nexport const classLook: LookSource = (s) => CLASS_LOOKS[s.classId] ?? null;\n\nexport const equipmentLook: LookSource = (s) =>\n mergeLooks(...Object.values(s.equipment).map((item) => ITEM_LOOKS[item]));\n\nexport const DEFAULT_LOOK_CHAIN: readonly LookSource[] = [\n varietyLook,\n classLook,\n equipmentLook,\n];\n\nexport function resolveLook(\n subject: LookSubject,\n chain: readonly LookSource[] = DEFAULT_LOOK_CHAIN,\n): CharacterLook {\n return mergeLooks(...chain.map((source) => source(subject)));\n}\n"
180
+ },
181
+ {
182
+ "path": "character/looks/sources.ts",
183
+ "content": "import type { CharacterLook } from \"../core/look\";\n\n// Where a character's look comes from: a chain of sources, each giving part of a look (or nothing);\n// later sources win type by type (mergeLooks). The game asks for a hero: its id, class and what it\n// wears. Another source later — the player's own choice saved in custom data, a look set on the\n// server for an item — is one more function in the chain (resolve.ts), nothing else changes.\n\nexport interface LookSubject {\n /** Stable id (seeds the variety of a hero nothing else describes). */\n id: string;\n /** The class, e.g. the engine's ClassID (\"Warrior\", \"Ranger\", \"Mage\"). */\n classId: string;\n /** What it wears: equipment slot → item id. */\n equipment: Readonly<Record<string, string>>;\n}\n\nexport type LookSource = (subject: LookSubject) => CharacterLook | null;\n"
184
+ },
185
+ {
186
+ "path": "character/packs/base/beards.ts",
187
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport { INK, line, path } from \"../../art/svg\";\n\n// Beards and moustaches (beard/beard_1…10) — white with light grey strands, the beard colour paints\n// them. A hole where the mouth shows is a real hole (the face is seen through it).\n\nconst H = \"#ffffff\";\nconst STRAND = \"#c4c4c4\";\n\nexport const beardsArt: Record<string, ArtDef> = {\n // A horseshoe moustache.\n \"beard/1\": {\n svg: path(\n \"M 5 24 C 3 16 6 6 21 3.5 C 34 3 40 12 40 22 C 40 25 37 26 34 25 C 32 22 33 14 21 10 C 12 11 10 18 10 23 C 9 25.5 6 25.5 5 24 Z\",\n H,\n ),\n },\n // A goatee round the mouth.\n \"beard/2\": {\n svg: path(\n \"M 5 18 C 4 8 12 2.5 21 2.5 C 31 2.5 38 8 38 18 L 38 27 C 36 29 32 29 30 28 C 29 33 25 36 21 36 C 17 36 14 33 13 29 C 10 30 6 30 4 28 Z \" +\n \"M 12 15 C 12 12 16 10.5 20 10.5 C 24 10.5 28 12 28 15 C 28 18 24 19.5 20 19.5 C 16 19.5 12 18 12 15 Z\",\n H,\n { holes: true },\n ),\n },\n \"beard/3\": {\n svg: path(\n \"M 5 4 L 84 3.5 C 87.5 3.5 88 6 86.5 8 C 85 10 81 10.5 80.5 12 C 81 14 82.5 16 81.5 18 C 79 20 70 19 64 19.5 \" +\n \"C 62 22 58 24 52 23 C 49 22 47 21.5 45 22.5 C 42 25.5 36 26.5 31 25 C 28 22 25 21 20 21 C 15 21 11.5 20 11 17 \" +\n \"C 11 15 12 14 11 13 C 6 12 2.5 10 3 7 C 3.2 5.5 4 4.5 5 4 Z M 30 10 C 30 6 46 6 46 10 C 46 14 30 14 30 10 Z\",\n H,\n { holes: true },\n ),\n },\n \"beard/4\": {\n svg:\n path(\n \"M 3.5 9 L 11 8.5 C 18 6 24 3.5 33 3.5 C 39 3.5 42 7 46 8.5 C 52 10 57 10 59.5 11.5 L 61 14.5 C 68 13 76 11 81.5 10 \" +\n \"C 82.5 15 82.5 20 80.5 24 C 78.5 28 76 31 72 32.5 C 66 34 60 32 58 33 C 57 37 54 40 48 41.5 C 42 43 37 42 34 38.5 \" +\n \"C 31 40 25 41 20 40 C 16 38 14 34 13.5 30 C 9 29 6 27 4.5 24 C 3 19 3 13 3.5 9 Z\",\n H,\n ) +\n line(\n \"M 5 10 C 9 13 12 17 16 17.5 C 22 18 28 16 33 13 C 36 16 38 19 42 20 C 48 21 54 20 59 17.5 L 60 13\",\n INK,\n 3,\n ),\n },\n // A full beard under a moustache.\n \"beard/5\": {\n svg:\n path(\n \"M 3 15 C 8 13 12 13 15 14 C 16 7 22 2.5 31 2.5 C 40 2.5 46 6 48 13 C 56 13 64 13 70 15 C 72 22 66 32 58 37 C 48 42 26 42 16 38 C 8 34 3 25 3 15 Z\",\n H,\n ) +\n line(\n \"M 15 14 L 15 22 C 20 19 25 16 30 10 C 35 17 40 21 47 23 L 48 14\",\n INK,\n 3,\n ) +\n line(\n \"M 12 28 L 10 31 M 28 34 L 28 38 M 46 34 L 46 38 M 59 29 L 61 31\",\n INK,\n 2.4,\n ),\n },\n // A pointed beard with a mouth hole.\n \"beard/6\": {\n svg: path(\n \"M 4 26 C 3 16 6 6 13 3 C 22 0.5 32 4 34 12 C 35 18 35 24 34 27 C 28 32 22 36 16 39 C 10 34 6 30 4 26 Z \" +\n \"M 10 15 C 10 8 14 6 19 6 C 24 6 28 9 28 16 C 28 22 24 26 21 25 L 18 20 L 16 25 C 12 25 10 20 10 15 Z\",\n H,\n { holes: true },\n ),\n },\n // A bushy moustache.\n \"beard/7\": {\n svg: path(\n \"M 3 7 C 10 6 18 5 24 4 C 26 2 28 2 29 4 C 36 4 44 5 51 6 C 52 9 48 12 44 13 C 40 13 36 15 32 15 C 30 18 26 18 23 15 C 20 16 16 16 13 14 C 8 13 4 11 3 7 Z\",\n H,\n ),\n },\n // A thin waxed moustache.\n \"beard/8\": {\n svg:\n path(\n \"M 3 3 C 12 6 22 10 30 10 C 32 9 33 7 34 6 C 33 12 30 16 25 16 C 18 15 8 9 3 3 Z\",\n H,\n { width: 3 },\n ) +\n path(\n \"M 42 7 C 44 5 46 5 47 7 C 55 8 66 6 77 3 C 72 9 62 14 50 15 C 44 15 42 11 42 7 Z\",\n H,\n { width: 3 },\n ) +\n line(\"M 9 7 C 16 10 22 12 28 12 M 52 12 C 60 11 66 9 72 6\", STRAND, 1.6),\n },\n // A soul patch.\n \"beard/9\": {\n svg: path(\n \"M 4 8 C 5 5 9 2.5 14 2.5 C 19 2.5 23 5 25.5 7 L 25 16 C 20 18 15 20 11 22 C 10 18 10 14 9 12 C 7 11 5 10 4 8 Z\",\n H,\n ),\n },\n \"beard/10\": {\n svg:\n path(\n \"M 4 8 C 4 3.5 10 2.5 13 6 C 16 8 20 9 23 9 C 26 4 30 2.5 34 2.5 C 40 2.5 43 5 46 8 C 50 9 56 10 60 11 \" +\n \"C 64 6 70 4.5 76 5 C 82 6 85 10 84 14 C 83 17 81 19 80 21 C 76 26 74 28 72 31 C 70 35 66 38 60 38 \" +\n \"C 56 40.5 50 41.5 44 40 C 40 41.5 34 41.5 30 40 C 24 40 18 39 14 36 C 8 35 4 32 4 28 C 3 24 3 18 3 14 C 3 11 4 9 4 8 Z\",\n H,\n ) +\n line(\n \"M 11 15 L 14 18 M 25 18 L 27 20 M 46 18 L 48 17 L 49 19 M 69 16 L 73 15 M 71 16 L 71 22 M 17 26 L 17 28 M 33 29 L 35 27 L 38 29 M 61 25 L 63 29\",\n STRAND,\n 2.4,\n ),\n },\n};\n"
188
+ },
189
+ {
190
+ "path": "character/packs/base/body.ts",
191
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport { clipped, flat, line, path } from \"../../art/svg\";\n\n// The body (skin/1): head, torso, arms and legs — drawn white with light grey shading, the skin colour\n// paints them. The arms and legs bend (core/limbMesh): they are drawn upright, filling their canvas.\n// The character faces left (the ear is on the right).\n\nconst SKIN = \"#ffffff\";\n\nexport const bodyArt: Record<string, ArtDef> = {\n // The head: the face side to the left, the ear on the right; flat white.\n \"skin/1_head\": {\n svg:\n path(\n \"M 4 50 C 3 22 20 3 52 3 C 75 3 89 16 91 44 C 96 45 99 51 99 58 C 99 65 94 70 88 70 C 85 70 83 69 82 68 \" +\n \"C 78 80 66 87 48 87 L 24 87 C 10 87 4 78 4 66 Z\",\n SKIN,\n ) +\n line(\n \"M 81 48 C 82 47 84 46 86 46 M 81 67 C 82 68 83 69 85 69 M 83 54 C 85 52 88 51 91 52\",\n \"#1f1a21\",\n 3,\n ),\n },\n // The torso is never seen bare (a top always covers it), so it has no outline: a plain shape and the\n // shadow the head casts at the neck.\n \"skin/1_torso\": {\n svg:\n flat(\n \"M 2 47 L 2 22 C 2 18 3 16 5 14 C 15 4 60 2 68 10 C 72 14 75 20 75 28 L 75 47 Z\",\n SKIN,\n ) +\n clipped(\n \"torso\",\n \"M 2 47 L 2 22 C 2 18 3 16 5 14 C 15 4 60 2 68 10 C 72 14 75 20 75 28 L 75 47 Z\",\n `<ellipse cx=\"37\" cy=\"13\" rx=\"31\" ry=\"15\" fill=\"#cfcfcf\"/>`,\n ),\n },\n // The arms and the leg bend: upright, filling the canvas.\n \"skin/1_arm_near\": {\n svg: path(\n \"M 11.5 2.5 C 17 2.5 20 5 20 11 L 19.5 61 C 19.5 67 16 70 11 70 C 6 70 3 67 3 61 L 3 11 C 3 5 6 2.5 11.5 2.5 Z\",\n SKIN,\n ),\n },\n \"skin/1_arm_far\": {\n svg: path(\n \"M 19 3.5 C 23.5 3.5 26 6.5 25.5 12 L 19.5 61 C 18.5 66.5 15 69 10 68.8 C 5.5 68.5 3.2 65 3.6 60 L 11 11 C 12 5 15 3 19 3 Z\",\n SKIN,\n ),\n },\n // The leg: hip at the top (hidden under the pelvis), the foot at the bottom, toes to the left.\n \"skin/1_leg\": {\n svg: path(\n \"M 5 2.5 L 24 2.5 L 24 47 C 24 51 21 53 16 53 L 8 53 C 3.5 53 2 50 3 47 C 4 44 6 43 6 40 Z\",\n SKIN,\n ),\n },\n};\n"
192
+ },
193
+ {
194
+ "path": "character/packs/base/brows.ts",
195
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport { circle, INK, line, path, rect } from \"../../art/svg\";\n\n// Brows (brows/1…10), both on one image. The brow colour paints them: the filled ones are drawn\n// white (the colour shows), the stroked ones black (they stay dark).\n\nconst W = \"#ffffff\";\n\nexport const browsArt: Record<string, ArtDef> = {\n \"brows/1\": {\n svg:\n line(\"M 2.5 3 C 8 2.5 14 4 17.5 7.5\", INK, 4.2) +\n line(\"M 32.5 8.5 C 34 4.5 42 3.5 48.5 4.5\", INK, 4.2),\n },\n \"brows/2\": {\n svg:\n path(\n \"M 3 10.5 C 6 7 16 3.5 22 3 C 25 3 24.5 7 22 8.5 C 16 11 7.5 13 4 12.5 C 2.5 12 2.5 11 3 10.5 Z\",\n W,\n { width: 3 },\n ) +\n path(\n \"M 38.5 4.5 C 42 2.5 51 7 59.5 11 C 62.5 12.5 61.5 15 58 15 C 50 14.5 41 10.5 38.5 6.5 Z\",\n W,\n { width: 3 },\n ),\n },\n \"brows/3\": {\n svg:\n rect(3.5, 4.5, 21, 8, 2.5, W, { width: 3.2 }) +\n rect(37, 3.5, 21, 9, 2.5, W, { width: 3.2 }),\n },\n \"brows/4\": {\n svg:\n line(\"M 3.5 3 L 25 14.5\", INK, 4.6) + line(\"M 35 15.5 L 62 5\", INK, 4.6),\n },\n \"brows/5\": {\n svg:\n path(\n \"M 2.5 4 C 9 3.5 15 5 19 7 L 20.5 2.5 C 25 6 28 12 29.5 19 C 24 15 17 12 10 11.5 L 4.5 12.5 L 8 8 Z\",\n W,\n { width: 2.8 },\n ) +\n path(\n \"M 69 4 C 62 3.5 55 4 48 6 L 46 2.5 C 40 6 36.5 12 35 19.5 C 42 15 52 13 61 12.5 L 66.5 12.5 L 62 9 Z\",\n W,\n { width: 2.8 },\n ),\n },\n \"brows/6\": {\n svg:\n path(\n \"M 1.5 5 C 1.5 2.5 3.5 1.5 6 1.5 C 8.5 1.5 10 3 10 5 C 10 7 8.5 8.5 6 8.5 C 3.5 8.5 1.5 7.5 1.5 5 Z\",\n INK,\n { stroke: \"none\" },\n ) +\n path(\n \"M 32 5.5 C 32 3 34.5 1.5 37.5 1.5 C 40.5 1.5 42.5 3 42.5 5 C 42.5 7.5 40 9 37 9 C 34 9 32 8 32 5.5 Z\",\n INK,\n { stroke: \"none\" },\n ),\n },\n \"brows/7\": {\n svg: path(\n \"M 2 3 C 3 1 7 0.8 9 2.5 C 12 6 18 10 25 12.5 C 27.5 13.5 27.5 16 25 16.5 C 18 17 9 13 4 9 C 2 7 1.5 5 2 3 Z \" +\n \"M 60 3 C 59.5 1 56.5 0.8 54.5 2.5 C 50 6 42 9.5 34 11.5 C 31.5 12.5 31 15.5 34 16.5 C 42 17 52 13.5 57 10 C 59.5 8 60.5 5 60 3 Z\",\n INK,\n { stroke: \"none\" },\n ),\n },\n \"brows/8\": {\n svg:\n path(\"M 3 6 C 8 8 16 9 21.5 8 C 20 13 16 16 10 15 C 6 14 4 11 3 6 Z\", W, {\n width: 2.8,\n }) +\n path(\n \"M 34 12.5 C 35 6 40 3 46 3 C 51 3 54 7 55 11 C 48 10 40 11 34 12.5 Z\",\n W,\n { width: 2.8 },\n ),\n },\n \"brows/9\": {\n svg: line(\"M 2.5 7.5 C 6 3 12 2 18 4 M 31 5 C 38 2 46 4 50 9.5\", INK, 4.6),\n },\n \"brows/10\": {\n svg: [4.5, 12.5]\n .flatMap((x) =>\n [4.5, 11.5, 18.5].map((y) =>\n circle(x, y, 3.3, INK, { stroke: \"none\" }),\n ),\n )\n .join(\"\"),\n },\n};\n"
196
+ },
197
+ {
198
+ "path": "character/packs/base/clothes.ts",
199
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport {\n circle,\n clipped,\n ellipse,\n flat,\n INK,\n line,\n path,\n rect,\n tone,\n} from \"../../art/svg\";\nimport { dashed } from \"./shapes\";\n\n// Clothes: tops (the torso and the two sleeves — short sleeves are caps, long ones are meshes that\n// bend with the arm, drawn upright), bottoms (the waist and two leg pieces), boots, gloves.\n\n/** A short sleeve cap (near / far arm). */\nexport const CAP_L =\n \"M 4 29 C 3 18 6 8 13 5 C 20 3 25 8 29 16 C 31 21 33 26 32 29 C 24 32 12 32 4 29 Z\";\nexport const CAP_R =\n \"M 3 24 C 6 14 12 5 20 3 C 26 2 30 6 31 12 L 31 30 C 26 32 18 33 9 31 C 4 30 2 27 3 24 Z\";\nexport const cap = (id: string, d: string, c: string, extra = \"\") =>\n path(d, c, { stroke: \"none\" }) + clipped(id, d, extra) + path(d, \"none\");\n\n/**\n * A long sleeve: the shoulder on top, the cuff at the bottom. Its outline stays inside its canvas\n * (the far sleeves lean in it) — whatever lies outside is cut away, the outline with it. Patterns (`extra`) and the cuff band (from y `cuff` down) are cut to the\n * outline and drawn under it.\n */\nconst sleeve = (\n id: string,\n outline: string,\n c: string,\n cuff: number,\n extra = \"\",\n) =>\n path(outline, c, { stroke: \"none\" }) +\n clipped(\n id,\n outline,\n extra,\n rect(-1, cuff, 40, 20, 0, tone(c, 0.12), { stroke: \"none\" }),\n line(`M -1 ${cuff} L 40 ${cuff}`, INK, 2.6),\n ) +\n path(outline, \"none\");\n\nconst SLEEVE_L_40 =\n \"M 14 3 C 22 2.5 27.5 6 27.5 14 L 25 50 L 23.5 58 C 23.2 60 22 61 20 61 L 6.5 61 C 4.5 61 3.5 60 3.5 58 L 3.5 13 C 3.5 6 7 3.2 14 3 Z\";\nconst SLEEVE_R_40 =\n \"M 11.5 3.5 C 16 2.5 26 2.5 29 5 C 30.5 7 30 12 29.5 17 L 26 36 L 23.8 58 C 23.6 60 22.5 60.5 21 60.5 L 5 60.5 C 3.5 60.5 2.8 59.5 2.8 58 L 2.8 36 L 6 20 C 7 12 8.5 6 11.5 3.5 Z\";\nconst SLEEVE_L_25 =\n \"M 13 3 C 20 2.5 25 6 25 13 L 23.5 52 C 23.3 54 22 55 20 55 L 6 55 C 4 55 3 54 3 52 L 3 12 C 3 6 6.5 3.2 13 3 Z\";\nconst SLEEVE_R_25 =\n \"M 12 3.5 C 17 2.5 26 2.5 28.5 5 C 30 7 29.5 12 29 17 L 27.8 36 L 25.5 54 C 25.2 55.6 24 56 22.5 55.9 L 5 53.8 C 3.4 53.6 2.6 52.6 2.6 51 L 2.6 34 L 7 17 C 8 10 9 6 12 3.5 Z\";\n\nconst WHITE_STRIPE = \"#f0f0f0\";\n\n/** Shorts' waist piece: the mesh's top dips in the middle and its bottom bows down — the outline too. */\nconst SHORTS =\n \"M 4 4 C 20 4 30 5.5 41 5.5 C 52 5.5 62 4 78 3.5 L 79 30 C 66 32.5 54 34 41 34 C 28 34 16 32.5 4 30 Z\";\n\n/** A tartan: bands of a darker tone every `step` both ways (cut to the shape by the caller). */\nconst plaid = (\n x0: number,\n y0: number,\n x1: number,\n y1: number,\n step: number,\n color: string,\n) => {\n let d = \"\";\n for (let x = x0 + step / 2; x < x1; x += step)\n d += `M ${x} ${y0} L ${x} ${y1} `;\n for (let y = y0 + step / 2; y < y1; y += step)\n d += `M ${x0} ${y} L ${x1} ${y} `;\n return line(d, color, step * 0.42);\n};\n\nconst PLAID = \"#e2404f\";\nconst PLAID_DARK = \"#b42a3a\";\n\nconst TOP_1 =\n \"M 4 62 L 4 32 C 5 18 12 8 24 3 C 20 12 20 20 24 26 C 30 32 40 33 48 32 C 56 30 60 24 60 18 L 60 2 \" +\n \"C 70 6 80 12 82 20 C 84 30 84 50 83 62 C 60 65 26 65 4 62 Z\";\nconst TOP_5 =\n \"M 8 22 C 12 10 24 4 40 3 C 58 2 76 8 82 18 C 85 30 85 44 85 60 L 85 72 C 85 75 83 77 79 77 L 6 77 \" +\n \"C 3 77 2 75 2 72 L 3 60 C 4 46 5 34 8 22 Z \" +\n // the open neck: the body shows through\n \"M 25 11 C 34 9 48 8 57 9 C 52 18 44 26 34 32 C 30 24 26 18 25 11 Z\";\nconst TOP_40 =\n \"M 10 30 C 8 12 22 4 46 3 C 70 3 84 10 83 26 C 86 40 88 56 87 70 C 87 76 84 80 80 81 L 80 83 \" +\n \"C 80 84 78 85.5 75 85.5 L 14 85.5 C 10 85.5 8 84 9 81 C 5 79 2 75 3 68 C 3 52 6 40 10 30 Z \" +\n \"M 23 14 C 34 11 50 11 61 14 C 54 22 44 28 36 32 C 30 26 26 20 23 14 Z\";\nconst TOP_25 =\n \"M 3 58 L 5 30 C 7 18 13 10 21 5 C 22 18 28 28 34 34 C 42 26 50 16 60 3 C 72 5 81 12 84 24 C 86 36 86 48 85 60 \" +\n \"C 85 65 81 69 76 69 L 9 69 C 5 69 3 64 3 58 Z\";\n\n/** Short caps of the 29×37 / 29×38 kind (rounded, the right one leaning). */\nconst CAP2_L = \"M 4 34 L 4 14 C 4 6 9 3 15 3 C 21 3 25 6 25 14 L 25 34 Z\";\nconst CAP2_R =\n \"M 4 33 C 3 22 6 6 15 3 C 22 2 26 6 25.5 14 L 24 34 C 16 36 9 35 4 33 Z\";\n/** Caps of the 34×37 / 33×36 kind. */\nconst CAP3_L =\n \"M 4 33 C 3 20 8 6 18 3.5 C 26 2 31 8 31 16 L 30 33 C 22 35 12 35 4 33 Z\";\nconst CAP3_R =\n \"M 3 31 C 3 18 8 5 17 3 C 25 2 30 8 30 16 L 30 32 C 22 34 11 34 3 31 Z\";\n/** Long sleeves of the 31×59 / 34×59 meshes. */\nconst SLEEVE_L_16 =\n \"M 14 3 C 22 2.5 27.5 6 27.5 14 L 27.5 53 C 27.5 55.5 26 56 24 56 L 6 56 C 4 56 3.3 55 3.3 53 L 3.3 13 C 3.3 6 7 3.2 14 3 Z\";\nconst SLEEVE_R_16 =\n \"M 14 3.5 C 20 2.5 28 2.5 30.5 5 C 31 8 31 12 31 16 L 31 53 C 31 55.5 29.5 56 27.5 56 L 6 56 C 4.5 56 3.5 55 3.5 53 L 4 34 L 9 16 C 10 9 11 6 14 3.5 Z\";\n\n/** A top: its silhouette filled, the patterns cut to it, then what sits on it (a collar, buttons) — also kept inside\n * the silhouette and out of the neck opening — and the outline over all. */\nexport const top = (id: string, d: string, c: string, inside = \"\", over = \"\") =>\n path(d, c, { stroke: \"none\", holes: true }) +\n clipped(id, d, inside) +\n (over\n ? path(d, \"none\", { holes: true }) + clipped(`${id}_on`, d, over)\n : \"\") +\n path(d, \"none\", { holes: true });\n\n/** A long sleeve with a cuff in its own colour. */\nconst cuffed = (\n id: string,\n outline: string,\n c: string,\n cuff: number,\n cuffColor: string,\n extra = \"\",\n) =>\n path(outline, c, { stroke: \"none\" }) +\n clipped(\n id,\n outline,\n extra,\n rect(-1, cuff, 40, 20, 0, cuffColor, { stroke: \"none\" }),\n line(`M -1 ${cuff} L 40 ${cuff}`, INK, 2.6),\n ) +\n path(outline, \"none\");\n\n/** A gingham shirt with a collar, buttons and a belt. */\nconst gingham = (\n id: string,\n d: string,\n c: string,\n light: string,\n belt: string,\n buckle: string,\n) =>\n top(\n id,\n d,\n c,\n plaid(0, 0, 90, 90, 10, light) +\n line(\"M 42 30 L 42 66\", INK, 2.4) +\n flat(\"M 0 64 L 90 64 L 90 76 L 0 76 Z\", belt) +\n line(\"M 0 64 L 90 64\", INK, 2.6),\n path(\"M 22 4 C 26 14 32 22 40 28 L 34 34 C 26 28 20 18 22 4 Z\", \"#f4f4f8\", {\n width: 2.6,\n }) +\n path(\n \"M 62 4 C 58 14 52 22 44 28 L 50 34 C 58 28 64 18 62 4 Z\",\n \"#f4f4f8\",\n { width: 2.6 },\n ) +\n rect(34, 63, 14, 13, 2, buckle, { width: 2.6 }) +\n rect(6, 64, 8, 10, 1, buckle, { width: 2.2 }),\n );\n\n/** The waistband of trousers (pants/N, 79 × 26): a bowl over the hips. */\nconst WAIST = \"M 3 4 L 76 3 C 78 16 62 23 40 23 C 18 23 2 16 3 4 Z\";\n\n/** Leg pieces (meshes): short and long straight legs, a wide short leg, flared legs (right / left). */\nconst LEG = {\n short:\n \"M 6 3 L 19 3 C 21 3 22 4 22 6 L 22 22 C 22 24 21 25 19 25 L 6 25 C 4 25 3 24 3 22 L 3 6 C 3 4 4 3 6 3 Z\",\n long: \"M 6 3 L 19 3 C 21 3 22 4 22 6 L 22 35 C 22 37 21 38 19 38 L 6 38 C 4 38 3 37 3 35 L 3 6 C 3 4 4 3 6 3 Z\",\n long40:\n \"M 6 3 L 19 3 C 21 3 22 4 22 6 L 22 34 C 22 36 21 37 19 37 L 6 37 C 4 37 3 36 3 34 L 3 6 C 3 4 4 3 6 3 Z\",\n wide: \"M 6 3 L 25 3 C 27 3 28 4 28 6 L 28 22 C 28 24 27 25 25 25 L 6 25 C 4 25 3 24 3 22 L 3 6 C 3 4 4 3 6 3 Z\",\n flareR:\n \"M 7 3.5 L 28 3.5 L 30.5 22.5 L 32.5 38.5 L 13.5 38.6 L 4 36 L 7 25 Z\",\n flareL:\n \"M 6.5 4 L 29 4 L 32.3 23 L 34.5 38.8 L 14.5 39.4 L 3.8 38.4 L 6.5 25.6 Z\",\n} as const;\n\n/** Skirts: each outline is its mesh's edge (an even margin inside it), so the skirt fills what is drawn. */\nconst SKIRT = {\n a: \"M 89.4 20.5 L 89.4 28.7 L 75.7 34.7 L 60.5 37.4 L 46 37.4 L 30.7 37.4 L 15.5 35.5 L 2.6 32 L 2.6 20.3 L 7.3 2.7 L 25.8 3.7 L 45.6 4.5 L 65.2 3.5 L 82.6 2.7 Z\",\n b: \"M 89.4 21.8 L 89.4 28 L 75.8 35.2 L 61.1 37.4 L 46 37.4 L 32 37.4 L 16.4 35.2 L 2.6 31.6 L 2.6 22.2 L 8.3 2.6 L 23 2.6 L 46 2.6 L 69 2.6 L 82.6 2.6 Z\",\n c: \"M 89.4 22.3 L 89.4 28.8 L 75.8 36.1 L 61.1 38.4 L 46 38.4 L 32 38.4 L 16.4 36.2 L 2.6 32.5 L 2.6 22.8 L 8.3 2.6 L 23 2.6 L 46 2.6 L 69 2.6 L 82.6 2.6 Z\",\n tall: \"M 90.4 17.8 L 90.4 43.4 L 77.4 43.4 L 61.8 43.4 L 46.5 43.4 L 31.9 43.4 L 12.7 43.4 L 2.6 43.4 L 2.6 19.9 L 7.9 2.6 L 23.3 2.6 L 46.5 2.6 L 69.7 2.6 L 83.3 2.6 Z\",\n tutu: \"M 85.4 20.2 L 85.4 26.9 L 73.3 34.6 L 58.3 37.4 L 44 37.4 L 29.2 37.4 L 14.7 36.2 L 2.6 27.9 L 2.6 20.1 L 3.8 2.8 L 22 2.6 L 44 2.6 L 66 2.6 L 83.2 2.7 Z\",\n pleat:\n \"M 89.4 20 L 89.4 28 L 75.7 33.8 L 60.5 36.4 L 46 36.4 L 30.7 36.4 L 15.5 34.5 L 2.6 31.1 L 2.6 19.8 L 7.3 2.7 L 25.8 3.7 L 45.6 4.4 L 65.2 3.5 L 82.7 2.7 Z\",\n layer:\n \"M 93.4 15.7 L 93.4 28.2 L 79.1 33.4 L 63.2 33.4 L 48 33.4 L 32 33.4 L 16.3 33.4 L 2.6 31.1 L 2.6 15.5 L 8.9 2.7 L 29.1 4.5 L 48.3 4.3 L 68.1 3.8 L 84.7 2.7 Z\",\n layer2:\n \"M 93.4 16.5 L 93.4 29.9 L 79.1 35.4 L 63.2 35.4 L 48 35.4 L 32 35.4 L 16.3 35.4 L 2.6 32.9 L 2.6 16.3 L 9 2.8 L 29.1 4.6 L 48.3 4.4 L 68.1 3.9 L 84.6 2.7 Z\",\n} as const;\n\n/** A shape filled, its pattern cut to it, outlined on top. */\nconst cloth = (id: string, d: string, c: string, pattern = \"\") =>\n path(d, c, { stroke: \"none\" }) + clipped(id, d, pattern) + path(d, \"none\");\n\n/** Polka dots over a w × h area. */\nconst dots = (w: number, h: number, step: number, r: number, color: string) => {\n let s = \"\";\n for (let y = step / 2, row = 0; y < h; y += step, row++)\n for (let x = row % 2 ? step : step / 2; x < w; x += step)\n s += circle(x, y, r, color, { stroke: \"none\" });\n return s;\n};\n\n/** Camouflage blotches. */\nconst camo = (w: number, h: number) =>\n flat(\n `M 0 ${h * 0.3} C ${w * 0.15} ${h * 0.1} ${w * 0.3} ${h * 0.5} ${w * 0.25} ${h * 0.8} L 0 ${h} Z`,\n \"#3e5428\",\n ) +\n flat(\n `M ${w * 0.45} 0 C ${w * 0.6} ${h * 0.2} ${w * 0.55} ${h * 0.6} ${w * 0.4} ${h * 0.55} C ${w * 0.3} ${h * 0.4} ${w * 0.35} ${h * 0.1} ${w * 0.45} 0 Z`,\n \"#a8b860\",\n ) +\n flat(\n `M ${w * 0.7} ${h * 0.4} C ${w * 0.85} ${h * 0.3} ${w} ${h * 0.5} ${w} ${h * 0.8} L ${w * 0.7} ${h} C ${w * 0.6} ${h * 0.8} ${w * 0.6} ${h * 0.5} ${w * 0.7} ${h * 0.4} Z`,\n \"#2e4220\",\n );\n\n/** Torn ends: a jagged bottom painted with the background lost (drawn as a patch of tears). */\nconst patches = (\n color: string,\n spots: ReadonlyArray<readonly [number, number]>,\n) =>\n spots\n .map(([x, y]) =>\n path(\n `M ${x} ${y} L ${x + 4} ${y - 2} L ${x + 6} ${y + 2} L ${x + 3} ${y + 5} L ${x - 1} ${y + 3} Z`,\n color,\n { stroke: \"none\" },\n ),\n )\n .join(\"\");\n\ntype LegKind = \"short\" | \"long\" | \"long40\" | \"wide\" | \"flare\";\n\n/** Trousers: the waistband (none when `waist` is \"\") and both leg pieces (none when `leg` is null). */\nconst pants = (\n n: number,\n waist: string,\n leg: LegKind | null,\n legColor: string,\n legPattern = \"\",\n waistPattern = \"\",\n leftLeg: LegKind | null = leg,\n): Record<string, ArtDef> => {\n const out: Record<string, ArtDef> = {};\n if (waist)\n out[`pants/${n}`] = {\n svg: cloth(`pants_${n}`, WAIST, waist, waistPattern),\n };\n const outline = (k: LegKind, side: \"r\" | \"l\") =>\n k === \"flare\" ? (side === \"r\" ? LEG.flareR : LEG.flareL) : LEG[k];\n if (leg)\n out[`pants/${n}_near`] = {\n svg: cloth(`pants_near_${n}`, outline(leg, \"r\"), legColor, legPattern),\n };\n if (leftLeg)\n out[`pants/${n}_far`] = {\n svg: cloth(`pants_far_${n}`, outline(leftLeg, \"l\"), legColor, legPattern),\n };\n return out;\n};\n\n/** Shoe outlines, each on its own image size (the toe to the left); several pairs share one (recoloured). */\nexport const SHOE = {\n low: \"M 9 2.3 L 25 2.3 C 27 2.5 27.7 4 27.7 6 L 27.7 12 C 27.7 15 26 16.7 22 16.7 L 7 16.7 C 4 16.7 2.8 15 2.8 12.5 C 2.8 9 4 5 6 3.5 C 7 2.6 8 2.3 9 2.3 Z\",\n clog: \"M 12.5 2.8 C 16 2.8 18.3 5 18.3 8.3 L 22 8.5 C 25 8.6 26.2 10 26.2 12 L 26.2 15 C 26 18 23 19.3 19 19.3 L 7 19.3 C 4 19.3 2.8 17 2.8 15 C 2.8 12.5 4 11 7.5 10.5 L 7.5 8 C 7.5 4.5 9.5 2.8 12.5 2.8 Z\",\n loafer:\n \"M 8.5 2.8 L 26.5 2.8 C 28.3 3 28.5 5 28.5 8 L 28.3 15 C 28 18.5 25 20.5 18 20.5 L 10 20.5 C 5 20.5 2.8 18 2.8 15 C 2.8 11 4.5 9 5.8 7 C 6 4.5 7 2.8 8.5 2.8 Z\",\n sock: \"M 8.8 3.8 L 26.3 3.8 L 26.5 9 C 27.5 11 28.3 13 28.3 15 C 28.3 18 26 19.2 22 19.2 L 9 19.2 C 5 19.2 2.8 17.5 2.8 15.5 C 2.8 13.5 4.5 12 6.5 11 L 7 8 C 7.2 5.5 7.8 3.8 8.8 3.8 Z\",\n tall: \"M 5.8 2.3 L 16 2.3 C 19 2.5 21 3.5 22.2 4.8 L 22.2 24 C 22.2 26 20 27.2 16 27.2 L 8 27.2 C 4.5 27.2 2.8 25 2.8 22 C 2.8 19 4 17 5.8 15.5 Z\",\n moccasin:\n \"M 5.5 2.8 C 7 3.5 8.5 5 10 5.5 L 18 5.3 C 21 4.5 23.5 4.5 25 5 C 26 6 26.3 8 26.2 11 C 26 14 22 15.3 16 15.3 L 10 15.3 C 6 15.3 2.8 12.5 2.8 9 L 2.8 5 C 2.8 3.5 4 2.5 5.5 2.8 Z\",\n ankle:\n \"M 11.8 2.8 L 27.5 2.8 C 29 2.8 29.2 4 29.2 5 L 29.2 19 C 29.2 21 27 21.2 25 21.2 L 4 21.2 C 3 21.2 2.8 20.5 2.8 19.5 C 2.8 18 4 16 6 14 L 11.8 9 Z\",\n slide:\n \"M 12 2.3 C 17 2.3 21 4 22 6.5 C 24 6 26 5.5 28 6 C 28.5 7.5 28.6 9 28.5 11 C 28.3 14.5 24 16.5 16 16.5 C 8 16.5 2.8 14 2.8 10.5 C 2.8 6 6 2.3 12 2.3 Z\",\n flat: \"M 9.8 2.8 L 27 2.8 C 28.5 2.8 28.2 4 28.2 6 L 28.2 13 C 28.2 16 26 16.6 22 16.6 L 8 16.6 C 4.5 16.6 2.8 14.5 2.8 11 C 2.8 7.5 5 5.8 8 5 Z\",\n pointed:\n \"M 5.5 2.8 C 7 3.5 7.5 5 8.5 6.5 C 9.5 5.5 10.5 4.8 12.5 4.8 L 30 4.8 C 31.5 4.8 31.5 6 31.5 7 L 31.5 13 C 31.5 15 29 15.5 26 15.6 L 9 15.6 C 5 15.5 2.8 13 2.8 9 L 2.8 5 C 2.8 3.5 4 2.5 5.5 2.8 Z\",\n hiker:\n \"M 12 2.8 C 15 2.3 17.5 2.5 19.2 4 L 19.5 7.5 C 22 8 25 8.5 27 9.5 C 28.5 10.5 28.3 12 28.3 14 L 28 20 C 28 22.5 26 22.2 24 22.2 L 6 22.2 C 3.5 22.2 2.8 21 2.8 19.5 C 2.8 18 4 16.5 6 15 L 10.8 11 L 10.8 4.5 C 10.8 3.5 11.3 3 12 2.8 Z\",\n cat: \"M 7.8 3 C 9.5 3.5 11 5 12 6 C 13 5 14.5 3.5 17 3 L 25 3 C 25.8 3.2 25.8 5 25.5 8 L 25.2 14 C 25 17.5 22 18.5 17 18.5 L 7 18.5 C 4 18.5 2.8 17 2.8 14.5 C 2.8 11.5 4 9 5.5 7.5 C 6 5 6.5 3 7.8 3 Z\",\n frog: \"M 7.3 6 C 7.3 3.5 9 2.3 10.8 2.3 C 13 2.3 14.5 3.5 14.8 5.2 C 16 4.2 18 3.5 19.5 3.5 C 21.5 3.5 22.5 5 22.5 6.5 C 23.5 8 24.2 10 24.2 13 C 24.2 17 22 19.2 16 19.2 L 9 19.2 C 5 19.2 2.8 17 2.8 14 C 2.8 11 4.5 8.5 7.3 6 Z\",\n oxford:\n \"M 6 2.8 L 25.5 2.8 C 27 2.8 27.3 4 27.3 6 L 27 10 C 26.5 13 22 13.8 16 13.8 L 10 13.8 C 5 13.8 2.8 12 2.8 8.5 L 2.8 6 C 2.8 4 4 2.8 6 2.8 Z\",\n wellie:\n \"M 6.8 3.5 C 9 2.5 13 2.3 16 2.5 C 19 2.8 22 3.5 23.3 5 L 24.2 16 L 23.5 21 C 23 24 20 24.8 14 24.8 L 8 24.8 C 4.5 24.8 2.8 22.5 2.8 19 C 2.8 16.5 4.5 15 6.8 13.5 Z\",\n bootie:\n \"M 7.5 2.8 L 24.5 2.8 C 25 3 25.2 4 25.2 6 L 25.5 18 C 25.5 21.5 22 22.8 15 22.8 L 9 22.8 C 5 22.8 2.8 21 2.8 18 C 2.8 15.5 4.5 13 6.8 11.5 L 6.8 4.5 C 6.8 3.5 7 2.8 7.5 2.8 Z\",\n bunny:\n \"M 5.8 4.5 C 6 3.8 7 3.5 8 4 L 11 6 L 15 3.5 C 16.5 2.8 18 2.8 18.5 4 L 19.3 9.5 C 21 10 24 10.5 26 11.5 C 26.5 13 26.3 15 26 17 C 25.5 19.5 22 20.5 16 20.5 L 9 20.5 C 5 20.5 3.5 18.5 3.5 16 L 4.5 8 Z\",\n sandal:\n \"M 7 2.8 C 9 2.8 10 4 10.5 4.8 L 17.5 4.8 C 18.5 5 18.7 6 18.5 7.5 L 24 7.5 C 25 7 26 7.3 27 8.5 C 27.6 9.5 27.5 11 27 12 C 26 14 22 14.5 16 14.5 L 8 14.5 C 4.5 14.5 2.8 13 2.8 11 L 3.8 5.5 C 4.3 3.5 5.5 2.8 7 2.8 Z\",\n hightop:\n \"M 7 4.8 L 11 4.8 C 11.5 3.5 12.5 2.8 14 2.8 C 15.5 2.8 16.5 3.5 17 4.8 L 22.5 4.8 C 24 4.8 24.2 6 24.2 8 L 24.2 15 C 24.2 18 21.5 19.2 16 19.2 L 9 19.2 C 5 19.2 2.8 17.5 2.8 14.5 C 2.8 12 4 10 6.8 8.5 L 6.8 6 C 6.8 5.2 6.9 4.8 7 4.8 Z\",\n claw: \"M 13.8 3.5 C 16 2.3 21 2.3 24 2.5 L 31.2 3.8 L 31.2 20 C 31.2 21.5 29 21.2 27 21.2 L 12 21 L 11 19.5 L 4 19.3 C 2.8 19.3 2.8 18.5 2.8 17.5 C 3 16 5 14.5 8 12.5 L 13.8 8 Z\",\n};\n\n/** A shoe: its outline filled, details cut to it, outlined, then what sits on top. */\nexport const shoe = (\n id: string,\n d: string,\n c: string,\n inside = \"\",\n over = \"\",\n) =>\n path(d, c, { stroke: \"none\" }) +\n clipped(id, d, inside) +\n path(d, \"none\") +\n over;\n\n/** A pair: the near and the far image, one picture. */\nconst pair = (\n n: number,\n d: string,\n c: string,\n inside = \"\",\n over = \"\",\n): Record<string, ArtDef> => ({\n [`boots/${n}_near`]: { svg: shoe(`boots_${n}_near`, d, c, inside, over) },\n [`boots/${n}_far`]: { svg: shoe(`boots_${n}_far`, d, c, inside, over) },\n});\n\n/** A boot cuff with a drip running down its middle (boots 5, 27, 28). */\nconst drip = (c: string) =>\n flat(\n \"M 0 0 L 26 0 L 26 6.5 L 15 6.5 C 14 6.5 13.5 7.5 13.5 8.5 L 13.5 11 C 13.5 12.8 11 12.8 11 11 L 11 8 C 11 7 10 6.5 9 6.5 L 0 6.5 Z\",\n c,\n ) +\n line(\n \"M 0 6.5 L 9 6.5 C 10 6.5 11 7 11 8 L 11 11 C 11 12.8 13.5 12.8 13.5 11 L 13.5 8.5 C 13.5 7.5 14 6.5 15 6.5 L 26 6.5\",\n tone(c, -0.4),\n 1.6,\n );\n\n/** A high-top's white toe cap and collar (shoes 19, 22, 23). */\nconst hightopTrim = (trim: string) =>\n flat(\"M 0 13 C 6 12 11 13 14 16 C 16 18 16 20 16 22 L 0 22 Z\", trim) +\n line(\"M 0 13 C 6 12 11 13 14 16 C 16 18 16 20 16 22\", INK, 1.8) +\n flat(\"M 10 4 L 18 4 L 18 7 L 10 7 Z\", trim);\n\n/** A glove on a w × h image: a mitten (cuff band on top when `cuff` is given), details cut to it. */\nexport const mitt = (\n id: string,\n w: number,\n h: number,\n c: string,\n cuff?: { h: number; color: string },\n inside = \"\",\n) => {\n const t = 3.5;\n const d = `M 7 ${t} L ${w - 7} ${t} C ${w - 4.5} ${t} ${w - 3.5} ${t + 2} ${w - 3.5} ${t + 5} L ${w - 3.5} ${h - 10} C ${w - 3.5} ${h - 5} ${w - 8} ${h - 3.5} ${w / 2} ${h - 3.5} C 8 ${h - 3.5} 3.5 ${h - 5} 3.5 ${h - 10} L 3.5 ${t + 5} C 3.5 ${t + 2} 4.5 ${t} 7 ${t} Z`;\n return (\n path(d, c, { stroke: \"none\" }) +\n clipped(\n id,\n d,\n inside,\n ...(cuff\n ? [\n rect(0, 0, w, t + cuff.h, 0, cuff.color, { stroke: \"none\" }),\n line(`M 0 ${t + cuff.h} L ${w} ${t + cuff.h}`, INK, 2.6),\n ]\n : []),\n ) +\n path(d, \"none\")\n );\n};\n\n/** A wristband (a thin strap round the wrist), bolts on it when given. */\nconst wristband = (w: number, h: number, c: string, bolt?: string) =>\n rect(3.5, 3.5, w - 7, h - 7, 3, c) +\n (bolt\n ? [w * 0.38, w * 0.62]\n .map(\n (x) =>\n circle(x, h / 2, h * 0.2, bolt, { width: 2 }) +\n circle(x, h / 2, h * 0.07, INK, { stroke: \"none\" }),\n )\n .join(\"\")\n : \"\");\n\n/** A bracer seen from the side: a plate over the forearm, a band at the wrist. */\nconst bracer = (w: number, h: number, c: string, plate: string) =>\n path(\n `M 4 7 L ${w - 8} 5 C ${w - 5} 5 ${w - 4} 7 ${w - 4} 10 L ${w - 4} ${h - 8} C ${w - 4} ${h - 5} ${w - 6} ${h - 4} ${w - 9} ${h - 4} L 4 ${h - 6} Z`,\n c,\n ) +\n rect(w * 0.35, 3.5, w * 0.3, h - 7, 2, plate, { width: 2.8 }) +\n rect(w * 0.42, h * 0.3, w * 0.16, h * 0.4, 1, tone(plate, -0.25), {\n width: 2,\n });\n\n// The tops' outlines and neck openings.\nexport const TOPS = {\n top_7:\n \"M 64 4 Q 60.2 3.1 60.1 3.2 Q 60.1 3.2 60.4 5.4 Q 60.8 7.5 60.2 10.2 Q 59.6 12.9 57.8 17 Q 56 21 53.5 23.5 Q 51.1 26 47.4 27.8 Q 43.8 29.7 37 29.7 Q 30.1 29.7 26.5 27.4 Q 22.9 25.1 22 20.8 Q 21.2 16.4 22.6 11.1 Q 24 5.8 23.9 5.6 Q 23.7 5.3 21.4 6.2 Q 19.1 7 15.6 11 Q 12.1 14.9 9.5 22 Q 6.9 29.1 5.3 39.8 Q 3.8 50.6 3.9 56.9 Q 3.9 63.2 6.3 64.3 Q 8.6 65.5 8.8 69.1 Q 8.9 72.6 9.9 73.7 Q 10.9 74.8 12.1 75 Q 13.4 75.2 15.1 74.6 Q 16.8 74.1 18.9 76 Q 21 77.9 22.3 78.5 Q 23.7 79 25.1 78.6 Q 26.5 78.2 28.7 76.5 Q 30.8 74.7 32.4 76.8 Q 34.1 79 35.7 79.5 Q 37.3 80 40.9 78.1 Q 44.5 76.2 46.3 77.1 Q 48.1 78.1 50.4 78.1 Q 52.7 78.1 54.8 76.2 Q 56.9 74.4 60.1 74.8 Q 63.2 75.2 64.6 74.6 Q 65.9 74 66.5 73.3 Q 67.1 72.7 67.2 70.1 Q 67.4 67.6 69.2 66.5 Q 71 65.4 76.5 64.4 Q 82 63.4 82.6 60.9 Q 83.2 58.3 83.2 50.8 Q 83.2 43.2 83.7 41.7 Q 84.2 40.3 83.7 32 Q 83.2 23.8 82.7 22.1 Q 82.2 20.4 78.5 14.6 Q 74.7 8.8 71.2 6.8 Q 67.7 4.8 64 4 Z\",\n top_9:\n \"M 64.4 4.5 Q 60.1 3.2 60 3.3 Q 59.9 3.4 59.7 4.7 Q 59.6 5.9 57.8 9.5 Q 56 13 52.5 17.9 Q 49.1 22.7 47.5 23.7 Q 45.9 24.7 38.5 24.7 Q 31.2 24.8 29.2 24 Q 27.2 23.3 25.8 19.4 Q 24.3 15.6 24.7 10.8 Q 25.2 6 25 5.7 Q 24.8 5.5 21.4 7.2 Q 18.1 9 15 12 Q 12 15.1 10.9 17.2 Q 9.8 19.3 8.4 24.6 Q 6.9 29.9 5.9 37.2 Q 4.8 44.5 4.8 48.6 Q 4.8 52.8 4.3 54.3 Q 3.8 55.8 3.8 63.9 Q 3.8 72.1 4.1 72.6 Q 4.4 73.2 5.3 73.7 Q 6.2 74.2 14.9 75.7 Q 23.6 77.2 34 77.7 Q 44.4 78.2 58.2 76.7 Q 72 75.1 76.3 74.1 Q 80.7 73.2 81.5 72.7 Q 82.2 72.3 82.7 68.9 Q 83.2 65.5 83.2 54.9 Q 83.2 44.2 83.7 42.7 Q 84.2 41.3 83.7 32.5 Q 83.2 23.8 82.7 22 Q 82.2 20.3 81.1 18.2 Q 80 16.1 75.9 12 Q 71.9 7.9 70.2 6.8 Q 68.6 5.8 64.4 4.5 Z\",\n top_14:\n \"M 64.8 4.5 Q 61 3.3 60.9 3.3 Q 60.9 3.4 60.8 7.6 Q 60.7 11.8 58.8 15.9 Q 57 20.1 53 23.6 Q 48.9 27.1 45.3 28.4 Q 41.7 29.7 35.9 29.7 Q 30.2 29.7 27.1 28 Q 24.1 26.3 22.6 22.5 Q 21.2 18.7 21.8 14.4 Q 22.4 10.1 23.6 7.8 Q 24.8 5.5 24.8 5.5 Q 24.8 5.6 19.3 8.9 Q 13.8 12.3 11.8 15.8 Q 9.8 19.3 7.9 27.6 Q 5.9 35.8 4.9 45.6 Q 3.8 55.4 4.1 64.5 Q 4.4 73.5 9.1 74.8 Q 13.9 76.1 23.9 76.7 Q 33.8 77.2 35.3 77.7 Q 36.8 78.2 48.5 77.7 Q 60.2 77.2 71.1 75.3 Q 81.9 73.4 82.6 69.9 Q 83.2 66.3 83.2 55.3 Q 83.2 44.2 83.7 42.7 Q 84.2 41.3 83.6 32.2 Q 83.1 23 81.7 19.8 Q 80.3 16.6 78 13.7 Q 75.8 10.8 72.2 8.3 Q 68.6 5.8 64.8 4.5 Z\",\n top_33:\n \"M 43.1 3.9 Q 33 3.9 29.5 5.5 Q 26.1 7 20.5 13 Q 14.9 19.1 12.4 23.2 Q 9.8 27.3 6.8 43.5 Q 3.8 59.7 3.4 64 Q 2.9 68.3 3.5 72.1 Q 4.1 75.9 5.2 76.5 Q 6.2 77.1 9.2 77.5 Q 12.3 77.9 15.7 76.6 Q 19.2 75.2 27.4 75.2 Q 35.6 75.2 39.3 75.8 Q 43 76.4 44.5 77.3 Q 46 78.2 52.4 78.2 Q 58.9 78.2 61.4 76.3 Q 64 74.4 71.5 73.8 Q 79.1 73.2 80.3 72.7 Q 81.5 72.2 82.4 69.7 Q 83.2 67.2 83.2 56.2 Q 83.2 45.3 83.7 41.4 Q 84.2 37.6 82.7 31.5 Q 81.2 25.3 79.7 22.8 Q 78.1 20.2 68.8 13 Q 59.5 5.7 56.4 4.8 Q 53.2 3.8 43.1 3.9 Z \" +\n \"M 28.4 15.7 Q 32.3 14.3 38.9 14.3 Q 45.6 14.3 50.6 15.4 Q 55.7 16.6 56.4 18.3 Q 57 20 52.5 23.5 Q 48 27 40.6 31.1 Q 33.2 35.1 29.1 30.6 Q 25 26 24 23.8 Q 23.1 21.6 23.8 19.3 Q 24.6 17.1 28.4 15.7 Z\",\n top_34:\n \"M 43.1 3.9 Q 33 3.9 29.5 5.5 Q 26.1 7 20.5 13 Q 14.9 19.1 12.4 23.2 Q 9.8 27.3 6.8 43.5 Q 3.8 59.7 3.4 64 Q 2.9 68.3 3.5 72.1 Q 4.1 75.9 5.2 76.5 Q 6.2 77.1 9.2 77.5 Q 12.3 77.9 15.7 76.6 Q 19.2 75.2 27.4 75.2 Q 35.6 75.2 39.3 75.8 Q 43 76.4 44.5 77.3 Q 46 78.2 52.4 78.2 Q 58.9 78.2 61.4 76.3 Q 64 74.4 71.5 73.8 Q 79.1 73.2 80.3 72.7 Q 81.5 72.2 82.4 69.7 Q 83.2 67.2 83.2 56.2 Q 83.2 45.3 83.7 41.4 Q 84.2 37.6 82.7 31.5 Q 81.2 25.3 79.7 22.8 Q 78.1 20.2 68.8 13 Q 59.5 5.7 56.4 4.8 Q 53.2 3.8 43.1 3.9 Z \" +\n \"M 28.4 15.7 Q 32.3 14.3 38.9 14.3 Q 45.6 14.3 50.6 15.4 Q 55.7 16.6 56.4 18.3 Q 57 20 52.5 23.5 Q 48 27 40.6 31.1 Q 33.2 35.1 29.1 30.6 Q 25 26 24 23.8 Q 23.1 21.6 23.8 19.3 Q 24.6 17.1 28.4 15.7 Z\",\n top_2:\n \"M 45.7 3.4 Q 34.9 2.9 33.9 3.4 Q 32.9 3.8 27.8 3.8 Q 22.7 3.8 18 4.8 Q 13.2 5.8 11.2 6.9 Q 9.2 7.9 7 10.7 Q 4.8 13.4 4.3 15.5 Q 3.9 17.5 5.3 23.5 Q 6.8 29.5 5.8 34.5 Q 4.8 39.6 5.8 47.6 Q 6.8 55.6 4.8 64.1 Q 2.8 72.5 3.3 74.5 Q 3.8 76.5 5.9 78.8 Q 8.1 81 10.2 82.1 Q 12.2 83.2 14.9 83.7 Q 17.7 84.2 24.2 84.2 Q 30.8 84.2 32.3 84.7 Q 33.8 85.2 47 84.7 Q 60.2 84.2 66.6 83.2 Q 72.9 82.1 76.7 80.7 Q 80.5 79.3 82.3 78 Q 84.1 76.8 85.6 73.5 Q 87.2 70.1 87.1 66 Q 87.1 62 85.6 58.3 Q 84.2 54.6 85.7 51.2 Q 87.1 47.9 87.7 44.2 Q 88.2 40.6 87.2 35.9 Q 86.2 31.2 84.2 27.5 Q 82.3 23.7 82.7 20.7 Q 83.1 17.7 82.6 15.5 Q 82.2 13.3 81.4 12.1 Q 80.6 10.9 79.1 9.8 Q 77.5 8.7 73.2 7.3 Q 68.9 5.9 62.7 4.8 Q 56.4 3.8 45.7 3.4 Z\",\n top_3:\n \"M 51.2 2.9 Q 41.8 2.9 31.4 3.9 Q 21.1 4.9 18.6 5.9 Q 16.1 7 13.5 10.1 Q 11 13.2 11.3 17.5 Q 11.6 21.8 10.2 25 Q 8.9 28.2 6.3 40.4 Q 3.8 52.7 3.9 58.8 Q 3.9 64.9 5.3 67.5 Q 6.6 70 7.2 73.4 Q 7.8 76.8 9.1 79 Q 10.3 81.1 12.3 82.2 Q 14.2 83.2 16.9 83.7 Q 19.7 84.2 23.7 84.2 Q 27.8 84.2 29.3 84.7 Q 30.8 85.2 44.5 85.2 Q 58.3 85.2 63.1 84.7 Q 68 84.1 70.7 83.2 Q 73.5 82.3 75.9 80.3 Q 78.4 78.3 82.3 69.2 Q 86.2 60.1 86.7 51.3 Q 87.2 42.6 86.7 39.4 Q 86.1 36.1 83.7 30.3 Q 81.2 24.5 82.1 20.5 Q 83 16.4 82.6 14.4 Q 82.2 12.4 80.5 10.2 Q 78.9 8 73.4 5.9 Q 67.9 3.9 64.2 3.3 Q 60.5 2.8 51.2 2.9 Z \" +\n \"M 33.2 19.3 Q 40.4 17.3 43.5 17.3 Q 46.6 17.3 49.6 17.9 Q 52.7 18.6 53.4 20.3 Q 54.1 22 45.2 28.6 Q 36.3 35.1 31.6 30.5 Q 26.8 25.9 26.5 23.6 Q 26.1 21.3 33.2 19.3 Z\",\n top_4:\n \"M 42.6 2.9 Q 35.9 2.9 32.1 3.9 Q 28.4 4.8 26.8 5.8 Q 25.2 6.9 20.1 12.9 Q 15 19 12.4 23.1 Q 9.8 27.3 7.3 40.5 Q 4.8 53.6 3.9 66.7 Q 3 79.7 3.3 80 Q 3.6 80.3 6.8 81.2 Q 10 82.1 18.2 83.2 Q 26.5 84.2 43.4 84.2 Q 60.3 84.2 70.9 82.2 Q 81.4 80.2 82.3 79.1 Q 83.2 77.9 83.2 61.6 Q 83.2 45.3 83.7 41.4 Q 84.2 37.5 83.7 34.3 Q 83.1 31.1 81.7 27.3 Q 80.3 23.5 77.5 20.2 Q 74.8 16.8 66.7 10.8 Q 58.6 4.8 54 3.8 Q 49.3 2.8 42.6 2.9 Z \" +\n \"M 28.4 15.7 Q 32.3 14.3 38.9 14.3 Q 45.6 14.3 50.6 15.4 Q 55.7 16.6 56.4 18.2 Q 57 19.9 54 22.4 Q 51 25 42.1 30 Q 33.1 35 29 30.5 Q 25 26 24 23.8 Q 23.1 21.6 23.8 19.3 Q 24.6 17.1 28.4 15.7 Z\",\n top_6:\n \"M 44.1 3.9 Q 34 3.9 30.5 5.5 Q 27.1 7 21 13.5 Q 15 20.1 12.9 23.7 Q 10.8 27.3 8.9 36 Q 6.9 44.8 6.3 51.8 Q 5.6 58.8 4.7 60.9 Q 3.8 62.9 3.8 66 Q 3.8 69.1 4.3 70.4 Q 4.9 71.6 7.9 72.8 Q 11 74.1 14.4 74.2 Q 17.8 74.3 21.9 76.2 Q 26 78.1 33.1 78.1 Q 40.2 78.2 42.7 77.2 Q 45.2 76.2 51.7 76.2 Q 58.2 76.2 65.6 75.2 Q 72.9 74.1 77.4 72.6 Q 81.8 71 83.5 68.5 Q 85.1 66.1 84.7 63.4 Q 84.2 60.6 84.7 49.1 Q 85.2 37.6 84.2 32.4 Q 83.2 27.3 81.1 23.7 Q 79.1 20.2 69.8 13 Q 60.5 5.7 57.4 4.8 Q 54.2 3.8 44.1 3.9 Z \" +\n \"M 29.4 15.7 Q 33.3 14.3 39.9 14.3 Q 46.6 14.3 51.6 15.4 Q 56.7 16.6 57.4 18.3 Q 58 20 53.5 23.5 Q 49 27 41.6 31.1 Q 34.2 35.1 30.1 30.6 Q 26 26 25 23.8 Q 24.1 21.6 24.8 19.3 Q 25.6 17.1 29.4 15.7 Z\",\n top_8:\n \"M 41.6 2.9 Q 36.9 2.9 33.1 3.9 Q 29.3 4.8 26.7 6.4 Q 24.1 7.9 18.6 14 Q 13 20 11.4 23.2 Q 9.8 26.3 8.4 31.6 Q 6.9 36.9 5.8 43.8 Q 4.8 50.7 4.3 55.6 Q 3.8 60.4 4 66.5 Q 4.2 72.5 8 73.4 Q 11.7 74.2 17.3 74.3 Q 22.9 74.3 24.4 75.3 Q 26 76.2 42.1 76.2 Q 58.3 76.2 66.6 75.2 Q 74.9 74.1 78.9 73 Q 82.9 71.9 83.5 70.9 Q 84.2 70 84.2 53.3 Q 84.2 36.7 83.2 32.5 Q 82.2 28.3 80.6 25.2 Q 79 22.1 76.4 19.5 Q 73.8 16.9 65.2 10.9 Q 56.7 4.8 51.5 3.8 Q 46.3 2.8 41.6 2.9 Z \" +\n \"M 30.7 14.9 Q 33.4 14.3 38.5 14.3 Q 43.6 14.3 48.9 15.4 Q 54.3 16.5 55.7 17.9 Q 57.2 19.4 47.5 30.4 Q 37.7 41.3 34.8 43.5 Q 32 45.6 27.7 36.8 Q 23.4 27.9 22.8 24.1 Q 22.1 20.3 22.7 19 Q 23.3 17.8 25.6 16.6 Q 28 15.4 30.7 14.9 Z\",\n top_11:\n \"M 64.1 3.5 Q 62.5 3.2 59.8 8.1 Q 57 13 54 16.4 Q 51.1 19.7 47.6 22.8 Q 44.1 25.9 39 29.1 Q 34 32.2 29.8 26.2 Q 25.5 20.2 24.4 17 Q 23.3 13.7 23.2 10.9 Q 23.1 8.2 22.6 7.3 Q 22.1 6.5 21.2 6.7 Q 20.4 6.8 16.9 9.3 Q 13.5 11.9 10.7 16.6 Q 7.8 21.3 5.8 32.9 Q 3.8 44.6 3.4 50.5 Q 3.1 56.4 3.4 58.5 Q 3.8 60.5 5.9 62.7 Q 7.9 64.8 16.3 66.5 Q 24.7 68.2 40 68.2 Q 55.2 68.2 66.1 66.7 Q 76.9 65.1 78.7 64.7 Q 80.5 64.2 81.2 63.7 Q 81.8 63.1 82.5 57.8 Q 83.2 52.5 83.2 39.1 Q 83.2 25.7 82.2 21.5 Q 81.2 17.4 79.2 14.3 Q 77.2 11.2 75.1 9.7 Q 73.1 8.1 69.4 5.9 Q 65.7 3.8 64.1 3.5 Z\",\n top_12:\n \"M 62.1 3.9 Q 61.3 3.9 57.1 7.5 Q 53 11 45.3 16 Q 37.6 20.9 35.2 19.9 Q 32.9 19 27.5 12.8 Q 22.2 6.6 18.5 8.9 Q 14.8 11.3 11.3 17.3 Q 7.8 23.3 6.4 29.1 Q 4.9 34.9 4.3 47.4 Q 3.8 59.9 3.4 60.9 Q 3 61.8 3.8 66.5 Q 4.6 71.1 13.3 74.1 Q 21.9 77.1 23.4 77.1 Q 24.9 77.1 28.2 75 Q 31.4 73 34.3 75 Q 37.2 77.1 42.3 77.1 Q 47.3 77.2 59.6 74.7 Q 71.9 72.1 76.5 70.4 Q 81.1 68.6 81.6 66.9 Q 82.2 65.2 82.2 59.2 Q 82.2 53.2 82.7 51.7 Q 83.2 50.2 83.2 40.9 Q 83.2 31.6 82.2 25.5 Q 81.2 19.4 76.5 14.2 Q 71.9 8.9 67.4 6.4 Q 62.9 3.9 62.1 3.9 Z\",\n top_13:\n \"M 51.5 3.9 Q 39.9 3.9 31.7 6.3 Q 23.5 8.7 18.6 13.2 Q 13.6 17.7 12.7 19.5 Q 11.8 21.3 8.9 32.6 Q 5.9 43.8 4.4 56.6 Q 2.8 69.4 3.5 72.2 Q 4.1 75 4.8 75.6 Q 5.5 76.2 9.9 77.2 Q 14.4 78.1 17.3 77.2 Q 20.2 76.2 24.1 76.3 Q 27.9 76.3 29.4 77.3 Q 31 78.2 33.5 78.1 Q 36 78.1 38.7 77.1 Q 41.5 76.1 46.2 77.6 Q 50.9 79.1 52.7 79.1 Q 54.4 79.2 62.2 78.2 Q 69.9 77.1 76.7 74.6 Q 83.4 72.2 84.3 66.3 Q 85.2 60.4 85.2 51 Q 85.2 41.6 84.2 35.4 Q 83.1 29.1 82.5 27.5 Q 81.9 25.9 79.7 23 Q 77.4 20.1 76.6 16 Q 75.8 11.8 74.3 9.9 Q 72.9 8 67.9 6 Q 63 3.9 51.5 3.9 Z\",\n top_15:\n \"M 51.2 2.9 Q 41.8 2.9 31.4 3.9 Q 21.1 4.9 18.6 5.9 Q 16.1 7 13.5 10.1 Q 11 13.2 11.3 17.5 Q 11.6 21.8 10.2 25 Q 8.9 28.2 6.3 40.4 Q 3.8 52.7 3.9 58.8 Q 3.9 64.9 5.3 67.5 Q 6.6 70 7.2 73.4 Q 7.8 76.8 9.1 79 Q 10.3 81.1 12.3 82.2 Q 14.2 83.2 16.9 83.7 Q 19.7 84.2 23.7 84.2 Q 27.8 84.2 29.3 84.7 Q 30.8 85.2 44.5 85.2 Q 58.3 85.2 63.1 84.7 Q 68 84.1 70.7 83.2 Q 73.5 82.3 75.9 80.3 Q 78.4 78.3 82.3 69.2 Q 86.2 60.1 86.7 51.3 Q 87.2 42.6 86.7 39.4 Q 86.1 36.1 83.7 30.3 Q 81.2 24.5 82.1 20.5 Q 83 16.4 82.6 14.4 Q 82.2 12.4 80.5 10.2 Q 78.9 8 73.4 5.9 Q 67.9 3.9 64.2 3.3 Q 60.5 2.8 51.2 2.9 Z \" +\n \"M 33.2 19.3 Q 40.4 17.3 43.5 17.3 Q 46.6 17.3 49.6 17.9 Q 52.7 18.6 53.4 20.3 Q 54.1 22 45.2 28.6 Q 36.3 35.1 31.6 30.5 Q 26.8 25.9 26.5 23.6 Q 26.1 21.3 33.2 19.3 Z\",\n top_16:\n \"M 42.6 2.9 Q 36.9 2.9 33.5 3.4 Q 30.1 3.9 25.1 5.9 Q 20.1 8 15.4 13.8 Q 10.6 19.6 8.7 23.4 Q 6.8 27.3 5.9 30.6 Q 4.9 33.9 3.8 39.8 Q 2.8 45.7 2.9 57.6 Q 2.9 69.6 6 72.3 Q 9 75 13.5 77.5 Q 17.9 80 19.8 79.6 Q 21.6 79.2 24.8 76.8 Q 28 74.3 31.1 74.3 Q 34.2 74.2 38 77.5 Q 41.8 80.8 43 80.9 Q 44.1 81 55.7 76.7 Q 67.2 72.4 71.8 71.8 Q 76.4 71.2 77.8 69.4 Q 79.2 67.5 80.2 64.3 Q 81.1 61.1 81.6 57.7 Q 82.2 54.4 82.2 44.5 Q 82.2 34.6 81.7 31.4 Q 81.1 28.2 79.6 25.1 Q 78 22.1 73.9 18 Q 69.8 13.9 63.2 9.3 Q 56.6 4.8 52.5 3.8 Q 48.3 2.8 42.6 2.9 Z \" +\n \"M 29 13.2 Q 33.2 11.3 37.5 11.3 Q 41.7 11.2 44.3 11.8 Q 47 12.4 50.7 14.3 Q 54.4 16.2 55.2 18 Q 56.1 19.8 52.5 22.9 Q 49 26 40.2 30.5 Q 31.3 35.1 27.2 31.1 Q 23 27.1 21.6 24.2 Q 20.1 21.3 22.5 18.2 Q 24.8 15 29 13.2 Z\",\n top_17:\n \"M 43.1 3.9 Q 33 3.9 29.5 5.5 Q 26.1 7 20.5 13 Q 14.9 19.1 12.4 23.2 Q 9.8 27.3 7.9 36.6 Q 5.9 45.8 4.7 58.8 Q 3.4 71.9 9.2 74.5 Q 15 77.1 19.6 77.1 Q 24.3 77.2 27.9 76.2 Q 31.6 75.1 36.8 77.1 Q 42.1 79.1 46.7 79.6 Q 51.4 80.2 59.6 78.2 Q 67.8 76.2 73.9 73.6 Q 79.9 71 81.5 69.3 Q 83.1 67.6 83.7 54.7 Q 84.2 41.7 83.7 36.3 Q 83.1 31 82.2 28.2 Q 81.2 25.4 79.7 22.8 Q 78.1 20.2 68.8 13 Q 59.5 5.7 56.4 4.8 Q 53.2 3.8 43.1 3.9 Z \" +\n \"M 28.4 15.7 Q 32.3 14.3 38.9 14.3 Q 45.6 14.3 50.6 15.4 Q 55.7 16.6 56.4 18.3 Q 57 20 52.5 23.5 Q 48 27 40.6 31.1 Q 33.2 35.1 29.1 30.6 Q 25 26 24 23.8 Q 23.1 21.6 23.8 19.3 Q 24.6 17.1 28.4 15.7 Z\",\n top_18:\n \"M 66.6 4 Q 63.5 3.2 57.1 14.3 Q 50.8 25.4 48 26.7 Q 45.3 27.9 43.6 30.7 Q 41.9 33.6 38.7 30.7 Q 35.5 27.8 33.6 27.4 Q 31.7 27 28.4 29.1 Q 25.1 31.1 23.6 26.4 Q 22.2 21.7 22.8 15.5 Q 23.4 9.3 24.1 6.9 Q 24.9 4.5 24.9 4.3 Q 24.8 4.1 24.3 4.1 Q 23.9 4 21.3 5.3 Q 18.7 6.6 16.8 8.4 Q 14.9 10.1 11.9 15.7 Q 8.8 21.3 6.4 31.5 Q 3.9 41.8 4.4 49.2 Q 4.8 56.6 4.3 59.8 Q 3.9 63.1 4.5 64 Q 5.1 65 7.1 65.5 Q 9 66.1 16 66.7 Q 23 67.4 25.6 69.2 Q 28.2 71.1 30.7 71.1 Q 33.1 71.2 36.1 69.7 Q 39 68.2 55.2 67.7 Q 71.3 67.2 76.6 66.7 Q 81.8 66.1 82.6 65.7 Q 83.3 65.2 83.7 64 Q 84 62.7 82.4 58.9 Q 80.9 55.1 83.5 52.4 Q 86.1 49.6 86.1 44.7 Q 86.2 39.7 85.7 34.4 Q 85.1 29.1 83.7 23.2 Q 82.2 17.4 79 13.1 Q 75.7 8.8 72.7 6.8 Q 69.6 4.8 66.6 4 Z\",\n top_20:\n \"M 64.8 4.5 Q 61 3.3 60.9 3.3 Q 60.9 3.4 60.8 7.6 Q 60.7 11.8 58.8 15.9 Q 57 20 54.5 22.5 Q 52 25 46.9 27.3 Q 41.8 29.7 35.9 29.7 Q 30 29.7 27 27.4 Q 24 25.1 23.1 23 Q 22.2 20.9 22.2 16.5 Q 22.2 12.2 23.5 8.6 Q 24.8 5.1 24.9 5.2 Q 25 5.3 22.9 6.2 Q 20.9 7.1 17.4 9.6 Q 13.9 12.2 11.8 15.8 Q 9.8 19.3 7.9 26.6 Q 5.9 33.8 4.9 42.2 Q 3.8 50.6 4.1 57 Q 4.4 63.5 9.1 64.8 Q 13.9 66.1 23.9 66.7 Q 33.8 67.2 35.3 67.7 Q 36.8 68.2 48.5 67.7 Q 60.2 67.2 71.1 65.3 Q 82 63.4 82.6 60.9 Q 83.2 58.3 83.2 50.8 Q 83.2 43.2 83.7 41.7 Q 84.2 40.3 83.7 32 Q 83.2 23.8 82.7 22.1 Q 82.2 20.4 79 15.6 Q 75.7 10.8 72.1 8.3 Q 68.6 5.8 64.8 4.5 Z\",\n top_21:\n \"M 64.8 4.5 Q 61 3.3 60.9 3.3 Q 60.9 3.4 60.8 7.6 Q 60.7 11.8 58.8 15.9 Q 57 20 54.5 22.5 Q 52 25 46.9 27.3 Q 41.8 29.7 36 29.7 Q 30.2 29.7 27.1 28 Q 24.1 26.3 22.6 22.5 Q 21.2 18.7 21.8 14.4 Q 22.4 10.1 23.6 7.8 Q 24.8 5.5 24.8 5.4 Q 24.7 5.4 22.8 6.2 Q 20.9 7.1 17.4 9.6 Q 13.9 12.2 11.8 15.8 Q 9.8 19.3 7.9 26.6 Q 5.9 33.8 4.9 42.2 Q 3.8 50.6 4.1 57 Q 4.4 63.5 9.1 64.8 Q 13.9 66.1 23.9 66.7 Q 33.8 67.2 35.3 67.7 Q 36.8 68.2 48.5 67.7 Q 60.2 67.2 71.1 65.3 Q 82 63.4 82.6 60.9 Q 83.2 58.3 83.2 50.8 Q 83.2 43.2 83.7 41.7 Q 84.2 40.3 83.7 32 Q 83.2 23.8 82.7 22.1 Q 82.2 20.4 79 15.6 Q 75.7 10.8 72.1 8.3 Q 68.6 5.8 64.8 4.5 Z\",\n top_22:\n \"M 66.4 5.7 Q 60.9 3.4 60.9 3.4 Q 60.9 3.4 60.8 8.1 Q 60.7 12.8 58.8 16.4 Q 57 20.1 54 23 Q 51 26 47 27.8 Q 42.9 29.6 39.2 30.2 Q 35.5 30.8 32.2 30.2 Q 28.9 29.6 25.9 27.3 Q 22.9 25 22 21.3 Q 21.2 17.6 21.8 13.8 Q 22.4 10 23.6 7.8 Q 24.8 5.5 24.8 5.5 Q 24.8 5.5 21.4 7.2 Q 18.1 9 15.1 12 Q 12 15 9.9 20.1 Q 7.9 25.1 6.3 33.4 Q 4.8 41.7 4.3 46.1 Q 3.8 50.5 3.8 61.3 Q 3.8 72.1 4 72.6 Q 4.3 73 7.2 74.1 Q 10.1 75.1 15.8 76.2 Q 21.6 77.2 34.4 77.7 Q 47.2 78.2 48.7 77.7 Q 50.2 77.2 56.3 77.2 Q 62.4 77.2 65.6 76.7 Q 68.9 76.1 70.8 75.7 Q 72.7 75.2 76.9 73 Q 81 70.8 81.6 69 Q 82.2 67.2 82.7 55.2 Q 83.2 43.2 83.7 41.7 Q 84.2 40.3 83.6 31.7 Q 83.1 23.1 81.5 19.5 Q 80 16 76 12 Q 72 8 66.4 5.7 Z\",\n top_23:\n \"M 66.4 5.7 Q 60.9 3.4 60.9 3.4 Q 60.9 3.4 60.8 8.1 Q 60.7 12.8 58.8 16.4 Q 57 20.1 54 23 Q 51 26 47 27.8 Q 42.9 29.6 39.2 30.2 Q 35.5 30.8 32.2 30.2 Q 28.9 29.6 25.9 27.3 Q 22.9 25 22 21.3 Q 21.2 17.6 21.8 13.8 Q 22.4 10 23.6 7.8 Q 24.8 5.5 24.8 5.5 Q 24.8 5.5 21.4 7.2 Q 18.1 9 15.1 12 Q 12 15 9.9 20.1 Q 7.9 25.1 6.3 33.4 Q 4.8 41.7 4.3 46.1 Q 3.8 50.5 3.8 61.3 Q 3.8 72.1 4 72.6 Q 4.3 73 7.2 74.1 Q 10.1 75.1 15.8 76.2 Q 21.6 77.2 34.4 77.7 Q 47.2 78.2 48.7 77.7 Q 50.2 77.2 56.3 77.2 Q 62.4 77.2 65.6 76.7 Q 68.9 76.1 70.8 75.7 Q 72.7 75.2 76.9 73 Q 81 70.8 81.6 69 Q 82.2 67.2 82.7 55.2 Q 83.2 43.2 83.7 41.7 Q 84.2 40.3 83.6 31.7 Q 83.1 23.1 81.5 19.5 Q 80 16 76 12 Q 72 8 66.4 5.7 Z\",\n top_24:\n \"M 66.4 5.7 Q 60.9 3.4 60.9 3.4 Q 60.9 3.4 60.8 8.1 Q 60.7 12.8 58.8 16.4 Q 57 20.1 54 23 Q 51 26 47 27.8 Q 42.9 29.6 39.2 30.2 Q 35.5 30.8 32.2 30.2 Q 28.9 29.6 25.9 27.3 Q 22.9 25 22 21.3 Q 21.2 17.6 21.8 13.8 Q 22.4 10 23.6 7.8 Q 24.8 5.5 24.8 5.5 Q 24.8 5.5 21.4 7.2 Q 18.1 9 15.1 12 Q 12 15 9.9 20.1 Q 7.9 25.1 6.3 33.4 Q 4.8 41.7 4.3 46.1 Q 3.8 50.5 3.8 61.3 Q 3.8 72.1 4 72.6 Q 4.3 73 7.2 74.1 Q 10.1 75.1 15.8 76.2 Q 21.6 77.2 34.4 77.7 Q 47.2 78.2 48.7 77.7 Q 50.2 77.2 56.3 77.2 Q 62.4 77.2 65.6 76.7 Q 68.9 76.1 70.8 75.7 Q 72.7 75.2 76.9 73 Q 81 70.8 81.6 69 Q 82.2 67.2 82.7 55.2 Q 83.2 43.2 83.7 41.7 Q 84.2 40.3 83.6 31.7 Q 83.1 23.1 81.5 19.5 Q 80 16 76 12 Q 72 8 66.4 5.7 Z\",\n top_27:\n \"M 37.2 2.9 Q 29.9 2.9 22.5 3.9 Q 15.2 4.9 12.1 6.4 Q 9.1 8 7.5 9.5 Q 6 11.1 4.9 13.1 Q 3.9 15.1 3.9 19.5 Q 3.9 23.9 5.8 27.1 Q 7.8 30.3 6.3 39.9 Q 4.8 49.6 4.8 58.7 Q 4.8 67.7 4.3 69.6 Q 3.9 71.5 4.3 72.8 Q 4.7 74.2 5.5 74.7 Q 6.3 75.2 11 76.2 Q 15.7 77.2 22 77.2 Q 28.2 77.2 29.8 76.7 Q 31.4 76.2 34.1 77.2 Q 36.8 78.2 48.1 78.2 Q 59.3 78.2 65.1 77.2 Q 70.9 76.1 76.9 73.6 Q 82.9 71 83 70.9 Q 83.1 70.7 83.1 66 Q 83.2 61.2 83.7 59.7 Q 84.2 58.2 84.2 47.4 Q 84.2 36.7 83.7 33.9 Q 83.1 31.2 81.7 27.8 Q 80.2 24.5 80.7 22.9 Q 81.2 21.3 81 17.5 Q 80.7 13.8 78.5 11.7 Q 76.3 9.6 73.5 8.2 Q 70.7 6.8 67 5.9 Q 63.2 4.9 53.8 3.9 Q 44.5 2.8 37.2 2.9 Z\",\n top_28:\n \"M 24.6 4.3 Q 24.6 4.3 21.8 5.9 Q 19 7.6 17.4 7.9 Q 15.9 8.2 14.4 9.6 Q 13 11.1 10.4 16.1 Q 7.8 21.2 6.4 28 Q 4.9 34.8 4.9 40.3 Q 4.8 45.8 4.3 47.3 Q 3.8 48.8 3.8 56 Q 3.8 63.2 4.2 64.1 Q 4.5 65.1 5.8 65.6 Q 7 66.2 16.4 66.8 Q 25.8 67.3 30.3 69.3 Q 34.8 71.2 35.9 71.1 Q 37 71.1 40.6 69.7 Q 44.1 68.2 55.7 67.7 Q 67.2 67.2 75 66 Q 82.8 64.9 83 64.5 Q 83.1 64.2 82.7 60.3 Q 82.2 56.5 82.2 50.4 Q 82.2 44.2 82.7 42.7 Q 83.2 41.3 82.7 32.6 Q 82.2 23.9 80.8 20.2 Q 79.3 16.5 77.7 14.3 Q 76.1 12.2 73.3 9.4 Q 70.4 6.7 66.7 5.3 Q 63 3.9 62.2 3.9 Q 61.3 3.9 61 7.4 Q 60.6 10.8 58.3 15.9 Q 56 21 53.5 23.5 Q 51.1 26 48.3 27.4 Q 45.5 28.8 44.9 30.7 Q 44.4 32.7 42.8 33.3 Q 41.3 33.9 39.4 32.4 Q 37.6 30.8 33.2 30.2 Q 28.8 29.6 26.2 27 Q 23.6 24.4 22.9 22.6 Q 22.2 20.8 22.2 15.5 Q 22.2 10.1 23.4 7.2 Q 24.6 4.3 24.6 4.3 Z\",\n top_30:\n \"M 41.6 2.9 Q 36.9 2.9 33.1 3.9 Q 29.3 4.8 26.7 6.4 Q 24.1 7.9 18.6 14 Q 13 20 11.4 23.2 Q 9.8 26.3 8.4 31.6 Q 6.9 36.9 5.8 43.8 Q 4.8 50.7 4.3 55.6 Q 3.8 60.4 4 66.5 Q 4.2 72.5 8 73.4 Q 11.7 74.2 17.3 74.3 Q 22.9 74.3 24.4 75.3 Q 26 76.2 42.1 76.2 Q 58.3 76.2 66.6 75.2 Q 74.9 74.1 78.9 73 Q 82.9 71.9 83.5 70.9 Q 84.2 70 84.2 52.8 Q 84.2 35.7 83.7 32.9 Q 83.1 30.2 81.5 26.6 Q 80 23 77.9 20.4 Q 75.8 17.8 66.2 11.3 Q 56.7 4.8 51.5 3.8 Q 46.3 2.8 41.6 2.9 Z \" +\n \"M 30.7 14.9 Q 33.4 14.3 38.5 14.3 Q 43.6 14.3 48.9 15.4 Q 54.3 16.5 55.7 17.9 Q 57.2 19.4 47.5 30.4 Q 37.7 41.3 34.8 43.5 Q 32 45.6 27.7 36.8 Q 23.4 27.9 22.8 24.1 Q 22.1 20.3 22.7 19 Q 23.3 17.8 25.6 16.6 Q 28 15.4 30.7 14.9 Z\",\n top_31:\n \"M 51.2 2.9 Q 41.8 2.9 31.4 3.9 Q 21.1 4.9 18.6 5.9 Q 16.1 7 13.5 10.1 Q 11 13.2 11.3 17.5 Q 11.6 21.8 10.2 25 Q 8.9 28.2 6.3 40.4 Q 3.8 52.7 3.9 58.8 Q 3.9 64.9 5.3 67.5 Q 6.6 70 7.2 73.4 Q 7.8 76.8 9.1 79 Q 10.3 81.1 12.3 82.2 Q 14.2 83.2 16.9 83.7 Q 19.7 84.2 23.7 84.2 Q 27.8 84.2 29.3 84.7 Q 30.8 85.2 44.5 85.2 Q 58.3 85.2 63.1 84.7 Q 68 84.1 70.7 83.2 Q 73.5 82.3 75.9 80.3 Q 78.4 78.3 82.3 69.2 Q 86.2 60.1 86.7 51.3 Q 87.2 42.6 86.7 39.4 Q 86.1 36.1 83.7 30.3 Q 81.2 24.5 82.1 20.5 Q 83 16.4 82.6 14.4 Q 82.2 12.4 80.5 10.2 Q 78.9 8 73.4 5.9 Q 67.9 3.9 64.2 3.3 Q 60.5 2.8 51.2 2.9 Z \" +\n \"M 33.2 19.3 Q 40.4 17.3 43.5 17.3 Q 46.6 17.3 49.6 17.9 Q 52.7 18.6 53.4 20.3 Q 54.1 22 45.2 28.6 Q 36.3 35.1 31.6 30.5 Q 26.8 25.9 26.5 23.6 Q 26.1 21.3 33.2 19.3 Z\",\n top_32:\n \"M 51.5 3.9 Q 39.9 3.9 31.7 6.3 Q 23.5 8.7 18.6 13.2 Q 13.6 17.7 12.7 19.5 Q 11.8 21.3 8.9 32.6 Q 5.9 43.8 4.4 56.6 Q 2.8 69.4 3.5 72.2 Q 4.1 75 4.8 75.6 Q 5.5 76.2 9.9 77.2 Q 14.4 78.1 17.3 77.2 Q 20.2 76.2 24.1 76.3 Q 27.9 76.3 29.4 77.3 Q 31 78.2 33.5 78.1 Q 36 78.1 38.7 77.1 Q 41.5 76.1 46.2 77.6 Q 50.9 79.1 52.7 79.1 Q 54.4 79.2 62.2 78.2 Q 69.9 77.1 76.7 74.6 Q 83.4 72.2 84.3 66.3 Q 85.2 60.4 85.2 51 Q 85.2 41.6 84.2 35.4 Q 83.1 29.1 82.5 27.5 Q 81.9 25.9 79.7 23 Q 77.4 20.1 76.6 16 Q 75.8 11.8 74.3 9.9 Q 72.9 8 67.9 6 Q 63 3.9 51.5 3.9 Z\",\n top_36:\n \"M 64.8 4.5 Q 61 3.3 60.9 3.3 Q 60.9 3.4 60.8 7.6 Q 60.7 11.8 58.8 15.9 Q 57 20 54.5 22.5 Q 52 25 46.9 27.3 Q 41.8 29.7 35.9 29.7 Q 30 29.7 27 27.4 Q 24 25.1 23.1 23 Q 22.2 20.9 22.2 16.5 Q 22.2 12.2 23.5 8.6 Q 24.8 5.1 24.9 5.2 Q 25 5.3 22.9 6.2 Q 20.9 7.1 17.4 9.6 Q 13.9 12.2 11.8 15.8 Q 9.8 19.3 7.9 26.6 Q 5.9 33.8 4.9 42.2 Q 3.8 50.6 4.1 57 Q 4.4 63.5 9.1 64.8 Q 13.9 66.1 23.9 66.7 Q 33.8 67.2 35.3 67.7 Q 36.8 68.2 48.5 67.7 Q 60.2 67.2 71.1 65.3 Q 82 63.4 82.6 60.9 Q 83.2 58.3 83.2 50.8 Q 83.2 43.2 83.7 41.7 Q 84.2 40.3 83.7 32 Q 83.2 23.8 82.7 22.1 Q 82.2 20.4 79 15.6 Q 75.7 10.8 72.1 8.3 Q 68.6 5.8 64.8 4.5 Z\",\n top_37:\n \"M 66.4 5.7 Q 60.9 3.4 60.9 3.4 Q 60.9 3.4 60.8 8.1 Q 60.7 12.8 58.8 16.4 Q 57 20.1 54 23 Q 51 26 47 27.8 Q 42.9 29.6 39.2 30.2 Q 35.5 30.8 32.2 30.2 Q 28.9 29.6 25.9 27.3 Q 22.9 25 22 21.3 Q 21.2 17.6 21.8 13.8 Q 22.4 10 23.6 7.8 Q 24.8 5.5 24.8 5.5 Q 24.8 5.5 21.4 7.2 Q 18.1 9 15.1 12 Q 12 15 9.9 20.1 Q 7.9 25.1 6.3 33.4 Q 4.8 41.7 4.3 46.1 Q 3.8 50.5 3.8 61.3 Q 3.8 72.1 4 72.6 Q 4.3 73 7.2 74.1 Q 10.1 75.1 15.8 76.2 Q 21.6 77.2 34.4 77.7 Q 47.2 78.2 48.7 77.7 Q 50.2 77.2 56.3 77.2 Q 62.4 77.2 65.6 76.7 Q 68.9 76.1 70.8 75.7 Q 72.7 75.2 76.9 73 Q 81 70.8 81.6 69 Q 82.2 67.2 82.7 55.2 Q 83.2 43.2 83.7 41.7 Q 84.2 40.3 83.6 31.7 Q 83.1 23.1 81.5 19.5 Q 80 16 76 12 Q 72 8 66.4 5.7 Z\",\n top_38:\n \"M 37.2 2.9 Q 29.9 2.9 22.5 3.9 Q 15.2 4.9 12.1 6.4 Q 9.1 8 7.5 9.5 Q 6 11.1 4.9 13.1 Q 3.9 15.1 3.9 19.5 Q 3.9 23.9 5.8 27.1 Q 7.8 30.3 6.3 39.9 Q 4.8 49.6 4.8 58.7 Q 4.8 67.7 4.3 69.6 Q 3.9 71.5 4.3 72.8 Q 4.7 74.2 5.5 74.7 Q 6.3 75.2 11 76.2 Q 15.7 77.2 22 77.2 Q 28.2 77.2 29.8 76.7 Q 31.4 76.2 34.1 77.2 Q 36.8 78.2 48.1 78.2 Q 59.3 78.2 65.1 77.2 Q 70.9 76.1 76.9 73.6 Q 82.9 71 83 70.9 Q 83.1 70.7 83.1 66 Q 83.2 61.2 83.7 59.7 Q 84.2 58.2 84.2 47.4 Q 84.2 36.7 83.7 33.9 Q 83.1 31.2 81.7 27.8 Q 80.2 24.5 80.7 22.9 Q 81.2 21.3 81 17.5 Q 80.7 13.8 78.5 11.7 Q 76.3 9.6 73.5 8.2 Q 70.7 6.8 67 5.9 Q 63.2 4.9 53.8 3.9 Q 44.5 2.8 37.2 2.9 Z\",\n top_39:\n \"M 43.1 3.9 Q 33 3.9 29.5 5.5 Q 26.1 7 20.5 13 Q 14.9 19.1 12.4 23.2 Q 9.8 27.3 7.9 36.6 Q 5.9 45.8 4.7 58.8 Q 3.4 71.9 9.2 74.5 Q 15 77.1 19.6 77.1 Q 24.3 77.2 27.9 76.2 Q 31.6 75.1 36.8 77.1 Q 42.1 79.1 46.7 79.6 Q 51.4 80.2 59.6 78.2 Q 67.8 76.2 73.9 73.6 Q 79.9 71 81.5 69.3 Q 83.1 67.6 83.7 54.7 Q 84.2 41.7 83.7 36.3 Q 83.1 31 82.2 28.2 Q 81.2 25.4 79.7 22.8 Q 78.1 20.2 68.8 13 Q 59.5 5.7 56.4 4.8 Q 53.2 3.8 43.1 3.9 Z \" +\n \"M 28.4 15.7 Q 32.3 14.3 38.9 14.3 Q 45.6 14.3 50.6 15.4 Q 55.7 16.6 56.4 18.3 Q 57 20 52.5 23.5 Q 48 27 40.6 31.1 Q 33.2 35.1 29.1 30.6 Q 25 26 24 23.8 Q 23.1 21.6 23.8 19.3 Q 24.6 17.1 28.4 15.7 Z\",\n top_41:\n \"M 50.2 2.9 Q 40.8 2.9 30.4 3.9 Q 20.1 4.9 17.6 6 Q 15 7 12.9 9.3 Q 10.7 11.6 10.2 12.7 Q 9.8 13.9 9.7 17.8 Q 9.7 21.7 7.8 26.4 Q 5.9 31.1 5.3 34.8 Q 4.8 38.5 5.8 46.6 Q 6.8 54.6 4.8 63.1 Q 2.8 71.5 3.3 73.5 Q 3.8 75.5 6.2 77.9 Q 8.6 80.3 13.2 81.8 Q 17.8 83.2 30.5 83.7 Q 43.2 84.2 44.7 83.7 Q 46.2 83.2 53.3 83.2 Q 60.3 83.2 68.5 81.7 Q 76.7 80.2 80.4 78 Q 84.1 75.7 85.6 72.4 Q 87.2 69.1 87.1 65 Q 87.1 61 85.6 57.3 Q 84.2 53.6 85.7 50.2 Q 87.1 46.9 87.7 43.2 Q 88.2 39.6 87.2 34.9 Q 86.2 30.3 83.7 26 Q 81.3 21.7 81.7 18.7 Q 82 15.7 81.6 14.1 Q 81.2 12.4 79.5 10.2 Q 77.9 8 72.4 5.9 Q 66.9 3.9 63.2 3.3 Q 59.5 2.8 50.2 2.9 Z \" +\n \"M 32.2 19.3 Q 39.4 17.3 42.5 17.3 Q 45.6 17.3 48.6 17.9 Q 51.7 18.6 52.4 20.3 Q 53.1 22 44.2 28.6 Q 35.3 35.1 30.6 30.5 Q 25.8 25.9 25.5 23.6 Q 25.1 21.3 32.2 19.3 Z\",\n top_42:\n \"M 66.4 5.7 Q 60.9 3.4 60.9 3.4 Q 60.9 3.4 60.8 8.1 Q 60.7 12.8 58.8 16.4 Q 57 20.1 54 23 Q 51 26 47 27.8 Q 42.9 29.6 39.2 30.2 Q 35.5 30.8 32.2 30.2 Q 28.9 29.6 25.9 27.3 Q 22.9 25 22 21.3 Q 21.2 17.6 21.8 13.8 Q 22.4 10 23.6 7.8 Q 24.8 5.5 24.8 5.5 Q 24.8 5.5 21.4 7.2 Q 18.1 9 15.1 12 Q 12 15 9.9 20.1 Q 7.9 25.1 6.3 33.4 Q 4.8 41.7 4.3 46.1 Q 3.8 50.5 3.8 61.3 Q 3.8 72.1 4 72.6 Q 4.3 73 7.2 74.1 Q 10.1 75.1 15.8 76.2 Q 21.6 77.2 34.4 77.7 Q 47.2 78.2 48.7 77.7 Q 50.2 77.2 56.3 77.2 Q 62.4 77.2 65.6 76.7 Q 68.9 76.1 70.8 75.7 Q 72.7 75.2 76.9 73 Q 81 70.8 81.6 69 Q 82.2 67.2 82.7 55.2 Q 83.2 43.2 83.7 41.7 Q 84.2 40.3 83.6 31.7 Q 83.1 23.1 81.5 19.5 Q 80 16 76 12 Q 72 8 66.4 5.7 Z\",\n top_43:\n \"M 43.1 3.9 Q 33 3.9 29.5 5.5 Q 26.1 7 20.5 13 Q 14.9 19.1 12.4 23.2 Q 9.8 27.3 7.9 36.6 Q 5.9 45.8 4.6 59 Q 3.4 72.3 3.9 72.7 Q 4.4 73.1 18.9 74.7 Q 33.5 76.2 52.4 75.7 Q 71.3 75.2 76.1 74.7 Q 80.8 74.1 81.7 73.6 Q 82.6 73.2 82.9 72.6 Q 83.2 72.1 83.2 58.7 Q 83.2 45.3 83.7 41.4 Q 84.2 37.6 82.7 31.5 Q 81.2 25.3 79.7 22.8 Q 78.1 20.2 68.8 13 Q 59.5 5.7 56.4 4.8 Q 53.2 3.8 43.1 3.9 Z \" +\n \"M 28.4 15.7 Q 32.3 14.3 38.9 14.3 Q 45.6 14.3 50.6 15.4 Q 55.7 16.6 56.4 18.3 Q 57 20 52.5 23.5 Q 48 27 40.6 31.1 Q 33.2 35.1 29.1 30.6 Q 25 26 24 23.8 Q 23.1 21.6 23.8 19.3 Q 24.6 17.1 28.4 15.7 Z\",\n top_44:\n \"M 64.1 3.5 Q 62.5 3.2 59.8 8.1 Q 57 13 54 16.4 Q 51.1 19.7 47.6 22.8 Q 44.1 25.9 39 29.1 Q 34 32.2 29.8 26.2 Q 25.5 20.2 24.4 17 Q 23.3 13.7 23.2 10.9 Q 23.1 8.2 22.6 7.3 Q 22.1 6.5 21.2 6.7 Q 20.4 6.8 16.9 9.3 Q 13.5 11.9 10.7 16.6 Q 7.8 21.3 5.8 32.9 Q 3.8 44.6 3.4 50.5 Q 3.1 56.4 3.4 58.5 Q 3.8 60.5 5.9 62.7 Q 7.9 64.8 16.3 66.5 Q 24.7 68.2 40 68.2 Q 55.2 68.2 66.1 66.7 Q 76.9 65.1 78.7 64.7 Q 80.5 64.2 81.2 63.7 Q 81.8 63.1 82.5 57.8 Q 83.2 52.5 83.2 39.1 Q 83.2 25.7 82.2 21.5 Q 81.2 17.4 79.2 14.3 Q 77.2 11.2 75.1 9.7 Q 73.1 8.1 69.4 5.9 Q 65.7 3.8 64.1 3.5 Z\",\n top_45:\n \"M 42.6 2.9 Q 35.9 2.9 32.1 3.9 Q 28.4 4.8 26.8 5.8 Q 25.2 6.9 20.1 12.9 Q 15 19 12.4 23.1 Q 9.8 27.3 7.3 40.5 Q 4.8 53.6 3.9 66.7 Q 3 79.7 3.3 80 Q 3.6 80.3 6.8 81.2 Q 10 82.1 18.2 83.2 Q 26.5 84.2 43.4 84.2 Q 60.3 84.2 70.9 82.2 Q 81.4 80.2 82.3 79.1 Q 83.2 77.9 83.2 61.6 Q 83.2 45.3 83.7 41.4 Q 84.2 37.5 83.7 34.3 Q 83.1 31.1 81.7 27.3 Q 80.3 23.5 77.5 20.2 Q 74.8 16.8 66.7 10.8 Q 58.6 4.8 54 3.8 Q 49.3 2.8 42.6 2.9 Z \" +\n \"M 28.4 15.7 Q 32.3 14.3 38.9 14.3 Q 45.6 14.3 50.6 15.4 Q 55.7 16.6 56.4 18.3 Q 57 20 52.5 23.5 Q 48 27 40.6 31.1 Q 33.2 35.1 29.1 30.6 Q 25 26 24 23.8 Q 23.1 21.6 23.8 19.3 Q 24.6 17.1 28.4 15.7 Z\",\n top_46:\n \"M 64.8 4.5 Q 61 3.3 60.9 3.3 Q 60.9 3.4 60.8 7.6 Q 60.7 11.8 58.8 15.9 Q 57 20.1 53 23.6 Q 48.9 27.1 45.3 28.4 Q 41.7 29.7 35.9 29.7 Q 30.2 29.7 27.1 28 Q 24.1 26.3 22.6 22.5 Q 21.2 18.7 21.8 14.4 Q 22.4 10.1 23.6 7.8 Q 24.8 5.5 24.8 5.5 Q 24.8 5.6 19.3 8.9 Q 13.8 12.3 11.8 15.8 Q 9.8 19.3 7.4 28.6 Q 4.9 37.9 4.4 41.7 Q 3.8 45.5 3.8 54.3 Q 3.8 63.1 4 63.5 Q 4.3 64 9.1 65.6 Q 14 67.1 17.8 67.7 Q 21.6 68.2 34.4 68.7 Q 47.2 69.2 48.7 68.7 Q 50.2 68.2 56.3 68.2 Q 62.4 68.2 65.6 67.7 Q 68.9 67.1 70.8 66.7 Q 72.7 66.2 76.8 64 Q 80.9 61.8 82.1 56.6 Q 83.2 51.3 83.2 45.8 Q 83.2 40.2 83.7 38.8 Q 84.2 37.3 83.2 28.8 Q 82.1 20.3 78.9 15.5 Q 75.7 10.8 72.1 8.3 Q 68.6 5.8 64.8 4.5 Z\",\n top_47:\n \"M 44.1 3.9 Q 34 3.9 30.5 5.5 Q 27.1 7 21 13.5 Q 15 20.1 12.9 23.7 Q 10.8 27.3 8.9 36 Q 6.9 44.8 6.3 51.8 Q 5.6 58.8 4.7 60.9 Q 3.8 62.9 3.8 66 Q 3.8 69.1 4.3 70.4 Q 4.9 71.6 7.9 72.8 Q 11 74.1 14.4 74.2 Q 17.8 74.3 21.9 76.2 Q 26 78.1 33.1 78.1 Q 40.2 78.2 42.7 77.2 Q 45.2 76.2 51.7 76.2 Q 58.2 76.2 65.6 75.2 Q 72.9 74.1 77.4 72.6 Q 81.8 71 83.5 68.5 Q 85.1 66.1 84.7 63.4 Q 84.2 60.6 84.7 49.1 Q 85.2 37.6 84.2 32.4 Q 83.2 27.3 81.1 23.7 Q 79.1 20.2 69.8 13 Q 60.5 5.7 57.4 4.8 Q 54.2 3.8 44.1 3.9 Z \" +\n \"M 29.4 15.7 Q 33.3 14.3 39.9 14.3 Q 46.6 14.3 51.6 15.4 Q 56.7 16.6 57.4 18.3 Q 58 20 53.5 23.5 Q 49 27 41.6 31.1 Q 34.2 35.1 30.1 30.6 Q 26 26 25 23.8 Q 24.1 21.6 24.8 19.3 Q 25.6 17.1 29.4 15.7 Z\",\n top_48:\n \"M 65.4 4.1 Q 63.2 3.4 60 7.8 Q 56.9 12.2 46.6 21.8 Q 36.4 31.3 28.9 24.3 Q 21.3 17.2 21.3 13.2 Q 21.2 9.2 21.6 7.9 Q 22.1 6.6 21.9 6.3 Q 21.7 6 21.8 6 Q 21.8 6 20.2 6.8 Q 18.6 7.7 15.7 9.9 Q 12.9 12.2 11.3 14.8 Q 9.8 17.4 8.8 20.2 Q 7.9 23 6.4 29.9 Q 4.9 36.9 4.3 46.9 Q 3.8 56.8 3.3 58.3 Q 2.8 59.8 2.9 61.3 Q 2.9 62.8 3.3 63.6 Q 3.8 64.3 11.2 65.7 Q 18.7 67.2 22.4 67.2 Q 26.2 67.2 27.9 66.6 Q 29.6 66.1 32.2 67.1 Q 34.8 68.2 44.6 68.2 Q 54.3 68.2 66.1 66.7 Q 77.9 65.1 79.9 64.4 Q 81.9 63.7 82.6 60 Q 83.2 56.3 83.1 39.7 Q 83.1 23.1 80.6 18.1 Q 78 13.1 75.5 10.5 Q 72.9 7.9 70.3 6.4 Q 67.6 4.8 65.4 4.1 Z\",\n top_49:\n \"M 62.1 3.9 Q 61.3 3.9 57.1 7.5 Q 53 11 45.3 16 Q 37.6 20.9 35.2 19.9 Q 32.9 19 27.5 12.8 Q 22.2 6.6 18.5 8.9 Q 14.8 11.3 11.3 17.3 Q 7.8 23.3 6.4 29.1 Q 4.9 34.9 4.3 47.4 Q 3.8 59.9 3.4 60.9 Q 3 61.8 3.8 66.5 Q 4.6 71.1 13.3 74.1 Q 21.9 77.1 23.4 77.1 Q 24.9 77.1 28.2 75 Q 31.4 73 34.3 75 Q 37.2 77.1 42.3 77.1 Q 47.3 77.2 59.6 74.7 Q 71.9 72.1 76.5 70.4 Q 81.1 68.6 81.6 66.9 Q 82.2 65.2 82.2 59.2 Q 82.2 53.2 82.7 51.7 Q 83.2 50.2 83.2 40.9 Q 83.2 31.6 82.2 25.5 Q 81.2 19.4 76.5 14.2 Q 71.9 8.9 67.4 6.4 Q 62.9 3.9 62.1 3.9 Z\",\n top_50:\n \"M 66.6 4 Q 63.5 3.2 57.1 14.3 Q 50.8 25.4 48 26.7 Q 45.3 27.9 43.6 30.7 Q 41.9 33.6 38.7 30.7 Q 35.5 27.8 33.6 27.4 Q 31.7 27 28.4 29.1 Q 25.1 31.1 23.6 26.4 Q 22.2 21.7 22.8 15.5 Q 23.4 9.3 24.1 6.9 Q 24.9 4.5 24.9 4.3 Q 24.8 4.1 24.3 4.1 Q 23.9 4 21.3 5.3 Q 18.7 6.6 16.8 8.4 Q 14.9 10.1 11.9 15.7 Q 8.8 21.3 6.4 31.5 Q 3.9 41.8 4.4 49.2 Q 4.8 56.6 4.3 59.8 Q 3.9 63.1 4.5 64 Q 5.1 65 7.1 65.5 Q 9 66.1 16 66.7 Q 23 67.4 25.6 69.2 Q 28.2 71.1 30.7 71.1 Q 33.1 71.2 36.1 69.7 Q 39 68.2 55.2 67.7 Q 71.3 67.2 76.6 66.7 Q 81.8 66.1 82.6 65.7 Q 83.3 65.2 83.7 64 Q 84 62.7 82.4 58.9 Q 80.9 55.1 83.5 52.4 Q 86.1 49.6 86.1 44.7 Q 86.2 39.7 85.7 34.4 Q 85.1 29.1 83.7 23.2 Q 82.2 17.4 79 13.1 Q 75.7 8.8 72.7 6.8 Q 69.6 4.8 66.6 4 Z\",\n top_51:\n \"M 64.1 3.5 Q 62.5 3.2 59.8 8.1 Q 57 13 54 16.4 Q 51.1 19.7 47.6 22.8 Q 44.1 25.9 39 29.1 Q 34 32.2 29.8 26.2 Q 25.5 20.2 24.4 17 Q 23.3 13.7 23.2 10.9 Q 23.1 8.2 22.6 7.3 Q 22.1 6.5 21.2 6.7 Q 20.4 6.8 16.9 9.3 Q 13.5 11.9 10.7 16.6 Q 7.8 21.3 5.8 32.9 Q 3.8 44.6 3.4 50.5 Q 3.1 56.4 3.4 58.5 Q 3.8 60.5 5.9 62.7 Q 7.9 64.8 16.3 66.5 Q 24.7 68.2 40 68.2 Q 55.2 68.2 66.1 66.7 Q 76.9 65.1 78.7 64.7 Q 80.5 64.2 81.2 63.7 Q 81.8 63.1 82.5 57.8 Q 83.2 52.5 83.2 39.1 Q 83.2 25.7 82.2 21.5 Q 81.2 17.4 79.2 14.3 Q 77.2 11.2 75.1 9.7 Q 73.1 8.1 69.4 5.9 Q 65.7 3.8 64.1 3.5 Z\",\n top_52:\n \"M 64.8 4.5 Q 61 3.3 60.9 3.3 Q 60.9 3.4 60.8 7.6 Q 60.7 11.8 58.8 15.9 Q 57 20.1 53 23.6 Q 48.9 27.1 45.3 28.4 Q 41.7 29.7 35.9 29.7 Q 30.2 29.7 27.1 28 Q 24.1 26.3 22.6 22.5 Q 21.2 18.7 21.8 14.4 Q 22.4 10.1 23.6 7.8 Q 24.8 5.5 24.8 5.5 Q 24.8 5.6 19.3 8.9 Q 13.8 12.3 11.8 15.8 Q 9.8 19.3 7.4 28.6 Q 4.9 37.9 4.4 41.7 Q 3.8 45.5 3.8 54.3 Q 3.8 63.1 4 63.5 Q 4.3 64 9.1 65.6 Q 14 67.1 17.8 67.7 Q 21.6 68.2 34.4 68.7 Q 47.2 69.2 48.7 68.7 Q 50.2 68.2 56.3 68.2 Q 62.4 68.2 65.6 67.7 Q 68.9 67.1 70.8 66.7 Q 72.7 66.2 76.8 64 Q 80.9 61.8 82.1 56.6 Q 83.2 51.3 83.2 45.8 Q 83.2 40.2 83.7 38.8 Q 84.2 37.3 83.2 28.8 Q 82.1 20.3 78.9 15.5 Q 75.7 10.8 72.1 8.3 Q 68.6 5.8 64.8 4.5 Z\",\n top_53:\n \"M 64.8 4.5 Q 61 3.3 60.9 3.3 Q 60.9 3.4 60.8 7.6 Q 60.7 11.8 58.8 15.9 Q 57 20.1 53 23.6 Q 48.9 27.1 45.3 28.4 Q 41.7 29.7 35.9 29.7 Q 30.2 29.7 27.1 28 Q 24.1 26.3 22.6 22.5 Q 21.2 18.7 21.8 14.4 Q 22.4 10.1 23.6 7.8 Q 24.8 5.5 24.8 5.5 Q 24.8 5.6 19.3 8.9 Q 13.8 12.3 11.8 15.8 Q 9.8 19.3 7.4 28.6 Q 4.9 37.9 4.4 41.7 Q 3.8 45.5 3.8 54.3 Q 3.8 63.1 4 63.5 Q 4.3 64 9.1 65.6 Q 14 67.1 17.8 67.7 Q 21.6 68.2 34.4 68.7 Q 47.2 69.2 48.7 68.7 Q 50.2 68.2 56.3 68.2 Q 62.4 68.2 65.6 67.7 Q 68.9 67.1 70.8 66.7 Q 72.7 66.2 76.8 64 Q 80.9 61.8 82.1 56.6 Q 83.2 51.3 83.2 45.8 Q 83.2 40.2 83.7 38.8 Q 84.2 37.3 83.2 28.8 Q 82.1 20.3 78.9 15.5 Q 75.7 10.8 72.1 8.3 Q 68.6 5.8 64.8 4.5 Z\",\n top_54:\n \"M 63.2 3.9 Q 61.4 3.9 61.3 4 Q 61.2 4.1 61.5 6.4 Q 61.8 8.6 61.2 11.3 Q 60.6 14 59 17.2 Q 57.4 20.4 55.2 22.7 Q 53.1 25 50.5 26.3 Q 47.8 27.7 37.9 27.7 Q 28 27.8 26 26.5 Q 24.1 25.2 23.2 23.5 Q 22.3 21.8 22.3 17.5 Q 22.2 13.2 23.9 9 Q 25.6 4.9 25.8 5.1 Q 25.9 5.2 22.5 6.6 Q 19 8 16 11 Q 13.1 14 10.5 21 Q 7.9 28 6.7 37.5 Q 5.6 47 4.2 49 Q 2.8 51 2.9 53.5 Q 2.9 56 3.9 58 Q 4.8 60.1 4.8 71.6 Q 4.8 83.1 5.1 83.6 Q 5.3 84.1 9.2 85.6 Q 13.1 87.1 16.4 87.7 Q 19.6 88.2 40.4 88.1 Q 61.1 88.1 64.2 87 Q 67.3 86 68.2 80.8 Q 69.1 75.6 74.9 74.4 Q 80.7 73.2 81.4 72.7 Q 82.2 72.3 82.8 64.7 Q 83.4 57.1 84.3 55.5 Q 85.2 54 85.1 51.6 Q 85.1 49.3 84.2 48.1 Q 83.3 46.9 83.7 40.8 Q 84.2 34.7 83.7 33.3 Q 83.2 31.8 83.1 27.4 Q 83.1 23 81.5 19.5 Q 80 16 75.5 11.5 Q 70.9 7 67.9 5.4 Q 64.9 3.9 63.2 3.9 Z\",\n top_56:\n \"M 42.6 2.9 Q 36.9 2.9 33.5 3.4 Q 30.1 3.9 25.1 5.9 Q 20.1 8 15.4 13.8 Q 10.6 19.6 8.7 23.4 Q 6.8 27.3 5.9 30.6 Q 4.9 33.9 3.8 39.8 Q 2.8 45.7 2.9 57.6 Q 2.9 69.6 6 72.3 Q 9 75 13.5 77.5 Q 17.9 80 19.8 79.6 Q 21.6 79.2 24.8 76.8 Q 28 74.3 31.1 74.3 Q 34.2 74.2 38 77.5 Q 41.8 80.8 43 80.9 Q 44.1 81 55.7 76.7 Q 67.2 72.4 71.8 71.8 Q 76.4 71.2 77.8 69.4 Q 79.2 67.5 80.2 64.3 Q 81.1 61.1 81.6 57.7 Q 82.2 54.4 82.2 44.5 Q 82.2 34.6 81.7 31.4 Q 81.1 28.2 79.6 25.1 Q 78 22.1 73.9 18 Q 69.8 13.9 63.2 9.3 Q 56.6 4.8 52.5 3.8 Q 48.3 2.8 42.6 2.9 Z \" +\n \"M 29 13.2 Q 33.2 11.3 37.5 11.3 Q 41.7 11.2 44.3 11.8 Q 47 12.4 50.7 14.3 Q 54.4 16.2 55.2 18 Q 56.1 19.8 52.5 22.9 Q 49 26 40.2 30.5 Q 31.3 35.1 27.2 31.1 Q 23 27.1 21.6 24.2 Q 20.1 21.3 22.5 18.2 Q 24.8 15 29 13.2 Z\",\n top_57:\n \"M 41.6 2.9 Q 36.9 2.9 33.1 3.9 Q 29.3 4.8 26.7 6.4 Q 24.1 7.9 18.6 14 Q 13 20 11.4 23.2 Q 9.8 26.3 8.4 31.6 Q 6.9 36.9 5.8 43.8 Q 4.8 50.7 4.3 55.6 Q 3.8 60.4 4 66.5 Q 4.2 72.5 8 73.4 Q 11.7 74.2 17.3 74.3 Q 22.9 74.3 24.4 75.3 Q 26 76.2 42.1 76.2 Q 58.3 76.2 66.6 75.2 Q 74.9 74.1 78.9 73 Q 82.9 71.9 83.5 70.9 Q 84.2 70 84.2 52.8 Q 84.2 35.7 83.7 32.9 Q 83.1 30.2 81.5 26.6 Q 80 23 77.9 20.4 Q 75.8 17.8 66.2 11.3 Q 56.7 4.8 51.5 3.8 Q 46.3 2.8 41.6 2.9 Z \" +\n \"M 30.7 14.9 Q 33.4 14.3 38.5 14.3 Q 43.6 14.3 48.9 15.4 Q 54.3 16.5 55.7 17.9 Q 57.2 19.4 47.5 30.4 Q 37.7 41.3 34.8 43.5 Q 32 45.6 27.7 36.8 Q 23.4 27.9 22.8 24.1 Q 22.1 20.3 22.7 19 Q 23.3 17.8 25.6 16.6 Q 28 15.4 30.7 14.9 Z\",\n top_59:\n \"M 51.2 2.9 Q 41.8 2.9 31.4 3.9 Q 21.1 4.9 18.6 5.9 Q 16.1 7 13.5 10.1 Q 11 13.2 11.3 17.5 Q 11.6 21.8 10.2 25 Q 8.9 28.2 6.3 40.4 Q 3.8 52.7 3.9 58.8 Q 3.9 64.9 5.3 67.5 Q 6.6 70 7.2 73.4 Q 7.8 76.8 9.1 79 Q 10.3 81.1 12.3 82.2 Q 14.2 83.2 16.9 83.7 Q 19.7 84.2 23.7 84.2 Q 27.8 84.2 29.3 84.7 Q 30.8 85.2 44.5 85.2 Q 58.3 85.2 63.1 84.7 Q 68 84.1 70.7 83.2 Q 73.5 82.3 75.9 80.3 Q 78.4 78.3 82.3 69.2 Q 86.2 60.1 86.7 51.3 Q 87.2 42.6 86.7 39.4 Q 86.1 36.1 83.7 30.3 Q 81.2 24.5 82.1 20.5 Q 83 16.4 82.6 14.4 Q 82.2 12.4 80.5 10.2 Q 78.9 8 73.4 5.9 Q 67.9 3.9 64.2 3.3 Q 60.5 2.8 51.2 2.9 Z \" +\n \"M 33.2 19.3 Q 40.4 17.3 43.5 17.3 Q 46.6 17.3 49.6 17.9 Q 52.7 18.6 53.4 20.3 Q 54.1 22 45.2 28.6 Q 36.3 35.1 31.6 30.5 Q 26.8 25.9 26.5 23.6 Q 26.1 21.3 33.2 19.3 Z\",\n top_60:\n \"M 41.6 2.9 Q 36.9 2.9 33.1 3.9 Q 29.3 4.8 26.7 6.4 Q 24.1 7.9 18.6 14 Q 13 20 11.4 23.2 Q 9.8 26.3 8.4 31.6 Q 6.9 36.9 5.8 43.8 Q 4.8 50.7 4.3 55.6 Q 3.8 60.4 4 66.5 Q 4.2 72.5 8 73.4 Q 11.7 74.2 17.3 74.3 Q 22.9 74.3 24.4 75.3 Q 26 76.2 42.1 76.2 Q 58.3 76.2 66.6 75.2 Q 74.9 74.1 78.9 73 Q 82.9 71.9 83.5 70.9 Q 84.2 70 84.2 53.3 Q 84.2 36.7 83.2 32.5 Q 82.2 28.3 80.6 25.2 Q 79 22.1 76.4 19.5 Q 73.8 16.9 65.2 10.9 Q 56.7 4.8 51.5 3.8 Q 46.3 2.8 41.6 2.9 Z \" +\n \"M 30.7 14.9 Q 33.4 14.3 38.5 14.3 Q 43.6 14.3 48.9 15.4 Q 54.3 16.5 55.7 17.9 Q 57.2 19.4 47.5 30.4 Q 37.7 41.3 34.8 43.5 Q 32 45.6 27.7 36.8 Q 23.4 27.9 22.8 24.1 Q 22.1 20.3 22.7 19 Q 23.3 17.8 25.6 16.6 Q 28 15.4 30.7 14.9 Z\",\n};\n\nexport const clothesArt: Record<string, ArtDef> = {\n // ── Tops ──\n // A white tank top: a scooped neck, a pale rim round it and over the hem.\n \"top/1\": {\n svg:\n path(TOP_1, \"#ebe7ef\", { stroke: \"none\" }) +\n clipped(\n \"top_1\",\n TOP_1,\n line(\n \"M 18 4 C 14 16 16 26 24 31 C 32 37 46 37 54 34 C 62 30 66 22 66 6\",\n \"#d5d0da\",\n 4,\n ),\n line(\"M 2 57 C 26 59 60 59 86 57\", \"#d5d0da\", 3),\n ) +\n path(TOP_1, \"none\"),\n },\n // A red flannel shirt: an open collar, buttons down the middle, a brown belt.\n \"top/5\": {\n svg:\n path(TOP_5, PLAID, { stroke: \"none\", holes: true }) +\n clipped(\"top_5\", TOP_5, plaid(0, 0, 90, 80, 11, PLAID_DARK)) +\n path(TOP_5, \"none\", { holes: true }) +\n path(\"M 22 6 C 34 2 54 2 68 8 C 62 10 58 10 57 9 L 25 12 Z\", \"#ec5f6c\", {\n width: 2.6,\n }) +\n path(\"M 25 10 C 19 18 16 28 15 36 L 34 33 C 28 26 24 18 25 10 Z\", PLAID, {\n width: 2.8,\n }) +\n path(\n \"M 57 8 C 62 12 68 16 70 20 C 66 28 62 34 58 40 L 34 33 C 44 26 52 18 57 8 Z\",\n PLAID,\n { width: 2.8 },\n ) +\n line(\"M 31 34 L 30 58\", INK, 2.8) +\n path(\n \"M 2 59 L 85 59 L 85 72 C 85 75 83 77 79 77 L 6 77 C 3 77 2 75 2 72 Z\",\n \"#a95b2c\",\n ) +\n rect(4, 58, 13, 17, 3, \"#94501f\", { width: 2.8 }) +\n rect(45, 58, 15, 17, 3, \"#94501f\", { width: 2.8 }) +\n circle(7.5, 67, 2.2, \"#6a3818\", { width: 1.4 }) +\n circle(52.5, 67, 2.2, \"#6a3818\", { width: 1.4 }),\n },\n \"top/5_sleeve_near\": {\n svg: cap(\n \"sleeve_near_5\",\n CAP_L,\n PLAID,\n plaid(0, 0, 36, 36, 11, PLAID_DARK),\n ),\n },\n \"top/5_sleeve_far\": {\n svg: cap(\"sleeve_far_5\", CAP_R, PLAID, plaid(0, 0, 36, 36, 11, PLAID_DARK)),\n },\n // A leather vest on shoulder straps over a blue shirt, a belt with a steel buckle.\n \"top/19\": {\n svg:\n path(\"M 66 10 C 76 12 82 18 83 26 L 84 72 L 70 77 Z\", \"#2b3f57\") +\n path(\n \"M 12 30 L 13 14 C 16 8 22 4 26 3 C 24 12 22 22 22 30 Z\",\n \"#8a5a32\",\n ) +\n path(\"M 57 30 C 58 20 60 10 61 3 C 66 4 70 6 72 9 L 68 30 Z\", \"#8a5a32\") +\n path(\n \"M 7 30 L 70 30 L 70 86 C 70 88 68 89 66 89 L 10 89 C 8 89 6 88 6 86 Z\",\n \"#8a5a32\",\n ) +\n line(\n \"M 48 32 L 52 33 M 23 37 L 26 35 M 51 45 L 54 47 M 62 44 L 64 47 M 10 64 L 11 68 M 28 72 L 31 73 M 16 77 L 17 80 M 36 80 L 38 82 M 56 70 L 58 73\",\n \"#5e3a1c\",\n 2.4,\n ) +\n path(\"M 2 48 L 86 48 L 86 58 L 2 58 Z\", \"#4a3428\") +\n rect(20, 47, 22, 17, 3, \"#b8b8b8\", { width: 3 }) +\n rect(25, 52, 12, 7, 1, \"#4a3428\", { width: 2.4 }),\n },\n \"top/19_sleeve_near\": { svg: cap(\"sleeve_near_19\", CAP_L, \"#2b3f57\") },\n \"top/19_sleeve_far\": { svg: cap(\"sleeve_far_19\", CAP_R, \"#2b3f57\") },\n // A camouflage vest with pockets, the dark shirt in the V.\n \"top/25\": {\n svg:\n path(\n \"M 21 5 C 30 12 34 20 34 34 C 42 26 50 16 60 3 L 34 36 Z\",\n \"#2e4433\",\n { stroke: \"none\" },\n ) +\n path(TOP_25, \"#79a04a\", { stroke: \"none\" }) +\n clipped(\n \"top_25\",\n TOP_25,\n flat(\n \"M 21 5 C 26 16 30 26 34 34 C 40 28 50 18 60 3 L 64 3 C 54 20 44 32 34 42 C 28 32 22 20 18 8 Z\",\n \"#2e4433\",\n ),\n flat(\n \"M 66 8 C 76 10 82 16 84 24 L 72 22 C 70 16 68 12 66 8 Z\",\n \"#6e6a2a\",\n ),\n flat(\n \"M 40 36 C 52 32 70 30 86 34 L 86 40 C 70 38 52 40 40 44 Z M 4 40 C 10 38 16 40 20 44 L 4 48 Z\",\n \"#90b65e\",\n ),\n flat(\n \"M 52 52 C 64 50 76 50 86 52 L 86 58 C 76 56 64 56 52 58 Z\",\n \"#56753a\",\n ),\n ) +\n path(TOP_25, \"none\") +\n line(\n \"M 21 7 C 24 20 30 30 34 36 C 42 28 52 18 60 5 M 34 36 L 34 69\",\n INK,\n 3,\n ) +\n [\n [5, 31, 15, 16],\n [2, 49, 10, 18],\n [13, 49, 16, 18],\n [36, 51, 16, 18],\n [54, 51, 16, 18],\n [55, 32, 15, 15],\n ]\n .map(\n ([x, y, w, h]) =>\n rect(x!, y!, w!, h!, 3, \"#6d9442\", { width: 3 }) +\n circle(x! + w! / 2, y! + h! / 2, 2.6, \"#3f5a29\", { width: 1.6 }),\n )\n .join(\"\"),\n },\n \"top/25_sleeve_near\": {\n svg: sleeve(\"sleeve_near_25\", SLEEVE_L_25, \"#6f9040\", 48),\n },\n \"top/25_sleeve_far\": {\n svg: sleeve(\n \"sleeve_far_25\",\n SLEEVE_R_25,\n \"#6f9040\",\n 99,\n line(\"M 0 46 L 30 50\", tone(\"#6f9040\", -0.35), 2.6),\n ),\n },\n // A track jacket: a high collar, a zip, two white bands across the chest.\n \"top/40\": {\n svg:\n path(TOP_40, \"#4f5166\", { stroke: \"none\", holes: true }) +\n clipped(\n \"top_40\",\n TOP_40,\n line(\n \"M 0 38 L 35 53 M 0 50 L 35 62 M 35 50 L 90 31 M 35 61 L 90 43\",\n WHITE_STRIPE,\n 6,\n ),\n flat(\"M 0 74 L 90 74 L 90 90 L 0 90 Z\", \"#5a5d73\"),\n line(\"M 0 74 L 90 74\", INK, 2.8),\n ) +\n path(TOP_40, \"none\", { holes: true }) +\n path(\n \"M 10 30 C 8 12 22 4 46 3 C 70 3 84 10 83 22 C 70 30 50 34 35 34 C 26 34 16 32 10 30 Z \" +\n \"M 23 14 C 34 11 50 11 61 14 C 54 22 44 28 36 32 C 30 26 26 20 23 14 Z\",\n \"#2b2c38\",\n { holes: true },\n ) +\n line(\"M 35 34 L 34 85.5\", INK, 2.8),\n },\n \"top/40_sleeve_near\": {\n svg: sleeve(\n \"sleeve_near_40\",\n SLEEVE_L_40,\n \"#4f5166\",\n 54,\n line(\"M 0 23 L 31 12 M 0 32 L 31 21\", WHITE_STRIPE, 4),\n ),\n },\n \"top/40_sleeve_far\": {\n svg: sleeve(\n \"sleeve_far_40\",\n SLEEVE_R_40,\n \"#4f5166\",\n 53,\n line(\"M 0 12 L 32 20 M 0 21 L 32 29\", WHITE_STRIPE, 4),\n ),\n },\n // ── Bottoms ──\n \"pants/26\": {\n svg:\n path(SHORTS, \"#6b86ad\") +\n line(\n \"M 31 6 L 31 12 M 31 18 L 31 26 M 70 6 L 70 12 M 70 18 L 70 26\",\n \"#4a5f80\",\n 2.4,\n ) +\n line(\"M 35 6 L 35 16 C 35 18 33 19 31 19\", \"#4a5f80\", 2.4),\n },\n \"pants/42\": {\n svg:\n path(SHORTS, \"#b99a6c\") +\n line(\n \"M 31 6 L 31 12 M 31 18 L 31 26 M 70 6 L 70 12 M 70 18 L 70 26\",\n \"#8c7350\",\n 2.4,\n ) +\n line(\"M 35 6 L 35 16 C 35 18 33 19 31 19\", \"#8c7350\", 2.4),\n },\n\n // ── Boots ──\n\n // Gloves (left and right images).\n \"gloves/1_near\": {\n svg: mitt(\n \"glove_near_1\",\n 28,\n 25,\n \"#2b2b2b\",\n undefined,\n rect(9, 9, 11, 5, 2, \"#e8e8e8\", { width: 1.8 }),\n ),\n },\n \"gloves/1_far\": {\n svg: mitt(\n \"glove_far_1\",\n 27,\n 25,\n \"#2b2b2b\",\n undefined,\n rect(9, 9, 11, 5, 2, \"#e8e8e8\", { width: 1.8 }),\n ),\n },\n \"gloves/10_near\": {\n svg: mitt(\"glove_near_10\", 34, 35, \"#e25a3a\", { h: 9, color: \"#f6a23a\" }),\n },\n \"gloves/10_far\": {\n svg: mitt(\"glove_far_10\", 32, 35, \"#e25a3a\", { h: 9, color: \"#f6a23a\" }),\n },\n // A puffer jacket over a white tee.\n \"top/2\": {\n svg: top(\n \"top_2\",\n TOPS.top_2,\n \"#3c3f4c\",\n line(\"M 6 30 C 30 36 60 36 86 28 M 6 60 C 30 58 60 58 86 58\", INK, 3) +\n line(\"M 6 62 L 14 70 M 58 60 L 74 72\", INK, 2.6) +\n path(\"M 26 36 L 30 36 L 30 86 L 26 86 Z\", \"#c8c8c8\", { width: 2.4 }),\n line(\"M 18 13 C 30 20 50 20 64 15\", INK, 3),\n ),\n },\n \"top/2_sleeve_near\": { svg: cap(\"sleeve_near_2\", CAP_L, \"#e6e6e0\") },\n \"top/2_sleeve_far\": { svg: cap(\"sleeve_far_2\", CAP_R, \"#e6e6e0\") },\n // A yellow hoodie with drawstrings.\n \"top/3\": {\n svg: top(\n \"top_3\",\n TOPS.top_3,\n \"#ffe090\",\n flat(\"M 14 6 C 30 2 56 2 70 8 L 64 14 C 50 8 32 8 22 14 Z\", \"#e0b860\") +\n flat(\"M 0 70 L 90 70 L 90 90 L 0 90 Z\", \"#ffe8a8\") +\n line(\"M 6 70 L 84 70\", INK, 2.8) +\n line(\n \"M 24 72 L 24 84 M 38 72 L 38 84 M 52 72 L 52 84 M 66 72 L 66 84\",\n \"#d8b060\",\n 2.4,\n ),\n line(\"M 24 38 L 20 48 M 48 38 L 52 48\", INK, 4) +\n line(\"M 21 45 L 19.5 49 M 51 45 L 52.5 49\", \"#ffffff\", 2.2),\n ),\n },\n \"top/3_sleeve_near\": {\n svg: cuffed(\"sleeve_near_3\", SLEEVE_L_40, \"#ffe090\", 54, \"#ffe8a8\"),\n },\n \"top/3_sleeve_far\": {\n svg: cuffed(\"sleeve_far_3\", SLEEVE_R_40, \"#ffe090\", 53, \"#ffe8a8\"),\n },\n // A striped polo with a white collar.\n \"top/4\": {\n svg: top(\n \"top_4\",\n TOPS.top_4,\n \"#90f0ff\",\n flat(\"M 0 50 L 90 50 L 90 58 L 0 58 Z\", \"#4090d0\") +\n line(\"M 0 47 L 90 47 M 0 62 L 90 62\", \"#ffffff\", 2.4),\n path(\n \"M 16 8 C 22 4 30 4 34 6 L 42 30 L 36 46 C 34 50 30 50 28 46 L 28 30 C 22 24 16 18 16 8 Z\",\n \"#f4f4fc\",\n ) +\n path(\n \"M 68 8 C 62 4 54 4 50 6 L 42 30 L 48 40 C 54 34 62 26 68 8 Z\",\n \"#f4f4fc\",\n ),\n ),\n },\n \"top/4_sleeve_near\": { svg: cap(\"sleeve_near_4\", CAP_L, \"#90f0ff\") },\n \"top/4_sleeve_far\": { svg: cap(\"sleeve_far_4\", CAP_R, \"#90f0ff\") },\n // An open denim jacket over a cream shirt, a belt with a brass buckle.\n \"top/6\": {\n svg: top(\n \"top_6\",\n TOPS.top_6,\n \"#fdf0d8\",\n flat(\n \"M 0 0 L 26 0 C 24 20 22 40 24 62 L 0 62 Z M 60 0 L 90 0 L 90 62 L 62 62 C 64 40 64 20 60 0 Z\",\n \"#3a6a98\",\n ) +\n dashed(\"M 6 46 L 22 46 M 66 46 L 84 46\", \"#2a5078\", 2.4, \"4 3\") +\n line(\n \"M 26 4 C 24 20 22 40 24 62 M 60 4 C 64 20 64 40 62 62\",\n INK,\n 2.8,\n ) +\n flat(\"M 0 62 L 90 62 L 90 82 L 0 82 Z\", \"#5a3a30\") +\n line(\"M 0 62 L 90 62\", INK, 2.8),\n rect(28, 62, 18, 14, 2, \"#e8c878\", { width: 2.8 }) +\n rect(33, 66, 8, 6, 1, \"#5a3a30\", { width: 2 }),\n ),\n },\n \"top/6_sleeve_near\": {\n svg: cap(\n \"sleeve_near_6\",\n CAP2_L,\n \"#fde6d0\",\n flat(\"M 0 25 L 30 25 L 30 29 L 0 29 Z\", \"#c8a888\"),\n ),\n },\n \"top/6_sleeve_far\": {\n svg: cap(\n \"sleeve_far_6\",\n CAP2_R,\n \"#fde6d0\",\n flat(\"M 0 25 L 30 27 L 30 31 L 0 29 Z\", \"#c8a888\"),\n ),\n },\n // A pink apron over a white top, a scalloped hem.\n \"top/7\": {\n svg: top(\n \"top_7\",\n TOPS.top_7,\n \"#f6f4f6\",\n path(\"M 10 40 L 74 40 L 72 62 L 12 62 Z\", \"#f0a8d0\") +\n line(\"M 18 4 L 18 40 M 66 4 L 66 40\", \"#f0a8d0\", 7) +\n flat(\"M 0 56 L 90 56 L 90 64 L 0 64 Z\", \"#c8a0f0\"),\n [16, 28, 40, 52, 64]\n .map((x) => circle(x, 68, 6, \"#c8a0f0\", { width: 2.6 }))\n .join(\"\") +\n [\n [36, 46],\n [42, 46],\n [36, 52],\n [42, 52],\n ]\n .map(([x, y]) => circle(x!, y!, 3.6, \"#ffffff\", { stroke: \"none\" }))\n .join(\"\") +\n circle(39, 49, 2.6, \"#f8b030\", { stroke: \"none\" }),\n ),\n },\n \"top/7_sleeve_near\": {\n svg: cap(\n \"sleeve_near_7\",\n CAP2_L,\n \"#f8f6f8\",\n flat(\"M 0 27 L 30 27 L 30 36 L 0 36 Z\", \"#f4c8dc\"),\n ),\n },\n \"top/7_sleeve_far\": {\n svg: cap(\n \"sleeve_far_7\",\n CAP2_R,\n \"#f8f6f8\",\n flat(\"M 0 27 L 30 29 L 30 38 L 0 36 Z\", \"#f4c8dc\"),\n ),\n },\n // A pinstripe blazer, two gold buttons.\n \"top/8\": {\n svg: top(\n \"top_8\",\n TOPS.top_8,\n \"#4a4a68\",\n line(\n \"M 16 20 L 16 80 M 34 40 L 34 80 M 50 40 L 50 80 M 66 20 L 66 80\",\n \"#4a6ad0\",\n 2.6,\n ),\n path(\"M 22 4 L 42 30 L 30 48 L 18 22 Z\", \"#5a5a7a\", { width: 2.6 }) +\n path(\"M 62 4 L 42 30 L 46 42 L 66 18 Z\", \"#5a5a7a\", { width: 2.6 }) +\n circle(32, 66, 3, \"#e8c040\", { width: 2 }) +\n circle(48, 66, 3, \"#e8c040\", { width: 2 }),\n ),\n },\n // A blue V-neck with a white trim.\n \"top/9\": {\n svg: top(\n \"top_9\",\n TOPS.top_9,\n \"#6a90e0\",\n flat(\"M 24 3 C 26 14 34 26 42 34 C 50 26 58 14 60 3 Z\", \"#262626\") +\n line(\"M 24 6 C 27 16 34 26 42 34 C 50 26 57 16 60 6\", \"#f4f6fc\", 3),\n ),\n },\n \"top/9_sleeve_near\": { svg: path(SLEEVE_L_25, \"#262626\") },\n \"top/9_sleeve_far\": {\n svg: cuffed(\n \"sleeve_far_9\",\n SLEEVE_R_25,\n \"#262626\",\n 99,\n \"#262626\",\n flat(\"M 0 0 L 34 0 L 34 20 L 0 16 Z\", \"#6a90e0\") +\n line(\"M 0 12 L 34 16\", \"#f4f6fc\", 3),\n ),\n },\n \"top/9_cap_near\": {\n svg: cap(\n \"sleeve_near_9_\",\n CAP_L,\n \"#6a90e0\",\n line(\"M 2 24 L 34 22\", \"#f4f6fc\", 3),\n ),\n },\n \"top/9_cap_far\": {\n svg: cap(\n \"sleeve_far_9_\",\n CAP_R,\n \"#6a90e0\",\n line(\"M 2 22 L 32 26\", \"#f4f6fc\", 3),\n ),\n },\n // A trench coat: an open shirt in front, the coat (a mesh) falling to the knees.\n \"top/10\": {\n svg:\n path(\n \"M 3 8 C 10 6 18 10 24 16 L 28 24 L 32 16 C 38 10 46 6 53 4 L 53 52 C 40 54 18 54 3 52 Z\",\n \"#ece0d4\",\n ) +\n line(\n \"M 28 24 L 28 52 M 3 8 L 18 20 L 26 26 M 53 4 L 38 18 L 30 26\",\n INK,\n 2.6,\n ),\n },\n \"top/10_coat\": {\n svg: top(\n \"top_10_\",\n \"M 4 100 L 4 30 C 4 12 14 4 30 4 L 60 4 C 76 4 84 12 84 28 L 84 100 L 50 100 L 50 52 C 48 36 42 26 35 22 C 28 26 22 36 18.5 52 L 18.5 100 Z\",\n \"#b07850\",\n line(\"M 10 30 L 10 96 M 78 30 L 78 96\", \"#8a5a38\", 2.4) +\n line(\"M 4 88 L 18 88 M 50 88 L 84 88\", \"#8a5a38\", 3),\n ),\n },\n \"top/10_sleeve_near\": {\n svg: cuffed(\n \"sleeve_near_10\",\n SLEEVE_L_25,\n \"#b07850\",\n 46,\n \"#b07850\",\n line(\"M 0 49 L 30 49\", \"#8a5a38\", 3),\n ),\n },\n \"top/10_sleeve_far\": {\n svg: cuffed(\n \"sleeve_far_10\",\n SLEEVE_R_25,\n \"#b07850\",\n 46,\n \"#b07850\",\n line(\"M 0 49 L 32 52\", \"#8a5a38\", 3),\n ),\n },\n // An orange V-neck sweater over a white collared shirt.\n \"top/11\": {\n svg: top(\n \"top_11\",\n TOPS.top_11,\n \"#f07028\",\n line(\"M 0 62 L 90 62\", \"#c05018\", 2.4) +\n flat(\n \"M 18 0 L 26 22 C 30 30 34 34 39 37 C 46 32 54 24 60 14 L 68 0 Z\",\n \"#f4eef4\",\n ) +\n line(\n \"M 18 6 L 26 22 C 30 30 34 34 39 37 C 46 32 54 24 60 14 L 66 4\",\n \"#c05018\",\n 3.2,\n ) +\n line(\"M 31 27 C 36 26 42 26 47 25 M 39 26 L 39 34\", \"#9a9a9a\", 2),\n ),\n },\n \"top/11_sleeve_near\": { svg: cap(\"sleeve_near_11\", CAP_L, \"#f4eef4\") },\n \"top/11_sleeve_far\": { svg: cap(\"sleeve_far_11\", CAP_R, \"#f4eef4\") },\n // A black suit, white shirt, dark tie.\n \"top/12\": {\n svg: top(\n \"top_12\",\n TOPS.top_12,\n \"#2e2e32\",\n flat(\"M 24 0 L 42 40 L 60 0 Z\", \"#f4f4f8\") +\n path(\"M 39 14 L 45 14 L 47 36 L 42 42 L 37 36 Z\", \"#1a1a40\", {\n width: 2,\n }),\n path(\"M 22 4 L 40 40 L 30 52 L 16 22 Z\", \"#3a3a40\", { width: 2.6 }) +\n path(\"M 62 4 L 44 40 L 50 48 L 68 18 Z\", \"#3a3a40\", { width: 2.6 }),\n ),\n },\n \"top/12_sleeve_near\": {\n svg: cuffed(\"sleeve_near_12\", SLEEVE_L_25, \"#2e2e32\", 48, \"#f4f4f8\"),\n },\n \"top/12_sleeve_far\": {\n svg: cuffed(\"sleeve_far_12\", SLEEVE_R_25, \"#2e2e32\", 48, \"#f4f4f8\"),\n },\n // A brown zigzag cardigan with a blue scarf.\n \"top/13\": {\n svg: top(\n \"top_13\",\n TOPS.top_13,\n \"#8a4a38\",\n line(\n \"M 4 56 L 12 50 L 20 56 L 28 50 M 56 50 L 64 56 L 72 50 L 80 56 L 88 50\",\n \"#5a2a20\",\n 3,\n ) +\n flat(\"M 36 30 L 52 30 L 52 82 L 36 82 Z\", \"#9a8070\") +\n line(\"M 36 30 L 36 82 M 52 30 L 52 82\", INK, 2.6) +\n [44, 56].map((y) => circle(44, y, 3, \"#5a4a40\", { width: 2 })).join(\"\"),\n path(\n \"M 8 18 C 20 8 40 4 60 6 C 72 8 80 14 82 22 C 70 30 50 34 30 32 C 20 30 12 26 8 18 Z\",\n \"#1aa0c8\",\n ) +\n flat(\"M 26 12 C 40 8 56 8 66 12 C 56 18 40 18 26 12 Z\", \"#108aaa\") +\n circle(44, 72, 5, \"#d8a040\", { width: 2.6 }),\n ),\n },\n \"top/13_sleeve_near\": { svg: path(SLEEVE_L_25, \"#7a2a28\") },\n \"top/13_sleeve_far\": { svg: path(SLEEVE_R_25, \"#7a2a28\") },\n // A red basketball jersey, number 23.\n \"top/14\": {\n svg: top(\n \"top_14\",\n TOPS.top_14,\n \"#d82838\",\n line(\"M 20 5 C 22 16 30 24 42 24 C 54 24 62 16 64 5\", \"#f4f4f4\", 3),\n `<text x=\"42\" y=\"58\" font-family=\"Arial Black, Arial, sans-serif\" font-weight=\"900\" font-size=\"22\" text-anchor=\"middle\" fill=\"#ffffff\" stroke=\"${INK}\" stroke-width=\"2.6\" paint-order=\"stroke\">23</text>`,\n ),\n },\n // A teal-and-grey track jacket with a hood.\n \"top/15\": {\n svg: top(\n \"top_15\",\n TOPS.top_15,\n \"#8aa0b0\",\n flat(\n \"M 0 0 L 90 0 L 90 36 C 70 40 50 42 40 40 C 30 40 14 36 0 30 Z\",\n \"#1a6a6a\",\n ) +\n line(\"M 2 48 L 40 62 L 88 42 M 2 56 L 40 70 L 88 50\", \"#f4f4f4\", 4) +\n flat(\"M 0 72 L 90 72 L 90 90 L 0 90 Z\", \"#a0b4c4\") +\n line(\"M 6 72 L 84 72 M 40 40 L 40 86\", INK, 2.8),\n ),\n },\n \"top/15_sleeve_near\": {\n svg: cuffed(\n \"sleeve_near_15\",\n SLEEVE_L_40,\n \"#8aa0b0\",\n 54,\n \"#a0b4c4\",\n flat(\"M 0 0 L 31 0 L 31 14 L 0 18 Z\", \"#1a6a6a\") +\n line(\"M 0 26 L 31 20\", \"#f4f4f4\", 3.6),\n ),\n },\n \"top/15_sleeve_far\": {\n svg: cuffed(\n \"sleeve_far_15\",\n SLEEVE_R_40,\n \"#8aa0b0\",\n 53,\n \"#a0b4c4\",\n flat(\"M 0 0 L 32 0 L 32 18 L 0 14 Z\", \"#1a6a6a\") +\n line(\"M 0 20 L 32 26\", \"#f4f4f4\", 3.6),\n ),\n },\n // A purple sweater over a mint collared shirt.\n \"top/16\": {\n svg: top(\n \"top_16\",\n TOPS.top_16,\n \"#7060d0\",\n flat(\"M 0 76 L 90 76 L 90 86 L 0 86 Z\", \"#a0f0d0\") +\n line(\"M 0 76 L 90 76\", INK, 2.6),\n path(\n \"M 20 4 C 24 14 32 22 38 26 L 42 30 L 30 36 C 24 30 18 18 20 4 Z\",\n \"#a0f0d0\",\n { width: 2.6 },\n ) +\n path(\n \"M 64 4 C 60 14 52 22 46 26 L 42 30 L 54 36 C 60 30 66 18 64 4 Z\",\n \"#a0f0d0\",\n { width: 2.6 },\n ) +\n path(\"M 38 28 L 46 28 L 44 40 L 40 40 Z\", \"#5040a0\", { width: 2.2 }),\n ),\n },\n \"top/16_sleeve_near\": {\n svg: cuffed(\"sleeve_near_16\", SLEEVE_L_16, \"#7060d0\", 46, \"#a0f0d0\"),\n },\n \"top/16_sleeve_far\": {\n svg: cuffed(\"sleeve_far_16\", SLEEVE_R_16, \"#7060d0\", 46, \"#a0f0d0\"),\n },\n // A light blue shirt with clouds.\n \"top/17\": {\n svg: top(\n \"top_17\",\n TOPS.top_17,\n \"#e4f6fc\",\n [\n [18, 44],\n [66, 30],\n [50, 66],\n [14, 70],\n ]\n .map(([x, y]) =>\n path(\n `M ${x! - 7} ${y} C ${x! - 7} ${y! - 6} ${x! - 1} ${y! - 7} ${x! + 1} ${y! - 4} C ${x! + 5} ${y! - 8} ${x! + 10} ${y! - 4} ${x! + 8} ${y} C ${x! + 10} ${y! + 4} ${x! + 6} ${y! + 7} ${x! + 2} ${y! + 5} C ${x! - 2} ${y! + 8} ${x! - 8} ${y! + 5} ${x! - 7} ${y} Z`,\n \"none\",\n { stroke: \"#8ad0f0\", width: 2.4 },\n ),\n )\n .join(\"\") + line(\"M 42 30 L 42 80\", INK, 2.4),\n path(\n \"M 22 4 C 26 14 32 22 40 28 L 34 34 C 26 28 20 18 22 4 Z\",\n \"#e4f6fc\",\n { width: 2.6 },\n ) +\n path(\n \"M 62 4 C 58 14 52 22 44 28 L 50 34 C 58 28 64 18 62 4 Z\",\n \"#e4f6fc\",\n { width: 2.6 },\n ),\n ),\n },\n \"top/17_sleeve_near\": {\n svg: cap(\n \"sleeve_near_17\",\n CAP3_L,\n \"#e4f6fc\",\n line(\n \"M 10 20 C 10 16 16 15 17 18 C 21 15 24 19 22 22 C 24 26 18 28 16 26 C 12 28 8 24 10 20 Z\",\n \"#8ad0f0\",\n 2.2,\n ),\n ),\n },\n \"top/17_sleeve_far\": {\n svg: cap(\n \"sleeve_far_17\",\n CAP3_R,\n \"#e4f6fc\",\n line(\n \"M 10 18 C 10 14 16 13 17 16 C 21 13 24 17 22 20 C 24 24 18 26 16 24 C 12 26 8 22 10 18 Z\",\n \"#8ad0f0\",\n 2.2,\n ),\n ),\n },\n // A black biker vest under an open orange jacket, a skull buckle.\n \"top/18\": {\n svg: top(\n \"top_18\",\n TOPS.top_18,\n \"#262628\",\n flat(\n \"M 0 0 L 22 0 C 20 20 20 40 22 58 L 0 58 Z M 66 0 L 90 0 L 90 58 L 66 58 C 68 40 68 20 66 0 Z\",\n \"#e05028\",\n ) +\n line(\n \"M 22 2 C 20 20 20 40 22 58 M 66 2 C 68 20 68 40 66 58\",\n INK,\n 2.8,\n ) +\n rect(8, 30, 8, 8, 2, \"#3050d0\", { width: 2 }) +\n path(\"M 70 26 L 80 24 L 82 32 L 72 34 Z\", \"#40c040\", { width: 2 }) +\n flat(\"M 0 58 L 90 58 L 90 74 L 0 74 Z\", \"#262628\") +\n line(\"M 4 58 L 86 58\", INK, 2.8),\n line(\"M 4 63 L 86 63\", \"#e05028\", 2.6) +\n path(\n \"M 38 58 C 38 54 50 54 50 58 C 50 62 48 64 46 64 L 42 64 C 40 64 38 62 38 58 Z\",\n \"#e8e8e8\",\n { width: 2.4 },\n ),\n ),\n },\n \"top/18_sleeve_near\": { svg: cap(\"sleeve_near_18\", CAP_L, \"#262628\") },\n \"top/18_sleeve_far\": { svg: cap(\"sleeve_far_18\", CAP_R, \"#262628\") },\n // A raglan tee, red shoulders.\n \"top/20\": {\n svg: top(\n \"top_20\",\n TOPS.top_20,\n \"#e8e2ea\",\n flat(\n \"M 0 0 L 90 0 L 90 26 C 70 30 56 30 42 26 C 28 30 14 30 0 26 Z\",\n \"#e05038\",\n ) + line(\"M 0 26 C 14 30 28 30 42 26 C 56 30 70 30 90 26\", INK, 2.6),\n ),\n },\n \"top/20_sleeve_near\": { svg: path(SLEEVE_L_25, \"#e05038\") },\n \"top/20_sleeve_far\": { svg: path(SLEEVE_R_25, \"#e05038\") },\n // A pastel-striped vest with a lilac charm.\n \"top/21\": {\n svg: top(\n \"top_21\",\n TOPS.top_21,\n \"#f6f4f8\",\n line(\n \"M 10 0 L 10 72 M 22 0 L 22 72 M 34 0 L 34 72 M 50 0 L 50 72 M 62 0 L 62 72 M 74 0 L 74 72\",\n \"#c8e8d8\",\n 3,\n ) + line(\"M 16 0 L 16 72 M 56 0 L 56 72 M 68 0 L 68 72\", \"#f8d8b8\", 3),\n path(\"M 46 40 L 60 40 L 53 46 Z\", \"#b890e0\", { width: 2.2 }),\n ),\n },\n \"top/21_sleeve_near\": {\n svg: cuffed(\n \"sleeve_near_21\",\n SLEEVE_L_25,\n \"#f6f4f8\",\n 99,\n \"#f6f4f8\",\n line(\"M 9 0 L 9 60 M 15 0 L 15 60 M 21 0 L 21 60\", \"#e0c8e8\", 2.6),\n ),\n },\n \"top/21_sleeve_far\": {\n svg: cuffed(\n \"sleeve_far_21\",\n SLEEVE_R_25,\n \"#f6f4f8\",\n 99,\n \"#f6f4f8\",\n line(\"M 14 0 L 10 60 M 20 0 L 16 60 M 26 0 L 22 60\", \"#e0c8e8\", 2.6),\n ),\n },\n // A mint \"HAPPY\" tee.\n \"top/22\": {\n svg: top(\n \"top_22\",\n TOPS.top_22,\n \"#a0f0c8\",\n line(\"M 22 5 C 22 16 32 24 42 24 C 52 24 62 16 62 5\", \"#30b060\", 3.4),\n [\"#ff5a8a\", \"#ffb020\", \"#40c0f0\", \"#a060e0\", \"#30c070\"]\n .map(\n (c, i) =>\n `<text x=\"${18 + i * 12}\" y=\"${54 + (i % 2 ? -2 : 2)}\" font-family=\"Arial Black, Arial, sans-serif\" font-weight=\"900\" font-size=\"15\" fill=\"${c}\" stroke=\"#ffffff\" stroke-width=\"1.6\" paint-order=\"stroke\">${\"HAPPY\"[i]}</text>`,\n )\n .join(\"\"),\n ),\n },\n \"top/22_sleeve_near\": { svg: path(SLEEVE_L_25, \"#50e080\") },\n \"top/22_sleeve_far\": { svg: path(SLEEVE_R_25, \"#50e080\") },\n // A cream tee with black stripes.\n \"top/23\": {\n svg: top(\n \"top_23\",\n TOPS.top_23,\n \"#f8f0e8\",\n line(\"M 0 52 L 90 52 M 0 64 L 90 64\", \"#262626\", 5),\n line(\"M 22 5 C 22 16 32 24 42 24 C 52 24 62 16 62 5\", \"#3a3a3a\", 3.4),\n ),\n },\n \"top/23_sleeve_near\": {\n svg: cuffed(\n \"sleeve_near_23\",\n SLEEVE_L_25,\n \"#f8f0e8\",\n 99,\n \"#f8f0e8\",\n line(\"M 0 30 L 30 30 M 0 40 L 30 40\", \"#262626\", 4.4),\n ),\n },\n \"top/23_sleeve_far\": {\n svg: cuffed(\n \"sleeve_far_23\",\n SLEEVE_R_25,\n \"#f8f0e8\",\n 99,\n \"#f8f0e8\",\n line(\"M 0 30 L 32 32 M 0 40 L 32 42\", \"#262626\", 4.4),\n ),\n },\n // A yellow tee with a puppy.\n \"top/24\": {\n svg: top(\n \"top_24\",\n TOPS.top_24,\n \"#ffe880\",\n \"\",\n path(\n \"M 18 42 C 14 36 18 30 24 32 C 28 26 42 26 46 32 C 52 30 56 36 52 42 C 54 52 46 60 35 60 C 24 60 16 52 18 42 Z\",\n \"#f0a040\",\n { width: 2.6 },\n ) +\n circle(29, 44, 1.8, INK, { stroke: \"none\" }) +\n circle(41, 44, 1.8, INK, { stroke: \"none\" }) +\n ellipse(35, 51, 6, 4, \"#f8d090\", { width: 2 }) +\n line(\"M 32 52 C 34 54 36 54 38 52\", INK, 1.8),\n ),\n },\n \"top/24_sleeve_near\": { svg: path(SLEEVE_L_25, \"#f09010\") },\n \"top/24_sleeve_far\": { svg: path(SLEEVE_R_25, \"#f09010\") },\n // A brown jacket with a fur collar and a belt.\n \"top/26\": {\n svg: top(\n \"top_26\",\n \"M 6 90 L 4 30 C 6 18 12 10 20 8 C 30 4 60 4 72 8 C 82 12 88 20 88 32 L 86 90 L 50 90 L 46 80 L 42 90 Z\",\n \"#b06018\",\n line(\"M 46 40 L 46 80 M 14 40 L 14 86 M 78 40 L 78 86\", \"#7a4010\", 2.6) +\n flat(\"M 0 68 L 92 66 L 92 76 L 0 78 Z\", \"#7a4010\") +\n line(\"M 0 68 L 92 66 M 0 78 L 92 76\", INK, 2.6),\n path(\n \"M 12 18 C 20 6 40 2 60 4 C 76 6 86 14 84 26 C 76 36 56 40 40 38 C 24 36 12 30 12 18 Z\",\n \"#e0c8a8\",\n ) +\n path(\n \"M 26 16 C 34 10 50 10 60 14 C 54 22 44 26 36 26 C 30 24 26 20 26 16 Z\",\n \"#c0a080\",\n { width: 2.4 },\n ),\n ),\n },\n // A purple turtleneck, a teal band.\n \"top/27\": {\n svg: top(\n \"top_27\",\n TOPS.top_27,\n \"#c070d8\",\n flat(\"M 0 40 L 90 30 L 90 52 L 0 62 Z\", \"#40d8c8\") +\n line(\"M 0 52 L 90 42\", \"#f4f4f4\", 3),\n path(\n \"M 8 20 C 10 6 30 2 48 2 C 66 2 80 6 82 18 C 82 28 66 32 46 32 C 26 32 8 30 8 20 Z\",\n \"#c070d8\",\n ) +\n path(\"M 22 14 C 30 8 56 8 66 14 C 58 20 30 20 22 14 Z\", \"#9a40b8\", {\n width: 2.4,\n }),\n ),\n },\n \"top/27_sleeve_near\": {\n svg: cuffed(\n \"sleeve_near_27\",\n SLEEVE_L_25,\n \"#40d8c8\",\n 30,\n \"#4080e0\",\n line(\"M 0 30 L 30 30\", \"#f4f4f4\", 3),\n ),\n },\n \"top/27_sleeve_far\": {\n svg: cuffed(\n \"sleeve_far_27\",\n SLEEVE_R_25,\n \"#40d8c8\",\n 30,\n \"#4080e0\",\n line(\"M 0 30 L 32 32\", \"#f4f4f4\", 3),\n ),\n },\n // A cream shirt with suspenders and a gold buckle.\n \"top/28\": {\n svg: top(\n \"top_28\",\n TOPS.top_28,\n \"#ffffd0\",\n line(\"M 18 0 L 18 60 M 68 0 L 68 60\", \"#a06030\", 4) +\n flat(\"M 0 58 L 90 58 L 90 68 L 0 68 Z\", \"#7a4020\") +\n line(\"M 0 58 L 90 58 M 0 68 L 90 68\", INK, 2.6),\n ellipse(42, 63, 7, 5.5, \"#f8c838\", { width: 2.6 }) +\n circle(18, 63, 2.6, \"#f8c838\", { width: 2 }) +\n circle(68, 63, 2.6, \"#f8c838\", { width: 2 }),\n ),\n },\n // A light shirt with a purple tie and a belt; a grey coat (a mesh) over it.\n \"top/29\": {\n svg:\n path(\n \"M 3 8 C 10 6 18 10 24 16 L 30 22 L 36 16 C 42 10 50 6 60 4 L 60 58 C 44 60 20 60 3 58 Z\",\n \"#d8f0f4\",\n ) +\n path(\n \"M 22 10 L 38 10 L 32 22 L 36 42 L 30 50 L 24 42 L 28 22 Z\",\n \"#7050b0\",\n { width: 2.6 },\n ) +\n flat(\"M 3 48 L 60 48 L 60 58 L 3 58 Z\", \"#262626\") +\n rect(26, 47, 10, 9, 1, \"#c8c8c8\", { width: 2 }),\n },\n \"top/29_coat\": {\n svg: top(\n \"top_29_\",\n \"M 4 100 L 4 30 C 4 12 14 4 30 4 L 60 4 C 76 4 84 12 84 28 L 84 100 L 50 100 L 50 52 C 48 36 42 26 35 22 C 28 26 22 36 18.5 52 L 18.5 100 Z\",\n \"#6a6a6e\",\n line(\"M 4 90 L 18 90 M 50 90 L 84 90\", \"#9a9aa0\", 3) +\n path(\"M 56 64 L 72 66 L 72 72 L 56 70 Z\", \"#5a5a5e\", { width: 2.4 }),\n path(\n \"M 8 10 C 20 4 50 4 70 8 C 78 10 82 16 82 24 L 64 30 C 56 20 46 16 35 18 C 26 20 18 26 12 32 Z\",\n \"#5a5a5e\",\n ),\n ),\n },\n \"top/29_sleeve_near\": {\n svg: cuffed(\"sleeve_near_29\", SLEEVE_L_25, \"#6a6a6e\", 46, \"#9a9aa0\"),\n },\n \"top/29_sleeve_far\": {\n svg: cuffed(\"sleeve_far_29\", SLEEVE_R_25, \"#6a6a6e\", 46, \"#9a9aa0\"),\n },\n // A navy police jacket with a white collar.\n \"top/30\": {\n svg: top(\n \"top_30\",\n TOPS.top_30,\n \"#1e2e88\",\n line(\"M 42 30 L 42 80\", INK, 2.6) +\n path(\"M 54 46 L 66 46 L 66 50 L 54 50 Z\", \"#4090f0\", {\n stroke: \"none\",\n }) +\n circle(20, 62, 2.6, \"#f4f4f4\", { width: 2 }),\n path(\"M 22 4 L 40 28 L 26 40 L 16 18 Z\", \"#f4f4f8\", { width: 2.6 }) +\n path(\"M 62 4 L 44 28 L 50 36 L 66 18 Z\", \"#f4f4f8\", { width: 2.6 }),\n ),\n },\n \"top/30_sleeve_near\": {\n svg: cuffed(\n \"sleeve_near_30\",\n SLEEVE_L_25,\n \"#1e2e88\",\n 99,\n \"#1e2e88\",\n ellipse(16, 18, 3, 5, \"#4090f0\", { width: 2 }) +\n line(\"M 0 46 L 30 46\", \"#c8d0f0\", 2.6),\n ),\n },\n \"top/30_sleeve_far\": {\n svg: cuffed(\n \"sleeve_far_30\",\n SLEEVE_R_25,\n \"#1e2e88\",\n 99,\n \"#1e2e88\",\n ellipse(20, 18, 3, 5, \"#4090f0\", { width: 2 }) +\n line(\"M 0 46 L 32 48\", \"#c8d0f0\", 2.6),\n ),\n },\n // A grey hoodie with a pocket.\n \"top/31\": {\n svg: top(\n \"top_31\",\n TOPS.top_31,\n \"#c0c8c8\",\n flat(\"M 14 6 C 30 2 56 2 70 8 L 64 14 C 50 8 32 8 22 14 Z\", \"#98a4a4\") +\n flat(\"M 0 72 L 90 72 L 90 90 L 0 90 Z\", \"#a8b2b2\") +\n line(\"M 6 72 L 84 72\", INK, 2.8) +\n path(\"M 18 52 L 64 52 L 70 70 L 12 70 Z\", \"none\", { width: 2.6 }),\n line(\"M 24 38 L 20 48 M 48 38 L 52 48\", INK, 4) +\n line(\"M 21 45 L 19.5 49 M 51 45 L 52.5 49\", \"#ffffff\", 2.2),\n ),\n },\n \"top/31_sleeve_near\": {\n svg: cuffed(\"sleeve_near_31\", SLEEVE_L_40, \"#c0c8c8\", 54, \"#a8b2b2\"),\n },\n \"top/31_sleeve_far\": {\n svg: cuffed(\"sleeve_far_31\", SLEEVE_R_40, \"#c0c8c8\", 53, \"#a8b2b2\"),\n },\n // A denim vest with a red bandana.\n \"top/32\": {\n svg: top(\n \"top_32\",\n TOPS.top_32,\n \"#305a8a\",\n flat(\"M 30 30 L 54 30 L 54 82 L 30 82 Z\", \"#f4ead8\") +\n line(\"M 30 30 L 30 82 M 54 30 L 54 82\", INK, 2.6) +\n circle(42, 46, 2.6, \"#5a4a3a\", { width: 2 }) +\n circle(42, 58, 2.6, \"#5a4a3a\", { width: 2 }) +\n flat(\"M 0 66 L 90 66 L 90 76 L 0 76 Z\", \"#5a3a28\") +\n line(\n \"M 4 40 L 10 44 L 14 40 L 20 44 M 64 40 L 70 44 L 74 40 L 80 44\",\n \"#1e3e60\",\n 2.6,\n ),\n path(\n \"M 8 18 C 20 6 40 2 60 4 C 74 6 82 12 82 20 C 70 30 52 36 40 40 C 28 34 14 28 8 18 Z\",\n \"#d82838\",\n ) + circle(42, 70, 6, \"#a0a0a0\", { width: 2.6 }),\n ),\n },\n \"top/32_sleeve_near\": { svg: path(SLEEVE_L_25, \"#f8f0e4\") },\n \"top/32_sleeve_far\": { svg: path(SLEEVE_R_25, \"#f8f0e4\") },\n // Gingham shirts with a belt: blue, and purple.\n \"top/33\": {\n svg: gingham(\n \"top_33\",\n TOPS.top_33,\n \"#4a70e0\",\n \"#a8c0f8\",\n \"#262628\",\n \"#3a3a3a\",\n ),\n },\n \"top/34\": {\n svg: gingham(\n \"top_34\",\n TOPS.top_34,\n \"#a060c0\",\n \"#80d0f0\",\n \"#9a6a40\",\n \"#7a4a28\",\n ),\n },\n \"top/33_sleeve_near\": {\n svg: cap(\n \"sleeve_near_33\",\n CAP_L,\n \"#4a70e0\",\n plaid(0, 0, 40, 40, 9, \"#ffffff\"),\n ),\n },\n \"top/33_sleeve_far\": {\n svg: cap(\n \"sleeve_far_33\",\n CAP_R,\n \"#4a70e0\",\n plaid(0, 0, 40, 40, 9, \"#ffffff\"),\n ),\n },\n \"top/34_sleeve_near\": {\n svg: cap(\n \"sleeve_near_34\",\n CAP_L,\n \"#a060c0\",\n plaid(0, 0, 40, 40, 9, \"#80d0f0\"),\n ),\n },\n \"top/34_sleeve_far\": {\n svg: cap(\n \"sleeve_far_34\",\n CAP_R,\n \"#a060c0\",\n plaid(0, 0, 40, 40, 9, \"#80d0f0\"),\n ),\n },\n // A black jersey, number 30.\n \"top/35\": {\n svg: top(\n \"top_35\",\n \"M 4 76 L 4 30 C 5 18 10 8 18 3 C 20 14 28 22 42 22 C 56 22 64 14 66 3 C 74 8 80 18 82 30 C 84 50 84 66 83 76 C 60 79 26 79 4 76 Z\",\n \"#1e1e20\",\n line(\"M 20 5 C 22 16 30 24 42 24 C 54 24 62 16 64 5\", \"#20a080\", 3.4) +\n line(\"M 20 9 C 23 19 31 27 42 27 C 53 27 61 19 64 9\", \"#f4f4f4\", 2),\n `<text x=\"42\" y=\"58\" font-family=\"Arial Black, Arial, sans-serif\" font-weight=\"900\" font-size=\"22\" text-anchor=\"middle\" fill=\"#ffffff\" stroke=\"${INK}\" stroke-width=\"2.6\" paint-order=\"stroke\">30</text>`,\n ),\n },\n // A lavender tee with green shoulders.\n \"top/36\": {\n svg: top(\n \"top_36\",\n TOPS.top_36,\n \"#a8a8d0\",\n flat(\n \"M 0 0 L 90 0 L 90 22 C 70 26 56 26 42 22 C 28 26 14 26 0 22 Z\",\n \"#108060\",\n ) + line(\"M 0 22 C 14 26 28 26 42 22 C 56 26 70 26 90 22\", INK, 2.6),\n ),\n },\n \"top/36_sleeve_near\": { svg: path(SLEEVE_L_25, \"#108060\") },\n \"top/36_sleeve_far\": { svg: path(SLEEVE_R_25, \"#108060\") },\n // A cream tee with a red heart.\n \"top/37\": {\n svg: top(\n \"top_37\",\n TOPS.top_37,\n \"#f8f0e4\",\n \"\",\n path(\n \"M 34 64 C 26 58 18 52 18 44 C 18 38 24 36 28 38 C 30 38 32 40 34 42 C 36 40 38 38 40 38 C 46 36 50 40 50 44 C 50 52 42 58 34 64 Z\",\n \"#f06060\",\n { width: 2.8 },\n ),\n ),\n },\n \"top/37_sleeve_near\": {\n svg: cap(\n \"sleeve_near_37\",\n CAP2_L,\n \"#f8f0e4\",\n flat(\"M 0 26 L 30 26 L 30 30 L 0 30 Z\", \"#e8dccc\"),\n ),\n },\n \"top/37_sleeve_far\": {\n svg: cap(\n \"sleeve_far_37\",\n CAP2_R,\n \"#f8f0e4\",\n flat(\"M 0 26 L 30 28 L 30 32 L 0 30 Z\", \"#e8dccc\"),\n ),\n },\n // A white puffer jacket with a blue stripe.\n \"top/38\": {\n svg: top(\n \"top_38\",\n TOPS.top_38,\n \"#e8e8f0\",\n line(\"M 0 54 L 90 50\", \"#2090e0\", 3.4) +\n line(\"M 6 30 C 30 36 60 36 84 28 M 40 34 L 40 82\", INK, 2.8),\n line(\"M 18 13 C 30 20 50 20 64 15\", INK, 3),\n ),\n },\n \"top/38_sleeve_near\": {\n svg: cuffed(\n \"sleeve_near_38\",\n SLEEVE_L_25,\n \"#e8e8f0\",\n 99,\n \"#e8e8f0\",\n line(\"M 0 20 L 30 20\", \"#2090e0\", 3.2),\n ),\n },\n \"top/38_sleeve_far\": {\n svg: cuffed(\n \"sleeve_far_38\",\n SLEEVE_R_25,\n \"#e8e8f0\",\n 99,\n \"#e8e8f0\",\n line(\"M 0 20 L 32 22\", \"#2090e0\", 3.2),\n ),\n },\n // A pink shirt with flowers.\n \"top/39\": {\n svg: top(\n \"top_39\",\n TOPS.top_39,\n \"#f8c0e0\",\n [\n [18, 44],\n [60, 52],\n [30, 70],\n [72, 26],\n ]\n .map(([x, y]) =>\n path(\n `M ${x} ${y! - 6} C ${x! + 3} ${y! - 9} ${x! + 8} ${y! - 6} ${x! + 6} ${y! - 2} C ${x! + 10} ${y} ${x! + 8} ${y! + 6} ${x! + 4} ${y! + 4} C ${x! + 2} ${y! + 9} ${x! - 4} ${y! + 8} ${x! - 4} ${y! + 4} C ${x! - 9} ${y! + 4} ${x! - 9} ${y! - 2} ${x! - 5} ${y! - 2} C ${x! - 7} ${y! - 6} ${x! - 3} ${y! - 9} ${x} ${y! - 6} Z`,\n \"none\",\n { stroke: \"#e080b8\", width: 2.2 },\n ),\n )\n .join(\"\") + line(\"M 42 30 L 42 82\", INK, 2.4),\n path(\n \"M 22 4 C 26 14 32 22 40 28 L 34 34 C 26 28 20 18 22 4 Z\",\n \"#f8c0e0\",\n { width: 2.6 },\n ) +\n path(\n \"M 62 4 C 58 14 52 22 44 28 L 50 34 C 58 28 64 18 62 4 Z\",\n \"#f8c0e0\",\n { width: 2.6 },\n ),\n ),\n },\n \"top/39_sleeve_near\": {\n svg: cap(\n \"sleeve_near_39\",\n CAP_L,\n \"#f8c0e0\",\n line(\"M 12 14 C 16 10 22 12 22 16 C 26 16 26 22 22 22\", \"#e080b8\", 2.2),\n ),\n },\n \"top/39_sleeve_far\": {\n svg: cap(\n \"sleeve_far_39\",\n CAP_R,\n \"#f8c0e0\",\n line(\"M 12 12 C 16 8 22 10 22 14 C 26 14 26 20 22 20\", \"#e080b8\", 2.2),\n ),\n },\n // A lilac hoodie with a pink hood.\n \"top/41\": {\n svg: top(\n \"top_41\",\n TOPS.top_41,\n \"#b0a0e8\",\n flat(\n \"M 0 0 L 92 0 L 92 30 C 70 36 50 38 40 34 C 28 36 14 32 0 26 Z\",\n \"#f0b0d0\",\n ) +\n line(\"M 40 36 L 40 84\", \"#8a7ad0\", 3) +\n line(\"M 10 60 L 20 66 M 64 58 L 76 64\", INK, 2.6),\n ),\n },\n \"top/41_sleeve_near\": {\n svg: cuffed(\"sleeve_near_41\", SLEEVE_L_40, \"#f0b0d0\", 54, \"#d07098\"),\n },\n \"top/41_sleeve_far\": {\n svg: cuffed(\"sleeve_far_41\", SLEEVE_R_40, \"#f0b0d0\", 53, \"#d07098\"),\n },\n // A cream tee with blue and red stripes.\n \"top/42\": {\n svg: top(\n \"top_42\",\n TOPS.top_42,\n \"#f8f0e8\",\n line(\"M 0 50 L 90 50\", \"#4a80f0\", 4.4) +\n line(\"M 0 60 L 90 60\", \"#e04858\", 4.4),\n line(\"M 22 5 C 22 16 32 24 42 24 C 52 24 62 16 62 5\", \"#c8c8c8\", 3.4),\n ),\n },\n \"top/42_sleeve_near\": {\n svg: cuffed(\n \"sleeve_near_42\",\n SLEEVE_L_25,\n \"#f8f0e8\",\n 99,\n \"#f8f0e8\",\n line(\"M 0 34 L 30 34\", \"#4a80f0\", 4) +\n line(\"M 0 44 L 30 44\", \"#e04858\", 4),\n ),\n },\n \"top/42_sleeve_far\": {\n svg: cuffed(\n \"sleeve_far_42\",\n SLEEVE_R_25,\n \"#f8f0e8\",\n 99,\n \"#f8f0e8\",\n line(\"M 0 34 L 32 36\", \"#4a80f0\", 4) +\n line(\"M 0 44 L 32 46\", \"#e04858\", 4),\n ),\n },\n // A white shirt with blue pinstripes.\n \"top/43\": {\n svg: top(\n \"top_43\",\n TOPS.top_43,\n \"#f4f8fc\",\n line(\n \"M 12 0 L 12 80 M 24 0 L 24 80 M 60 0 L 60 80 M 72 0 L 72 80\",\n \"#80c0f0\",\n 3,\n ) + line(\"M 42 30 L 42 80\", INK, 2.4),\n path(\n \"M 22 4 C 26 14 32 22 40 28 L 34 34 C 26 28 20 18 22 4 Z\",\n \"#f4f8fc\",\n { width: 2.6 },\n ) +\n path(\n \"M 62 4 C 58 14 52 22 44 28 L 50 34 C 58 28 64 18 62 4 Z\",\n \"#f4f8fc\",\n { width: 2.6 },\n ),\n ),\n },\n \"top/43_sleeve_near\": {\n svg: cap(\n \"sleeve_near_43\",\n CAP2_L,\n \"#f4f8fc\",\n line(\"M 15 4 L 15 34\", \"#80c0f0\", 3),\n ),\n },\n \"top/43_sleeve_far\": {\n svg: cap(\n \"sleeve_far_43\",\n CAP2_R,\n \"#f4f8fc\",\n line(\"M 17 4 L 14 36\", \"#80c0f0\", 3),\n ),\n },\n // A grey V-neck sweater over a cream shirt.\n \"top/44\": {\n svg: top(\n \"top_44\",\n TOPS.top_44,\n \"#b0b4bc\",\n line(\"M 0 64 L 90 64\", \"#8a8e96\", 2.4) +\n flat(\n \"M 18 0 L 26 22 C 30 30 34 34 39 37 C 46 32 54 24 60 14 L 68 0 Z\",\n \"#f4f6ee\",\n ) +\n line(\n \"M 18 6 L 26 22 C 30 30 34 34 39 37 C 46 32 54 24 60 14 L 66 4\",\n \"#8a8e96\",\n 3.2,\n ),\n ),\n },\n \"top/44_sleeve_near\": {\n svg: cuffed(\"sleeve_near_44\", SLEEVE_L_40, \"#f4f6ee\", 56, \"#f4f6ee\"),\n },\n \"top/44_sleeve_far\": {\n svg: cuffed(\"sleeve_far_44\", SLEEVE_R_40, \"#f4f6ee\", 55, \"#f4f6ee\"),\n },\n // A green-and-navy striped rugby shirt.\n \"top/45\": {\n svg: top(\n \"top_45\",\n TOPS.top_45,\n \"#2e5a4a\",\n flat(\"M 0 44 L 90 44 L 90 60 L 0 60 Z\", \"#3a3a78\") +\n line(\"M 0 40 L 90 40 M 0 64 L 90 64\", \"#e8e890\", 2.6),\n path(\"M 18 8 C 30 4 50 4 64 8 L 56 14 C 50 10 32 10 28 14 Z\", \"#f0f0c8\", {\n width: 2.6,\n }) +\n path(\"M 28 14 L 42 30 L 36 44 C 34 48 30 48 28 44 Z\", \"#f0f0c8\", {\n width: 2.6,\n }),\n ),\n },\n \"top/45_sleeve_near\": {\n svg: cuffed(\n \"sleeve_near_45\",\n SLEEVE_L_25,\n \"#2e4a40\",\n 99,\n \"#2e4a40\",\n flat(\"M 0 26 L 30 26 L 30 38 L 0 38 Z\", \"#3a3a78\") +\n line(\"M 0 32 L 30 32\", \"#e8e890\", 2.4),\n ),\n },\n \"top/45_sleeve_far\": {\n svg: cuffed(\n \"sleeve_far_45\",\n SLEEVE_R_25,\n \"#2e4a40\",\n 99,\n \"#2e4a40\",\n flat(\"M 0 26 L 32 28 L 32 40 L 0 38 Z\", \"#3a3a78\") +\n line(\"M 0 32 L 32 34\", \"#e8e890\", 2.4),\n ),\n },\n // The arms of tops 46–59.\n \"top/46_sleeve_near\": { svg: path(SLEEVE_L_25, \"#2e6a60\") },\n \"top/46_sleeve_far\": { svg: path(SLEEVE_R_25, \"#2e6a60\") },\n \"top/46_cap_near\": { svg: cap(\"sleeve_near_46_\", CAP_L, \"#f0b040\") },\n \"top/46_cap_far\": { svg: cap(\"sleeve_far_46_\", CAP_R, \"#f0b040\") },\n \"top/47_sleeve_near\": {\n svg: cap(\n \"sleeve_near_47\",\n CAP2_L,\n \"#f0f0f8\",\n flat(\"M 0 26 L 30 26 L 30 36 L 0 36 Z\", \"#c8c8d8\"),\n ),\n },\n \"top/47_sleeve_far\": {\n svg: cap(\n \"sleeve_far_47\",\n CAP2_R,\n \"#f0f0f8\",\n flat(\"M 0 26 L 30 28 L 30 38 L 0 36 Z\", \"#c8c8d8\"),\n ),\n },\n \"top/48_sleeve_near\": {\n svg: cuffed(\n \"sleeve_near_48\",\n SLEEVE_L_25,\n \"#6a90c0\",\n 99,\n \"#6a90c0\",\n dashed(\"M 9 4 L 9 52 M 20 4 L 20 52\", \"#4a70a0\", 2, \"4 3\"),\n ),\n },\n \"top/48_sleeve_far\": {\n svg: cuffed(\n \"sleeve_far_48\",\n SLEEVE_R_25,\n \"#6a90c0\",\n 99,\n \"#6a90c0\",\n dashed(\"M 14 4 L 9 52 M 25 4 L 20 52\", \"#4a70a0\", 2, \"4 3\"),\n ),\n },\n \"top/49_sleeve_near\": {\n svg: cuffed(\n \"sleeve_near_49\",\n SLEEVE_L_25,\n \"#f0ead8\",\n 46,\n \"#ffffff\",\n line(\"M 0 50 L 30 50\", INK, 2.6),\n ),\n },\n \"top/49_sleeve_far\": {\n svg: cuffed(\n \"sleeve_far_49\",\n SLEEVE_R_25,\n \"#f0ead8\",\n 46,\n \"#ffffff\",\n line(\"M 0 50 L 32 52\", INK, 2.6),\n ),\n },\n \"top/50_sleeve_near\": {\n svg: cap(\n \"sleeve_near_50\",\n \"M 4 29 C 3 18 6 8 13 5 C 20 3 25 8 29 16 C 31 21 33 26 32 29 L 26 27 L 24 32 L 18 28 L 14 32 L 10 28 Z\",\n \"#f8f070\",\n ),\n },\n \"top/50_sleeve_far\": {\n svg: cap(\n \"sleeve_far_50\",\n \"M 3 24 C 6 14 12 5 20 3 C 26 2 30 6 31 12 L 31 30 L 26 28 L 22 33 L 18 29 L 13 32 L 9 28 Z\",\n \"#f8f070\",\n ),\n },\n \"top/51_sleeve_near\": {\n svg: cuffed(\"sleeve_near_51\", SLEEVE_L_40, \"#a0e0d0\", 54, \"#80c8b8\"),\n },\n \"top/51_sleeve_far\": {\n svg: cuffed(\"sleeve_far_51\", SLEEVE_R_40, \"#a0e0d0\", 53, \"#80c8b8\"),\n },\n \"top/52_sleeve_near\": {\n svg: cap(\n \"sleeve_near_52\",\n CAP2_L,\n \"#f4f4f8\",\n flat(\"M 0 26 L 30 26 L 30 36 L 0 36 Z\", \"#d8d8e0\"),\n ),\n },\n \"top/52_sleeve_far\": {\n svg: cap(\n \"sleeve_far_52\",\n CAP2_R,\n \"#f4f4f8\",\n flat(\"M 0 26 L 30 28 L 30 38 L 0 36 Z\", \"#d8d8e0\"),\n ),\n },\n \"top/53_sleeve_near\": {\n svg: cap(\n \"sleeve_near_53\",\n CAP2_L,\n \"#f8d0e0\",\n flat(\"M 0 27 L 30 27 L 30 36 L 0 36 Z\", \"#ffffff\"),\n ),\n },\n \"top/53_sleeve_far\": {\n svg: cap(\n \"sleeve_far_53\",\n CAP2_R,\n \"#f8d0e0\",\n flat(\"M 0 27 L 30 29 L 30 38 L 0 36 Z\", \"#ffffff\"),\n ),\n },\n \"top/54_sleeve_near\": { svg: cap(\"sleeve_near_54\", CAP_L, \"#f4ece4\") },\n \"top/54_sleeve_far\": { svg: cap(\"sleeve_far_54\", CAP_R, \"#f4ece4\") },\n \"top/56_sleeve_near\": {\n svg: cuffed(\"sleeve_near_56\", SLEEVE_L_16, \"#a02060\", 46, \"#f8f0e0\"),\n },\n \"top/56_sleeve_far\": {\n svg: cuffed(\"sleeve_far_56\", SLEEVE_R_16, \"#a02060\", 46, \"#f8f0e0\"),\n },\n \"top/57_sleeve_near\": {\n svg: cuffed(\n \"sleeve_near_57\",\n SLEEVE_L_25,\n \"#c8986a\",\n 99,\n \"#c8986a\",\n ellipse(20, 22, 3.4, 5, \"#3a3a4a\", { width: 2 }),\n ),\n },\n \"top/57_sleeve_far\": {\n svg: cuffed(\n \"sleeve_far_57\",\n SLEEVE_R_25,\n \"#c8986a\",\n 99,\n \"#c8986a\",\n ellipse(22, 22, 3.4, 5, \"#3a3a4a\", { width: 2 }),\n ),\n },\n \"top/59_sleeve_near\": { svg: path(SLEEVE_L_40, \"#3a3a8a\") },\n \"top/59_sleeve_far\": { svg: path(SLEEVE_R_40, \"#3a3a8a\") },\n // An orange tee.\n \"top/46\": {\n svg: top(\n \"top_46\",\n TOPS.top_46,\n \"#f0b848\",\n \"\",\n line(\"M 22 5 C 22 16 32 24 42 24 C 52 24 62 16 62 5\", \"#c88a20\", 3),\n ),\n },\n // A black leather jacket over a white shirt, a gold buckle.\n \"top/47\": {\n svg: top(\n \"top_47\",\n TOPS.top_47,\n \"#2a2a2c\",\n flat(\"M 30 0 L 54 0 L 54 82 L 30 82 Z\", \"#f4f4f8\") +\n line(\"M 30 30 L 30 66 M 54 30 L 54 66\", INK, 2.6) +\n dashed(\n \"M 10 40 L 26 40 M 58 40 L 80 40 M 10 52 L 26 52 M 58 52 L 80 52\",\n \"#6a6a6e\",\n 2.2,\n \"3 3\",\n ) +\n flat(\"M 0 64 L 90 64 L 90 78 L 0 78 Z\", \"#1a1a1c\") +\n line(\"M 0 64 L 90 64\", INK, 2.6),\n path(\n \"M 22 4 C 26 14 32 22 40 28 L 30 36 C 22 28 18 18 22 4 Z\",\n \"#f4f4f8\",\n { width: 2.6 },\n ) +\n path(\n \"M 62 4 C 58 14 52 22 44 28 L 54 36 C 62 28 66 18 62 4 Z\",\n \"#f4f4f8\",\n { width: 2.6 },\n ) +\n rect(32, 63, 18, 14, 2, \"#e8c060\", { width: 2.6 }) +\n rect(37, 67, 8, 6, 1, \"#1a1a1c\", { width: 2 }),\n ),\n },\n // A blue vest over a white shirt.\n \"top/48\": {\n svg: top(\n \"top_48\",\n TOPS.top_48,\n \"#6a90c0\",\n dashed(\"M 12 30 L 12 66 M 74 30 L 74 66\", \"#4a70a0\", 2.2, \"4 3\") +\n line(\"M 42 30 L 42 70\", INK, 2.4),\n path(\"M 24 4 L 42 30 L 36 34 L 20 12 Z\", \"#f4f4f8\", { width: 2.4 }) +\n path(\"M 60 4 L 42 30 L 48 34 L 64 12 Z\", \"#f4f4f8\", { width: 2.4 }) +\n circle(48, 46, 2.6, \"#3a6090\", { width: 2 }),\n ),\n },\n // A cream suit, a yellow tie.\n \"top/49\": {\n svg: top(\n \"top_49\",\n TOPS.top_49,\n \"#f0ead8\",\n flat(\"M 24 0 L 42 40 L 60 0 Z\", \"#ffffff\") +\n path(\"M 39 14 L 45 14 L 47 34 L 42 40 L 37 34 Z\", \"#f8d040\", {\n width: 2,\n }),\n path(\"M 22 4 L 40 40 L 30 52 L 16 22 Z\", \"#e4dcc4\", { width: 2.6 }) +\n path(\"M 62 4 L 44 40 L 50 48 L 68 18 Z\", \"#e4dcc4\", { width: 2.6 }) +\n line(\"M 60 30 L 66 32\", \"#f8d040\", 3),\n ),\n },\n // An olive vest open over a yellow tee, patches, a skull buckle.\n \"top/50\": {\n svg: top(\n \"top_50\",\n TOPS.top_50,\n \"#f8f070\",\n flat(\n \"M 0 0 L 22 0 C 20 20 20 40 22 58 L 0 58 Z M 66 0 L 90 0 L 90 58 L 66 58 C 68 40 68 20 66 0 Z\",\n \"#8a8020\",\n ) +\n line(\n \"M 22 2 C 20 20 20 40 22 58 M 66 2 C 68 20 68 40 66 58\",\n INK,\n 2.8,\n ) +\n rect(6, 30, 9, 10, 2, \"#c050d0\", { width: 2 }) +\n rect(70, 26, 10, 8, 2, \"#40c0a0\", { width: 2 }) +\n circle(74, 44, 4, \"#f07030\", { width: 2 }) +\n flat(\"M 0 58 L 90 58 L 90 74 L 0 74 Z\", \"#c08a10\") +\n line(\"M 4 58 L 86 58\", INK, 2.8),\n path(\"M 24 4 L 30 14 L 36 6 L 42 16 L 48 6 L 54 14 L 60 4 Z\", \"#8a8020\", {\n width: 2.4,\n }) +\n path(\n \"M 38 58 C 38 54 50 54 50 58 C 50 62 48 64 46 64 L 42 64 C 40 64 38 62 38 58 Z\",\n \"#e8e8e8\",\n { width: 2.4 },\n ),\n ),\n },\n // A mint V-neck sweater over a cream collar.\n \"top/51\": {\n svg: top(\n \"top_51\",\n TOPS.top_51,\n \"#a0e0d0\",\n line(\"M 0 64 L 90 64\", \"#70b8a8\", 2.4) +\n flat(\n \"M 18 0 L 26 22 C 30 30 34 34 39 37 C 46 32 54 24 60 14 L 68 0 Z\",\n \"#f4f6ee\",\n ) +\n line(\n \"M 18 6 L 26 22 C 30 30 34 34 39 37 C 46 32 54 24 60 14 L 66 4\",\n \"#60a898\",\n 3.2,\n ),\n ),\n },\n // A black tank over a white shirt.\n \"top/52\": {\n svg: top(\n \"top_52\",\n TOPS.top_52,\n \"#f4f4f8\",\n path(\"M 6 30 L 82 30 L 84 72 L 4 72 Z\", \"#3a3e48\", { width: 2.6 }) +\n line(\"M 22 4 L 18 30 M 64 4 L 68 30\", \"#3a3e48\", 6) +\n line(\"M 22 30 C 32 34 52 34 64 30\", \"#c8c8d0\", 3),\n ),\n },\n // A pink tee with a music note.\n \"top/53\": {\n svg: top(\n \"top_53\",\n TOPS.top_53,\n \"#f8d0e0\",\n \"\",\n line(\"M 34 50 L 34 36 L 42 40\", INK, 4) +\n line(\"M 34 50 L 34 36 L 42 40\", \"#e04070\", 2) +\n circle(30, 50, 4, \"#e04070\", { width: 2.4 }),\n ),\n },\n // Denim overalls over a cream shirt.\n \"top/54\": {\n svg: top(\n \"top_54\",\n TOPS.top_54,\n \"#f8f0e8\",\n path(\"M 8 30 L 80 30 L 82 92 L 6 92 Z\", \"#4a6a90\", { width: 2.6 }) +\n line(\"M 18 4 L 16 30 M 66 4 L 70 30\", \"#4a6a90\", 6) +\n flat(\"M 0 50 L 92 50 L 92 58 L 0 58 Z\", \"#262626\") +\n dashed(\"M 12 66 L 76 66 M 44 58 L 44 92\", \"#2e4a6a\", 2.2, \"3 3\"),\n rect(36, 47, 16, 12, 2, \"#a0a0a0\", { width: 2.4 }),\n ),\n },\n // A blue basketball jersey, number 7.\n \"top/55\": {\n svg: top(\n \"top_55\",\n \"M 4 76 L 4 30 C 5 18 10 8 18 3 C 20 14 28 22 42 22 C 56 22 64 14 66 3 C 74 8 80 18 82 30 C 84 50 84 66 83 76 C 60 79 26 79 4 76 Z\",\n \"#2840b8\",\n line(\"M 20 5 C 22 16 30 24 42 24 C 54 24 62 16 64 5\", \"#e03040\", 3.4) +\n line(\"M 20 9 C 23 19 31 27 42 27 C 53 27 61 19 64 9\", \"#f4f4f4\", 2),\n `<text x=\"42\" y=\"60\" font-family=\"Arial Black, Arial, sans-serif\" font-weight=\"900\" font-size=\"22\" text-anchor=\"middle\" fill=\"#e03040\" stroke=\"#ffffff\" stroke-width=\"2.6\" paint-order=\"stroke\">7</text>`,\n ),\n },\n // A magenta sweater over a cream collared shirt.\n \"top/56\": {\n svg: top(\n \"top_56\",\n TOPS.top_56,\n \"#a02060\",\n flat(\"M 0 76 L 90 76 L 90 86 L 0 86 Z\", \"#f8f0e0\") +\n line(\"M 0 76 L 90 76\", INK, 2.6),\n path(\n \"M 20 4 C 24 14 32 22 38 26 L 42 30 L 30 36 C 24 30 18 18 20 4 Z\",\n \"#f8f0e0\",\n { width: 2.6 },\n ) +\n path(\n \"M 64 4 C 60 14 52 22 46 26 L 42 30 L 54 36 C 60 30 66 18 64 4 Z\",\n \"#f8f0e0\",\n { width: 2.6 },\n ) +\n path(\"M 36 32 L 48 32 L 42 40 Z\", \"#d04080\", { width: 2.2 }),\n ),\n },\n // A tan blazer.\n \"top/57\": {\n svg: top(\n \"top_57\",\n TOPS.top_57,\n \"#c8986a\",\n line(\"M 42 30 L 42 80\", INK, 2.4) +\n path(\"M 56 46 L 68 46 L 68 50 L 56 50 Z\", \"#a87848\", { width: 2 }) +\n circle(36, 60, 2.6, \"#7a5838\", { width: 2 }),\n path(\"M 22 4 L 40 30 L 30 44 L 16 20 Z\", \"#d8ac80\", { width: 2.6 }) +\n path(\"M 62 4 L 44 30 L 50 40 L 66 18 Z\", \"#d8ac80\", { width: 2.6 }),\n ),\n },\n // A teal parka with a mint fur hood and a belt.\n \"top/58\": {\n svg: top(\n \"top_58\",\n \"M 6 90 L 4 30 C 6 18 12 10 20 8 C 30 4 60 4 72 8 C 82 12 88 20 88 32 L 86 90 L 50 90 L 46 80 L 42 90 Z\",\n \"#2a5a58\",\n line(\"M 46 40 L 46 80\", \"#1a3a38\", 2.6) +\n flat(\"M 0 66 L 92 64 L 92 74 L 0 76 Z\", \"#8a5a30\") +\n line(\"M 0 66 L 92 64 M 0 76 L 92 74\", INK, 2.6),\n path(\n \"M 12 18 C 20 6 40 2 60 4 C 76 6 86 14 84 26 C 76 36 56 40 40 38 C 24 36 12 30 12 18 Z\",\n \"#70e0c0\",\n ) +\n path(\n \"M 26 16 C 34 10 50 10 60 14 C 54 22 44 26 36 26 C 30 24 26 20 26 16 Z\",\n \"#40b898\",\n { width: 2.4 },\n ),\n ),\n },\n // A navy hoodie with drawstrings.\n \"top/59\": {\n svg: top(\n \"top_59\",\n TOPS.top_59,\n \"#3a3a8a\",\n flat(\"M 14 6 C 30 2 56 2 70 8 L 64 14 C 50 8 32 8 22 14 Z\", \"#2a2a6a\") +\n flat(\"M 0 72 L 90 72 L 90 90 L 0 90 Z\", \"#4a4a9a\") +\n line(\"M 6 72 L 84 72\", INK, 2.8) +\n line(\n \"M 24 74 L 24 84 M 38 74 L 38 84 M 52 74 L 52 84 M 66 74 L 66 84\",\n \"#2a2a6a\",\n 2.4,\n ) +\n path(\"M 18 52 L 64 52 L 70 70 L 12 70 Z\", \"none\", { width: 2.6 }),\n line(\"M 24 38 L 20 48 M 48 38 L 52 48\", INK, 4) +\n line(\"M 21 45 L 19.5 49 M 51 45 L 52.5 49\", \"#c8c8e0\", 2.2),\n ),\n },\n // A red pinstripe blazer with brass buttons.\n \"top/60\": {\n svg: top(\n \"top_60\",\n TOPS.top_60,\n \"#c02040\",\n line(\n \"M 16 20 L 16 80 M 34 40 L 34 80 M 50 40 L 50 80 M 66 20 L 66 80\",\n \"#e05070\",\n 2.4,\n ),\n path(\"M 22 4 L 42 30 L 30 48 L 18 22 Z\", \"#d83050\", { width: 2.6 }) +\n path(\"M 62 4 L 42 30 L 46 42 L 66 18 Z\", \"#d83050\", { width: 2.6 }) +\n circle(32, 66, 3, \"#e8c040\", { width: 2 }) +\n circle(48, 66, 3, \"#e8c040\", { width: 2 }),\n ),\n },\n // Skirts (one image each, on the waist).\n \"pants/6\": {\n svg: cloth(\n \"pants_6\",\n SKIRT.a,\n \"#d0c0d8\",\n line(\"M 0 30 C 30 36 60 36 92 28\", \"#b8a4c4\", 2.4),\n ),\n },\n \"pants/7\": {\n svg: cloth(\n \"pants_7\",\n SKIRT.a,\n \"#e83860\",\n plaid(0, 0, 92, 40, 12, \"#f878a8\") +\n line(\n \"M 6 0 L 6 40 M 30 0 L 30 40 M 54 0 L 54 40 M 78 0 L 78 40\",\n \"#f8a040\",\n 2.4,\n ),\n ),\n },\n \"pants/8\": {\n svg: cloth(\n \"pants_8\",\n SKIRT.tall,\n \"#f0b0d8\",\n flat(\n \"M 0 34 C 10 30 20 38 30 34 C 40 30 50 38 60 34 C 70 30 80 38 93 34 L 93 46 L 0 46 Z\",\n \"#f8d0e8\",\n ) +\n [\n [18, 14],\n [44, 22],\n [70, 12],\n [30, 28],\n [62, 28],\n ]\n .map(([x, y], i) =>\n path(\n `M ${x} ${y! + 4} C ${x! - 5} ${y! + 1} ${x! - 5} ${y! - 4} ${x! - 2} ${y! - 4} C ${x! - 1} ${y! - 4} ${x} ${y! - 3} ${x} ${y! - 2} C ${x} ${y! - 3} ${x! + 1} ${y! - 4} ${x! + 2} ${y! - 4} C ${x! + 5} ${y! - 4} ${x! + 5} ${y! + 1} ${x} ${y! + 4} Z`,\n i % 2 ? \"#ffffff\" : \"#f04080\",\n { stroke: \"none\" },\n ),\n )\n .join(\"\"),\n ),\n },\n \"pants/9\": {\n svg: cloth(\n \"pants_9\",\n SKIRT.c,\n \"#3a3a3a\",\n path(\n \"M 24 4 L 66 4 C 66 20 64 30 60 34 C 54 38 36 38 30 34 C 26 30 24 20 24 4 Z\",\n \"#ffffff\",\n { width: 2.4 },\n ) +\n [30, 38, 46, 54, 62]\n .map((x) => circle(x, 36, 3.6, \"#ffffff\", { width: 2 }))\n .join(\"\") +\n line(\"M 0 6 L 92 6\", \"#ffffff\", 4),\n ),\n },\n \"pants/10\": {\n svg: cloth(\n \"pants_10\",\n SKIRT.b,\n \"#70a0d0\",\n line(\n \"M 2 30 C 10 24 16 32 24 26 C 32 20 38 30 46 24 C 54 18 62 30 70 24 C 78 18 84 28 90 24\",\n \"#4a78b0\",\n 2.6,\n ),\n ),\n },\n \"pants/11\": {\n svg: cloth(\n \"pants_11\",\n SKIRT.tutu,\n \"#e0f4f8\",\n line(\n \"M 16 6 C 14 16 14 26 18 34 M 32 6 C 30 16 30 26 32 36 M 50 6 C 50 16 50 26 50 36 M 68 6 C 70 16 70 26 68 34\",\n \"#a8d8e8\",\n 2.6,\n ),\n ),\n },\n \"pants/17\": {\n svg: cloth(\n \"pants_17\",\n SKIRT.pleat,\n \"#262628\",\n line(\n \"M 18 6 L 14 34 M 32 6 L 30 36 M 46 6 L 46 36 M 60 6 L 62 36 M 74 6 L 78 34\",\n \"#4a4a4e\",\n 2.4,\n ),\n ),\n },\n \"pants/36\": {\n svg: cloth(\n \"pants_36\",\n SKIRT.pleat,\n \"#3a3a90\",\n line(\n \"M 18 6 L 14 34 M 32 6 L 30 36 M 46 6 L 46 36 M 60 6 L 62 36 M 74 6 L 78 34\",\n \"#5a5ab8\",\n 2.4,\n ),\n ),\n },\n \"pants/18\": {\n svg: cloth(\n \"pants_18\",\n SKIRT.layer,\n \"#f8e0e0\",\n flat(\n \"M 0 18 C 10 14 20 22 30 18 C 40 14 50 22 60 18 C 70 14 80 22 96 18 L 96 40 L 0 40 Z\",\n \"#c8a898\",\n ) +\n line(\n \"M 0 18 C 10 14 20 22 30 18 C 40 14 50 22 60 18 C 70 14 80 22 96 18\",\n INK,\n 2.6,\n ),\n ),\n },\n \"pants/47\": {\n svg: cloth(\n \"pants_47\",\n SKIRT.layer,\n \"#f4f8f8\",\n flat(\n \"M 0 18 C 10 14 20 22 30 18 C 40 14 50 22 60 18 C 70 14 80 22 96 18 L 96 40 L 0 40 Z\",\n \"#b8e8c0\",\n ) +\n line(\n \"M 0 18 C 10 14 20 22 30 18 C 40 14 50 22 60 18 C 70 14 80 22 96 18\",\n INK,\n 2.6,\n ),\n ),\n },\n \"pants/48\": {\n svg: cloth(\n \"pants_48\",\n SKIRT.layer2,\n \"#3a3e48\",\n flat(\n \"M 0 20 C 10 16 20 24 30 20 C 40 16 50 24 60 20 C 70 16 80 24 96 20 L 96 40 L 0 40 Z\",\n \"#4a5a58\",\n ) +\n line(\n \"M 0 20 C 10 16 20 24 30 20 C 40 16 50 24 60 20 C 70 16 80 24 96 20\",\n INK,\n 2.6,\n ),\n ),\n },\n \"pants/44\": {\n svg: cloth(\n \"pants_44\",\n SKIRT.tutu,\n \"#f8f0f4\",\n flat(\n \"M 0 16 C 14 22 30 22 44 16 C 58 22 74 22 88 16 L 88 40 L 0 40 Z\",\n \"#f8b8d0\",\n ) +\n line(\n \"M 16 18 C 14 24 14 30 18 34 M 32 20 C 30 26 30 32 32 36 M 50 20 C 50 26 50 32 50 36 M 68 18 C 70 24 70 30 68 34\",\n \"#e080a8\",\n 2.4,\n ),\n ),\n },\n // Shorts (one image).\n \"pants/43\": {\n svg:\n path(SHORTS, \"#3a3e48\") +\n line(\n \"M 31 6 L 31 12 M 31 18 L 31 26 M 70 6 L 70 12 M 70 18 L 70 26\",\n \"#262830\",\n 2.4,\n ) +\n line(\"M 35 6 L 35 16 C 35 18 33 19 31 19\", \"#262830\", 2.4),\n },\n // A waistband with no legs (worn over leggings or a long top).\n \"pants/49\": { svg: path(WAIST, \"#5a6ae0\") },\n // Trousers: the waistband and the two leg pieces (the far one the near one's twin where they match).\n ...pants(1, \"#2a2a2a\", \"short\", \"#2a2a2a\"),\n ...pants(2, \"#3a7aa0\", \"short\", \"#3a7aa0\"),\n ...pants(\n 3,\n \"#4b4aa3\",\n \"long\",\n \"#4b4aa3\",\n line(\"M 5 13 C 9 10 16 11 20 13 C 16 16 9 16 5 13\", INK, 2),\n ),\n ...pants(\n 4,\n \"#f0c880\",\n \"wide\",\n \"#f0c880\",\n \"\",\n line(\"M 6 10 C 30 14 50 14 74 10\", \"#d0a860\", 2.4),\n ),\n ...pants(\n 5,\n \"#5050e0\",\n \"flare\",\n \"#5050e0\",\n dots(40, 44, 9, 2.6, \"#f8c030\"),\n dots(80, 26, 10, 3, \"#f8c030\"),\n ),\n ...pants(\n 12,\n \"#e060d0\",\n null,\n \"\",\n \"\",\n `<text x=\"20\" y=\"18\" font-family=\"Arial Black, Arial, sans-serif\" font-weight=\"900\" font-size=\"13\" fill=\"#4a1048\">PINK</text>`,\n ),\n ...pants(\n 13,\n \"#20a070\",\n \"long\",\n \"#20a070\",\n line(\"M 9 3 L 9 38 M 15 3 L 15 38\", \"#f4f4f4\", 2.6),\n line(\n \"M 58 3 C 60 10 60 16 58 20 M 64 3 C 66 10 66 16 64 20\",\n \"#f4f4f4\",\n 2.6,\n ),\n ),\n ...pants(\n 14,\n \"#c02060\",\n \"flare\",\n \"#c02060\",\n flat(\"M 0 32 L 40 32 L 40 44 L 0 44 Z\", \"#f06080\") +\n dots(40, 44, 7, 1.6, \"#f8b0c8\"),\n ),\n ...pants(\n 15,\n \"#8a8a8a\",\n \"long\",\n \"#8a8a8a\",\n patches(\"#7a5a40\", [\n [8, 26],\n [14, 32],\n ]),\n ),\n ...pants(\n 16,\n \"#7a3a30\",\n \"short\",\n \"#7a3a30\",\n patches(\"#5a2a20\", [\n [6, 12],\n [14, 18],\n ]),\n patches(\"#5a2a20\", [\n [20, 8],\n [50, 10],\n [64, 6],\n ]),\n \"long40\",\n ),\n ...pants(19, \"#6a8a40\", \"long\", \"#6a8a40\", camo(25, 41), camo(79, 26)),\n ...pants(\n 20,\n \"#6a2a9a\",\n \"short\",\n \"#6a2a9a\",\n line(\"M 9 3 L 9 25 M 16 3 L 16 25\", \"#d040c0\", 3),\n line(\n \"M 10 4 L 12 18 M 24 4 L 25 21 M 38 4 L 38 23 M 52 4 L 51 22 M 66 4 L 64 18\",\n \"#d040c0\",\n 3,\n ),\n ),\n ...pants(21, \"#262626\", \"long\", \"#262626\"),\n ...pants(\n 22,\n \"#5a6ae0\",\n \"long\",\n \"#f4f4f8\",\n flat(\"M 12 0 L 18 0 L 18 41 L 12 41 Z\", \"#5a6ae0\"),\n ),\n ...pants(\n 23,\n \"#3a3a48\",\n \"long\",\n \"#3a3a48\",\n flat(\"M 0 24 L 25 24 L 25 30 L 0 30 Z\", \"#a0a0a8\"),\n ),\n ...pants(24, \"#e0f4fc\", \"long\", \"#e0f4fc\"),\n ...pants(\n 25,\n \"#2a3a28\",\n \"long\",\n \"#2a3a28\",\n line(\"M 6 20 C 10 16 16 17 19 20 C 16 23 10 23 6 20\", INK, 2),\n ),\n ...pants(\n 27,\n \"#f06030\",\n \"long\",\n \"#f06030\",\n dots(25, 41, 12, 3.6, \"#f890c0\"),\n dots(79, 26, 14, 4, \"#f890c0\"),\n ),\n ...pants(\n 28,\n \"#7a58b8\",\n \"short\",\n \"#7a58b8\",\n patches(\"#5a3a90\", [\n [6, 10],\n [14, 16],\n ]),\n patches(\"#5a3a90\", [\n [18, 8],\n [46, 12],\n [62, 6],\n ]),\n ),\n ...pants(\n 29,\n \"#7a5030\",\n \"short\",\n \"#7a5030\",\n patches(\"#5a3018\", [\n [6, 10],\n [14, 16],\n ]),\n patches(\"#5a3018\", [\n [18, 8],\n [46, 12],\n [62, 6],\n ]),\n \"long40\",\n ),\n ...pants(\n 30,\n \"#70a8d0\",\n \"long\",\n \"#70a8d0\",\n line(\"M 6 20 C 10 16 16 17 19 20 C 16 23 10 23 6 20\", INK, 2),\n ),\n ...pants(31, \"#c8a870\", \"long\", \"#c8a870\"),\n ...pants(32, \"#5a5e70\", \"wide\", \"#5a5e70\"),\n ...pants(33, \"#f4f0e0\", \"wide\", \"#f4f0e0\"),\n ...pants(\n 34,\n \"#40b090\",\n \"wide\",\n \"#40b090\",\n \"\",\n line(\"M 6 10 C 30 14 50 14 74 10\", \"#2a8a70\", 2.4),\n ),\n ...pants(\n 35,\n \"#a0aaa0\",\n \"wide\",\n \"#a0aaa0\",\n \"\",\n line(\"M 6 10 C 30 14 50 14 74 10\", \"#7a847a\", 2.4),\n ),\n ...pants(\n 37,\n \"#3a3a40\",\n \"long\",\n \"#262628\",\n line(\"M 10 3 L 10 38 M 15 3 L 15 38\", \"#f4f4f4\", 2.6),\n line(\"M 52 4 L 50 20 M 58 4 L 56 22\", \"#f4f4f4\", 2.6),\n ),\n ...pants(\n 38,\n \"#262626\",\n \"short\",\n \"#262626\",\n line(\"M 6 6 L 19 22 M 19 6 L 6 22\", \"#5a5a5e\", 2.4),\n ),\n ...pants(\n 39,\n \"#d040a0\",\n \"short\",\n \"#d040a0\",\n line(\"M 8 3 L 8 25 M 16 3 L 16 25\", \"#30a080\", 3.4),\n line(\n \"M 10 4 L 12 18 M 24 4 L 25 21 M 38 4 L 38 23 M 52 4 L 51 22 M 66 4 L 64 18\",\n \"#30a080\",\n 3.4,\n ),\n ),\n ...pants(40, \"#d02838\", \"long\", \"#d02838\", dots(25, 41, 9, 1.8, \"#7a1018\")),\n ...pants(\n 41,\n \"#70a8d0\",\n \"long\",\n \"#70a8d0\",\n flat(\"M 0 32 L 25 32 L 25 41 L 0 41 Z\", \"#e0f0f8\") +\n line(\"M 0 32 L 25 32\", INK, 2.4),\n ),\n ...pants(45, \"#a05a28\", \"flare\", \"#a05a28\"),\n ...pants(\n 46,\n \"#f0b040\",\n \"flare\",\n \"#f0b040\",\n dots(40, 44, 10, 3.4, \"#7a4a20\"),\n dots(80, 26, 12, 3.6, \"#7a4a20\"),\n ),\n ...pants(17, \"\", \"long\", \"#c0d0c8\"),\n ...pants(47, \"\", \"long\", \"#80e060\"),\n ...pants(50, \"#6a8a40\", \"wide\", \"#6a8a40\", camo(31, 28), camo(79, 26)),\n // Boots: both images of a pair are one picture.\n ...pair(\n 1,\n SHOE.low,\n \"#c0302b\",\n flat(\"M 7 6 C 12 4.5 18 4.5 22 6 C 18 7.5 12 7.5 7 6 Z\", \"#f08a80\"),\n ),\n ...pair(\n 21,\n SHOE.low,\n \"#4a4a4e\",\n flat(\"M 7 6 C 12 4.5 18 4.5 22 6 C 18 7.5 12 7.5 7 6 Z\", \"#8a8a90\"),\n ),\n ...pair(\n 2,\n SHOE.clog,\n \"#a0a8b0\",\n flat(\"M 0 16 L 30 16 L 30 23 L 0 23 Z\", \"#7a828a\"),\n line(\"M 18 9 C 18 12 16.5 14 13.5 15\", INK, 2),\n ),\n ...pair(\n 29,\n SHOE.clog,\n \"#8a8a70\",\n flat(\"M 0 16 L 30 16 L 30 23 L 0 23 Z\", \"#5a5a48\"),\n line(\"M 18 9 C 18 12 16.5 14 13.5 15\", INK, 2),\n ),\n ...pair(3, SHOE.loafer, \"#7a9440\", line(\"M 8 8 C 14 5 22 7 27 14\", INK, 2.4)),\n ...pair(\n 4,\n SHOE.sock,\n \"#f4f4f4\",\n flat(\"M 0 13 L 32 13 L 32 22 L 0 22 Z\", \"#3a3a3e\") +\n line(\"M 0 13 L 32 13\", INK, 2) +\n line(\"M 9 12 L 22 6\", \"#3a3a3e\", 3),\n ),\n ...pair(5, SHOE.tall, \"#7048c8\", drip(\"#e8b030\")),\n ...pair(27, SHOE.tall, \"#5a3a28\", drip(\"#c88a30\")),\n ...pair(28, SHOE.tall, \"#20c0b0\", drip(\"#f4f4f8\")),\n ...pair(\n 6,\n SHOE.moccasin,\n \"#8a5a30\",\n flat(\"M 0 12 L 30 12 L 30 18 L 0 18 Z\", \"#6a4020\"),\n ),\n ...pair(\n 7,\n SHOE.ankle,\n \"#c8b890\",\n line(\n \"M 8 21 C 8 17 11 14 15 13 M 13 21 C 13 18 16 16 19 16\",\n \"#a89870\",\n 2.4,\n ),\n ),\n ...pair(\n 8,\n SHOE.slide,\n \"#4060d0\",\n flat(\"M 0 12 L 32 12 L 32 20 L 0 20 Z\", \"#3048a8\"),\n ellipse(11.5, 8.3, 3.2, 1.5, INK, { stroke: \"none\" }),\n ),\n ...pair(\n 30,\n SHOE.slide,\n \"#f07030\",\n flat(\"M 0 12 L 32 12 L 32 20 L 0 20 Z\", \"#c85820\"),\n ellipse(11.5, 8.3, 3.2, 1.5, INK, { stroke: \"none\" }),\n ),\n ...pair(\n 9,\n SHOE.flat,\n \"#1a6a90\",\n line(\"M 8 8 C 14 6.5 20 6.5 26 8\", \"#104a68\", 2.4),\n ),\n ...pair(\n 10,\n SHOE.pointed,\n \"#d93a3a\",\n flat(\"M 0 11 L 35 11 L 35 19 L 0 19 Z\", \"#a82424\"),\n ),\n ...pair(\n 11,\n SHOE.hiker,\n \"#f8b030\",\n flat(\"M 0 15 C 10 15 20 18 32 20 L 32 26 L 0 26 Z\", \"#f07818\") +\n line(\"M 6 19 L 13 13.5 M 10 22 L 17 16\", \"#e06010\", 2.4),\n ),\n ...pair(12, SHOE.cat, \"#c8d8e0\"),\n ...pair(\n 13,\n SHOE.frog,\n \"#30c070\",\n line(\"M 6 13.5 C 10 15 15 15 20 13.5\", INK, 1.8),\n circle(10.8, 6, 2.6, \"#ffffff\", { width: 1.6 }) +\n circle(19.5, 6.8, 2.3, \"#ffffff\", { width: 1.6 }) +\n circle(10.8, 6, 1.1, INK, { stroke: \"none\" }) +\n circle(19.5, 6.8, 1, INK, { stroke: \"none\" }),\n ),\n ...pair(\n 14,\n SHOE.oxford,\n \"#8a5040\",\n line(\"M 9 6.5 L 18 6.5 M 13.5 6.5 L 13.5 11\", INK, 2.4),\n ),\n ...pair(\n 15,\n SHOE.wellie,\n \"#5a8a20\",\n line(\"M 13 8.5 L 18 8.5 M 13 12.5 L 18 12.5\", \"#2e5a10\", 2.4),\n ),\n ...pair(\n 24,\n SHOE.wellie,\n \"#f4f0e8\",\n line(\"M 13 8.5 L 18 8.5 M 13 12.5 L 18 12.5\", \"#a8a090\", 2.4),\n ),\n ...pair(\n 25,\n SHOE.wellie,\n \"#c03080\",\n flat(\"M 0 0 L 28 0 L 28 8 C 22 10 14 8 8 9.5 L 0 10 Z\", \"#50c050\") +\n line(\"M 4 9.8 C 10 8.5 18 10 26 8\", INK, 2),\n ),\n ...pair(\n 16,\n SHOE.bootie,\n \"#10904a\",\n flat(\"M 0 0 L 30 0 L 30 13 L 0 13 Z\", \"#30c070\") +\n line(\"M 0 13 L 30 13\", INK, 2) +\n line(\n \"M 11 4 L 11 11 M 15 4 L 15 11 M 19 4 L 19 11 M 23 4 L 23 11\",\n \"#0a6a34\",\n 2.2,\n ),\n ),\n ...pair(\n 17,\n SHOE.bunny,\n \"#b8d8e8\",\n circle(11, 14, 1.4, INK, { stroke: \"none\" }) +\n circle(15.5, 14, 1.4, INK, { stroke: \"none\" }),\n ),\n ...pair(\n 26,\n SHOE.bunny,\n \"#f4f4f8\",\n circle(11, 14, 1.6, \"#f070c0\", { stroke: \"none\" }) +\n circle(15.5, 14, 1.6, \"#f070c0\", { stroke: \"none\" }),\n ),\n ...pair(\n 18,\n SHOE.sandal,\n \"#f070e0\",\n flat(\"M 0 9.5 L 31 9.5 L 31 18 L 0 18 Z\", \"#c040b8\") +\n line(\"M 0 9.5 L 31 9.5\", INK, 2),\n ),\n ...pair(19, SHOE.hightop, \"#3a3a9a\", hightopTrim(\"#f4f4f8\")),\n ...pair(22, SHOE.hightop, \"#5a80e0\", hightopTrim(\"#c8d8f8\")),\n ...pair(23, SHOE.hightop, \"#7a1830\", hightopTrim(\"#f0e0c8\")),\n ...pair(\n 20,\n SHOE.claw,\n \"#f8a820\",\n line(\"M 8 19 L 12 15 M 13 20 L 16 16 M 18 20.5 L 20 17\", \"#e07010\", 2.4),\n ),\n \"gloves/2_near\": {\n svg: mitt(\"glove_near_2\", 34, 35, \"#a040c0\", { h: 8, color: \"#f4f4f8\" }),\n },\n \"gloves/2_far\": {\n svg: mitt(\"glove_far_2\", 32, 35, \"#3080d0\", { h: 8, color: \"#f4f4f8\" }),\n },\n \"gloves/3_near\": {\n svg: mitt(\n \"glove_near_3\",\n 28,\n 25,\n \"#e0e0e0\",\n undefined,\n line(\"M 0 9 L 30 7 M 0 14 L 30 12 M 0 19 L 30 17\", \"#a0a0a0\", 2),\n ),\n },\n \"gloves/3_far\": {\n svg: mitt(\n \"glove_far_3\",\n 27,\n 25,\n \"#e0e0e0\",\n undefined,\n line(\"M 0 9 L 30 7 M 0 14 L 30 12 M 0 19 L 30 17\", \"#a0a0a0\", 2),\n ),\n },\n \"gloves/4_near\": {\n svg: mitt(\n \"glove_near_4\",\n 31,\n 34,\n \"#c05a40\",\n undefined,\n flat(\"M 0 25 L 40 25 L 40 40 L 0 40 Z\", \"#a0a0a0\") +\n line(\"M 0 25 L 40 25\", INK, 2.4),\n ),\n },\n \"gloves/4_far\": {\n svg: mitt(\n \"glove_far_4\",\n 32,\n 33,\n \"#c05a40\",\n undefined,\n flat(\"M 0 24 L 40 24 L 40 40 L 0 40 Z\", \"#a0a0a0\") +\n line(\"M 0 24 L 40 24\", INK, 2.4),\n ),\n },\n \"gloves/5_near\": {\n svg: mitt(\n \"glove_near_5\",\n 36,\n 36,\n \"#f0b030\",\n undefined,\n line(\n \"M 6 14 L 12 10 L 18 16 L 24 10 L 30 14 M 6 22 L 12 18 L 18 24 L 24 18 L 30 22\",\n \"#e06020\",\n 2.6,\n ),\n ),\n },\n \"gloves/5_far\": {\n svg: mitt(\n \"glove_far_5\",\n 35,\n 36,\n \"#f0b030\",\n undefined,\n line(\n \"M 6 14 L 12 10 L 18 16 L 24 10 L 30 14 M 6 22 L 12 18 L 18 24 L 24 18 L 30 22\",\n \"#e06020\",\n 2.6,\n ),\n ),\n },\n \"gloves/6_near\": {\n svg: mitt(\"glove_near_6\", 31, 32, \"#8080c0\", { h: 9, color: \"#5a5aa0\" }),\n },\n \"gloves/6_far\": {\n svg: mitt(\"glove_far_6\", 32, 33, \"#8080c0\", { h: 9, color: \"#5a5aa0\" }),\n },\n \"gloves/7_near\": {\n svg: mitt(\n \"glove_near_7\",\n 35,\n 34,\n \"#f4f4f8\",\n { h: 7, color: \"#f070c0\" },\n line(\"M 4 16 L 32 14 M 4 23 L 32 21\", \"#e040a0\", 3.6),\n ),\n },\n \"gloves/7_far\": {\n svg: mitt(\n \"glove_far_7\",\n 34,\n 34,\n \"#f4f4f8\",\n { h: 7, color: \"#f070c0\" },\n line(\"M 4 16 L 32 14 M 4 23 L 32 21\", \"#e040a0\", 3.6),\n ),\n },\n \"gloves/8_near\": { svg: wristband(30, 14, \"#8a90b0\") },\n \"gloves/8_far\": { svg: wristband(28, 14, \"#8a90b0\") },\n \"gloves/9_near\": { svg: wristband(38, 18, \"#c8a888\", \"#8a6a48\") },\n \"gloves/9_far\": { svg: wristband(38, 18, \"#c8a888\", \"#8a6a48\") },\n \"gloves/11_near\": {\n svg: mitt(\n \"glove_near_11\",\n 30,\n 23,\n \"#4a4a4e\",\n undefined,\n flat(\"M 15 12 L 40 12 L 40 40 L 15 40 Z\", \"#3a3a3e\"),\n ),\n },\n \"gloves/11_far\": {\n svg: mitt(\n \"glove_far_11\",\n 28,\n 23,\n \"#4a4a4e\",\n undefined,\n flat(\"M 14 12 L 40 12 L 40 40 L 14 40 Z\", \"#3a3a3e\"),\n ),\n },\n \"gloves/12_near\": {\n svg: mitt(\"glove_near_12\", 33, 35, \"#3050e0\", { h: 8, color: \"#f4f4f8\" }),\n },\n \"gloves/12_far\": {\n svg: mitt(\"glove_far_12\", 33, 36, \"#3050e0\", { h: 8, color: \"#f4f4f8\" }),\n },\n \"gloves/13_near\": {\n svg: mitt(\n \"glove_near_13\",\n 36,\n 36,\n \"#2aa088\",\n { h: 9, color: \"#1a7060\" },\n line(\n \"M 8 24 C 12 22 16 22 18 24 M 8 29 C 12 27 16 27 18 29\",\n \"#1a7060\",\n 2.4,\n ),\n ),\n },\n \"gloves/13_far\": {\n svg: mitt(\n \"glove_far_13\",\n 32,\n 37,\n \"#2aa088\",\n { h: 9, color: \"#1a7060\" },\n line(\n \"M 8 24 C 12 22 16 22 18 24 M 8 29 C 12 27 16 27 18 29\",\n \"#1a7060\",\n 2.4,\n ),\n ),\n },\n \"gloves/14_near\": { svg: bracer(32, 27, \"#3070e0\", \"#f8c020\") },\n \"gloves/14_far\": { svg: bracer(29, 24, \"#3070e0\", \"#f8c020\") },\n \"gloves/15_near\": {\n svg: mitt(\n \"glove_near_15\",\n 36,\n 40,\n \"#e03040\",\n { h: 9, color: \"#f4f4f8\" },\n ellipse(18, 24, 3, 5, \"#f8e060\", { stroke: \"none\" }),\n ),\n },\n \"gloves/15_far\": {\n svg: mitt(\n \"glove_far_15\",\n 35,\n 40,\n \"#e03040\",\n { h: 9, color: \"#f4f4f8\" },\n ellipse(18, 24, 3, 5, \"#f8e060\", { stroke: \"none\" }),\n ),\n },\n \"gloves/16_near\": {\n svg: mitt(\"glove_near_16\", 31, 36, \"#7a7a70\", { h: 9, color: \"#5a5a52\" }),\n },\n \"gloves/16_far\": {\n svg: mitt(\"glove_far_16\", 30, 37, \"#7a7a70\", { h: 9, color: \"#5a5a52\" }),\n },\n \"gloves/17_near\": {\n svg: mitt(\"glove_near_17\", 31, 36, \"#6a3a28\", { h: 9, color: \"#4a2818\" }),\n },\n \"gloves/17_far\": {\n svg: mitt(\"glove_far_17\", 30, 37, \"#6a3a28\", { h: 9, color: \"#4a2818\" }),\n },\n \"gloves/18_near\": {\n svg: mitt(\"glove_near_18\", 32, 31, \"#d8d090\", { h: 8, color: \"#b8bcc0\" }),\n },\n \"gloves/18_far\": {\n svg: mitt(\"glove_far_18\", 30, 30, \"#d8d090\", { h: 8, color: \"#b8bcc0\" }),\n },\n \"gloves/19_near\": {\n svg: mitt(\n \"glove_near_19\",\n 32,\n 34,\n \"#2a7aa0\",\n { h: 8, color: \"#1a5a78\" },\n line(\"M 8 25 L 10 31 M 14 25 L 15 31 M 20 25 L 21 31\", \"#1a5a78\", 2.4),\n ),\n },\n \"gloves/19_far\": {\n svg: mitt(\n \"glove_far_19\",\n 29,\n 34,\n \"#2a7aa0\",\n { h: 8, color: \"#1a5a78\" },\n line(\"M 8 25 L 10 31 M 14 25 L 15 31 M 20 25 L 21 31\", \"#1a5a78\", 2.4),\n ),\n },\n \"gloves/20_near\": {\n svg: mitt(\"glove_near_20\", 34, 38, \"#90c040\", { h: 10, color: \"#2a7a40\" }),\n },\n \"gloves/20_far\": {\n svg: mitt(\"glove_far_20\", 31, 38, \"#90c040\", { h: 10, color: \"#2a7a40\" }),\n },\n \"gloves/21_near\": {\n svg: mitt(\n \"glove_near_21\",\n 28,\n 25,\n \"#a0b860\",\n undefined,\n flat(\"M 0 8 L 30 8 L 30 13 L 0 13 Z\", \"#c0d080\"),\n ),\n },\n \"gloves/21_far\": {\n svg: mitt(\n \"glove_far_21\",\n 27,\n 25,\n \"#a0b860\",\n undefined,\n flat(\"M 0 8 L 30 8 L 30 13 L 0 13 Z\", \"#c0d080\"),\n ),\n },\n \"gloves/22_near\": {\n svg: mitt(\n \"glove_near_22\",\n 28,\n 25,\n \"#e07080\",\n undefined,\n flat(\"M 0 8 L 30 8 L 30 13 L 0 13 Z\", \"#f0a0b0\"),\n ),\n },\n \"gloves/22_far\": {\n svg: mitt(\n \"glove_far_22\",\n 27,\n 25,\n \"#e07080\",\n undefined,\n flat(\"M 0 8 L 30 8 L 30 13 L 0 13 Z\", \"#f0a0b0\"),\n ),\n },\n \"gloves/23_near\": {\n svg: mitt(\n \"glove_near_23\",\n 36,\n 40,\n \"#3a3aa0\",\n { h: 9, color: \"#f4f4f8\" },\n ellipse(18, 24, 3, 5, \"#f8e060\", { stroke: \"none\" }),\n ),\n },\n \"gloves/23_far\": {\n svg: mitt(\n \"glove_far_23\",\n 35,\n 40,\n \"#3a3aa0\",\n { h: 9, color: \"#f4f4f8\" },\n ellipse(18, 24, 3, 5, \"#f8e060\", { stroke: \"none\" }),\n ),\n },\n \"gloves/24_near\": {\n svg: mitt(\n \"glove_near_24\",\n 36,\n 40,\n \"#a8a8a8\",\n undefined,\n ellipse(18, 18, 4, 6, \"#8050a0\", { stroke: \"none\" }),\n ),\n },\n \"gloves/24_far\": {\n svg: mitt(\n \"glove_far_24\",\n 35,\n 40,\n \"#a8a8a8\",\n undefined,\n ellipse(18, 18, 4, 6, \"#8050a0\", { stroke: \"none\" }),\n ),\n },\n \"gloves/25_near\": {\n svg: mitt(\n \"glove_near_25\",\n 36,\n 36,\n \"#8080e0\",\n { h: 9, color: \"#5a5ab8\" },\n line(\"M 8 24 C 12 22 16 22 18 24\", \"#5a5ab8\", 2.4),\n ),\n },\n \"gloves/25_far\": {\n svg: mitt(\n \"glove_far_25\",\n 32,\n 37,\n \"#8080e0\",\n { h: 9, color: \"#5a5ab8\" },\n line(\"M 8 24 C 12 22 16 22 18 24\", \"#5a5ab8\", 2.4),\n ),\n },\n \"gloves/26_near\": { svg: wristband(30, 14, \"#8a8a40\") },\n \"gloves/26_far\": { svg: wristband(28, 14, \"#8a8a40\") },\n \"gloves/27_near\": { svg: wristband(38, 18, \"#3a3a40\", \"#6a6a70\") },\n \"gloves/27_far\": { svg: wristband(38, 18, \"#3a3a40\", \"#6a6a70\") },\n \"gloves/28_near\": { svg: wristband(38, 18, \"#f0a030\", \"#e04020\") },\n \"gloves/28_far\": { svg: wristband(38, 18, \"#f0a030\", \"#e04020\") },\n \"gloves/29_near\": {\n svg: mitt(\"glove_near_29\", 31, 34, \"#3a3a70\", { h: 9, color: \"#6a50b0\" }),\n },\n \"gloves/29_far\": {\n svg: mitt(\"glove_far_29\", 32, 33, \"#3a3a70\", { h: 9, color: \"#6a50b0\" }),\n },\n \"gloves/30_near\": { svg: bracer(32, 27, \"#3a3a80\", \"#8a90b0\") },\n \"gloves/30_far\": { svg: bracer(29, 24, \"#3a3a80\", \"#8a90b0\") },\n};\n"
200
+ },
201
+ {
202
+ "path": "character/packs/base/eyes.ts",
203
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport {\n circle,\n clipped,\n ellipse,\n flat,\n INK,\n line,\n PAINT,\n path,\n} from \"../../art/svg\";\n\n// Eyes (eyes/1…20): both eyes on one image, looking left where the character faces. Twenty\n// characters, each: an almond of white under a heavy lid with a wing at\n// the outer corner, a tall iris in a dark ring with a shade at its top, a big pupil, two highlights,\n// a short line under the eye. The iris is `PAINT` — the eye colour draws it (the whites stay white);\n// `paint` is its colour until one is chosen.\n\nconst WHITE_EYE = \"#fffaf4\";\nconst PUPIL = \"#160f1a\";\n\n/** An ellipse as a path (for clips). */\nconst oval = (cx: number, cy: number, rx: number, ry: number) =>\n `M ${cx - rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx + rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx - rx} ${cy} Z`;\n\n/** An iris: the colour in a dark ring, darker at the top, the pupil, two highlights (light from the\n * upper right). */\nconst iris = (\n id: string,\n x: number,\n y: number,\n r: number,\n colour: string,\n pupil = 0.5,\n) => {\n const ry = r * 1.12;\n return (\n ellipse(x, y, r, ry, colour, { width: 1.3 }) +\n clipped(\n id,\n oval(x, y, r, ry),\n ellipse(x, y - ry * 0.62, r * 1.1, ry * 0.68, \"#000000\", {\n stroke: \"none\",\n opacity: 0.38,\n }),\n ellipse(x, y + ry * 0.72, r * 0.8, ry * 0.4, \"#ffffff\", {\n stroke: \"none\",\n opacity: 0.3,\n }),\n ) +\n (pupil > 0\n ? ellipse(x, y - ry * 0.12, r * pupil, ry * (pupil + 0.12), PUPIL, {\n stroke: \"none\",\n })\n : \"\") +\n circle(x + r * 0.4, y - ry * 0.42, r * 0.3, \"#ffffff\", { stroke: \"none\" }) +\n circle(x - r * 0.38, y + ry * 0.42, r * 0.14, \"#ffffff\", { stroke: \"none\" })\n );\n};\n\ninterface EyeOpts {\n cx: number;\n cy: number;\n /** Corner to corner, and the height of the opening when fully open. */\n w: number;\n h: number;\n /** Where the outer corner (the lid's wing) is: -1 left, 1 right. */\n dir: -1 | 1;\n /** The iris radius; 0 — none. */\n r?: number;\n /** The iris's x offset from the centre (− looks left, where the character faces). */\n look?: number;\n /** Its colour (default PAINT — the eye colour). */\n colour?: string;\n pupil?: number;\n /** How far open: 1 fully, 0.5 the top lid half down. */\n open?: number;\n /** The lid slants down to the nose by this much (angry > 0, sad < 0), px. */\n tilt?: number;\n /** The lid's thickness at its outer end, its wing, and lashes flicking out of it. */\n lid?: number;\n wing?: number;\n lashes?: number;\n /** Eyeshadow above the lid. */\n shade?: string;\n /** The line under the eye (default on). */\n under?: boolean;\n /** The white is the colour (glowing eyes) — no iris. */\n glow?: boolean;\n /** Tears gathering at the bottom. */\n tears?: boolean;\n /** A lid that came down (open < 1): the skin over the eye in shadow, its fold outlined. */\n fold?: boolean;\n}\n\n/** One eye. `id` must be unique in its image (clips). */\nfunction heroEye(id: string, o: EyeOpts): string {\n const { cx, cy, w, h, dir } = o;\n const X = (u: number) => +(cx + dir * u).toFixed(2); // u > 0 towards the outer corner\n const open = o.open ?? 1;\n const tilt = o.tilt ?? 0;\n const t = o.lid ?? 2.8;\n const wing = o.wing ?? 3.2;\n const topOf = (k: number) => cy - h * 0.66 * (2 * k - 1);\n const top = topOf(open);\n const iy = +(cy + h * 0.06 + tilt * 0.4).toFixed(2);\n const ix = (o.look ?? -(o.r ?? h * 0.4) * 0.25) + cx;\n const bottom = cy + h * 0.62;\n const iX = X(-w / 2);\n const iY = +(cy + h * 0.06 + tilt * 0.5).toFixed(2);\n const oX = X(w / 2);\n const oY = +(cy - h * 0.08 - tilt * 0.3).toFixed(2);\n const upperAt = (y: number) =>\n `C ${X(-w * 0.28)} ${(y + tilt).toFixed(2)} ${X(w * 0.22)} ${(y - tilt * 0.3).toFixed(2)} ${oX} ${oY}`;\n const upper = upperAt(top);\n const lower = `C ${X(w * 0.3)} ${bottom.toFixed(2)} ${X(-w * 0.25)} ${bottom.toFixed(2)} ${iX} ${iY} Z`;\n const d = `M ${iX} ${iY} ${upper} ${lower}`;\n const full = `M ${iX} ${iY} ${upperAt(topOf(1))} ${lower}`;\n const wX = X(w / 2 + wing);\n const wY = +(oY - wing * 0.55).toFixed(2);\n // The lid's top edge, from the wing back to the inner corner (k thickens it: the eyeshadow).\n const edge = (k: number): [number, number][] => [\n [wX, wY],\n [X(w * 0.22), top - tilt * 0.3 - t * k * 1.5],\n [X(-w * 0.28), top + tilt - t * k * 0.8],\n [X(-w / 2 - 0.4), iY - t * k * 0.35],\n ];\n const lidD = (k: number) => {\n const [, c1, c2, e] = edge(k);\n return `M ${iX} ${iY} ${upper} L ${wX} ${wY} C ${c1![0]} ${c1![1].toFixed(2)} ${c2![0]} ${c2![1].toFixed(2)} ${e![0]} ${e![1].toFixed(2)} Z`;\n };\n const r = o.r ?? h * 0.4;\n const inside = o.glow\n ? flat(d, PAINT) +\n ellipse(\n cx + dir * w * 0.12,\n cy - h * 0.05,\n w * 0.12,\n h * 0.1,\n \"#ffffff\",\n { stroke: \"none\", opacity: 0.85 },\n )\n : (r > 0 ? iris(`${id}_i`, ix, iy, r, o.colour ?? PAINT, o.pupil) : \"\") +\n (o.tears\n ? flat(\n `M ${cx - w} ${cy + h * 0.22} C ${cx - w / 3} ${cy + h * 0.12} ${cx + w / 3} ${cy + h * 0.3} ${cx + w} ${cy + h * 0.2} L ${cx + w} ${cy + h} L ${cx - w} ${cy + h} Z`,\n \"#bfeaff\",\n 0.85,\n ) +\n line(\n `M ${X(-w * 0.25)} ${cy + h * 0.3} L ${X(w * 0.05)} ${cy + h * 0.28}`,\n \"#ffffff\",\n 1.2,\n )\n : \"\");\n // Lashes grow out of the lid's top edge near the wing, flicking outwards.\n let lashes = \"\";\n const [p0, p1, p2, p3] = edge(1);\n for (let k = 0; k < (o.lashes ?? 0); k++) {\n const s = 0.12 + 0.13 * k;\n const b = (i: 0 | 1) =>\n (1 - s) ** 3 * p0![i] +\n 3 * (1 - s) ** 2 * s * p1![i] +\n 3 * (1 - s) * s * s * p2![i] +\n s ** 3 * p3![i];\n const x = b(0) - dir * 0.3;\n const y = b(1) + 0.6;\n lashes += line(\n `M ${x.toFixed(2)} ${y.toFixed(2)} L ${(x + dir * 1.8).toFixed(2)} ${(y - 2.4).toFixed(2)}`,\n INK,\n 1.3,\n );\n }\n const ul = bottom - h * 0.15 + 1.8;\n return (\n (o.shade ? flat(lidD(2.4), o.shade) : \"\") +\n (o.fold && open < 1\n ? path(full, \"#000000\", { stroke: \"none\", opacity: 0.22 }) +\n path(full, \"none\", { width: 1.3 })\n : \"\") +\n path(d, WHITE_EYE, { stroke: \"none\" }) +\n clipped(\n id,\n d,\n inside,\n o.glow\n ? \"\"\n : flat(\n `M ${cx - w} ${cy - h} L ${cx + w} ${cy - h} L ${cx + w} ${top + h * 0.28} L ${cx - w} ${top + h * 0.28} Z`,\n \"#000000\",\n 0.12,\n ),\n ) +\n path(d, \"none\", { width: 1.3 }) +\n flat(lidD(1), INK) +\n lashes +\n (o.under === false\n ? \"\"\n : line(\n `M ${X(-w * 0.2)} ${ul.toFixed(2)} Q ${X(w * 0.05)} ${(ul + 0.9).toFixed(2)} ${X(w * 0.3)} ${(ul - 0.6).toFixed(2)}`,\n INK,\n 1.6,\n ))\n );\n}\n\n/** Both eyes: the far one (left, its outer corner on the left) and the near one. */\nconst eyes = (l: Omit<EyeOpts, \"dir\">, r: Omit<EyeOpts, \"dir\">) =>\n heroEye(\"el\", { ...l, dir: -1 }) + heroEye(\"er\", { ...r, dir: 1 });\n\n/** An eye with no white — a dark shape with highlights (chibi, beady, buttons). */\nconst dotEye = (cx: number, cy: number, rx: number, ry: number) =>\n ellipse(cx, cy, rx, ry, PUPIL, { stroke: \"none\" }) +\n ellipse(cx + rx * 0.3, cy - ry * 0.38, rx * 0.42, ry * 0.26, \"#ffffff\", {\n stroke: \"none\",\n }) +\n circle(cx - rx * 0.35, cy + ry * 0.4, Math.min(rx, ry) * 0.16, \"#ffffff\", {\n stroke: \"none\",\n });\n\n/** A round wide-open eye (surprised): a white circle, a small iris. */\nconst roundEye = (\n id: string,\n cx: number,\n cy: number,\n R: number,\n r: number,\n look: number,\n) =>\n circle(cx, cy, R, WHITE_EYE, { stroke: \"none\" }) +\n clipped(\n id,\n oval(cx, cy, R, R),\n iris(`${id}_i`, cx + look, cy + 0.5, r, PAINT, 0.45),\n ) +\n circle(cx, cy, R, \"none\", { width: 2.8 }) +\n line(\n `M ${cx - R * 0.8} ${cy - R * 0.75} C ${cx - R * 0.3} ${cy - R * 1.12} ${cx + R * 0.5} ${cy - R * 1.1} ${cx + R * 0.9} ${cy - R * 0.6}`,\n INK,\n 3.4,\n );\n\nexport const eyesArt: Record<string, ArtDef> = {\n \"eyes/1\": {\n paint: \"#3a73e0\",\n svg: eyes(\n { cx: 11, cy: 10.6, w: 16, h: 11, r: 4.4, wing: 2.4 },\n { cx: 44, cy: 10.6, w: 20, h: 12, r: 4.9 },\n ),\n },\n \"eyes/2\": {\n paint: \"#6a4126\",\n svg: eyes(\n {\n cx: 12.5,\n cy: 11.6,\n w: 18,\n h: 14,\n r: 6,\n look: -1.4,\n wing: 2.6,\n pupil: 0.58,\n },\n { cx: 46, cy: 11.6, w: 22, h: 15, r: 6.6, look: -1.6, pupil: 0.58 },\n ),\n },\n \"eyes/3\": {\n paint: \"#4f8a3c\",\n svg: [\n { cx: 14.5, cy: 16, w: 22, h: 18, dir: -1 as const },\n { cx: 49, cy: 16, w: 24, h: 19, dir: 1 as const },\n ]\n .map((e, i) =>\n heroEye(`s${i}`, {\n ...e,\n r: 6.6,\n open: 0.48,\n fold: true,\n lid: 2.8,\n wing: i ? 3 : 2.4,\n }),\n )\n .join(\"\"),\n },\n \"eyes/4\": {\n paint: \"#9a6324\",\n svg:\n roundEye(\"rl\", 15, 16, 11.2, 4.6, -2.2) +\n roundEye(\"rr\", 48, 16, 11.8, 4.8, -2.4),\n },\n \"eyes/5\": {\n paint: \"#c8402c\",\n svg: eyes(\n {\n cx: 13,\n cy: 13.5,\n w: 19,\n h: 12,\n r: 4.4,\n tilt: 3.6,\n open: 0.85,\n lid: 3.2,\n wing: 2.6,\n },\n {\n cx: 48,\n cy: 13.5,\n w: 22,\n h: 13,\n r: 4.8,\n tilt: 3.8,\n open: 0.85,\n lid: 3.4,\n },\n ),\n },\n \"eyes/6\": {\n paint: \"#4f7a3a\",\n svg:\n path(\"M 5 6 C 10 2.5 18 2.5 23 6 M 38 6 C 43 2.5 52 2.5 57 6\", \"none\", {\n stroke: \"#000000\",\n width: 2.2,\n opacity: 0.35,\n }) +\n eyes(\n { cx: 15, cy: 15.5, w: 21, h: 10, r: 4, open: 0.62, wing: 2.6 },\n { cx: 46.5, cy: 15.5, w: 23, h: 10.5, r: 4.2, open: 0.62 },\n ),\n },\n \"eyes/7\": {\n paint: \"#7a3ad0\",\n svg: eyes(\n {\n cx: 14,\n cy: 16,\n w: 20,\n h: 14,\n r: 5.8,\n lid: 3,\n wing: 3.4,\n lashes: 3,\n shade: \"#b48ae6\",\n },\n {\n cx: 51,\n cy: 16,\n w: 24,\n h: 15,\n r: 6.4,\n lid: 3,\n wing: 4,\n lashes: 3,\n shade: \"#b48ae6\",\n },\n ),\n },\n \"eyes/8\": {\n svg: dotEye(6.5, 11, 4.4, 8.8) + dotEye(37.5, 11, 4.6, 9.2),\n },\n \"eyes/9\": {\n svg: dotEye(8, 10, 3.6, 6.4) + dotEye(40, 10, 3.6, 6.6),\n },\n \"eyes/10\": {\n paint: \"#22c79e\",\n svg: eyes(\n {\n cx: 11,\n cy: 12.5,\n w: 17,\n h: 11,\n r: 4.4,\n tilt: 2.4,\n open: 0.78,\n lid: 3,\n wing: 2.6,\n },\n {\n cx: 43,\n cy: 12.5,\n w: 21,\n h: 12,\n r: 4.8,\n tilt: 2.6,\n open: 0.78,\n lid: 3.2,\n wing: 3.6,\n },\n ),\n },\n \"eyes/11\": {\n paint: \"#6b4426\",\n svg: eyes(\n {\n cx: 12,\n cy: 11.5,\n w: 18,\n h: 13,\n r: 5.4,\n look: -2.2,\n open: 0.56,\n lid: 3.2,\n wing: 2.6,\n },\n {\n cx: 45,\n cy: 11.5,\n w: 22,\n h: 14,\n r: 5.9,\n look: -2.6,\n open: 0.56,\n lid: 3.4,\n },\n ),\n },\n \"eyes/12\": {\n paint: \"#d060e0\",\n svg:\n eyes(\n {\n cx: 12.5,\n cy: 14,\n w: 19,\n h: 15,\n r: 6.6,\n lid: 2.8,\n wing: 3,\n lashes: 3,\n },\n { cx: 45, cy: 14, w: 24, h: 17, r: 7.4, lid: 3, wing: 4, lashes: 3 },\n ) +\n // a star of light in each eye\n path(\n \"M 14 9.5 L 14.7 11.2 L 16.4 11.9 L 14.7 12.6 L 14 14.3 L 13.3 12.6 L 11.6 11.9 L 13.3 11.2 Z\",\n \"#ffffff\",\n { stroke: \"none\" },\n ) +\n path(\n \"M 46 8.5 L 46.8 10.5 L 48.8 11.3 L 46.8 12.1 L 46 14.1 L 45.2 12.1 L 43.2 11.3 L 45.2 10.5 Z\",\n \"#ffffff\",\n { stroke: \"none\" },\n ),\n },\n \"eyes/13\": {\n paint: \"#3a8fd8\",\n svg: eyes(\n { cx: 14.5, cy: 10.5, w: 21, h: 12, r: 5.2, tears: true, wing: 2.6 },\n { cx: 42.5, cy: 10.5, w: 23, h: 13, r: 5.6, tears: true },\n ),\n },\n \"eyes/14\": {\n paint: \"#36c8f0\",\n svg: eyes(\n { cx: 12.5, cy: 11.8, w: 18, h: 13, r: 5.6, wing: 2.6 },\n { cx: 45, cy: 11.8, w: 22, h: 14, r: 6.1, colour: \"#f0c419\" },\n ),\n },\n \"eyes/15\": {\n paint: \"#e04040\",\n svg:\n [\n [\"M 3 4 L 22 10.5 L 22 17 L 9 19 Z\", 11.5, 12.5],\n [\"M 32 10.5 L 60 4.5 L 55 18 L 37 19 Z\", 43, 13],\n ]\n .map(\n ([d, x, y], i) =>\n path(d as string, WHITE_EYE, { stroke: \"none\" }) +\n clipped(\n `t${i}`,\n d as string,\n iris(`t${i}_i`, x as number, y as number, 4.6, PAINT, 0.4),\n ) +\n path(d as string, \"none\", { width: 3 }),\n )\n .join(\"\") + line(\"M 2.5 3.5 L 22.5 10.5 M 32 10.5 L 60.5 4\", INK, 3.4),\n },\n \"eyes/16\": {\n svg: dotEye(11.5, 13, 8.6, 10.2) + dotEye(45.5, 13.5, 10.4, 11.4),\n },\n \"eyes/17\": {\n svg: line(\n \"M 4 3.5 C 12 1.5 19 6 19 13 C 19 19 14 21 9 20 C 4 19 2 15 3 11 C 4 7 9 6 12 9 C 14 11 12 14 9 13 \" +\n \"M 52 9 C 48 3 42 2 38 3 C 33 5 31 10 33 15 C 35 20 42 21 46 18 C 49 15 48 10 44 9 C 41 8 39 11 41 13\",\n INK,\n 3.4,\n ),\n },\n \"eyes/18\": {\n svg:\n line(\"M 2.5 1.8 C 7 4.6 13 4.6 18.5 2\", INK, 3) +\n line(\"M 34 2 C 41 5 48 5 54.5 1.8\", INK, 3.2),\n },\n \"eyes/19\": {\n paint: \"#ff6a86\",\n svg:\n flat(\n \"M 4 2.5 C 8 1.5 12 2 14 5 L 15 9 C 10 7 6 5.5 4 2.5 Z M 37 6 C 43 2 50 2 54 3.5 L 41 8.5 Z\",\n \"#202020\",\n 0.25,\n ) +\n eyes(\n {\n cx: 10.5,\n cy: 13,\n w: 15,\n h: 11,\n glow: true,\n tilt: 3,\n open: 0.85,\n lid: 2.6,\n wing: 2.4,\n },\n {\n cx: 44.5,\n cy: 12.5,\n w: 21.5,\n h: 12,\n glow: true,\n tilt: 3.2,\n open: 0.85,\n lid: 2.8,\n wing: 2.8,\n },\n ),\n },\n \"eyes/20\": {\n paint: \"#7a4a22\",\n svg: eyes(\n {\n cx: 11.5,\n cy: 13.5,\n w: 18,\n h: 17,\n r: 7.2,\n look: -1,\n wing: 2.6,\n pupil: 0.42,\n },\n { cx: 44.5, cy: 13.5, w: 23, h: 18, r: 8.2, look: -1.2, pupil: 0.42 },\n ),\n },\n};\n"
204
+ },
205
+ {
206
+ "path": "character/packs/base/eyewear.ts",
207
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport {\n circle,\n clipped,\n ellipse,\n flat,\n INK,\n line,\n path,\n rect,\n} from \"../../art/svg\";\nimport { dashed, dollar, star } from \"./shapes\";\n\n// Eyewear (eyewear/glasses_1…30): glasses, goggles and masks over the eyes, in their own colours. Every\n// lens is see-through (owner: the eyes must show through glasses): a frame or a mask has a hole where the\n// lens is, and the lens is its tint at `CLEAR` / `TINT` / `DARK` (only the eye patch hides an eye).\n\n/** A filled shape with patterns cut to it, outlined on top. */\nconst piece = (\n id: string,\n d: string,\n fill: string,\n inside: string,\n width = 3.6,\n) =>\n path(d, fill, { stroke: \"none\", holes: true }) +\n clipped(id, d, inside) +\n path(d, \"none\", { width, holes: true });\n\n/** A see-through lens: its tint at `alpha`, details cut to it, outlined. */\nconst lens = (\n id: string,\n d: string,\n tint: string,\n alpha: number,\n inside: string,\n width = 3.6,\n) =>\n path(d, tint, { stroke: \"none\", opacity: alpha }) +\n clipped(id, d, inside) +\n path(d, \"none\", { width });\n\n/** How much of the eye a lens hides: clear glass, tinted, dark shades. */\nconst CLEAR = 0.3;\nconst TINT = 0.5;\nconst DARK = 0.62;\n\n/** An ellipse as a sub-path — a lens hole in a frame (with `holes`), a ring. */\nconst oval = (cx: number, cy: number, rx: number, ry = rx) =>\n `M ${cx - rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx + rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx - rx} ${cy} Z`;\n\nconst VISOR_6 =\n \"M 5 14 C 5 7 9 4 16 4 L 64 4 C 70 4 73 8 73 16 C 73 24 70 30 62 31 C 50 32 40 26 30 25 C 22 25 16 31 10 30 C 6 28 5 22 5 14 Z\";\n\nconst ALIEN_9 = [\n \"M 7 10 C 17 11 28 18 30 27 C 30 33 25 35 20 33 C 12 29 7 21 7 10 Z\",\n \"M 77 10 C 67 11 56 18 54 27 C 54 33 59 35 64 33 C 72 29 77 21 77 10 Z\",\n] as const;\n\nconst LENSES_12 = [\n \"M 8 10 C 8 8 10 7 12 7 L 30 7 C 33 7 34 9 33 12 L 30 23 C 29 25 27 26 24 26 L 12 26 C 9 26 8 24 8 22 Z\",\n \"M 44 9 C 44 7 46 7 48 7 L 70 7 C 72 7 73 9 73 11 L 72 22 C 72 25 70 26 67 26 L 52 26 C 49 26 47 24 46 21 Z\",\n] as const;\n\nconst GOGGLE_30 =\n \"M 10 9 L 60 9 L 58 22 C 56 26 52 27 48 27 L 22 27 C 16 27 12 24 11 20 Z\";\n\nexport const eyewearArt: Record<string, ArtDef> = {\n // An eye patch.\n \"glasses/1\": {\n svg:\n circle(48, 29, 16, INK, { stroke: \"none\" }) +\n line(\"M 41 22 L 55 36 M 55 22 L 41 36\", \"#d4c6c6\", 6),\n },\n \"glasses/2\": {\n svg:\n [\n \"M 3.5 7.5 C 6 3.5 14 2.5 22 3.5 C 27 4 29 6.5 29 10 C 29 17 27 23.5 20 25.5 C 12 27 5.5 24.5 4 18 C 3.5 14 3.5 10 3.5 7.5 Z\",\n \"M 37.5 8.5 C 42 4 54 3 62 4 C 66.5 4.5 68 7 67.5 11 C 67 18 64 24.5 56 26 C 47 27 40.5 24.5 38.5 18 C 37.5 14 37 11 37.5 8.5 Z\",\n ]\n .map((d, i) =>\n lens(\n `glasses_2_${i}`,\n d,\n \"#70e0ff\",\n 0.25,\n flat(\n i\n ? \"M 50 0 L 80 0 L 80 30 L 58 30 Z\"\n : \"M 13 0 L 40 0 L 40 30 L 20 30 Z\",\n \"#e0f0ff\",\n 0.6,\n ),\n 4,\n ),\n )\n .join(\"\") + line(\"M 29 7.5 C 31.5 6.5 34.5 6.5 37.5 7.5\", INK, 4),\n },\n // Yellow safety goggles.\n \"glasses/3\": {\n svg:\n piece(\n \"glasses_3\",\n \"M 4 18 C 4 6 12 3 24 3 L 70 3 C 82 3 87 8 87 18 C 87 28 82 34 72 34 C 60 34 50 28 45 25 C 40 28 30 34 18 34 C 8 34 4 28 4 18 Z \" +\n \"M 11 16 C 11 11 14 9 20 9 L 66 9 C 72 9 76 11 76 16 C 76 22 72 26 66 26 C 58 26 52 22 45 19 C 38 22 30 26 22 26 C 15 26 11 22 11 16 Z\",\n \"#f2d630\",\n flat(\"M 79 0 L 90 0 L 90 40 L 79 40 Z\", \"#e07030\"),\n ) +\n lens(\n \"glasses_3_lens\",\n \"M 11 16 C 11 11 14 9 20 9 L 66 9 C 72 9 76 11 76 16 C 76 22 72 26 66 26 C 58 26 52 22 45 19 C 38 22 30 26 22 26 C 15 26 11 22 11 16 Z\",\n \"#a0d0ff\",\n 0.4,\n flat(\n \"M 16 9 L 22 9 L 28 26 L 22 26 Z M 46 9 L 56 9 L 64 26 L 54 26 Z\",\n \"#f0f0ff\",\n 0.6,\n ),\n 3,\n ),\n },\n \"glasses/4\": {\n svg:\n line(\n \"M 8 13 C 9 24 18 35 30 36 C 40 36 44 29 43 22 M 48 22 C 47 30 52 37 62 37 C 74 37 84 27 86 13\",\n INK,\n 3.6,\n ) +\n path(\n \"M 3 4.5 C 3.5 2 6 2 10 2.5 C 22 4.5 36 8 45.5 13 C 54 9 70 4.5 86 2 C 91 1.5 93.5 3 93 6 C 92 9 89 11 86 12 C 74 14 60 18 48 23 C 36 18 20 14 8.5 12 C 5.5 11 3.5 8 3 4.5 Z\",\n INK,\n { width: 1.5 },\n ),\n },\n // Star party glasses.\n \"glasses/5\": {\n svg: (\n [\n [20.5, 8],\n [59.3, -8],\n ] as const\n )\n .map(\n ([x, rot]) =>\n path(\n star(x, 23.5, 19.6, rot, 0.5) + \" \" + star(x, 24, 13, rot, 0.5),\n \"#f088d8\",\n { width: 3.6, holes: true },\n ) +\n path(star(x, 24, 13, rot, 0.5), \"#e000ff\", {\n stroke: \"none\",\n opacity: TINT,\n }) +\n path(star(x, 24, 13, rot, 0.5), \"none\", { width: 3 }),\n )\n .join(\"\"),\n },\n // A visor headset.\n \"glasses/6\": {\n svg:\n path(\n \"M 2 15 C 2 6 8 2 16 2 L 78 2 C 86 2 89 6 89 14 C 89 24 86 37 76 38 L 30 37 C 24 33 18 33 12 35 C 6 34 2 26 2 15 Z \" +\n VISOR_6,\n \"#c9c9c9\",\n { holes: true },\n ) +\n lens(\n \"glasses_6\",\n VISOR_6,\n \"#3e3e40\",\n DARK,\n flat(\"M 52 4 L 60 4 L 48 31 L 40 31 Z\", \"#8a8a90\", 0.5),\n 3,\n ) +\n ellipse(81, 15, 3, 5, \"none\", { width: 2.6 }),\n },\n // Shutter shades.\n \"glasses/7\": {\n svg:\n lens(\n \"glasses_7\",\n \"M 4 4 L 32 3 C 35 3 36 6 37 9 L 43 9 C 44 6 45 3 48 3 L 76 4 C 78 10 77 20 74 27 C 72 32 64 33 56 32 C 50 31 46 23 44 18 L 36 18 C 34 23 30 31 24 32 C 16 33 8 32 6 27 C 3 20 2 10 4 4 Z\",\n \"#a050ff\",\n TINT,\n \"\",\n ) +\n line(\n \"M 9 10 L 27 10 M 9 16 L 27 16 M 11 23 L 26 23 M 50 10 L 70 10 M 50 16 L 70 16 M 54 23 L 68 23\",\n \"#1a1a1a\",\n 3.6,\n ),\n },\n // Little round spectacles.\n \"glasses/8\": {\n svg:\n circle(12.5, 13.5, 8.5, \"#d8f0ff\", { stroke: \"none\", opacity: CLEAR }) +\n circle(45.5, 13.5, 10, \"#d8f0ff\", { stroke: \"none\", opacity: CLEAR }) +\n circle(12.5, 13.5, 8.5, \"none\", { width: 3.2 }) +\n circle(45.5, 13.5, 10, \"none\", { width: 3.2 }) +\n line(\"M 21 11 C 25.5 8.5 31 8.5 35.5 11\", INK, 3),\n },\n // Green alien shades.\n \"glasses/9\": {\n svg:\n path(\n \"M 3 4 C 14 4 26 10 32 22 C 34 26 36 28 42 28 C 48 28 50 26 52 22 C 58 10 70 4 81 4 C 82 18 76 34 66 40 C 58 44 48 41 42 38 C 36 41 26 44 18 40 C 8 34 2 18 3 4 Z \" +\n ALIEN_9.join(\" \"),\n \"#50e040\",\n { holes: true },\n ) +\n lens(\"glasses_9_l\", ALIEN_9[0], \"#6a1a1a\", DARK, \"\", 3) +\n lens(\"glasses_9_r\", ALIEN_9[1], \"#6a1a1a\", DARK, \"\", 3),\n },\n // An owl mask.\n \"glasses/10\": {\n svg:\n path(\n \"M 4 28 C 2 16 8 11 18 11 L 64 11 C 74 11 79 16 78 28 C 77 40 70 46 60 46 L 20 46 C 10 46 4 40 4 28 Z \" +\n oval(21, 30, 12) +\n \" \" +\n oval(55, 30, 12),\n \"#d07838\",\n {\n holes: true,\n },\n ) +\n path(\n \"M 28 8 C 28 3 32 2 37 2 C 43 2 46 3 46 8 C 46 14 42 18 38 20 C 32 18 28 14 28 8 Z\",\n \"#d04838\",\n ) +\n [21, 55]\n .map(\n (x) =>\n path(oval(x, 30, 12) + \" \" + oval(x, 30, 9), \"#5a3218\", {\n holes: true,\n }) +\n circle(x, 30, 9, \"#ffe848\", { stroke: \"none\", opacity: TINT }) +\n circle(x, 30, 9, \"none\", { width: 2.4 }),\n )\n .join(\"\") +\n path(\"M 30 45 L 38 37 L 46 45 C 44 48 32 48 30 45 Z\", \"#fff07a\", {\n width: 3,\n }) +\n path(\"M 32 49 C 36 48 40 48 44 49 C 42 53 34 53 32 49 Z\", \"#ffb868\", {\n width: 3,\n }),\n },\n // Half-moon readers.\n \"glasses/11\": {\n svg:\n [\n \"M 4 4 C 4 13 10 18 18 18 C 26 18 32 13 33 4 Z\",\n \"M 40 4 C 41 13 47 18 55 18 C 63 18 68 13 68 4 Z\",\n ]\n .map((d, i) =>\n lens(\n `glasses_11_${i}`,\n d,\n \"#d070e0\",\n TINT,\n flat(\n i\n ? \"M 55 0 L 61 0 L 61 20 L 55 20 Z\"\n : \"M 18 0 L 24 0 L 24 20 L 18 20 Z\",\n \"#ffffff\",\n 0.6,\n ),\n ),\n )\n .join(\"\") + line(\"M 2 3 L 70 3\", INK, 4),\n },\n // 3D glasses.\n \"glasses/12\": {\n svg:\n path(\n \"M 3 6 C 3 3 6 2 9 2 L 75 2 C 79 2 81 4 81 8 L 80 28 C 80 32 76 33 72 32 L 52 30 C 46 29 44 26 41 23 C 37 26 34 30 28 31 L 8 33 C 4 33 2 30 2 26 Z \" +\n LENSES_12.join(\" \"),\n \"#ffffff\",\n { width: 3.4, holes: true },\n ) +\n lens(\n \"glasses_12_l\",\n LENSES_12[0],\n \"#3060c0\",\n TINT,\n flat(\"M 18 6 L 23 6 L 21 27 L 16 27 Z\", \"#60b0f8\", 0.6),\n 3,\n ) +\n lens(\n \"glasses_12_r\",\n LENSES_12[1],\n \"#d04838\",\n TINT,\n flat(\"M 56 6 L 62 6 L 64 27 L 58 27 Z\", \"#ff7060\", 0.6),\n 3,\n ),\n },\n // Pixel shades.\n \"glasses/13\": {\n svg:\n path(\n \"M 1 1 L 80 1 L 80 10 L 75 10 L 75 17 L 70 17 L 70 23 L 47 23 L 47 17 L 41 17 L 41 11 L 40 11 L 40 17 L 35 17 L 35 23 L 12 23 L 12 17 L 6 17 L 6 10 L 1 10 Z\",\n INK,\n { stroke: \"none\", opacity: DARK },\n ) +\n [\n [57, 9],\n [67, 9],\n [52, 14],\n [62, 14],\n ]\n .map(([x, y]) => rect(x!, y!, 5, 5, 0, \"#ffffff\", { stroke: \"none\" }))\n .join(\"\"),\n },\n // Orange round goggles.\n \"glasses/14\": {\n svg:\n path(\n \"M 3 17 C 3 8 10 3 20 3 L 52 3 C 62 3 69 8 69 17 C 69 27 62 34 53 34 C 44 34 38 28 37 20 L 35 20 C 34 28 28 34 19 34 C 10 34 3 27 3 17 Z \" +\n oval(19, 19, 11) +\n \" \" +\n oval(53, 19, 11),\n \"#f07028\",\n {\n holes: true,\n },\n ) +\n [19, 53]\n .map(\n (x) =>\n circle(x, 19, 11, INK, { stroke: \"none\", opacity: DARK }) +\n circle(x, 19, 11, \"none\", { width: 3 }),\n )\n .join(\"\"),\n },\n // Ski goggles.\n \"glasses/15\": {\n svg:\n path(\n \"M 4 20 C 4 8 12 4 24 4 L 60 4 C 72 4 79 9 79 20 C 79 30 74 38 64 38 C 54 38 48 30 41 24 C 34 30 28 38 18 38 C 8 38 4 30 4 20 Z \" +\n \"M 9 20 C 9 13 13 10 22 10 L 61 10 C 69 10 73 13 73 20 C 73 27 70 32 63 32 C 55 32 48 24 41 20 C 34 24 28 32 20 32 C 13 32 9 27 9 20 Z\",\n \"#f4f4f4\",\n { holes: true },\n ) +\n lens(\n \"glasses_15\",\n \"M 9 20 C 9 13 13 10 22 10 L 61 10 C 69 10 73 13 73 20 C 73 27 70 32 63 32 C 55 32 48 24 41 20 C 34 24 28 32 20 32 C 13 32 9 27 9 20 Z\",\n \"#ffd000\",\n 0.5,\n flat(\"M 54 8 C 58 16 60 26 58 34 L 80 34 L 80 8 Z\", \"#fff050\", 0.85),\n 3,\n ),\n },\n // Green hex frames.\n \"glasses/16\": {\n svg: path(\n \"M 3 6 C 3 3.5 5 2.8 8 2.8 L 29 2.8 C 33 2.8 36 5 38 8 L 40 8 C 42 5 45 2.8 49 2.8 L 78 2.8 C 81 2.8 82.3 4 82.3 7 L 82.3 24 C 82 30 78 35.2 70 35.2 L 50 35.2 C 45 35.2 42 33 40.5 30 L 38.5 30 C 37 33 34 35.2 30 35.2 L 16 35.2 C 8 35.2 3 31 2.8 24 Z \" +\n \"M 11 8.7 L 28 8.7 C 31 10.5 34.3 14 34.3 17 L 34.3 22 C 34.3 27 30 31.3 26 31.3 L 19 31.3 C 13 31.3 8.7 26 8.7 21 L 8.7 12 C 8.7 10 9.5 8.7 11 8.7 Z \" +\n \"M 52 8.7 L 73 8.7 C 74.5 8.7 75.3 10 75.3 12 L 75.3 21 C 75.3 26 71 31.3 64 31.3 L 54 31.3 C 49 31.3 42.7 26 42.7 21 L 42.7 17 C 42.7 14 46 10.5 52 8.7 Z\",\n \"#10c060\",\n { holes: true },\n ),\n },\n // A cat-eared mask.\n \"glasses/17\": {\n svg: path(\n \"M 4 30 C 4 22 6 18 8 16 C 8 10 10 5 14 4 C 20 4 24 8 27 12 C 36 10 52 10 62 11 C 65 6 69 3 74 3 C 80 4 82 10 82 16 C 85 20 86 26 86 32 C 86 44 80 52 68 53 C 56 54 46 48 40 45 C 34 48 24 54 16 54 C 8 54 4 44 4 30 Z \" +\n \"M 12 32 C 12 24 18 20 30 19 C 44 18 60 18 70 21 C 76 23 78 28 77 33 C 76 40 72 44 64 44 C 56 44 50 38 42 38 C 34 38 28 44 20 44 C 14 44 12 38 12 32 Z\",\n \"#60e0ff\",\n { holes: true },\n ),\n },\n // Gold-coin glasses.\n \"glasses/18\": {\n svg:\n rect(32, 16.5, 9, 6, 1, \"#ffd800\", { width: 3 }) +\n [17, 56]\n .map(\n (x) =>\n lens(\n `glasses_18_${x}`,\n oval(x, 19.5, 15),\n \"#ffd800\",\n TINT,\n flat(\n `M ${x + 9} 0 L ${x + 20} 0 L ${x + 20} 40 L ${x + 9} 40 Z`,\n \"#ff9a00\",\n TINT,\n ),\n ) + dollar(x, 19.5, 8),\n )\n .join(\"\"),\n },\n // A neon visor.\n \"glasses/19\": {\n svg: lens(\n \"glasses_19\",\n \"M 3 6 C 3 3 5 2 8 2 L 81 2 C 85 2 86 4 86 7 L 86 28 C 86 32 84 34 80 34 L 56 34 C 52 34 50 31 48 28 C 46 25 44 24 41 24 C 38 24 36 25 34 28 C 32 31 30 34 26 34 L 8 34 C 5 34 3 32 3 29 Z\",\n \"#8a0a48\",\n TINT,\n flat(\n \"M 0 0 L 14 0 L 4 40 L 0 40 Z M 66 0 L 90 0 L 90 40 L 76 40 Z\",\n \"#b01880\",\n TINT,\n ) +\n line(\n \"M 0 8 L 25 8 L 31 13 L 90 13 M 0 20 L 70 20 L 76 25 L 90 25 M 0 28 L 15 28\",\n \"#e050c8\",\n 2.4,\n ),\n ),\n },\n // Dark wraparound shades.\n \"glasses/20\": {\n svg: lens(\n \"glasses_20\",\n \"M 4 12 C 4 6 8 4 14 6 C 22 10 30 18 38 22 L 50 22 C 58 18 68 10 78 4 C 84 2 87 6 87 12 C 86 26 82 38 74 46 C 66 52 54 50 48 44 L 44 40 L 40 44 C 32 52 20 52 14 46 C 6 38 4 24 4 12 Z\",\n \"#3a3a3c\",\n DARK,\n flat(\"M 36 20 L 52 20 L 52 46 L 36 46 Z\", \"#2a2a2c\", 0.4) +\n line(\n \"M 12 10 C 18 24 20 36 18 46 M 72 8 C 68 22 66 36 68 48\",\n \"#6a6a70\",\n 3,\n ),\n ),\n },\n // Brown shades under a gold brow bar.\n \"glasses/21\": {\n svg:\n lens(\n \"glasses_21\",\n \"M 5 12 L 73 12 C 73 22 72 30 66 34 C 60 37 46 36 40 30 L 38 28 C 34 32 28 36 18 36 C 10 36 6 30 5 22 Z\",\n \"#8a6638\",\n DARK,\n flat(\n \"M 8 14 L 20 14 L 20 20 L 8 20 Z M 30 18 L 42 18 L 42 24 L 30 24 Z M 50 14 L 62 14 L 62 20 L 50 20 Z M 14 24 L 24 24 L 24 30 L 14 30 Z M 56 24 L 66 24 L 66 30 L 56 30 Z\",\n \"#a88050\",\n 0.5,\n ),\n ) +\n path(\n \"M 2 5 C 4 3 6 3 7 6 L 72 6 C 73 3 75 3 77 5 C 76 9 74 12 72 12 L 7 12 C 5 12 3 9 2 5 Z\",\n \"#d0a820\",\n ),\n },\n // Heart glasses.\n \"glasses/22\": {\n svg:\n rect(30, 10, 7, 6, 1, \"#c02070\", { width: 3 }) +\n [0, 34]\n .map(\n (dx) =>\n path(\n `M ${17 + dx} 28 C ${10 + dx} 24 ${3 + dx} 18 ${3 + dx} 11 C ${3 + dx} 6 ${7 + dx} 3 ${11 + dx} 3 C ${14 + dx} 3 ${16 + dx} 5 ${17 + dx} 7 C ${18 + dx} 5 ${21 + dx} 3 ${24 + dx} 3 C ${29 + dx} 3 ${32 + dx} 7 ${32 + dx} 12 C ${32 + dx} 19 ${25 + dx} 24 ${17 + dx} 28 Z`,\n \"#f03a8a\",\n { opacity: TINT, stroke: \"none\" },\n ) +\n path(\n `M ${17 + dx} 28 C ${10 + dx} 24 ${3 + dx} 18 ${3 + dx} 11 C ${3 + dx} 6 ${7 + dx} 3 ${11 + dx} 3 C ${14 + dx} 3 ${16 + dx} 5 ${17 + dx} 7 C ${18 + dx} 5 ${21 + dx} 3 ${24 + dx} 3 C ${29 + dx} 3 ${32 + dx} 7 ${32 + dx} 12 C ${32 + dx} 19 ${25 + dx} 24 ${17 + dx} 28 Z`,\n \"none\",\n ) +\n ellipse(11 + dx, 10, 3, 2, \"#ffffff\", {\n stroke: \"none\",\n opacity: 0.8,\n }),\n )\n .join(\"\"),\n },\n // Candy-striped frames with dark lenses.\n \"glasses/23\": {\n svg:\n path(\n \"M 3 14 C 3 6 10 3 20 4 C 28 4 34 7 38 9 L 44 9 C 48 7 54 4 62 4 C 72 3 79 6 79 14 C 79 26 72 34 60 34 C 50 34 44 28 42 22 L 40 22 C 38 28 32 34 22 34 C 10 34 3 26 3 14 Z \" +\n oval(21, 19, 12, 10) +\n \" \" +\n oval(61, 19, 12, 10),\n \"#ffffff\",\n { holes: true },\n ) +\n dashed(\n \"M 6 14 C 6 8 12 6 20 6.5 C 28 7 34 9 41 12 C 48 9 54 7 62 6.5 C 70 6 76 8 76 14 C 76 24 70 31 60 31 C 52 31 46 26 41 18 C 36 26 30 31 22 31 C 12 31 6 24 6 14\",\n \"#e03838\",\n 4.5,\n \"6 5\",\n ) +\n [21, 61]\n .map(\n (x) =>\n ellipse(x, 19, 12, 10, \"#101010\", {\n stroke: \"none\",\n opacity: DARK,\n }) +\n flat(\n `M ${x - 9} 14 C ${x - 5} 11 ${x} 10 ${x + 4} 10.5 C ${x} 13 ${x - 4} 16 ${x - 7} 20 Z`,\n \"#909090\",\n 0.5,\n ) +\n ellipse(x, 19, 12, 10, \"none\", { width: 3 }),\n )\n .join(\"\"),\n },\n // Dark motocross goggles.\n \"glasses/24\": {\n svg: lens(\n \"glasses_24\",\n \"M 3 6 C 3 3 5 3 8 3 L 70 3 C 73 3 74 5 73 8 L 66 26 C 64 30 60 31 56 31 L 48 31 C 44 31 41 27 37 22 C 33 27 28 31 22 31 L 18 31 C 14 31 10 29 8 26 Z\",\n \"#262628\",\n DARK,\n flat(\n \"M 12 3 L 20 3 L 24 31 L 16 31 Z M 38 3 L 46 3 L 50 31 L 42 31 Z\",\n \"#6a6a6e\",\n 0.4,\n ),\n ),\n },\n // Glasses with a big pink nose.\n \"glasses/25\": {\n svg:\n [16, 55]\n .map(\n (x) =>\n path(oval(x, 17, 13, 12) + \" \" + oval(x, 17, 9.5, 7.5), \"#a05a30\", {\n holes: true,\n }) +\n ellipse(x, 17, 9.5, 7.5, \"#2e3430\", {\n stroke: \"none\",\n opacity: DARK,\n }) +\n ellipse(x, 17, 9.5, 7.5, \"none\", { width: 2.6 }),\n )\n .join(\"\") +\n path(\n \"M 28 26 C 27 18 29 10 34 9 C 39 9 42 14 42 22 C 42 28 38 31 33 31 C 30 31 28 29 28 26 Z\",\n \"#ff8890\",\n ) +\n path(\"M 21 22 C 23 21 25 22 26 25 C 24 27 22 27 21 25 Z\", \"#ff8890\", {\n width: 2.4,\n }),\n },\n // Blind man's glasses.\n \"glasses/26\": {\n svg: lens(\n \"glasses_26\",\n \"M 4 16 C 4 7 10 3 18 3 C 25 3 30 7 33 11 L 37 11 C 40 7 45 3 52 3 C 60 3 66 8 66 16 C 66 25 60 30 52 30 C 45 30 40 26 37 22 L 33 22 C 30 26 25 30 18 30 C 10 30 4 25 4 16 Z\",\n \"#8a7a56\",\n DARK,\n flat(\n \"M 10 7 C 14 5 20 5 24 7 L 22 9 C 18 8 14 8 11 9 Z M 46 7 C 50 5 56 5 60 7 L 58 9 C 54 8 50 8 47 9 Z\",\n \"#f0e0b0\",\n 0.8,\n ),\n ),\n },\n // A skull mask.\n \"glasses/27\": {\n svg:\n piece(\n \"glasses_27\",\n \"M 4 30 C 4 14 18 3 37 3 C 56 3 70 14 70 30 C 70 46 62 56 50 59 C 42 61 32 61 24 59 C 12 56 4 46 4 30 Z \" +\n oval(17, 30, 9) +\n \" \" +\n oval(49, 30, 10),\n \"#e6dccc\",\n flat(\n \"M 0 50 C 20 54 50 54 74 50 L 74 64 L 0 64 Z M 10 36 L 14 36 L 14 54 L 10 54 Z M 56 36 L 60 36 L 60 54 L 56 54 Z\",\n \"#cfc0a8\",\n ),\n ) +\n circle(17, 30, 9, \"#200f0c\", { stroke: \"none\", opacity: DARK }) +\n circle(49, 30, 10, \"#200f0c\", { stroke: \"none\", opacity: DARK }) +\n line(\"M 24 44 L 24 53 M 32 44 L 32 53 M 40 44 L 40 53\", \"#3a2828\", 4.5),\n },\n // A gas mask.\n \"glasses/28\": {\n svg:\n path(\"M 30 50 L 72 46 C 70 56 56 66 38 70 Z\", \"#4a4858\") +\n path(\n \"M 6 30 C 6 12 22 3 42 3 C 64 3 82 12 82 30 C 82 44 76 50 66 52 L 30 58 C 20 60 10 54 6 44 Z \" +\n oval(21, 30, 11.5, 14.5) +\n \" \" +\n oval(55, 31, 14.5, 14),\n \"#5a5a5c\",\n {\n holes: true,\n },\n ) +\n path(\n oval(21, 30, 11.5, 14.5) + \" \" + oval(21, 30, 8.5, 11.5),\n \"#6a6a6e\",\n { width: 3, holes: true },\n ) +\n path(oval(55, 31, 14.5, 14) + \" \" + oval(55, 31, 11.5, 11), \"#6a6a6e\", {\n width: 3,\n holes: true,\n }) +\n lens(\n \"glasses_28_l\",\n oval(21, 30, 8.5, 11.5),\n \"#1e1e20\",\n DARK,\n flat(\"M 22 18 L 28 18 L 24 42 L 18 42 Z\", \"#8a8a90\", 0.5),\n 2.6,\n ) +\n lens(\n \"glasses_28_r\",\n oval(55, 31, 11.5, 11),\n \"#1e1e20\",\n DARK,\n flat(\"M 57 20 L 64 20 L 59 42 L 52 42 Z\", \"#8a8a90\", 0.5),\n 2.6,\n ) +\n ellipse(20, 57, 17, 16, \"#8a92a0\") +\n ellipse(19, 58, 11, 10.5, \"#3e4a54\", { width: 3 }),\n },\n // Rainbow goggles.\n \"glasses/29\": {\n svg: lens(\n \"glasses_29\",\n \"M 3 20 C 3 10 10 3 20 3 L 52 3 C 62 3 69 10 69 20 C 69 30 62 38 50 38 C 42 38 38 30 35 24 C 32 30 28 38 20 38 C 10 38 3 30 3 20 Z\",\n \"#e8e030\",\n TINT,\n flat(\"M 0 0 L 18 0 L 12 40 L 0 40 Z\", \"#e04a3a\", TINT) +\n flat(\"M 18 0 L 36 0 L 30 40 L 12 40 Z\", \"#f09030\", TINT) +\n flat(\"M 58 0 L 72 0 L 72 40 L 48 40 Z\", \"#60e040\", TINT),\n ),\n },\n // A respirator with goggles.\n \"glasses/30\": {\n svg:\n path(\n \"M 4 10 C 4 5 8 3 14 3 L 58 3 C 64 3 67 6 67 11 L 64 26 C 62 30 58 32 54 32 L 18 32 C 12 32 8 28 6 24 Z \" +\n GOGGLE_30,\n \"#4e6a6e\",\n { holes: true },\n ) +\n lens(\n \"glasses_30\",\n GOGGLE_30,\n \"#9ade30\",\n TINT,\n flat(\n \"M 22 9 L 30 9 L 26 27 L 18 27 Z M 44 9 L 54 9 L 50 20 L 40 20 Z\",\n \"#6aa820\",\n TINT,\n ),\n 3,\n ) +\n path(\n \"M 22 28 C 26 20 30 17 36 17 C 42 17 46 20 50 28 L 50 40 C 50 46 46 49 40 49 L 30 49 C 22 49 18 44 18 36 Z\",\n \"#5a7878\",\n ) +\n line(\"M 26 34 L 26 45 M 35 28 L 35 45 M 44 34 L 44 45\", \"#304848\", 4),\n },\n};\n"
208
+ },
209
+ {
210
+ "path": "character/packs/base/gear.ts",
211
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport {\n circle,\n clipped,\n ellipse,\n flat,\n group,\n INK,\n line,\n path,\n rect,\n tone,\n} from \"../../art/svg\";\nimport { dashed } from \"./shapes\";\n\n// What the hands hold and what is worn on the back. Right-hand items lie along x with the business\n// end to the LEFT (where the character faces) and the grip on the right; shields face the viewer.\n\ninterface Blade {\n /** The blade's upper half, its lower (lit) half, the ridge between. */\n top: string;\n low: string;\n ridge: string;\n guard: string;\n grip: string;\n wrap: string;\n}\n\n/** A sword: the tip at the left, a crescent guard, a wrapped grip and a round pommel at the right. */\nconst sword = (id: string, c: Blade) => {\n const BLADE = \"M 3 25.5 L 25 16 L 103 17 L 103 35 L 25 35.5 Z\";\n return (\n path(BLADE, c.low, { stroke: \"none\" }) +\n clipped(\n id,\n BLADE,\n flat(\"M 0 0 L 110 0 L 110 25 L 0 25 Z\", c.top),\n flat(\n \"M 78 14 C 82 20 84 22 104 22 L 104 30 C 84 30 82 32 78 38 L 110 38 L 110 14 Z\",\n c.ridge,\n 0.55,\n ),\n line(\"M 8 25.5 L 103 25.5\", c.ridge, 2.4),\n ) +\n path(BLADE, \"none\") +\n path(\"M 117 19 L 143 19 L 143 31 L 117 31 Z\", c.grip) +\n line(\"M 121 20 L 125 30 M 128 20 L 132 30 M 135 20 L 139 30\", c.wrap, 2.4) +\n circle(146.5, 25, 6.5, c.guard) +\n path(\"M 106 17 C 101 19 99 22 99 25 C 99 28 101 31 106 33 Z\", c.guard, {\n width: 3,\n }) +\n path(\n \"M 105 4 C 113 8 118 16 117 25 C 118 34 113 42 105 48 C 107 40 108 32 108 25 C 108 18 107 11 105 4 Z\",\n c.guard,\n )\n );\n};\n\n/** A fire / magic rod: a blade with three hooks above and three below, all bent toward the hilt. */\nconst hooked = (id: string, c: Blade) => {\n const BLADE = \"M 3 23 L 14 15 L 127 15 L 127 31 L 14 31 Z\";\n const up = [35, 66, 97]\n .map((x) => `M ${x} 7.5 L ${x + 14} 7.5 L ${x + 19} 16`)\n .join(\" \");\n const down = [20, 52, 83]\n .map((x) => `M ${x} 39 L ${x + 14} 39 L ${x + 19} 30`)\n .join(\" \");\n return (\n line(up, INK, 11.5) +\n line(down, INK, 11.5) +\n line(up, c.top, 6) +\n line(down, c.low, 6) +\n path(BLADE, c.low, { stroke: \"none\" }) +\n clipped(id, BLADE, flat(\"M 0 0 L 130 0 L 130 23 L 0 23 Z\", c.top)) +\n path(BLADE, \"none\") +\n line(\"M 12 23 L 126 23\", INK, 2.2) +\n path(\"M 137 20 L 164 20 L 164 27 L 137 27 Z\", c.grip, { width: 3 }) +\n path(\"M 165 18 C 170 18 171 29 165 29 Z\", c.guard, { width: 2.8 }) +\n path(\"M 127 11 L 133 11 C 137 16 137 30 133 34 L 127 34 Z\", c.guard)\n );\n};\n\nconst SHIELD_4 =\n \"M 7 3 L 58 3 C 70 4 80 14 92 36 C 94 39 94 42 92 45 C 80 66 70 76 58 77 L 7 78 C 4 78 3 76 3 72 L 3 8 C 3 5 4 3 7 3 Z\";\nconst PACK_1 =\n \"M 4 22 C 4 8 10 3 22 3 L 62 3 C 72 3 76 6 78 12 C 82 16 82 24 82 34 C 82 38 80 41 76 42 L 75 62 C 75 70 70 73 62 73 \" +\n \"L 16 73 C 8 73 4 68 4 60 Z\";\nconst PACK_14 =\n \"M 4 60 L 4 28 C 4 12 14 3 30 2 C 46 2 58 10 61 26 L 62 62 C 62 70 56 72 50 72 L 12 72 C 6 72 4 68 4 60 Z\";\n\n/** A hatchet: a curved blade under the handle's end. */\nconst hatchet = (head: string, edge: string, handle: string) =>\n path(\n \"M 20 17 L 57 17 C 57 26 62 34 70 40 C 60 50 48 56 36 56 C 24 56 12 50 3 41 C 12 34 18 26 20 17 Z\",\n head,\n ) +\n path(\n \"M 12 35 C 20 41 28 43 36 43 C 46 43 54 40 61 35 C 63 37 66 39 70 40 C 60 50 48 56 36 56 C 24 56 12 50 3 41 C 6 38 9 36 12 35 Z\",\n edge,\n ) +\n line(\n \"M 13 34 C 20 39 28 41 36 41 C 46 41 54 38 60 33\",\n tone(head, -0.35),\n 2.6,\n ) +\n rect(17, 3, 93, 15, 3, handle);\n\n/** A pickaxe: a crescent head on a long handle. */\nconst pickaxe = (head: string, inner: string, handle: string) =>\n rect(2.8, 38.5, 105.5, 15, 6, handle) +\n path(\n \"M 40 3.5 C 22 14 11 30 11 46 C 11 62 22 78 40 88.5 C 31 78 26.5 62 26.5 46 C 26.5 30 31 16 42.5 4 Z\",\n head,\n ) +\n path(\n \"M 32 16 C 24 26 20 36 20 46 C 20 56 24 66 32 76 C 28 66 26 56 26 46 C 26 36 28 26 32 16 Z\",\n inner,\n { stroke: \"none\" },\n );\n\n/** A spade: a pointed blade, a short shaft and a D-shaped grip. */\nconst spade = (blade: string, shaft: string, grip: string) =>\n path(\"M 66 22 L 92 22 L 92 36 L 66 36 Z\", shaft) +\n path(\n \"M 90 20 L 104 6 L 118 6 C 120 6 121 8 121 10 L 121 48 C 121 50 120 51 118 51 L 106 51 L 90 36 Z M 106 18 L 112 16 L 112 40 Z\",\n grip,\n { holes: true },\n ) +\n path(\n \"M 4 28 L 20 3 L 58 3 C 61 3 62 5 62 8 L 62 49 C 62 52 60 54 57 54 L 20 54 Z\",\n blade,\n { stroke: \"none\" },\n ) +\n clipped(\n `spade_${blade.slice(1)}`,\n \"M 4 28 L 20 3 L 58 3 C 61 3 62 5 62 8 L 62 49 C 62 52 60 54 57 54 L 20 54 Z\",\n flat(\"M 0 34 L 66 30 L 66 60 L 0 60 Z\", tone(blade, -0.2)),\n ) +\n path(\n \"M 4 28 L 20 3 L 58 3 C 61 3 62 5 62 8 L 62 49 C 62 52 60 54 57 54 L 20 54 Z\",\n \"none\",\n ) +\n path(\"M 36 29 C 40 22 56 20 66 22 L 66 36 C 56 38 40 36 36 29 Z\", blade);\n\n/** A club with a rounded head on a wooden stick (bands of colour over the head when given). */\nconst stickClub = (id: string, head: string, inside: string) => {\n const HEAD =\n \"M 4 22 C 4 10 10 4 22 4 L 84 4 L 84 40 L 22 40 C 10 40 4 34 4 22 Z\";\n return (\n path(\n \"M 84 14 L 128 14 C 131 14 132 16 132 21 C 132 26 131 28 128 28 L 84 28 Z\",\n \"#c06838\",\n ) +\n line(\"M 90 18 L 110 20 M 100 24 L 124 24\", \"#8a4020\", 1.8) +\n path(HEAD, head, { stroke: \"none\" }) +\n clipped(id, HEAD, inside) +\n path(HEAD, \"none\")\n );\n};\n\n/** A bat studded with spikes. */\nconst spikedBat = (body: string, spike: string, wrap: string) => {\n const spikes: Array<[number, number, number]> = [\n [20, 18, -1],\n [38, 18, -1],\n [62, 20, -1],\n [72, 21, -1],\n [28, 44, 1],\n [52, 42, 1],\n [76, 40, 1],\n ];\n return (\n spikes\n .map(([x, y, s]) =>\n path(`M ${x - 4} ${y} L ${x} ${y + s * 14} L ${x + 4} ${y} Z`, spike, {\n width: 2.4,\n }),\n )\n .join(\"\") +\n path(\"M 4 6 L 12 22 L 4 34 Z\", spike, { width: 2.4 }) +\n path(\n \"M 6 30 C 6 22 12 18 22 18 L 92 22 C 108 24 118 26 130 26 C 134 20 140 20 140 30 C 140 40 134 42 130 34 C 118 36 108 38 92 40 L 22 44 C 12 44 6 38 6 30 Z\",\n body,\n ) +\n (wrap\n ? path(\"M 94 23 L 124 26 L 124 35 L 94 38 Z\", wrap, { width: 2.4 }) +\n line(\"M 100 24 L 108 37 M 108 25 L 116 36\", tone(wrap, -0.3), 1.8)\n : \"\") +\n [\n [30, 26],\n [46, 32],\n [64, 28],\n [80, 34],\n ]\n .map(([x, y]) =>\n path(\n `M ${x} ${y! - 4} L ${x! + 3} ${y! + 3} L ${x! - 3} ${y! + 3} Z`,\n spike,\n { width: 1.8 },\n ),\n )\n .join(\"\")\n );\n};\n\n/** A fish held by the tail. */\nconst fish = (id: string, back: string, belly: string, head: string) => {\n const BODY =\n \"M 6 24 C 6 14 22 8 40 8 C 50 4 64 4 72 8 L 82 6 C 90 8 96 10 100 14 C 110 14 116 18 118 22 L 132 10 C 136 10 138 14 136 18 L 130 24 L 136 32 C 138 36 134 38 130 36 L 118 26 C 112 32 100 36 88 36 C 70 40 40 40 26 38 C 14 36 6 32 6 24 Z\";\n return (\n path(BODY, back, { stroke: \"none\" }) +\n clipped(\n id,\n BODY,\n flat(\"M 0 26 C 40 30 80 30 140 24 L 140 44 L 0 44 Z\", belly),\n flat(\"M 0 0 L 30 0 C 34 14 34 28 30 44 L 0 44 Z\", head),\n line(\"M 30 8 C 34 18 34 30 30 40\", INK, 2.6),\n ) +\n path(BODY, \"none\") +\n circle(20, 18, 4, \"#ffffff\", { width: 2.2 }) +\n circle(20, 18, 1.6, INK, { stroke: \"none\" }) +\n line(\"M 8 26 L 14 26\", INK, 2.2)\n );\n};\n\n/** A claw hammer. */\nconst clawHammer = (head: string, handle: string, bands = \"\") =>\n rect(18, 18, 66, 14, 3, handle) +\n bands +\n path(\n \"M 6 30 C 4 20 6 10 12 4 C 18 6 20 12 18 18 L 18 40 C 20 44 20 52 16 54 L 8 54 C 4 52 4 44 8 40 C 6 36 6 34 6 30 Z\",\n head,\n ) +\n line(\"M 10 10 C 12 14 12 18 12 22\", tone(head, -0.35), 2.2);\n\n/** An ellipse as a path (for clips and cut-outs). */\nconst oval = (cx: number, cy: number, rx: number, ry = rx) =>\n `M ${cx - rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx + rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx - rx} ${cy} Z`;\n\n/** A regular pentagon (a football's patch). */\nconst pentagon = (cx: number, cy: number, r: number, rot = 0) =>\n \"M \" +\n [0, 1, 2, 3, 4]\n .map((i) => {\n const a = ((rot - 90 + i * 72) * Math.PI) / 180;\n return `${(cx + r * Math.cos(a)).toFixed(1)} ${(cy + r * Math.sin(a)).toFixed(1)}`;\n })\n .join(\" L \") +\n \" Z\";\n\n// Outlines of the left-hand items, each in its image's own pixels.\nconst SHIELD_5 =\n \"M 6 8 C 6 5 9 4 14 4 L 98 3.5 C 102 3.5 105 6 105 10 L 105 62 C 105 70 102 76 96 78 L 16 80 C 10 82 6 84 4 80 L 3.5 14 C 3.5 11 4 9 6 8 Z\";\nconst SHIELD_7 =\n \"M 22 3.5 L 57 3.5 C 70 5 82 16 89 35 C 82 52 70 63 52 66.5 L 24 67 C 22 56 14 44 3 35 C 14 26 21 14 22 3.5 Z\";\nconst SHIELD_9 =\n \"M 3.5 3 L 88 3 C 91 3 92.5 5 92.5 8 C 93.5 28 93.5 44 91.5 62 C 91.5 65.5 89.5 67 86 67 L 3.5 67.5 C 9 48 9 22 3.5 3 Z\";\nconst SHIELD_10 =\n \"M 5.5 3.5 L 55 3.5 C 68 4.5 76 18 82.5 40 C 76.5 60 68 72 52 76 L 6 78.5 C 4.5 78.5 3.5 77.5 3.5 76 L 3.5 6 C 3.5 4.5 4.5 3.5 5.5 3.5 Z\";\nconst BOTTLE_15 =\n \"M 14 12 C 20 10 24 4 34 3.5 L 70 3.5 C 74 3.5 76 6 76 10 L 76 14 C 72 16 72 20 76 22 L 76 26 C 76 30 74 32.5 70 32.5 L 34 32.5 C 24 32 20 26 14 24 Z\";\nconst CARROT_16 =\n \"M 22 4 C 26 2.8 33 2.8 40 3.2 C 48 4.5 56 8.5 60 13 C 61.8 15.5 61.5 19 58.5 21.5 C 54 24.5 46 26.5 38 26.5 L 26 26.5 C 22 25.5 19.5 21 19.5 15 C 19.5 9 20 5 22 4 Z\";\nconst DUCK_17 =\n \"M 7.5 13 C 7.5 6.5 12.5 2.8 19.5 2.8 C 26.5 2.8 31.5 7.5 31.5 13.5 C 31.5 14.5 31.3 15.3 31 15.5 C 37 15.5 42 14 46 12 C 49 10.5 51.5 11.5 51.5 15 L 51.5 21 C 51 32 44 40.5 30 40.5 L 24 40.5 C 18 40.5 13.5 37 13 31 L 8 30.5 C 4.5 30 2.8 27.5 2.8 24.5 C 2.8 21.5 4.5 19.5 7.5 18.5 Z\";\nconst ROCK_19 =\n \"M 4 14 L 14 4 L 30 3.5 L 40 8 L 43.5 22 L 40 34 L 28 39.5 L 12 39 L 3.5 28 Z\";\nconst APPLE_20 =\n \"M 21 9 C 12 4 3 10 3 22 C 3 32 10 38.5 19 38.5 C 21 38 23 38 25 38.5 C 34 38.5 40.5 31 40.5 21 C 40.5 10 32 5 23 9 Z\";\nconst BANANA_21 =\n \"M 4 13 C 4 9 6 7.5 10 7 C 14 6.5 16 5 20 3.8 C 26 2.8 33 3 38 5 C 45 8 50 14 51.5 22 C 52.5 28 52 34 50.5 37.5 C 49.5 39.5 46 40 43.5 38.5 C 41 33 37 27 31 22.5 C 26 18.5 20 16.5 14 16 C 10 15.5 7 16 5 15.5 C 4 15 4 14 4 13 Z\";\nconst CAN_22 =\n \"M 22.5 21 C 22.5 19.5 24.5 18.8 30 18.8 L 46 18.8 C 49 18.8 50.5 20 50.5 22 L 50.5 40 C 50.5 43 48 44.3 44 44.3 L 28 44.3 C 24.5 44.3 22.5 42.5 22.5 40 Z\";\nconst DRILL_23 =\n \"M 3.5 10 C 3.5 5 8 2.5 16 2.5 C 24 2.5 30 5 30 10 L 30 40 C 30 46 26 48 22 50 L 10 50 C 6 48 3.5 46 3.5 40 Z\";\nconst ROLL_24 =\n \"M 12 3.5 L 44 3.5 C 50 3.5 53.5 10 53.5 21.5 C 53.5 33 50 39.5 44 39.5 L 12 39.5 C 6 39.5 3.5 33 3.5 21.5 C 3.5 10 6 3.5 12 3.5 Z\";\nconst MAT_26 =\n \"M 8 18.5 C 30 14 60 8 84 3.5 C 90 2.5 91.5 8 91.5 14 C 91.5 20 90 24.5 86 25.5 L 12 46 C 6 47 3.5 42 3.5 33 C 3.5 25 4 19.5 8 18.5 Z\";\nconst POT_27 =\n \"M 49 16.5 C 56 16.5 64 18 70 20.5 C 73.5 22 74.5 25 74.5 30 C 74.5 36 73 42 71 45.5 C 69 48.5 66 49.5 62 49.5 L 49 49.5 Z\";\nconst PHONE_28 =\n \"M 5 14 C 4 8 8 4 16 4 C 28 3 42 4 52 10 C 58 14 61.5 20 61 26 C 61 30 58 32 54 32 L 44 31 C 40 30 38 27 40 23 C 41 20 44 18 46 17 \" +\n \"C 40 14 32 13 24 14 C 26 18 26 22 22 25 C 18 28 10 28 7 25 C 5 22 5 18 5 14 Z\";\nconst PIZZA_30 =\n \"M 8 56 C 5.5 55 3.8 53 3.8 50 L 4.5 11 C 4.5 6 8 2.8 14 2.8 C 22 2.8 30 5 36 9 C 40 12 42.5 17 42.5 23 C 42.5 26 41 28 38 31 L 14 54.5 C 12 56 10 56.5 8 56 Z\";\nconst EGG_31 =\n \"M 4 24 C 4 10 16 3.5 28 3.5 C 42 3.5 50.5 12 50.5 22 C 50.5 34 40 41.5 26 41.5 C 12 41.5 4 36 4 24 Z\";\nconst HORN_32 =\n \"M 15 14 C 15 6 18 3.5 23 3.5 C 28 3.5 32 6 32 14 L 34 30 C 40 36 41 42 40 46 L 3 46 C 2 42 4 36 10 30 Z\";\nconst CHICKEN_34 =\n \"M 18 12 C 30 8 50 6 66 8 C 72 4 76 2.5 78 6 C 78 9 76 10 74 11 L 88 14 C 90.5 15 90.5 18 88 18 L 80 18 L 89 22 C 91 24 89.5 26 87 25.5 \" +\n \"L 72 23 C 60 30 40 31.5 26 30 Z\";\nconst BREAD_35 =\n \"M 15 4.5 L 44 8 C 47.5 8.5 49 11 48.5 14.5 L 46 37 C 45.5 40 43.5 41.5 40 41 L 11 37.5 C 7.5 37 6 35 6.3 31.5 L 8.5 10 C 9 6.5 11.5 4.3 15 4.5 Z\";\nconst HEART_37 =\n \"M 14 3 C 17 2.8 21 2.8 23 4 C 27 4 33 4.5 36 6.5 C 42 9.5 47 15 47.3 23 C 47.5 30 45 35 40 37.5 C 33 40 24 39.5 17 38.5 C 11 37.5 7 34 7 29 C 6 25 4 21 3.5 17 C 2.8 13 4 10 7 9 L 12 8 C 12 6 12.5 3.5 14 3 Z\";\nconst BONE_38 =\n \"M 16 11 L 48 11 C 48 5 51 3 55 3 C 59.5 3 62 6.5 62 10 C 62 13 61 15 59 16 C 61 17 62 19 62 22 C 62 25.5 59.5 29 55 29 C 51 29 48 27 48 21 L 16 21 C 16 27 13 29 9.5 29 C 5.5 29 3 25.5 3 22 C 3 19 4 17 6 16 C 4 15 3 13 3 10 C 3 6.5 5.5 3 9.5 3 C 13 3 16 5 16 11 Z\";\n\n/** A round flask lying on its side, the corked neck up at the left. */\nconst potion = (id: string, glass: string, rim: string) =>\n group(\n \"rotate(-38 15 12)\",\n rect(9, 6, 13, 12, 3, rim),\n rect(2, 5.5, 9, 13, 3.5, \"#e08a48\"),\n ) +\n circle(34, 19.5, 15.5, glass, { stroke: \"none\" }) +\n clipped(\n id,\n oval(34, 19.5, 15.5),\n flat(oval(38, 26, 15, 12), tone(glass, -0.18)),\n ) +\n circle(34, 19.5, 15.5, \"none\") +\n ellipse(38, 11.5, 4.5, 2.6, \"#ffffff\", { stroke: \"none\", opacity: 0.85 }) +\n circle(44, 17, 1.6, \"#ffffff\", { stroke: \"none\", opacity: 0.85 });\n\n// Backs. Several are one shape in two colourings (back_21 is back_1 recoloured, and so on): those are helpers.\n\n/** A plain rucksack with a strap line at the right (back_1, back_21). */\nconst pack1 = (id: string, body: string, bottom: string) =>\n path(PACK_1, body, { stroke: \"none\" }) +\n clipped(\n id,\n PACK_1,\n flat(\"M 0 58 C 30 61 60 61 90 58 L 90 80 L 0 80 Z\", bottom),\n ) +\n path(PACK_1, \"none\") +\n line(\"M 72 22 L 73 40\", INK, 3);\n\nconst PACK_2 =\n \"M 2.8 30 C 2.8 12 13 2.8 32 2.8 C 52 2.8 63 12 65.5 28 L 66.5 58 C 67 66 62 70.2 54 70.2 L 12 70.2 C 6 70.2 2.8 66 2.8 60 Z\";\n/** A rounded pack with a stripe over the top and a buttoned pocket (back_2, back_23). */\nconst pack2 = (id: string, body: string, stripe: string, button: string) =>\n path(PACK_2, body, { stroke: \"none\" }) +\n clipped(\n id,\n PACK_2,\n flat(\"M 0 60 C 20 64 50 64 70 58 L 70 74 L 0 74 Z\", tone(body, -0.12)),\n line(\"M 40 2 C 53 5 59 14 61 34\", stripe, 6),\n line(\"M 40 2 C 53 5 59 14 61 34\", \"#ffffff\", 1.8),\n ) +\n path(PACK_2, \"none\") +\n rect(51.5, 36.5, 19.5, 24.5, 6, body) +\n line(\"M 55 50 C 58 54 63 54 66 51\", INK, 2.2) +\n circle(64, 43.5, 2.6, button, { stroke: \"none\" });\n\nconst PACK_3 =\n \"M 2.8 22 C 2.8 9 10 2.8 24 2.8 L 46 2.8 C 60 2.8 67.2 9 67.2 22 L 67.2 56 C 67.2 63 62 67.2 54 67.2 L 16 67.2 C 8 67.2 2.8 63 2.8 56 Z\";\n/** A boxy pack with a band along the bottom (back_3, back_22). */\nconst pack3 = (id: string, body: string, band: string) =>\n path(PACK_3, body, { stroke: \"none\" }) +\n clipped(\n id,\n PACK_3,\n flat(\"M 0 50 L 71 50 L 71 55 L 0 55 Z\", tone(body, -0.2)),\n flat(\"M 0 55 L 71 55 L 71 71 L 0 71 Z\", band),\n line(\"M 0 55 L 71 55\", INK, 2.4),\n ) +\n path(PACK_3, \"none\") +\n line(\"M 57 9 C 58 18 58 32 57 42\", \"#ffffff\", 2.2);\n\nconst SACK_12 =\n \"M 36 16 C 46 18 54 22 60 30 C 68 42 69.5 56 69.5 70 C 69.5 84 60 93 40 95 C 22 96 5 90 3.5 76 C 2.5 64 3 48 8 34 C 12 24 22 16 36 16 Z\";\n/** A sack tied at the top, a tuft above the cord, a patch on its side (back_12, back_27). */\nconst sack12 = (id: string, body: string, cord: string, patch: string) =>\n path(\n \"M 44 20 C 40 14 42 6 50 4 C 54 2.5 60 3 63 5 L 61 8 L 66 9 L 63 13 L 68 15 L 64 19 C 58 22 50 23 44 20 Z\",\n body,\n ) +\n path(SACK_12, body, { stroke: \"none\" }) +\n clipped(\n id,\n SACK_12,\n flat(\"M 0 80 C 20 90 50 90 72 76 L 72 98 L 0 98 Z\", tone(body, -0.12)),\n ) +\n path(SACK_12, \"none\") +\n path(\"M 38 18 C 44 15 52 16 57 20 L 56 27 C 50 24 43 24 37 27 Z\", cord) +\n path(\"M 52 52 L 64 50 L 66 74 L 54 76 Z\", patch, {\n stroke: tone(body, -0.3),\n width: 2,\n });\n\nconst BAG_16 =\n \"M 26 20 L 46 20 C 52 30 60 42 64 50 L 66 66 C 66.5 76 60 82 48 82 L 14 82 C 7 82 3 78 3 72 L 3 62 C 3 46 14 30 26 20 Z\";\n/** A round-bellied bag with a top handle and a band at the bottom (back_16, back_30). */\nconst bag16 = (id: string, body: string, trim: string) =>\n path(\"M 62 46 C 70 46 75.5 52 75.5 60 C 75.5 68 70 74 62 74 Z\", trim) +\n path(\n \"M 18 21 L 18 12 C 18 6 24 2.8 33 2.8 C 42 2.8 48 6 48 12 L 48 21 L 41 21 L 41 13 C 41 10.5 38 9.5 33 9.5 C 28 9.5 25 10.5 25 13 L 25 21 Z\",\n trim,\n ) +\n path(BAG_16, body, { stroke: \"none\" }) +\n clipped(\n id,\n BAG_16,\n flat(\"M 0 68 C 20 72 50 72 70 66 L 70 74 C 50 79 20 79 0 75 Z\", trim),\n flat(\"M 46 20 L 80 20 L 80 90 L 56 90 Z\", tone(body, -0.06)),\n ) +\n path(BAG_16, \"none\") +\n line(\"M 46 22 C 50 30 52 36 50 42\", INK, 2) +\n line(\"M 55 59 L 63 57\", trim, 4);\n\nconst ROLL_18 =\n \"M 22 3 C 40 2.8 60 2.8 74 5 C 86 8 92.2 22 92.2 44 C 92.2 66 86 82 70 86 C 54 88 34 88 20 84 C 8 80 2.8 66 2.8 46 C 2.8 24 8 6 22 3 Z\";\n/** A bedroll seen end on: its face, the hollow in it, the rolled side (back_18, back_25). */\nconst roll18 = (face: string, hollow: string, side: string) =>\n path(ROLL_18, side) +\n line(\n \"M 82 20 L 90 18 M 84 34 L 92 34 M 84 50 L 92 52 M 82 66 L 89 70 M 86 12 L 84 26 M 88 40 L 86 56 M 86 60 L 84 76\",\n tone(side, -0.25),\n 2,\n ) +\n path(\n \"M 22 3 C 40 2.8 58 3 66 6 C 76 12 80 28 80 46 C 80 64 76 78 66 84 C 54 88 34 88 20 84 C 8 80 2.8 66 2.8 46 C 2.8 24 8 6 22 3 Z\",\n face,\n ) +\n path(\n \"M 20 14 C 30 11.5 48 11.5 58 14 C 66 18 70 30 70 46 C 70 62 66 72 58 76 C 48 79 30 79 20 76 C 13 72 11 62 11 46 C 11 30 13 18 20 14 Z\",\n hollow,\n {\n stroke: tone(hollow, -0.3),\n width: 2.6,\n },\n );\n\n/** A sitting teddy bear (back_20, back_26). */\nconst bear20 = (fur: string) =>\n circle(14.5, 9, 6.5, fur) +\n circle(41.5, 10, 7, fur) +\n path(\n \"M 6 43 L 46 43 C 52 45 57 48 60 52 C 62 55 61.5 60 58 61.5 C 55.5 62.5 54.5 63.5 54 65 C 57 67 58 71 57.5 76 C 57 81 54 84.5 49 84.5 C 44 84.5 39 82 37 79 L 12 79 C 6 79 2.8 74 2.8 66 C 2.8 55 3.5 48 6 43 Z\",\n fur,\n ) +\n line(\"M 37 79 C 36 72 38 66 44 63 M 38 60 C 44 62 50 62 54 65\", INK, 2.4) +\n path(\n \"M 4 26 C 4 14 12 8.5 26 8.5 C 40 8.5 49 13 49.5 24 C 50 32 50.5 38 46 42 C 40 46 14 46 8 42 C 4 39 3 33 4 26 Z\",\n fur,\n );\n\n/** A sword worn across the back, the hilt up at the right (back_17, back_28). */\nconst backSword = (\n blade: string,\n edge: string,\n grip: string,\n wrap: string,\n guard: string,\n) =>\n path(\"M 3 68 L 31 27 L 51 41 L 18 78 L 5 80 C 3 80 2 78 2 76 Z\", blade) +\n line(\"M 6 76 L 44 33\", edge, 3) +\n path(\"M 47 21 L 58 6 L 64 12 L 55 27 Z\", grip) +\n line(\"M 52 16 L 58 19 M 55 12 L 61 15\", wrap, 2) +\n circle(66.5, 9, 6, guard) +\n path(\n \"M 30 18 C 31 15 34 15 37 17 C 46 22 54 32 59 42 C 60 46 57 49 54 47 C 48 38 40 30 31 24 Z\",\n guard,\n );\n\nconst TANK_8 =\n \"M 10 30 C 14 22 20 19.5 27 19.5 C 34 19.5 41 22 45 28 C 49 34 51.2 40 51.2 48 L 51.2 98 C 51.2 103 48 105.2 43 105.2 L 11 105.2 \" +\n \"C 6 105.2 2.8 103 2.8 98 L 2.8 46 C 2.8 40 5 34 10 30 Z\";\n/** A gas bottle with a valve on top and a warning label (back_8, back_29). */\nconst tank8 = (id: string, body: string, knob: string, label: string) =>\n rect(24, 9, 10, 13, 2, \"#a8a8b0\") +\n rect(33, 11, 10, 9, 2.5, \"#b8b8c0\") +\n circle(38, 15.5, 2.2, \"#6a6a72\", { stroke: \"none\" }) +\n path(\n \"M 16 8 C 16 4.5 20 4 24 4.5 C 25 2.8 33 2.8 34 4.5 C 38 4 43 4.5 43 8 C 43 11.5 38 11.5 34 11 L 24 11 C 20 11.5 16 11.5 16 8 Z\",\n knob,\n ) +\n path(TANK_8, body, { stroke: \"none\" }) +\n clipped(\n id,\n TANK_8,\n flat(\"M 51.5 46 L 51.5 84 L 39 65 Z\", label),\n line(\"M 51.5 46 L 39 65 L 51.5 84\", INK, 2.6),\n flat(\"M 8 34 C 10 30 13 27 17 25 L 17 98 L 8 98 Z\", tone(body, 0.12)),\n ) +\n path(TANK_8, \"none\");\n\nconst PACK_15 =\n \"M 14 30 L 74 30 C 80 32 84 38 84 46 L 85 96 C 85 102 80 104 74 104 L 12 104 C 8 104 5.8 101 5.8 96 L 5.8 42 C 5.8 35 9 30 14 30 Z\";\n/** A big hiking pack: a top flap, side pockets with straps, a pad strapped under it (back_15, back_24). */\nconst pack15 = (\n id: string,\n body: string,\n flap: string,\n window: string,\n pad: string,\n strap: string,\n) =>\n path(\n \"M 70 40 C 82 40 91.2 46 91.2 56 L 91.2 88 C 91.2 94 86 96 80 96 L 70 96 Z\",\n body,\n ) +\n rect(76, 50, 15, 8, 2, strap, { width: 2.4 }) +\n rect(76, 72, 15, 8, 2, strap, { width: 2.4 }) +\n rect(80, 51.5, 6, 5, 1, \"#f0f0f0\", { width: 1.8 }) +\n rect(80, 73.5, 6, 5, 1, \"#f0f0f0\", { width: 1.8 }) +\n path(PACK_15, body, { stroke: \"none\" }) +\n clipped(\n id,\n PACK_15,\n flat(\"M 60 30 L 90 30 L 90 106 L 60 106 Z\", tone(body, -0.1)),\n ) +\n path(PACK_15, \"none\") +\n path(\n \"M 12 26 C 10.8 20 10.8 16 11.2 13 C 12 6 18 2.8 30 2.8 L 58 2.8 C 70 2.8 77 8 77 16 L 77 28 C 77 32 74 34 70 34 L 16 34 C 13.5 34 12.3 30.5 12 26 Z\",\n flap,\n ) +\n path(\n \"M 19 14 C 19 11.5 21.5 10 25 10 L 52 10 C 56 10 58 12 58 15 L 58 30 L 19 30 Z\",\n window,\n { width: 2.6 },\n ) +\n path(\n \"M 9 96 C 5 96 2.8 100 2.8 106 C 2.8 114 4.5 118.5 10 118.5 L 70 118.5 C 76 118.5 78.5 116 78.5 110 C 78.5 102 76 96 70 96 Z\",\n pad,\n ) +\n circle(20, 99, 2.4, tone(pad, -0.4), { stroke: \"none\" }) +\n circle(56, 99, 2.4, tone(pad, -0.4), { stroke: \"none\" });\n\n/** An arrow standing out of a quiver: its shaft from (ax, ay) to the fletched end (bx, by). */\nconst quiverArrow = (ax: number, ay: number, bx: number, by: number) => {\n const deg = ((Math.atan2(by - ay, bx - ax) * 180) / Math.PI).toFixed(1);\n return (\n line(`M ${ax} ${ay} L ${bx} ${by}`, INK, 4.4) +\n line(`M ${ax} ${ay} L ${bx} ${by}`, \"#6a4a30\", 2) +\n group(\n `rotate(${deg} ${bx} ${by})`,\n path(\n `M ${bx - 11} ${by - 3} L ${bx} ${by - 3.5} L ${bx + 1.5} ${by} L ${bx} ${by + 3.5} L ${bx - 11} ${by + 3} Z`,\n \"#a0a0a0\",\n { width: 2 },\n ),\n )\n );\n};\n\nconst CAMO_7 =\n \"M 2.8 40 C 2.8 16 16 2.8 36 2.8 C 58 2.8 75 18 75.2 40 L 75.2 56 C 75 66 66 72 52 74 C 40 75.5 24 75 14 72 C 6 70 2.8 66 2.8 60 Z\";\nconst RADIO_9 =\n \"M 18 22 L 66 22 C 72 22 77.5 26 77.5 32 L 77.5 88 C 77.5 94 73 96 66 96 L 10 96 C 5 96 2.8 93 2.8 88 L 2.8 36 C 2.8 28 9 22 18 22 Z\";\n\nexport const gearArt: Record<string, ArtDef> = {\n \"weapon/3\": { svg: hatchet(\"#7b7a80\", \"#cfc2c4\", \"#6b4a36\") },\n \"weapon/5\": {\n svg:\n path(\"M 56 28 L 104 28 L 104 42 L 56 42 Z\", \"#474b55\") +\n path(\n \"M 103 27 C 103 22 108 22 112 25 L 133 25 C 139 25 140 30 140 36 C 140 42 139 46 133 46 L 112 46 C 108 49 103 49 103 44 Z\",\n \"#6a4a40\",\n ) +\n line(\n \"M 116 27 L 116 44 M 122 27 L 122 44 M 128 27 L 128 44 M 134 27 L 134 44\",\n \"#553730\",\n 2.4,\n ) +\n [-100, -54, -155, 168, 105, 48]\n .map((a) => {\n const r = (a * Math.PI) / 180;\n const at = (rr: number, d: number) =>\n `${(31 + Math.cos(r + d) * rr).toFixed(1)} ${(36 + Math.sin(r + d) * rr).toFixed(1)}`;\n return path(\n `M ${at(22, -0.28)} L ${at(29.5, 0)} L ${at(22, 0.28)} Z`,\n \"#8a929e\",\n { width: 2.6 },\n );\n })\n .join(\"\") +\n circle(31, 36, 25, \"#4a4e58\") +\n [\n [21.5, 21.5],\n [42.5, 22.5],\n [30, 34],\n [49, 34],\n [21.5, 48],\n [41.5, 48],\n ]\n .map(([x, y]) => ellipse(x!, y!, 3.8, 4.6, \"#8a929e\", { width: 2 }))\n .join(\"\"),\n },\n \"weapon/14\": {\n svg:\n path(\n \"M 6 3 L 55 3 C 62 3 70 8 78 15 L 78 30 C 70 37 62 42 55 42 L 6 42 C 3 42 2 38 2 34 L 2 11 C 2 6 3 3 6 3 Z\",\n \"#c0784a\",\n ) +\n line(\n \"M 8 11 L 22 11 M 38 11 L 50 11 M 5 20 C 30 20 52 20 72 24 M 18 27 L 30 27 M 58 30 L 70 26 M 8 35 C 26 36 40 36 50 34\",\n \"#a05a32\",\n 2.4,\n ) +\n path(\"M 78 17 L 108 17 L 108 28 L 78 28 Z\", \"#d8d4d8\") +\n line(\"M 89 18 L 89 27 M 99 18 L 99 27\", \"#a08aa0\", 2) +\n path(\n \"M 108 17 L 117 7 C 124 6 128 10 128 22 C 128 34 124 38 117 37 L 108 28 Z\",\n \"#c0784a\",\n ) +\n line(\"M 119 10 C 121 18 121 28 119 34\", \"#8a3a2a\", 2.4),\n },\n \"weapon/27\": {\n svg: sword(\"weapon_27\", {\n top: \"#c4cfc8\",\n low: \"#e0e7e3\",\n ridge: \"#86918b\",\n guard: \"#d4a855\",\n grip: \"#5b5555\",\n wrap: \"#3e3a3a\",\n }),\n },\n \"weapon/33\": {\n svg: sword(\"weapon_33\", {\n top: \"#f2b740\",\n low: \"#ffe09a\",\n ridge: \"#d8902a\",\n guard: \"#d4a855\",\n grip: \"#9a3030\",\n wrap: \"#c65050\",\n }),\n },\n \"weapon/28\": {\n svg: hooked(\"weapon_28\", {\n top: \"#4a1aa8\",\n low: \"#7a22c0\",\n ridge: \"#3a1488\",\n guard: \"#9a30e0\",\n grip: \"#2b2b30\",\n wrap: \"#2b2b30\",\n }),\n },\n \"weapon/36\": {\n svg: hooked(\"weapon_36\", {\n top: \"#d63f42\",\n low: \"#ee8040\",\n ridge: \"#a82a2e\",\n guard: \"#e89a2a\",\n grip: \"#7a3a22\",\n wrap: \"#7a3a22\",\n }),\n },\n\n // ── Left hand ──\n \"offhand/1\": {\n svg:\n ellipse(50.5, 41.5, 47, 38.5, \"#707078\") +\n ellipse(50.5, 41.5, 37.5, 29.5, \"#4e4e5a\") +\n clipped(\n \"offhand_1\",\n \"M 13 41.5 A 37.5 29.5 0 1 0 88 41.5 A 37.5 29.5 0 1 0 13 41.5 Z\",\n flat(\"M 36 0 L 63 0 L 63 90 L 36 90 Z\", \"#6e6e78\"),\n flat(\n \"M 29 0 L 34 0 L 34 90 L 29 90 Z M 64 0 L 69 0 L 69 90 L 64 90 Z\",\n \"#9a9aaa\",\n ),\n line(\n \"M 27 0 L 27 90 M 36 0 L 36 90 M 62 0 L 62 90 M 71 0 L 71 90\",\n INK,\n 2.6,\n ),\n ) +\n ellipse(50.5, 41.5, 37.5, 29.5, \"none\"),\n },\n \"offhand/4\": {\n svg:\n path(SHIELD_4, \"#ece8ec\", { stroke: \"none\" }) +\n clipped(\n \"offhand_4\",\n SHIELD_4,\n flat(\"M 62 0 L 100 0 L 100 90 L 62 90 Z\", \"#d5d2d6\"),\n flat(\n \"M 34 0 L 47 0 L 47 90 L 34 90 Z M 0 34 L 100 34 L 100 47 L 0 47 Z\",\n \"#e8505e\",\n ),\n ) +\n path(SHIELD_4, \"none\") +\n path(\"M 4 4 L 26 4 C 24 8 20 10 18 9 C 16 12 12 16 4 17 Z\", \"#e2b864\", {\n width: 2.8,\n }) +\n path(\n \"M 4 77 L 26 77 C 24 73 20 71 18 72 C 16 69 12 65 4 64 Z\",\n \"#e2b864\",\n { width: 2.8 },\n ) +\n path(\n \"M 83 30 C 87 32 92 36 93 40 C 92 44 87 49 83 51 C 85 46 85 44 82 40 C 85 37 85 34 83 30 Z\",\n \"#e2b864\",\n { width: 2.8 },\n ) +\n path(\n \"M 25 22 L 48 22 C 54 26 62 34 66 40 C 62 46 54 54 48 58 L 25 58 C 23 58 22 57 22 55 L 22 25 C 22 23 23 22 25 22 Z\",\n \"#e2b864\",\n ) +\n flat(\n \"M 30 40 C 30 34 36 30 42 30 C 48 30 54 36 55 40 C 54 44 48 50 42 50 C 36 50 30 46 30 40 Z\",\n \"#ffe27a\",\n ),\n },\n \"offhand/8\": {\n svg:\n circle(44.5, 44.5, 42, \"#a8743a\") +\n circle(44.5, 44.5, 35, \"#e0a848\") +\n clipped(\n \"offhand_8\",\n \"M 9.5 44.5 A 35 35 0 1 0 79.5 44.5 A 35 35 0 1 0 9.5 44.5 Z\",\n flat(\"M 60 0 L 90 0 L 90 90 L 60 90 L 52 44 Z\", \"#c08a40\"),\n path(\"M 58 12 L 70 22 L 52 44 L 70 66 L 58 76 L 37 44 Z\", \"#b0803c\", {\n width: 3,\n }),\n line(\n \"M 30 20 L 31 24 M 42 19 L 42 23 M 14 35 L 16 36 M 34 40 L 36 41 M 22 50 L 22 54 M 44 62 L 46 63 M 26 70 L 28 71 M 68 38 L 69 40 M 64 52 L 66 54\",\n \"#c28838\",\n 2.4,\n ),\n ) +\n circle(44.5, 44.5, 35, \"none\"),\n },\n\n \"offhand/2\": {\n svg:\n ellipse(39.5, 43.5, 37, 41, \"#7a8a88\") +\n path(\n `${oval(39.5, 43.5, 37, 41)} ${oval(39.5, 47.5, 34, 37)}`,\n \"#5e6e6c\",\n { stroke: \"none\", holes: true },\n ) +\n clipped(\n \"offhand_2\",\n oval(39.5, 43.5, 37, 41),\n line(\"M 22 3 L 23 8 M 39.5 2 L 39.5 7 M 57 3 L 56 8\", \"#3e4a48\", 2),\n ) +\n line(\n \"M 7 38 C 12 18 24 10.5 39.5 10.5 C 55 10.5 67 18 72 38\",\n \"#3e4a48\",\n 2.2,\n ) +\n ellipse(39.5, 43.5, 37, 41, \"none\") +\n path(\n \"M 16 41 L 68 41 L 68 52 L 58 52 C 52 56 32 56 26 52 L 16 52 Z\",\n \"#5a6a68\",\n ) +\n line(\"M 24 46.5 L 60 46.5\", \"#3e4a48\", 2) +\n circle(21, 46.5, 2.4, \"#9aaaa8\", { width: 2 }) +\n circle(63, 46.5, 2.4, \"#9aaaa8\", { width: 2 }),\n },\n \"offhand/3\": {\n svg:\n circle(43.5, 43.5, 41, \"#d23a40\", { stroke: \"none\" }) +\n clipped(\n \"offhand_3\",\n oval(43.5, 43.5, 41),\n flat(\n \"M 43.5 43.5 L 0 26 L 0 66 Z M 43.5 43.5 L 87 66 L 87 87 L 66 87 Z\",\n \"#f06a70\",\n ),\n flat(oval(43.5, 43.5, 31), \"#ebe6ea\"),\n flat(\n \"M 43.5 43.5 L 0 26 L 0 66 Z M 43.5 43.5 L 87 66 L 87 87 L 66 87 Z\",\n \"#ffffff\",\n 0.75,\n ),\n flat(oval(43.5, 43.5, 20), \"#3a78e0\"),\n clipped(\n \"offhand_3c\",\n oval(43.5, 43.5, 20),\n flat(\n \"M 43.5 43.5 L 43.5 0 L 87 0 L 87 43.5 Z M 43.5 43.5 L 43.5 87 L 0 87 L 0 43.5 Z\",\n \"#66c4f8\",\n ),\n ),\n ) +\n circle(43.5, 43.5, 41, \"none\"),\n },\n \"offhand/5\": {\n svg:\n path(SHIELD_5, \"#565c6c\", { stroke: \"none\" }) +\n clipped(\n \"offhand_5\",\n SHIELD_5,\n flat(\"M 0 0 L 110 0 L 110 28 L 0 28 Z\", \"#646b7a\"),\n flat(\"M 0 0 L 17 0 L 17 90 L 0 90 Z\", \"#3c424e\"),\n line(\"M 17 0 L 17 90\", INK, 2.4),\n line(\"M 17 28 L 110 28 M 17 66 L 110 66\", \"#4a5060\", 2),\n ) +\n path(SHIELD_5, \"none\") +\n rect(30, 20, 23, 55, 8, \"#a8b0c0\") +\n rect(35.5, 26, 12, 43, 6, \"#3a4050\", { width: 2.6 }),\n },\n \"offhand/6\": {\n svg:\n circle(36.5, 36.5, 34.5, \"#b4b4b4\", { stroke: \"none\" }) +\n clipped(\n \"offhand_6\",\n oval(36.5, 36.5, 34.5),\n flat(\"M 73 18 L 73 73 L 18 73 Z\", \"#8e8e8e\"),\n flat(\"M 0 0 L 40 0 L 0 40 Z\", \"#cacaca\"),\n ) +\n circle(36.5, 36.5, 34.5, \"none\") +\n circle(36.5, 36.5, 25.5, \"#b07838\", { stroke: \"none\" }) +\n clipped(\n \"offhand_6w\",\n oval(36.5, 36.5, 25.5),\n flat(\"M 0 28 L 73 28 L 73 33 L 0 33 Z\", \"#c89048\"),\n line(\"M 0 22 L 73 22 M 0 36 L 73 36 M 0 50 L 73 50\", \"#8a5a28\", 2),\n ) +\n circle(36.5, 36.5, 25.5, \"none\") +\n circle(36.5, 36.5, 10, \"#b8b8b8\") +\n flat(\"M 31 33 C 32 30 35 29 38 29.5 C 35 31 33 33 32.5 36 Z\", \"#e0e0e0\"),\n },\n \"offhand/7\": {\n svg:\n path(SHIELD_7, \"#6e6e6e\") +\n flat(\n \"M 27 11 L 56 11 C 66 13 74 22 80 35 C 74 47 66 56 54 59 L 29 59 C 27 50 21 42 13 35 C 21 28 26 20 27 11 Z\",\n \"#606060\",\n ) +\n [\n [30, 8],\n [44, 8],\n [59, 9.5],\n [72, 17],\n [82, 29],\n [82, 41],\n [72, 53],\n [59, 61],\n [44, 62],\n [30, 62],\n [16, 45],\n [16, 25],\n ]\n .map(([x, y]) => circle(x!, y!, 2.2, \"#555555\", { stroke: \"none\" }))\n .join(\"\") +\n path(\n \"M 36 28 C 36 22 44 20 48 24 C 52 20 60 22 60 28 C 64 30 64 36 60 38 C 62 44 56 48 52 44 C 50 50 42 50 42 44 C 36 46 32 40 36 36 C 32 34 32 30 36 28 Z\",\n \"#585858\",\n { stroke: \"#474747\", width: 2.4 },\n ) +\n line(\n \"M 42 32 C 45 30 48 32 48 35 M 50 33 C 52 31 55 32 55 35 M 44 40 C 47 42 51 42 54 40\",\n \"#474747\",\n 2,\n ),\n },\n \"offhand/9\": {\n svg:\n path(SHIELD_9, \"#c04420\", { stroke: \"none\" }) +\n clipped(\n \"offhand_9\",\n SHIELD_9,\n flat(\"M 62 0 L 100 0 L 100 75 L 62 75 Z\", \"#a83a1c\"),\n [\n \"M 38 26 C 30 14 20 8 12 8 C 16 16 26 24 38 30 Z\",\n \"M 65 26 C 73 14 83 8 90 8 C 86 16 77 24 65 30 Z\",\n \"M 38 42 C 30 54 20 60 12 60 C 16 52 26 44 38 38 Z\",\n \"M 65 42 C 73 54 83 60 90 60 C 86 52 77 44 65 38 Z\",\n \"M 37 34 C 28 31 18 31 10 34 C 18 37 28 37 37 34 Z\",\n \"M 66 34 C 75 31 84 31 91 34 C 84 37 75 37 66 34 Z\",\n ]\n .map((d) => path(d, \"#f0c060\", { stroke: \"#8a3010\", width: 2 }))\n .join(\"\"),\n ellipse(51.5, 9.5, 4, 5, \"#f0c060\", { stroke: \"#8a3010\", width: 2 }),\n ellipse(51.5, 58.5, 4, 5, \"#f0c060\", { stroke: \"#8a3010\", width: 2 }),\n ) +\n path(SHIELD_9, \"none\") +\n rect(37, 21, 29, 26, 2, \"#d8a040\") +\n circle(51.5, 34, 8, \"#f0c060\", { stroke: \"#8a3010\", width: 2.2 }),\n },\n \"offhand/10\": {\n svg:\n path(SHIELD_10, \"#3a3a3c\", { stroke: \"none\" }) +\n clipped(\n \"offhand_10\",\n SHIELD_10,\n flat(\"M 0 0 L 40 0 L 40 42 L 0 42 Z\", \"#c84a2a\"),\n flat(\"M 40 42 L 90 42 L 90 90 L 40 90 Z\", \"#b04030\"),\n flat(\n \"M 35 0 L 45 0 L 45 90 L 35 90 Z M 0 38 L 90 38 L 90 47 L 0 47 Z\",\n \"#d8b030\",\n ),\n flat(\n \"M 40 47 L 45 47 L 45 90 L 40 90 Z M 45 42.5 L 90 42.5 L 90 47 L 45 47 Z\",\n \"#b89020\",\n ),\n ) +\n path(SHIELD_10, \"none\") +\n path(\n \"M 17 14 L 23 14 L 22 20.5 L 28.5 19.5 L 28.5 27 L 22 26 L 23 32.5 L 17 32.5 L 18 26 L 11.5 27 L 11.5 19.5 L 18 20.5 Z\",\n \"#e8c040\",\n {\n stroke: \"#8a2a1a\",\n width: 1.8,\n },\n ),\n },\n \"offhand/11_low\": {\n svg:\n circle(28, 28, 25.5, \"#e8643c\", { stroke: \"none\" }) +\n clipped(\n \"offhand_11\",\n oval(28, 28, 25.5),\n flat(oval(34, 36, 24), \"#d4542e\"),\n line(\n \"M 2 26 C 18 31 38 31 54 24 M 26 2 C 35 16 35 40 24 54 M 9 9 C 20 18 20 38 9 47 M 46 7 C 37 18 38 38 49 46\",\n \"#a8442a\",\n 2.2,\n ),\n ) +\n circle(28, 28, 25.5, \"none\"),\n },\n \"offhand/12_low\": {\n svg:\n circle(28, 28, 25.5, \"#f2f2f2\", { stroke: \"none\" }) +\n clipped(\n \"offhand_12\",\n oval(28, 28, 25.5),\n flat(oval(36, 36, 24), \"#d4d4d8\"),\n line(\n \"M 26 27 L 10 18 M 26 27 L 44 15 M 26 27 L 26 48 M 26 27 L 44 38\",\n \"#9a9aa0\",\n 2,\n ),\n path(pentagon(26, 27, 7.5), \"#2b2b2e\", { width: 2 }),\n path(pentagon(8, 21, 7, 36), \"#2b2b2e\", { width: 2 }),\n path(pentagon(47, 12, 7, 20), \"#2b2b2e\", { width: 2 }),\n path(pentagon(26, 52, 7, 36), \"#2b2b2e\", { width: 2 }),\n path(pentagon(47, 40, 6.5, -10), \"#2b2b2e\", { width: 2 }),\n ) +\n circle(28, 28, 25.5, \"none\"),\n },\n \"offhand/13_low\": {\n svg:\n ellipse(32.5, 32, 30, 29.5, \"#2e7a36\", { stroke: \"none\" }) +\n clipped(\n \"offhand_13\",\n oval(32.5, 32, 30, 29.5),\n line(\n \"M 2 12 C 8 8 12 16 18 12 C 24 8 28 16 34 12 C 40 8 44 16 50 12 C 56 8 60 14 64 12 \" +\n \"M 2 25 C 8 21 12 29 18 25 C 24 21 28 29 34 25 C 40 21 44 29 50 25 C 56 21 60 27 64 25 \" +\n \"M 2 38 C 8 34 12 42 18 38 C 24 34 28 42 34 38 C 40 34 44 42 50 38 C 56 34 60 40 64 38 \" +\n \"M 2 51 C 8 47 12 55 18 51 C 24 47 28 55 34 51 C 40 47 44 55 50 51 C 56 47 60 53 64 51\",\n \"#1b5626\",\n 4.2,\n ),\n flat(\"M 12 10 C 18 6 24 5 30 5 C 24 8 18 12 14 16 Z\", \"#5aa860\", 0.8),\n ) +\n ellipse(32.5, 32, 30, 29.5, \"none\"),\n },\n \"offhand/14_low\": {\n svg: group(\n \"rotate(-26 30.5 23)\",\n ellipse(30.5, 23, 29, 17, \"#b8552e\", { stroke: \"none\" }),\n clipped(\n \"offhand_14\",\n oval(30.5, 23, 29, 17),\n flat(\"M 47 0 C 51 10 51 36 47 46 L 62 46 L 62 0 Z\", \"#f4c8b8\"),\n line(\"M 2 27 C 20 30 40 30 60 25\", \"#a8a29e\", 2.6),\n ),\n ellipse(30.5, 23, 29, 17, \"none\"),\n line(\n \"M 27 15 L 27 23 M 33 14 L 33 23 M 39 14 L 39 23 M 45 15 L 45 23\",\n \"#ffffff\",\n 3,\n ),\n ),\n },\n \"offhand/15_low\": {\n svg:\n path(BOTTLE_15, \"#2b2b2b\", { stroke: \"none\" }) +\n clipped(\n \"offhand_15\",\n BOTTLE_15,\n flat(\"M 34 0 L 52 0 L 52 40 L 34 40 Z\", \"#f05040\"),\n flat(\"M 14 6 L 76 6 L 76 9 L 14 9 Z\", \"#4a4a4a\"),\n ) +\n path(BOTTLE_15, \"none\") +\n rect(3.5, 9, 12, 18, 4, \"#d8dde4\") +\n flat(\"M 6 12 L 9 12 L 9 24 L 6 24 Z\", \"#ffffff\"),\n },\n \"offhand/16_low\": {\n svg:\n path(\n \"M 21 7 C 17 4 12 3 7 4 C 4.5 5 4 7 5 8.5 C 3 10 3 13 4.5 14.5 C 3 17 3 20 4.5 22 C 5 24 9 25 13 24 C 16 23 19 21 21 20 Z\",\n \"#4ab048\",\n ) +\n line(\n \"M 20 11 C 14 9 9 9 6 10 M 20 17 C 14 17 9 18 6 19\",\n \"#2a7a30\",\n 2.2,\n ) +\n path(CARROT_16, \"#f07a34\", { stroke: \"none\" }) +\n clipped(\n \"offhand_16\",\n CARROT_16,\n flat(\"M 0 19 C 30 22 50 20 66 14 L 66 30 L 0 30 Z\", \"#d8622a\"),\n ) +\n path(CARROT_16, \"none\") +\n line(\n \"M 32 7 L 34 11 M 44 18 L 47 21 M 50 9 L 52 12 M 27 18 L 29 21\",\n \"#b8501e\",\n 2,\n ),\n },\n \"offhand/17_low\": {\n svg:\n path(DUCK_17, \"#f8c838\", { stroke: \"none\" }) +\n clipped(\n \"offhand_17\",\n DUCK_17,\n flat(\"M 0 34 C 20 40 40 38 56 26 L 56 44 L 0 44 Z\", \"#e8a828\"),\n ) +\n path(DUCK_17, \"none\") +\n line(\n \"M 7.5 18.5 C 10.5 19.5 12.5 21.5 13 24.5 M 3 24.5 L 12.5 25\",\n INK,\n 2.4,\n ) +\n line(\"M 24 28 C 30 34 40 32 44 24\", \"#c88a18\", 2.4) +\n circle(21, 12, 4.8, \"#f0a030\", { width: 2.4 }) +\n circle(21, 12, 2, \"#f8e070\", { stroke: \"none\" }),\n },\n \"offhand/18_low\": {\n svg:\n circle(18.5, 18.5, 16.5, \"#f4ece4\") +\n dashed(\"M 9 5 C 14 13 14 24 9 32\", \"#d03030\", 2.4, \"3 2.6\") +\n dashed(\"M 28 5 C 23 13 23 24 28 32\", \"#d03030\", 2.4, \"3 2.6\"),\n },\n \"offhand/19_low\": {\n svg:\n path(ROCK_19, \"#a8acb0\", { stroke: \"none\" }) +\n clipped(\n \"offhand_19\",\n ROCK_19,\n flat(\"M 6 14 L 16 6 L 31 5 L 38 12 L 27 17 L 13 19 Z\", \"#c4c8cc\"),\n flat(\"M 40 24 L 46 20 L 46 43 L 24 43 L 30 36 Z\", \"#8c9094\"),\n flat(\n \"M 18 26 L 22 24 L 24 28 L 20 30 Z M 30 22 L 33 21 L 34 24 Z M 12 32 L 15 31 L 15 34 Z\",\n \"#80848a\",\n ),\n ) +\n path(ROCK_19, \"none\"),\n },\n \"offhand/20_low\": {\n svg:\n line(\"M 19 10 C 18 6 16 4 13 2.5\", INK, 4.4) +\n path(APPLE_20, \"#e84a3a\", { stroke: \"none\" }) +\n clipped(\n \"offhand_20\",\n APPLE_20,\n flat(\"M 0 30 C 14 40 30 40 44 28 L 44 44 L 0 44 Z\", \"#c83a2e\"),\n ) +\n path(APPLE_20, \"none\") +\n path(\n \"M 30 21 C 34 19 37 23 35 26 C 38 28 37 33 33 32.5 C 30 32.5 28 30 29 27 C 27 25 28 22 30 21 Z\",\n \"#f8d880\",\n { width: 2 },\n ) +\n flat(\"M 9 15 C 11 12 14 11 16 12 C 13 14 11 16 10 19 Z\", \"#f48070\"),\n },\n \"offhand/21_low\": {\n svg:\n path(BANANA_21, \"#f2d34c\", { stroke: \"none\" }) +\n clipped(\n \"offhand_21\",\n BANANA_21,\n flat(\n \"M 0 0 L 56 0 L 56 44 L 47 44 C 47 36 46 28 44 22 C 42 16 38 12 30 10.5 C 22 9.5 16 9 10 9 L 0 9 Z\",\n \"#ebaf23\",\n ),\n flat(oval(7.5, 11.5, 3.5, 2.6), \"#ffeea2\"),\n line(\"M 11 9 C 12 11 12 13 11 15\", INK, 2),\n ) +\n path(BANANA_21, \"none\"),\n },\n \"offhand/22_low\": {\n svg:\n path(\n \"M 16 19 L 16 11 C 16 5.5 22 3 30.5 3 C 39 3 45.5 6 45.5 12 L 45.5 19 L 36.5 19 L 36.5 15 C 36.5 12.5 34 11 30.5 11 C 27 11 24.5 12.5 24.5 15 L 24.5 19 Z\",\n \"#f0e7d2\",\n ) +\n path(\n \"M 29 43 L 40 43 L 40 47 C 40 51 36 54.5 30 56 C 26 57 24 58 23 59.5 C 22 61 21.5 62 20 62.5 C 17 63 15 61.5 15 59 L 15 56 C 15 53 18 51 22 49.5 C 26 48 29 47 29 43 Z\",\n \"#f0e7d2\",\n ) +\n ellipse(18.5, 58.5, 2.4, 3.4, \"#918568\", { stroke: \"none\" }) +\n path(\n \"M 23 21.5 C 17 21.5 14 22.5 13 25.5 L 6 25.8 C 4 26 3.6 27.5 3.6 28.5 C 3.6 30 4 31 6 31.2 L 13 31.5 C 13.5 35.5 16 38 23 38 Z\",\n \"#f0e7d2\",\n ) +\n flat(\"M 13 22 L 23 22 L 23 27 L 13 27 Z\", \"#decea8\") +\n path(CAN_22, \"#f0e7d2\", { stroke: \"none\" }) +\n clipped(\n \"offhand_22\",\n CAN_22,\n flat(\"M 0 20 L 56 20 L 56 27 L 0 27 Z\", \"#decea8\"),\n ) +\n path(CAN_22, \"none\"),\n },\n \"offhand/23_low\": {\n svg:\n path(\"M 11 49 L 21 49 L 20 65 L 16 70 L 12 65 Z\", \"#80888f\") +\n line(\"M 12 54 L 20 52 M 12 59 L 20 57 M 13 64 L 19 62\", \"#4a5058\", 2) +\n path(\n \"M 28 12 L 42 9 C 46 9 47.5 12 46.5 16 L 44.5 22 C 43.5 24 40.5 25 37.5 24 L 28 22 Z\",\n \"#3a7ae8\",\n ) +\n path(DRILL_23, \"#3a7ae8\", { stroke: \"none\" }) +\n clipped(\n \"offhand_23\",\n DRILL_23,\n flat(\"M 0 10 L 34 10 L 34 17 L 0 17 Z\", \"#a0a8b0\"),\n line(\"M 0 10 L 34 10 M 0 17 L 34 17\", INK, 2.2),\n flat(\"M 22 17 L 34 17 L 34 52 L 22 52 Z\", \"#2a62c8\"),\n ) +\n path(DRILL_23, \"none\") +\n line(\"M 11 4.5 L 11 7 M 16 4 L 16 7 M 21 4.5 L 21 7\", \"#1e4ea8\", 2),\n },\n \"offhand/24_low\": {\n svg:\n path(\n \"M 3.5 30 L 3.5 50 L 8 47 L 13 51 L 18 47 L 23 51 L 28 47 L 34 50 L 36 40 Z\",\n \"#b8b8b2\",\n ) +\n path(ROLL_24, \"#d8d8d4\") +\n ellipse(12, 21.5, 8, 17.5, \"#ececea\") +\n ellipse(12, 21.5, 4, 8.5, \"#a07850\", { width: 2.6 }) +\n [30, 35, 40, 45]\n .map((x) => circle(x, 22, 1.1, \"#a8a8a2\", { stroke: \"none\" }))\n .join(\"\"),\n },\n \"offhand/25_low\": {\n svg:\n path(\n \"M 22 3 L 29 3 C 33 4 36 6 38 7 L 48 8.5 C 53 10 56 12 56.5 14 L 56.5 18 L 22 18 Z\",\n \"#3a9a40\",\n ) +\n line(\n \"M 33 4.5 L 32 16 M 42 7.5 L 41 16 M 50 9.5 L 49.5 16\",\n \"#a8e0a0\",\n 3.4,\n ) +\n path(\n \"M 16 32 L 61 32 L 61.5 42 C 61 46 58 47.5 53 47.5 C 44 46 32 42.5 22 40 C 17.5 39 15.8 37 15.8 34 Z\",\n \"#3a7ae0\",\n ) +\n line(\n \"M 34 34 C 35 37 36 40 37 43 M 46 34 C 47 38 48 42 50 46\",\n \"#a8ccf8\",\n 3.4,\n ) +\n path(\n \"M 44 15.8 L 64 15.8 C 70 16 73.5 19.5 73.5 24 C 73.5 28 71 31 66 31 L 44 31 Z\",\n \"#f0c020\",\n ) +\n line(\"M 58 16.5 L 58 30.5 M 65 16.5 L 65.5 30.5\", \"#ffffff\", 3) +\n path(\n \"M 8 14.5 L 44 14.5 C 48 15 50 19 50 23 C 50 28 47 31.5 42 31.5 L 8 31.5 C 4.5 31.5 2.8 28 2.8 23 C 2.8 18 4.5 14.5 8 14.5 Z\",\n \"#4a4a4e\",\n ) +\n flat(\"M 22 20 L 46 20 L 46 27 L 22 27 Z\", \"#6a6a6e\") +\n ellipse(11.5, 23, 4.5, 5.5, \"#d0e8f8\", { width: 2.4 }) +\n circle(10.5, 23.5, 1.8, INK, { stroke: \"none\" }) +\n path(\"M 15 31.5 L 31 31.5 L 32 35.5 L 16 37 Z\", \"#d04030\", {\n width: 2.6,\n }),\n },\n \"offhand/26_low\": {\n svg:\n path(MAT_26, \"#3a78e0\", { stroke: \"none\" }) +\n clipped(\n \"offhand_26\",\n MAT_26,\n flat(\"M 0 34 L 94 12 L 94 50 L 0 50 Z\", \"#2c64c4\"),\n line(\"M 10 25 L 84 8\", \"#7aa8f0\", 3.4),\n ) +\n path(MAT_26, \"none\") +\n line(\"M 9 18.5 C 14 18 17 25 17 32 C 17 39 14 46 10 46\", INK, 2.4) +\n ellipse(8.5, 32, 3, 6, \"#2c64c4\", { width: 2.2 }),\n },\n \"offhand/27_low\": {\n svg:\n [\n \"M 23.5 3 C 24 8 24.5 13 25.5 16 C 27 19.5 31 21.5 38 22.5 L 38 26 C 30 25.5 22 22 17.5 18 C 14.5 15 14 11.5 16 8 C 18 5.5 21 3.5 23.5 3 Z\",\n \"M 5 11 C 9 12 12.5 14 14.5 17.5 C 18 21.5 26 24.5 37 27 L 37 29 L 16 28 C 11 27 7.5 24.5 5.5 21 C 4 18 4 14 5 11 Z\",\n \"M 3.5 43.5 C 4 40 8 36 13 33.5 C 18 31.5 26 31.5 37 31.5 L 37 34.5 C 28 35 21 36 16 38.5 C 11 41 7 43 3.5 43.5 Z\",\n \"M 21 55 C 18.5 52 17.5 48 18 44.5 C 19 41 23 39.5 30 38.5 L 36 38.5 L 36 41 C 31 42 26.5 44 25 47 C 23.5 50 23 53 21 55 Z\",\n ]\n .map((d) => path(d, \"#68be5a\"))\n .join(\"\") +\n line(\n \"M 22 6 C 20 12 24 18 34 23.5 M 7 14 C 10 20 18 25 34 28 M 6 41.5 C 12 36 22 33 34 33 M 21 52 C 20 46 25 41 34 40\",\n \"#4a9a40\",\n 2,\n ) +\n path(POT_27, \"#b8643a\", { stroke: \"none\" }) +\n clipped(\n \"offhand_27\",\n POT_27,\n flat(\"M 40 40 L 80 40 L 80 60 L 40 60 Z\", \"#9a4e2c\"),\n ) +\n path(POT_27, \"none\") +\n rect(31.8, 10.8, 17.4, 38.6, 7, \"#c87044\") +\n clipped(\n \"offhand_27r\",\n \"M 31.8 10.8 L 49.2 10.8 L 49.2 49.4 L 31.8 49.4 Z\",\n flat(\"M 30 40 L 50 40 L 50 50 L 30 50 Z\", \"#a85a34\", 0.8),\n ),\n },\n \"offhand/28_low\": {\n svg:\n path(PHONE_28, \"#f4ead8\", { stroke: \"none\" }) +\n clipped(\n \"offhand_28\",\n PHONE_28,\n flat(\n \"M 0 22 C 10 30 20 28 26 18 C 34 16 44 18 50 26 L 64 36 L 0 36 Z\",\n \"#d8cbb8\",\n ),\n ) +\n path(PHONE_28, \"none\"),\n },\n \"offhand/29_low\": {\n svg:\n rect(47, 15, 28, 6.5, 3, \"#e8c090\") +\n ellipse(25.5, 18.5, 23, 14, \"#c87040\") +\n flat(oval(25.5, 20, 17.5, 9.5), \"#8a3a20\") +\n path(\n \"M 10 12 C 12 6 16 6 18 10 C 20 4 26 4 27 10 C 30 6 36 6 37 12 C 38 16 34 18 30 15 C 26 18 20 18 17 15 C 14 18 9 17 10 12 Z\",\n \"#e04030\",\n {\n width: 2.4,\n },\n ),\n },\n \"offhand/30_low\": {\n svg:\n path(PIZZA_30, \"#f8c868\") +\n circle(22, 22, 4.6, \"#d84a30\", { width: 2.2 }) +\n circle(31, 27, 3.8, \"#d84a30\", { width: 2.2 }) +\n circle(14, 35, 3.8, \"#d84a30\", { width: 2.2 }) +\n path(\n \"M 10 4.5 C 12 3 14 2.8 16 2.8 C 24 3 31 5.5 36 9 C 40 12 42.5 17 42.5 23 C 42.5 25 41.5 26.5 40 27 C 38 20 34 15 28 12 C 23 10 17 9 12 9.5 C 9 9.5 8.5 6 10 4.5 Z\",\n \"#f0b060\",\n ),\n },\n \"offhand/31_low\": {\n svg:\n path(EGG_31, \"#f8dcb0\", { stroke: \"none\" }) +\n clipped(\n \"offhand_31\",\n EGG_31,\n [\n [16, 14, 5],\n [30, 8, 4],\n [42, 16, 4.5],\n [27, 24, 6],\n [12, 30, 4],\n [39, 32, 5],\n [24, 38, 3.5],\n ]\n .map(([x, y, r]) => circle(x!, y!, r!, \"#f0a040\", { stroke: \"none\" }))\n .join(\"\"),\n ) +\n path(EGG_31, \"none\"),\n },\n \"offhand/32_low\": {\n svg:\n path(\n \"M 30 9 L 50 8 C 54 8 55 10 55 13 C 55 16 54 18 50 18 L 30 18 Z\",\n \"#c83838\",\n ) +\n path(HORN_32, \"#c83838\", { stroke: \"none\" }) +\n clipped(\n \"offhand_32\",\n HORN_32,\n flat(\"M 0 13 L 60 13 L 60 20 L 0 20 Z\", \"#f4ecec\"),\n line(\"M 0 13 L 60 13 M 0 20 L 60 20\", INK, 2),\n flat(\"M 26 0 L 40 0 L 40 48 L 26 48 Z\", \"#a82c2c\"),\n ) +\n path(HORN_32, \"none\") +\n ellipse(21.5, 46, 18.5, 5.5, \"#f0a0a0\") +\n circle(21.5, 46, 3.4, \"#f8d040\", { width: 2 }),\n },\n \"offhand/33_low\": {\n svg:\n rect(9, 3.5, 53, 22, 6, \"#5a5658\") +\n circle(7, 14.5, 4, \"#70f0b0\", { width: 2.4 }) +\n circle(21, 14.5, 3.4, \"#8a8688\", { width: 2 }) +\n circle(32, 14.5, 5, \"#b0acae\", { width: 2 }) +\n rect(41, 8, 14, 13, 2, \"#a09ca0\", { width: 2 }) +\n line(\"M 48 8 L 48 21 M 41 14.5 L 55 14.5\", \"#6a6668\", 1.6),\n },\n \"offhand/34_low\": {\n svg:\n path(CHICKEN_34, \"#f8d040\", { stroke: \"none\" }) +\n clipped(\n \"offhand_34\",\n CHICKEN_34,\n flat(\"M 0 26 C 30 30 60 26 92 20 L 92 34 L 0 34 Z\", \"#e8b020\"),\n ) +\n path(CHICKEN_34, \"none\") +\n path(\n \"M 4 15 C 2 9 6 5 10 7 C 12 3 18 3 18.5 7 C 22 5 26 9 22.5 13 Z\",\n \"#d03028\",\n { width: 2.6 },\n ) +\n circle(14, 20, 10, \"#ececec\") +\n ellipse(19, 25, 7, 4, \"#f08020\", { width: 2.4 }) +\n line(\"M 8 15.5 L 12 19.5 M 12 15.5 L 8 19.5\", INK, 2),\n },\n \"offhand/35_low\": {\n svg:\n path(\n \"M 14 3 C 24 3.5 36 5 44 6.5 C 48 7.5 50 10 50 14 L 50.5 26 C 50.5 31 48.5 36 46 40 C 44 43 41.5 44.5 38 44 C 30 43.5 20 42 12 40.5 C 6 39.5 3 36 2.8 30 L 3.5 12 C 4 6 8 3 14 3 Z\",\n \"#48b040\",\n ) +\n path(BREAD_35, \"#c89050\", { width: 2.8 }) +\n flat(\n \"M 15.5 8 L 42 11.2 C 44.5 11.6 45.4 13 45 15 L 43 34 C 42.6 36.5 41.2 37.6 39 37.3 L 12.5 34 C 10.4 33.7 9.6 32.6 9.8 30.5 L 11.8 11 C 12.2 9 13.5 7.8 15.5 8 Z\",\n \"#ecd090\",\n ),\n },\n \"offhand/36_low\": {\n svg:\n path(\n \"M 32 13 C 44 13.5 56 13 66 12.5 C 72 12 76 12 78 13.5 C 80 15 80 19.5 78 21 C 74 23 66 23 58 22.5 C 50 22 42 22 32 22 Z\",\n \"#5ab040\",\n ) +\n rect(2.8, 2.8, 29.5, 25, 6, \"#5ab040\") +\n [9, 16, 23]\n .flatMap((x) =>\n [9, 15.5, 22].map((y) =>\n circle(x, y, 1.8, \"#2e7a24\", { stroke: \"none\" }),\n ),\n )\n .join(\"\"),\n },\n \"offhand/37_low\": {\n svg:\n path(HEART_37, \"#e03828\", { stroke: \"none\" }) +\n clipped(\n \"offhand_37\",\n HEART_37,\n flat(\"M 0 30 C 20 40 40 38 52 26 L 52 42 L 0 42 Z\", \"#b82818\"),\n line(\n \"M 22 16 C 28 20 30 26 28 32 M 34 14 C 38 18 42 22 44 28\",\n \"#a02818\",\n 2.2,\n ),\n ) +\n path(HEART_37, \"none\") +\n line(\"M 16 4 L 16.5 9 M 21 4 L 21 8.5\", INK, 2.2) +\n flat(\"M 28 11 C 34 10 40 12 43 16 C 38 15 33 14 29 15 Z\", \"#f87060\"),\n },\n \"offhand/38_low\": {\n svg:\n path(BONE_38, \"#f0e8d8\", { stroke: \"none\" }) +\n clipped(\n \"offhand_38\",\n BONE_38,\n flat(\n \"M 30 16 C 34 14 40 15 42 18 C 38 21 32 21 30 16 Z M 52 22 C 55 21 58 22 59 25 C 56 27 53 26 52 22 Z M 6 8 C 8 6 11 6 12 8 C 10 10 8 10 6 8 Z\",\n \"#d8b888\",\n ),\n ) +\n path(BONE_38, \"none\"),\n },\n \"offhand/39_low\": {\n svg: potion(\"offhand_39\", \"#e04838\", \"#f8c4bc\"),\n },\n \"offhand/40_low\": {\n svg: potion(\"offhand_40\", \"#3a80f0\", \"#c4e0f8\"),\n },\n\n \"back/1\": { svg: pack1(\"back_1\", \"#4d7244\", \"#3f5f38\") },\n \"back/14\": {\n svg:\n path(\n \"M 62 28 C 68 27 72 32 72 40 L 72 50 C 72 56 68 59 62 59 Z\",\n \"#d63040\",\n ) +\n path(PACK_14, \"#3470c4\", { stroke: \"none\" }) +\n clipped(\n \"back_14\",\n PACK_14,\n flat(\"M 0 61 L 70 62 L 70 80 L 0 80 Z\", \"#3a78cc\"),\n line(\"M 0 61 L 70 62\", INK, 3),\n ) +\n path(PACK_14, \"none\") +\n line(\"M 38 5 C 48 8 55 14 56 22 L 57 42\", \"#1f1a32\", 6) +\n line(\"M 38 5 C 48 8 55 14 56 22 L 57 42\", \"#f2e04c\", 2),\n },\n \"back/17\": {\n svg: backSword(\"#8eacd0\", \"#6a8ec0\", \"#8a2333\", \"#c25060\", \"#e8a33a\"),\n },\n \"weapon/32\": { svg: hatchet(\"#a0b0c0\", \"#c8d4dc\", \"#7a6a90\") },\n \"weapon/2\": { svg: pickaxe(\"#4a4a5c\", \"#6a6a80\", \"#6a3a28\") },\n \"weapon/31\": { svg: pickaxe(\"#a8b8c8\", \"#d0dce8\", \"#7a6a90\") },\n \"weapon/12\": { svg: spade(\"#6a90b0\", \"#7a4a30\", \"#6a90b0\") },\n \"weapon/37\": { svg: spade(\"#c8784a\", \"#7a4a30\", \"#f0b030\") },\n \"weapon/6\": {\n svg: stickClub(\n \"club_2\",\n \"#e0d0b8\",\n line(\n \"M 20 6 C 16 16 22 26 18 38 M 44 6 C 40 18 46 28 42 38 M 66 6 C 64 18 68 28 64 38\",\n \"#b8a080\",\n 2.4,\n ),\n ),\n },\n \"weapon/38\": {\n svg: stickClub(\n \"weapon_38\",\n \"#e8b850\",\n flat(\n \"M 22 0 C 18 14 26 28 22 44 L 46 44 C 50 28 42 14 46 0 Z\",\n \"#9a5ad0\",\n ) +\n flat(\n \"M 46 0 C 42 14 50 28 46 44 L 68 44 C 70 28 66 14 68 0 Z\",\n \"#50c878\",\n ) +\n flat(\"M 68 0 C 66 14 70 28 68 44 L 90 44 L 90 0 Z\", \"#e86838\"),\n ),\n },\n \"weapon/15\": { svg: spikedBat(\"#c8884a\", \"#7a7e8e\", \"#d8c8a8\") },\n \"weapon/39\": { svg: spikedBat(\"#5a5a6a\", \"#9a9eae\", \"\") },\n \"weapon/16\": {\n svg: fish(\"weapon_16\", \"#2a3a90\", \"#c8c8e8\", \"#8890b0\"),\n },\n \"weapon/34\": {\n svg: fish(\"weapon_34\", \"#7a3ac0\", \"#d8c8f0\", \"#9a80c0\"),\n },\n \"weapon/35\": {\n svg: fish(\"weapon_35\", \"#20a080\", \"#c8e8d8\", \"#3a4a48\"),\n },\n \"weapon/23\": { svg: clawHammer(\"#8090a0\", \"#a0582c\") },\n \"weapon/40\": {\n svg: clawHammer(\n \"#3a3a40\",\n \"#3a3a40\",\n line(\"M 56 19 L 56 31 M 62 19 L 62 31\", \"#e03040\", 2.6),\n ),\n },\n \"weapon/7\": {\n svg:\n path(\n \"M 8 3.5 L 108 3.5 L 112 2.8 L 134 2.8 C 137 2.8 137.5 6 137.5 10 C 137.5 14 136.5 17 133 17 L 112 17.5 L 108 17.5 L 19 18.5 L 18.5 26 C 18.5 29 17.5 31 16 31 L 13.5 29 L 12 33 C 11.5 35.5 10 36.5 9.5 36.5 C 4 30 2.8 22 2.8 13 C 2.8 6.5 5 3.5 8 3.5 Z\",\n \"#8890a8\",\n ) +\n line(\"M 8 8 L 104 8\", \"#a8b0c4\", 2.4) +\n path(\n \"M 96 3.5 L 108 3.5 L 112 2.8 L 134 2.8 C 137 2.8 137.5 6 137.5 10 C 137.5 14 136.5 17 133 17 L 112 17.5 L 96 17.5 Z\",\n \"#a8987a\",\n ) +\n line(\n \"M 103 4 L 107 17 M 113 3.5 L 117 17 M 123 3.5 L 127 17\",\n \"#7a6a50\",\n 1.8,\n ),\n },\n // A mallet with a red and a blue cap.\n \"weapon/1\": {\n svg:\n rect(35, 26, 70, 11, 4, \"#9a98a0\") +\n rect(74, 24, 8, 15, 3, \"#b8b6c0\", { width: 2.6 }) +\n rect(96, 22, 11, 19, 4, \"#b8b6c0\", { width: 2.6 }) +\n circle(7, 32, 4, \"#c8c6d0\", { width: 2.4 }) +\n rect(8, 16, 26, 30, 2, \"#c8c6d0\") +\n path(\n \"M 5 8 C 5 4 8 3 12 3 L 28 3 C 33 3 35 5 35 9 L 35 18 L 5 18 Z\",\n \"#e83838\",\n ) +\n path(\n \"M 5 44 L 35 44 L 35 66 C 35 71 32 73 28 73 L 12 73 C 8 73 5 71 5 66 Z\",\n \"#3a70d8\",\n ) +\n line(\"M 7 9 L 33 9 M 7 52 L 33 52 M 7 60 L 33 60 M 7 67 L 33 67\", INK, 2),\n },\n // A felling axe.\n \"weapon/4\": {\n svg:\n rect(24, 22, 68, 15, 4, \"#8a5a3a\") +\n rect(24, 22, 10, 15, 0, \"#e8dcc0\", { width: 2.4 }) +\n path(\n \"M 4 4 L 24 4 C 24 16 26 30 30 40 C 36 52 44 56 54 58 L 50 72 L 6 70 C 6 50 4 26 4 4 Z\",\n \"#a0a890\",\n ) +\n path(\"M 6 58 C 22 60 38 60 52 62 L 50 72 L 6 70 Z\", \"#d8d8c0\") +\n line(\"M 6 58 C 22 60 38 60 52 62\", \"#6a7060\", 2.4),\n },\n \"weapon/10\": {\n svg:\n path(\n \"M 8 20 L 22 20 L 22 48 C 22 54 26 58 34 61 C 28 63.5 20 63 14 58 C 9.5 54 8 48 8 40 Z\",\n \"#b8b0e0\",\n ) +\n line(\"M 13 24 L 13 46 C 14 52 18 56 24 59\", \"#e8e4f8\", 2) +\n rect(2.8, 2.8, 58, 16.5, 3, \"#a04838\") +\n rect(60, 4, 36.5, 15, 3, \"#e8d8b8\") +\n line(\"M 68 5 L 72 18 M 78 5 L 82 18 M 88 5 L 92 18\", \"#b89a78\", 1.8),\n },\n \"weapon/11\": {\n svg:\n path(\n \"M 18 2.8 C 22 4 22.5 8 21.5 14 L 19.5 22 L 19.5 52 C 20 64 25 74 34 83 C 24 82 14 74 9 64 C 5 56 4.5 48 4.8 40 C 5 26 9 12 18 2.8 Z\",\n \"#b8b0e0\",\n ) +\n line(\"M 12 20 C 10 34 11 52 16 64 C 20 72 24 76 28 80\", \"#e8e4f8\", 2) +\n path(\n \"M 3 22.5 L 95 26 C 97.5 26.2 97.5 29 97.3 33 C 97 37 96 39 93 39 L 4 37.5 C 3 34 2.8 26 3 22.5 Z\",\n \"#7a4428\",\n ) +\n path(\n \"M 24 23.5 L 37 24 L 37 38.2 L 24 37.9 Z M 57 24.8 L 73 25.4 L 73 38.7 L 57 38.4 Z\",\n \"#e8d8b8\",\n { width: 2.2 },\n ),\n },\n // A baseball bat with a taped grip.\n \"weapon/13\": {\n svg:\n path(\n \"M 4 16 C 4 6 10 3 20 3 L 82 6 C 96 8 110 10 122 12 C 126 6 132 4 136 6 C 140 10 140 22 136 26 C 132 28 126 26 122 20 C 110 22 96 26 82 28 L 20 30 C 10 30 4 26 4 16 Z\",\n \"#8a90b0\",\n ) +\n path(\"M 86 7 L 120 11 L 120 21 L 86 27 Z\", \"#d8d0d8\", { width: 2.2 }) +\n line(\n \"M 92 8 L 100 26 M 102 9 L 108 24 M 110 10 L 116 22\",\n \"#a898a8\",\n 1.8,\n ),\n },\n \"weapon/17\": {\n svg:\n path(\"M 36 5.8 L 124 5.8 L 124 18.6 L 36 18.6 Z\", \"#c88a50\") +\n ellipse(124, 12.5, 5.5, 8.5, \"#c88a50\") +\n path(\n \"M 9 3 C 18 2.5 28 2.8 34 5 L 40 6.5 L 40 18 L 34 20.5 C 28 23 22 24 20 24 C 20.5 28 20 32 16 32 C 11 32 9.5 28 10 24 C 6 22 4 20 4.5 17 C 2.8 15 2.8 12 4 9.5 C 3 6 5 3 9 3 Z\",\n \"#c88a50\",\n ) +\n line(\"M 5 10 L 18 10 M 5 16 L 18 16 M 12 24 L 22 22\", \"#9a6030\", 2),\n },\n // A sledgehammer.\n \"weapon/18\": {\n svg:\n rect(38, 26, 54, 14, 3, \"#3a3048\") +\n line(\n \"M 46 28 L 50 33 L 46 38 M 56 28 L 60 33 L 56 38 M 66 28 L 70 33 L 66 38 M 76 28 L 80 33 L 76 38\",\n \"#8a7aa8\",\n 2.2,\n ) +\n rect(88, 22, 12, 22, 4, \"#7a6a90\", { width: 2.6 }) +\n path(\n \"M 4 8 C 4 4 8 3 14 3 L 32 3 C 36 3 38 6 38 10 L 38 54 C 38 60 34 62 30 62 L 12 62 C 6 62 4 58 4 54 Z\",\n \"#7a6a90\",\n ) +\n line(\n \"M 30 6 C 28 14 28 22 34 26 M 30 60 C 28 52 28 44 34 40\",\n \"#5a4a70\",\n 2.4,\n ),\n },\n // Knives.\n \"weapon/19\": {\n svg:\n path(\n \"M 3 20 L 16 12 L 40 10 L 44 6 L 48 10 L 52 6 L 56 10 L 60 6 L 64 10 L 82 10 L 82 30 L 30 30 C 18 30 8 26 3 20 Z\",\n \"#3a4a48\",\n ) +\n flat(\n \"M 10 22 C 20 26 40 27 80 26 L 80 29 L 30 29 C 20 29 12 26 10 22 Z\",\n \"#c0c8c8\",\n ) +\n rect(90, 12, 34, 16, 4, \"#6a3a30\") +\n rect(120, 14, 8, 12, 2, \"#8a8a90\", { width: 2.4 }) +\n rect(82, 3, 8, 34, 2, \"#6a3a30\"),\n },\n \"weapon/20\": {\n svg:\n path(\n \"M 4 26 C 12 16 26 8 42 4 L 92 4.5 L 92 18 L 58 19 C 54 22 48 25.5 42 26.5 C 30 29 16 30 8 29.5 C 4 29 2.8 28 4 26 Z\",\n \"#90b8d0\",\n ) +\n line(\"M 10 26 C 24 22 40 18 58 15\", \"#c8e0f0\", 2.2) +\n path(\n \"M 92 4.2 L 126 4.2 C 129 4.2 129.5 6 129.5 9 L 129.5 18 C 129.5 21 127 21.5 124 21 L 116 19 L 92 18.7 Z\",\n \"#262628\",\n ) +\n line(\"M 104 5 L 104 18\", \"#d03040\", 3),\n },\n \"weapon/21\": {\n svg:\n path(\n \"M 3 18 C 4 10 12 4.5 22 4.3 L 64 4.3 L 64 26.2 L 30 26.2 C 18 26 8 23 3 20 Z\",\n \"#b8c8d8\",\n ) +\n flat(\n \"M 8 20 C 18 23 28 24.5 34 24.5 L 63 24.5 L 63 20 L 30 20 Z\",\n \"#e0ecf4\",\n ) +\n path(\n \"M 66 4 C 70 3 74 2.8 80 2.8 L 106 2.8 C 108.5 2.8 108.5 5 108.5 9 L 108.5 15 C 108.5 18 106 18.5 100 18.5 C 94 18.5 90 17 84 16 L 66 15.5 Z\",\n \"#6a3a28\",\n ) +\n circle(80, 9, 1.8, \"#d8c0a0\", { stroke: \"none\" }) +\n circle(96, 9, 1.8, \"#d8c0a0\", { stroke: \"none\" }),\n },\n \"weapon/22\": {\n svg:\n rect(70, 6, 42, 15, 3, \"#6a3a28\") +\n circle(84, 13.5, 1.8, INK, { stroke: \"none\" }) +\n circle(102, 13.5, 1.8, INK, { stroke: \"none\" }) +\n path(\n \"M 4 6 C 4 4 6 3 8 3 L 70 3 L 70 40 C 50 42 30 42 8 40 C 5 40 4 38 4 36 Z M 9 14 A 3.5 3.5 0 1 0 16 14 A 3.5 3.5 0 1 0 9 14 Z\",\n \"#a8b8c8\",\n { holes: true },\n ) +\n flat(\n \"M 6 30 C 30 31 50 31 68 30 L 68 39 C 50 40 30 40 7 39 Z\",\n \"#d0dce8\",\n ),\n },\n \"weapon/24\": {\n svg:\n path(\n \"M 3 6 C 3 3.5 8 3 20 2.8 C 34 2.6 46 3.6 51 7.5 C 54 10 56 12 60 12 L 60 17.5 C 56 17.5 54 19 51 21.5 C 46 25 34 26 20 25.8 C 8 25.6 2.8 25 2.8 22.5 C 2.8 20.5 6 20 20 20 C 32 20 40 19 44 16 L 44 12 C 40 9.5 32 8.8 20 8.8 C 6 8.8 3 8.5 3 6 Z\",\n \"#5a6070\",\n ) +\n rect(58, 11.8, 33, 4.4, 1.5, \"#5a6070\") +\n path(\n \"M 90 8 L 108 7.5 C 110 6 114 5.8 120 5.8 L 128 5.8 C 132 5.8 133.5 9 133.5 14 C 133.5 19 132 22.5 128 22.5 L 112 22.5 C 106 22.5 102 21.5 100 20 L 90 19.5 Z\",\n \"#4a4e5a\",\n ) +\n line(\n \"M 100 8 L 103 20 M 108 7.5 L 111 21 M 116 6 L 119 22\",\n \"#2a2e3a\",\n 2,\n ),\n },\n // Swords.\n \"weapon/25\": {\n svg:\n path(\n \"M 3 10 C 20 6 60 6 96 8 L 96 30 L 40 30 C 20 28 8 22 3 10 Z\",\n \"#a09890\",\n ) +\n flat(\n \"M 8 14 C 30 18 60 20 94 20 L 94 26 L 40 26 C 24 24 12 20 8 14 Z\",\n \"#c8c0b8\",\n ) +\n rect(108, 16, 26, 10, 2, \"#8a2830\") +\n line(\"M 114 17 L 118 25 M 122 17 L 126 25\", \"#c04050\", 1.8) +\n path(\n \"M 136 21 A 7 7 0 1 0 150 21 A 7 7 0 1 0 136 21 Z M 140.5 21 A 2.5 2.5 0 1 0 145.5 21 A 2.5 2.5 0 1 0 140.5 21 Z\",\n \"#f0c040\",\n { holes: true },\n ) +\n path(\n \"M 99 4 C 106 8 110 14 109 21 C 110 28 106 35 99 40 C 101 33 102 27 102 21 C 102 15 101 10 99 4 Z\",\n \"#f0c040\",\n ),\n },\n \"weapon/26\": {\n svg:\n path(\"M 3 14 L 112 14 L 112 34 L 18 34 C 12 32 6 24 3 14 Z\", \"#5a5468\") +\n flat(\n \"M 10 28 C 40 30 80 30 110 30 L 110 33 L 18 33 C 14 32 12 30 10 28 Z\",\n \"#8a9a98\",\n ) +\n rect(122, 17, 24, 14, 3, \"#262628\") +\n line(\n \"M 124 19 L 132 29 M 132 19 L 124 29 M 132 19 L 140 29 M 140 19 L 132 29\",\n \"#5a5a60\",\n 1.6,\n ) +\n path(\n \"M 146 16 C 152 16 156 20 156 24 C 156 28 152 32 146 32 C 150 28 150 20 146 16 Z\",\n \"#d0a040\",\n ) +\n rect(114, 3, 8, 44, 2, \"#d0a040\"),\n },\n \"weapon/29\": {\n svg:\n path(\"M 14 20 L 100 16 L 110 24 L 110 40 L 100 48 L 14 46 Z\", \"#8a8e98\") +\n line(\"M 14 33 L 104 33\", \"#5a5e68\", 2.6) +\n path(\n \"M 16 4 C 6 16 4 40 14 60 C 18 58 20 54 18 50 C 12 40 12 26 20 10 C 22 6 20 3 16 4 Z\",\n \"#c0c8d8\",\n ) +\n rect(120, 26, 28, 12, 2, \"#5a2020\") +\n line(\n \"M 126 27 L 130 37 M 134 27 L 138 37 M 142 27 L 146 37\",\n \"#8a3a30\",\n 1.8,\n ) +\n path(\"M 148 22 L 154 20 L 156 44 L 150 42 Z\", \"#f0a040\") +\n rect(112, 12, 8, 40, 2, \"#f0a040\"),\n },\n \"weapon/30\": {\n svg:\n path(\n \"M 2.8 19 C 30 17 60 15.5 95 14.5 L 95 21.5 C 60 21.5 30 21 2.8 19.5 Z\",\n \"#b0b0b0\",\n { width: 3 },\n ) +\n rect(110, 14.8, 32, 6.4, 2, \"#6a5a40\") +\n line(\n \"M 114 15 L 117 21 M 122 15 L 125 21 M 130 15 L 133 21\",\n \"#3a3020\",\n 1.6,\n ) +\n line(\n \"M 100 30 C 108 35.5 124 36 133 32.5 C 137 31 139 27 139 22\",\n INK,\n 8,\n ) +\n line(\n \"M 100 30 C 108 35.5 124 36 133 32.5 C 137 31 139 27 139 22\",\n \"#d8d0b0\",\n 4.4,\n ) +\n ellipse(102.5, 16.5, 6.3, 13.5, \"#d8d0b0\"),\n },\n \"back/21\": { svg: pack1(\"back_21\", \"#d8843a\", \"#b86a2a\") },\n \"back/2\": { svg: pack2(\"back_2\", \"#f2c832\", \"#c05828\", \"#c05828\") },\n \"back/23\": { svg: pack2(\"back_23\", \"#d870a0\", \"#b04878\", \"#f0a030\") },\n \"back/3\": { svg: pack3(\"back_3\", \"#2a8a8a\", \"#8a4444\") },\n \"back/22\": { svg: pack3(\"back_22\", \"#4a4a4c\", \"#3a3a3c\") },\n \"back/4\": {\n svg:\n path(\n \"M 7 32 L 66 32 C 69 32 70 35 70 40 L 70 70 C 70 76 66 79 60 79 L 14 79 C 10 79 7 76 7 72 Z\",\n \"#2a5a8a\",\n { stroke: \"none\" },\n ) +\n clipped(\n \"back_4\",\n \"M 7 32 L 66 32 C 69 32 70 35 70 40 L 70 70 C 70 76 66 79 60 79 L 14 79 C 10 79 7 76 7 72 Z\",\n flat(\"M 0 64 L 74 64 L 74 83 L 0 83 Z\", \"#1c3a5c\"),\n line(\"M 0 64 L 74 64\", INK, 2.4),\n ) +\n path(\n \"M 7 32 L 66 32 C 69 32 70 35 70 40 L 70 70 C 70 76 66 79 60 79 L 14 79 C 10 79 7 76 7 72 Z\",\n \"none\",\n ) +\n path(\n \"M 50 38 L 66 38 C 69 38 70.5 40 70.5 44 L 70.5 64 C 70.5 69 68 71 64 71 L 54 71 C 50 71 48.5 69 48.5 64 L 48.5 42 C 48.5 39.5 49 38 50 38 Z\",\n \"#2f6496\",\n ) +\n line(\"M 49 46 C 56 50 62 52 70 50\", INK, 2.2) +\n circle(66, 55, 2.4, \"#c84a2a\", { width: 1.8 }) +\n path(\n \"M 10 4 L 56 4 C 66 4 71 10 71 18 C 71 26 66 32.5 56 32.5 L 10 32.5 C 5 32.5 2.8 26 2.8 18 C 2.8 10 5 4 10 4 Z\",\n \"#e8dcb8\",\n { stroke: \"none\" },\n ) +\n clipped(\n \"back_4r\",\n \"M 10 4 L 56 4 C 66 4 71 10 71 18 C 71 26 66 32.5 56 32.5 L 10 32.5 C 5 32.5 2.8 26 2.8 18 C 2.8 10 5 4 10 4 Z\",\n line(\n \"M 12 3 C 8 10 8 26 12 33 M 26 3 C 22 10 22 26 26 33 M 40 3 C 36 10 36 26 40 33\",\n \"#3a68a8\",\n 5,\n ),\n line(\n \"M 12 3 C 8 10 8 26 12 33 M 26 3 C 22 10 22 26 26 33 M 40 3 C 36 10 36 26 40 33\",\n \"#f0c040\",\n 1.8,\n ),\n ) +\n path(\n \"M 10 4 L 56 4 C 66 4 71 10 71 18 C 71 26 66 32.5 56 32.5 L 10 32.5 C 5 32.5 2.8 26 2.8 18 C 2.8 10 5 4 10 4 Z\",\n \"none\",\n ) +\n ellipse(59, 18, 10, 13.5, \"#d8cca4\", { width: 2.6 }),\n },\n \"back/5\": {\n svg:\n path(\"M 22 34 C 19 24 20 15 27 13 C 34 12 38 18 36 34 Z\", \"#f07838\") +\n path(\"M 47 34 C 45 24 47 17 54 16 C 61 16 64 22 62 34 Z\", \"#f07838\") +\n path(\n \"M 28 14 C 26 9 24 5 25 3 C 28 5 29 8 30 10 C 31 6 32 4 34 3.5 C 34 7 33 11 32 14 Z\",\n \"#3aa040\",\n { width: 2.6 },\n ) +\n path(\n \"M 54 17 C 52 11 51 7 52 4 C 55 6 56 8 57 10 C 58 7 60 5 63 4 C 63 8 61 13 58 17 Z\",\n \"#3aa040\",\n { width: 2.6 },\n ) +\n ellipse(15, 28, 6.5, 8, \"#f8c030\") +\n ellipse(41, 27, 6, 9, \"#f8c030\") +\n ellipse(66, 28, 6.5, 7.5, \"#f8c030\") +\n path(\n \"M 12 26 C 30 22 52 22 70 26 C 76 30 77.5 40 77.5 54 C 77.5 74 66 91 42 92.5 C 18 93 2.8 76 2.8 56 C 2.8 42 5 32 12 26 Z\",\n \"#e8a858\",\n { stroke: \"none\" },\n ) +\n clipped(\n \"back_5\",\n \"M 12 26 C 30 22 52 22 70 26 C 76 30 77.5 40 77.5 54 C 77.5 74 66 91 42 92.5 C 18 93 2.8 76 2.8 56 C 2.8 42 5 32 12 26 Z\",\n flat(\n \"M 0 22 C 30 30 50 30 81 22 L 81 34 C 50 40 30 40 0 34 Z\",\n \"#f4c078\",\n ),\n line(\n \"M 14 40 C 12 56 14 72 20 86 M 26 38 C 24 56 25 74 30 90 M 40 38 C 39 56 40 74 42 92 M 54 38 C 55 56 54 74 52 92 M 66 38 C 68 54 66 72 62 88\",\n \"#c07a30\",\n 2.2,\n ),\n ) +\n path(\n \"M 12 26 C 30 22 52 22 70 26 C 76 30 77.5 40 77.5 54 C 77.5 74 66 91 42 92.5 C 18 93 2.8 76 2.8 56 C 2.8 42 5 32 12 26 Z\",\n \"none\",\n ),\n },\n \"back/6\": {\n svg:\n quiverArrow(50, 26, 56, 4) +\n quiverArrow(55, 28, 66, 9) +\n quiverArrow(60, 31, 71, 18) +\n path(\n \"M 47.2 19 L 64.8 34 L 21 84 C 17 86.3 9 86.3 5 83.3 C 1.5 80.5 1 75 3.2 70.5 Z\",\n \"#6a3a22\",\n ) +\n dashed(\"M 14 74 L 48 35\", \"#3a2016\", 2, \"4 3\") +\n path(\"M 47 18.5 L 65 33.8 L 57 43 L 39 27.8 Z\", \"#8a5a3a\"),\n },\n \"back/7\": {\n svg:\n path(CAMO_7, \"#6a8a4a\", { stroke: \"none\" }) +\n clipped(\n \"back_7\",\n CAMO_7,\n flat(\n \"M 6 14 C 14 10 24 12 26 18 C 20 22 10 22 6 18 Z M 40 8 C 50 6 58 10 56 16 C 48 18 42 16 40 8 Z M 4 34 C 12 30 20 34 18 40 C 12 42 4 40 4 34 Z M 48 28 C 58 26 70 28 72 34 C 62 38 52 36 48 28 Z M 28 42 C 36 40 44 44 42 50 C 34 52 28 48 28 42 Z\",\n \"#3e6a30\",\n ),\n flat(\n \"M 22 26 C 30 24 38 28 36 32 C 30 34 24 32 22 26 Z M 8 48 C 16 46 22 50 20 54 C 14 56 8 54 8 48 Z M 58 44 C 64 42 72 44 72 48 C 66 50 60 50 58 44 Z\",\n \"#8a6a3a\",\n ),\n flat(\"M 0 56 C 20 60 50 58 79 50 L 79 78 L 0 78 Z\", \"#3a4a3a\"),\n line(\"M 0 56 C 20 60 50 58 79 50\", INK, 2.4),\n ) +\n path(CAMO_7, \"none\"),\n },\n \"back/8\": { svg: tank8(\"back_8\", \"#2a2a48\", \"#d03030\", \"#f0c030\") },\n \"back/29\": { svg: tank8(\"back_29\", \"#4a6ad0\", \"#f0a030\", \"#f08030\") },\n \"back/9\": {\n svg:\n rect(68.5, 2.8, 7.5, 40, 3.5, \"#3a4826\") +\n path(RADIO_9, \"#4a5a32\", { stroke: \"none\" }) +\n clipped(\n \"back_9\",\n RADIO_9,\n flat(\"M 0 0 L 85 0 L 85 36 L 0 36 Z\", \"#5a6a3a\"),\n flat(\"M 62 36 L 85 36 L 85 100 L 62 100 Z\", \"#3a4826\"),\n line(\"M 0 36 L 62 36 L 62 100\", INK, 2.4),\n ) +\n path(RADIO_9, \"none\") +\n circle(22, 29, 3.4, \"#60c040\", { width: 2 }) +\n circle(37, 29, 3.4, \"#60c040\", { width: 2 }) +\n circle(52, 29, 3.4, \"#e04040\", { width: 2 }) +\n rect(64, 56, 17.2, 26, 4, \"#4a5a32\") +\n line(\"M 64 64 L 81 64\", INK, 2.2),\n },\n \"back/10\": {\n svg:\n ellipse(29, 24, 22.5, 21, \"#f8c8b0\") +\n path(\"M 40 5 C 42 2.8 46 2.5 47 4 C 47 7 46 9 44 10 Z\", \"#f0c040\", {\n width: 2.4,\n }) +\n line(\"M 37 22 L 37 28 C 37 31 34 31 33 29\", INK, 2.2) +\n circle(47, 29, 3, \"#f8a0a8\", { stroke: \"none\" }) +\n path(\n \"M 8 44 C 14 38 24 36 32 38 C 44 38 54 42 58 50 C 60 56 59.5 66 58 74 C 55 86 44 91 32 91 C 18 91 6 84 3.5 70 C 2.5 60 3 50 8 44 Z\",\n \"#b8daf0\",\n ) +\n line(\n \"M 8 56 C 22 52 40 52 56 58 M 10 70 C 24 64 40 66 54 70 M 32 40 C 30 48 26 54 20 58\",\n \"#8ab0d0\",\n 2.2,\n ),\n },\n \"back/11\": {\n svg:\n [\n [80, 18, 8.5],\n [92, 32, 9],\n [82, 40, 8.5],\n [94, 52, 8.5],\n [82, 60, 8],\n ]\n .map(\n ([x, y, r]) =>\n circle(x!, y!, r!, \"#c06838\") +\n circle(x!, y!, r! * 0.55, \"#e09058\", {\n stroke: \"#a05028\",\n width: 1.6,\n }),\n )\n .join(\"\") +\n path(\"M 14 70 L 96 63 L 92 70 L 14 78 Z\", \"#8a5a38\") +\n [8, 27, 46]\n .map(\n (y) =>\n rect(10, y, 62, 19, 3, \"#7a4a2a\") +\n line(\n `M 16 ${y + 7} L 40 ${y + 7} M 30 ${y + 13} L 62 ${y + 13}`,\n \"#5a3420\",\n 2,\n ),\n )\n .join(\"\") +\n rect(2.8, 2.8, 79, 6.5, 3, \"#5a3a2a\") +\n rect(2.8, 2.8, 11.5, 94.4, 3, \"#5a3a2a\") +\n rect(59.8, 2.8, 12.4, 94.4, 3, \"#5a3a2a\"),\n },\n \"back/12\": { svg: sack12(\"back_12\", \"#b8ae8a\", \"#7a4a28\", \"#c8b890\") },\n \"back/27\": { svg: sack12(\"back_27\", \"#2a2a2c\", \"#4a4a4e\", \"#3a3a3c\") },\n \"back/13\": {\n svg:\n path(\n \"M 20 21 L 20 10 C 20 5 25 2.8 32 2.8 L 48 2.8 C 54 2.8 58 5 58 10 L 58 21 L 50 21 L 50 14 C 50 11.5 48 10.5 44 10.5 L 34 10.5 C 31 10.5 29 11.5 29 14 L 29 21 Z\",\n \"#c83038\",\n ) +\n path(\n \"M 72 30 C 80 30 88 36 88 46 L 88 72 C 88 78 84 80 78 80 L 72 80 Z\",\n \"#a82830\",\n ) +\n path(\n \"M 8 20 L 72 20 C 77 20 80 24 80 30 L 80 78 C 80 82 77 84 72 84 L 12 84 C 7 84 4.8 81 4.8 76 L 4.8 30 C 4.8 24 5 20 8 20 Z\",\n \"#c83038\",\n ) +\n path(\n \"M 2.8 28 C 2.8 22 6 19.5 12 19.5 L 66 19.5 C 72 19.5 74 23 74 30 L 74 40 C 66 44 52 46 40 44 L 2.8 44 Z\",\n \"#d83a42\",\n ) +\n rect(64, 9, 12, 12, 3, \"#f0f0f0\") +\n rect(64.8, 18, 10.4, 60, 2, \"#2a2a2c\") +\n [30, 38, 46, 54, 62]\n .map((y) => circle(70, y, 1.8, \"#f0f0f0\", { stroke: \"none\" }))\n .join(\"\") +\n path(\n \"M 63 76 L 77 76 L 78.5 88 C 78.5 90 76 90.5 70 90.5 C 64 90.5 61.5 90 61.5 88 Z\",\n \"#f0f0f0\",\n ),\n },\n \"back/15\": {\n svg: pack15(\n \"back_15\",\n \"#f86410\",\n \"#20a070\",\n \"#107070\",\n \"#c84020\",\n \"#20a060\",\n ),\n },\n \"back/24\": {\n svg: pack15(\n \"back_24\",\n \"#7aa0d8\",\n \"#a8a040\",\n \"#807a30\",\n \"#2a4070\",\n \"#4a6aa0\",\n ),\n },\n \"back/16\": { svg: bag16(\"back_16\", \"#e8e4ee\", \"#3a70d0\") },\n \"back/30\": { svg: bag16(\"back_30\", \"#e8d4a8\", \"#8a6a40\") },\n \"back/28\": {\n svg: backSword(\"#7a7a80\", \"#5a5a60\", \"#5a2a2a\", \"#8a4a40\", \"#6a4a3a\"),\n },\n \"back/18\": { svg: roll18(\"#98e888\", \"#6aa050\", \"#22a048\") },\n \"back/25\": { svg: roll18(\"#9a5a38\", \"#7a4228\", \"#b06a48\") },\n \"back/19\": {\n svg:\n path(\n \"M 12 28 L 76 28 C 82 28 84.5 32 84.5 38 L 84.5 83 C 84.5 87 82 88.5 78 88.5 L 14 88.5 C 10 88.5 7.8 86 7.8 81 L 7.8 34 C 7.8 30 9 28 12 28 Z\",\n \"#7a2a8a\",\n { stroke: \"none\" },\n ) +\n clipped(\n \"back_19\",\n \"M 12 28 L 76 28 C 82 28 84.5 32 84.5 38 L 84.5 83 C 84.5 87 82 88.5 78 88.5 L 14 88.5 C 10 88.5 7.8 86 7.8 81 L 7.8 34 C 7.8 30 9 28 12 28 Z\",\n flat(\"M 0 79 L 90 79 L 90 92 L 0 92 Z\", \"#9a4aaa\"),\n line(\"M 0 79 L 90 79\", INK, 2.4),\n ) +\n path(\n \"M 12 28 L 76 28 C 82 28 84.5 32 84.5 38 L 84.5 83 C 84.5 87 82 88.5 78 88.5 L 14 88.5 C 10 88.5 7.8 86 7.8 81 L 7.8 34 C 7.8 30 9 28 12 28 Z\",\n \"none\",\n ) +\n rect(70, 38.5, 22, 8.5, 4, \"#a060b8\") +\n rect(70, 53.5, 22, 8.5, 4, \"#a060b8\") +\n path(\n \"M 73 74 L 97 74 L 97 88 C 97 92 95 93.5 91 93.5 L 79 93.5 C 75 93.5 73 92 73 88 Z\",\n \"#a0b040\",\n ) +\n rect(71.5, 65.5, 27, 10, 4, \"#50c0c0\") +\n path(\n \"M 12 3 L 68 3 C 77 3 82.5 9 82.5 16.5 C 82.5 24 77 30 68 30 L 12 30 C 6 30 2.8 24 2.8 16.5 C 2.8 9 6 3 12 3 Z\",\n \"#e888b8\",\n ) +\n path(\"M 22 3 C 18 8 18 26 22 30 L 50 30 C 46 26 46 8 50 3 Z\", \"#f4d0e4\", {\n width: 2.6,\n }) +\n circle(68, 16.5, 11.5, \"#e888b8\") +\n line(\n \"M 68 16.5 C 70 14 73 16 72 19 C 70 23 64 22 63 17 C 62 11 70 8 75 12 C 79 16 77 24 72 26\",\n INK,\n 2.4,\n ),\n },\n \"back/20\": { svg: bear20(\"#a05a30\") },\n \"back/26\": { svg: bear20(\"#f0e0c0\") },\n};\n"
212
+ },
213
+ {
214
+ "path": "character/packs/base/hair.ts",
215
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport { circle, clipped, flat, INK, LINE, line, path } from \"../../art/svg\";\n\n// Hair — white with light grey shading, the hair colour paints them. Every short\n// hair has its \"under a hat\" twin of the same number (hair_under/…, hair_hat/…): what still shows\n// below a hat. The rig swaps one for the other by that number, so they are drawn in pairs.\n\nconst H = \"#ffffff\";\nconst SHADE = \"#dcdcdc\";\nconst STRAND = \"#c4c4c4\";\n/** A shorn or shadowed part: no outline, see-through black (25–30%). */\nconst SHADOW = \"#000000\";\nconst RIBBON = \"#333333\";\n\nconst LONG_20 =\n \"M 4 30 C 4 12 16 3 32 3 C 50 3 60 14 60 30 L 60 76 C 60 82 62 86 62 88 C 50 86 30 86 14 82 C 6 78 4 68 4 30 Z\";\nconst LONG_21 =\n \"M 8 36 C 6 22 14 12 26 10 C 30 4 40 2 48 6 C 56 2 70 4 76 12 C 84 14 88 22 86 30 C 92 34 92 42 88 46 C 90 54 88 62 84 66 C 86 76 82 82 86 86 \" +\n \"L 76 86 L 82 98 L 64 90 L 52 100 L 44 86 L 34 94 L 26 82 C 20 76 16 70 14 62 C 8 58 6 50 8 44 C 6 42 6 38 8 36 Z\";\nconst LONG_28 =\n \"M 4 40 C 4 18 20 4 40 4 C 54 4 62 6 68 12 C 78 22 86 36 90 56 C 92 64 92 68 86 70 L 92 76 L 80 76 L 76 96 L 66 84 L 44 98 L 26 86 L 22 92 L 18 80 C 8 72 4 58 4 40 Z\";\nconst LONG_29 =\n \"M 4 22 C 4 10 12 3 24 3 C 44 3 62 20 70 44 C 76 62 74 86 72 108 L 40 108 C 38 96 34 84 28 76 C 16 64 4 44 4 22 Z\";\n\nconst CURLS_7 =\n \"M 4 32 C 2 26 6 22 10 22 C 10 14 16 10 22 12 C 24 5 32 4 36 8 C 40 2 50 2 52 7 C 56 2 64 2 66 8 C 72 4 80 8 80 14 C 86 14 90 20 88 26 \" +\n \"C 94 28 94 36 90 40 C 94 44 92 48 86 48 C 82 48 76 48 72 48 C 66 50 62 48 60 46 C 54 48 48 48 46 44 C 40 48 32 48 28 44 C 22 48 14 46 12 42 C 6 42 2 38 4 32 Z\";\n\n// Under a hat only the hair's lower edge shows, so a hair_under / hair_hat image has its outline only\n// there: its top runs under the hat and is left open (a fill without a stroke + the visible edge).\n\nexport const hairArt: Record<string, ArtDef> = {\n // hair_2 — swept, falling over the forehead in an M, ragged ends.\n \"hair/2\": {\n svg:\n path(\n \"M 5 55 L 8 51 L 11 57 L 14 51 L 16 48 C 16 40 16 33 18 28 C 20 23 24 20 28 20 L 35 21 L 43 28 L 50 22 \" +\n \"C 54 21 57 22 59 25 C 63 32 68 42 76 50 L 83 56 L 86 50 L 92 52 L 96 47 L 104 48 L 108 40 C 110 34 110 28 106 22 \" +\n \"C 98 10 80 3 56 3 C 42 3 28 6 18 13 C 8 21 3 34 3 46 Z\",\n H,\n ) +\n flat(\n \"M 66 36 C 72 30 84 34 96 36 C 104 38 106 42 104 47 L 96 47 L 92 52 L 86 50 L 83 56 L 76 50 C 72 46 68 42 66 36 Z\",\n SHADE,\n ) +\n flat(\n \"M 4 44 C 8 46 12 44 16 42 L 16 48 L 14 51 L 11 57 L 8 51 L 5 55 Z\",\n SHADE,\n ) +\n line(\"M 56 10 C 58 8 60 7 62 6 M 92 28 C 94 34 96 40 96 46\", INK, 3),\n },\n \"hair_under/2\": {\n svg:\n path(\n \"M 4 48 C 3 36 8 26 16 18 C 30 6 46 2 62 2 C 80 3 94 12 98 28 L 99 40 L 106 40 L 107 45 L 100 46 L 97 50 \" +\n \"L 91 55 L 86 49 L 83 57 L 78 52 C 72 44 66 32 58 20 L 44 25 L 30 17 L 18 25 L 16 45 L 12 54 L 10 48 L 7 52 Z\",\n H,\n ) +\n flat(\n \"M 64 30 C 74 34 86 36 98 36 L 99 40 L 106 40 L 107 45 L 100 46 L 97 50 L 91 55 L 86 49 L 83 57 L 78 52 C 72 44 68 38 64 30 Z\",\n SHADE,\n ) +\n line(\n \"M 16 19 C 10 24 5 30 4 36 L 3 49 L 7 52 L 10 48 L 12 54 L 16 49 L 17 26 C 22 18 26 16 30 17 L 44 25 L 56 19 \" +\n \"C 62 22 66 32 72 42 C 76 48 80 54 83 57 L 86 49 L 91 55 L 97 50 L 96 26 M 98 44 L 106 46 L 107 41 M 58 4 C 61 2 64 2 67 3\",\n INK,\n LINE,\n ),\n },\n // hair_3 — a lumpy crop, a lock down the side.\n \"hair/3\": {\n svg:\n path(\n \"M 4 26 C 2 10 8 2 20 2 C 28 2 32 6 34 10 C 40 4 50 1 58 4 C 61 6 62 9 63 11 C 74 12 82 18 88 28 \" +\n \"C 92 36 96 46 96 54 C 90 54 84 55 78 58 C 76 60 74 62 73 63 L 69 40 C 50 38 20 37 10 35 C 6 34 4 30 4 26 Z\",\n H,\n ) +\n flat(\n \"M 71 46 L 95 46 C 96 50 96 52 96 54 C 90 54 84 55 78 58 C 76 60 74 62 73 63 Z\",\n SHADE,\n ),\n },\n \"hair_under/3\": {\n svg:\n path(\n \"M 45.5 3.9 Q 35.9 3.9 29.6 5.9 Q 23.4 7.8 19.3 10.3 Q 15.1 12.9 12.6 15.4 Q 10.1 18 7.1 19.6 Q 4.1 21.2 4.1 21.5 Q 4 21.8 4.5 22.4 Q 5.1 23 9.1 24.5 Q 13.1 26.1 17.8 26.7 Q 22.6 27.2 45.2 28.3 Q 67.9 29.4 69.4 37.6 Q 70.9 45.9 71.8 48.4 Q 72.8 50.9 73 50.8 Q 73.1 50.8 75.6 48.4 Q 78 46 80.2 45.1 Q 82.4 44.3 87.1 44.7 Q 91.8 45 91.9 45 Q 91.9 45 92.3 43.4 Q 92.7 41.8 89.6 40.8 Q 86.5 39.7 85.8 34.4 Q 85.1 29.1 83.7 25.9 Q 82.3 22.6 77.1 16.8 Q 71.9 10.9 68.3 8.9 Q 64.7 6.8 59.9 5.4 Q 55.1 3.9 45.5 3.9 Z\",\n H,\n { holes: true },\n ) +\n flat(\n \"M 70 36 L 88 36 L 92 40 L 93 45 L 80 46 C 76 48 74 52 72 53 Z\",\n SHADE,\n ),\n },\n // hair_5 — curly, a curl over the ear, a lock down the side.\n \"hair/5\": {\n svg:\n path(\n \"M 3 22 C 2 8 12 2 24 2 C 32 2 38 4 42 6 C 48 5 54 6 56 9 C 62 7 70 8 74 11 C 82 12 88 16 90 20 \" +\n \"C 96 22 100 26 100 30 C 104 32 106 38 104 44 C 105 52 100 55 92 55 C 90 60 88 64 89 68 C 89 72 85 74 82 72 \" +\n \"C 81 64 82 56 80 49 C 70 42 50 40 38 40 C 30 42 20 44 12 42 C 6 40 3 32 3 22 Z\",\n H,\n ) +\n flat(\n \"M 4 32 C 14 36 24 30 36 26 C 46 30 56 30 66 30 C 76 32 86 34 104 38 C 105 44 105 52 100 54 C 96 55 94 55 92 55 \" +\n \"C 90 60 88 64 89 68 C 89 72 85 74 82 72 C 81 64 82 56 80 49 C 70 42 50 40 38 40 C 30 42 20 44 12 42 C 8 40 5 36 4 32 Z\",\n SHADE,\n ) +\n line(\"M 30 11 C 35 15 35 26 25 34\", INK, 3),\n },\n \"hair_under/5\": {\n svg:\n path(\n \"M 4 30 C 8 20 18 14 28 8 C 40 2 60 1 76 4 C 90 8 94 22 93 40 L 96 40 L 95 44 L 84 46 C 82 50 80 58 78 60 \" +\n \"L 74 61 C 74 52 74 44 72 40 L 62 32 L 52 31 L 46 32 L 32 30 L 30 31 L 22 33 Z\",\n H,\n ) +\n flat(\n \"M 4 30 C 12 26 20 20 30 18 C 38 22 46 20 54 22 C 62 26 74 24 92 26 L 93 40 L 96 40 L 95 44 L 84 46 \" +\n \"C 82 50 80 58 78 60 L 74 61 C 74 52 74 44 72 40 L 62 32 L 52 31 L 46 32 L 32 30 L 30 31 L 22 33 Z\",\n SHADE,\n ) +\n line(\n \"M 3 30 C 6 32 10 33 20 33 L 30 30 C 34 31 38 31 46 31 L 52 30 C 58 30 62 31 66 33 C 70 36 74 38 76 40 \" +\n \"L 75 59 L 79 59 C 82 50 84 46 88 45 L 95 41 M 27 8 C 26 14 24 18 19 22\",\n INK,\n LINE,\n ),\n },\n // hair_10 — a bob with a straight fringe.\n \"hair/10\": {\n svg:\n path(\n \"M 3 76 C 2 52 8 24 30 10 C 44 2 60 1 68 4 C 72 2 80 1 90 3 C 112 8 130 34 134 72 C 120 78 104 80 93 80 \" +\n \"L 92 55 L 22 55 L 22 80 Z\",\n H,\n ) +\n flat(\n \"M 4 66 C 10 70 16 70 22 70 L 22 80 L 3 76 Z M 93 70 C 108 70 122 66 134 62 L 134 72 C 120 78 104 80 93 80 Z\",\n SHADE,\n ) +\n line(\n \"M 38 36 L 36 50 M 50 47 L 50 53 M 66 47 L 66 53 M 79 40 L 79 50 M 106 46 L 108 74 M 13 60 L 13 76\",\n STRAND,\n 2,\n ) +\n line(\n \"M 30 26 C 22 32 18 40 18 54 L 97 54 L 94 36 M 64 13 C 66 9 69 7 72 6\",\n INK,\n LINE,\n ),\n },\n \"hair_under/10\": {\n svg:\n path(\n \"M 2 53 C 6 42 12 34 20 30 L 21 22 C 30 16 34 10 44 6 C 60 0 80 1 92 6 C 102 12 106 22 108 36 L 116 40 L 117 47 \" +\n \"L 127 53 C 126 62 118 68 104 69 L 92 69 L 91 34 L 25 34 L 23 60 L 4 57 Z\",\n H,\n ) +\n flat(\n \"M 4 50 C 10 54 16 54 23 52 L 23 60 L 4 57 Z M 92 60 C 104 60 116 58 125 56 C 124 62 118 68 104 69 L 92 69 Z\",\n SHADE,\n ) +\n line(\n \"M 38 14 L 36 30 M 50 28 L 50 32 M 66 26 L 66 32 M 79 20 L 79 30 M 104 50 L 105 66\",\n STRAND,\n 2,\n ) +\n line(\n \"M 19 29 C 10 36 3 45 3 52 L 4 56 L 22 60 L 24 33 M 19 32 L 95 33 L 94 15 M 92 34 L 92 69 L 108 68 \" +\n \"C 116 66 123 60 128 54 L 117 47 L 116 40\",\n INK,\n LINE,\n ),\n },\n // hair_13 — bald on top: a strand at the temple and a tuft at the back.\n \"hair/13\": {\n svg:\n path(\n \"M 15 1.5 C 8 6 3 14 3 22 C 3 28 4 32 6 35 C 7 26 9 16 15 1.5 Z\",\n H,\n { width: 3 },\n ) +\n path(\n \"M 89 2 C 92 2 94 4 93 6 C 97 5 100 7 101 12 C 101 20 100 26 100 30 C 94 30 88 31 84 33 C 80 36 78 38 77 36 \" +\n \"C 76 30 75 22 76 16 C 78 12 84 10 90 9 C 89 7 88 4 89 2 Z\",\n H,\n ) +\n line(\n \"M 78 18 C 86 16 94 16 100 17 M 78 25 C 86 23 94 23 100 24\",\n SHADE,\n 2.6,\n ),\n },\n \"hair_under/13\": {\n svg:\n path(\n \"M 15 1.5 C 8 6 3 14 3 22 C 3 28 4 32 6 35 C 7 26 9 16 15 1.5 Z\",\n H,\n { width: 3 },\n ) +\n flat(\n \"M 87 2 C 89 4 91 6 91 8 L 92 10 C 93 14 94 20 95 25 L 100 25 L 100 30 C 92 31 86 32 82 34 C 80 36 78 37 77 36 \" +\n \"C 76 30 75 22 76 16 C 77 13 82 11 88 10 L 90 9 Z\",\n H,\n ) +\n line(\n \"M 78 18 C 84 16 89 16 93 17 M 78 25 C 84 23 89 23 94 24\",\n SHADE,\n 2.6,\n ) +\n line(\n \"M 87 2 C 89 4 91 6 91 9 C 84 10 78 12 76 16 C 75 22 76 30 77 36 C 80 36 84 33 88 31 C 92 30 96 30 100 30 L 100 25\",\n INK,\n LINE,\n ),\n },\n // hair_1 — a buzz cut: no outline, a darker grey the hair colour paints.\n \"hair/1\": {\n svg: flat(\n \"M 2 35 C 6 20 18 6 34 3 C 52 1 72 6 80 20 C 84 28 84 36 82 42 C 79 46 76 49 72 51 L 68 51 C 66 47 66 43 65 40 C 62 30 52 20 40 19 C 28 18 12 22 4 32 Z\",\n SHADOW,\n 0.3,\n ),\n },\n // hair_4 — spiky.\n \"hair/4\": {\n svg:\n path(\n \"M 2 53 L 20 52 L 21 24 L 44 32 L 81 3 L 84 41 L 116 41 L 98 68 L 112 77 L 107 85 L 83 91 L 72 74 L 42 72 L 34 60 L 10 76 L 3 62 Z\",\n H,\n ) +\n flat(\n \"M 77 8 L 81 4 L 84 41 L 78 42 Z M 115 42 L 98 68 L 86 66 Z M 84 80 L 107 84 L 83 90 Z\",\n SHADE,\n ),\n },\n \"hair_under/4\": {\n svg:\n path(\n \"M 3 22 C 10 12 30 3 56 2 C 76 2 88 12 92 26 C 93 32 94 38 95 40 L 99 42 L 97 46 L 84 46 L 78 52 L 70 41 L 66 25 L 37 35 L 30 20 L 5 37 Z\",\n H,\n ) +\n flat(\"M 84 24 L 92 26 L 94 40 L 86 38 Z\", SHADE) +\n line(\n \"M 3 22 L 5 36 L 29 20 L 36 34 L 66 25 L 70 40 L 77 50 L 82 45 L 98 42\",\n INK,\n LINE,\n ),\n },\n // hair_6 — a swept comb-over, a dark sideburn under it.\n \"hair/6\": {\n svg:\n flat(\n \"M 76 42 L 104 46 C 104 54 100 62 92 68 L 86 68 C 84 60 82 50 76 42 Z\",\n SHADOW,\n 0.25,\n ) +\n path(\n \"M 2 44 C 10 34 20 28 34 22 L 28 15 L 64 15 L 60 3 C 78 6 96 14 108 26 C 112 30 112 38 110 42 C 108 46 106 48 104 48 C 102 44 98 42 92 42 L 20 42 C 12 43 6 44 2 44 Z\",\n H,\n ) +\n flat(\n \"M 46 31 C 66 29 90 28 109 31 C 110 36 109 41 106 46 C 102 43 98 42 92 42 L 20 42 C 28 38 36 34 46 31 Z\",\n SHADE,\n ),\n },\n \"hair_under/6\": {\n svg:\n flat(\n \"M 66 24 L 92 30 C 93 38 90 46 82 50 L 74 51 C 72 40 70 30 66 24 Z\",\n SHADOW,\n 0.25,\n ) +\n path(\n \"M 52.5 3.9 Q 42.9 3.9 35.7 6.3 Q 28.4 8.8 25.3 10.8 Q 22.1 12.9 19.6 15.4 Q 17.1 17.9 12.7 20.5 Q 8.4 23 7.6 24.2 Q 6.8 25.4 4.8 32 Q 2.8 38.6 3.7 44 Q 4.5 49.4 4.5 49.4 Q 4.4 49.4 9.3 42.1 Q 14.1 34.8 19.5 30.1 Q 24.9 25.4 30.7 24.8 Q 36.5 24.2 58.6 24.2 Q 80.6 24.2 84.4 24.8 Q 88.1 25.4 90.5 27.1 Q 93 28.8 93.4 29.8 Q 93.8 30.8 93.1 31 Q 92.4 31.1 91.3 27.7 Q 90.2 24.4 88.7 21.8 Q 87.1 19.2 81.3 13.9 Q 75.4 8.7 68.7 6.3 Q 62.1 3.9 52.5 3.9 Z\",\n H,\n { holes: true },\n ) +\n flat(\n \"M 8 30 C 30 16 60 12 90 14 L 93 24 L 24 24 C 18 26 12 28 8 30 Z\",\n SHADE,\n ),\n },\n // hair_7 — a cloud of curls.\n \"hair/7\": {\n svg:\n flat(\n \"M 72 48 L 90 48 C 90 54 86 58 80 60 L 74 64 C 72 58 72 52 72 48 Z\",\n SHADOW,\n 0.25,\n ) +\n path(CURLS_7, H, { stroke: \"none\" }) +\n clipped(\n \"hair_7\",\n CURLS_7,\n flat(\"M 0 34 C 30 38 60 36 96 34 L 96 70 L 0 70 Z\", SHADE),\n ) +\n path(CURLS_7, \"none\"),\n },\n \"hair_under/7\": {\n svg:\n flat(\n \"M 70 32 L 88 34 C 88 40 86 44 80 44 L 72 48 C 72 42 72 36 70 32 Z\",\n SHADOW,\n 0.25,\n ) +\n path(\n \"M 2 22 C 8 14 20 6 40 3 C 60 1 80 8 86 22 L 88 32 C 84 30 80 30 77 29 C 72 32 68 31 66 28 C 62 26 58 28 56 28 C 52 26 50 26 48 26 C 44 28 40 30 36 28 C 34 32 28 32 26 26 C 22 28 18 28 16 26 C 14 22 10 20 6 22 Z\",\n H,\n ) +\n flat(\"M 4 20 C 30 18 60 18 86 20 L 88 32 C 70 28 40 26 4 22 Z\", SHADE) +\n line(\n \"M 2 22 C 6 20 10 20 12 22 C 14 26 18 28 22 26 C 26 30 30 32 34 28 C 38 30 42 28 46 26 C 50 26 54 28 58 27 C 62 26 66 28 68 30 C 72 31 76 29 80 31 C 84 32 86 32 88 32\",\n INK,\n LINE,\n ),\n },\n // hair_8 — a bob with a black bunny-ear ribbon.\n \"hair/8\": {\n svg:\n path(\"M 18 8 C 30 6 50 14 58 34 L 44 36 C 34 30 22 20 18 8 Z\", RIBBON) +\n path(\"M 100 2 C 96 18 88 30 76 36 L 64 34 C 66 18 80 6 100 2 Z\", RIBBON) +\n path(\n \"M 4 78 C 6 68 12 60 20 56 C 26 48 34 42 44 40 C 64 36 90 38 102 50 C 108 56 114 62 120 66 C 130 70 140 72 143 80 C 145 86 140 90 128 88 \" +\n \"C 126 96 120 102 110 104 C 104 110 96 116 92 114 L 92 82 L 24 82 L 24 112 C 20 118 10 116 10 110 C 6 104 8 98 6 92 C 2 90 2 84 4 78 Z\",\n H,\n ) +\n flat(\n \"M 10 106 L 24 108 L 24 112 C 20 118 10 116 10 110 Z M 94 104 C 110 100 126 92 140 84 C 140 90 134 92 128 88 C 126 96 120 102 110 104 C 104 110 96 116 92 114 Z\",\n SHADE,\n ) +\n line(\"M 24 58 L 20 79 L 92 80 L 92 62\", INK, LINE) +\n path(\n \"M 30 46 C 50 38 80 36 96 44 C 100 50 102 58 102 66 L 98 62 C 92 52 76 44 60 44 C 48 44 36 46 30 48 Z\",\n RIBBON,\n { width: 3 },\n ),\n },\n \"hair_under/8\": {\n svg:\n path(\n \"M 2 44 C 4 36 10 32 16 30 C 18 20 30 10 50 4 C 70 0 90 6 100 18 C 104 24 108 34 110 40 L 113 40 L 112 48 L 120 52 C 122 58 118 64 112 64 L 104 62 C 100 68 96 72 90 72 L 88 36 L 20 36 L 20 64 C 18 72 10 74 6 70 C 4 64 4 58 4 54 C 2 52 2 48 2 44 Z\",\n H,\n ) +\n flat(\n \"M 4 62 L 20 62 L 20 64 C 18 72 10 74 6 70 Z M 90 62 C 100 60 112 58 120 56 C 118 62 116 64 112 64 L 104 62 C 100 68 96 72 90 72 Z\",\n SHADE,\n ) +\n line(\n \"M 16 30 C 10 32 4 36 2 44 C 2 48 2 52 4 54 C 4 58 4 64 6 70 C 10 74 18 72 20 64 L 20 36 L 88 36 L 88 18 \" +\n \"M 88 36 L 90 72 C 96 72 100 68 104 62 L 112 64 C 118 64 122 58 120 52 L 112 48 L 113 40\",\n INK,\n LINE,\n ),\n },\n // hair_9 — a bun on top with a gold band, locks down both sides.\n \"hair/9\": {\n svg:\n circle(66.5, 27, 24.5, H) +\n path(\"M 48 34 C 54 26 78 26 84 34 Z\", \"#f0b850\", { width: 3 }) +\n path(\n \"M 4 104 C 2.8 92 2.8 78 4 68 C 6 56 12 44 22 38 C 28 34 34 32 42 31.5 L 90 31.5 C 96 36 99 46 100 56 L 101.2 74 C 101 80 98 82 94 83 C 93 90 90 98 86 104 \" +\n \"C 84 108 81 110 79 109.5 C 77 104 76 98 74 92 C 72 84 68 76 60 72 C 50 67 38 64 28 62 C 24 61 22 62 21 64 C 16 72 10 84 8 92 C 7 98 7 103 6 105.5 Z\",\n H,\n ) +\n flat(\n \"M 76 92 C 78 98 79 104 79 109 C 82 108 85 104 87 100 C 84 96 80 94 76 92 Z M 92 48 C 96 54 98 64 99 74 L 99 80 C 96 72 94 60 92 48 Z\",\n SHADE,\n ) +\n line(\"M 14 50 C 16 52 18 54 18 56 M 84 50 C 86 52 88 54 90 56\", INK, 2.6),\n },\n \"hair_under/9\": {\n svg:\n path(\n \"M 51.5 3.9 Q 41.9 3.9 34.7 6.3 Q 27.4 8.8 24.3 10.8 Q 21.1 12.9 18.6 15.4 Q 16.1 18 13.1 19.5 Q 10.1 21 8.9 22.2 Q 7.7 23.5 5.3 32.1 Q 2.8 40.8 2.8 48.5 Q 2.8 56.3 4.3 62.9 Q 5.8 69.5 5.8 69.5 Q 5.9 69.6 6 69.5 Q 6.1 69.4 6.1 61.4 Q 6.2 53.3 6.8 49.7 Q 7.4 46.1 9 42.3 Q 10.6 38.5 18.9 31.2 Q 27.2 23.9 31.7 26.5 Q 36.3 29.2 46.6 31.3 Q 57 33.4 62.1 36.2 Q 67.1 39.1 68.8 40.8 Q 70.4 42.6 73 48.4 Q 75.6 54.1 77.2 64.6 Q 78.8 75.1 78.7 75.1 Q 78.6 75 79.5 74.7 Q 80.4 74.3 82.7 72.1 Q 85.1 69.9 86.7 67.3 Q 88.2 64.6 90.2 60.2 Q 92.2 55.8 93.4 50.8 Q 94.7 45.9 97.2 45.8 Q 99.7 45.7 100.2 44.1 Q 100.7 42.5 99.2 41.7 Q 97.8 40.8 95.1 40.4 Q 92.5 39.9 91.8 34.5 Q 91.1 29.1 89.7 25.8 Q 88.3 22.5 87.2 20.8 Q 86.1 19.2 80.6 14.1 Q 75 9.1 68.1 6.5 Q 61.1 3.9 51.5 3.9 Z\",\n H,\n { holes: true },\n ) +\n flat(\n \"M 72 56 C 80 58 88 56 94 52 C 92 64 88 72 82 74 L 79 74 C 77 68 75 62 72 56 Z\",\n SHADE,\n ) +\n line(\n \"M 84 18 C 92 26 94 40 92 52 M 94 44 C 97 42 99 44 97 46.5\",\n INK,\n 2.6,\n ),\n },\n // hair_11 — spiky pigtails with a green tie, a lock down the side.\n \"hair/11\": {\n svg:\n path(\n \"M 76 56 C 82 66 86 80 84 108 C 92 100 96 88 94 74 L 104 76 L 100 54 Z\",\n H,\n ) +\n path(\n \"M 2 24 L 14 22 L 14 2 L 34 14 L 44 4 L 47 26 C 38 30 28 34 20 40 Z\",\n H,\n ) +\n path(\n \"M 84 36 C 84 22 92 12 98 8 L 102 20 L 122 18 L 118 32 L 130 44 L 112 50 C 104 54 96 54 88 52 Z\",\n H,\n ) +\n path(\n \"M 14 44 C 20 34 36 28 52 28 C 66 28 78 32 84 36 L 86 50 C 82 54 78 56 76 56 C 66 58 54 58 44 54 L 38 46 L 36 52 L 14 68 C 12 60 12 50 14 44 Z\",\n H,\n ) +\n flat(\n \"M 80 66 C 84 78 86 92 84 108 C 92 100 96 88 94 74 Z M 4 26 L 14 26 L 18 40 Z M 108 36 L 128 44 L 112 50 Z\",\n SHADE,\n ) +\n path(\n \"M 80 38 C 82 46 88 52 96 54 L 98 50 C 92 48 88 44 86 36 Z\",\n \"#80c898\",\n { width: 2.6 },\n ),\n },\n \"hair_under/11\": {\n svg:\n path(\n \"M 3 42 C 2 22 18 5 46 4 C 70 3 88 14 92 30 L 93 48 L 85 48 L 83 70 L 74 84 C 72 70 70 56 67 40 C 54 36 42 32 30 26 L 27 22 C 20 30 12 38 3 42 Z\",\n H,\n ) +\n flat(\n \"M 70 46 L 92 44 L 93 48 L 85 48 L 83 70 L 74 84 C 73 70 72 58 70 46 Z\",\n SHADE,\n ) +\n line(\"M 64 30 C 66 34 67 38 67 40\", INK, LINE),\n },\n // hair_12 — two locks falling either side.\n \"hair/12\": {\n svg:\n path(\n \"M 101.6 15.4 Q 101.2 14.8 93.7 20.3 Q 86.3 25.8 81.6 30.4 Q 77 35 75.5 38.5 Q 74 41.9 74.4 46.4 Q 74.9 50.9 77.3 56.2 Q 79.7 61.4 81.7 64.1 Q 83.8 66.7 86 68.4 Q 88.1 70 89 69.9 Q 89.8 69.8 90.9 67.3 Q 92.1 64.8 94.6 63 Q 97.1 61.3 103.1 61.7 Q 109.1 62.2 109.7 61.9 Q 110.3 61.6 113.3 57.1 Q 116.3 52.6 120.2 44.7 Q 124.2 36.8 127.1 27.9 Q 130.1 19.1 130.1 18.1 Q 130.1 17.2 129.6 17.1 Q 129.1 17 123.2 21.2 Q 117.3 25.4 109.7 29 Q 102.1 32.6 101.6 28 Q 101.2 23.5 101.7 19.7 Q 102.1 16 101.6 15.4 Z M 6.2 4.1 Q 5.3 4 5.1 4.2 Q 4.8 4.5 4.3 8.5 Q 3.8 12.5 3.8 23.4 Q 3.8 34.3 4.3 37.1 Q 4.9 39.8 6.8 44.2 Q 8.7 48.5 10.2 50.6 Q 11.8 52.7 13.5 53.9 Q 15.2 55.1 15.1 55.1 Q 15.1 55.1 17.8 46.9 Q 20.5 38.7 25.2 33.4 Q 29.8 28.1 32.3 26.5 Q 34.8 24.9 34.9 24 Q 35 23.1 30.5 16.7 Q 26.1 10.3 25 10.3 Q 24 10.3 21.9 13.4 Q 19.8 16.6 13.5 10.4 Q 7.2 4.2 6.2 4.1 Z\",\n H,\n { holes: true },\n ) +\n path(\n \"M 74 36 C 82 26 92 18 100 14 L 102 30 C 112 26 120 20 128 15 C 124 28 118 48 108 60 L 96 58 C 92 62 90 66 88 70 C 82 64 76 56 74 48 Z\",\n H,\n ) +\n flat(\n \"M 6 10 C 8 24 10 40 14 56 L 8 44 C 5 34 4 20 6 10 Z M 88 60 C 100 52 114 40 126 20 C 122 34 116 50 108 60 L 96 58 C 92 62 90 66 88 70 Z\",\n SHADE,\n ),\n },\n \"hair_under/12\": {\n svg:\n path(\n \"M 99.6 11.6 Q 98.3 11.1 93.2 12 Q 88.1 12.9 85.3 13.8 Q 82.6 14.7 81.8 15.5 Q 80.9 16.4 80.9 23.8 Q 80.8 31.3 82.3 37 Q 83.8 42.7 86.3 46.7 Q 88.9 50.8 93.2 54 Q 97.5 57.3 101.7 58.7 Q 105.9 60.1 111.1 60.1 Q 116.3 60.1 116.5 59.8 Q 116.7 59.5 113.7 54.6 Q 110.6 49.7 114.8 47.8 Q 118.9 46 120.7 44.1 Q 122.5 42.2 122.3 41.9 Q 122.1 41.5 118 39.3 Q 113.9 37.2 113.4 34.9 Q 112.9 32.6 109.6 31.7 Q 106.4 30.8 106.2 27.4 Q 106.1 24 104.7 19.7 Q 103.2 15.4 102.1 13.7 Q 100.9 12 99.6 11.6 Z M 32.2 6 Q 32.3 6.9 31.2 7.9 Q 30.1 9 26 11.2 Q 21.8 13.5 21 14.7 Q 20.1 15.9 16.3 26.5 Q 12.5 37.1 10.2 41.1 Q 8 45.1 6 47.2 Q 4 49.2 4.1 49.8 Q 4.2 50.5 5.4 50.8 Q 6.6 51.2 10.2 50.6 Q 13.8 50.1 15.8 49.1 Q 17.8 48.1 19.9 45.6 Q 21.9 43.1 24 37.5 Q 26.1 32 26.7 23.6 Q 27.4 15.2 29.7 10.1 Q 32 5.1 32.2 6 Z\",\n H,\n { holes: true },\n ) +\n path(\n \"M 80 15 C 90 12 98 10 104 10 L 106 30 L 112 34 L 122 42 L 112 48 L 116 60 C 104 58 92 52 86 44 C 82 36 80 26 80 15 Z\",\n H,\n ),\n },\n // hair_14 — a jagged side sweep.\n \"hair/14\": {\n svg:\n path(\n \"M 3 52 L 12 18 L 16 25 C 26 14 36 8 46 3 L 51 14 L 83 5 L 88 25 L 110 24 L 96 50 L 88 66 L 75 74 L 74 64 L 71 46 L 30 54 L 6 46 Z\",\n H,\n ) + flat(\"M 76 50 L 104 34 L 96 50 L 88 66 L 75 74 L 74 64 Z\", SHADE),\n },\n \"hair_under/14\": {\n svg:\n path(\n \"M 46.5 3.9 Q 36.9 3.9 29.7 6.3 Q 22.4 8.8 19.3 10.8 Q 16.1 12.9 13.6 15.4 Q 11.1 18 8.1 19.4 Q 5.2 20.9 4.5 22 Q 3.8 23 3.8 25.1 Q 3.8 27.1 3.9 27.2 Q 3.9 27.3 3.6 27.4 Q 3.4 27.5 5.7 24.2 Q 8 20.9 19.4 25.5 Q 30.8 30 51.1 25.5 Q 71.5 21 72.1 22.6 Q 72.8 24.2 71.4 29.7 Q 70 35.1 70.5 36 Q 70.9 36.8 72.8 38.3 Q 74.8 39.8 74.3 45 Q 73.8 50.2 74 50.4 Q 74.1 50.7 74.7 50.7 Q 75.3 50.7 78 48.1 Q 80.8 45.5 85.9 44.5 Q 91 43.5 91.4 42.2 Q 91.7 40.9 89.6 40.4 Q 87.5 39.8 86.8 34.5 Q 86.1 29.1 84.7 25.9 Q 83.3 22.6 78.1 16.8 Q 72.9 10.9 69.3 8.9 Q 65.7 6.8 60.9 5.4 Q 56.1 3.9 46.5 3.9 Z\",\n H,\n { holes: true },\n ) + flat(\"M 74 22 L 86 22 L 90 40 L 78 46 Z\", SHADE),\n },\n // hair_15 — banana-shaped strands, a shadow behind.\n \"hair/15\": {\n svg:\n flat(\n \"M 48 10 C 66 8 82 18 88 40 L 86 52 L 78 54 C 74 46 70 40 64 38 Z\",\n SHADOW,\n 0.25,\n ) +\n path(\n \"M 4 32 C 4 29 8 28 11 30 C 13 32 11 35 8 35 C 5 35 4 34 4 32 Z\",\n H,\n { width: 3 },\n ) +\n path(\n \"M 14 24 C 14 18 18 13 22 14 C 24 16 22 22 20 28 C 18 31 14 30 14 24 Z\",\n H,\n ) +\n path(\n \"M 30 20 C 32 10 38 4 44 4 C 50 5 50 10 46 14 C 42 18 38 24 36 30 C 34 33 30 30 30 20 Z\",\n H,\n ) +\n path(\n \"M 46 28 C 50 20 58 12 66 10 C 72 10 76 13 74 17 C 68 22 60 26 54 32 C 50 34 46 33 46 28 Z\",\n H,\n ) +\n path(\n \"M 66 34 C 72 28 80 26 88 28 C 92 30 92 34 88 36 C 80 37 72 38 68 38 C 65 37 65 36 66 34 Z\",\n H,\n ),\n },\n \"hair_under/15\": {\n svg:\n flat(\n \"M 60 12 C 74 14 84 22 88 34 L 88 46 L 80 48 C 78 40 72 34 66 30 Z\",\n SHADOW,\n 0.25,\n ) +\n path(\n \"M 3 22 C 3 18 6 16 10 18 C 14 10 24 4 40 3 C 60 2 78 6 84 14 C 74 18 70 24 66 30 C 56 26 30 22 3 22 Z\",\n H,\n ) +\n line(\n \"M 4 22 C 2 18 6 15 10 18 C 12 20 10 24 6 24 Z M 18 20 C 16 14 18 10 22 8 M 34 20 C 34 12 40 6 46 4 M 50 22 C 54 14 62 8 70 6 M 66 30 C 72 24 80 22 88 24 M 3 22 C 30 22 56 26 66 30\",\n INK,\n 3.2,\n ),\n },\n \"hair/16\": {\n svg:\n path(\n \"M 39 2.8 C 46 6 53 14 55 24 C 58 28 66 28 74 30 C 84 32 92 40 93.2 50 L 93.2 64 C 92.5 72 90 78 84 82 C 80 84 77 83 76 80 C 75.5 74 76 68 74 64 C 72 60 70 57 66 55 \" +\n \"C 62 53 56 53 50 53 L 24 53.5 C 20 54 17 56 15 58 C 11 62 7 66 3 67.5 C 2.8 62 3 56 4 52 C 6 46 10 42 12 38 C 11.5 30 13 24 17 19 C 23 12 31 6 39 2.8 Z\",\n H,\n ) +\n flat(\n \"M 84 50 C 90 56 92 64 91 72 C 90 76 88 79 84 81 C 86 72 86 60 84 50 Z\",\n SHADE,\n ) +\n line(\n \"M 40 8 C 44 20 42 32 36 44 M 62 34 C 70 36 78 40 84 46 M 66 40 C 72 42 78 46 82 52\",\n \"#e0e0e0\",\n 2.2,\n ),\n },\n \"hair_under/16\": {\n svg:\n path(\n \"M 3 36 C 2.8 26 8 16 18 10 C 28 4 40 2.8 50 2.8 C 64 2.8 76 8 82 16 C 88 24 91 34 91.2 44 C 91 47 88 48 84 47.5 C 82 50 79 51 76 49.5 C 75 44 74 36 70 30 \" +\n \"C 68 26 66 24 62 22 L 58 20.5 L 46 21 C 44 24 40 25 36 24 C 32 24 30 23 28 22 L 16 23 C 12 26 8 31 5 36 C 4.5 37 3.2 37 3 36 Z\",\n H,\n ) +\n flat(\n \"M 80 30 C 86 36 89 42 89.5 46 C 88 47.5 86 47.5 84 47.5 C 82 50 79 50.5 77 49 C 79 42 80 36 80 30 Z\",\n SHADE,\n ) +\n line(\n \"M 50 6 C 56 14 60 20 62 22 M 70 18 C 78 22 84 30 88 38\",\n \"#e0e0e0\",\n 2.2,\n ),\n },\n // hair_17 — waves parted in an M.\n \"hair/17\": {\n svg:\n path(\n \"M 3 50 C 2.8 36 9 22 22 12 C 30 6 40 2.8 50 2.8 C 54 2.8 58 4 61 5 C 64 3.5 70 2.8 76 3.5 C 90 6 99 16 100.5 30 L 101.2 38 C 101 44 99.5 49 96 51.5 \" +\n \"C 92 53 89 54 87.5 56 C 86.5 59 84 59.5 80 59.5 L 76 59.5 C 72 59.5 71.2 56 70.8 50 C 70 42 68 36 64 32 L 47 31.5 C 46 34 44 35 41 35 C 38 35 36 34 35 32 \" +\n \"L 26 32 C 21 33.5 18 37 16.5 42 L 14 50 C 12 54 6 54.5 3 50 Z\",\n H,\n ) +\n flat(\n \"M 78 34 C 84 38 86 44 87 52 C 86.5 57 84 59 80 59.5 C 81 50 81 42 78 34 Z\",\n SHADE,\n ) +\n line(\n \"M 20 18 C 26 16 32 16 36 20 M 61 5 C 62 9 62 13 60 17 M 46 18 C 52 16 60 18 62 22\",\n INK,\n 3,\n ),\n },\n \"hair_under/17\": {\n svg:\n path(\n \"M 2.8 48 C 2.5 36 9 22 22 12 C 34 5 50 2.8 64 2.8 C 82 4 94 14 97 28 C 98 33 98.5 37 98 40 C 97 44 92 45 87 47 C 86.5 50 85 52 81 52 L 74.5 52 C 72 51.5 71.5 49 71.5 45.5 C 70.5 38 68.5 31 65.5 26 L 48 25 C 47 27.5 44.5 28.5 41 28.5 C 37.5 28.5 35.5 27.5 34.5 25 L 26 25 C 21.5 27 18.5 31 17 37 L 14.5 45.5 C 12.5 49 7 50 2.8 48 Z\",\n H,\n ) +\n flat(\n \"M 76 30 C 82 34 86 40 87 47 C 86.5 50 85 52 81 52 L 77 52 C 78 44 78 36 76 30 Z\",\n SHADE,\n ) +\n line(\n \"M 2.6 30 C 2.4 36 2.5 42 2.8 48 C 7 50 12.5 49 14.5 45.5 L 17 37 C 18.5 31 21.5 27 26 25 L 34.5 25 C 35.5 27.5 37.5 28.5 41 28.5 C 44.5 28.5 47 27.5 48 25 L 65.5 26 C 68.5 31 70.5 38 71.5 45.5 C 71.5 49 72 51.5 74.5 52 L 81 52 C 85 52 86.5 50 87 47 C 92 45 97 44 98 40 C 98.5 37 98 34 97.6 31\",\n INK,\n LINE,\n ) +\n line(\n \"M 26 12 C 32 14 36 16 38 18 M 46 14 C 52 12 60 14 64 20 M 54 3 L 56 7\",\n INK,\n 3,\n ),\n },\n // hair_18 — a full shag, ragged ends.\n \"hair/18\": {\n svg:\n path(\n \"M 4 54 C 12 46 18 36 20 24 C 24 10 40 3 56 3 C 58 5 58 8 57 10 C 60 4 70 3 80 4 C 98 6 108 18 112 34 C 114 42 116 48 122 52 L 112 56 L 120 66 L 104 68 L 108 72 L 86 70 L 78 76 L 74 70 L 54 72 L 48 70 L 32 74 L 30 68 L 14 68 L 18 60 L 6 58 Z\",\n H,\n ) +\n flat(\n \"M 14 64 C 40 62 80 64 116 62 L 120 66 L 104 68 L 108 72 L 86 70 L 78 76 L 74 70 L 54 72 L 48 70 L 32 74 L 30 68 L 14 68 Z\",\n SHADE,\n ),\n },\n \"hair_under/18\": {\n svg:\n path(\n \"M 59.5 3.9 Q 49.9 3.9 42.7 6.3 Q 35.4 8.8 32.3 10.8 Q 29.1 12.9 26.6 15.4 Q 24.1 17.9 19.7 20.5 Q 15.4 23 14.6 24.1 Q 13.8 25.3 13.2 28.6 Q 12.6 32 11.3 34.5 Q 9.9 37.1 6.4 40.3 Q 2.9 43.4 2.9 43.4 Q 2.9 43.4 3.5 43.7 Q 4.1 44 7.8 43.8 Q 11.5 43.6 7.8 48.8 Q 4 54.1 4.1 54.9 Q 4.3 55.7 7.3 57.5 Q 10.3 59.2 12 59.6 Q 13.7 60 21.5 58.5 Q 29.3 56.9 29.2 59.6 Q 29 62.3 29.9 62.7 Q 30.7 63.1 35.8 62.1 Q 40.9 61.1 44.7 59.6 Q 48.5 58.1 55.8 60.6 Q 63.1 63.1 72.6 64.5 Q 82.1 65.9 82.7 65.6 Q 83.3 65.2 83.7 63.1 Q 84.1 61 88.4 62.1 Q 92.7 63.2 96 63.2 Q 99.3 63.2 104.5 62.2 Q 109.7 61.2 111.8 60 Q 113.9 58.9 113.9 58.4 Q 114 57.9 111.6 53.3 Q 109.3 48.7 109.6 46.1 Q 109.9 43.5 107.8 42.2 Q 105.7 40.8 103.1 40.4 Q 100.5 39.9 99.8 34.5 Q 99.1 29.1 97.7 25.9 Q 96.3 22.6 91.1 16.8 Q 85.9 10.9 82.3 8.9 Q 78.7 6.8 73.9 5.4 Q 69.1 3.9 59.5 3.9 Z\",\n H,\n { holes: true },\n ) +\n flat(\n \"M 14 52 C 40 50 80 52 108 50 L 110 52 L 96 54 L 98 58 L 80 58 L 74 64 L 70 58 L 52 60 L 46 58 L 32 62 L 30 56 L 14 56 Z\",\n SHADE,\n ),\n },\n // hair_19 — pigtails tied with a red bow.\n \"hair/19\": {\n svg:\n path(\n \"M 43.8 3.4 Q 41.8 3 40.4 3.5 Q 39.1 4 38.3 4.8 Q 37.6 5.6 36.7 7.4 Q 35.9 9.2 35.2 13.3 Q 34.5 17.5 30.8 19.7 Q 27.1 21.9 24 25 Q 21 28.1 19.9 30.2 Q 18.8 32.2 14.7 50.6 Q 10.6 68.9 7.2 77 Q 3.9 85.1 3.4 87.7 Q 3 90.2 4 93.1 Q 5 96 6.3 97.1 Q 7.6 98.3 10 99.1 Q 12.4 100 15.1 99 Q 17.8 98 18.9 96 Q 20.1 93.9 20.1 92 Q 20.1 90.1 19.1 88.1 Q 18.1 86.2 16.1 84.6 Q 14.1 83.1 15.6 76.1 Q 17.1 69.2 17.2 65.7 Q 17.3 62.2 18.4 59.7 Q 19.5 57.1 22.4 57.6 Q 25.3 58 27 57.6 Q 28.6 57.2 31 55.7 Q 33.3 54.2 39.4 55.7 Q 45.5 57.2 47.6 56.7 Q 49.7 56.2 52.6 54.6 Q 55.5 52.9 60.9 56 Q 66.2 59.2 71.2 60 Q 76.2 60.9 78.9 66.5 Q 81.6 72 82.8 78 Q 84 83.9 80.1 84.4 Q 76.3 84.9 75.1 85.7 Q 73.9 86.4 72.8 87.9 Q 71.8 89.4 71.4 91 Q 71 92.5 72.3 96.4 Q 73.6 100.3 74.7 101.1 Q 75.8 101.9 79.7 103 Q 83.5 104.1 87.6 103.2 Q 91.6 102.2 95.4 99.2 Q 99.2 96.2 102.6 97.6 Q 106 99.1 109.6 99.1 Q 113.1 99.2 116.5 97.6 Q 119.8 96.1 122 92.9 Q 124.2 89.7 124.5 87.6 Q 124.9 85.6 124.1 82 Q 123.2 78.4 117.4 71.4 Q 111.6 64.3 104.9 47.4 Q 98.3 30.6 95 26.7 Q 91.8 22.8 88.3 20.5 Q 84.9 18.1 84 14.2 Q 83.2 10.3 82.4 9.2 Q 81.6 8 79.8 7 Q 77.9 5.9 75.4 5.9 Q 72.8 5.8 65.5 8.8 Q 58.2 11.8 54.8 9.2 Q 51.4 6.7 48.6 5.2 Q 45.8 3.8 43.8 3.4 Z\",\n H,\n { holes: true },\n ) +\n flat(\n \"M 8 86 C 12 90 16 90 20 86 C 22 90 20 94 14 94 C 8 94 6 90 8 86 Z M 76 88 C 90 92 110 92 122 84 C 122 92 116 96 108 96 C 100 98 94 96 90 92 C 84 96 76 96 72 92 Z\",\n SHADE,\n ) +\n line(\n \"M 26 40 C 26 46 28 50 30 54 M 52 38 C 54 44 54 50 52 54 M 76 52 L 76 64 M 94 62 C 94 72 96 80 100 86\",\n INK,\n 3,\n ) +\n path(\"M 36 4 L 58 12 L 80 4 L 82 18 C 70 22 46 22 34 16 Z\", \"#e83a40\", {\n width: 3,\n }),\n },\n \"hair_under/19\": {\n svg:\n path(\n \"M 14 22 C 20 10 36 2.8 58 2.8 C 80 2.8 94 12 97.5 28 C 99 34 102 38 106 42 C 108 46 108.5 52 110 56 C 113 60 114.2 64 114.2 70 C 114 76 110 80 104 81 \" +\n \"C 100 83 94 84 88 82.5 C 82 82.5 76 82 72 79 C 70 75 70 70 73 66 C 76 64 78.5 62 79 58 L 78 46 C 74 42 66 40 60 40 L 58 37 L 54 37 L 49 38 L 47 41.5 \" +\n \"L 41 41.5 L 39 38 L 31 38 L 29 40 L 22 41 L 19 40 L 17 46 C 17 52 18 58 18.5 64 C 21 68 21 74 19 78 C 16 82 8 82 4.5 78 C 2.8 74 2.8 70 3.5 66 \" +\n \"C 5 56 8 44 10.5 36 C 11 30 12 26 14 22 Z\",\n H,\n ) +\n flat(\n \"M 4 74 C 8 78 14 78 19 74 C 19 79 15 82 10 82 C 6 82 4 78 4 74 Z M 72 74 C 84 80 100 80 113 72 C 112 78 108 81 102 82 C 96 84 90 84 86 82.5 C 80 83 74 81 72 74 Z\",\n SHADE,\n ) +\n line(\n \"M 26 22 C 26 28 28 32 30 36 M 52 20 C 54 26 54 32 52 36 M 94 42 C 96 52 100 58 106 62 M 88 82.5 C 88 78 90 74 92 72\",\n INK,\n 3,\n ),\n },\n // hair_20 — slicked back with a notch at the temple.\n \"hair/20\": {\n svg:\n path(\n \"M 58.9 3.4 Q 53.7 2.9 46 4.4 Q 38.3 5.8 30.6 10.2 Q 23 14.6 20.1 15.2 Q 17.2 15.8 14.2 17.4 Q 11.1 19 9 21 Q 7 23.1 5.9 25.2 Q 4.8 27.3 3.8 31 Q 2.8 34.7 2.9 38.5 Q 2.9 42.2 2.8 42.2 Q 2.7 42.1 10.9 40.7 Q 19 39.3 20.9 34.1 Q 22.8 29 24.6 30.8 Q 26.5 32.7 27.2 35.3 Q 27.9 37.9 30.2 37.5 Q 32.4 37.2 49.2 37.3 Q 66.1 37.3 67.5 38.6 Q 68.9 39.9 73.3 48 Q 77.7 56.1 77.7 56.1 Q 77.8 56.1 80.3 54.3 Q 82.9 52.4 87 51.6 Q 91.1 50.8 91.6 50.2 Q 92.2 49.6 93.2 41.6 Q 94.2 33.5 93.7 29.8 Q 93.1 26.1 91.6 22.6 Q 90 19 87 16 Q 84 13 79.9 11.2 Q 75.7 9.5 73.2 7.6 Q 70.6 5.8 67.3 4.8 Q 64 3.9 58.9 3.4 Z\",\n H,\n { holes: true },\n ) +\n flat(\n \"M 72 42 L 90 38 L 90 42 L 82 44 L 86 52 L 76 56 C 74 50 73 46 72 42 Z\",\n SHADE,\n ),\n },\n \"hair_under/20\": {\n svg:\n path(\n \"M 2 36 C 6 24 14 14 30 8 C 46 2 66 2 78 8 C 88 14 92 24 92 32 L 92 38 L 82 40 L 86 46 L 78 49.5 C 76 46 74 42 72 38 L 70 20 L 68 32 L 28 32 L 26 20 L 22 34 Z\",\n H,\n ) +\n flat(\n \"M 74 38 L 92 34 L 92 38 L 82 40 L 86 46 L 78 49.5 C 76 46 75 42 74 38 Z\",\n SHADE,\n ) +\n line(\n \"M 3 26 L 2 36 L 22 34 L 26 20 L 28 32 L 68 32 L 70 20 M 72 38 C 74 42 76 46 78 49.5 L 86 46 L 82 40 L 92 38 M 24 6 C 28 5 32 4 36 4\",\n INK,\n LINE,\n ),\n },\n // hair_21 — curls in swirls.\n \"hair/21\": {\n svg:\n path(\n \"M 60.3 3.4 Q 56.7 2.9 53.9 3.4 Q 51.2 3.9 49.4 4.8 Q 47.6 5.8 45.9 5.3 Q 44.2 4.8 39.7 4.9 Q 35.1 4.9 31 7.3 Q 26.9 9.6 24.5 9.8 Q 22.1 9.9 20.1 10.9 Q 18.2 11.9 15.9 14.7 Q 13.7 17.5 11.8 22.4 Q 9.9 27.4 8 28.2 Q 6.2 29 5 30.7 Q 3.8 32.4 3.3 34.4 Q 2.9 36.4 3.9 39.6 Q 5 42.8 5.9 43.5 Q 6.9 44.1 8.5 43.7 Q 10.2 43.3 12.2 40 Q 14.2 36.6 14.9 34.1 Q 15.7 31.6 19 31.9 Q 22.4 32.2 24.5 31.7 Q 26.7 31.2 32 27.7 Q 37.3 24.2 41.6 26.1 Q 45.9 28 50 27.6 Q 54.1 27.2 57.6 31.2 Q 61.2 35.1 63.8 36.7 Q 66.3 38.2 70.1 39.3 Q 73.9 40.3 73.3 44.3 Q 72.8 48.4 73.8 52 Q 74.8 55.6 76.5 57.8 Q 78.2 60.1 79.1 60.5 Q 79.9 61 80.5 60.9 Q 81 60.8 81 60.9 Q 80.9 60.9 80.6 59.1 Q 80.2 57.3 82 54.2 Q 83.8 51 87.5 49.7 Q 91.3 48.3 93.3 48.2 Q 95.4 48.1 96.3 46.9 Q 97.2 45.6 97.6 43.7 Q 98 41.8 96.7 38.2 Q 95.3 34.5 93.7 32.7 Q 92.1 30.8 92.6 29.1 Q 93.1 27.5 92.7 26 Q 92.3 24.5 89.1 20.8 Q 85.9 17 81.9 15.5 Q 77.9 14 76.5 12.1 Q 75.2 10.3 73.1 8.7 Q 71.1 7.1 67.5 5.5 Q 63.9 3.9 60.3 3.4 Z\",\n H,\n { holes: true },\n ) +\n flat(\n \"M 74 44 C 80 46 86 46 90 46 C 92 52 90 56 86 58 L 80 62 C 77 56 75 50 74 44 Z\",\n SHADE,\n ) +\n line(\n \"M 10 34 C 8 30 12 26 15 28 C 18 30 15 34 12 33 M 22 26 C 24 22 30 22 30 26 C 30 30 26 30 25 28 M 44 20 C 46 16 52 16 52 20 C 52 24 48 24 47 22 M 66 26 C 68 22 74 22 74 26 C 74 30 70 30 69 28\",\n INK,\n 2.8,\n ),\n },\n \"hair_under/21\": {\n svg:\n path(\n \"M 3 34 C 2.8 24 10 14 22 8 C 32 3.5 44 2.8 52 2.8 C 70 2.8 86 12 90 24 C 91 30 91 34 91 37 L 97.5 39.5 C 96 44 92 48 88 50 C 86 53 84 56 81 56.5 \" +\n \"C 77 56 74 50 73 44 C 72 40 70 37 66 35 C 62 33 58 30 56 28 C 54 26 52 24.5 50 24.5 L 44 23 C 40 22.5 36 23 33 25 C 30 27 26 28.5 20 28.5 L 16 29 \" +\n \"C 14 33 12 37 9 39.5 C 6 40 3.2 38 3 34 Z\",\n H,\n ) +\n flat(\n \"M 74 40 C 82 42 88 44 92 46 L 96 41 C 95 45 92 48 88 50 C 86 53 84 56 81 56.5 C 77 56 75 48 74 40 Z\",\n SHADE,\n ) +\n line(\n \"M 10 28 C 8 24 12 20 15 22 C 18 24 15 28 12 27 M 22 18 C 24 14 30 14 30 18 C 30 22 26 22 25 20 M 44 13 C 46 9 52 9 52 13 C 52 17 48 17 47 15 M 66 18 C 68 14 74 14 74 18 C 74 22 70 22 69 20\",\n INK,\n 2.8,\n ),\n },\n // hair_22 — a quiff with a notch, a shadow under the back.\n \"hair/22\": {\n svg:\n flat(\"M 72 50 L 88 50 L 86 60 L 76 68 Z\", SHADOW, 0.25) +\n path(\n \"M 3 48 C 4 34 6 20 10 12 C 30 4 56 2 74 4 C 82 6 86 10 88 12 L 82 18 L 92 20 C 100 24 106 28 111 34 C 104 38 96 40 86 40 L 68 41 L 72 50 C 64 46 54 44 46 44 C 34 44 22 42 14 38 Z\",\n H,\n ) +\n flat(\n \"M 6 30 C 30 34 70 34 108 32 L 111 34 C 104 38 96 40 86 40 L 68 41 L 72 50 C 64 46 54 44 46 44 C 34 44 22 42 14 38 L 3 48 C 4 42 5 36 6 30 Z\",\n SHADE,\n ),\n },\n \"hair_under/22\": {\n svg:\n flat(\"M 70 32 L 86 30 C 87 38 86 44 82 48 L 72 52 Z\", SHADOW, 0.25) +\n path(\n \"M 3 22 C 8 12 24 4 44 2 C 62 2 78 8 84 20 C 88 28 88 34 86 38 L 70 32 L 64 18 C 56 22 44 26 30 26 C 22 26 16 22 12 18 L 4 30 Z\",\n H,\n ) +\n flat(\"M 66 18 L 84 20 C 88 28 88 34 86 38 L 70 32 Z\", SHADE) +\n line(\n \"M 3 30 L 4 22 L 12 18 C 16 22 22 26 30 26 C 44 26 56 22 64 18 L 70 32 L 86 30\",\n INK,\n LINE,\n ),\n },\n // hair_23 — a pageboy cut with a straight fringe.\n \"hair/23\": {\n svg:\n path(\n \"M 4 4 C 40 4 80 6 110 12 C 116 16 118 22 116 28 L 106 74 L 82 82 L 76 66 L 80 52 L 74 54 C 60 56 40 56 30 54 L 18 50 L 12 64 C 8 56 6 40 4 4 Z\",\n H,\n ) +\n line(\n \"M 8 7 C 40 12 80 18 112 24 M 32 12 L 32 18 M 76 20 L 76 27 M 100 23 L 100 28\",\n STRAND,\n 2.4,\n ) +\n line(\"M 84 62 L 104 62 M 82 70 L 102 70\", SHADE, 2.4),\n },\n \"hair_under/23\": {\n svg:\n path(\n \"M 46.5 3.9 Q 36.9 3.9 29.7 6.3 Q 22.4 8.8 19.3 10.8 Q 16.1 12.9 13.6 15.4 Q 11.1 18 8.1 19.4 Q 5.2 20.9 4 22.5 Q 2.8 24.1 2.9 28 Q 2.9 31.9 3.2 32.3 Q 3.5 32.8 4.8 31.8 Q 6.1 30.8 8.9 25.3 Q 11.7 19.8 17.5 23 Q 23.3 26.2 26.5 27.2 Q 29.7 28.2 40.4 27.2 Q 51.2 26.2 60 24.7 Q 68.7 23.1 70.1 23.8 Q 71.5 24.4 72.2 29 Q 72.8 33.7 71.4 38 Q 70 42.4 70.9 44.9 Q 71.7 47.5 72.3 48.1 Q 72.9 48.8 73.5 48.9 Q 74.1 49 76.5 47.7 Q 79 46.4 83.8 45.3 Q 88.5 44.2 89.2 43.6 Q 89.9 43 90.3 41.9 Q 90.6 40.8 89.1 40.2 Q 87.5 39.7 86.8 34.4 Q 86.1 29.1 84.7 25.9 Q 83.3 22.6 78.1 16.8 Q 72.9 10.9 69.3 8.9 Q 65.7 6.8 60.9 5.4 Q 56.1 3.9 46.5 3.9 Z\",\n H,\n { holes: true },\n ) + line(\"M 76 32 L 88 32 M 74 38 L 88 38\", SHADE, 2.4),\n },\n // hair_24 — wild and spiky.\n \"hair/24\": {\n svg:\n path(\n \"M 62 3.9 Q 56.6 2.9 53.4 3.4 Q 50.1 3.9 46.9 5.4 Q 43.8 6.9 38.6 6 Q 33.5 5.1 31.9 7.3 Q 30.4 9.5 26.4 10.2 Q 22.4 10.8 20.3 12.4 Q 18.2 14 18.1 14.4 Q 18 14.8 19.1 16 Q 20.2 17.2 17 21.8 Q 13.8 26.4 11.6 33.8 Q 9.5 41.2 6.8 44.9 Q 4.1 48.7 4.2 48.8 Q 4.3 49 6.7 49.7 Q 9.2 50.4 7.6 55.2 Q 5.9 59.9 5.9 60.8 Q 5.9 61.6 9.8 61.8 Q 13.6 61.9 13.7 66 Q 13.8 70.1 14 70.4 Q 14.1 70.7 14 70.7 Q 13.9 70.7 15 68.8 Q 16.1 66.8 17.6 65.6 Q 19.1 64.5 19.2 58.9 Q 19.3 53.3 21 48.8 Q 22.7 44.3 24.4 43.5 Q 26.1 42.7 28.1 38.3 Q 30 33.9 31.9 32.2 Q 33.7 30.5 36 29.9 Q 38.3 29.2 40.1 29.3 Q 41.8 29.3 44.1 30.5 Q 46.3 31.6 49 33.6 Q 51.7 35.7 54.3 33.9 Q 57 32.2 60.9 32.3 Q 64.9 32.3 68.1 34.3 Q 71.3 36.2 74 40.6 Q 76.6 45 76.9 48.1 Q 77.2 51.1 77.6 51.5 Q 78.1 51.9 79.3 51.5 Q 80.5 51.1 82.4 51.6 Q 84.3 52.1 85.5 55.7 Q 86.7 59.3 86.8 63.1 Q 86.9 66.8 87.3 67.5 Q 87.6 68.1 87.7 68.1 Q 87.8 68.1 89.1 67 Q 90.5 66 92.3 67 Q 94.1 68.1 94.9 68.1 Q 95.8 68.1 98.6 65.9 Q 101.5 63.8 104.5 66.5 Q 107.5 69.2 108.7 69.5 Q 109.9 69.7 110.5 65.5 Q 111.1 61.4 115.5 61.2 Q 119.9 61.1 121.4 60.3 Q 122.9 59.4 122.9 59.2 Q 122.8 58.9 118.1 54.7 Q 113.4 50.4 116.5 49.8 Q 119.6 49.2 121 48.3 Q 122.3 47.3 122.5 45.9 Q 122.8 44.6 118.8 42.3 Q 114.8 40.1 114 34.2 Q 113.1 28.2 112.1 26.2 Q 111.1 24.1 107.8 21.2 Q 104.5 18.3 104.1 16.5 Q 103.8 14.7 98.3 13.6 Q 92.8 12.6 87.8 8.3 Q 82.8 4 75.1 4.4 Q 67.4 4.8 62 3.9 Z\",\n H,\n { holes: true },\n ) +\n flat(\n \"M 82 40 L 108 38 L 122 46 L 108 50 L 116 60 L 104 58 L 108 70 L 94 62 L 88 70 L 82 60 C 82 52 82 46 82 40 Z M 6 50 L 22 48 L 24 58 L 16 54 L 12 68 L 10 56 Z\",\n SHADE,\n ),\n },\n \"hair_under/24\": {\n svg:\n path(\n \"M 63.5 3.9 Q 53.9 3.9 47.6 5.9 Q 41.4 7.8 37.3 10.3 Q 33.1 12.9 30.6 15.4 Q 28.1 18 26.4 18.9 Q 24.8 19.8 24.2 23.5 Q 23.6 27.2 20.8 30.1 Q 18.1 33 11.1 36.3 Q 4.1 39.7 4.1 40.4 Q 4.2 41.1 6.6 43.1 Q 9 45 7.5 48 Q 5.9 51.1 5.9 51.8 Q 5.9 52.6 9.8 52.8 Q 13.6 52.9 13.7 57 Q 13.8 61.1 14 61.4 Q 14.1 61.7 14 61.7 Q 13.9 61.7 15 59.8 Q 16.1 57.8 17.6 56.6 Q 19.1 55.5 19.2 49.9 Q 19.3 44.3 21 39.8 Q 22.7 35.3 24.4 34.5 Q 26.1 33.7 28.1 29.3 Q 30 24.9 31.9 23.2 Q 33.7 21.5 36 20.9 Q 38.3 20.2 40.1 20.3 Q 41.8 20.3 44.1 21.5 Q 46.3 22.6 49 24.6 Q 51.7 26.7 54.3 24.9 Q 57 23.2 60.9 23.3 Q 64.9 23.3 68.1 25.3 Q 71.3 27.2 74 31.6 Q 76.6 36 76.9 39.1 Q 77.2 42.1 77.6 42.5 Q 78.1 42.9 79.3 42.5 Q 80.5 42.1 82.4 42.6 Q 84.3 43.1 85.5 46.7 Q 86.7 50.3 86.8 54.1 Q 86.9 57.8 87.2 58.3 Q 87.4 58.7 89.5 57.9 Q 91.7 57.1 92.9 58 Q 94.1 59 96.4 58.6 Q 98.7 58.2 101.9 60.2 Q 105.1 62.1 106.4 62.1 Q 107.6 62.1 107.9 61.8 Q 108.2 61.5 108.6 58.4 Q 108.9 55.2 111.5 55.6 Q 114 56 115.9 55 Q 117.7 53.9 117.7 53.4 Q 117.7 52.8 114.4 49.9 Q 111.2 47.1 111.9 44.8 Q 112.7 42.5 111.2 41.7 Q 109.8 40.8 107.1 40.4 Q 104.5 39.9 103.8 34.5 Q 103.1 29.1 101.7 25.9 Q 100.3 22.6 95.1 16.8 Q 89.9 10.9 86.3 8.9 Q 82.7 6.8 77.9 5.4 Q 73.1 3.9 63.5 3.9 Z\",\n H,\n { holes: true },\n ) +\n flat(\n \"M 84 36 L 106 34 L 112 46 L 118 44 L 114 52 L 120 56 L 104 60 L 92 60 L 86 52 Z M 6 44 L 22 42 L 14 50 L 12 62 L 8 52 L 4 52 Z\",\n SHADE,\n ),\n },\n // hair_25 — a bowl cut under a purple headband.\n \"hair/25\": {\n svg:\n path(\n \"M 16 84 C 4 70 2 50 8 36 C 18 14 40 4 68 3 C 96 2 120 14 128 34 C 134 48 132 70 124 82 C 120 90 116 94 114 96 L 106 86 C 98 70 80 56 60 52 C 46 50 30 54 20 62 L 18 84 Z\",\n H,\n ) +\n flat(\n \"M 6 66 C 10 74 14 80 18 84 L 20 62 C 14 64 10 64 6 66 Z M 104 62 C 116 64 126 60 132 56 C 132 70 128 80 124 84 C 120 90 116 94 114 96 L 106 86 C 104 78 104 70 104 62 Z\",\n SHADE,\n ) +\n path(\n \"M 20 62 C 30 54 46 50 60 52 C 80 56 98 70 106 86 L 100 88 C 92 74 76 66 60 64 C 46 62 32 66 20 74 Z\",\n \"#c050c0\",\n { width: 3 },\n ),\n },\n \"hair_under/25\": {\n svg:\n path(\n \"M 58.5 3.9 Q 48.9 3.9 41.7 6.3 Q 34.4 8.8 31.3 10.8 Q 28.1 12.9 25.6 15.4 Q 23.1 17.9 18.9 20.4 Q 14.7 22.8 13.6 26.5 Q 12.5 30.2 8.1 35.8 Q 3.8 41.4 3.5 42.8 Q 3.2 44.2 4.2 46.2 Q 5.3 48.2 6.2 48.6 Q 7.1 49.1 8.5 49.1 Q 10 49.2 11.1 48.5 Q 12.1 47.8 12.6 46.8 Q 13.1 45.9 13.8 39.2 Q 14.4 32.6 16.6 31.1 Q 18.8 29.5 25.1 27.8 Q 31.3 26.2 42 26.2 Q 52.7 26.2 60.3 27.8 Q 68 29.4 73.6 31.5 Q 79.2 33.5 87.7 38.8 Q 96.2 44.1 100.3 44.2 Q 104.3 44.4 107 48.2 Q 109.7 52 109.8 57.6 Q 109.9 63.2 110.6 63.4 Q 111.3 63.7 112.6 62.8 Q 113.9 61.9 114.4 59.3 Q 115 56.7 112.7 50.3 Q 110.4 43.9 107.6 42.4 Q 104.8 40.8 102.1 40.4 Q 99.5 39.9 98.8 34.5 Q 98.1 29.1 96.7 25.9 Q 95.3 22.6 90.1 16.8 Q 84.9 10.9 81.3 8.9 Q 77.7 6.8 72.9 5.4 Q 68.1 3.9 58.5 3.9 Z\",\n H,\n { holes: true },\n ) +\n path(\n \"M 104 44 C 108 48 110 54 110 62 L 112 64 C 114 56 114 48 110 42 Z\",\n H,\n { width: 3 },\n ) +\n flat(\n \"M 20 16 C 40 4 70 2 90 8 C 100 14 104 22 106 32 C 98 22 86 14 72 12 C 54 10 34 12 20 16 Z\",\n SHADE,\n ) +\n path(\n \"M 14 22 C 30 12 60 8 80 12 C 92 16 100 26 106 36 L 100 46 C 90 38 76 30 60 26 C 44 24 26 28 14 34 Z\",\n \"#c050c0\",\n { width: 3 },\n ),\n },\n // hair_26 — a crown of spikes.\n \"hair/26\": {\n svg:\n path(\n \"M 32.7 3.3 Q 32.2 3.2 27.7 7.9 Q 23.2 12.7 20.8 8.9 Q 18.5 5.1 18.3 5.1 Q 18.1 5.1 14.5 11.5 Q 10.8 17.8 8.3 15 Q 5.8 12.2 5.5 12.3 Q 5.1 12.4 4 20.9 Q 2.8 29.5 2.8 33.3 Q 2.8 37.2 3.3 38.6 Q 3.8 40 8.2 39.6 Q 12.6 39.2 14.3 43.5 Q 15.9 47.8 17.8 48.9 Q 19.6 50 19.5 50 Q 19.4 50.1 20.7 46.5 Q 22 43 24 41.1 Q 26 39.2 28.7 39.6 Q 31.3 39.9 33.8 39.1 Q 36.2 38.2 52.1 38.3 Q 68 38.3 70.2 40 Q 72.5 41.6 73.7 48.5 Q 74.8 55.5 74.9 59.6 Q 74.9 63.7 75 63.8 Q 75.2 64 79.1 60.6 Q 83 57.3 87.6 57.7 Q 92.2 58.2 92.6 58 Q 93 57.8 96 46.2 Q 99.1 34.7 99.2 34.8 Q 99.2 35 96.3 35.1 Q 93.3 35.3 93.3 28.1 Q 93.2 20.9 93 20.5 Q 92.8 20.1 92.4 20.1 Q 91.9 20 87.6 22.2 Q 83.2 24.4 82.1 19 Q 81 13.5 79.9 13.3 Q 78.8 13 75.1 14.6 Q 71.4 16.2 69.8 11.4 Q 68.3 6.6 68 6.3 Q 67.8 6.1 66 6.5 Q 64.2 6.8 58.8 9.6 Q 53.3 12.4 52.3 8.5 Q 51.3 4.6 50.9 4.2 Q 50.6 3.9 50.4 3.9 Q 50.1 3.9 44.3 7 Q 38.6 10 37.2 9.4 Q 35.8 8.7 34.5 6.1 Q 33.2 3.5 32.7 3.3 Z\",\n H,\n { holes: true },\n ) +\n flat(\"M 76 40 L 94 40 L 94 44 L 86 46 L 90 56 L 76 62 Z\", SHADE) +\n line(\"M 14 36 C 18 32 24 32 28 34\", INK, 3),\n },\n \"hair_under/26\": {\n svg:\n path(\n \"M 45.5 3.9 Q 35.9 3.9 28.7 6.3 Q 21.4 8.8 18.3 10.8 Q 15.1 12.9 12.6 15.4 Q 10.1 18 7.1 19.4 Q 4.2 20.9 3.6 21.9 Q 2.9 22.8 3.5 24.9 Q 4 27 8.3 26.6 Q 12.6 26.2 14.3 30.5 Q 15.9 34.8 17.8 35.9 Q 19.6 37 19.5 37 Q 19.4 37.1 20.7 33.5 Q 22 30 24 28.1 Q 26 26.2 28.7 26.6 Q 31.3 26.9 33.8 26.1 Q 36.2 25.2 52.1 25.3 Q 68 25.3 70.2 27 Q 72.5 28.6 73.7 35.5 Q 74.8 42.5 74.9 46.6 Q 74.9 50.7 75 50.8 Q 75.2 51 79.1 47.6 Q 83 44.3 87.6 44.7 Q 92.2 45.2 92.6 44.9 Q 93 44.7 93.4 43.5 Q 93.8 42.3 92.7 41.6 Q 91.7 40.8 89.1 40.4 Q 86.5 39.9 85.8 34.5 Q 85.1 29.1 83.7 25.9 Q 82.3 22.6 77.1 16.8 Q 71.9 10.9 68.3 8.9 Q 64.7 6.8 59.9 5.4 Q 55.1 3.9 45.5 3.9 Z\",\n H,\n { holes: true },\n ) + flat(\"M 76 28 L 88 28 L 88 38 L 94 42 L 88 48 L 78 52.5 Z\", SHADE),\n },\n // hair_27 — tufts: a lock at the temple, a cap on top, a curl behind.\n \"hair/27\": {\n svg:\n path(\n \"M 48.2 3.4 Q 43.7 2.9 39.6 3.9 Q 35.4 4.8 31.2 8 Q 26.9 11.2 25.9 13.2 Q 24.8 15.2 24.4 17.4 Q 24 19.6 24.4 21.6 Q 24.8 23.5 25.4 24.3 Q 26.1 25 27.4 25.6 Q 28.7 26.1 30.6 25.7 Q 32.6 25.2 35.1 26.5 Q 37.6 27.8 40.5 26.5 Q 43.5 25.3 46.9 26.6 Q 50.2 27.9 52.2 26.8 Q 54.1 25.7 56.1 20.8 Q 58.1 15.9 58.6 13.6 Q 59 11.4 58.6 9.5 Q 58.3 7.7 55.5 5.8 Q 52.7 3.9 48.2 3.4 Z M 79.3 8.9 Q 76.6 8.9 76.8 8.7 Q 76.9 8.5 79.9 10.7 Q 82.9 12.8 84.4 16 Q 85.8 19.1 85.7 22.5 Q 85.7 25.8 84.8 27.5 Q 83.9 29.2 80.6 31.5 Q 77.3 33.8 76.6 34.9 Q 75.9 36.1 75.9 39.1 Q 75.9 42.1 76.8 44.7 Q 77.7 47.3 79.2 48.5 Q 80.7 49.6 84.9 47.5 Q 89.2 45.3 92.8 45.2 Q 96.5 45.1 97.8 43.4 Q 99.2 41.6 99.7 38.6 Q 100.2 35.5 99.7 32.8 Q 99.1 30.1 97.2 25.3 Q 95.3 20.5 92.4 16.7 Q 89.6 12.9 88.1 11.8 Q 86.5 10.7 84.3 9.8 Q 82 8.9 79.3 8.9 Z M 13.9 20.3 Q 13.9 20.2 11.4 22.2 Q 8.9 24.2 6.4 29.2 Q 3.9 34.2 3.4 37.7 Q 3 41.3 3.4 42.9 Q 3.8 44.6 4.6 45.8 Q 5.4 46.9 6.1 46.8 Q 6.7 46.7 8.9 42.8 Q 11.1 38.9 11.2 33.1 Q 11.3 27.3 12.7 23.8 Q 14 20.3 14 20.3 Q 14 20.3 13.9 20.3 Z\",\n H,\n { holes: true },\n ) +\n flat(\"M 80 40 C 86 38 92 36 96 36 C 96 44 90 48 82 48 L 78 46 Z\", SHADE),\n },\n \"hair_under/27\": {\n svg:\n path(\n \"M 48.8 3.9 Q 42.9 3.9 36.7 5.8 Q 30.4 7.8 28.7 9 Q 26.9 10.2 25.9 12.2 Q 24.8 14.2 24.4 16.4 Q 24 18.6 24.4 20.6 Q 24.8 22.5 25.4 23.3 Q 26.1 24 27.4 24.6 Q 28.7 25.1 30.6 24.7 Q 32.6 24.2 35.1 25.5 Q 37.6 26.8 40.5 25.5 Q 43.5 24.3 46.9 25.6 Q 50.2 26.9 52.2 25.8 Q 54.1 24.7 56.1 19.8 Q 58.1 14.9 58.6 12.6 Q 59 10.4 58.6 8.5 Q 58.2 6.6 56.5 5.3 Q 54.8 3.9 48.8 3.9 Z M 76.4 8.9 Q 77 8.9 76.8 8.2 Q 76.6 7.4 79.7 9.6 Q 82.9 11.8 84.4 15 Q 85.8 18.1 85.7 21.5 Q 85.7 24.8 84.8 26.5 Q 83.9 28.2 80.6 30.5 Q 77.3 32.8 76.6 33.9 Q 75.9 35.1 75.9 38.1 Q 75.9 41.1 76.8 43.7 Q 77.7 46.3 79.2 47.5 Q 80.7 48.6 84.9 46.5 Q 89.2 44.3 92.9 44.2 Q 96.7 44.1 97.3 43.5 Q 98 42.9 98.4 42 Q 98.8 41.1 98.6 40.9 Q 98.5 40.8 96 40.4 Q 93.5 39.9 92.8 34.5 Q 92.1 29.1 90.7 25.9 Q 89.3 22.6 84.1 16.7 Q 78.8 10.9 77.3 9.9 Q 75.8 8.9 76.4 8.9 Z M 13.3 20 Q 13 20 11.6 20.9 Q 10.2 21.8 8.7 23.9 Q 7.1 25.9 5.5 30 Q 3.9 34.1 3.4 37.2 Q 3 40.3 3.4 41.9 Q 3.8 43.6 4.6 44.8 Q 5.4 45.9 6.1 45.8 Q 6.7 45.7 8.9 41.8 Q 11.1 37.9 11.2 32.1 Q 11.3 26.3 12.4 23.2 Q 13.5 20.1 13.3 20 Z\",\n H,\n { holes: true },\n ) +\n flat(\"M 80 40 C 86 38 92 36 96 36 C 96 44 90 48 82 48 L 78 46 Z\", SHADE),\n },\n // hair_28 — swept into a swoosh.\n \"hair/28\": {\n svg:\n path(\n \"M 77.7 3.9 Q 71.7 3 66.9 3.9 Q 62.1 4.9 58.3 6.4 Q 54.5 7.9 50.3 6.4 Q 46.1 4.9 42.5 4.9 Q 38.9 4.9 34.7 6.3 Q 30.4 7.8 27.3 9.8 Q 24.3 11.8 21 16.1 Q 17.8 20.4 17.3 22.6 Q 16.8 24.7 16.8 30.7 Q 16.8 36.7 15.2 44.5 Q 13.5 52.2 11.8 54.1 Q 10.1 56 6.8 57.4 Q 3.5 58.8 3.4 59.2 Q 3.3 59.5 7.2 61.8 Q 11.2 64.1 13.7 64.4 Q 16.3 64.6 16.7 64.1 Q 17.2 63.6 18.3 57.8 Q 19.4 52.1 21 47.4 Q 22.5 42.8 25.3 37.3 Q 28.1 31.8 31.6 29 Q 35.2 26.2 37.7 26.8 Q 40.1 27.5 41.7 28.5 Q 43.2 29.6 44.6 28.3 Q 46 27 49.3 25.6 Q 52.6 24.3 56.3 26.2 Q 60.1 28 64 32.5 Q 67.9 36.9 72.9 44.9 Q 77.9 52.9 83.5 60 Q 89.1 67.1 92.4 74.7 Q 95.8 82.4 95.9 82.4 Q 96.1 82.5 97.1 80.6 Q 98.2 78.8 99.5 70.7 Q 100.9 62.6 106.7 60.9 Q 112.6 59.2 117.2 55.9 Q 121.9 52.6 121.9 52.7 Q 121.9 52.7 118.4 52.2 Q 115 51.6 111.4 49.3 Q 107.9 47.1 108.5 45.5 Q 109.2 43.9 114.3 41.1 Q 119.5 38.3 119.6 37.9 Q 119.7 37.4 116.8 35.7 Q 113.9 33.9 111.5 31.5 Q 109.1 29.2 104.6 22.7 Q 100.1 16.2 96.6 13.1 Q 93 10 88.4 7.4 Q 83.8 4.9 77.7 3.9 Z\",\n H,\n { holes: true },\n ) +\n flat(\n \"M 76 40 C 90 46 104 46 120 40 L 108 46 L 122 52 L 104 58 L 102 68 L 96 82 C 92 70 86 56 76 40 Z M 6 56 L 18 52 L 18 62 C 14 66 8 64 3 58 Z\",\n SHADE,\n ),\n },\n \"hair_under/28\": {\n svg:\n path(\n \"M 63.1 3.9 Q 53.9 3.9 46.7 6.3 Q 39.4 8.8 36.3 10.8 Q 33.1 12.9 30.6 15.4 Q 28.1 18 24 20.2 Q 20 22.4 18.4 25.3 Q 16.8 28.2 15.2 36.7 Q 13.5 45.2 11.8 47.1 Q 10.1 49 6.8 50.4 Q 3.5 51.8 3.4 52.2 Q 3.3 52.5 7.2 54.8 Q 11.2 57.1 13.7 57.4 Q 16.3 57.6 16.7 57.1 Q 17.2 56.6 18.3 50.8 Q 19.4 45.1 21 40.4 Q 22.5 35.8 25.3 30.3 Q 28.1 24.8 31.6 22 Q 35.2 19.2 37.7 19.8 Q 40.1 20.5 41.7 21.5 Q 43.2 22.6 44.6 21.3 Q 46 20 49.3 18.6 Q 52.6 17.3 56.3 19.2 Q 60.1 21 64 25.5 Q 67.9 29.9 72.9 37.9 Q 77.9 45.9 83.5 53 Q 89.1 60.1 92.3 67.6 Q 95.6 75.2 95.7 75.3 Q 95.8 75.4 97 73.5 Q 98.2 71.7 99.7 63.3 Q 101.2 54.8 106.8 56.5 Q 112.4 58.1 114.8 57.2 Q 117.2 56.4 117.5 56 Q 117.8 55.5 113.6 49.8 Q 109.4 44 109.3 42.5 Q 109.2 41 106.8 40.4 Q 104.5 39.8 103.8 34.5 Q 103.1 29.1 101.7 25.9 Q 100.3 22.6 95.1 16.8 Q 89.9 10.9 86.3 8.9 Q 82.7 6.8 77.5 5.3 Q 72.3 3.8 63.1 3.9 Z\",\n H,\n { holes: true },\n ) +\n flat(\n \"M 74 34 C 88 42 100 42 112 44 L 116 50 L 104 54 L 98 62 L 94 76 C 90 64 84 50 74 34 Z\",\n SHADE,\n ),\n },\n // hair_29 — long straight hair with strand bangs, a yellow crown of curls on top.\n \"hair/29\": {\n svg:\n path(\n \"M 29 18 C 28 10 32 5 37 5 C 40 5 42 6 44 8 C 46 4 50 2.8 55 2.8 C 60 2.8 64 4 66 7 C 70 6.5 74 7 76 9 C 80 7.5 84 9 85 12 C 87 14 87 18 86 22 L 30 22 Z\",\n \"#f8e090\",\n ) +\n path(\n \"M 6.5 107.5 C 4.5 106 3.5 103 3.5 98 L 2.8 60 C 3 44 7 32 16 24 C 24 18 36 14.5 50 14.5 C 66 14.5 80 18 88 26 C 93 32 95 40 95 50 L 95.2 62 C 95 65 92 66.5 86 66.5 \" +\n \"C 83.5 67 83 70 83 74 L 82.5 96 C 82 102 80 106 77.5 107.5 C 75.5 106 74 102 73.5 96 L 72.5 72 C 72 66 70.5 60 68 56.5 L 66 53 C 65 54.5 63 54.5 59 54 \" +\n \"C 57 52 56.5 48 55 40 C 54 48 54 52 52.5 54 L 43 54 C 41.5 52 41 46 39 39 C 37.5 46 37 51 35 53.5 L 23 53.5 C 21.5 51 21 46 21 40 C 19.5 46 18 51 15 54 \" +\n \"C 12.5 56 11 58 10.5 62 L 9.5 98 C 9.3 103 8.5 106 6.5 107.5 Z\",\n H,\n ) +\n flat(\n \"M 84 40 C 90 46 94 52 95 58 L 95 62 C 94 65 90 66 86 66.5 C 85 58 85 48 84 40 Z\",\n SHADE,\n ) +\n line(\n \"M 9 66 C 10 54 14 42 20 32 M 77 74 C 78.5 82 79 90 79 98\",\n INK,\n 2.6,\n ),\n },\n \"hair_under/29\": {\n svg:\n path(\n \"M 3.5 86 L 3 40 C 3 26 6 16 14 10 C 22 4.5 34 2.8 50 2.8 C 66 2.8 80 8 86 18 C 89 24 89.5 32 89.5 38 L 95.2 39.5 L 95.2 44.5 L 88 46 C 84 47 83 50 82.5 56 \" +\n \"L 82 74 C 81 80 79 86 76.5 87.5 C 75 86 74.5 82 74 76 L 71 36 L 67 34.5 L 65 33 L 64.5 33.5 L 58 33 L 56 28 L 54.5 33 L 52 34.5 L 43 34.5 L 41.5 28 \" +\n \"L 39 21 L 36 30 L 34.5 31.5 L 23 31.5 L 21 26 L 20.5 21 L 19 24 L 14 32 C 12 36 11.5 40 10.5 46 L 9 84 C 8.5 87 7.5 88 7 88 C 5 88 3.8 87 3.5 86 Z\",\n H,\n ) +\n flat(\n \"M 80 30 C 88 32 89.5 36 89.5 38 L 95 40 L 95 44 L 88 46 C 84 47 83 50 82.5 56 L 82 70 C 80 56 80 42 80 30 Z\",\n SHADE,\n ),\n },\n // hair_30 — long, swept to one side.\n \"hair/30\": {\n svg:\n path(\n \"M 11 103 C 6 100 3 92 2.8 80 L 2.8 60 C 3 36 14 18 32 9 C 44 3.5 56 2.8 66 2.8 C 84 3 100 14 108 30 C 113 40 115.2 52 115.2 66 L 115 76 C 114 92 108 104 98 108 \" +\n \"C 92 110 84 110.5 78 110 C 79 104 81.5 98 81.5 90 L 81 76 C 80.5 64 78 52 73 45 L 16 45 C 14.5 47 14 50 14 54 L 13.5 96 C 13.3 100 12.5 102 11 103 Z\",\n H,\n ) +\n flat(\n \"M 90 80 C 98 82 106 80 113 76 C 112 90 106 100 98 106 L 80 109 C 84 100 88 90 90 80 Z\",\n SHADE,\n ) +\n line(\n \"M 88 32 C 94 44 96 60 96 78 M 100 38 C 104 50 106 64 106 80\",\n \"#e0e0e0\",\n 2.4,\n ) +\n line(\n \"M 24 15 C 22 20 22 24 24 28 M 45 36 L 45 44 M 64 36 L 64 44\",\n INK,\n 2.4,\n ),\n },\n \"hair_under/30\": {\n svg:\n path(\n \"M 57.5 3.9 Q 47.9 3.9 40.7 6.3 Q 33.4 8.8 30.3 10.8 Q 27.1 12.9 24.6 15.4 Q 22.1 18 19.2 19.4 Q 16.4 20.8 14.4 25.1 Q 12.4 29.3 10.1 32.4 Q 7.7 35.5 5.8 40.8 Q 3.9 46 3.4 50.8 Q 2.8 55.5 3.8 63.7 Q 4.9 71.9 6.3 75.7 Q 7.7 79.5 9.9 82.3 Q 12.2 85.1 13 85.5 Q 13.9 86 13.9 86 Q 13.8 86 13 80.8 Q 12.2 75.6 12.2 54.9 Q 12.2 34.2 13.9 29.6 Q 15.7 25 19.6 24.1 Q 23.5 23.2 25.8 23.6 Q 28.2 24 29.2 23.6 Q 30.1 23.2 38.2 23.2 Q 46.2 23.2 47.7 22.7 Q 49.3 22.2 61.1 22.8 Q 72.9 23.3 74.9 24.5 Q 76.9 25.7 79.2 30.9 Q 81.6 36.1 83.7 45.7 Q 85.8 55.3 85.7 67 Q 85.7 78.6 85.1 80.9 Q 84.5 83.1 81.7 87.2 Q 78.9 91.2 78.9 91.1 Q 78.9 91.1 81.7 92.1 Q 84.6 93.1 86.1 92.7 Q 87.7 92.2 90.8 90.6 Q 94 89 97 86.3 Q 100.1 83.6 105.5 76.9 Q 110.9 70.2 111.9 66.3 Q 113 62.4 112.1 58.8 Q 111.2 55.3 108.8 51.2 Q 106.5 47.1 105.7 44.6 Q 104.9 42 104.2 41.4 Q 103.6 40.8 101 40.4 Q 98.5 39.9 97.8 34.5 Q 97.1 29.1 95.5 25.6 Q 94 22 88.9 16.5 Q 83.9 10.9 80.3 8.9 Q 76.7 6.8 71.9 5.4 Q 67.1 3.9 57.5 3.9 Z\",\n H,\n { holes: true },\n ) +\n flat(\n \"M 86 66 C 96 68 104 66 108 62 C 108 72 104 82 96 88 L 80 92 C 84 84 86 74 86 66 Z\",\n SHADE,\n ) +\n line(\n \"M 82 22 C 88 34 90 48 90 62 M 92 28 C 96 40 98 52 98 66\",\n \"#e0e0e0\",\n 2.4,\n ),\n },\n // The long hair down the back (hair/N_back, worn with hair/N).\n \"hair/20_back\": {\n svg:\n path(LONG_20, H, { stroke: \"none\" }) +\n clipped(\n \"hair_back_20\",\n LONG_20,\n flat(\"M 0 36 C 20 30 46 30 70 36 L 70 95 L 0 95 Z\", SHADE),\n ) +\n path(LONG_20, \"none\"),\n },\n \"hair/21_back\": {\n svg:\n path(LONG_21, H, { stroke: \"none\" }) +\n clipped(\n \"hair_back_21\",\n LONG_21,\n flat(\n \"M 0 28 C 20 20 40 24 50 22 C 66 20 80 26 96 34 L 96 110 L 0 110 Z\",\n SHADE,\n ),\n ) +\n path(LONG_21, \"none\") +\n line(\n \"M 30 80 L 32 88 M 52 82 L 52 90 M 82 62 C 76 62 74 56 78 54 C 82 52 84 56 81 58\",\n INK,\n 2.6,\n ),\n },\n \"hair/28_back\": {\n svg:\n path(LONG_28, SHADE, { stroke: \"none\" }) +\n clipped(\n \"hair_back_28\",\n LONG_28,\n flat(\"M 60 0 C 72 20 84 40 90 66 L 96 66 L 96 0 Z\", H),\n ) +\n path(LONG_28, \"none\"),\n },\n \"hair/29_back\": {\n svg:\n path(LONG_29, H, { stroke: \"none\" }) +\n clipped(\n \"hair_back_29\",\n LONG_29,\n flat(\"M 36 56 C 56 56 70 66 76 80 L 76 112 L 36 112 Z\", SHADE),\n ) +\n path(LONG_29, \"none\") +\n line(\n \"M 50 104 L 50 98 M 58 104 L 58 94 M 66 104 L 66 99\",\n \"#b8b8b8\",\n 2.2,\n ),\n },\n};\n"
216
+ },
217
+ {
218
+ "path": "character/packs/base/headwear.ts",
219
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport {\n circle,\n clipped,\n ellipse,\n flat,\n group,\n INK,\n line,\n path,\n rect,\n tone,\n} from \"../../art/svg\";\nimport { dashed, star } from \"./shapes\";\n\n// Helmets and hats (coloured by themselves). Some come in two or three images: the front (hat/N), the\n// low front under the hair line (hat/N_low) and the part behind the head (hat/N_back), each on its slot.\n\nconst SHELL_1 =\n \"M 7 54 C 6 26 28 3 60 2.5 C 92 2 116 24 115.5 56 C 115 76 109 92 97 97 L 84 98 C 82 92 81 86 81 80 \" +\n \"C 86 76 86 60 74 56 L 9 55 Z\";\n\nconst CROWN_2 =\n \"M 28 48 C 30 30 34 16 40 9 C 56 3 82 1 104 5 C 112 7 118 11 120 14 C 124 24 128 36 129 50 Z\";\nconst BRIM_2 =\n \"M 4 63 C 2 58 6 55 14 53 C 22 51 26 49 30 46 C 42 40 56 36 66 36 C 80 37 96 42 110 46 C 120 48 128 50 132 51 \" +\n \"C 140 54 148 58 151 63 C 152 67 150 70 146 70 C 128 68 112 64 98 59 C 84 54 70 50 60 50 C 48 50 36 55 26 59 C 18 62 10 66 4 63 Z\";\nconst BRIM_12 =\n \"M 3 26 C 14 26 26 27 40 28 C 70 32 96 37 110 40 C 120 42 128 44 133 47 L 132 58 C 132 63 128 65 124 64 \" +\n \"C 116 62 110 63 108 63 C 92 58 70 54 50 52 C 38 51 30 50 26 49 C 18 42 10 34 3 26 Z\";\nconst CROWN_15 =\n \"M 28 64 C 30 40 32 22 36 12 C 46 4 66 2 86 3 C 104 4 118 12 125 22 C 126 40 124 58 122 76 L 100 72 C 80 64 60 60 40 64 Z\";\nconst BRIM_15 =\n \"M 3 66 C 10 63 20 63 28 64 C 40 62 54 60 64 62 C 76 64 90 70 100 74 L 120 78 C 128 80 136 84 142 88 \" +\n \"C 138 94 128 98 120 98 C 110 96 104 90 96 86 C 82 78 66 74 50 76 C 36 78 22 84 10 82 C 4 80 2 74 3 66 Z\";\n\nconst TURBAN =\n \"M 4 56 C 2 46 6 40 12 38 C 16 22 34 6 60 3 C 86 1 104 10 112 26 C 116 34 116 42 114 48 C 118 52 120 58 120 66 C 120 74 116 78 110 78 \" +\n \"C 96 76 76 68 54 58 C 42 54 28 58 20 62 C 12 66 6 64 4 56 Z\";\n\n/** A pirate's tricorne: gold trim along the crown, a skull on the front. */\nconst tricorne = (\n id: string,\n c: string,\n dark: string,\n bone: string,\n eye: string,\n) => {\n const HAT =\n \"M 4 48 C 2 42 4 40 10 40 C 22 38 32 30 40 20 C 50 8 64 4 80 4 C 96 4 110 10 118 22 C 122 28 122 36 122 44 C 124 46 130 48 132 50 \" +\n \"L 130 64 C 130 70 126 72 122 70 C 104 62 72 58 46 60 C 30 62 18 64 10 64 C 6 64 4 60 4 54 Z\";\n return (\n path(HAT, c, { stroke: \"none\" }) +\n clipped(\n id,\n HAT,\n flat(\n \"M 92 6 C 108 10 120 18 124 34 L 124 50 C 116 38 108 24 92 6 Z\",\n dark,\n ),\n flat(\"M 0 52 C 40 50 90 52 136 58 L 136 80 L 0 80 Z\", dark),\n line(\n \"M 8 44 C 20 42 30 36 40 26 C 50 12 64 7 80 7 C 96 7 108 12 116 24\",\n \"#f0a028\",\n 3.4,\n ),\n ) +\n path(HAT, \"none\") +\n path(\n \"M 48 26 C 48 20 54 16 62 16 C 70 16 76 20 76 26 C 76 32 72 34 70 35 L 70 40 L 54 40 L 54 35 C 50 34 48 32 48 26 Z\",\n bone,\n { stroke: \"none\" },\n ) +\n ellipse(57, 26, 2.6, 3.6, eye, { stroke: \"none\" }) +\n ellipse(67, 26, 2.6, 3.6, eye, { stroke: \"none\" })\n );\n};\n\n/** A baseball cap: a white front panel, coloured side panels and peak. */\nconst cap = (id: string, side: string, peak: string) => {\n const CROWN =\n \"M 22 52 C 22 30 32 14 50 8 C 64 4 76 4 88 5 C 98 6 104 6 108 8 C 114 18 118 34 118 52 L 118 74 L 96 64 Z\";\n return (\n path(\"M 72 5 C 74 1 82 0 85 4 Z\", side, { width: 2.6 }) +\n path(CROWN, \"#e0dceb\", { stroke: \"none\" }) +\n clipped(\n id,\n CROWN,\n flat(\"M 88 5 C 92 20 94 40 96 64 L 120 76 L 120 0 Z\", side),\n line(\"M 88 5 C 92 20 94 40 96 64\", tone(side, -0.3), 2.4),\n ) +\n path(CROWN, \"none\") +\n path(\n \"M 2 58 C 8 50 20 46 34 46 C 56 46 80 54 96 64 C 92 68 86 70 80 68 C 64 62 44 56 26 58 C 16 60 8 62 2 58 Z\",\n peak,\n ) +\n line(\"M 20 52 C 36 50 56 52 74 58\", tone(peak, -0.25), 2.4)\n );\n};\n\n/** A full-face racing helmet: a stripe over the top, two accent wedges, an open visor (the face shows through). */\nconst racer = (id: string, c: string, stripe: string, accent: string) => {\n const SHELL =\n \"M 4 70 C 2.8 50 8 30 22 16 C 34 6 46 2.8 58 2.8 C 80 2.8 98 12 108 28 C 113 38 114.2 50 114.2 62 C 114.2 78 112 92 106 100 \" +\n \"C 98 110 80 116 56 116.2 C 36 116 18 110 10 100 C 5 92 4 82 4 70 Z\";\n const HOLE =\n \"M 13.2 57 C 22 60 40 61.5 60 61 C 70 60.5 78 59.5 81.3 59.5 L 81.3 77 C 78 82 66 88 54 91.5 C 46 93 38 92.5 32 90 C 24 86 18 82 13.2 76 Z\";\n const RIM =\n \"M 10.5 54.5 C 22 57.5 40 59 60 58.5 C 70 58 79 57 84 57 L 84 77.5 C 80 84 67 90.5 54 94 C 45 96 37 95.5 30 92.5 C 21 88.5 15 84 10.5 78 Z\";\n const FRAME =\n \"M 7 51 C 22 54 42 55.5 62 55 C 72 54.5 82 53.5 88 53.5 L 88 78.5 C 84 87 69 95 54 98 C 44 100 34 99 26 95.5 C 17 91.5 10 86 7 80 Z\";\n const ink = tone(c, -0.45);\n return (\n path(`${SHELL} ${HOLE}`, c, { stroke: \"none\", holes: true }) +\n clipped(\n id,\n SHELL,\n path(\n \"M 21 26 C 24 20 28 14 34 9 L 26 34 Z M 72 14 C 82 10 92 12 98 18 L 101 32 C 92 34 82 38 74 44 Z\",\n accent,\n { stroke: ink, width: 2.4 },\n ),\n path(\"M 56 1 L 72 1 L 52 55 L 38 55 Z\", stripe, {\n stroke: ink,\n width: 2.4,\n }),\n flat(\"M 0 98 C 30 108 80 110 116 90 L 116 120 L 0 120 Z\", tone(c, -0.2)),\n line(\n \"M 4 98 C 30 108 80 110 114 90 M 10 106 C 34 114 76 114 106 100\",\n INK,\n 2.6,\n ),\n ) +\n path(`${SHELL} ${HOLE}`, \"none\", { holes: true }) +\n path(`${FRAME} ${HOLE}`, stripe, { holes: true, width: 3 }) +\n path(`${RIM} ${HOLE}`, \"#7a7a7c\", { holes: true, stroke: \"none\" }) +\n path(HOLE, \"none\", { width: 2.6 })\n );\n};\n\n/** A builder's hard hat: a ridge over the top, a raised panel on the side. */\nconst hardHat = (id: string, c: string) => {\n const HAT =\n \"M 4 66 C 2 58 8 54 16 52 C 20 32 30 14 44 8 C 56 4 66 2 72 3 C 82 2 90 6 96 10 C 110 18 118 32 120 46 C 122 58 124 66 124 74 \" +\n \"C 110 72 90 66 70 62 C 50 58 30 58 16 62 C 10 64 6 66 4 66 Z\";\n const ridge = tone(c, -0.22);\n return (\n path(HAT, c, { stroke: \"none\" }) +\n clipped(\n id,\n HAT,\n flat(\n \"M 74 4 C 96 8 112 24 118 44 L 126 80 L 96 80 C 96 60 90 30 74 4 Z\",\n tone(c, -0.08),\n ),\n line(\n \"M 70 4 C 70 16 72 26 76 34 M 20 48 C 50 50 90 54 124 58 M 80 32 C 82 20 90 18 98 22 C 108 28 114 40 116 56\",\n ridge,\n 3,\n ),\n ) +\n path(HAT, \"none\")\n );\n};\n\n/** A batting helmet with an ear flap, a \"V\" on the front. */\nconst battingHelmet = (c: string, letter: string, rim: string) =>\n path(\n \"M 80 62 C 82 56 90 54 98 56 C 108 60 116 70 116 82 C 116 94 110 102 100 102 C 92 102 84 98 82 90 C 80 80 80 70 80 62 Z\",\n c,\n ) +\n ellipse(101, 82, 3.5, 6.5, \"none\", { stroke: tone(c, -0.35), width: 2.6 }) +\n path(\n \"M 4 54 C 6 50 10 48 14 46 C 16 24 34 6 60 4 C 86 2 104 18 108 40 C 110 48 112 56 112 62 L 86 62 C 64 58 40 58 22 60 C 12 62 6 60 4 54 Z\",\n c,\n ) +\n flat(\n \"M 18 40 C 40 36 70 36 92 40 L 92 48 C 70 46 40 46 18 50 Z\",\n tone(c, -0.15),\n ) +\n line(\"M 16 50 C 40 46 70 44 90 46\", tone(c, -0.35), 2.6) +\n path(\"M 30 24 L 38 24 L 44 38 L 52 24 L 60 24 L 48 46 L 40 46 Z\", letter, {\n stroke: rim,\n width: 2.2,\n });\n\n/** A batting helmet's far ear flap. */\nconst earFlap = (c: string, rim: string) =>\n ellipse(16.5, 26.5, 13, 23.5, c, { width: 3 }) +\n path(\"M 10 6 C 5 14 5 38 10 47 C 8 40 7 14 10 6 Z\", rim, { stroke: \"none\" });\n\nconst FUR = \"#dcdcea\";\n\n/** A Santa hat: a floppy cone, a fur brim and pompom. */\nconst santaHat = (id: string, c: string) => {\n const CONE =\n \"M 10 54 C 20 34 36 14 56 6 C 72 2 90 4 100 14 C 108 22 112 30 116 38 C 116 46 112 50 110 52 L 110 62 L 14 56 Z\";\n const BRIM =\n \"M 4 62 C 6 54 14 52 24 52 C 60 50 90 54 112 62 C 116 64 118 70 116 78 C 116 86 110 88 104 86 C 80 78 50 72 20 76 C 8 78 2 72 4 62 Z\";\n return (\n path(CONE, c, { stroke: \"none\" }) +\n clipped(\n id,\n CONE,\n flat(\"M 0 40 C 40 30 80 36 120 46 L 120 70 L 0 70 Z\", tone(c, -0.12)),\n ) +\n path(CONE, \"none\") +\n line(\"M 98 24 C 100 34 106 40 114 42\", INK, 2.8) +\n path(\n \"M 114 30 C 116 24 124 22 128 26 C 134 28 136 34 134 40 C 136 46 132 54 126 54 C 122 58 114 56 112 50 C 110 44 110 36 114 30 Z\",\n FUR,\n ) +\n path(BRIM, FUR, { stroke: \"none\" }) +\n clipped(\n `${id}_brim`,\n BRIM,\n flat(\"M 0 76 C 40 70 80 76 120 84 L 120 92 L 0 92 Z\", \"#b8b8cc\"),\n ) +\n path(BRIM, \"none\") +\n line(\n \"M 12 64 L 14 68 M 40 56 L 42 58 M 58 66 L 62 68 M 84 60 L 86 62 M 104 70 L 106 74 M 120 36 L 122 38 M 124 46 L 125 49\",\n \"#9a9ab8\",\n 2.6,\n )\n );\n};\n\n/** A leather aviator cap: a dashed seam, long ear flaps. */\nconst aviatorCap = (id: string, c: string, seam: string) => {\n const CAP =\n \"M 4 50 C 2 30 12 12 36 6 C 60 0 88 2 102 14 C 110 22 112 36 110 52 C 110 58 108 62 106 64 L 104 74 L 96 68 L 94 80 C 92 92 92 104 90 110 L 76 110 \" +\n \"C 74 96 74 80 74 66 C 74 54 70 50 64 50 L 18 52 C 14 54 12 58 12 66 L 12 102 C 10 108 6 108 4 104 Z\";\n return (\n path(CAP, c, { stroke: \"none\" }) +\n clipped(\n id,\n CAP,\n flat(\"M 0 82 C 30 80 60 80 114 84 L 114 116 L 0 116 Z\", tone(c, -0.1)),\n dashed(\n \"M 6 34 C 30 26 60 24 80 30 C 90 34 100 42 108 52\",\n seam,\n 4.5,\n \"5 4\",\n ),\n ) +\n path(CAP, \"none\")\n );\n};\n\n/** A metal bucket helmet with a chin strap. */\nconst bucketHelmet = (id: string, c: string, strap: string, buckle: string) => {\n const BUCKET =\n \"M 4 60 L 26 12 C 40 4 62 3 82 5 C 98 7 108 14 112 26 L 110 74 L 4 60 Z\";\n return (\n line(\n \"M 8 68 C 2 86 4 102 18 112 C 30 118 60 118 76 112 C 86 106 92 92 98 70\",\n INK,\n 7,\n ) +\n line(\n \"M 8 68 C 2 86 4 102 18 112 C 30 118 60 118 76 112 C 86 106 92 92 98 70\",\n strap,\n 3.4,\n ) +\n rect(22, 113, 24, 11, 4, buckle, { width: 3 }) +\n path(BUCKET, c, { stroke: \"none\" }) +\n clipped(\n id,\n BUCKET,\n flat(\"M 40 0 L 52 0 L 28 60 L 16 60 Z\", tone(c, 0.12)),\n flat(\n \"M 86 0 L 120 0 L 120 80 L 100 76 C 104 50 98 20 86 0 Z\",\n tone(c, -0.15),\n ),\n ) +\n path(BUCKET, \"none\") +\n path(\n \"M 3 60 C 4 56 8 56 12 56 C 40 58 80 64 110 72 C 112 74 112 80 108 82 L 106 84 C 80 76 40 68 8 66 C 4 66 3 64 3 60 Z\",\n tone(c, 0.1),\n )\n );\n};\n\n/** A fish that swallowed the head: the face shows through its mouth. */\nconst fishHat = (\n id: string,\n c: string,\n rim: string,\n fin: string,\n eye: string,\n pupil: string,\n lips: string,\n belly?: string,\n) => {\n const BODY =\n \"M 22 110 C 14 90 12 66 20 48 C 28 30 44 20 60 18 C 76 16 96 22 108 34 C 120 46 124 66 122 86 C 122 100 120 108 112 112 L 92 112 \" +\n \"C 92 96 90 80 84 68 C 76 56 64 52 50 54 C 36 56 26 66 24 80 C 22 92 24 102 26 110 Z\";\n return (\n path(\"M 52 22 C 52 10 58 4 66 6 C 74 8 80 14 82 20 Z\", fin) +\n path(\"M 116 40 C 122 34 130 36 132 44 C 132 50 128 56 124 58 Z\", fin) +\n path(\"M 6 68 C 2 70 2 76 6 80 C 4 84 8 88 14 86 L 18 76 Z\", fin) +\n path(BODY, c, { stroke: \"none\" }) +\n clipped(\n id,\n BODY,\n ...(belly\n ? [flat(\"M 0 54 C 40 60 90 56 124 40 L 124 120 L 0 120 Z\", belly)]\n : []),\n line(\n \"M 24 108 C 18 84 22 64 38 58 C 54 52 74 54 84 66 C 92 78 94 96 94 112\",\n rim,\n 5,\n ),\n ) +\n path(BODY, \"none\") +\n path(\n \"M 102 82 C 108 72 122 68 132 74 C 128 80 128 84 132 88 C 126 94 112 94 102 82 Z\",\n fin,\n ) +\n circle(36, 32, 8, eye) +\n circle(36, 32, 2.4, pupil, { stroke: \"none\" }) +\n circle(74, 36, 10, eye) +\n circle(74, 36, 2.6, pupil, { stroke: \"none\" }) +\n path(\n \"M 42 44 C 46 38 54 38 60 42 C 62 46 60 52 54 52 C 50 54 44 52 42 48 Z\",\n lips,\n { width: 3 },\n ) +\n line(\"M 44 46 L 58 46\", INK, 2.2)\n );\n};\n\n/** A sun visor: see-through glass, a coloured band, a cloth hanging behind. */\nconst visorCap = (id: string, cloth: string, glass: string, band: string) => {\n const GLASS =\n \"M 4 30 C 30 24 70 22 100 30 L 112 46 C 110 56 104 66 98 70 C 70 72 40 72 6 72 Z\";\n return (\n path(\n \"M 22 14 C 40 4 70 2 92 6 C 106 10 114 20 116 34 L 120 50 C 126 56 128 66 124 76 C 120 84 110 86 100 82 L 98 66 Z\",\n cloth,\n ) +\n path(GLASS, glass, { stroke: \"none\", opacity: 0.8 }) +\n clipped(\n id,\n GLASS,\n flat(\"M 26 20 L 40 20 L 52 76 L 38 76 Z\", tone(glass, 0.35)),\n line(\"M 14 42 C 40 36 70 38 96 46\", tone(glass, -0.15), 2.4),\n ) +\n path(GLASS, \"none\") +\n path(\n \"M 6 22 C 40 16 80 18 112 36 L 120 48 C 120 54 116 56 112 52 C 84 32 40 26 6 30 Z\",\n band,\n )\n );\n};\n\n/** The cloth of a visor cap behind the head. */\nconst visorCloth = (c: string, rim: string) =>\n path(\n \"M 24 2.8 C 28 2.5 32 2.8 32.2 5 L 32.2 22 C 32 32 30 42 26 48 C 22 52.5 14 53 9 50 C 4 46 2.8 40 2.8 32 C 2.8 20 6 12 10 9.5 C 14 7.5 18 6.5 21 5 C 22 4 23 3 24 2.8 Z\",\n c,\n ) + flat(\"M 8 14 C 5.5 22 5.5 34 8 44 L 10 44 C 8 34 8 22 10 14 Z\", rim);\n\n/** A cap turned backwards: the strap's opening at the front, the peak behind (hat_back). */\nconst backwardsCap = (id: string, c: string, strap: string) => {\n const CAP =\n \"M 4 46 C 4 26 18 8 42 4 C 66 0 92 10 100 30 C 104 42 104 56 102 68 L 92 64 C 78 46 56 40 34 42 C 22 44 10 46 4 46 Z\";\n return (\n path(CAP, c, { stroke: \"none\" }) +\n clipped(\n id,\n CAP,\n flat(\"M 0 26 C 40 18 80 24 106 40 L 106 72 L 0 72 Z\", tone(c, -0.12)),\n line(\n \"M 22 8 C 22 24 22 36 24 44 M 72 8 C 76 26 78 44 80 58\",\n tone(c, -0.3),\n 2.6,\n ),\n ) +\n path(CAP, \"none\") +\n path(\"M 24 40 C 26 30 36 26 44 26 C 52 26 58 30 60 38 Z\", tone(c, -0.2), {\n width: 3,\n }) +\n rect(24, 34, 36, 8, 2, strap, { width: 3 })\n );\n};\n\n/** The peak of a backwards cap, behind the head. */\nconst capPeak = (c: string) =>\n path(\n \"M 6 10 C 8 4 20 2 34 8 C 50 14 64 26 62 36 C 60 42 48 42 34 36 C 18 30 4 18 6 10 Z\",\n c,\n );\n\n/** A knitted beanie with a ribbed cuff (extra: goggles pushed up on it). */\nconst beanie = (id: string, c: string, extra = \"\") => {\n const HAT =\n \"M 4 50 C 4 42 8 36 14 34 C 24 18 40 10 60 8 C 70 6 80 4 86 4 C 96 2 104 8 108 16 C 112 28 112 40 112 52 L 110 70 C 106 72 100 70 90 66 \" +\n \"C 70 58 40 54 20 56 C 10 56 4 54 4 50 Z\";\n const rib = tone(c, -0.3);\n return (\n path(HAT, c, { stroke: \"none\" }) +\n clipped(\n id,\n HAT,\n flat(\n \"M 0 34 C 40 24 80 26 116 34 L 116 40 C 80 32 40 32 0 40 Z\",\n tone(c, -0.15),\n ),\n line(\n \"M 12 38 C 40 28 80 28 112 36 M 20 40 L 20 56 M 34 36 L 34 56 M 48 34 L 48 57 M 62 35 L 62 59 M 76 37 L 76 62 M 90 40 L 90 66 M 102 42 L 102 70\",\n rib,\n 2.6,\n ),\n ) +\n path(HAT, \"none\") +\n line(\"M 82 8 C 86 12 90 16 94 18\", INK, 2.8) +\n extra\n );\n};\n\n/** A headband with headphones: a padded top, a label, one earcup (the other is hat_back). */\nconst headphones = (band: string, pad: string, label: string) =>\n path(\n \"M 78 4 C 92 6 106 14 112 26 C 115 34 115 42 114 48 L 104 46 C 104 36 100 24 90 14 Z\",\n band,\n ) +\n path(\n \"M 22 14 C 28 8 40 3 58 2.8 C 76 2.8 90 6 98 14 C 102 20 103 30 101 38 L 92 40 C 84 30 72 24 58 22 C 44 20 32 20 22 18 Z\",\n pad,\n ) +\n path(\n \"M 3 37 C 3 32 8 28 18 25 C 36 20 62 19 80 24 C 88 27 93 32 95 38 L 94 47 C 84 48 74 47 64 45 C 52 42 42 39 32 39 C 22 39 14 42 8 45.5 C 4.5 47 2.8 42 3 37 Z\",\n band,\n ) +\n line(\"M 10 37 C 28 30 60 27 92 34\", tone(band, -0.3), 2.4) +\n rect(27, 27.5, 25, 9, 2, label, { stroke: tone(band, -0.4), width: 2.4 }) +\n rect(90, 37, 12, 13, 1, \"#9a9a9a\", { width: 2.6 }) +\n ellipse(101, 70, 18, 21.5, pad) +\n flat(\n \"M 84 76 C 92 82 110 82 118 76 C 116 86 110 90 101 90 C 92 90 86 86 84 76 Z\",\n tone(pad, -0.15),\n ) +\n line(\"M 99 49 C 96 60 96 80 99 91\", tone(pad, -0.3), 2.4);\n\n/** The far earcup. */\nconst earcup = (c: string) =>\n ellipse(18.5, 23, 15, 20, c, { width: 3 }) +\n path(\"M 12 6 C 6 14 6 32 12 40 C 10 32 10 14 12 6 Z\", tone(c, 0.25), {\n stroke: \"none\",\n });\n\n/** An animal hood (boar, pig): pointed ears, a snout, teeth over the brow. */\nconst animalHood = (id: string, c: string, ear: string, extra: string) => {\n const HOOD =\n \"M 3 74 C 6 54 14 40 24 32 C 24 20 26 10 32 4 C 38 8 46 16 52 24 L 72 24 C 76 14 84 6 92 4 C 96 12 98 22 96 30 C 112 40 124 56 130 74 \" +\n \"C 136 86 130 98 122 104 C 120 106 118 106 118 104 C 116 96 112 90 106 86 C 90 76 60 72 30 72 C 20 72 10 74 3 74 Z\";\n return (\n rect(36, 70, 8, 9, 2, \"#fbecb4\", { width: 2.6 }) +\n rect(46, 70, 8, 9, 2, \"#fbecb4\", { width: 2.6 }) +\n path(HOOD, c, { stroke: \"none\" }) +\n clipped(\n id,\n HOOD,\n flat(\"M 0 70 C 40 66 90 72 136 90 L 136 112 L 0 112 Z\", tone(c, -0.15)),\n ) +\n path(HOOD, \"none\") +\n flat(\n \"M 32 16 C 36 20 40 24 42 28 L 32 28 Z M 86 14 C 90 18 90 24 88 28 L 78 28 Z\",\n ear,\n ) +\n extra +\n circle(49, 40, 2.8, INK, { stroke: \"none\" }) +\n circle(61, 40, 2.8, INK, { stroke: \"none\" }) +\n ellipse(50, 57, 14, 9, \"#f0a0a0\", { width: 3 }) +\n line(\"M 45 54 L 45 60 M 55 54 L 55 60\", \"#8a2a30\", 2.6)\n );\n};\n\n/** The back of an animal hood. */\nconst hoodBack = (c: string) =>\n path(\n \"M 4 26 C 4 12 14 6 30 6 C 60 2 100 2 120 6 C 128 8 130 14 130 24 L 130 46 C 130 56 124 60 112 60 L 24 60 C 12 60 4 52 4 40 Z\",\n c,\n );\n\n/** A peaked police cap with a shield badge. */\nconst policeCap = (crown: string, band: string, peak: string) =>\n path(\n \"M 4 20 L 28 4 L 48 10 C 70 10 86 10 96 14 C 112 22 124 34 130 50 L 108 66 L 20 50 C 12 40 6 30 4 20 Z\",\n crown,\n ) +\n flat(\"M 96 16 C 112 24 122 36 128 50 L 108 64 L 90 56 Z\", tone(crown, -0.1)) +\n path(\n \"M 12 50 C 20 48 30 50 40 52 C 70 56 96 62 110 70 C 110 76 104 80 96 78 C 70 72 40 70 20 76 C 12 72 10 60 12 50 Z\",\n peak,\n ) +\n path(\"M 16 44 C 50 48 80 54 110 62 L 108 68 C 80 60 50 54 16 52 Z\", band, {\n width: 2.6,\n }) +\n path(\"M 18 28 L 64 26 L 64 40 L 44 48 L 20 40 Z\", \"#f8a830\") +\n path(\"M 30 28 C 32 34 38 36 42 36 C 48 36 52 32 54 27 Z\", \"#ffd040\", {\n stroke: \"none\",\n }) +\n line(\"M 42 36 L 42 46 M 30 34 L 24 40 M 54 32 L 60 38\", \"#d07018\", 2);\n\n/** A beret with a grinning skull. */\nconst skullBeret = (id: string, c: string) => {\n const BERET =\n \"M 3 42 C 2.8 28 14 12 30 6 C 40 2.8 52 2.8 60 2.8 C 80 4 96 16 101 30 C 103.5 38 103.5 50 103.5 58 C 103.5 64 103 67 102 67.5 C 90 60 72 52 50 49 C 36 47 26 46.5 18 47 C 12 47.5 8 47.5 5 46.5 C 3.5 45.5 3 44 3 42 Z\";\n return (\n path(BERET, c, { stroke: \"none\" }) +\n clipped(\n id,\n BERET,\n flat(\"M 0 38 C 40 32 80 36 110 48 L 110 72 L 0 72 Z\", tone(c, -0.12)),\n ) +\n path(BERET, \"none\") +\n line(\"M 90 50 L 101 60\", INK, 2.6) +\n path(\n \"M 28 16 C 30 10 40 8 46 8 C 54 8 58 12 58 20 C 58 26 56 30 54 32 L 54 36 C 50 40 36 40 32 36 L 32 32 C 28 30 26 24 28 16 Z\",\n \"#f8dcb0\",\n ) +\n ellipse(37, 20, 3.4, 2.6, \"#3a3040\", { stroke: \"none\" }) +\n ellipse(48, 20, 3.4, 2.6, \"#3a3040\", { stroke: \"none\" }) +\n path(\"M 34 28 C 38 32 46 32 50 28 L 48 34 L 36 34 Z\", \"#3a3040\", {\n stroke: \"none\",\n })\n );\n};\n\n/** A beret's ribbon tails, behind. */\nconst ribbonTails = (c: string) =>\n path(\n \"M 6 40 C 2 26 6 10 14 6 C 20 8 22 20 22 30 C 22 40 18 48 12 50 C 8 50 6 46 6 40 Z\",\n c,\n ) +\n path(\"M 16 4 C 28 6 40 20 42 36 C 40 40 36 40 32 36 C 24 28 18 16 16 4 Z\", c);\n\n/** A football helmet: stripes over the shell, a plus on the side, the cage in front of the face. */\nconst footballHelmet = (\n shell: string,\n stripe: string,\n dark: string,\n cage: string,\n) =>\n path(SHELL_1, shell) +\n flat(\n \"M 52 3 L 60 3 C 52 16 44 32 40 48 L 32 48 C 36 32 44 16 52 3 Z M 80 6 L 88 9 C 78 20 70 34 66 50 L 58 49 C 62 32 70 18 80 6 Z\",\n stripe,\n ) +\n flat(\"M 16 22 L 22 30 L 20 20 Z M 92 24 L 104 30 L 98 38 Z\", dark) +\n flat(\n \"M 95 46 L 104 42 L 106 50 L 114 50 L 115 66 L 106 64 L 104 78 L 97 72 L 98 62 L 94 58 Z\",\n stripe,\n ) +\n flat(\n \"M 84 82 C 92 84 104 82 113 76 C 112 86 106 94 97 97 L 84 98 C 82 92 82 86 84 82 Z\",\n dark,\n ) +\n flat(\n \"M 8 47 L 64 49 C 76 50 88 58 90 70 C 90 76 88 80 84 82 C 85 72 82 60 74 56 L 9 55 Z\",\n stripe,\n ) +\n // The rim and the plus painted over the shell's edge: its outline goes on again on top.\n path(SHELL_1, \"none\") +\n path(\n \"M 33 51 C 33 43 36 40 44 40 L 52 40 C 56 40 57 43 57 47 L 57 54 L 33 54 Z\",\n cage === stripe ? stripe : cage,\n ) +\n path(\n \"M 9 78.5 L 78 80 C 83 80 84.5 83 84.5 86 L 84 100 C 84 104 81 106 77 106 L 9 105 C 5 105 3 102 3 98 L 3 85 \" +\n \"C 3 81 5 78.5 9 78.5 Z M 10 86.5 L 22 86.5 L 22 99 L 10 99 Z M 28 87 L 47 87 L 47 100 L 28 100 Z \" +\n \"M 53 88 L 73 88 L 73 100 L 53 100 Z\",\n cage,\n { holes: true },\n );\n\n/** The back of a football helmet. */\nconst footballBack = (c: string) =>\n path(\n \"M 7 3 L 63 6 C 80 7 90 16 91 30 L 90 53 L 14 54 C 6 52 3 45 3 38 C 3 31 6 27 6 20 Z\",\n c,\n );\n\n/** A bucket hat with a badge. */\nconst bucketHat = (\n id: string,\n crown: string,\n crownDark: string,\n badge: string,\n stitch: string,\n brim: string,\n brimLight: string,\n) =>\n path(CROWN_2, crown, { stroke: \"none\" }) +\n clipped(\n id,\n CROWN_2,\n flat(\"M 0 34 C 40 30 90 36 140 40 L 140 60 L 0 60 Z\", crownDark),\n ) +\n path(CROWN_2, \"none\") +\n rect(53, 15, 29, 15, 5, badge, { width: 3 }) +\n line(\"M 44 12 L 50 13.5 M 87 16 L 93 16 M 99 16.5 L 112 14\", stitch, 2.2) +\n path(BRIM_2, brim, { stroke: \"none\" }) +\n clipped(\n `${id}_brim`,\n BRIM_2,\n flat(\n \"M 0 72 C 10 66 20 60 30 56 C 44 50 54 46 62 46 C 76 46 90 52 104 57 C 118 62 134 66 152 64 L 152 80 L 0 80 Z\",\n brimLight,\n ),\n ) +\n path(BRIM_2, \"none\");\n\n/** A gold crown over a cushion. */\nconst crownHat = (cushion: string, cushionDark: string) =>\n path(\n \"M 6 64 C 4 44 12 30 24 28 C 34 26 44 25 52 26 C 62 25 70 27 78 32 C 96 34 110 44 112 60 C 113 70 110 76 104 76 L 10 76 Z\",\n cushion,\n ) +\n flat(\n \"M 8 46 C 16 44 22 46 28 48 L 28 58 L 8 60 Z M 52 44 C 60 42 68 42 74 44 L 72 58 L 52 58 Z M 82 50 C 96 50 106 54 111 58 L 110 72 L 92 68 Z\",\n cushionDark,\n ) +\n path(\n \"M 3 68 L 10 64 L 17 34 C 18 30 22 30 23 35 L 25 56 L 31 57 L 39 9 C 40 2 45 2 46 8 L 52 58 L 72 58 L 75 37 \" +\n \"L 90 66 C 96 70 102 72 108 74 L 110 82 C 110 88 106 90 102 89 C 70 78 40 74 8 80 C 3 81 2 72 3 68 Z\",\n \"#f0aa42\",\n ) +\n flat(\n \"M 10 65 L 17 36 L 20 64 L 16 78 L 9 79 Z M 39 10 L 42 8 L 44 70 L 36 72 Z\",\n \"#f8d277\",\n ) +\n flat(\n \"M 75 40 L 90 66 C 96 70 102 72 108 74 L 110 82 C 110 88 106 90 102 89 C 94 86 86 84 78 82 Z\",\n \"#dd8f30\",\n );\n\n/** A hunter's hat with a feather in the band. */\nconst featherHat = (\n id: string,\n hat: string,\n hatDark: string,\n feather: string,\n quill: string,\n) =>\n path(\n \"M 116 40 C 114 28 122 14 140 3 C 142 6 141 12 138 20 C 136 22 134 24 133 25 C 136 28 136 32 134 36 C 128 40 120 42 116 40 Z\",\n feather,\n { width: 3 },\n ) +\n line(\"M 119 38 C 124 26 130 16 138 7\", quill, 2) +\n path(\"M 38 27 C 50 18 72 8 98 5 L 112 20 L 116 40 L 40 28 Z\", hat) +\n path(BRIM_12, hat, { stroke: \"none\" }) +\n clipped(id, BRIM_12, flat(\"M 88 30 L 140 30 L 140 70 L 82 70 Z\", hatDark)) +\n path(BRIM_12, \"none\");\n\n/** A tall hat (patterns on the crown: stars, a band). */\nconst tallHat = (id: string, crown: string, brim: string, pattern: string) =>\n path(CROWN_15, crown, { stroke: \"none\" }) +\n clipped(id, CROWN_15, pattern) +\n path(CROWN_15, \"none\") +\n path(BRIM_15, brim) +\n line(\"M 36 71 C 50 64 62 62 72 64 M 98 80 C 104 84 108 87 110 88\", INK, 2.6);\n\n/** A horned helmet with a riveted band. */\nconst hornedHelmet = (\n dome: string,\n band: string,\n horn: string,\n rivet: string,\n) =>\n path(\n \"M 12 3 C 16 4 19 10 33 16 L 22 42 C 8 38 1 26 3 15 C 4 8 8 3 12 3 Z\",\n horn,\n ) +\n path(\n \"M 17 48 C 22 28 40 10 64 7 C 82 5 95 12 100 18 L 118 50 L 120 62 L 17 62 Z\",\n dome,\n ) +\n path(\n \"M 42 15 C 50 9 58 7 66 8 C 58 18 54 32 53 46 L 35 46 C 36 34 38 22 42 15 Z\",\n band,\n ) +\n path(\n \"M 126 6 C 131 12 131 30 124 40 C 118 46 104 46 97 40 C 90 34 90 24 94 21 C 104 22 114 18 120 8 C 122 5 124 5 126 6 Z\",\n horn,\n ) +\n path(\n \"M 10 49 C 40 42 90 46 122 60 C 125 64 125 74 123 80 C 90 66 40 58 12 64 C 7 62 7 52 10 49 Z\",\n band,\n ) +\n [\n [12.5, 56],\n [32.5, 52.5],\n [60, 53],\n [91, 58],\n [116, 67.5],\n ]\n .map(([x, y]) => circle(x!, y!, 3.8, rivet, { width: 2.6 }))\n .join(\"\");\n\n/** A dinosaur hood: snout over the brow, spikes on the head, the neck down the back. */\nconst dinoHood = (\n id: string,\n c: string,\n spike: string,\n eye: string,\n pupil: string,\n) => {\n const HOOD =\n \"M 6 58 C 3 50 4 38 12 30 C 18 24 28 22 38 24 C 44 18 56 14 68 15 C 82 16 94 22 104 30 C 112 38 118 50 119 66 C 120 80 119 92 118 102 \" +\n \"L 92 103 C 92 90 90 80 86 72 C 80 64 70 62 60 62 L 10 62 Z\";\n return (\n path(\"M 52 22 C 54 12 58 6 62 4 C 66 6 72 10 74 14 Z\", spike) +\n path(\"M 88 20 L 100 17 L 102 28 Z\", spike) +\n path(\"M 113 40 L 118 46 L 116 54 Z\", spike) +\n path(HOOD, c, { stroke: \"none\" }) +\n clipped(\n id,\n HOOD,\n flat(\"M 86 84 C 96 86 108 86 120 82 L 120 106 L 86 106 Z\", tone(c, -0.1)),\n ) +\n path(HOOD, \"none\") +\n line(\"M 8 32 C 20 24 40 24 46 34\", INK, 3) +\n line(\"M 18 38 L 18 46 M 28 38 L 28 46\", pupil, 3.2) +\n circle(86, 46, 11, eye) +\n circle(88, 42, 2.4, pupil, { stroke: \"none\" }) +\n path(\"M 6 62 L 16 62 L 11 71 Z\", \"#f4f2e0\", { width: 2.6 }) +\n path(\"M 20 62 L 32 62 L 26 72 Z\", \"#f4f2e0\", { width: 2.6 })\n );\n};\n\n/** A sweatband with goggles and a snorkel down the side. */\nconst snorkel = (\n id: string,\n band: string,\n frame: string,\n tube: string,\n tubeEnd: string,\n mouth: string,\n) => {\n const BAND =\n \"M 30 30 C 50 20 70 18 86 24 C 104 30 116 44 120 60 C 122 72 120 84 118 90 C 104 76 84 66 66 62 C 54 60 40 58 30 58 Z\";\n const lens =\n \"M 22 38 C 30 36 42 36 50 38 L 50 54 C 44 58 34 58 26 54 Z M 62 42 C 72 40 86 42 96 46 C 96 56 92 62 84 62 C 74 62 66 58 62 50 Z\";\n return (\n line(\"M 40 7 L 12 100\", INK, 11) +\n line(\"M 40 7 L 18 80\", tube, 6) +\n line(\"M 18 80 L 12 100\", tubeEnd, 6) +\n circle(12, 102, 9, mouth) +\n circle(12, 102, 4, tone(mouth, -0.35), { stroke: \"none\" }) +\n path(BAND, band, { stroke: \"none\" }) +\n clipped(\n id,\n BAND,\n line(\"M 40 52 C 70 54 96 64 118 82\", tone(band, -0.15), 2.6),\n ) +\n path(BAND, \"none\") +\n path(lens, \"#c8dcf8\", { stroke: \"none\" }) +\n path(lens, \"none\", { stroke: INK, width: 7 }) +\n path(lens, \"none\", { stroke: frame, width: 4 }) +\n line(\"M 50 44 L 62 46\", INK, 7) +\n line(\"M 50 44 L 62 46\", frame, 4)\n );\n};\n\nexport const headwearArt: Record<string, ArtDef> = {\n // Football helmets: the cage in front of the face, the back behind the head.\n \"hat/1\": {\n svg: footballHelmet(\"#d8292f\", \"#ffd2d0\", \"#b0232a\", \"#ffd2d0\"),\n },\n \"hat/1_back\": { svg: footballBack(\"#6e2027\") },\n // Bucket hats.\n \"hat/2\": {\n svg: bucketHat(\n \"hat_2\",\n \"#8c8475\",\n \"#706a5d\",\n \"#b27a00\",\n \"#6a6458\",\n \"#8c8475\",\n \"#b6b897\",\n ),\n },\n // Crowns over a cushion.\n \"hat/9_low\": {\n svg:\n crownHat(\"#4b3e9e\", \"#3b3083\") +\n ellipse(38, 60, 5, 8, \"#c23fd0\", { width: 2.6 }) +\n flat(\n \"M 37 54 C 39 54 40 56 40 59 L 38 59 C 38 57 37.5 56 37 54 Z\",\n \"#f6b6ff\",\n ),\n },\n // Hunter's hats with a feather.\n \"hat/12_low\": {\n svg: featherHat(\"hat_12_\", \"#06a34a\", \"#068a3c\", \"#ead8d8\", \"#a89a9a\"),\n },\n // Tall hats: the wizard's with stars, a black one with a white band.\n \"hat/15\": {\n svg: tallHat(\n \"hat_15\",\n \"#271a5c\",\n \"#2c1d62\",\n flat(star(88, 22, 18, 10), \"#c83ad0\") +\n flat(star(41, 40, 15, -12), \"#a230d0\") +\n flat(star(112, 64, 15, 20), \"#a632ec\"),\n ),\n },\n // Horned helmets.\n \"hat/27_low\": {\n svg: hornedHelmet(\"#6c3a2c\", \"#77756b\", \"#dfb8a6\", \"#5e5c54\"),\n },\n // A turban.\n \"hat/3_low\": {\n svg:\n path(TURBAN, \"#ead8b4\", { stroke: \"none\" }) +\n clipped(\n \"hat_3_\",\n TURBAN,\n flat(\n \"M 0 50 C 30 46 60 52 90 60 C 104 64 114 64 124 62 L 124 90 L 0 90 Z\",\n \"#c8b490\",\n ),\n line(\n \"M 40 5 C 44 14 52 20 62 22 M 16 34 C 30 30 50 24 64 22 C 80 20 92 18 100 18 M 18 36 C 40 38 70 42 92 50 C 102 54 108 56 114 56\",\n \"#8a7a60\",\n 2.4,\n ),\n ) +\n path(TURBAN, \"none\"),\n },\n // Pirate tricornes with a skull.\n \"hat/8_low\": {\n svg: tricorne(\"hat_8_\", \"#3a2848\", \"#2a1c38\", \"#e8dcd4\", \"#7a6a60\"),\n },\n \"hat/31_low\": {\n svg: tricorne(\"hat_31_\", \"#c03048\", \"#9a2038\", \"#f4eef4\", \"#8a2a3a\"),\n },\n // Baseball caps.\n \"hat/4\": {\n svg:\n cap(\"hat_4\", \"#9a58c8\", \"#c070d0\") +\n path(\"M 38 22 L 56 16 L 72 22 L 70 38 L 40 40 Z\", \"#1f7d8a\", {\n stroke: \"#145a64\",\n width: 2,\n }) +\n flat(\n \"M 54 22 C 50 22 46 24 46 28 L 54 26 L 52 36 L 56 36 L 56 26 L 64 28 C 62 24 58 22 54 22 Z\",\n \"#0d4a52\",\n ),\n },\n \"hat/48\": {\n svg:\n cap(\"hat_48\", \"#e84850\", \"#e84850\") +\n ellipse(56, 27, 14, 10, \"#68c0ff\", { stroke: \"#3a8ac8\", width: 2.4 }),\n },\n // Racing helmets.\n \"hat/5\": {\n svg: racer(\"hat_5\", \"#2e2e30\", \"#c8c8c8\", \"#70e050\"),\n },\n \"hat/39\": {\n svg: racer(\"hat_39\", \"#e83ca0\", \"#18e0e8\", \"#ffffff\"),\n },\n // Hard hats.\n \"hat/6\": { svg: hardHat(\"hat_6\", \"#ffb020\") },\n \"hat/57\": { svg: hardHat(\"hat_57\", \"#ff7038\") },\n // Batting helmets and their far ear flaps.\n \"hat/7\": { svg: battingHelmet(\"#4048d8\", \"#2a2a70\", \"#d8d8e8\") },\n \"hat/59\": { svg: battingHelmet(\"#e8f0f8\", \"#444448\", \"#ffffff\") },\n \"hat/7_back\": { svg: earFlap(\"#9a9ad0\", \"#4848c8\") },\n \"hat/59_back\": { svg: earFlap(\"#c8d0d8\", \"#f4f8fc\") },\n // Santa hats.\n \"hat/10_low\": { svg: santaHat(\"hat_10_\", \"#d83a2a\") },\n \"hat/47_low\": { svg: santaHat(\"hat_47_\", \"#10a050\") },\n // Aviator caps.\n \"hat/11_low\": { svg: aviatorCap(\"hat_11_\", \"#d07830\", \"#a02a20\") },\n \"hat/45_low\": { svg: aviatorCap(\"hat_45_\", \"#383c44\", \"#6a7078\") },\n \"hat/46_low\": { svg: aviatorCap(\"hat_46_\", \"#ffae20\", \"#d88a10\") },\n // Bucket helmets.\n \"hat/13_low\": {\n svg: bucketHelmet(\"hat_13_\", \"#8a8eae\", \"#a0b0b0\", \"#6a6a90\"),\n },\n \"hat/51_low\": {\n svg: bucketHelmet(\"hat_51_\", \"#8a5a48\", \"#c08070\", \"#4a3830\"),\n },\n // Fish hats.\n \"hat/14_low\": {\n svg: fishHat(\n \"hat_14_\",\n \"#ff7a28\",\n \"#d85a28\",\n \"#ff9a40\",\n \"#f8e8a0\",\n \"#8a5a30\",\n \"#e870b8\",\n ),\n },\n \"hat/53_low\": {\n svg: fishHat(\n \"hat_53_\",\n \"#e8608a\",\n \"#ff88c0\",\n \"#a8287a\",\n \"#f8e070\",\n \"#1f1a21\",\n \"#ff5a48\",\n ),\n },\n \"hat/54_low\": {\n svg: fishHat(\n \"hat_54_\",\n \"#2838a0\",\n \"#b8b8cc\",\n \"#60b8d8\",\n \"#f4f4f8\",\n \"#1f1a21\",\n \"#ff7ab0\",\n \"#d8d8e8\",\n ),\n },\n // Visor caps and their cloths.\n \"hat/16\": {\n svg: visorCap(\"hat_16\", \"#eaf4fc\", \"#a8d8f8\", \"#e83ca0\"),\n },\n \"hat/58\": {\n svg: visorCap(\"hat_58\", \"#3c2c78\", \"#3a3a40\", \"#ffb020\"),\n },\n \"hat/16_back\": { svg: visorCloth(\"#c0b8d8\", \"#eef0f8\") },\n \"hat/58_back\": { svg: visorCloth(\"#1a1822\", \"#4a3c90\") },\n // A chef's toque.\n \"hat/17_low\": {\n svg:\n path(\n \"M 4 10 C 30 4 70 2 100 8 C 116 12 124 22 122 34 C 120 44 116 50 112 54 L 14 48 C 10 34 6 20 4 10 Z\",\n \"#f2e8f0\",\n ) +\n flat(\"M 88 40 C 96 34 108 36 116 46 L 112 54 L 88 50 Z\", \"#ddd0dc\") +\n line(\n \"M 30 5 C 34 20 36 34 38 46 M 58 4 L 58 46 M 86 8 C 84 22 82 36 80 50 M 112 22 C 106 32 100 42 96 52\",\n \"#a89ab0\",\n 2.4,\n ) +\n path(\n \"M 12 46 C 40 48 80 52 114 60 L 112 78 C 80 72 40 66 14 66 Z\",\n \"#f2e8f0\",\n ) +\n flat(\n \"M 84 54 C 94 56 104 58 113 60 L 111 76 C 102 74 92 73 84 72 Z\",\n \"#ddd0dc\",\n ),\n },\n // A pith helmet with a badge.\n \"hat/18\": {\n svg:\n path(\n \"M 30 60 C 30 30 50 10 76 6 C 82 2 88 2 90 6 C 110 10 122 28 124 52 L 124 62 Z\",\n \"#e8b878\",\n ) +\n line(\"M 82 8 C 86 24 90 40 92 56\", \"#d0985a\", 2.6) +\n path(\n \"M 30 32 C 60 28 100 36 124 48 L 124 60 C 100 52 60 48 30 50 Z\",\n \"#c07838\",\n ) +\n [94, 104, 112]\n .map((x) => rect(x, 38, 4, 18, 1, \"#f0d0a0\", { width: 2 }))\n .join(\"\") +\n path(\n \"M 38 28 L 50 26 L 56 42 L 62 24 L 74 24 L 62 54 L 50 56 Z\",\n \"#b0703a\",\n { stroke: \"#8a5028\", width: 2.6 },\n ) +\n path(\n \"M 4 70 C 14 60 30 56 50 56 C 80 56 110 60 126 64 C 134 70 140 80 138 88 C 110 82 70 78 40 76 C 24 76 12 78 4 76 Z\",\n \"#e8b878\",\n ) +\n line(\"M 14 72 C 50 68 100 72 130 80\", \"#d09a60\", 2.4),\n },\n // An officer's cap with a gold badge.\n \"hat/19_low\": {\n svg:\n path(\n \"M 30 26 C 50 18 78 16 98 22 C 106 30 110 44 112 60 L 110 72 C 90 62 60 54 30 54 Z\",\n \"#0e5a78\",\n ) +\n path(\n \"M 6 72 C 30 64 70 62 100 70 C 108 76 114 88 116 100 L 102 102 C 90 88 70 82 50 82 C 34 82 22 86 14 90 C 8 86 4 80 6 72 Z\",\n \"#0e6a8a\",\n ) +\n path(\n \"M 4 66 C 30 54 70 50 100 56 C 108 60 112 66 114 72 L 112 76 C 90 64 50 62 10 74 Z\",\n \"#f8b830\",\n ) +\n path(\n \"M 38 14 L 44 10 L 52 22 L 46 26 Z M 58 2 L 66 2 L 64 18 L 58 18 Z M 76 18 L 84 14 L 88 20 L 78 26 Z\",\n \"#ffe070\",\n { width: 2.6 },\n ) +\n path(\"M 48 52 L 56 74 L 64 52 Z\", \"#0e5a78\", { width: 2.6 }) +\n ellipse(56, 40, 12, 14, \"#ffd848\") +\n ellipse(56, 40, 6, 8, \"#0e5a78\", { width: 2.6 }),\n },\n // Caps worn backwards, and their peaks.\n \"hat/20_low\": {\n svg: backwardsCap(\"hat_20_\", \"#d8d8e8\", \"#333336\"),\n },\n \"hat/34_low\": {\n svg: backwardsCap(\"hat_34_\", \"#2e5aa0\", \"#f4f6f4\"),\n },\n \"hat/35_low\": {\n svg: backwardsCap(\"hat_35_\", \"#f0a0e0\", \"#f4f6f4\"),\n },\n \"hat/20_back\": { svg: capPeak(\"#2e2e30\") },\n \"hat/34_back\": { svg: capPeak(\"#2e5aa0\") },\n \"hat/35_back\": { svg: capPeak(\"#f0a0e0\") },\n // Beanies, two with goggles pushed up.\n \"hat/21_low\": {\n svg: beanie(\n \"hat_21_\",\n \"#6a8a30\",\n line(\"M 38 28 C 44 30 50 30 56 28\", \"#3e5418\", 2.4) +\n circle(30, 28, 8, \"#b8c040\", { stroke: \"#3e5418\", width: 2.4 }) +\n path(\"M 60 20 L 80 32 L 72 42 L 54 30 Z\", \"#d8e0d0\", {\n stroke: \"#3e5418\",\n width: 2.4,\n }),\n ),\n },\n \"hat/40_low\": {\n svg: beanie(\n \"hat_40_\",\n \"#4048c8\",\n line(\"M 38 28 C 44 30 50 30 56 28\", \"#262a80\", 2.4) +\n circle(30, 28, 8, \"#ffc020\", { stroke: \"#262a80\", width: 2.4 }) +\n path(\"M 60 20 L 80 32 L 72 42 L 54 30 Z\", \"#20a890\", {\n stroke: \"#262a80\",\n width: 2.4,\n }),\n ),\n },\n \"hat/41_low\": { svg: beanie(\"hat_41_\", \"#5a3a3a\") },\n // Headbands with headphones, and the far earcups.\n \"hat/22_low\": { svg: headphones(\"#a060c0\", \"#3a3a3e\", \"#f4f2ea\") },\n \"hat/43_low\": { svg: headphones(\"#4a78c8\", \"#e0e0e8\", \"#3a3c40\") },\n \"hat/22_back\": { svg: earcup(\"#3a3a3e\") },\n \"hat/43_back\": { svg: earcup(\"#a8a8ac\") },\n // Animal hoods: a boar with tusks, a pink pig, a grey pig — and their backs.\n \"hat/23\": {\n svg: animalHood(\n \"hat_23\",\n \"#c05a48\",\n \"#9a3a30\",\n path(\n \"M 26 46 C 30 40 36 38 40 40 L 36 54 C 32 56 28 52 26 46 Z\",\n \"#fbecb4\",\n { width: 2.6 },\n ) +\n path(\"M 64 40 C 68 38 74 40 76 46 C 74 52 70 56 66 54 Z\", \"#fbecb4\", {\n width: 2.6,\n }) +\n path(\"M 12 74 L 22 75 L 18 90 Z\", \"#fbecb4\", { width: 2.6 }) +\n path(\"M 80 80 L 92 82 L 86 96 Z\", \"#fbecb4\", { width: 2.6 }),\n ),\n },\n \"hat/55\": {\n svg: animalHood(\n \"hat_55\",\n \"#ffb0b0\",\n \"#e07a90\",\n circle(22, 54, 8, \"#ff9880\", { stroke: \"none\" }) +\n circle(78, 54, 8, \"#ff9880\", { stroke: \"none\" }),\n ),\n },\n \"hat/56\": {\n svg: animalHood(\"hat_56\", \"#6a6a7a\", \"#f090a8\", \"\"),\n },\n \"hat/23_back\": { svg: hoodBack(\"#a85040\") },\n \"hat/55_back\": { svg: hoodBack(\"#e09094\") },\n \"hat/56_back\": { svg: hoodBack(\"#60606e\") },\n // Police caps.\n \"hat/24\": { svg: policeCap(\"#3e2c88\", \"#8a82c8\", \"#3a2878\") },\n \"hat/37\": { svg: policeCap(\"#2a2a2e\", \"#6a6a60\", \"#1e1e22\") },\n \"hat/38\": { svg: policeCap(\"#ece4ec\", \"#c8c0c8\", \"#222226\") },\n // A dotted bandana cap with a tail.\n \"hat/25\": {\n svg:\n path(\n \"M 82 54 C 78 64 78 80 86 90 L 92 96 C 84 90 80 78 82 54 Z\",\n \"#2a3c40\",\n { width: 2.6 },\n ) +\n path(\n \"M 4 52 C 10 44 18 42 24 40 C 30 22 50 8 74 6 C 98 4 116 20 120 44 C 122 56 120 70 118 80 C 118 88 116 96 110 96 C 98 96 88 90 84 80 C 82 70 86 60 92 54 L 96 52 C 70 48 40 50 10 54 C 6 54 4 53 4 52 Z\",\n \"#c82a30\",\n ) +\n flat(\n \"M 88 76 C 100 74 112 72 120 70 C 120 80 118 90 112 95 C 100 96 90 90 88 76 Z\",\n \"#a82028\",\n ) +\n line(\"M 14 40 C 40 34 80 34 108 44\", \"#9a1820\", 2.6) +\n [\n [22, 28],\n [38, 24],\n [60, 24],\n [84, 28],\n [104, 36],\n ]\n .map(([x, y]) => circle(x!, y!, 4, \"#9a1820\", { stroke: \"none\" }))\n .join(\"\") +\n path(\"M 100 80 C 102 76 110 76 110 80 C 110 84 102 84 100 80 Z\", \"none\", {\n stroke: \"#7a1018\",\n width: 2,\n }),\n },\n \"hat/25_back\": {\n svg:\n path(\n \"M 6 10 C 16 6 28 4 38 4 C 38 14 36 22 34 30 C 32 42 30 52 24 56 C 16 56 8 52 6 44 C 4 34 6 22 6 10 Z\",\n \"#2a3c40\",\n ) +\n flat(\"M 8 11 C 10 24 10 40 16 54 L 12 54 C 8 46 6 34 7 11 Z\", \"#b0101c\"),\n },\n // Berets with a skull, and their ribbon tails.\n \"hat/26\": { svg: skullBeret(\"hat_26_\", \"#b0288a\") },\n \"hat/49_low\": { svg: skullBeret(\"hat_49_\", \"#3a6a3a\") },\n \"hat/26_back\": { svg: ribbonTails(\"#9a1e7a\") },\n \"hat/49_back\": { svg: ribbonTails(\"#2e562e\") },\n \"hat/32\": {\n svg: footballHelmet(\"#18c080\", \"#f2eaf2\", \"#10a068\", \"#3a3a3c\"),\n },\n \"hat/32_back\": { svg: footballBack(\"#108050\") },\n \"hat/36\": {\n svg: bucketHat(\n \"hat_36\",\n \"#d8a860\",\n \"#b88a48\",\n \"#7a5048\",\n \"#b08040\",\n \"#d8a860\",\n \"#f0d090\",\n ),\n },\n \"hat/42_low\": {\n svg:\n crownHat(\"#c82a40\", \"#9a1e30\") +\n ellipse(38, 60, 5, 8, \"#f8e020\", { width: 2.6 }) +\n flat(\n \"M 37 54 C 39 54 40 56 40 59 L 38 59 C 38 57 37.5 56 37 54 Z\",\n \"#ffffff\",\n ),\n },\n \"hat/33_low\": {\n svg: featherHat(\"hat_33_\", \"#a05a38\", \"#7e4428\", \"#e84860\", \"#a02a3a\"),\n },\n \"hat/60\": {\n svg: tallHat(\n \"hat_60\",\n \"#222226\",\n \"#222226\",\n path(\n \"M 20 52 C 50 48 90 52 130 62 L 130 76 C 90 66 50 62 20 64 Z\",\n \"#d4d8e0\",\n { width: 3 },\n ),\n ),\n },\n \"hat/44_low\": {\n svg: group(\n \"scale(1 0.95)\",\n hornedHelmet(\"#525c48\", \"#a0aaa0\", \"#b8c4b8\", \"#a0aaa0\"),\n ),\n },\n // Dino hoods and their backs.\n \"hat/28\": {\n svg: dinoHood(\"hat_28\", \"#28b098\", \"#207060\", \"#f8dede\", \"#1e3a34\"),\n },\n \"hat/52\": {\n svg: dinoHood(\"hat_52\", \"#6868e0\", \"#50d0f0\", \"#e0e0f0\", \"#24245a\"),\n },\n \"hat/28_back\": {\n svg: path(\"M 3 8 L 22 3 L 60 3 L 92 18 L 108 50 L 6 50 Z\", \"#1e4a40\"),\n },\n \"hat/52_back\": {\n svg: path(\"M 3 8 L 22 3 L 60 3 L 92 18 L 108 50 L 6 50 Z\", \"#3c3a90\"),\n },\n // Sweatbands with snorkel goggles.\n \"hat/29_low\": {\n svg: snorkel(\n \"hat_29_\",\n \"#1a70d0\",\n \"#40e0e0\",\n \"#f0c030\",\n \"#d040d0\",\n \"#d08010\",\n ),\n },\n \"hat/50_low\": {\n svg: snorkel(\n \"hat_50_\",\n \"#f0f060\",\n \"#e8507a\",\n \"#30d080\",\n \"#30d080\",\n \"#1e9a58\",\n ),\n },\n // A newsboy cap.\n \"hat/30_low\": {\n svg:\n circle(56, 8, 6, \"#e06090\") +\n path(\n \"M 4 52 C 2 30 18 16 40 12 C 60 8 86 8 100 16 C 110 22 114 32 112 46 L 110 56 Z\",\n \"#e06090\",\n ) +\n flat(\"M 6 38 C 40 32 80 36 112 44 L 110 56 L 5 52 Z\", \"#c85080\") +\n path(\n \"M 4 52 C 30 52 70 56 110 60 C 112 64 112 70 108 72 C 70 64 30 62 6 60 C 3 58 3 54 4 52 Z\",\n \"#e06090\",\n ),\n },\n};\n"
220
+ },
221
+ {
222
+ "path": "character/packs/base/index.ts",
223
+ "content": "import type { ArtDef, ArtPack } from \"../../core/catalog\";\nimport { bodyArt } from \"./body\";\nimport { clothesArt } from \"./clothes\";\nimport { beardsArt } from \"./beards\";\nimport { browsArt } from \"./brows\";\nimport { eyesArt } from \"./eyes\";\nimport { eyewearArt } from \"./eyewear\";\nimport { mouthsArt } from \"./mouths\";\nimport { gearArt } from \"./gear\";\nimport { hairArt } from \"./hair\";\nimport { headwearArt } from \"./headwear\";\n\n// The base pack — the classic parts: every image of every part type.\n// Each file draws a family of images by their path (\"top/12\") on the image's own canvas; a part\n// (a skin) is drawable — and offered by ‹ › — as soon as every image it uses has an entry here.\n// Which images a skin needs: the maker page lists them (its comparison sheet), or skinImages().\n\nconst all: Record<string, ArtDef> = {\n ...bodyArt,\n ...eyesArt,\n ...browsArt,\n ...mouthsArt,\n ...beardsArt,\n ...eyewearArt,\n ...hairArt,\n ...headwearArt,\n ...clothesArt,\n ...gearArt,\n};\n\n// Images that are the same twice (a hair that is the same with or without a hat, the two\n// legs of some trousers): drawn once, the twin reuses it.\nconst SAME_AS: Readonly<Record<string, string>> = {\n \"pants/21\": \"pants/1\",\n \"pants/41\": \"pants/30\",\n \"hair_under/1\": \"hair/1\",\n ...Object.fromEntries(\n [2, 17, 21, 24, 28, 31, 32, 33, 34, 35, 41, 47].map((n) => [\n `pants/${n}_far`,\n `pants/${n}_near`,\n ]),\n ),\n};\nfor (const [twin, path] of Object.entries(SAME_AS)) {\n const art = all[path];\n if (art && !all[twin]) all[twin] = art;\n}\n\nexport const basePack: ArtPack = { id: \"base\", art: all };\n"
224
+ },
225
+ {
226
+ "path": "character/packs/base/mouths.ts",
227
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport { clipped, ellipse, flat, INK, line, path } from \"../../art/svg\";\n\n// Mouths (mouth/1…10), in their own colours: an open mouth is a dark\n// hole in a heavy outline, white teeth along its edge, a mauve tongue at the bottom; a closed one a\n// line thick in the middle and thin at the ends. mouth/4 (the grin), mouth/8 (clenched) and mouth/10\n// (the groan) are also the faces of the rig's expressions (rigs/hero: Angry, Hurt, Dead).\n\nconst HOLE = \"#2a1418\";\nconst TONGUE = \"#b8707e\";\nconst TEETH = \"#fbf6ef\";\n\n/** An open mouth: the hole, what is inside it (cut to it), the outline over all. */\nconst open = (id: string, d: string, inside: string, width = 2.4) =>\n path(d, HOLE, { stroke: \"none\" }) +\n clipped(id, d, inside) +\n path(d, \"none\", { width });\n\nexport const mouthsArt: Record<string, ArtDef> = {\n // A closed mouth, a little pursed.\n \"mouth/1\": {\n svg: flat(\n \"M 1.6 3.6 C 5 2.2 10 2 13.6 3 C 13.9 3.2 13.8 3.7 13.4 3.8 C 10 5 5 5.2 1.6 3.6 Z\",\n INK,\n ),\n },\n // A smirk to one side, the teeth showing in its corner.\n \"mouth/2\": {\n svg:\n open(\n \"m2\",\n \"M 3.5 8.5 C 9 10 17 9 24.5 4.5 C 24 10 19.5 15.5 13 15.5 C 8 15.5 4.8 12.5 3.5 8.5 Z\",\n flat(\"M 0 0 L 27 0 L 27 6.5 C 19 11.5 11 12 0 9.8 Z\", TEETH) +\n ellipse(15, 17, 7, 3.4, TONGUE, { stroke: \"none\" }),\n ) + line(\"M 23.5 3 C 24.8 3.6 25.6 4.6 25.8 6\", INK, 1.8),\n },\n // A cat's mouth.\n \"mouth/3\": {\n svg: line(\n \"M 2.5 4.5 C 3 8.5 8 9.5 10 6.5 L 11.5 3 L 13 6.5 C 15 9.5 20 8.5 20.5 4\",\n INK,\n 3.2,\n ),\n },\n // The grin: teeth bared, two fangs, the tongue at the bottom (the Angry face).\n \"mouth/4\": {\n svg: open(\n \"m4\",\n \"M 2 2 C 8 3.3 16 3.3 22 2 C 22.5 6.5 19.5 9.5 12 9.5 C 4.5 9.5 1.5 6.5 2 2 Z\",\n flat(\"M 0 0 L 24 0 L 24 4.4 C 16 5.4 8 5.4 0 4.4 Z\", TEETH) +\n flat(\n \"M 4.8 4.4 L 6.1 6.9 L 7.3 4.6 Z M 16.7 4.6 L 17.9 6.9 L 19.2 4.4 Z\",\n TEETH,\n ) +\n ellipse(12, 10.4, 7.6, 3, TONGUE, { stroke: \"none\" }) +\n flat(\"M 0 9.4 C 8 8.4 16 8.4 24 9.4 L 24 12 L 0 12 Z\", TEETH),\n 2.2,\n ),\n },\n // A wide open smile: the upper teeth, the tongue.\n \"mouth/5\": {\n svg: open(\n \"m5\",\n \"M 2.5 3.5 C 11 5 22 5 30.5 3.5 C 31 9.5 25 14 16.5 14 C 8 14 2 9.5 2.5 3.5 Z\",\n flat(\"M 0 0 L 33 0 L 33 6.2 C 22 7.4 11 7.4 0 6.2 Z\", TEETH) +\n ellipse(16.5, 15, 9.5, 4.6, TONGUE, { stroke: \"none\" }) +\n line(\"M 16.5 11 L 16.5 14\", \"#8a4a58\", 1.2),\n ),\n },\n // Sewn shut.\n \"mouth/6\": {\n svg:\n line(\"M 3 8 C 8 7 12 7 17 8\", INK, 2.6) +\n line(\"M 5.5 3 L 6 13 M 10 2.5 L 10 13.5 M 14.5 3 L 14 13\", INK, 2.2),\n },\n // Round and open — surprise.\n \"mouth/7\": {\n svg: open(\n \"m7\",\n \"M 10 2.5 C 15 2.5 17.5 6.5 17.5 10.5 C 17.5 14.5 14.5 17 10 17 C 5.5 17 2.5 14.5 2.5 10.5 C 2.5 6.5 5 2.5 10 2.5 Z\",\n flat(\"M 0 0 L 20 0 L 20 5.6 C 14 6.6 6 6.6 0 5.6 Z\", TEETH) +\n ellipse(10, 17, 6, 4.5, TONGUE, { stroke: \"none\" }),\n ),\n },\n // Teeth clenched (the Hurt face).\n \"mouth/8\": {\n svg:\n path(\n \"M 3 2.5 C 9 3.6 14 3.6 20 2.5 C 21.5 5 21.5 8.5 20 10.5 C 14 11.5 9 11.5 3 10.5 C 1.5 8.5 1.5 5 3 2.5 Z\",\n TEETH,\n { width: 2.2 },\n ) +\n line(\n \"M 2.6 6.6 C 9 7.2 14 7.2 20.4 6.6 M 7.5 3.4 L 7.5 10.8 M 11.5 3.6 L 11.5 11 M 15.5 3.4 L 15.5 10.8\",\n INK,\n 1.3,\n ),\n },\n // The tongue out.\n \"mouth/9\": {\n svg:\n path(\n \"M 8.5 7 C 7.5 12.5 9 16.5 13 16.5 C 17 16.5 18.5 12.5 17.5 7 Z\",\n TONGUE,\n { width: 2.2 },\n ) +\n line(\"M 13 8 L 13 12.5\", \"#8a4a58\", 1.3) +\n line(\"M 2.5 6.5 C 9 8.5 16 8.5 21.5 5\", INK, 2.8),\n },\n // A groan: the corners down, the lower teeth (the Dead face).\n \"mouth/10\": {\n svg: open(\n \"m10\",\n \"M 2.5 12.5 C 4 6 8 3 11.5 3 C 15 3 19 6 20.5 12.5 C 15 11 8 11 2.5 12.5 Z\",\n ellipse(11.5, 3, 6, 3.4, \"#4a2830\", { stroke: \"none\" }) +\n flat(\"M 0 15 L 0 10.2 C 8 8.8 15 8.8 23 10.2 L 23 15 Z\", TEETH),\n ),\n },\n};\n"
228
+ },
229
+ {
230
+ "path": "character/packs/base/shapes.ts",
231
+ "content": "import { INK, THIN } from \"../../art/svg\";\n\n// Shapes several families of the base pack draw (stars on hats and glasses, dashed trims).\n\n/** A five-point star's outline, centre (cx, cy), outer radius r, turned `rot` degrees. */\nexport const star = (\n cx: number,\n cy: number,\n r: number,\n rot = 0,\n inner = 0.45,\n) => {\n const pts: string[] = [];\n for (let i = 0; i < 10; i++) {\n const a = ((i * 36 + rot - 90) * Math.PI) / 180;\n const rr = i % 2 ? r * inner : r;\n pts.push(\n `${(cx + Math.cos(a) * rr).toFixed(1)} ${(cy + Math.sin(a) * rr).toFixed(1)}`,\n );\n }\n return `M ${pts.join(\" L \")} Z`;\n};\n\n/** An open line drawn in dashes (a striped frame, stitches). */\nexport const dashed = (\n d: string,\n color = INK,\n width = THIN,\n dash = \"5 4\",\n): string =>\n `<path d=\"${d}\" fill=\"none\" stroke=\"${color}\" stroke-width=\"${width}\" stroke-dasharray=\"${dash}\"/>`;\n\n/** A dollar sign centred on (x, y), `s` — its half height. */\nexport const dollar = (\n x: number,\n y: number,\n s: number,\n color = INK,\n width = 3.2,\n): string =>\n `<path d=\"M ${x + s * 0.55} ${y - s * 0.55} C ${x + s * 0.3} ${y - s * 0.85} ${x - s * 0.6} ${y - s * 0.8} ${x - s * 0.6} ${y - s * 0.3} ` +\n `C ${x - s * 0.6} ${y + s * 0.15} ${x + s * 0.6} ${y - s * 0.05} ${x + s * 0.6} ${y + s * 0.4} C ${x + s * 0.6} ${y + s * 0.85} ${x - s * 0.3} ${y + s * 0.9} ${x - s * 0.6} ${y + s * 0.55} ` +\n `M ${x} ${y - s} L ${x} ${y + s}\" fill=\"none\" stroke=\"${color}\" stroke-width=\"${width}\"/>`;\n"
232
+ },
233
+ {
234
+ "path": "character/packs/fantasy/armor.ts",
235
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport { circle, clipped, flat, INK, line, path, rect } from \"../../art/svg\";\nimport {\n CAP_L,\n CAP_R,\n cap,\n mitt,\n shoe,\n SHOE,\n top,\n TOPS,\n} from \"../base/clothes\";\nimport { dashed, star } from \"../base/shapes\";\nimport {\n DARK_LEATHER,\n gem,\n GOLD,\n LEATHER,\n rivet,\n scratches,\n STEEL,\n} from \"./kit\";\n\n// The fantasy set's armour, gauntlets, boots and capes, each on the canvas and outline of a classic\n// piece where it sits on the body (rigs/hero/wear.ts): the tops on the silhouettes of classic tops (TOPS),\n// the shoulder pieces on the shoulder caps, boots on the shoe outlines — so they fit the body the same.\n\n/** The neck opening of a top outline (its second sub-path), when it has one. */\nconst neckOf = (d: string) => {\n const subs = d.split(/(?=M )/);\n return subs.length > 1 ? subs[1]! : \"\";\n};\n\n/** Chain mail rings over a box. */\nconst mail = (\n x0: number,\n y0: number,\n x1: number,\n y1: number,\n c = \"#5a616a\",\n) => {\n let d = \"\";\n for (let y = y0, row = 0; y < y1; y += 4.4, row++)\n for (let x = x0 + (row % 2) * 2.6; x < x1; x += 5.2)\n d += `M ${x} ${y} C ${x} ${y + 3.2} ${x + 4.2} ${y + 3.2} ${x + 4.2} ${y} `;\n return line(d, c, 1.3);\n};\n\n/** A belt across a top at y, a buckle at bx. */\nconst belt = (y: number, bx: number, c = LEATHER.dark) =>\n rect(-2, y, 96, 7, 0, c, { stroke: \"none\" }) +\n line(`M -2 ${y} L 96 ${y} M -2 ${y + 7} L 96 ${y + 7}`, INK, 2) +\n rect(bx - 5, y - 1.5, 10, 10, 2, GOLD.base, { width: 2 }) +\n rect(bx - 1.8, y + 1.5, 3.6, 4, 1, LEATHER.dark, { stroke: \"none\" });\n\n/** Studs in a row. */\nconst studs = (pts: [number, number][], m = GOLD) =>\n pts.map(([x, y]) => rivet(x, y, 1.8, m)).join(\"\");\n\n/** A shoulder piece on a shoulder cap: the material, lames across it, a rivet. */\nconst pauldron = (\n id: string,\n d: string,\n c: string,\n lames: string,\n extra = \"\",\n) => cap(id, d, c, lames + extra);\n\nconst PLATE = TOPS.top_4;\nconst PLATE_NECK = neckOf(PLATE);\n\nexport const armorArt: Record<string, ArtDef> = {\n // Plate armour: a steel breastplate with a ridge, a gold-trimmed neck, a belt, plates over the hips.\n \"top/plate_armor\": {\n svg: top(\n \"fpa\",\n PLATE,\n STEEL.base,\n flat(\"M -2 -2 L 92 -2 L 92 26 C 60 30 30 30 -2 26 Z\", STEEL.light, 0.6) +\n flat(\n \"M 44 34 C 60 34 76 40 92 44 L 92 92 L 44 92 Z\",\n STEEL.dark,\n 0.55,\n ) +\n line(\"M 40 34 C 41 42 42 50 42 56\", STEEL.dark, 2.4) +\n flat(\"M -2 64 L 92 64 L 92 92 L -2 92 Z\", STEEL.dark) +\n line(\"M -2 64 L 92 64 M -2 73 L 92 73 M -2 82 L 92 82\", INK, 2) +\n flat(\n \"M -2 65 L 92 65 L 92 68 L -2 68 Z M -2 74 L 92 74 L 92 77 L -2 77 Z\",\n STEEL.light,\n 0.5,\n ) +\n belt(56, 40) +\n scratches(STEEL, [16, 40, 22, 44], [60, 26, 66, 30], [70, 48, 74, 52]),\n path(PLATE_NECK, \"none\", { stroke: GOLD.base, width: 6 }) +\n path(PLATE_NECK, \"none\", { width: 2 }) +\n studs(\n [\n [12, 42],\n [72, 40],\n ],\n STEEL,\n ),\n ),\n },\n \"top/plate_armor_sleeve_near\": {\n svg:\n pauldron(\n \"fpal\",\n CAP_L,\n STEEL.base,\n line(\"M 2 14 C 12 10 22 12 32 18 M 2 22 C 12 18 22 20 33 26\", INK, 2) +\n flat(\"M 0 0 L 36 0 L 36 9 C 24 6 12 6 0 10 Z\", STEEL.light, 0.6) +\n line(\"M 3 29 C 12 31 24 31 32 29\", GOLD.base, 3),\n ) + rivet(14, 8, 1.8, STEEL),\n },\n \"top/plate_armor_sleeve_far\": {\n svg:\n pauldron(\n \"fpar\",\n CAP_R,\n STEEL.base,\n line(\"M 2 14 C 12 8 22 8 31 12 M 2 22 C 12 16 22 16 31 20\", INK, 2) +\n flat(\"M 0 0 L 36 0 L 36 8 C 24 5 12 6 0 9 Z\", STEEL.light, 0.6) +\n line(\"M 4 29 C 14 32 24 32 30 30\", GOLD.base, 3),\n ) + rivet(20, 7, 1.8, STEEL),\n },\n // Chain mail under a red tabard with a gold cross, a leather belt.\n \"top/chainmail\": {\n svg: top(\n \"fcm\",\n TOPS.top_2,\n \"#9aa1aa\",\n mail(0, 0, 92, 90) +\n flat(\"M 26 0 L 56 0 L 58 92 L 24 92 Z\", \"#b8282c\") +\n line(\"M 26 0 L 24 92 M 56 0 L 58 92\", INK, 2.4) +\n flat(\n \"M 37 22 L 45 22 L 45 32 L 54 32 L 54 40 L 45 40 L 45 54 L 37 54 L 37 40 L 28 40 L 28 32 L 37 32 Z\",\n GOLD.base,\n ) +\n flat(\"M 50 0 L 56 0 L 58 92 L 52 92 Z\", \"#000000\", 0.18) +\n belt(60, 40),\n ),\n },\n \"top/chainmail_sleeve_near\": {\n svg: pauldron(\n \"fcml\",\n CAP_L,\n \"#9aa1aa\",\n mail(0, 0, 38, 36) + line(\"M 3 28 C 12 31 24 31 32 28\", LEATHER.base, 4),\n ),\n },\n \"top/chainmail_sleeve_far\": {\n svg: pauldron(\n \"fcmr\",\n CAP_R,\n \"#9aa1aa\",\n mail(0, 0, 38, 36) + line(\"M 4 29 C 14 32 24 32 30 29\", LEATHER.base, 4),\n ),\n },\n // Leather armour: stitched panels, a strap across with studs, a belt with a pouch.\n \"top/leather_armor\": {\n svg: top(\n \"fla\",\n TOPS.top_54,\n LEATHER.base,\n flat(\n \"M 46 0 L 92 0 L 92 94 L 50 94 C 46 60 44 30 46 0 Z\",\n LEATHER.dark,\n 0.6,\n ) +\n dashed(\n \"M 20 10 C 18 34 18 60 22 90 M 64 10 C 66 34 66 60 62 90\",\n LEATHER.light,\n 1.6,\n \"3 3\",\n ) +\n line(\"M 0 8 L 92 84\", INK, 12) +\n line(\"M 0 8 L 92 84\", DARK_LEATHER.base, 8) +\n studs([\n [14, 20],\n [32, 35],\n [50, 50],\n [68, 65],\n ]) +\n belt(66, 30) +\n path(\n \"M 56 72 L 72 72 L 72 86 C 72 88 70 89 68 89 L 60 89 C 58 89 56 88 56 86 Z\",\n DARK_LEATHER.base,\n { width: 2.2 },\n ) +\n rivet(64, 77, 1.6, GOLD) +\n scratches(LEATHER, [26, 48, 30, 52], [70, 28, 74, 32]),\n ),\n },\n \"top/leather_armor_sleeve_near\": {\n svg:\n pauldron(\n \"flal\",\n CAP_L,\n LEATHER.base,\n line(\"M 2 18 C 12 14 22 16 32 22\", INK, 2) +\n dashed(\"M 4 26 C 12 28 24 28 31 26\", LEATHER.light, 1.4, \"3 2\"),\n ) +\n studs([\n [12, 10],\n [22, 12],\n ]),\n },\n \"top/leather_armor_sleeve_far\": {\n svg:\n pauldron(\n \"flar\",\n CAP_R,\n LEATHER.base,\n line(\"M 2 18 C 12 12 22 12 31 16\", INK, 2) +\n dashed(\"M 5 27 C 14 29 24 29 30 27\", LEATHER.light, 1.4, \"3 2\"),\n ) +\n studs([\n [14, 9],\n [24, 9],\n ]),\n },\n // A mage's robe: deep blue, a gold trim down the front, a purple sash, small stars.\n \"top/mage_robe\": {\n svg: top(\n \"fmr\",\n TOPS.top_18,\n \"#2a3a9a\",\n flat(\"M 48 0 L 92 0 L 92 80 L 52 80 Z\", \"#1e2a78\") +\n path(\"M 24 0 L 40 34 L 56 0 L 50 0 L 40 22 L 30 0 Z\", GOLD.base, {\n width: 1.6,\n }) +\n flat(\"M 37 34 L 43 34 L 45 80 L 35 80 Z\", GOLD.base) +\n line(\"M 37 34 L 35 80 M 43 34 L 45 80\", INK, 1.6) +\n flat(\"M -2 50 L 92 52 L 92 60 L -2 58 Z\", \"#7a2a9a\") +\n line(\"M -2 50 L 92 52 M -2 58 L 92 60\", INK, 2) +\n flat(star(16, 30, 3.2), \"#ffe060\") +\n flat(star(66, 22, 2.8, 12), \"#ffe060\") +\n flat(star(70, 68, 3, -10), \"#ffe060\") +\n flat(star(14, 68, 2.6, 20), \"#ffe060\"),\n ),\n },\n \"top/mage_robe_sleeve_near\": {\n svg: pauldron(\n \"fmrl\",\n CAP_L,\n \"#2a3a9a\",\n line(\"M 3 28 C 12 31 24 31 32 28\", GOLD.base, 4) +\n flat(star(16, 16, 3), \"#ffe060\"),\n ),\n },\n \"top/mage_robe_sleeve_far\": {\n svg: pauldron(\n \"fmrr\",\n CAP_R,\n \"#2a3a9a\",\n line(\"M 4 29 C 14 32 24 32 30 29\", GOLD.base, 4) +\n flat(star(18, 15, 3), \"#ffe060\"),\n ),\n },\n\n // Steel gauntlets: a flared cuff, plates over the fingers.\n \"gloves/gauntlets_near\": {\n svg:\n mitt(\n \"fgl\",\n 34,\n 35,\n STEEL.base,\n { h: 9, color: STEEL.dark },\n line(\"M 4 22 L 30 22 M 4 27 L 30 27\", INK, 1.6) +\n flat(\"M 0 13 L 34 13 L 34 17 L 0 17 Z\", STEEL.light, 0.6),\n ) + rivet(17, 8, 1.8, GOLD),\n },\n \"gloves/gauntlets_far\": {\n svg:\n mitt(\n \"fgr\",\n 32,\n 35,\n STEEL.base,\n { h: 9, color: STEEL.dark },\n line(\"M 4 22 L 28 22 M 4 27 L 28 27\", INK, 1.6) +\n flat(\"M 0 13 L 32 13 L 32 17 L 0 17 Z\", STEEL.light, 0.6),\n ) + rivet(16, 8, 1.8, GOLD),\n },\n // Leather gloves under bracers with a gold stud.\n \"gloves/bracers_near\": {\n svg:\n mitt(\n \"fbl\",\n 36,\n 40,\n LEATHER.base,\n { h: 14, color: DARK_LEATHER.base },\n dashed(\"M 4 22 L 32 22\", LEATHER.light, 1.4, \"3 2\"),\n ) + rivet(18, 10.5, 2.2, GOLD),\n },\n \"gloves/bracers_far\": {\n svg:\n mitt(\n \"fbr\",\n 35,\n 40,\n LEATHER.base,\n { h: 14, color: DARK_LEATHER.base },\n dashed(\"M 4 22 L 31 22\", LEATHER.light, 1.4, \"3 2\"),\n ) + rivet(17.5, 10.5, 2.2, GOLD),\n },\n\n // Plate boots: steel over the foot, a band of plates, a gold rivet.\n \"boots/plate_boots_far\": {\n svg:\n shoe(\n \"fpbl\",\n SHOE.clog,\n STEEL.base,\n flat(\"M 0 15 L 30 15 L 30 23 L 0 23 Z\", STEEL.dark) +\n line(\"M 0 15 L 30 15\", INK, 1.8) +\n line(\"M 8 9.5 L 18 9.5\", INK, 1.6) +\n flat(\"M 8 3 L 17 3 L 17 6 L 8 6 Z\", STEEL.light, 0.6),\n ) + rivet(13, 7, 1.5, GOLD),\n },\n \"boots/plate_boots_near\": {\n svg:\n shoe(\n \"fpbr\",\n SHOE.clog,\n STEEL.base,\n flat(\"M 0 15 L 30 15 L 30 23 L 0 23 Z\", STEEL.dark) +\n line(\"M 0 15 L 30 15\", INK, 1.8) +\n line(\"M 8 9.5 L 18 9.5\", INK, 1.6) +\n flat(\"M 8 3 L 17 3 L 17 6 L 8 6 Z\", STEEL.light, 0.6),\n ) + rivet(13, 7, 1.5, GOLD),\n },\n // Leather boots: a cuff folded over, a strap with a buckle.\n \"boots/leather_boots_far\": {\n svg: shoe(\n \"flbl\",\n SHOE.tall,\n LEATHER.base,\n flat(\"M 0 0 L 26 0 L 26 8 L 0 8 Z\", DARK_LEATHER.base) +\n line(\"M 0 8 L 26 8\", INK, 1.8) +\n line(\"M 3 17 L 22 17\", DARK_LEATHER.base, 3) +\n rect(10, 15, 4.5, 4.5, 1, GOLD.base, { width: 1.4 }),\n ),\n },\n \"boots/leather_boots_near\": {\n svg: shoe(\n \"flbr\",\n SHOE.tall,\n LEATHER.base,\n flat(\"M 0 0 L 26 0 L 26 8 L 0 8 Z\", DARK_LEATHER.base) +\n line(\"M 0 8 L 26 8\", INK, 1.8) +\n line(\"M 3 17 L 22 17\", DARK_LEATHER.base, 3) +\n rect(10, 15, 4.5, 4.5, 1, GOLD.base, { width: 1.4 }),\n ),\n },\n\n // Capes behind the back: falling in folds from a clasp at the shoulders.\n \"back/knight_cape\": {\n svg: cape(\"fkc\", \"#b8282c\", \"#8a1a1e\", GOLD.base),\n },\n \"back/mage_cape\": {\n svg: cape(\"fmc\", \"#2a3a9a\", \"#1e2a78\", \"#c0c8ff\"),\n },\n};\n\n/** A cape on the back canvas (95 × 124): wide at the hem, gathered at the shoulders, folds down it. */\nfunction cape(id: string, c: string, dark: string, trim: string): string {\n const d =\n \"M 22 4 L 74 4 C 80 30 86 70 92 118 C 70 121 26 121 4 118 C 10 70 16 30 22 4 Z\";\n return (\n path(d, c, { stroke: \"none\" }) +\n clipped(\n id,\n d,\n flat(\"M 48 0 L 100 0 L 100 130 L 56 130 C 54 90 52 40 48 0 Z\", dark),\n line(\n \"M 30 20 C 26 50 22 80 18 118 M 48 20 C 48 50 48 80 48 120 M 66 20 C 70 50 74 80 78 118\",\n dark,\n 2.4,\n ),\n line(\"M 4 114 C 26 117 70 117 92 114\", trim, 5),\n ) +\n path(d, \"none\") +\n circle(26, 8, 5, GOLD.base) +\n circle(70, 8, 5, GOLD.base) +\n gem(26, 8, 2.4, \"#3a7ad8\") +\n gem(70, 8, 2.4, \"#3a7ad8\")\n );\n}\n"
236
+ },
237
+ {
238
+ "path": "character/packs/fantasy/helmets.ts",
239
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport { circle, clipped, flat, INK, line, path } from \"../../art/svg\";\nimport { dashed, star } from \"../base/shapes\";\nimport { gem, GOLD, rivet, scratches, STEEL, type Material } from \"./kit\";\n\n// The fantasy set's helmets and hats, each on the canvas of a classic head piece (rigs/hero/wear.ts)\n// and inside its outline there, so it sits on the head the same way. The\n// face looks left.\n\n/** The racer helmet's shell (hat_5) — a whole closed helm round the head. */\nconst SHELL =\n \"M 4 70 C 2.8 50 8 30 22 16 C 34 6 46 2.8 58 2.8 C 80 2.8 98 12 108 28 C 113 38 114.2 50 114.2 62 C 114.2 78 112 92 106 100 \" +\n \"C 98 110 80 116 56 116.2 C 36 116 18 110 10 100 C 5 92 4 82 4 70 Z\";\n/** The aviator cap's outline (hat_11_ / _45_): a dome, a flap down the cheek, a flap down the neck. */\nconst CAP =\n \"M 4 50 C 2 30 12 12 36 6 C 60 0 88 2 102 14 C 110 22 112 36 110 52 C 110 58 108 62 106 64 L 104 74 L 96 68 L 94 80 C 92 92 92 104 90 110 L 76 110 \" +\n \"C 74 96 74 80 74 66 C 74 54 70 50 64 50 L 18 52 C 14 54 12 58 12 66 L 12 102 C 10 108 6 108 4 104 Z\";\n/** The hard hat's outline (hat_6). */\nconst DOME_6 =\n \"M 4 66 C 2 58 8 54 16 52 C 20 32 30 14 44 8 C 56 4 66 2 72 3 C 82 2 90 6 96 10 C 110 18 118 32 120 46 C 122 58 124 66 124 74 \" +\n \"C 110 72 90 66 70 62 C 50 58 30 58 16 62 C 10 64 6 66 4 66 Z\";\nconst BRIM_2 =\n \"M 4 63 C 2 58 6 55 14 53 C 22 51 26 49 30 46 C 42 40 56 36 66 36 C 80 37 96 42 110 46 C 120 48 128 50 132 51 \" +\n \"C 140 54 148 58 151 63 C 152 67 150 70 146 70 C 128 68 112 64 98 59 C 84 54 70 50 60 50 C 48 50 36 55 26 59 C 18 62 10 66 4 63 Z\";\n\n/** A shape in metal: its colour, the lit top (above `lit`), what else is on it (cut to it), the outline. */\nconst metal = (id: string, d: string, m: Material, lit: number, extra = \"\") =>\n path(d, m.base, { stroke: \"none\" }) +\n clipped(\n id,\n d,\n flat(\n `M -5 -5 L 160 -5 L 160 ${lit} C 110 ${lit + 6} 50 ${lit + 6} -5 ${lit} Z`,\n m.light,\n 0.55,\n ),\n extra,\n ) +\n path(d, \"none\");\n\n/** Chain mail: rows of small rings. */\nconst mail = (\n x0: number,\n y0: number,\n x1: number,\n y1: number,\n c = \"#4e555e\",\n) => {\n let d = \"\";\n for (let y = y0, row = 0; y < y1; y += 4.2, row++)\n for (let x = x0 + (row % 2) * 2.5; x < x1; x += 5)\n d += `M ${x} ${y} C ${x} ${y + 3} ${x + 4} ${y + 3} ${x + 4} ${y} `;\n return line(d, c, 1.3);\n};\n\nexport const helmetsArt: Record<string, ArtDef> = {\n // A knight's great helm: closed steel, an eye slit, breathing holes, a gold cross down the face.\n \"hat/great_helm\": {\n svg: metal(\n \"gh\",\n SHELL,\n STEEL,\n 26,\n flat(\"M 0 100 C 30 110 80 112 120 92 L 120 124 L 0 124 Z\", STEEL.dark) +\n line(\"M 4 99 C 30 109 80 111 114 91\", INK, 2.6) +\n flat(\"M 40 0 L 51 0 L 50 124 L 41 124 Z\", GOLD.base) +\n flat(\"M 40 0 L 44 0 L 43.5 124 L 41 124 Z\", GOLD.light, 0.8) +\n line(\"M 40 0 L 41 124 M 51 0 L 50 124\", INK, 2) +\n flat(\"M 6 54 C 26 58 52 60 86 57 L 86 66 C 52 69 26 67 6 63 Z\", INK) +\n flat(\"M 40 52 L 51 52 L 51 70 L 40 70 Z\", GOLD.base) +\n [\n [20, 80],\n [28, 84],\n [20, 89],\n [28, 93],\n [62, 82],\n [70, 86],\n [62, 91],\n [70, 95],\n ]\n .map(([x, y]) => circle(x!, y!, 1.9, INK, { stroke: \"none\" }))\n .join(\"\") +\n scratches(STEEL, [72, 22, 80, 26], [90, 70, 96, 74], [16, 36, 22, 34]) +\n [12, 30, 62, 80]\n .map((x) => rivet(x, 50 + (x > 50 ? 1 : 0), 2, STEEL))\n .join(\"\"),\n ),\n },\n // A knight's helm, open at the face: a steel dome with a gold-trimmed brow, a cheek plate,\n // chain mail down the neck.\n \"hat/knight_helm_low\": {\n svg:\n metal(\n \"kh\",\n CAP,\n STEEL,\n 20,\n flat(\"M 72 54 L 120 54 L 120 116 L 72 116 Z\", \"#8c939b\") +\n mail(74, 56, 112, 112) +\n flat(\"M 0 56 L 13 56 L 13 110 L 0 110 Z\", STEEL.dark) +\n line(\"M 2 50 C 30 44 70 44 112 50\", GOLD.dark, 9) +\n line(\"M 2 50 C 30 44 70 44 112 50\", GOLD.base, 5.4) +\n line(\"M 56 4 C 50 16 48 30 50 46\", STEEL.dark, 2.4) +\n scratches(STEEL, [26, 22, 32, 26], [80, 18, 86, 22]),\n ) +\n line(\"M 4 51 C 30 45 70 45 108 51\", INK, 1.6) +\n rivet(8, 66, 2, STEEL) +\n rivet(8, 92, 2, STEEL),\n },\n // A winged helm: a steel dome, a gold band with a sapphire, white wings where horns were.\n \"hat/winged_helm_low\": {\n svg:\n [\n [\n \"M 32 20 C 22 12 12 6 3 4 C 5 13 9 20 15 25 C 9 26 5 28 3 32 C 11 36 19 38 25 38 C 21 40 19 42 21 45 L 35 32 Z\",\n \"M 6 8 L 22 22 M 6 30 L 24 33\",\n ],\n [\n \"M 97 22 C 107 14 119 8 131 5 C 129 14 125 20 119 25 C 125 26 129 28 131 32 C 123 36 113 38 105 38 L 99 34 Z\",\n \"M 126 9 L 108 22 M 127 31 L 108 33\",\n ],\n ]\n .map(([d, f]) => path(d!, \"#f2f2f6\") + line(f!, \"#b8bcc8\", 1.6))\n .join(\"\") +\n metal(\n \"wh\",\n \"M 17 48 C 22 28 40 10 64 7 C 82 5 95 12 100 18 L 118 50 L 120 62 L 17 62 Z\",\n STEEL,\n 20,\n scratches(STEEL, [44, 26, 50, 22], [90, 30, 96, 34]),\n ) +\n path(\n \"M 10 49 C 40 42 90 46 122 60 C 125 64 125 74 123 80 C 90 66 40 58 12 64 C 7 62 7 52 10 49 Z\",\n GOLD.base,\n ) +\n line(\"M 12 51 C 40 45 88 49 120 62\", GOLD.light, 2) +\n gem(58, 56, 5, \"#3a7ad8\"),\n },\n // A wizard's hat: a tall blue cone bent at the tip, a gold band, stars and a moon on it.\n \"hat/wizard_hat\": {\n svg:\n path(BRIM_2, \"#23307e\") +\n line(\"M 30 50 C 50 44 80 44 112 50\", \"#3a4aa8\", 2.4) +\n path(\n \"M 30 48 C 40 36 52 22 66 12 C 78 4 96 0 116 4 C 104 8 96 14 96 24 C 96 34 108 44 128 50 C 96 42 60 42 30 48 Z\",\n \"#2a3a9a\",\n { stroke: \"none\" },\n ) +\n clipped(\n \"wzh\",\n \"M 30 48 C 40 36 52 22 66 12 C 78 4 96 0 116 4 C 104 8 96 14 96 24 C 96 34 108 44 128 50 C 96 42 60 42 30 48 Z\",\n flat(\"M 60 0 L 160 0 L 160 60 L 96 60 C 92 40 88 20 96 0 Z\", \"#1e2a78\"),\n flat(\"M 0 36 L 160 36 L 160 50 L 0 50 Z\", GOLD.base),\n line(\"M 0 37 L 160 37\", GOLD.dark, 1.6),\n flat(star(60, 24, 5.5, 10), \"#ffe060\") +\n flat(star(86, 12, 4, -8), \"#ffe060\") +\n flat(star(104, 30, 3.6), \"#ffe060\"),\n ) +\n path(\n \"M 30 48 C 40 36 52 22 66 12 C 78 4 96 0 116 4 C 104 8 96 14 96 24 C 96 34 108 44 128 50 C 96 42 60 42 30 48 Z\",\n \"none\",\n ) +\n path(\"M 80 34 C 76 40 78 46 84 48 C 80 44 80 38 84 34 Z\", \"#fff0a0\", {\n width: 1.6,\n }),\n },\n // A ranger's hood: green cloth, its edge folded back, a seam over the top.\n \"hat/ranger_hood_low\": {\n svg:\n path(CAP, \"#3f7a3a\", { stroke: \"none\" }) +\n clipped(\n \"rh\",\n CAP,\n flat(\"M 0 76 C 30 72 70 74 114 80 L 114 116 L 0 116 Z\", \"#2f5e2c\"),\n flat(\n \"M 60 0 L 120 0 L 120 116 L 80 116 C 76 80 78 40 60 0 Z\",\n \"#356a32\",\n ),\n line(\"M 2 50 C 30 46 70 46 112 52\", \"#244a22\", 10),\n line(\"M 2 49 C 30 45 70 45 112 51\", \"#5a9a50\", 3),\n dashed(\n \"M 8 30 C 30 18 60 14 84 22 C 94 26 102 34 108 46\",\n \"#244a22\",\n 2,\n \"4 3\",\n ),\n ) +\n path(CAP, \"none\") +\n line(\"M 74 66 C 76 80 76 96 78 108\", \"#244a22\", 2),\n },\n // An iron helm: a steel cone with a gold ridge, a riveted rim.\n \"hat/iron_helm\": {\n svg:\n metal(\n \"ih\",\n DOME_6,\n STEEL,\n 24,\n flat(\n \"M 66 0 L 80 0 C 86 20 92 40 96 66 L 84 66 C 80 42 74 20 66 0 Z\",\n GOLD.base,\n ) +\n line(\n \"M 66 0 C 74 20 80 42 84 66 M 80 0 C 86 20 92 40 96 66\",\n INK,\n 2,\n ) +\n line(\"M 4 62 C 40 52 90 58 124 70\", STEEL.dark, 9) +\n scratches(STEEL, [30, 30, 36, 34], [102, 30, 108, 36]),\n ) +\n line(\"M 6 64 C 40 55 90 61 122 72\", INK, 1.6) +\n [14, 34, 56, 100, 116]\n .map((x) =>\n rivet(x, x < 60 ? 60 - x * 0.08 : 64 + (x - 100) * 0.3, 2, STEEL),\n )\n .join(\"\"),\n },\n};\n"
240
+ },
241
+ {
242
+ "path": "character/packs/fantasy/index.ts",
243
+ "content": "import type { ArtPack } from \"../../core/catalog\";\nimport { armorArt } from \"./armor\";\nimport { helmetsArt } from \"./helmets\";\nimport { rangedArt } from \"./ranged\";\nimport { shieldsArt } from \"./shields\";\nimport { weaponsArt } from \"./weapons\";\n\n// The fantasy set — images of its parts (rigs/hero/wear.ts): weapons, bows and guns (their parts:\n// rigs/hero/ranged.ts), shields, helmets, armour, gauntlets,\n// boots, capes.\n\nexport const fantasyPack: ArtPack = {\n id: \"fantasy\",\n art: {\n ...weaponsArt,\n ...rangedArt,\n ...shieldsArt,\n ...helmetsArt,\n ...armorArt,\n },\n};\n"
244
+ },
245
+ {
246
+ "path": "character/packs/fantasy/kit.ts",
247
+ "content": "import { circle, clipped, flat, line, path, tone } from \"../../art/svg\";\n\n// The materials of the fantasy set: a heavy outline, the material's own\n// tone, a bevel of light along an edge, a darker half, scratches and nicks on metal, wraps on grips,\n// grain on wood. The light and dark sides over the whole image come from the rig's shading.\n\nexport interface Material {\n base: string;\n light: string;\n dark: string;\n /** Lines drawn on it (scratches, grain, seams). */\n mark: string;\n}\n\nconst mat = (\n base: string,\n light: string,\n dark: string,\n mark = tone(base, -0.45),\n): Material => ({ base, light, dark, mark });\n\nexport const STEEL = mat(\"#b8c0c8\", \"#eef2f5\", \"#848e99\", \"#5e6873\");\nexport const DARK_STEEL = mat(\"#4a5160\", \"#7d8696\", \"#30353f\", \"#22262e\");\nexport const IRON = mat(\"#7d838c\", \"#a9afb7\", \"#5a5f68\", \"#40444b\");\nexport const GOLD = mat(\"#e8b23a\", \"#ffe28a\", \"#b07818\", \"#8a5a10\");\nexport const WOOD = mat(\"#9a6236\", \"#c08250\", \"#6e4222\", \"#5a3418\");\nexport const LEATHER = mat(\"#7a4628\", \"#a0663e\", \"#55301a\", \"#3e2212\");\nexport const DARK_LEATHER = mat(\"#4a3226\", \"#6c4c3a\", \"#30201a\", \"#20140e\");\nexport const RUST = \"#9a5a2e\";\n\n/** A shape in a material: its fill, a lit band along its top (`bevel`: how far the band reaches, a\n * y below which it ends) and a darker lower part, cut to it, then the outline. */\nexport function plate(\n id: string,\n d: string,\n m: Material,\n o: { light?: string; dark?: string; extra?: string; width?: number } = {},\n): string {\n return (\n path(d, m.base, { stroke: \"none\" }) +\n clipped(\n id,\n d,\n o.dark ? flat(o.dark, m.dark) : \"\",\n o.light ? flat(o.light, m.light) : \"\",\n o.extra ?? \"\",\n ) +\n path(d, \"none\", o.width ? { width: o.width } : {})\n );\n}\n\n/** Scratches: short thin lines in the material's mark colour. */\nexport const scratches = (\n m: Material,\n ...segs: [number, number, number, number][]\n): string =>\n line(\n segs.map(([x1, y1, x2, y2]) => `M ${x1} ${y1} L ${x2} ${y2}`).join(\" \"),\n m.mark,\n 1.2,\n );\n\n/** A gem: a round stone with a shine. */\nexport const gem = (x: number, y: number, r: number, c: string): string =>\n circle(x, y, r, c, { width: Math.min(2.6, r * 0.7) }) +\n flat(\n `M ${x - r * 0.55} ${y - r * 0.05} C ${x - r * 0.5} ${y - r * 0.55} ${x - r * 0.05} ${y - r * 0.65} ${x + r * 0.2} ${y - r * 0.55} C ${x - r * 0.1} ${y - r * 0.35} ${x - r * 0.35} ${y - r * 0.2} ${x - r * 0.55} ${y - r * 0.05} Z`,\n \"#ffffff\",\n 0.85,\n );\n\n/** A rivet. */\nexport const rivet = (x: number, y: number, r: number, m: Material): string =>\n circle(x, y, r, m.base, { width: 1.8 }) +\n circle(x - r * 0.3, y - r * 0.3, r * 0.35, m.light, { stroke: \"none\" });\n\n/** A grip wrapped in leather between x0 and x1, centred on y, half height h. */\nexport function grip(\n id: string,\n x0: number,\n x1: number,\n y: number,\n h: number,\n m: Material = LEATHER,\n): string {\n const d = `M ${x0} ${y - h} L ${x1} ${y - h} L ${x1} ${y + h} L ${x0} ${y + h} Z`;\n let wraps = \"\";\n for (let x = x0 + 3; x < x1 - 1; x += 5)\n wraps += `M ${x} ${y - h} L ${x + 4} ${y + h} `;\n return (\n path(d, m.base, { stroke: \"none\" }) +\n clipped(\n id,\n d,\n flat(\n `M ${x0} ${y + h * 0.25} L ${x1} ${y + h * 0.25} L ${x1} ${y + h} L ${x0} ${y + h} Z`,\n m.dark,\n ),\n line(wraps, m.mark, 1.6),\n ) +\n path(d, \"none\", { width: 3 })\n );\n}\n\n/** A straight blade along x: the tip at `tip`, the base at `base`, centred on y, half width `hw`;\n * a lit edge on top, the lower half darker, a fuller down the middle. `edge` — a wavy or notched\n * edge instead of a straight one is drawn by passing its outline. */\nexport function blade(\n id: string,\n tip: number,\n base: number,\n y: number,\n hw: number,\n m: Material,\n o: { d?: string; fuller?: string; extra?: string } = {},\n): string {\n const neck = tip + Math.min(hw * 2.6, (base - tip) * 0.22);\n const d =\n o.d ??\n `M ${tip} ${y} L ${neck} ${y - hw} L ${base} ${y - hw} L ${base} ${y + hw} L ${neck} ${y + hw} Z`;\n return (\n path(d, m.base, { stroke: \"none\" }) +\n clipped(\n id,\n d,\n flat(\n `M 0 ${y + 0.5} L ${base + 2} ${y + 0.5} L ${base + 2} ${y + hw + 4} L 0 ${y + hw + 4} Z`,\n m.dark,\n ),\n flat(\n `M 0 ${y - hw - 4} L ${base + 2} ${y - hw - 4} L ${base + 2} ${y - hw + hw * 0.32} L 0 ${y - hw + hw * 0.32} Z`,\n m.light,\n ),\n o.fuller === \"none\"\n ? \"\"\n : line(\n `M ${neck + 4} ${y} L ${base - 4} ${y}`,\n o.fuller ?? m.mark,\n Math.max(1.6, hw * 0.28),\n ),\n o.extra ?? \"\",\n ) +\n path(d, \"none\")\n );\n}\n\n/** A shaft of wood along x, grain lines on it. */\nexport function shaft(\n id: string,\n x0: number,\n x1: number,\n y: number,\n h: number,\n m: Material = WOOD,\n): string {\n const d = `M ${x0} ${y - h} L ${x1} ${y - h} C ${x1 + h * 0.6} ${y - h} ${x1 + h * 0.6} ${y + h} ${x1} ${y + h} L ${x0} ${y + h} Z`;\n const grain = [];\n for (let x = x0 + 8; x < x1 - 8; x += 17)\n grain.push(\n `M ${x} ${y - h * 0.2} L ${x + 9} ${y - h * 0.2}`,\n `M ${x + 6} ${y + h * 0.45} L ${x + 13} ${y + h * 0.45}`,\n );\n return (\n path(d, m.base, { stroke: \"none\" }) +\n clipped(\n id,\n d,\n flat(\n `M ${x0 - 2} ${y + h * 0.2} L ${x1 + h} ${y + h * 0.2} L ${x1 + h} ${y + h + 2} L ${x0 - 2} ${y + h + 2} Z`,\n m.dark,\n ),\n line(grain.join(\" \"), m.mark, 1.3),\n ) +\n path(d, \"none\", { width: 3 })\n );\n}\n"
248
+ },
249
+ {
250
+ "path": "character/packs/fantasy/ranged.ts",
251
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport { circle, flat, group, INK, line, path, rect } from \"../../art/svg\";\nimport {\n DARK_STEEL,\n gem,\n GOLD,\n IRON,\n LEATHER,\n plate,\n rivet,\n STEEL,\n WOOD,\n} from \"./kit\";\n\n// The fantasy set's ranged weapons (rigs/hero/ranged.ts places them in the hand): the muzzle or the\n// arrow's head to the LEFT, as every held weapon. A bow comes in three images the shot switches\n// between: at rest (an arrow nocked), drawn (the string pulled back), shot (the arrow gone); a\n// crossbow in two (loaded, shot).\n\n/** A bow on a W × H canvas: the riser at x `rx`, the tips at x `tx` (y `top` and `H - top`), the\n * string straight between them or pulled to x `pull`; an arrow from the nock leftwards. */\nfunction bow(o: {\n h: number;\n rx: number;\n tx: number;\n top: number;\n pull: number | null;\n arrow: boolean;\n wood: typeof WOOD;\n trim: string;\n recurve?: boolean;\n}): string {\n const { h, rx, tx, top, pull, arrow, wood, trim } = o;\n const mid = h / 2;\n const limb = (s: 1 | -1) => {\n const ty = mid - s * (mid - top);\n return o.recurve\n ? `M ${rx} ${mid - s * 8} C ${rx - 4} ${mid - s * 26} ${tx - 18} ${ty + s * 14} ${tx - 4} ${ty + s * 6} C ${tx} ${ty + s * 3} ${tx - 6} ${ty} ${tx - 10} ${ty - s * 2}`\n : `M ${rx} ${mid - s * 8} C ${rx - 6} ${mid - s * 26} ${tx - 14} ${ty + s * 10} ${tx} ${ty}`;\n };\n const nockX = pull ?? tx;\n const string = `M ${tx} ${top} L ${nockX} ${mid} L ${tx} ${h - top}`;\n const shaft = arrow\n ? line(`M ${nockX + 1} ${mid} L ${nockX - 32} ${mid}`, INK, 4.6) +\n line(`M ${nockX + 1} ${mid} L ${nockX - 32} ${mid}`, \"#c8a070\", 2) +\n path(\n `M ${nockX - 32} ${mid - 4} L ${nockX - 40} ${mid} L ${nockX - 32} ${mid + 4} Z`,\n STEEL.base,\n { width: 1.8 },\n ) +\n path(\n `M ${nockX + 1} ${mid} L ${nockX - 7} ${mid - 4.5} L ${nockX - 11} ${mid - 4.5} L ${nockX - 5} ${mid} L ${nockX - 11} ${mid + 4.5} L ${nockX - 7} ${mid + 4.5} Z`,\n \"#e04040\",\n { width: 1.4 },\n )\n : \"\";\n return (\n line(string, INK, 2.6) +\n line(string, \"#f0e8d8\", 1.1) +\n [1, -1]\n .map(\n (s) =>\n line(limb(s as 1 | -1), INK, 9) +\n line(limb(s as 1 | -1), wood.base, 5.4) +\n line(limb(s as 1 | -1), wood.light, 1.6),\n )\n .join(\"\") +\n plate(\n \"bg\",\n `M ${rx - 4.5} ${mid - 11} L ${rx + 4.5} ${mid - 11} L ${rx + 4.5} ${mid + 11} L ${rx - 4.5} ${mid + 11} Z`,\n LEATHER,\n {\n extra: line(\n `M ${rx - 5} ${mid - 7} L ${rx + 5} ${mid - 4} M ${rx - 5} ${mid - 1} L ${rx + 5} ${mid + 2} M ${rx - 5} ${mid + 5} L ${rx + 5} ${mid + 8}`,\n LEATHER.dark,\n 1.4,\n ),\n },\n ) +\n [1, -1]\n .map((s) => circle(tx, mid - s * (mid - top), 2.4, trim, { width: 1.6 }))\n .join(\"\") +\n shaft\n );\n}\n\nconst HUNT = { h: 112, rx: 26, tx: 42, top: 6, wood: WOOD, trim: GOLD.base };\nconst ELVEN = {\n h: 128,\n rx: 26,\n tx: 44,\n top: 9,\n wood: {\n ...WOOD,\n base: \"#e8e0c8\",\n light: \"#ffffff\",\n dark: \"#b8a888\",\n mark: \"#8a7a5a\",\n },\n trim: \"#5ad0a0\",\n recurve: true,\n};\n\n/** A crossbow: a stock along x, the prod (its bow) across near the front, the string cocked or not. */\nfunction crossbow(loaded: boolean): string {\n const string = loaded ? \"M 22 5 L 48 28 L 22 51\" : \"M 22 5 L 28 28 L 22 51\";\n return (\n plate(\n \"cbs\",\n \"M 12 23 L 96 23 C 100 23 101 25 101 28 L 101 33 L 86 33 L 82 46 C 81 48 78 48 76 47 L 78 33 L 12 33 Z\",\n WOOD,\n {\n light: \"M 0 20 L 110 20 L 110 26 L 0 26 Z\",\n extra: line(\"M 30 28 L 70 28\", WOOD.mark, 1.3),\n },\n ) +\n line(string, INK, 2.6) +\n line(string, \"#f0e8d8\", 1.1) +\n line(\"M 22 5 C 14 14 12 22 12 28 C 12 34 14 42 22 51\", INK, 8.5) +\n line(\"M 22 5 C 14 14 12 22 12 28 C 12 34 14 42 22 51\", STEEL.base, 5) +\n line(\"M 21 7 C 15 15 14 22 14 28\", STEEL.light, 1.4) +\n plate(\"cbm\", \"M 8 24 L 18 24 L 18 32 L 8 32 Z\", IRON) +\n plate(\"cbl\", \"M 46 21 L 58 21 L 58 25 L 46 25 Z\", IRON) +\n rivet(64, 28, 1.8, GOLD) +\n rivet(90, 28, 1.8, GOLD) +\n (loaded\n ? line(\"M 47 21 L 8 21\", INK, 4.4) +\n line(\"M 47 21 L 8 21\", \"#c8a070\", 2) +\n path(\"M 8 17.5 L 2 21 L 8 24.5 Z\", STEEL.base, { width: 1.6 }) +\n path(\n \"M 47 21 L 42 17.5 L 39 17.5 L 43 21 L 39 24.5 L 42 24.5 Z\",\n \"#e04040\",\n { width: 1.2 },\n )\n : \"\")\n );\n}\n\n/** Room left of a gun's muzzle for its flash, px. */\nexport const FLASH = 20;\n\n/** A muzzle flash: a star of fire at the muzzle (x FLASH, y). */\nconst flash = (y: number) =>\n path(\n `M ${FLASH + 2} ${y} L 13 ${y - 9} L 12 ${y - 3} L 2 ${y - 5} L 8 ${y} L 2 ${y + 5} L 12 ${y + 3} L 13 ${y + 9} Z`,\n \"#ffb020\",\n { width: 2 },\n ) +\n path(`M ${FLASH} ${y} L 13 ${y - 4} L 9 ${y} L 13 ${y + 4} Z`, \"#fff4b0\", {\n stroke: \"none\",\n });\n\n/** A gun moved right to leave room for its flash; `fired` draws the flash. */\nconst gun = (art: string, muzzleY: number, fired: boolean) =>\n group(`translate(${FLASH} 0)`, art) + (fired ? flash(muzzleY) : \"\");\n\nconst PISTOL =\n plate(\n \"pg\",\n \"M 44 12 L 62 12 C 66 18 70 26 72 32 C 73 35 70 37 66 36 L 58 35 C 56 28 52 22 46 20 Z\",\n WOOD,\n {\n light: \"M 40 8 L 76 8 L 76 18 L 40 18 Z\",\n extra: line(\"M 52 22 L 62 32\", WOOD.mark, 1.3),\n },\n ) +\n plate(\"pb\", \"M 3 9 L 50 9 L 50 17 L 3 17 Z\", STEEL, {\n light: \"M 0 6 L 54 6 L 54 11 L 0 11 Z\",\n }) +\n plate(\"pl\", \"M 40 8 L 56 8 L 56 20 L 40 20 Z\", GOLD, {\n light: \"M 38 6 L 58 6 L 58 11 L 38 11 Z\",\n }) +\n path(\"M 52 8 L 55 2.5 L 58 4 L 56 8 Z\", IRON.base, { width: 1.8 }) +\n path(\"M 48 20 C 48 26 52 28 56 26\", \"none\", { stroke: INK, width: 2 }) +\n rect(2.5, 8, 4, 10, 1, GOLD.base, { width: 1.6 }) +\n rivet(66, 32, 1.8, GOLD);\n\nconst MUSKET =\n plate(\n \"ms\",\n \"M 40 15 L 104 15 L 116 18 C 128 16 140 12 156 10 C 158 18 158 28 156 34 C 140 32 126 28 116 26 L 104 22 L 40 22 Z\",\n WOOD,\n {\n light: \"M 30 8 L 160 8 L 160 16 L 30 16 Z\",\n extra: line(\"M 120 22 C 134 22 144 24 152 26\", WOOD.mark, 1.4),\n },\n ) +\n plate(\"mb\", \"M 3 11 L 100 11 L 100 17 L 3 17 Z\", DARK_STEEL, {\n light: \"M 0 8 L 104 8 L 104 13 L 0 13 Z\",\n }) +\n [30, 62, 92]\n .map((x) => rect(x, 9.5, 4, 14, 1, GOLD.base, { width: 1.6 }))\n .join(\"\") +\n plate(\"ml\", \"M 100 10 L 116 10 L 116 22 L 100 22 Z\", GOLD) +\n path(\"M 110 10 L 113 4 L 116 5.5 L 114 10 Z\", IRON.base, { width: 1.8 }) +\n path(\"M 106 22 C 106 28 110 30 114 28\", \"none\", { stroke: INK, width: 2 }) +\n plate(\"mp\", \"M 154 9 L 158 9 L 158 35 L 154 35 Z\", IRON);\n\nconst RIFLE =\n plate(\n \"rm\",\n \"M 72 24 L 86 24 C 88 32 90 40 94 46 L 82 49 C 78 42 74 34 72 24 Z\",\n DARK_STEEL,\n { extra: line(\"M 76 30 L 87 30\", DARK_STEEL.mark, 1.2) },\n ) +\n plate(\"rg\", \"M 98 26 L 108 26 L 112 42 L 103 43 Z\", DARK_STEEL) +\n plate(\n \"rs\",\n \"M 104 14 L 134 12 C 137 12 138 14 138 17 L 138 32 C 138 34 136 35 134 34 L 116 28 L 104 26 Z\",\n DARK_STEEL,\n {\n light: \"M 100 8 L 140 8 L 140 17 L 100 17 Z\",\n },\n ) +\n plate(\n \"rr\",\n \"M 56 12 L 106 12 L 106 26 L 56 26 Z\",\n { ...DARK_STEEL, base: \"#3a404c\" },\n {\n light: \"M 50 8 L 110 8 L 110 15 L 50 15 Z\",\n extra: rect(74, 15, 18, 4, 1, \"#22262e\", { stroke: \"none\" }),\n },\n ) +\n plate(\n \"rh\",\n \"M 26 13 L 58 13 L 58 24 L 26 24 Z\",\n { ...DARK_STEEL, base: \"#5a6070\" },\n { extra: line(\"M 32 18.5 L 54 18.5\", \"#22262e\", 2.2) },\n ) +\n plate(\"rb\", \"M 3 16 L 28 16 L 28 21 L 3 21 Z\", IRON) +\n path(\"M 20 13 L 22 6 L 26 6 L 26 13 Z\", DARK_STEEL.base, { width: 2 }) +\n path(\"M 62 12 L 64 7 L 90 7 L 92 12 Z\", DARK_STEEL.base, { width: 2 }) +\n flat(\"M 66 8.5 L 88 8.5 L 88 10 L 66 10 Z\", \"#7a8292\") +\n gem(102, 19, 1.6, \"#d03030\");\n\nexport const rangedArt: Record<string, ArtDef> = {\n // A hunting bow: a plain wooden bow, a leather grip, a red-fletched arrow.\n \"weapon/hunting_bow\": {\n svg: bow({ ...HUNT, pull: null, arrow: true }),\n },\n \"weapon/hunting_bow_drawn\": {\n svg: bow({ ...HUNT, pull: 62, arrow: true }),\n },\n \"weapon/hunting_bow_shot\": {\n svg: bow({ ...HUNT, pull: null, arrow: false }),\n },\n // An elven longbow: pale wood, recurved tips, jade fittings.\n \"weapon/elven_bow\": {\n svg: bow({ ...ELVEN, pull: null, arrow: true }),\n },\n \"weapon/elven_bow_drawn\": {\n svg: bow({ ...ELVEN, pull: 64, arrow: true }),\n },\n \"weapon/elven_bow_shot\": {\n svg: bow({ ...ELVEN, pull: null, arrow: false }),\n },\n // A crossbow, loaded with a bolt and after the shot.\n \"weapon/crossbow\": { svg: crossbow(true) },\n \"weapon/crossbow_shot\": { svg: crossbow(false) },\n // A flintlock pistol: a steel barrel, a brass lock, a curved wooden grip.\n \"weapon/pistol\": { svg: gun(PISTOL, 13, false) },\n \"weapon/pistol_shot\": { svg: gun(PISTOL, 13, true) },\n // A musket: a long barrel on a wooden stock, brass bands, a butt plate.\n \"weapon/musket\": { svg: gun(MUSKET, 14, false) },\n \"weapon/musket_shot\": { svg: gun(MUSKET, 14, true) },\n // An assault rifle: black, a curved magazine, a pistol grip, a stock.\n \"weapon/rifle\": { svg: gun(RIFLE, 18.5, false) },\n \"weapon/rifle_shot\": { svg: gun(RIFLE, 18.5, true) },\n};\n"
252
+ },
253
+ {
254
+ "path": "character/packs/fantasy/shields.ts",
255
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport { circle, clipped, flat, line, path, tone } from \"../../art/svg\";\nimport {\n DARK_STEEL,\n gem,\n GOLD,\n IRON,\n rivet,\n scratches,\n STEEL,\n WOOD,\n type Material,\n} from \"./kit\";\n\n// The fantasy set's shields, on the classic shield canvases: a shield lies on its side there — its\n// top edge at the left of the image, its point at the right — and faces the viewer.\n\nconst KITE =\n \"M 5.5 3.5 L 55 3.5 C 68 4.5 76 18 82.5 40 C 76.5 60 68 72 52 76 L 6 78.5 C 4.5 78.5 3.5 77.5 3.5 76 L 3.5 6 C 3.5 4.5 4.5 3.5 5.5 3.5 Z\";\nconst HEATER =\n \"M 7 3 L 58 3 C 70 4 80 14 92 36 C 94 39 94 42 92 45 C 80 66 70 76 58 77 L 7 78 C 4 78 3 76 3 72 L 3 8 C 3 5 4 3 7 3 Z\";\nconst TOWER =\n \"M 6 8 C 6 5 9 4 14 4 L 98 3.5 C 102 3.5 105 6 105 10 L 105 62 C 105 70 102 76 96 78 L 16 80 C 10 82 6 84 4 80 L 3.5 14 C 3.5 11 4 9 6 8 Z\";\nconst disc = (cx: number, cy: number, r: number) =>\n `M ${cx - r} ${cy} A ${r} ${r} 0 1 0 ${cx + r} ${cy} A ${r} ${r} 0 1 0 ${cx - r} ${cy} Z`;\n\n/** A shield: the field, its device (cut to it), a metal rim round the edge, the outline. */\nconst shield = (\n id: string,\n d: string,\n field: string,\n rim: Material,\n device: string,\n rimWidth = 8,\n) =>\n path(d, field, { stroke: \"none\" }) +\n clipped(\n id,\n d,\n device,\n path(d, \"none\", { stroke: rim.base, width: rimWidth }),\n path(d, \"none\", { stroke: rim.light, width: rimWidth * 0.3, opacity: 0.6 }),\n ) +\n path(d, \"none\");\n\nexport const shieldsArt: Record<string, ArtDef> = {\n // A kite shield: red, a white cross, a steel rim with rivets.\n \"offhand/kite_shield\": {\n svg:\n shield(\n \"ks\",\n KITE,\n \"#b8282c\",\n STEEL,\n flat(\n \"M 0 35 L 90 35 L 90 46 L 0 46 Z M 33 0 L 44 0 L 44 82 L 33 82 Z\",\n \"#f2eee8\",\n ) +\n flat(\"M 38.5 30 L 44 35 L 38.5 40.5 L 33 35 Z\", GOLD.base) +\n scratches(STEEL, [14, 14, 20, 18], [56, 58, 62, 54]),\n ) +\n [\n [8, 8],\n [8, 73],\n [52, 8],\n [50, 72],\n [76, 40],\n ]\n .map(([x, y]) => rivet(x!, y!, 2.2, STEEL))\n .join(\"\"),\n },\n // A heater shield: royal blue, a gold chevron, a gold rim.\n \"offhand/heater_shield\": {\n svg: shield(\n \"hs\",\n HEATER,\n \"#2a58a8\",\n GOLD,\n path(\"M 18 10 L 58 40.5 L 18 71 L 18 58 L 42 40.5 L 18 23 Z\", GOLD.base, {\n width: 2.6,\n }) +\n flat(\"M 18 10 L 58 40.5 L 54 40.5 L 18 14 Z\", GOLD.light, 0.8) +\n [\n [64, 22],\n [64, 59],\n [30, 40.5],\n ]\n .map(([x, y]) => gem(x!, y!, 3, \"#f2eee8\"))\n .join(\"\"),\n ),\n },\n // A round viking shield: planks of wood, an iron rim, an iron boss, rivets round it.\n \"offhand/round_shield\": {\n svg:\n shield(\n \"rs\",\n disc(44.5, 44.5, 42),\n WOOD.base,\n IRON,\n [14, 28, 42, 56, 70]\n .map((x, i) =>\n flat(\n `M ${x} 0 L ${x + 14} 0 L ${x + 14} 90 L ${x} 90 Z`,\n i % 2 ? WOOD.light : WOOD.base,\n ),\n )\n .join(\"\") +\n line(\n \"M 14 0 L 14 90 M 28 0 L 28 90 M 42 0 L 42 90 M 56 0 L 56 90 M 70 0 L 70 90\",\n WOOD.mark,\n 1.8,\n ) +\n line(\n \"M 20 20 L 24 26 M 62 64 L 66 70 M 50 14 L 52 20\",\n WOOD.mark,\n 1.2,\n ),\n 9,\n ) +\n [0, 45, 90, 135, 180, 225, 270, 315]\n .map((a) => {\n const r = (a * Math.PI) / 180;\n return rivet(\n 44.5 + Math.cos(r) * 35.5,\n 44.5 + Math.sin(r) * 35.5,\n 2,\n IRON,\n );\n })\n .join(\"\") +\n circle(44.5, 44.5, 12, IRON.base) +\n flat(\n \"M 37 41 C 38 37 41 35 45 35 C 41 37.5 39.5 40 39.5 44 Z\",\n \"#ffffff\",\n 0.7,\n ),\n },\n // A tower shield: dark steel, a gold rim, a sun of gold round a ruby.\n \"offhand/tower_shield\": {\n svg:\n shield(\n \"ts\",\n TOWER,\n DARK_STEEL.base,\n GOLD,\n flat(\"M 0 0 L 110 0 L 110 26 L 0 26 Z\", tone(DARK_STEEL.base, 0.12)) +\n [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]\n .map((a) => {\n const r = (a * Math.PI) / 180;\n const at = (rr: number, d: number) =>\n `${(54 + Math.cos(r + d) * rr).toFixed(1)} ${(42 + Math.sin(r + d) * rr).toFixed(1)}`;\n return path(\n `M ${at(10, -0.16)} L ${at(24, 0)} L ${at(10, 0.16)} Z`,\n GOLD.base,\n { width: 1.6 },\n );\n })\n .join(\"\") +\n scratches(\n DARK_STEEL,\n [20, 20, 28, 24],\n [80, 60, 86, 56],\n [24, 64, 30, 66],\n ),\n ) +\n circle(54, 42, 10, GOLD.base) +\n gem(54, 42, 5.6, \"#d0202a\"),\n },\n // A steel buckler: rings round a spiked boss.\n \"offhand/buckler\": {\n svg:\n shield(\n \"bk\",\n disc(36.5, 36.5, 34.5),\n STEEL.base,\n IRON,\n circle(36.5, 36.5, 23, \"none\", { stroke: STEEL.dark, width: 2.4 }) +\n flat(\"M 0 0 L 73 0 L 0 50 Z\", STEEL.light, 0.45) +\n scratches(STEEL, [16, 46, 22, 50], [50, 20, 54, 26]),\n 7,\n ) +\n circle(36.5, 36.5, 11, STEEL.base) +\n path(\"M 32 36.5 L 36.5 27 L 41 36.5 L 36.5 46 Z\", STEEL.light, {\n width: 2,\n }) +\n [45, 135, 225, 315]\n .map((a) => {\n const r = (a * Math.PI) / 180;\n return rivet(\n 36.5 + Math.cos(r) * 29,\n 36.5 + Math.sin(r) * 29,\n 1.9,\n IRON,\n );\n })\n .join(\"\"),\n },\n};\n"
256
+ },
257
+ {
258
+ "path": "character/packs/fantasy/weapons.ts",
259
+ "content": "import type { ArtDef } from \"../../core/catalog\";\nimport { circle, flat, INK, line, path } from \"../../art/svg\";\nimport {\n blade,\n DARK_LEATHER,\n DARK_STEEL,\n gem,\n GOLD,\n grip,\n IRON,\n LEATHER,\n plate,\n rivet,\n RUST,\n scratches,\n shaft,\n STEEL,\n WOOD,\n} from \"./kit\";\n\n// The fantasy set's weapons. Each lies on the canvas of a classic weapon (rigs/hero/wear.ts): the\n// business end to the LEFT, the grip where that weapon's grip is — that is where the hand holds.\n\nexport const weaponsArt: Record<string, ArtDef> = {\n // A knight's sword: a tapered steel blade, a gold cross-guard with a ruby, a leather grip, a gold pommel.\n \"weapon/knight_sword\": {\n svg:\n blade(\"ks\", 3, 106, 25, 8.5, STEEL, {\n extra: scratches(\n STEEL,\n [40, 19, 46, 21],\n [70, 29, 76, 27],\n [88, 20, 92, 22],\n ),\n }) +\n grip(\"ksg\", 114, 140, 25, 5.5) +\n plate(\n \"ksx\",\n \"M 104 8 C 104 5 106 4 108 4 C 110 4 112 5 112 8 L 112 42 C 112 45 110 46 108 46 C 106 46 104 45 104 42 Z\",\n GOLD,\n {\n light: \"M 100 0 L 108 0 L 108 50 L 100 50 Z\",\n dark: \"M 100 32 L 120 32 L 120 50 L 100 50 Z\",\n },\n ) +\n gem(108, 25, 3.2, \"#d0202a\") +\n circle(146.5, 25, 6.2, GOLD.base) +\n gem(146.5, 25, 2.8, \"#d0202a\"),\n },\n // A dark knight's blade: black steel, a blue rune glowing down it, a bat-wing guard, a spiked pommel.\n \"weapon/dark_blade\": {\n svg:\n blade(\"db\", 3, 113, 24, 9, DARK_STEEL, {\n d: \"M 3 24 L 22 15 L 40 15 L 44 12 L 48 15 L 70 15 L 74 12 L 78 15 L 113 15 L 113 33 L 22 33 Z\",\n fuller: \"#5fd0ff\",\n extra: flat(\n \"M 30 22.6 L 106 22.6 L 106 25.4 L 30 25.4 Z\",\n \"#bff0ff\",\n 0.8,\n ),\n }) +\n grip(\"dbg\", 124, 146, 24, 6, DARK_LEATHER) +\n plate(\n \"dbx\",\n \"M 113 18 C 108 10 108 4 114 3 C 118 8 122 12 124 18 L 124 30 C 122 36 118 40 114 45 C 108 44 108 38 113 30 Z\",\n DARK_STEEL,\n {\n light: \"M 100 0 L 116 0 L 116 50 L 100 50 Z\",\n },\n ) +\n gem(118.5, 24, 3.4, \"#4ab0ff\") +\n path(\"M 146 18 L 156 24 L 146 30 Z\", DARK_STEEL.base),\n },\n // A broadsword like the old ones of the promo: wide, nicked, rust on it, an iron bar for a guard.\n \"weapon/broadsword\": {\n svg:\n blade(\"bs\", 4, 112, 32, 14, STEEL, {\n d: \"M 4 32 L 22 18 L 46 18 L 50 21 L 55 18 L 112 18 L 112 46 L 78 46 L 74 43 L 70 46 L 22 46 Z\",\n extra:\n flat(\n \"M 30 34 C 36 30 44 31 46 36 C 48 41 42 44 36 43 C 33 42 31 39 30 34 Z M 84 22 C 88 20 94 22 94 26 C 92 29 88 29 84 27 Z\",\n RUST,\n 0.85,\n ) +\n scratches(\n STEEL,\n [58, 24, 66, 27],\n [62, 38, 70, 36],\n [96, 34, 104, 36],\n [16, 30, 22, 28],\n ),\n }) +\n plate(\"bsx\", \"M 112 12 L 120 12 L 120 52 L 112 52 Z\", IRON, {\n light: \"M 108 0 L 115 0 L 115 60 L 108 60 Z\",\n }) +\n grip(\"bsg\", 120, 148, 32, 6, DARK_LEATHER) +\n circle(152, 32, 5.5, IRON.base) +\n rivet(116, 16, 1.8, IRON) +\n rivet(116, 48, 1.8, IRON),\n },\n // A flame sword: a red blade with waved edges and gold diamonds, a gold guard with a ruby.\n \"weapon/flame_sword\": {\n svg:\n blade(\n \"fs\",\n 3,\n 128,\n 23,\n 9,\n { base: \"#c42a28\", light: \"#ff7a50\", dark: \"#8a1418\", mark: \"#5a0a0e\" },\n {\n d: \"M 3 23 L 16 15 C 26 12 30 18 40 15 C 50 12 56 18 66 15 C 76 12 82 18 92 15 C 102 12 110 16 128 14 L 128 32 C 110 30 102 34 92 31 C 82 28 76 34 66 31 C 56 28 50 34 40 31 C 30 28 26 34 16 31 Z\",\n fuller: \"none\",\n extra: [36, 62, 88, 112]\n .map((x) =>\n path(\n `M ${x - 5} 23 L ${x} 19 L ${x + 5} 23 L ${x} 27 Z`,\n GOLD.base,\n { stroke: GOLD.dark, width: 1.4 },\n ),\n )\n .join(\"\"),\n },\n ) +\n plate(\n \"fsx\",\n \"M 128 9 C 132 7 136 9 137 13 L 137 33 C 136 37 132 39 128 37 Z\",\n GOLD,\n { light: \"M 124 0 L 132 0 L 132 47 L 124 47 Z\" },\n ) +\n gem(132.5, 23, 3, \"#ff3040\") +\n grip(\"fsg\", 138, 164, 23, 4) +\n path(\"M 164 17 L 172 23 L 164 29 Z\", GOLD.base),\n },\n // A dagger: a short leaf blade, a small guard, a wrapped grip, a ball pommel.\n \"weapon/dagger\": {\n svg:\n blade(\"dg\", 3, 62, 12, 6.5, STEEL, {\n d: \"M 3 12 C 12 7 24 5.5 40 5.5 L 62 6 L 62 18 L 40 18.5 C 24 18.5 12 17 3 12 Z\",\n extra: scratches(STEEL, [30, 9, 34, 10]),\n }) +\n plate(\n \"dgx\",\n \"M 60 3 C 62 2 66 2 67 3 L 67 21 C 66 22 62 22 60 21 Z\",\n GOLD,\n { light: \"M 56 0 L 63 0 L 63 29 L 56 29 Z\" },\n ) +\n grip(\"dgg\", 67, 98, 11.5, 4.5, LEATHER) +\n circle(103, 11.5, 4.6, GOLD.base) +\n gem(103, 11.5, 2, \"#3a9ad0\"),\n },\n // A battle axe: a bearded steel head under a wooden haft, an iron collar.\n \"weapon/battle_axe\": {\n svg:\n shaft(\"bah\", 14, 108, 10.5, 6) +\n plate(\n \"ba\",\n \"M 24 15 L 50 15 C 52 26 58 36 66 42 C 56 52 40 57 26 56 C 14 55 6 50 3 44 C 12 36 20 26 24 15 Z\",\n STEEL,\n {\n light:\n \"M 0 50 C 14 52 30 52 50 48 C 58 46 64 43 70 40 L 70 60 L 0 60 Z\",\n dark: \"M 20 14 L 52 14 C 52 22 54 28 58 34 C 44 30 30 30 18 34 Z\",\n extra:\n scratches(STEEL, [20, 44, 26, 42], [40, 38, 46, 40]) +\n line(\"M 8 44 C 22 50 44 50 62 41\", STEEL.dark, 2),\n },\n ) +\n plate(\"bac\", \"M 20 2.5 L 52 2.5 L 52 18.5 L 20 18.5 Z\", IRON, {\n light: \"M 18 0 L 54 0 L 54 7 L 18 7 Z\",\n }) +\n rivet(27, 10.5, 2.2, IRON) +\n rivet(45, 10.5, 2.2, IRON) +\n grip(\"bag\", 80, 106, 10.5, 6.4, LEATHER),\n },\n // A double-bitted war axe: two crescent blades either side of the haft, a spike at its end.\n \"weapon/double_axe\": {\n svg:\n shaft(\"dah\", 12, 106, 46, 7) +\n [1, -1]\n .map((s, i) => {\n const y = (v: number) => 46 - s * v;\n const d = `M 20 ${y(6)} C 16 ${y(16)} 8 ${y(26)} 3 ${y(36)} C 16 ${y(43)} 42 ${y(43)} 55 ${y(36)} C 50 ${y(26)} 42 ${y(16)} 38 ${y(6)} Z`;\n return plate(`da${i}`, d, STEEL, {\n light: `M 0 ${y(34)} C 16 ${y(40)} 42 ${y(40)} 58 ${y(34)} L 58 ${y(50)} L 0 ${y(50)} Z`,\n extra:\n line(\n `M 8 ${y(33)} C 20 ${y(38)} 38 ${y(38)} 50 ${y(33)}`,\n STEEL.dark,\n 2,\n ) + scratches(STEEL, [24, y(22), 30, y(24)]),\n });\n })\n .join(\"\") +\n plate(\"dac\", \"M 17 36 L 41 36 L 41 56 L 17 56 Z\", GOLD, {\n light: \"M 15 34 L 43 34 L 43 41 L 15 41 Z\",\n }) +\n gem(29, 46, 3.4, \"#d0202a\") +\n path(\"M 12 40 L 3 46 L 12 52 Z\", IRON.base) +\n grip(\"dag\", 76, 104, 46, 7.4, LEATHER),\n },\n // A flanged mace: a steel ball with six fins, a gold collar, a leather grip.\n \"weapon/mace\": {\n svg:\n shaft(\"mh\", 54, 134, 35.5, 5.5, IRON) +\n [0, 60, 120, 180, 240, 300]\n .map((a, i) => {\n const r = (a * Math.PI) / 180;\n const at = (rr: number, d: number) =>\n `${(31 + Math.cos(r + d) * rr).toFixed(1)} ${(36 + Math.sin(r + d) * rr).toFixed(1)}`;\n return plate(\n `mf${i}`,\n `M ${at(12, -0.5)} L ${at(26.5, -0.13)} L ${at(26.5, 0.13)} L ${at(12, 0.5)} Z`,\n STEEL,\n { width: 2.8 },\n );\n })\n .join(\"\") +\n plate(\"mb\", \"M 13 36 A 18 18 0 1 0 49 36 A 18 18 0 1 0 13 36 Z\", STEEL, {\n light: \"M 0 0 L 60 0 L 60 28 C 44 22 24 22 0 30 Z\",\n dark: \"M 0 44 C 20 50 40 50 60 42 L 60 70 L 0 70 Z\",\n }) +\n flat(\n \"M 22 26 C 26 22 32 21 36 22 C 30 24 26 27 24 31 Z\",\n \"#ffffff\",\n 0.7,\n ) +\n plate(\"mc\", \"M 48 28 L 58 28 L 58 43 L 48 43 Z\", GOLD, {\n light: \"M 46 26 L 60 26 L 60 32 L 46 32 Z\",\n }) +\n grip(\"mg\", 104, 134, 35.5, 7, LEATHER) +\n circle(137, 35.5, 4.2, GOLD.base),\n },\n // A warhammer: a steel block with a flared face, a spike above and below, a wooden haft.\n \"weapon/warhammer\": {\n svg:\n shaft(\"wh\", 36, 98, 33, 5) +\n path(\n \"M 16 13 L 22 2.5 L 28 13 Z M 16 53 L 22 62.5 L 28 53 Z\",\n STEEL.base,\n ) +\n plate(\n \"wb\",\n \"M 10 12 L 34 12 L 37 18 L 37 48 L 34 54 L 10 54 L 8 48 L 8 18 Z\",\n STEEL,\n {\n light: \"M 0 0 L 45 0 L 45 20 L 0 20 Z\",\n dark: \"M 0 42 L 45 42 L 45 65 L 0 65 Z\",\n extra:\n line(\"M 10 33 L 36 33\", STEEL.dark, 2) +\n scratches(STEEL, [14, 26, 20, 28], [24, 40, 30, 38]),\n },\n ) +\n plate(\"wf\", \"M 3 17 C 3 15 5 15 8 16 L 8 50 C 5 51 3 51 3 49 Z\", IRON) +\n rivet(22, 22, 2.2, IRON) +\n rivet(22, 44, 2.2, IRON) +\n grip(\"whg\", 70, 98, 33, 6, LEATHER),\n },\n // A spear: a leaf blade, a gold socket with a red tassel, a long ash shaft, an iron butt cap.\n \"weapon/spear\": {\n svg:\n shaft(\"sp\", 40, 140, 18, 3.6) +\n path(\n \"M 44 22 C 42 28 40 32 38 37 M 46 22 C 46 28 46 32 45 37 M 48 22 C 50 28 51 32 52 36\",\n \"none\",\n { stroke: INK, width: 5.5 },\n ) +\n path(\n \"M 44 22 C 42 28 40 32 38 37 M 46 22 C 46 28 46 32 45 37 M 48 22 C 50 28 51 32 52 36\",\n \"none\",\n { stroke: \"#d02a2a\", width: 2.6 },\n ) +\n blade(\"spb\", 3, 36, 18, 8, STEEL, {\n d: \"M 3 18 C 12 10 22 8 30 10 L 36 14 L 36 22 L 30 26 C 22 28 12 26 3 18 Z\",\n }) +\n plate(\"sps\", \"M 34 12.5 L 46 13.5 L 46 22.5 L 34 23.5 Z\", GOLD, {\n light: \"M 30 10 L 50 10 L 50 16 L 30 16 Z\",\n }) +\n plate(\n \"spc\",\n \"M 138 13.5 L 144 13.5 C 145.5 13.5 145.5 22.5 144 22.5 L 138 22.5 Z\",\n IRON,\n ) +\n grip(\"spg\", 110, 136, 18, 4.6, LEATHER),\n },\n // A wizard's staff: a glowing blue orb held in wooden claws, gold rings round the staff.\n \"weapon/wizard_staff\": {\n svg:\n circle(17, 21, 15.5, \"#7ad8ff\", { stroke: \"none\", opacity: 0.35 }) +\n shaft(\"wz\", 30, 129, 21, 5.5) +\n path(\n \"M 30 15 C 24 8 18 6 12 7 M 30 27 C 24 34 18 36 12 35 M 31 21 L 34 21\",\n \"none\",\n { stroke: INK, width: 7 },\n ) +\n path(\"M 30 15 C 24 8 18 6 12 7 M 30 27 C 24 34 18 36 12 35\", \"none\", {\n stroke: WOOD.base,\n width: 3.4,\n }) +\n plate(\n \"wzo\",\n \"M 6 21 A 11 11 0 1 0 28 21 A 11 11 0 1 0 6 21 Z\",\n { base: \"#3a9ae0\", light: \"#bff0ff\", dark: \"#1d5aa8\", mark: \"#123a70\" },\n {\n dark: \"M 0 24 C 10 30 22 30 32 24 L 32 40 L 0 40 Z\",\n extra:\n circle(14, 16, 3.6, \"#ffffff\", { stroke: \"none\", opacity: 0.9 }) +\n circle(21, 26, 1.6, \"#ffffff\", { stroke: \"none\" }),\n },\n ) +\n plate(\"wz1\", \"M 42 14 L 48 14 L 48 28 L 42 28 Z\", GOLD) +\n plate(\"wz2\", \"M 118 14.5 L 123 14.5 L 123 27.5 L 118 27.5 Z\", GOLD),\n },\n // A druid's staff: a crook of living wood curling round a green stone, leaves sprouting.\n \"weapon/druid_staff\": {\n svg:\n shaft(\"dr\", 28, 138, 16, 5) +\n path(\n \"M 30 16 C 22 16 10 15 8 11 C 6 7 12 6 18 7.5 C 24 9 26 13 22 15\",\n \"none\",\n { stroke: INK, width: 9.5 },\n ) +\n path(\n \"M 30 16 C 22 16 10 15 8 11 C 6 7 12 6 18 7.5 C 24 9 26 13 22 15\",\n \"none\",\n { stroke: WOOD.base, width: 5 },\n ) +\n gem(15.5, 11.5, 3.6, \"#3ad070\") +\n [\n [34, 22, 20],\n [60, 9, -25],\n [96, 22, 30],\n ]\n .map(\n ([x, y, r]) =>\n `<g transform=\"rotate(${r} ${x} ${y})\">` +\n path(\n `M ${x! - 6} ${y} C ${x! - 3} ${y! - 4} ${x! + 3} ${y! - 4} ${x! + 6} ${y} C ${x! + 3} ${y! + 4} ${x! - 3} ${y! + 4} ${x! - 6} ${y} Z`,\n \"#5ab040\",\n { width: 2 },\n ) +\n line(`M ${x! - 5} ${y} L ${x! + 5} ${y}`, \"#2e6a20\", 1.2) +\n `</g>`,\n )\n .join(\"\") +\n grip(\"drg\", 110, 138, 16, 5.6, LEATHER),\n },\n};\n"
260
+ },
261
+ {
262
+ "path": "character/packs/index.ts",
263
+ "content": "import type { ArtPack } from \"../core/catalog\";\nimport { basePack } from \"./base\";\nimport { fantasyPack } from \"./fantasy\";\n\n// Every art pack the game ships, in order (a later pack's image of the same path wins). A new pack is\n// a folder next to base/ and one line here.\n\nexport const allPacks: readonly ArtPack[] = [basePack, fantasyPack];\n"
264
+ },
265
+ {
266
+ "path": "character/render/canvas/live.ts",
267
+ "content": "import {\n Animator,\n type AnimationEvent,\n type PlayOptions,\n} from \"../../core/animator\";\nimport { worldGeometry } from \"../../core/attachments\";\nimport type { ArtCatalog } from \"../../core/catalog\";\nimport { skinImages } from \"../../core/data\";\nimport type { CharacterLook } from \"../../core/look\";\nimport { PartsManager } from \"../../core/PartsManager\";\nimport type { RigDef } from \"../../core/rig\";\nimport { Skeleton } from \"../../core/skeleton\";\nimport { hexToNumber } from \"../../core/types\";\nimport {\n createThumbnailer,\n vectorPictures,\n type PictureArt,\n} from \"./thumbnail\";\n\n// A character on a page without an engine — a hero on a screen of the UI (the heroes screen): one\n// <canvas> filling its host, the figure standing on the bottom edge, centred. Live, it plays like the\n// game's CharacterView (the same frame: the animation, the stance for what is held, the face for the\n// animation, worn colours, the world, every slot's triangles) on a small WebGL drawer of its own; still,\n// it is one frame drawn by the thumbnailer on a 2D canvas (a list of many heroes — a page may hold only\n// a few WebGL canvases). Without WebGL a live one falls back to still.\n\nexport interface CharacterCanvasOptions {\n rig: RigDef;\n catalog: ArtCatalog;\n look?: CharacterLook;\n /** 1 — the rig's own facing, -1 — mirrored. */\n facing?: 1 | -1;\n /** \"live\" (default) — moving; \"still\" — one frame of the rig's default animation. */\n mode?: \"live\" | \"still\";\n /** The animation to start with (default the rig's). */\n animation?: string;\n /**\n * How far the canvas reaches past its host, as shares of the host's size: the standing body fills\n * the host, while what sticks out — a long weapon, a sword raised — is drawn over the space around\n * (the canvas lets clicks through). Default: live on WebGL { side: 0.7, top: 0.5 }, still — none.\n */\n overflow?: { side: number; top: number };\n}\n\n/** Room round the standing body inside the host, as shares of it. */\nconst ROOM = { side: 0.04, top: 0.04 };\n\nexport class CharacterCanvas {\n readonly canvas: HTMLCanvasElement;\n readonly parts: PartsManager;\n readonly animator: Animator;\n private readonly skeleton: Skeleton;\n private readonly rig: RigDef;\n private readonly facing: 1 | -1;\n private readonly gl: Gl | null;\n private readonly overflow: { side: number; top: number };\n private readonly offs: Array<() => void> = [];\n private asked: { name: string; options?: PlayOptions } | null = null;\n private skinsDirty = true;\n private frame = 0;\n private last = 0;\n private visible = true;\n private dead = false;\n private stillDrawing: Promise<void> | null = null;\n private stillAgain = false;\n\n constructor(\n host: HTMLElement,\n private readonly options: CharacterCanvasOptions,\n ) {\n const rig = (this.rig = options.rig);\n this.facing = options.facing ?? 1;\n this.skeleton = new Skeleton(rig.data);\n const catalog = options.catalog;\n this.parts = new PartsManager(rig, (skin) =>\n skinImages(rig.data, skin).every((p) => catalog.has(p)),\n );\n if (options.look) this.parts.applyLook(options.look);\n this.animator = new Animator(this.skeleton, rig.data, rig.mix);\n this.animator.setBlink(rig.blink ?? null);\n this.play(options.animation ?? rig.defaultAnimation, { mix: 0 });\n\n const canvas = (this.canvas = document.createElement(\"canvas\"));\n this.gl = options.mode === \"still\" ? null : Gl.create(canvas, rig, catalog);\n // a still frame is framed on what is drawn: no room past the host\n const over = (this.overflow =\n options.overflow ??\n (this.gl ? { side: 0.7, top: 0.5 } : { side: 0, top: 0 }));\n canvas.style.cssText =\n `position:absolute;display:block;pointer-events:none;` +\n `left:${-over.side * 100}%;width:${(1 + 2 * over.side) * 100}%;` +\n `top:${-over.top * 100}%;height:${(1 + over.top) * 100}%`;\n if (getComputedStyle(host).position === \"static\")\n host.style.position = \"relative\";\n host.appendChild(canvas);\n\n this.offs.push(\n this.parts.on(() => {\n this.skinsDirty = true;\n if (!this.gl) this.drawStill();\n }),\n );\n const resize = new ResizeObserver(() => this.resize());\n resize.observe(canvas);\n this.offs.push(() => resize.disconnect());\n this.resize();\n if (this.gl) {\n // off screen (a closed tab of the lobby) it stops drawing\n const seen = new IntersectionObserver(([e]) => {\n this.visible = !!e?.isIntersecting;\n });\n seen.observe(canvas);\n this.offs.push(() => seen.disconnect());\n const loop = (now: number) => {\n if (this.dead) return;\n const dt = this.last ? Math.min(0.1, (now - this.last) / 1000) : 0;\n this.last = now;\n if (this.visible) this.update(dt);\n this.frame = requestAnimationFrame(loop);\n };\n this.frame = requestAnimationFrame(loop);\n }\n }\n\n /** Plays an animation — or the rig's stance of it for what is worn (a walk with a rifle). */\n play(animation: string, options?: PlayOptions): boolean {\n this.asked = { name: animation, ...(options ? { options } : {}) };\n return this.animator.play(this.stanceOf(animation), options);\n }\n\n /** An animation event, \"end\", or \"*\". Returns the unsubscribe. */\n on(event: string, listener: (e: AnimationEvent) => void): () => void {\n const off = this.animator.on(event, listener);\n this.offs.push(off);\n return off;\n }\n\n setLook(look: CharacterLook): void {\n this.parts.reset();\n this.parts.applyLook(look);\n }\n\n destroy(): void {\n this.dead = true;\n cancelAnimationFrame(this.frame);\n for (const off of this.offs.splice(0)) off();\n this.gl?.destroy();\n this.canvas.remove();\n }\n\n private stanceOf(animation: string): string {\n const parts = this.parts;\n return (\n this.rig.stance?.(animation, (t) =>\n parts.isWorn(t) ? parts.part(t) : null,\n ) ?? animation\n );\n }\n\n private resize(): void {\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n const w = Math.max(1, Math.round(this.canvas.clientWidth * dpr));\n const h = Math.max(1, Math.round(this.canvas.clientHeight * dpr));\n if (w === this.canvas.width && h === this.canvas.height) return;\n this.canvas.width = w;\n this.canvas.height = h;\n if (!this.gl) this.drawStill();\n }\n\n /** Where the rig's px land on the canvas: x → a·x + b, y (up) → c − a·y. */\n private view(): { a: number; b: number; c: number } {\n const d = this.rig.data;\n const side = d.width * ROOM.side;\n const w = d.width + side * 2;\n const h = d.height * (1 + ROOM.top);\n const a = Math.min(\n this.canvas.width / (1 + 2 * this.overflow.side) / w,\n this.canvas.height / (1 + this.overflow.top) / h,\n );\n const mid = (d.x + d.width / 2) * this.facing;\n return {\n a,\n b: this.canvas.width / 2 - mid * a,\n c: this.canvas.height + d.y * a,\n };\n }\n\n private update(dt: number): void {\n const gl = this.gl!;\n const sk = this.skeleton;\n const faces = this.rig.expressions;\n if (faces)\n this.parts.setExpression(\n faces.byAnimation[this.animator.current() ?? \"\"] ?? null,\n );\n if (this.skinsDirty) {\n this.skinsDirty = false;\n sk.setSkins(\n this.parts.wornSkins().flatMap((n) => {\n const s = this.rig.data.skins.get(n);\n return s ? [s] : [];\n }),\n );\n const asked = this.asked;\n if (asked && this.stanceOf(asked.name) !== this.animator.current())\n this.animator.play(this.stanceOf(asked.name), {\n ...asked.options,\n mix: 0.15,\n });\n }\n this.animator.update(dt);\n sk.slots.forEach((slot, i) => {\n const c = this.parts.slotColor(i);\n if (!c) return;\n const n = hexToNumber(c);\n slot.color.r = ((n >> 16) & 255) / 255;\n slot.color.g = ((n >> 8) & 255) / 255;\n slot.color.b = (n & 255) / 255;\n slot.color.a = 1;\n });\n sk.updateWorldTransform();\n const { a, b, c } = this.view();\n gl.begin(this.canvas.width, this.canvas.height, a * this.facing, b, c, a);\n sk.slots.forEach((slot, i) => {\n const at = slot.attachment;\n if (!at || slot.color.a * at.color.a <= 0) return;\n const g = worldGeometry(sk, slot, at, null);\n gl.draw(\n at.path,\n this.parts.artColor(i),\n g.positions,\n g.uvs,\n g.triangles,\n [\n slot.color.r * at.color.r,\n slot.color.g * at.color.g,\n slot.color.b * at.color.b,\n ],\n slot.color.a * at.color.a,\n );\n });\n }\n\n /** One frame on a 2D canvas (still, or no WebGL); a change while drawing draws again after. */\n private drawStill(): void {\n if (this.dead) return;\n if (this.stillDrawing) {\n this.stillAgain = true;\n return;\n }\n const parts = this.parts;\n const flipped = this.facing < 0;\n const target = flipped ? document.createElement(\"canvas\") : this.canvas;\n target.width = this.canvas.width;\n target.height = this.canvas.height;\n this.stillDrawing = stillThumbnailer(this.rig, this.options.catalog)\n .draw(target, {\n skins: parts.wornSkins(),\n slotColor: (i) => parts.slotColor(i),\n artColor: (i) => parts.artColor(i),\n frame: \"content\",\n padding: 0.04,\n })\n .then(() => {\n if (!flipped || this.dead) return;\n const ctx = this.canvas.getContext(\"2d\")!;\n ctx.setTransform(-1, 0, 0, 1, this.canvas.width, 0);\n ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);\n ctx.drawImage(target, 0, 0);\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n })\n .finally(() => {\n this.stillDrawing = null;\n if (this.stillAgain) {\n this.stillAgain = false;\n this.drawStill();\n }\n });\n }\n}\n\nconst thumbnailers = new WeakMap<\n RigDef,\n ReturnType<typeof createThumbnailer>\n>();\n\nfunction stillThumbnailer(rig: RigDef, catalog: ArtCatalog) {\n let t = thumbnailers.get(rig);\n if (!t)\n thumbnailers.set(\n rig,\n (t = createThumbnailer(rig, pictures(rig, catalog, 3))),\n );\n return t;\n}\n\n/** The rasterized art of a rig, made once per resolution and shared by every canvas. */\nconst pictureCache = new WeakMap<RigDef, Map<number, PictureArt>>();\n\nfunction pictures(\n rig: RigDef,\n catalog: ArtCatalog,\n resolution: number,\n): PictureArt {\n let byRes = pictureCache.get(rig);\n if (!byRes) pictureCache.set(rig, (byRes = new Map()));\n let p = byRes.get(resolution);\n if (!p) byRes.set(resolution, (p = vectorPictures(rig, catalog, resolution)));\n return p;\n}\n\n// ── The WebGL drawer ─────────────────────────────────────────────────────────────────────────────\n\nconst VERTEX = `\nattribute vec2 a_pos;\nattribute vec2 a_uv;\nuniform vec4 u_view;\nvarying vec2 v_uv;\nvoid main() {\n v_uv = a_uv;\n gl_Position = vec4(a_pos * u_view.xy + u_view.zw, 0.0, 1.0);\n}`;\n\nconst FRAGMENT = `\nprecision mediump float;\nuniform sampler2D u_tex;\nuniform vec4 u_tint;\nvarying vec2 v_uv;\nvoid main() {\n gl_FragColor = texture2D(u_tex, v_uv) * vec4(u_tint.rgb * u_tint.a, u_tint.a);\n}`;\n\n/** Triangles of the art on one WebGL canvas: a texture per image, premultiplied alpha. */\nclass Gl {\n private readonly textures = new Map<string, WebGLTexture | \"loading\">();\n private readonly pos: WebGLBuffer;\n private readonly uv: WebGLBuffer;\n private readonly index: WebGLBuffer;\n private readonly program: WebGLProgram;\n private readonly uView: WebGLUniformLocation;\n private readonly uTint: WebGLUniformLocation;\n private readonly art: PictureArt;\n private lost = false;\n\n static create(\n canvas: HTMLCanvasElement,\n rig: RigDef,\n catalog: ArtCatalog,\n ): Gl | null {\n const gl = canvas.getContext(\"webgl\", {\n premultipliedAlpha: true,\n alpha: true,\n antialias: true,\n });\n if (!gl) return null;\n try {\n return new Gl(gl, canvas, pictures(rig, catalog, 3));\n } catch {\n return null;\n }\n }\n\n private constructor(\n private readonly gl: WebGLRenderingContext,\n canvas: HTMLCanvasElement,\n art: PictureArt,\n ) {\n this.art = art;\n const shader = (type: number, src: string) => {\n const s = gl.createShader(type)!;\n gl.shaderSource(s, src);\n gl.compileShader(s);\n if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))\n throw new Error(gl.getShaderInfoLog(s) ?? \"shader\");\n return s;\n };\n const p = (this.program = gl.createProgram()!);\n gl.attachShader(p, shader(gl.VERTEX_SHADER, VERTEX));\n gl.attachShader(p, shader(gl.FRAGMENT_SHADER, FRAGMENT));\n gl.linkProgram(p);\n if (!gl.getProgramParameter(p, gl.LINK_STATUS))\n throw new Error(gl.getProgramInfoLog(p) ?? \"program\");\n gl.useProgram(p);\n this.uView = gl.getUniformLocation(p, \"u_view\")!;\n this.uTint = gl.getUniformLocation(p, \"u_tint\")!;\n this.pos = gl.createBuffer()!;\n this.uv = gl.createBuffer()!;\n this.index = gl.createBuffer()!;\n const aPos = gl.getAttribLocation(p, \"a_pos\");\n const aUv = gl.getAttribLocation(p, \"a_uv\");\n gl.bindBuffer(gl.ARRAY_BUFFER, this.pos);\n gl.enableVertexAttribArray(aPos);\n gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);\n gl.bindBuffer(gl.ARRAY_BUFFER, this.uv);\n gl.enableVertexAttribArray(aUv);\n gl.vertexAttribPointer(aUv, 2, gl.FLOAT, false, 0, 0);\n gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.index);\n gl.enable(gl.BLEND);\n gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);\n gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);\n canvas.addEventListener(\"webglcontextlost\", () => (this.lost = true));\n }\n\n /** Clears the canvas; px of the rig map to canvas px as x → sx·x + tx, y → ty − sy·y. */\n begin(\n width: number,\n height: number,\n sx: number,\n tx: number,\n ty: number,\n sy: number,\n ): void {\n const gl = this.gl;\n if (this.lost) return;\n gl.viewport(0, 0, width, height);\n gl.clearColor(0, 0, 0, 0);\n gl.clear(gl.COLOR_BUFFER_BIT);\n // canvas px → clip space: x/w·2 − 1, 1 − y/h·2\n gl.uniform4f(\n this.uView,\n (sx / width) * 2,\n (sy / height) * 2,\n (tx / width) * 2 - 1,\n 1 - (ty / height) * 2,\n );\n }\n\n draw(\n path: string,\n paint: string | null,\n positions: Float32Array,\n uvs: Float32Array,\n triangles: Uint16Array,\n tint: [number, number, number],\n alpha: number,\n ): void {\n if (this.lost) return;\n const tex = this.texture(path, paint);\n if (!tex) return;\n const gl = this.gl;\n gl.bindTexture(gl.TEXTURE_2D, tex);\n gl.bindBuffer(gl.ARRAY_BUFFER, this.pos);\n gl.bufferData(gl.ARRAY_BUFFER, positions, gl.DYNAMIC_DRAW);\n gl.bindBuffer(gl.ARRAY_BUFFER, this.uv);\n gl.bufferData(gl.ARRAY_BUFFER, uvs, gl.DYNAMIC_DRAW);\n gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, triangles, gl.DYNAMIC_DRAW);\n gl.uniform4f(this.uTint, tint[0], tint[1], tint[2], alpha);\n gl.drawElements(gl.TRIANGLES, triangles.length, gl.UNSIGNED_SHORT, 0);\n }\n\n destroy(): void {\n const gl = this.gl;\n for (const t of this.textures.values())\n if (t !== \"loading\") gl.deleteTexture(t);\n this.textures.clear();\n gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n }\n\n /** The image's texture, or null while it is being made. */\n private texture(path: string, paint: string | null): WebGLTexture | null {\n const key = paint ? `${path}${paint}` : path;\n const t = this.textures.get(key);\n if (t) return t === \"loading\" ? null : t;\n this.textures.set(key, \"loading\");\n void this.art.image(path, paint).then((img) => {\n if (!img || this.lost) return;\n const gl = this.gl;\n const tex = gl.createTexture()!;\n gl.bindTexture(gl.TEXTURE_2D, tex);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);\n this.textures.set(key, tex);\n });\n return null;\n }\n}\n"
268
+ },
269
+ {
270
+ "path": "character/render/canvas/thumbnail.ts",
271
+ "content": "import { svgDocument } from \"../../art/svg\";\nimport { worldGeometry } from \"../../core/attachments\";\nimport type { ArtCatalog } from \"../../core/catalog\";\nimport { imageSizes } from \"../../core/data\";\nimport { imageLights, type ImageLight } from \"../../core/shading\";\nimport type { RigDef } from \"../../core/rig\";\nimport { Skeleton } from \"../../core/skeleton\";\nimport type { Hex } from \"../../core/types\";\n\n// Still pictures of a character or of single parts on a plain 2D canvas — the editor's tiles, preset\n// portraits, a shop icon — without an engine: the skeleton is posed (the rig's default animation at its\n// first frame), every attachment becomes triangles, each triangle is drawn from the part's own art by\n// an affine map. The skeleton's y is up; the picture's is down, flipped here.\n\nexport interface ThumbnailBackdrop {\n /** What is drawn behind, as a flat silhouette (a head under the face parts). */\n skins: readonly string[];\n /** Only these slots of it (by name); omitted — all. */\n slots?: readonly string[];\n color: Hex;\n}\n\nexport interface ThumbnailRequest {\n skins: readonly string[];\n /** The paint of a slot (by index) over its own colour, null — its own (PartsManager.slotColor). */\n slotColor?: (slotIndex: number) => Hex | null;\n /** The colour drawn into a slot's art (an iris), null — the art's own (PartsManager.artColor). */\n artColor?: (slotIndex: number) => Hex | null;\n /** Only these slots are drawn (by name); omitted — all. */\n slots?: readonly string[];\n backdrop?: ThumbnailBackdrop;\n /** What the picture is framed on: the backdrop (every face part on the same head) or what is drawn. */\n frame?: \"backdrop\" | \"content\";\n /** Free space round the framed box, as a share of the canvas (default 0.08). */\n padding?: number;\n /** The animation and time of the pose (default the rig's default animation at 0). */\n pose?: { animation: string; time: number };\n}\n\n/** Where the pictures' images come from: our vector art (vectorPictures) or any other source. */\nexport interface PictureArt {\n /** `paint` — the colour of the image's painted part (an iris), when the source draws one. */\n image(\n path: string,\n paint?: Hex | null,\n ): Promise<HTMLImageElement | HTMLCanvasElement | null>;\n}\n\n/** Our vector art, each image rasterized once at `resolution`× its size. */\nexport function vectorPictures(\n rig: RigDef,\n catalog: ArtCatalog,\n resolution = 2,\n): PictureArt {\n const sizes = imageSizes(rig.data);\n const lights: ReadonlyMap<string, ImageLight> = rig.shading\n ? imageLights(rig.data, rig.shading)\n : new Map();\n const images = new Map<string, Promise<HTMLImageElement | null>>();\n return {\n image(path, paint) {\n const art = catalog.get(path);\n const colour = art?.paint ? (paint ?? null) : null;\n const id = colour ? `${path}${colour}` : path;\n let p = images.get(id);\n if (p) return p;\n const size = sizes.get(path);\n p =\n art && size\n ? new Promise((resolve) => {\n const img = new Image();\n img.onload = () => resolve(img);\n img.onerror = () => resolve(null);\n img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgDocument(art, size, resolution, { paint: colour, light: lights.get(path) ?? null }))}`;\n })\n : Promise.resolve(null);\n images.set(id, p);\n return p;\n },\n };\n}\n\nexport interface Thumbnailer {\n /** Draws onto the canvas (cleared first), at its own pixel size. */\n draw(canvas: HTMLCanvasElement, request: ThumbnailRequest): Promise<void>;\n}\n\ninterface Piece {\n path: string;\n /** The colour drawn into its art, null — its own. */\n paint: Hex | null;\n positions: Float32Array;\n uvs: Float32Array;\n triangles: Uint16Array;\n /** Multiplied over the art, 0..1 each; or a flat fill for a silhouette. */\n tint: [number, number, number];\n alpha: number;\n fill: Hex | null;\n}\n\nexport function createThumbnailer(rig: RigDef, art: PictureArt): Thumbnailer {\n const painted = new Map<string, HTMLCanvasElement>();\n const skeleton = new Skeleton(rig.data);\n\n /** The art multiplied by a tint, or filled flat (a silhouette) — cached per path and colour. */\n const paint = (\n path: string,\n img: HTMLImageElement | HTMLCanvasElement,\n piece: Piece,\n ): CanvasImageSource => {\n const [r, g, b] = piece.tint;\n if (!piece.fill && r === 1 && g === 1 && b === 1) return img;\n const key = `${path}|${piece.fill ?? `${r.toFixed(3)},${g.toFixed(3)},${b.toFixed(3)}`}`;\n let c = painted.get(key);\n if (c) return c;\n c = document.createElement(\"canvas\");\n c.width = img.width;\n c.height = img.height;\n const ctx = c.getContext(\"2d\")!;\n ctx.drawImage(img, 0, 0);\n ctx.globalCompositeOperation = piece.fill ? \"source-in\" : \"multiply\";\n ctx.fillStyle = piece.fill ?? `rgb(${r * 255}, ${g * 255}, ${b * 255})`;\n ctx.fillRect(0, 0, c.width, c.height);\n if (!piece.fill) {\n // multiply also darkens the transparent pixels: keep the art's own alpha.\n ctx.globalCompositeOperation = \"destination-in\";\n ctx.drawImage(img, 0, 0);\n }\n painted.set(key, c);\n return c;\n };\n\n /** Poses the skeleton in these skins and collects what the chosen slots show. */\n const collect = (\n skins: readonly string[],\n slots: readonly string[] | undefined,\n slotColor: ((i: number) => Hex | null) | undefined,\n artColor: ((i: number) => Hex | null) | undefined,\n fill: Hex | null,\n pose: { animation: string; time: number },\n ): Piece[] => {\n const sk = skeleton;\n sk.setSkins(\n skins.flatMap((n) =>\n rig.data.skins.get(n) ? [rig.data.skins.get(n)!] : [],\n ),\n );\n sk.setupPose();\n rig.data.animations.get(pose.animation)?.apply(sk, pose.time);\n sk.resolveAttachments();\n sk.updateWorldTransform();\n const only = slots ? new Set(slots) : null;\n const out: Piece[] = [];\n sk.slots.forEach((slot, i) => {\n const a = slot.attachment;\n if (!a || (only && !only.has(slot.data.name))) return;\n const paintHex = slotColor?.(i) ?? null;\n const own = paintHex\n ? hexRgb(paintHex)\n : [slot.color.r, slot.color.g, slot.color.b];\n const alpha = slot.color.a * a.color.a;\n if (alpha <= 0) return;\n const g = worldGeometry(sk, slot, a, null);\n out.push({\n path: a.path,\n paint: artColor?.(i) ?? null,\n positions: Float32Array.from(g.positions),\n uvs: g.uvs,\n triangles: g.triangles,\n tint: [own[0]! * a.color.r, own[1]! * a.color.g, own[2]! * a.color.b],\n alpha,\n fill,\n });\n });\n return out;\n };\n\n return {\n async draw(canvas, req) {\n const pose = req.pose ?? { animation: rig.defaultAnimation, time: 0 };\n const behind = req.backdrop\n ? collect(\n req.backdrop.skins,\n req.backdrop.slots,\n undefined,\n undefined,\n req.backdrop.color,\n pose,\n )\n : [];\n const front = collect(\n req.skins,\n req.slots,\n req.slotColor,\n req.artColor,\n null,\n pose,\n );\n const pieces = [...behind, ...front];\n const loaded = await Promise.all(\n pieces.map((p) => art.image(p.path, p.paint)),\n );\n\n const framed = req.frame === \"content\" || !behind.length ? front : behind;\n const box = bounds(framed.length ? framed : pieces);\n const ctx = canvas.getContext(\"2d\")!;\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n if (!box) return;\n const pad = (req.padding ?? 0.08) * Math.min(canvas.width, canvas.height);\n const scale = Math.min(\n (canvas.width - pad * 2) / Math.max(1, box.w),\n (canvas.height - pad * 2) / Math.max(1, box.h),\n );\n const ox = canvas.width / 2 - (box.x + box.w / 2) * scale;\n const oy = canvas.height / 2 + (box.y + box.h / 2) * scale;\n pieces.forEach((p, k) => {\n const img = loaded[k];\n if (!img) return;\n const src = paint(p.paint ? `${p.path}${p.paint}` : p.path, img, p);\n ctx.globalAlpha = p.alpha;\n const t = p.triangles;\n const X = (i: number) => ox + p.positions[i * 2]! * scale;\n const Y = (i: number) => oy - p.positions[i * 2 + 1]! * scale;\n const U = (i: number) => p.uvs[i * 2]! * img.width;\n const V = (i: number) => p.uvs[i * 2 + 1]! * img.height;\n for (let j = 0; j < t.length; j += 3) {\n const a = t[j]!;\n const b = t[j + 1]!;\n const c = t[j + 2]!;\n triangle(\n ctx,\n src,\n X(a),\n Y(a),\n X(b),\n Y(b),\n X(c),\n Y(c),\n U(a),\n V(a),\n U(b),\n V(b),\n U(c),\n V(c),\n );\n }\n });\n ctx.globalAlpha = 1;\n },\n };\n}\n\nfunction bounds(\n pieces: readonly Piece[],\n): { x: number; y: number; w: number; h: number } | null {\n let x0 = Infinity;\n let y0 = Infinity;\n let x1 = -Infinity;\n let y1 = -Infinity;\n for (const p of pieces)\n for (let i = 0; i < p.positions.length; i += 2) {\n x0 = Math.min(x0, p.positions[i]!);\n x1 = Math.max(x1, p.positions[i]!);\n y0 = Math.min(y0, p.positions[i + 1]!);\n y1 = Math.max(y1, p.positions[i + 1]!);\n }\n return x0 === Infinity ? null : { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };\n}\n\n/** One triangle of the image mapped onto the canvas (clipped a hair wider to close the seams). */\nfunction triangle(\n ctx: CanvasRenderingContext2D,\n img: CanvasImageSource,\n x0: number,\n y0: number,\n x1: number,\n y1: number,\n x2: number,\n y2: number,\n u0: number,\n v0: number,\n u1: number,\n v1: number,\n u2: number,\n v2: number,\n): void {\n const du1 = u1 - u0;\n const dv1 = v1 - v0;\n const du2 = u2 - u0;\n const dv2 = v2 - v0;\n const det = du1 * dv2 - du2 * dv1;\n if (Math.abs(det) < 1e-9) return;\n const dx1 = x1 - x0;\n const dy1 = y1 - y0;\n const dx2 = x2 - x0;\n const dy2 = y2 - y0;\n const a = (dx1 * dv2 - dx2 * dv1) / det;\n const b = (dy1 * dv2 - dy2 * dv1) / det;\n const c = (dx2 * du1 - dx1 * du2) / det;\n const d = (dy2 * du1 - dy1 * du2) / det;\n const cx = (x0 + x1 + x2) / 3;\n const cy = (y0 + y1 + y2) / 3;\n const grow = (x: number, y: number): [number, number] => {\n const l = Math.hypot(x - cx, y - cy) || 1;\n return [x + ((x - cx) / l) * 0.6, y + ((y - cy) / l) * 0.6];\n };\n ctx.save();\n ctx.beginPath();\n ctx.moveTo(...grow(x0, y0));\n ctx.lineTo(...grow(x1, y1));\n ctx.lineTo(...grow(x2, y2));\n ctx.closePath();\n ctx.clip();\n ctx.transform(a, b, c, d, x0 - a * u0 - c * v0, y0 - b * u0 - d * v0);\n ctx.drawImage(img, 0, 0);\n ctx.restore();\n}\n\nfunction hexRgb(hex: Hex): [number, number, number] {\n const n = parseInt(hex.replace(\"#\", \"\"), 16);\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];\n}\n"
272
+ },
273
+ {
274
+ "path": "character/render/phaser/artSource.ts",
275
+ "content": "import type Phaser from \"phaser\";\nimport type { ArtCatalog, ArtStyle } from \"../../core/catalog\";\nimport { ensureArtTexture } from \"./textures\";\n\n// Where a character's images come from. The game draws our vector art (`vectorArt`); a tool may plug\n// in another source (bitmaps, say) without the view knowing the difference. Every source gives one\n// texture per image, the image's full size, (0, 0) top-left: attachments' UVs map straight onto it.\n\nexport interface ArtSource {\n /** Can this source draw the image. */\n has(path: string): boolean;\n /** The texture key for an image; null while it is being made (onReady runs once it exists).\n * `style` — its painted part's colour and the light on it, for a source that draws them. */\n texture(\n path: string,\n size: { w: number; h: number },\n onReady: () => void,\n style?: ArtStyle,\n ): string | null;\n}\n\n/** Our SVG art from a catalog, rasterized at `resolution`× per image. */\nexport function vectorArt(\n textures: Phaser.Textures.TextureManager,\n catalog: ArtCatalog,\n resolution = 2,\n): ArtSource {\n return {\n has: (path) => catalog.has(path),\n texture(path, size, onReady, style) {\n const art = catalog.get(path);\n return art\n ? ensureArtTexture(\n textures,\n path,\n art,\n size,\n resolution,\n onReady,\n style,\n )\n : null;\n },\n };\n}\n"
276
+ },
277
+ {
278
+ "path": "character/render/phaser/CharacterView.ts",
279
+ "content": "import Phaser from \"phaser\";\nimport {\n Animator,\n type AnimationEvent,\n type PlayOptions,\n} from \"../../core/animator\";\nimport { worldGeometry } from \"../../core/attachments\";\nimport { imageSizes, skinImages } from \"../../core/data\";\nimport type { CharacterLook } from \"../../core/look\";\nimport { PartsManager } from \"../../core/PartsManager\";\nimport { imageLights, type ImageLight } from \"../../core/shading\";\nimport type { RigDef } from \"../../core/rig\";\nimport { Skeleton } from \"../../core/skeleton\";\nimport { hexToNumber } from \"../../core/types\";\nimport type { ArtSource } from \"./artSource\";\n\n// A character on a Phaser scene: one Mesh2D per slot, in the skeleton's draw order, inside one\n// Container. Each frame: the animator writes the pose, worn colours paint their slots, the skeleton\n// computes the world (constraints included), and every slot's attachment becomes triangles —\n// a region two, a mesh its own. The skeleton's y is up, the screen's down: the view flips y here, only here.\n// The playing animation picks the face (rig.expressions: Angry while attacking, Dead in Die).\n\nexport interface CharacterViewOptions {\n rig: RigDef;\n art: ArtSource;\n look?: CharacterLook;\n x?: number;\n y?: number;\n /** 1 = the rig's own px (the hero is ~188 px tall). */\n scale?: number;\n /** Follow the scene's update by itself (default true); false — call update(dt) yourself. */\n autoUpdate?: boolean;\n /** The animation to start with (default the rig's). */\n animation?: string;\n}\n\n/** Mesh2D has tint and tint mode at runtime (Phaser 4.2), its typings do not list them. */\ntype TintedMesh = Phaser.GameObjects.Mesh2D & {\n setTint(color: number): TintedMesh;\n setTintMode(mode: number): TintedMesh;\n};\n\ninterface SlotView {\n mesh: TintedMesh;\n positions: Float32Array | null;\n vertices: number[];\n indices: number[];\n path: string | null;\n key: string | null;\n /** The colour drawn into the art the texture was made with (an iris). */\n paint: string | null;\n /** A texture is being made for `path`. */\n waiting: boolean;\n}\n\nexport class CharacterView {\n readonly root: Phaser.GameObjects.Container;\n readonly skeleton: Skeleton;\n readonly animator: Animator;\n readonly parts: PartsManager;\n private readonly slots: SlotView[] = [];\n private readonly sizes: ReadonlyMap<string, { w: number; h: number }>;\n private readonly lights: ReadonlyMap<string, ImageLight>;\n private skinsDirty = true;\n private overlay: number | null = null;\n private flashUntil = 0;\n private flashColor = 0xffffff;\n private facing: 1 | -1 = 1;\n private size = 1;\n private readonly offs: Array<() => void> = [];\n /** The animation asked for (play) — what is played may be a stance of it (rig.stance). */\n private asked: { name: string; options?: PlayOptions } | null = null;\n\n readonly rig: RigDef;\n private art: ArtSource;\n\n constructor(\n private readonly scene: Phaser.Scene,\n options: CharacterViewOptions,\n ) {\n const rig = (this.rig = options.rig);\n this.art = options.art;\n this.skeleton = new Skeleton(rig.data);\n this.sizes = imageSizes(rig.data);\n this.lights = rig.shading ? imageLights(rig.data, rig.shading) : new Map();\n this.parts = new PartsManager(rig, (skin) => this.drawsSkin(skin));\n if (options.look) this.parts.applyLook(options.look);\n this.offs.push(this.parts.on(() => (this.skinsDirty = true)));\n this.animator = new Animator(this.skeleton, rig.data, rig.mix);\n this.animator.setBlink(rig.blink ?? null);\n this.play(options.animation ?? rig.defaultAnimation, { mix: 0 });\n\n this.root = scene.add.container(options.x ?? 0, options.y ?? 0);\n for (let i = 0; i < this.skeleton.slots.length; i++) {\n // flipV: our UVs run top-down (v = 0 at the image's top), Mesh2D expects GL's bottom-up.\n const mesh = new Phaser.GameObjects.Mesh2D(\n scene,\n 0,\n 0,\n \"__DEFAULT\",\n [],\n [],\n true,\n ) as TintedMesh;\n mesh.setRenderAsTriangles(true);\n mesh.setVisible(false);\n this.root.add(mesh);\n this.slots.push({\n mesh,\n positions: null,\n vertices: [],\n indices: [],\n path: null,\n key: null,\n paint: null,\n waiting: false,\n });\n }\n this.setScale(options.scale ?? 1);\n if (options.autoUpdate !== false) {\n const tick = (_t: number, deltaMs: number) => this.update(deltaMs / 1000);\n scene.events.on(Phaser.Scenes.Events.UPDATE, tick);\n this.offs.push(() => scene.events.off(Phaser.Scenes.Events.UPDATE, tick));\n }\n this.root.once(Phaser.GameObjects.Events.DESTROY, () => {\n for (const off of this.offs.splice(0)) off();\n });\n this.update(0);\n }\n\n // ── Control ────────────────────────────────────────────────────────────────────────────────\n\n /** Plays an animation — or the rig's stance of it for what is worn (a walk with a rifle). */\n play(animation: string, options?: PlayOptions): boolean {\n this.asked = { name: animation, ...(options ? { options } : {}) };\n return this.animator.play(this.stanceOf(animation), options);\n }\n\n /** The animation last asked for (play), not its stance. */\n animation(): string | null {\n return this.asked?.name ?? null;\n }\n\n private stanceOf(animation: string): string {\n const parts = this.parts;\n return (\n this.rig.stance?.(animation, (t) =>\n parts.isWorn(t) ? parts.part(t) : null,\n ) ?? animation\n );\n }\n\n /** An animation event (\"Attack\", \"Step\", \"Hit\", \"Die\"), \"end\", or \"*\". Returns the unsubscribe. */\n on(event: string, listener: (e: AnimationEvent) => void): () => void {\n const off = this.animator.on(event, listener);\n this.offs.push(off);\n return off;\n }\n\n setLook(look: CharacterLook): void {\n this.parts.applyLook(look);\n }\n\n /** Another art source (the maker swaps to a reference atlas and back). */\n setArt(art: ArtSource): void {\n this.art = art;\n for (const s of this.slots) {\n s.key = s.path = null;\n s.waiting = false;\n }\n this.parts.setDrawable((skin) => this.drawsSkin(skin));\n }\n\n setPosition(x: number, y: number): this {\n this.root.setPosition(x, y);\n return this;\n }\n\n setScale(scale: number): this {\n this.size = scale;\n this.root.setScale(scale * this.facing, scale);\n return this;\n }\n\n /** 1 — the rig's own facing, -1 — mirrored. */\n setFacing(direction: 1 | -1): this {\n this.facing = direction;\n return this.setScale(this.size);\n }\n\n setDepth(depth: number): this {\n this.root.setDepth(depth);\n return this;\n }\n\n setVisible(visible: boolean): this {\n this.root.setVisible(visible);\n return this;\n }\n\n setAlpha(alpha: number): this {\n this.root.setAlpha(alpha);\n return this;\n }\n\n /** A colour multiplied over the whole character (a frenzy glow); null — off. */\n setOverlay(color: number | null): this {\n this.overlay = color;\n return this;\n }\n\n /** The whole character filled with a colour for a moment (a hit flash). */\n flash(color = 0xffffff, ms = 90): void {\n this.flashColor = color;\n this.flashUntil = this.scene.time.now + ms;\n }\n\n /** A bone's position in world space — by anchor role (\"hand\", \"head\") or bone name. */\n worldPoint(boneOrRole: string): { x: number; y: number } {\n const name = this.rig.anchors[boneOrRole] ?? boneOrRole;\n const b = this.skeleton.bone(name).applied;\n const m = this.root.getWorldTransformMatrix();\n return {\n x: m.tx + m.a * b.worldX + m.c * -b.worldY,\n y: m.ty + m.b * b.worldX + m.d * -b.worldY,\n };\n }\n\n /** The character's height on screen (the skeleton's height × scale). */\n get displayHeight(): number {\n return this.rig.data.height * this.size;\n }\n\n destroy(): void {\n this.root.destroy();\n }\n\n // ── Frame ──────────────────────────────────────────────────────────────────────────────────\n\n update(dt: number): void {\n const sk = this.skeleton;\n const faces = this.rig.expressions;\n if (faces)\n this.parts.setExpression(\n faces.byAnimation[this.animator.current() ?? \"\"] ?? null,\n );\n if (this.skinsDirty) {\n this.skinsDirty = false;\n sk.setSkins(\n this.parts.wornSkins().flatMap((n) => {\n const s = this.rig.data.skins.get(n);\n return s ? [s] : [];\n }),\n );\n // What is held changed the stance (a rifle taken up): the same animation, held its new way.\n const asked = this.asked;\n if (asked && this.stanceOf(asked.name) !== this.animator.current())\n this.animator.play(this.stanceOf(asked.name), {\n ...asked.options,\n mix: 0.15,\n });\n }\n this.animator.update(dt);\n sk.slots.forEach((slot, i) => {\n const c = this.parts.slotColor(i);\n if (!c) return;\n const n = hexToNumber(c);\n slot.color.r = ((n >> 16) & 255) / 255;\n slot.color.g = ((n >> 8) & 255) / 255;\n slot.color.b = (n & 255) / 255;\n slot.color.a = 1;\n });\n sk.updateWorldTransform();\n const flashing = this.flashUntil > this.scene.time.now;\n sk.slots.forEach((slot, i) => this.drawSlot(i, flashing));\n }\n\n private drawSlot(i: number, flashing: boolean): void {\n const view = this.slots[i]!;\n const slot = this.skeleton.slots[i]!;\n const a = slot.attachment;\n const mesh = view.mesh;\n if (!a || slot.color.a <= 0) {\n mesh.setVisible(false);\n return;\n }\n const paint = this.parts.artColor(i);\n if (view.path !== a.path || view.paint !== paint) {\n view.path = a.path;\n view.paint = paint;\n view.key = null;\n view.waiting = false;\n }\n if (!view.key && !view.waiting) {\n const size = this.sizes.get(a.path) ?? { w: a.width, h: a.height };\n const path = a.path;\n const key = this.art.texture(\n path,\n size,\n () => {\n if (view.path === path) view.waiting = false; // ask again next frame: it exists now\n },\n { paint, light: this.lights.get(path) ?? null },\n );\n if (key) {\n view.key = key;\n mesh.setTexture(key);\n } else view.waiting = true;\n }\n if (!view.key) {\n mesh.setVisible(false);\n return;\n }\n const g = worldGeometry(this.skeleton, slot, a, view.positions);\n view.positions = g.positions;\n const n = g.uvs.length / 2;\n const v = view.vertices;\n v.length = n * 4;\n for (let k = 0; k < n; k++) {\n v[k * 4] = g.positions[k * 2]!;\n v[k * 4 + 1] = -g.positions[k * 2 + 1]!;\n v[k * 4 + 2] = g.uvs[k * 2]!;\n v[k * 4 + 3] = g.uvs[k * 2 + 1]!;\n }\n const t = g.triangles;\n const ind = view.indices;\n if (ind.length !== (t.length / 3) * 4) {\n ind.length = 0;\n for (let k = 0; k < t.length; k += 3)\n ind.push(t[k]!, t[k + 1]!, t[k + 2]!, 0);\n } else\n for (let k = 0, j = 0; k < t.length; k += 3, j += 4) {\n ind[j] = t[k]!;\n ind[j + 1] = t[k + 1]!;\n ind[j + 2] = t[k + 2]!;\n }\n mesh.vertices = v;\n mesh.indices = ind;\n const c = slot.color;\n const ac = a.color;\n let tint =\n (Math.round(c.r * ac.r * 255) << 16) |\n (Math.round(c.g * ac.g * 255) << 8) |\n Math.round(c.b * ac.b * 255);\n if (this.overlay !== null) tint = multiply(tint, this.overlay);\n if (flashing)\n mesh.setTint(this.flashColor).setTintMode(Phaser.TintModes.FILL);\n else mesh.setTint(tint).setTintMode(Phaser.TintModes.MULTIPLY);\n mesh.setAlpha(c.a * ac.a);\n mesh.setVisible(true);\n }\n\n private drawsSkin(skin: string): boolean {\n return skinImages(this.rig.data, skin).every((p) => this.art.has(p));\n }\n}\n\nfunction multiply(a: number, b: number): number {\n const ch = (shift: number) =>\n Math.round((((a >> shift) & 255) * ((b >> shift) & 255)) / 255) << shift;\n return ch(16) | ch(8) | ch(0);\n}\n"
280
+ },
281
+ {
282
+ "path": "character/render/phaser/stage.ts",
283
+ "content": "import Phaser from \"phaser\";\nimport type { ArtCatalog } from \"../../core/catalog\";\nimport type { CharacterLook } from \"../../core/look\";\nimport type { RigDef } from \"../../core/rig\";\nimport { vectorArt, type ArtSource } from \"./artSource\";\nimport { CharacterView } from \"./CharacterView\";\n\n// A character on its own little canvas, for screens outside a game scene: the maker page, a hero's\n// portrait, a shop preview. One Phaser game per stage — destroy it when the screen goes.\n\nexport interface CharacterStageOptions {\n rig: RigDef;\n catalog: ArtCatalog;\n look?: CharacterLook;\n width: number;\n height: number;\n /** The character's scale; it stands centred, its feet on a line 88% down. */\n scale?: number;\n /** Background colour; omitted — transparent. */\n background?: string;\n}\n\nexport interface CharacterStage {\n view: CharacterView;\n game: Phaser.Game;\n /** Our vector art for this stage (to switch back to after another source). */\n vector: ArtSource;\n destroy(): void;\n}\n\nexport function createCharacterStage(\n parent: HTMLElement,\n options: CharacterStageOptions,\n): Promise<CharacterStage> {\n return new Promise((resolve) => {\n const { width, height } = options;\n class StageScene extends Phaser.Scene {\n create(): void {\n const scale = options.scale ?? 1;\n const vector = vectorArt(\n this.textures,\n options.catalog,\n Math.max(2, Math.ceil(scale * 1.5)),\n );\n const view = new CharacterView(this, {\n rig: options.rig,\n art: vector,\n ...(options.look ? { look: options.look } : {}),\n x: width / 2,\n y: Math.round(height * 0.88),\n scale,\n });\n resolve({ view, game, vector, destroy: () => game.destroy(true) });\n }\n }\n const game = new Phaser.Game({\n type: Phaser.WEBGL,\n parent,\n width,\n height,\n transparent: !options.background,\n ...(options.background ? { backgroundColor: options.background } : {}),\n scene: StageScene,\n banner: false,\n audio: { noAudio: true },\n });\n });\n}\n"
284
+ },
285
+ {
286
+ "path": "character/render/phaser/textures.ts",
287
+ "content": "import Phaser from \"phaser\";\nimport { svgDocument } from \"../../art/svg\";\nimport type { ArtDef, ArtStyle } from \"../../core/catalog\";\nimport type { ArtId } from \"../../core/types\";\n\n// SVG art → Phaser textures, once per image for the whole game (textures are shared by every scene).\n// Rasterized at `resolution`× so the art stays crisp when the character is scaled up or the screen is\n// dense. Decoding an SVG is asynchronous: the first\n// ask starts it and returns null, `onReady` runs when the texture exists.\n\nconst pending = new WeakMap<\n Phaser.Textures.TextureManager,\n Map<string, Array<() => void>>\n>();\n\nexport function artTextureKey(\n id: ArtId,\n resolution: number,\n paint?: string | null,\n): string {\n return `chr:${id}@${resolution}${paint ? paint : \"\"}`;\n}\n\nexport function ensureArtTexture(\n textures: Phaser.Textures.TextureManager,\n id: ArtId,\n art: ArtDef,\n size: { w: number; h: number },\n resolution: number,\n onReady: () => void,\n style: ArtStyle = {},\n): string | null {\n // Only art with a painted part is made once per colour; the light is the image's own, always the same.\n const colour = art.paint ? (style.paint ?? null) : null;\n const key = artTextureKey(id, resolution, colour);\n if (textures.exists(key)) return key;\n let waits = pending.get(textures);\n if (!waits) pending.set(textures, (waits = new Map()));\n const queue = waits.get(key);\n if (queue) {\n queue.push(onReady);\n return null;\n }\n const list = [onReady];\n waits.set(key, list);\n const img = new Image();\n const done = () => {\n waits.delete(key);\n for (const cb of list) cb();\n };\n img.onload = () => {\n if (!textures.exists(key)) textures.addImage(key, img);\n done();\n };\n img.onerror = () => {\n console.warn(`[character] art ${id} did not render`);\n done();\n };\n img.src =\n \"data:image/svg+xml;charset=utf-8,\" +\n encodeURIComponent(\n svgDocument(art, size, resolution, {\n paint: colour,\n light: style.light ?? null,\n }),\n );\n return null;\n}\n"
288
+ },
289
+ {
290
+ "path": "character/rigs/hero/animations.ts",
291
+ "content": "import { cycle, motion, pose, type Pose } from \"./motion\";\n\n// The hero's own animations (rigs/hero/motion.ts — poses in time). Facing −x: forward is −x, +r on\n// the torso leans it forward, on a hand turns what it holds tip-down. Hand targets move in their\n// shoulder's space, foot targets on the ground. Events: \"Attack\" — the blow lands (the game times the\n// shot to it), \"Step\" — a foot touches down, \"Hit\", \"Die\".\n\nexport const HERO_ANIMATIONS = {\n idle: \"Idle\",\n /** Idle now and then: a look up and around. */\n look: \"Look\",\n walk: \"Walk\",\n run: \"Run\",\n /** A run with a weapon held ready. */\n runArmed: \"RunArmed\",\n /** Bare hands. */\n punch: \"Punch\",\n /** Spears, daggers. */\n stab: \"Stab\",\n /** Swords, axes, maces, clubs. */\n swing: \"Swing\",\n hit: \"Hit\",\n die: \"Die\",\n} as const;\n\n/** The blink, played over animations that leave the eyes alone (RigDef.blink). */\nexport const BLINK = \"Blink\";\n\n/** Standing: hands a little forward, what the far hand holds tipped up. */\nexport const REST: Pose = {\n hand_target_far: { x: -3, y: 1, r: -15 },\n hand_target_near: { x: -1, y: 1, r: -10 },\n};\n\nconst TAU = Math.PI * 2;\nconst smooth = (t: number) => t * t * (3 - 2 * t);\n\n/** A foot over a stride: on the ground sliding back for the first half, lifted and swung forward for\n * the second. `stride` — how far it reaches either way, `lift` — how high. */\nfunction foot(phase: number, stride: number, lift: number) {\n const p = ((phase % 1) + 1) % 1;\n if (p < 0.5) return { x: -stride + (2 * stride * p) / 0.5, y: 0 };\n const u = (p - 0.5) / 0.5;\n return {\n x: stride - 2 * stride * smooth(u),\n y: lift * Math.sin(Math.PI * u),\n };\n}\n\n/** The toes' lift (degrees, up is negative) over a stride: up while the foot swings forward. */\nfunction toes(phase: number) {\n const p = ((phase % 1) + 1) % 1;\n return p < 0.5 ? 0 : -10 * Math.sin(Math.PI * ((p - 0.5) / 0.5));\n}\n\n/** Walking and running: the legs in opposite phase, the hips bobbing twice a stride, the arms swinging\n * against the legs. `armed` — the far hand holds its weapon ready instead of swinging. */\nfunction gait(o: {\n stride: number;\n lift: number;\n bob: number;\n lean: number;\n swing: number;\n raise: number;\n armed?: boolean;\n}) {\n return (p: number): Pose => {\n const twice = Math.cos(2 * TAU * p);\n const arm = Math.cos(TAU * p);\n return {\n foot_target_near: foot(p, o.stride, o.lift),\n foot_target_far: foot(p + 0.5, o.stride, o.lift),\n // the toes lift a little as a foot swings forward\n foot_near: { r: toes(p) },\n foot_far: { r: toes(p + 0.5) },\n hips: { y: -o.bob / 2 - (o.bob / 2) * twice },\n torso: { r: o.lean + 1 * twice },\n head: { r: -o.lean / 2 - 1 * twice },\n hand_target_near: {\n x: -1 + o.swing * arm,\n y: 1 + o.raise + 1.5 * (1 + arm),\n r: -10 - o.raise * 2,\n },\n hand_target_far: o.armed\n ? { x: -8, y: 14, r: -35 }\n : {\n x: -3 - o.swing * arm,\n y: 1 + o.raise + 1.5 * (1 - arm),\n r: -15 - o.raise * 2,\n },\n cape: { r: -2 - 3 * Math.sin(2 * TAU * p) - o.lean / 2 },\n };\n };\n}\n\nconst gaitEvents = (seconds: number) =>\n [\n [0, \"Step\"],\n [seconds / 2, \"Step\"],\n ] as const;\n\nconst WALK = { stride: 8, lift: 5, bob: 1.4, lean: 2, swing: 5, raise: 0 };\nconst RUN = { stride: 12, lift: 9, bob: 3, lean: 9, swing: 8, raise: 10 };\nconst WALK_SECONDS = 0.8;\nconst RUN_SECONDS = 0.5;\n\n/**\n * How fast the ground passes under a planted foot at speed 1, skeleton units a second: the foot is on\n * the ground for half a stride and slides back 2 × stride (`foot`). A world scrolling at exactly this\n * speed × the character's scale × the animation's speed keeps the feet put — no skating.\n */\nexport const GAIT_GROUND_SPEED: Readonly<Record<string, number>> = {\n [HERO_ANIMATIONS.walk]: (2 * WALK.stride) / (WALK_SECONDS / 2),\n [HERO_ANIMATIONS.run]: (2 * RUN.stride) / (RUN_SECONDS / 2),\n [HERO_ANIMATIONS.runArmed]: (2 * RUN.stride) / (RUN_SECONDS / 2),\n};\n\n/* eslint-disable @typescript-eslint/no-explicit-any -- the export format */\nexport const heroAnimations: Record<string, any> = {\n [HERO_ANIMATIONS.idle]: motion({\n keys: [\n { t: 0, pose: REST },\n {\n t: 1,\n pose: {\n // breathing in: the chest rises, the legs stay as they stand\n torso: { y: 0.6, r: 1 },\n head: { r: -1.5 },\n hand_target_far: { x: -3, y: 2.3, r: -17 },\n hand_target_near: { x: -1, y: 2.3, r: -12 },\n cape: { r: 2 },\n },\n },\n { t: 2, pose: REST },\n ],\n }),\n\n [HERO_ANIMATIONS.look]: motion({\n keys: [\n { t: 0, pose: REST },\n {\n t: 0.6,\n pose: pose(REST, {\n head: { r: -8 },\n torso: { r: -2 },\n hips: { x: 1.5, y: -0.4 },\n }),\n },\n {\n t: 1.5,\n pose: pose(REST, {\n head: { r: -7 },\n torso: { r: -2 },\n hips: { x: 1.5, y: -0.4 },\n }),\n },\n {\n t: 2.1,\n pose: pose(REST, { head: { r: 5 }, torso: { r: 2 }, hips: { x: -1 } }),\n },\n {\n t: 2.7,\n pose: pose(REST, { head: { r: 4 }, torso: { r: 2 }, hips: { x: -1 } }),\n },\n { t: 3.2, pose: REST },\n ],\n }),\n\n [HERO_ANIMATIONS.walk]: motion({\n keys: cycle(WALK_SECONDS, gait(WALK)),\n events: gaitEvents(WALK_SECONDS),\n }),\n [HERO_ANIMATIONS.run]: motion({\n keys: cycle(RUN_SECONDS, gait(RUN)),\n events: gaitEvents(RUN_SECONDS),\n }),\n [HERO_ANIMATIONS.runArmed]: motion({\n keys: cycle(RUN_SECONDS, gait({ ...RUN, armed: true })),\n events: gaitEvents(RUN_SECONDS),\n }),\n\n [HERO_ANIMATIONS.punch]: motion({\n keys: [\n { t: 0, pose: REST },\n {\n t: 0.12,\n pose: {\n torso: { r: -6 },\n hips: { x: 2 },\n head: { r: -2 },\n hand_target_far: { x: 8, y: 16, r: -60 },\n hand_target_near: { x: -6, y: 18, r: -40 },\n },\n },\n {\n t: 0.25,\n ease: \"out\",\n pose: {\n torso: { r: 10 },\n hips: { x: -3 },\n head: { r: 4 },\n hand_target_far: { x: -26, y: 30, r: -90 },\n hand_target_near: { x: 4, y: 16, r: -40 },\n },\n },\n {\n t: 0.4,\n pose: {\n torso: { r: 8 },\n hips: { x: -2 },\n head: { r: 3 },\n hand_target_far: { x: -22, y: 28, r: -85 },\n hand_target_near: { x: 3, y: 15, r: -38 },\n },\n },\n { t: 0.75, pose: REST },\n ],\n events: [[0.25, \"Attack\"]],\n }),\n\n [HERO_ANIMATIONS.stab]: motion({\n keys: [\n { t: 0, pose: REST },\n {\n t: 0.15,\n pose: {\n torso: { r: -5 },\n hips: { x: 2 },\n hand_target_far: { x: 10, y: 12, r: -5 },\n hand_target_near: { x: -4, y: 6, r: -20 },\n },\n },\n {\n t: 0.3,\n ease: \"out\",\n pose: {\n torso: { r: 11 },\n hips: { x: -4 },\n head: { r: 3 },\n hand_target_far: { x: -27, y: 17, r: 0 },\n hand_target_near: { x: 6, y: 4, r: -10 },\n },\n },\n {\n t: 0.45,\n pose: {\n torso: { r: 9 },\n hips: { x: -3 },\n head: { r: 2 },\n hand_target_far: { x: -24, y: 16, r: 0 },\n hand_target_near: { x: 5, y: 4, r: -10 },\n },\n },\n { t: 0.8, pose: REST },\n ],\n events: [[0.3, \"Attack\"]],\n }),\n\n [HERO_ANIMATIONS.swing]: motion({\n keys: [\n { t: 0, pose: REST },\n {\n t: 0.16,\n pose: {\n torso: { r: -8 },\n hips: { x: 2 },\n head: { r: -3 },\n hand_target_far: { x: 8, y: 72, r: -100 },\n hand_target_near: { x: -4, y: 10, r: -30 },\n },\n },\n {\n t: 0.3,\n ease: \"out\",\n pose: {\n torso: { r: 12 },\n hips: { x: -4 },\n head: { r: 4 },\n hand_target_far: { x: -22, y: 16, r: 40 },\n hand_target_near: { x: 5, y: 4, r: -10 },\n },\n },\n {\n t: 0.42,\n pose: {\n torso: { r: 10 },\n hips: { x: -3 },\n head: { r: 3 },\n hand_target_far: { x: -14, y: 4, r: 70 },\n hand_target_near: { x: 4, y: 3, r: -10 },\n },\n },\n { t: 0.85, pose: REST },\n ],\n events: [[0.3, \"Attack\"]],\n }),\n\n [HERO_ANIMATIONS.hit]: motion({\n keys: [\n { t: 0, pose: REST },\n {\n t: 0.08,\n ease: \"out\",\n pose: {\n torso: { r: -10 },\n head: { r: -8 },\n hips: { x: 4 },\n hand_target_far: { x: 4, y: 7, r: -25 },\n hand_target_near: { x: 5, y: 9, r: -20 },\n },\n },\n { t: 0.45, pose: REST },\n ],\n events: [[0, \"Hit\"]],\n }),\n\n [HERO_ANIMATIONS.die]: motion({\n keys: [\n { t: 0, pose: REST },\n {\n t: 0.15,\n pose: {\n torso: { r: -12 },\n head: { r: -10 },\n hips: { x: 4 },\n hand_target_far: { x: 6, y: 10, r: -30 },\n hand_target_near: { x: 8, y: 12, r: -25 },\n },\n },\n {\n t: 0.5,\n ease: \"in\",\n pose: {\n torso: { r: -55 },\n head: { r: -12 },\n hips: { x: 14, y: -20 },\n hand_target_far: { x: 14, y: 18, r: -60 },\n hand_target_near: { x: 16, y: 20, r: -50 },\n foot_target_near: { x: -10, y: 2 },\n foot_target_far: { x: -6, y: 1 },\n },\n },\n {\n t: 0.72,\n ease: \"out\",\n pose: {\n torso: { r: -88 },\n head: { r: 6 },\n hips: { x: 18, y: -33 },\n hand_target_far: { x: 22, y: 14, r: -80 },\n hand_target_near: { x: 26, y: 16, r: -70 },\n foot_target_near: { x: -22, y: 4 },\n foot_target_far: { x: -14, y: 2 },\n },\n },\n {\n t: 1.1,\n pose: {\n torso: { r: -90 },\n head: { r: 4 },\n hips: { x: 18, y: -34 },\n hand_target_far: { x: 24, y: 12, r: -85 },\n hand_target_near: { x: 28, y: 14, r: -75 },\n foot_target_near: { x: -24, y: 0 },\n foot_target_far: { x: -16, y: 0 },\n },\n },\n ],\n events: [[0.72, \"Die\"]],\n }),\n\n [BLINK]: motion({\n keys: [\n { t: 0, pose: { eyes: { sx: 1 } } },\n { t: 0.06, pose: { eyes: { sx: 0.1 } } },\n { t: 0.12, pose: { eyes: { sx: 0.1 } } },\n { t: 0.2, pose: { eyes: { sx: 1 } } },\n ],\n }),\n};\n/* eslint-enable @typescript-eslint/no-explicit-any */\n"
292
+ },
293
+ {
294
+ "path": "character/rigs/hero/index.ts",
295
+ "content": "import { readSkeletonData } from \"../../core/data\";\nimport { defineRig } from \"../../core/rig\";\nimport { bothHands, swapUnder } from \"../../core/rules\";\nimport { BLINK, HERO_ANIMATIONS, heroAnimations } from \"./animations\";\nimport { heroColorTargets, heroEditor, heroPartTypes } from \"./parts\";\nimport {\n addRanged,\n RANGED_ANIMATIONS,\n RANGED_ATTACKS,\n RANGED_SHOTS,\n rangedStance,\n TWO_HANDED,\n type ShotKind,\n} from \"./ranged\";\nimport { skeletonJson } from \"./skeleton\";\nimport { WEAR } from \"./wear\";\nimport { skinJson } from \"./wearing\";\n\nexport type { ShotKind } from \"./ranged\";\nexport { GAIT_GROUND_SPEED, HERO_ANIMATIONS } from \"./animations\";\nexport { RANGED_ANIMATIONS } from \"./ranged\";\n\n// The hero: a chibi body of our own (skeleton.ts), what it wears (wear.ts — every part's pictures and\n// where they sit; the pictures themselves are the art packs, packs/*), its animations (animations.ts,\n// ranged.ts) and how its parts behave.\n\n/** Weapons that stab rather than swing: spears, daggers and knives, the rapier, the fork. */\nconst THRUSTING = new Set([\n \"weapon/19\",\n \"weapon/21\",\n \"weapon/24\",\n \"weapon/30\",\n \"weapon/spear\",\n \"weapon/dagger\",\n]);\n\n/** Weapons that cast: staves and the magic rod. */\nconst CASTING = new Set([\n \"weapon/28\",\n \"weapon/wizard_staff\",\n \"weapon/druid_staff\",\n]);\n\n/** The attack for what the weapon hand holds: a ranged weapon or a staff has its own (ranged.ts — a bow\n * drawn and loosed, a crossbow, a pistol, a musket, a burst, a cast); otherwise bare hands punch, a\n * thrusting weapon stabs, anything else — swords, axes, maces, clubs — swings. */\nexport function heroAttack(weapon: string | null): string {\n if (!weapon) return HERO_ANIMATIONS.punch;\n const ranged = RANGED_ATTACKS[weapon];\n if (ranged) return ranged;\n if (CASTING.has(weapon)) return RANGED_ANIMATIONS.cast;\n return THRUSTING.has(weapon) ? HERO_ANIMATIONS.stab : HERO_ANIMATIONS.swing;\n}\n\n/** Does the hero fight face to face with this in hand — bare hands, a sword, an axe, a spear (not a\n * bow, a gun or a staff)? The fight then hits only at the monster, with no shot in flight. */\nexport function heroIsMelee(weapon: string | null): boolean {\n const attack = heroAttack(weapon);\n return (\n attack === HERO_ANIMATIONS.punch ||\n attack === HERO_ANIMATIONS.swing ||\n attack === HERO_ANIMATIONS.stab\n );\n}\n\n/** What the hero's shot looks like in flight with this in hand: a bow's arrow, a gun's bullet; a swung\n * weapon sends the crescent wave of its swing, a stabbing one a spike; a staff and bare hands — the\n * game's orb. */\nexport function heroShot(weapon: string | null): ShotKind {\n const ranged = weapon ? RANGED_SHOTS[weapon] : undefined;\n if (ranged) return ranged;\n const attack = heroAttack(weapon);\n if (attack === HERO_ANIMATIONS.swing) return \"slash\";\n if (attack === HERO_ANIMATIONS.stab) return \"thrust\";\n return \"orb\";\n}\n\nconst json = addRanged({\n ...skeletonJson(),\n skins: Object.entries(WEAR).map(([name, pieces]) => skinJson(name, pieces)),\n events: Object.fromEntries(\n [\"Attack\", \"Step\", \"Hit\", \"Die\"].map((e) => [e, {}]),\n ),\n animations: heroAnimations,\n});\n\nexport const heroRig = defineRig({\n id: \"hero\",\n data: readSkeletonData(json),\n partTypes: heroPartTypes,\n colorTargets: heroColorTargets,\n rules: [\n // under a hat the hair turns into its tucked-in twin of the same number\n swapUnder({\n type: \"Hair\",\n under: \"Hat\",\n variant: \"HairUnderHat\",\n rename: (skin) => skin.replace(/^hair\\//, \"hair_under/\"),\n }),\n // a bow or a long gun takes both hands — the shield is put away\n bothHands({ type: \"Weapon\", other: \"Offhand\", skins: TWO_HANDED }),\n ],\n defaultAnimation: HERO_ANIMATIONS.idle,\n mix: 0.2,\n anchors: {\n body: \"torso\",\n head: \"head\",\n hand: \"hand_far\",\n gear: \"weapon\",\n offHand: \"hand_near\",\n },\n // a blink every 2–5 s over the animations that leave the eyes alone\n blink: {\n animation: BLINK,\n from: 0,\n to: 0.2,\n bones: [\"eyes\"],\n slots: [],\n every: [2, 5],\n },\n // a two-handed gun is carried in both hands at rest, walking, running\n stance: (animation, worn) => rangedStance(animation, worn(\"Weapon\")),\n // light from the upper left (where the hero faces) on every picture but the face's\n shading: { light: [-0.5, 1], skip: [\"eyes/\", \"mouth/\", \"brows/\"] },\n // the face changes with what the body does\n expressions: {\n faces: {\n Angry: { Brows: \"brows/4\", Mouth: \"mouth/4\" },\n Hurt: { Mouth: \"mouth/8\" },\n Dead: { Mouth: \"mouth/10\" },\n },\n byAnimation: {\n [HERO_ANIMATIONS.punch]: \"Angry\",\n [HERO_ANIMATIONS.stab]: \"Angry\",\n [HERO_ANIMATIONS.swing]: \"Angry\",\n [HERO_ANIMATIONS.hit]: \"Hurt\",\n [HERO_ANIMATIONS.die]: \"Dead\",\n ...Object.fromEntries(\n Object.values(RANGED_ANIMATIONS).map((a) => [a, \"Angry\"]),\n ),\n },\n },\n editor: heroEditor,\n});\n"
296
+ },
297
+ {
298
+ "path": "character/rigs/hero/motion.ts",
299
+ "content": "// Writing animations as poses in time. A pose says how far each bone is from where it stands: turned\n// `r` degrees (counter-clockwise; the hero faces −x, so +r on the torso leans it forward), moved `x`,\n// `y` px in its parent's space (a hand or foot target: in its shoulder's / the ground's space), scaled\n// `sx`, `sy`. A bone a pose leaves out is at rest there. Between poses the motion eases in and out\n// unless a key says \"linear\" (a foot sliding on the ground) or \"step\". The result is the export format\n// the runtime reads (core/animation).\n\nexport interface BoneMove {\n r?: number;\n x?: number;\n y?: number;\n sx?: number;\n sy?: number;\n /** How this bone moves on to the next key, past the key's own ease. */\n e?: Ease;\n}\n\nexport type Pose = Readonly<Record<string, BoneMove>>;\n\nexport type Ease = \"ease\" | \"linear\" | \"step\" | \"in\" | \"out\";\n\n/** A key: at `t` seconds the pose; `ease` — how it moves on to the next key. */\nexport interface Key {\n t: number;\n pose: Pose;\n ease?: Ease;\n}\n\nexport interface MotionDef {\n keys: readonly Key[];\n events?: ReadonlyArray<readonly [time: number, name: string]>;\n /** Slot keys: slot → [time, attachment | null][] (a weapon's flash, a drawn bow). */\n slots?: Readonly<\n Record<string, ReadonlyArray<readonly [number, string | null]>>\n >;\n}\n\n/** Bezier control points of an ease from (t0, v0) to (t1, v1), the export format's absolute units. */\nfunction curve(\n ease: Ease,\n t0: number,\n v0: number,\n t1: number,\n v1: number,\n): number[] | \"stepped\" | undefined {\n const dt = t1 - t0;\n switch (ease) {\n case \"linear\":\n return undefined;\n case \"step\":\n return \"stepped\";\n case \"in\":\n return [t0 + dt * 0.45, v0, t1 - dt * 0.1, v1 - (v1 - v0) * 0.1];\n case \"out\":\n return [t0 + dt * 0.1, v0 + (v1 - v0) * 0.1, t1 - dt * 0.45, v1];\n default:\n return [t0 + dt * 0.4, v0, t1 - dt * 0.4, v1];\n }\n}\n\n/* eslint-disable @typescript-eslint/no-explicit-any -- the export format's animation */\n\n/** A motion in the export format. */\nexport function motion(def: MotionDef): any {\n const keys = [...def.keys].sort((a, b) => a.t - b.t);\n const bones = new Set<string>();\n for (const k of keys) for (const b of Object.keys(k.pose)) bones.add(b);\n const out: any = { bones: {} };\n const timeline = (\n bone: string,\n get: (m: BoneMove) => number[],\n shape: (t: number, v: number[]) => any,\n ) =>\n keys.map((k, i) => {\n const next = keys[i + 1];\n const v = get(k.pose[bone] ?? {});\n const frame = shape(k.t, v);\n if (next) {\n const nv = get(next.pose[bone] ?? {});\n const ease = k.pose[bone]?.e ?? k.ease ?? \"ease\";\n const parts = v.map((x, c) => curve(ease, k.t, x, next.t, nv[c]!));\n if (parts.some((p) => p === \"stepped\")) frame.curve = \"stepped\";\n else if (parts.every((p) => p !== undefined))\n frame.curve = parts.flat();\n }\n return frame;\n });\n for (const b of bones) {\n const used = (p: keyof BoneMove) =>\n keys.some((k) => k.pose[b]?.[p] !== undefined);\n const tl: any = {};\n if (used(\"r\"))\n tl.rotate = timeline(\n b,\n (m) => [m.r ?? 0],\n (time, [value]) => ({ time, value }),\n );\n if (used(\"x\") || used(\"y\"))\n tl.translate = timeline(\n b,\n (m) => [m.x ?? 0, m.y ?? 0],\n (time, [x, y]) => ({ time, x, y }),\n );\n if (used(\"sx\") || used(\"sy\"))\n tl.scale = timeline(\n b,\n (m) => [m.sx ?? 1, m.sy ?? 1],\n (time, [x, y]) => ({ time, x, y }),\n );\n out.bones[b] = tl;\n }\n if (def.slots)\n out.slots = Object.fromEntries(\n Object.entries(def.slots).map(([slot, ks]) => [\n slot,\n { attachment: ks.map(([time, name]) => ({ time, name })) },\n ]),\n );\n if (def.events)\n out.events = def.events.map(([time, name]) => ({ time, name }));\n return out;\n}\n/* eslint-enable @typescript-eslint/no-explicit-any */\n\n/** Poses merged left to right (a later bone move wins). */\nexport const pose = (...poses: Pose[]): Pose => Object.assign({}, ...poses);\n\n/** A looping cycle sampled from a pose function of its phase (0..1): keys every `step` of the phase,\n * linear between them — dense enough to read smooth, exact where feet must stay planted. */\nexport function cycle(\n seconds: number,\n at: (phase: number) => Pose,\n step = 1 / 24,\n): Key[] {\n const n = Math.round(1 / step);\n return Array.from({ length: n + 1 }, (_, i) => ({\n t: +((i / n) * seconds).toFixed(4),\n pose: at(i === n ? 0 : i / n),\n ease: \"linear\" as const,\n }));\n}\n"
300
+ },
301
+ {
302
+ "path": "character/rigs/hero/parts.ts",
303
+ "content": "import type { ColorTargetDef, EditorGroup, PartTypeDef } from \"../../core/rig\";\n\n// What the hero wears: 16 part types, each a group of parts (skins) by name prefix. `canHide` — the\n// part can be taken off (‹ › pass through \"none\"). The ORDER is the order parts are combined: a later\n// type wins when two fill the same slot.\n\nconst type = (\n id: string,\n label: string,\n skinPrefix: string,\n canHide: boolean,\n colorTarget?: string,\n): PartTypeDef => ({\n id,\n label,\n skinPrefix,\n canHide,\n ...(colorTarget ? { colorTarget } : {}),\n});\n\nexport const heroPartTypes: readonly PartTypeDef[] = [\n type(\"Back\", \"Back\", \"back/\", true),\n type(\"Beard\", \"Beard\", \"beard/\", true, \"Beard\"),\n type(\"Boots\", \"Boots\", \"boots/\", true),\n type(\"Pants\", \"Pants\", \"pants/\", true),\n type(\"Brows\", \"Brows\", \"brows/\", false, \"Brows\"),\n type(\"Eyes\", \"Eyes\", \"eyes/\", false, \"Eyes\"),\n type(\"Gloves\", \"Gloves\", \"gloves/\", true),\n type(\"Hair\", \"Hair\", \"hair/\", true, \"Hair\"),\n {\n id: \"HairUnderHat\",\n label: \"Hair under a hat\",\n skinPrefix: \"hair_under/\",\n canHide: true,\n colorTarget: \"Hair\",\n auto: true,\n },\n type(\"Hat\", \"Hat\", \"hat/\", true),\n type(\"Mouth\", \"Mouth\", \"mouth/\", false),\n type(\"Glasses\", \"Glasses\", \"glasses/\", true),\n type(\"Offhand\", \"Off hand\", \"offhand/\", true),\n type(\"Weapon\", \"Weapon\", \"weapon/\", true),\n type(\"Top\", \"Top\", \"top/\", false),\n type(\"Skin\", \"Skin\", \"skin/\", false, \"Skin\"),\n];\n\n/** Colours paint slots by name prefix; the eye colour is drawn into the iris, the whites stay white. */\nexport const heroColorTargets: readonly ColorTargetDef[] = [\n {\n id: \"Skin\",\n label: \"Skin\",\n slotPrefixes: [\"torso\", \"leg_\", \"arm_\", \"head\"],\n },\n { id: \"Hair\", label: \"Hair\", slotPrefixes: [\"hair\"] },\n { id: \"Beard\", label: \"Beard\", slotPrefixes: [\"beard\"] },\n { id: \"Brows\", label: \"Brows\", slotPrefixes: [\"brows\"] },\n { id: \"Eyes\", label: \"Eyes\", slotPrefixes: [\"eyes\"], into: \"art\" },\n];\n\nconst FACE_SLOTS = [\n \"hair_back\",\n \"head\",\n \"mouth\",\n \"eyes\",\n \"beard\",\n \"brows\",\n \"hair\",\n \"hair_under\",\n \"glasses\",\n];\n\n/** The editor's two panels: the face (its tiles drawn on a dark head) and the body. */\nexport const heroEditor: { groups: readonly EditorGroup[] } = {\n groups: [\n {\n id: \"face\",\n label: \"Face\",\n types: [\"Hair\", \"Brows\", \"Eyes\", \"Skin\", \"Mouth\", \"Beard\"],\n thumbnail: {\n slots: FACE_SLOTS,\n backdrop: { skin: \"skin/1\", slots: [\"head\"] },\n },\n },\n {\n id: \"body\",\n label: \"Body\",\n types: [\n \"Hat\",\n \"Top\",\n \"Glasses\",\n \"Back\",\n \"Pants\",\n \"Gloves\",\n \"Weapon\",\n \"Boots\",\n \"Offhand\",\n ],\n },\n ],\n};\n"
304
+ },
305
+ {
306
+ "path": "character/rigs/hero/ranged.ts",
307
+ "content": "import { REST } from \"./animations\";\nimport { BONES } from \"./skeleton\";\n\n// Ranged weapons and their attacks, added to the hero's data as it is built: parts holding the weapon\n// (a picture placed on a hand's bone), the attacks (keyed on the hand targets, the torso, the head), and\n// for the two-handed guns their own stances — idle, walk, run, hit with the gun held — played instead\n// of the plain ones while such a gun is held (`rangedStance`). A shot is the \"Attack\" event.\n//\n// The hands are IK targets in their shoulder's space: `handFar` / `handNear` take where the hand should\n// be in the world, measured from the FAR shoulder standing (x forward is negative — the hero faces\n// left; y up), and give the translate key.\n\n/* eslint-disable @typescript-eslint/no-explicit-any -- the export format is built here field by field */\n\nconst at = (name: string) => {\n const b = BONES.find((x) => x[0] === name);\n if (!b) throw new Error(`hero: no bone ${name}`);\n return { x: b[2], y: b[3] };\n};\nconst FAR = at(\"shoulder_far\");\nconst NEAR = at(\"shoulder_near\");\n/** The hands standing, from their own shoulder. */\nconst HAND_FAR = {\n x: at(\"hand_target_far\").x - FAR.x,\n y: at(\"hand_target_far\").y - FAR.y,\n};\nconst HAND_NEAR = {\n x: at(\"hand_target_near\").x - NEAR.x,\n y: at(\"hand_target_near\").y - NEAR.y,\n};\n\nconst handFar = (x: number, y: number): [number, number] => [\n x - HAND_FAR.x,\n y - HAND_FAR.y,\n];\nconst handNear = (x: number, y: number): [number, number] => [\n x - (NEAR.x - FAR.x) - HAND_NEAR.x,\n y - (NEAR.y - FAR.y) - HAND_NEAR.y,\n];\n\n// The idle's pose at its start — the attacks begin and end here, so they blend in and out.\nconst IDLE = {\n far: {\n rot: REST.hand_target_far?.r ?? 0,\n x: REST.hand_target_far?.x ?? 0,\n y: REST.hand_target_far?.y ?? 0,\n },\n near: {\n rot: REST.hand_target_near?.r ?? 0,\n x: REST.hand_target_near?.x ?? 0,\n y: REST.hand_target_near?.y ?? 0,\n },\n};\n\ntype Rot = [time: number, value: number];\ntype Move = [time: number, x: number, y: number];\n\nconst rotate = (...keys: Rot[]) =>\n keys.map(([time, value]) => ({ time, value }));\nconst translate = (...keys: Move[]) =>\n keys.map(([time, x, y]) => ({ time, x, y }));\n\n/** A hand's track: [time, world x, world y, aim°] keys, the idle at both ends. The shoulders are never\n * moved — each stays at its side of the body, so a hand goes only where its arm reaches (≈48). */\nfunction hand(\n side: \"far\" | \"near\",\n end: number,\n keys: Array<[number, number, number, number]>,\n) {\n const idle = IDLE[side];\n const place = (k: [number, number, number, number]): Move => {\n const [x, y] = side === \"far\" ? handFar(k[1], k[2]) : handNear(k[1], k[2]);\n return [k[0], x, y];\n };\n return {\n rotate: rotate([0, idle.rot], ...keys.map((k): Rot => [k[0], k[3]]), [\n end,\n idle.rot,\n ]),\n translate: translate([0, idle.x, idle.y], ...keys.map(place), [\n end,\n idle.x,\n idle.y,\n ]),\n };\n}\n\nconst lean = (end: number, keys: Rot[]) => ({\n rotate: rotate([0, 0], ...keys, [end, 0]),\n});\n\n/** Swaps a weapon's picture in its slot during the attack (a drawn bow, a muzzle flash), back at the end. */\nconst swap = (\n slot: string,\n rest: string,\n end: number,\n keys: Array<[number, string | null]>,\n) => ({\n [slot]: {\n attachment: [\n ...keys.map(([time, name]) => ({ time, name })),\n { time: end, name: rest },\n ],\n },\n});\n\n// Where each weapon is drawn and which bone carries it:\n// - a pistol: the far hand's own slot (behind the body, as swords), on the weapon bone;\n// - a bow: carried as a sword is — the far hand's slot, behind the body; while it shoots, the slot over\n// the body (`BOW_AIM`, on the same bone): the far hand on the riser held out past the body, the near\n// hand drawing the string;\n// - a crossbow, a musket, a rifle: the near arm (drawn over the body, from the back shoulder) holds the\n// grip, the gun on that hand in its slot under the arm; the far arm (behind the body, from the front\n// shoulder) reaches forward to the fore-stock, its hand behind the gun. The shoulders stay put.\nconst SLOT = {\n hand: { slot: \"weapon\", key: \"weapon\", turn: 90 },\n bow: { slot: \"weapon\", key: \"weapon\", turn: 90 },\n gun: { slot: \"offhand_low\", key: \"offhand_low\", turn: 0 },\n} as const;\n\n/** The slot a bow is drawn in while it shoots, over the body; its pictures there: `bow_held`, `bow_drawn`,\n * `bow_shot` (the slot's own key shows nothing for a bow — it is put away). */\nconst BOW_AIM = \"weapon_aim\";\n\nexport const RANGED_ANIMATIONS = {\n /** A staff: raised high, then thrust at the enemy — the spell leaves at its point. */\n cast: \"Cast\",\n bow: \"ShootBow\",\n crossbow: \"ShootCrossbow\",\n pistol: \"ShootPistol\",\n musket: \"ShootMusket\",\n /** A burst: three kicks of the gun (one shot event — the game shoots once per attack). */\n burst: \"ShootBurst\",\n} as const;\n\n/** How the pistol is turned in the palm (degrees past a sword): its barrel points forward and down\n * from a hand hanging at rest, the way a hand holds a gun — not up like a raised sword. */\nconst PISTOL_TURN = 55;\n\nconst animations: Record<string, any> = {\n [RANGED_ANIMATIONS.cast]: {\n bones: {\n hand_target_far: hand(\"far\", 0.9, [\n [0.18, -18, 34, -62],\n [0.3, -40, 6, -20],\n [0.36, -46, -2, -6],\n [0.55, -46, -2, -6],\n ]),\n hand_target_near: hand(\"near\", 0.9, [\n [0.18, 34, -28, -30],\n [0.36, 28, -20, -10],\n [0.55, 28, -20, -10],\n ]),\n torso: lean(0.9, [\n [0.18, -6],\n [0.36, 9],\n [0.55, 7],\n ]),\n head: {\n rotate: rotate([0, 0], [0.18, -6], [0.36, 8], [0.55, 6], [0.9, 0]),\n },\n },\n events: [{ time: 0.36, name: \"Attack\" }],\n },\n [RANGED_ANIMATIONS.bow]: {\n bones: {\n // the bow held close, so the near arm reaches the string from its own shoulder\n hand_target_far: hand(\"far\", 1, [\n [0.15, -26, -2, 0],\n [0.5, -26, -2, 0],\n [0.56, -28, -2, 2],\n [0.75, -24, -4, -4],\n ]),\n hand_target_near: hand(\"near\", 1, [\n [0.15, 26, -6, -10],\n [0.3, 22, -5, -10],\n [0.5, 22, -5, -10],\n [0.56, 30, -2, -20],\n [0.75, 28, -14, -20],\n ]),\n torso: lean(1, [\n [0.15, 3],\n [0.5, -2],\n [0.56, 1],\n ]),\n head: { rotate: rotate([0, 0], [0.15, 4], [0.5, 2], [1, 0]) },\n },\n // out of the far hand's slot, over the body while it shoots, back at the end\n slots: {\n ...swap(SLOT.bow.slot, SLOT.bow.key, 0.9, [[0.1, null]]),\n ...swap(BOW_AIM, BOW_AIM, 0.9, [\n [0.1, \"bow_held\"],\n [0.28, \"bow_drawn\"],\n [0.52, \"bow_shot\"],\n [0.85, \"bow_held\"],\n ]),\n },\n events: [{ time: 0.53, name: \"Attack\" }],\n },\n [RANGED_ANIMATIONS.pistol]: {\n bones: {\n hand_target_far: hand(\"far\", 0.7, [\n [0.12, -46, -2, -PISTOL_TURN],\n [0.22, -46, -2, -PISTOL_TURN],\n [0.26, -41, 3, 22 - PISTOL_TURN],\n [0.4, -46, -2, 2 - PISTOL_TURN],\n ]),\n torso: lean(0.7, [\n [0.12, 5],\n [0.26, 1],\n [0.4, 4],\n ]),\n head: { rotate: rotate([0, 0], [0.12, 3], [0.26, -3], [0.7, 0]) },\n },\n slots: swap(SLOT.hand.slot, SLOT.hand.key, 0.32, [[0.24, \"weapon_shot\"]]),\n events: [{ time: 0.24, name: \"Attack\" }],\n },\n};\n\n// ── The weapons ──────────────────────────────────────────────────────────────────────────────────\n\n/** What a hero's shot looks like in flight: an arrow, a crossbow bolt, a bullet; for a melee weapon\n * (the idle fight is ranged) the wave of its blow — a crescent from a swing, a spike from a stab; the\n * game's own glowing orb for magic and bare hands. */\nexport type ShotKind = \"arrow\" | \"bolt\" | \"bullet\" | \"slash\" | \"thrust\" | \"orb\";\n\ninterface Held {\n name: string;\n w: number;\n h: number;\n /** Where the hand carrying it holds it, in its picture (px, y down). */\n hand: [number, number];\n /** Its other pictures, by the key an attack switches to (\"weapon_shot\" → \"_shot\"; a bow's — \"bow_drawn\"). */\n also: string[];\n /** Where it is drawn, the bone carrying it. */\n slot: (typeof SLOT)[keyof typeof SLOT];\n /** Turned in the hand past the slot's usual turn, degrees. */\n turn?: number;\n /** A bow's turn in the hand while carried (not shooting), past `turn`: lying along the arm the way a\n * sword does — the limbs forward-up and back-down, not standing upright at the hip. */\n carried?: number;\n attack: string;\n shot: ShotKind;\n /** Held in both hands (the shield is put away). */\n twoHands: boolean;\n}\n\n/** A two-handed gun: the near hand holds it at `hand` (the grip), the far one at `fore` (the\n * fore-stock). `aim` and `carry` — where the near hand is (from the far shoulder, world) aiming and at\n * rest: near the back shoulder, so neither arm is stretched and no shoulder moves. */\ninterface Gun extends Held {\n fore: [number, number];\n aim: [number, number];\n carry: [number, number];\n /** The muzzle's tilt down at rest, degrees. */\n low: number;\n /** The shooting animation, built from the hold (`gunShot`). */\n fire: (g: Gun) => any;\n}\n\n/** Both hands on a gun with the near hand at `at` (world, from the far shoulder) and the gun tilted\n * `angle`° (muzzle down for > 0): the translate and rotate keys of both hands. */\nfunction grips(g: Gun, at: [number, number], angle: number) {\n const a = (angle * Math.PI) / 180;\n // from the grip to the fore-stock, in the world (picture y is down)\n const ox = g.fore[0] - g.hand[0];\n const oy = -(g.fore[1] - g.hand[1]);\n const rx = at[0] + ox * Math.cos(a) - oy * Math.sin(a);\n const ry = at[1] + ox * Math.sin(a) + oy * Math.cos(a);\n return { far: handFar(rx, ry), near: handNear(at[0], at[1]), angle };\n}\n\n/** The hands of a gun hold over keys [time, near hand x, y, tilt°]. */\nfunction holdBones(g: Gun, keys: Array<[number, number, number, number]>) {\n const k = keys.map(([t, x, y, angle]) => ({ t, ...grips(g, [x, y], angle) }));\n return {\n hand_target_far: {\n rotate: rotate(...k.map((p): Rot => [p.t, p.angle])),\n translate: translate(...k.map((p): Move => [p.t, p.far[0], p.far[1]])),\n },\n hand_target_near: {\n rotate: rotate(...k.map((p): Rot => [p.t, p.angle])),\n translate: translate(...k.map((p): Move => [p.t, p.near[0], p.near[1]])),\n },\n };\n}\n\n/** A gun's shot from its rest: raised to the aim, fired at `fire` times (a kick each), back at the end. */\nfunction gunShot(\n g: Gun,\n o: {\n end: number;\n raise: number;\n fire: number[];\n kick: [number, number, number];\n lean: Rot[];\n },\n) {\n const [ax, ay] = g.aim;\n const [kx, ky, ka] = o.kick;\n const rest: [number, number, number] = [g.carry[0], g.carry[1], g.low];\n const keys: Array<[number, number, number, number]> = [\n [0, ...rest],\n [o.raise, ax, ay, 0],\n ];\n for (const t of o.fire)\n keys.push(\n [t, ax, ay, 0],\n [t + 0.03, ax + kx, ay + ky, ka],\n [t + 0.08, ax, ay, 0],\n );\n keys.push([o.end - 0.2, ax, ay, 0], [o.end, ...rest]);\n return {\n bones: { ...holdBones(g, keys), torso: lean(o.end, o.lean) },\n slots: {\n [g.slot.slot]: {\n attachment: o.fire.flatMap((t) => [\n { time: t, name: \"gun_shot\" },\n { time: t + 0.05, name: g.slot.key },\n ]),\n },\n },\n events: [{ time: o.fire[0]!, name: \"Attack\" }],\n };\n}\n\nconst BOW_CARRIED = 90;\n\nconst HELD: Held[] = [\n {\n name: \"hunting_bow\",\n w: 70,\n h: 112,\n hand: [26, 51],\n also: [\"drawn\", \"shot\"],\n slot: SLOT.bow,\n carried: BOW_CARRIED,\n attack: RANGED_ANIMATIONS.bow,\n shot: \"arrow\",\n twoHands: true,\n },\n {\n name: \"elven_bow\",\n w: 72,\n h: 128,\n hand: [26, 59],\n also: [\"drawn\", \"shot\"],\n slot: SLOT.bow,\n carried: BOW_CARRIED,\n attack: RANGED_ANIMATIONS.bow,\n shot: \"arrow\",\n twoHands: true,\n },\n {\n name: \"pistol\",\n w: 96,\n h: 40,\n hand: [76, 16],\n also: [\"shot\"],\n slot: SLOT.hand,\n turn: PISTOL_TURN,\n attack: RANGED_ANIMATIONS.pistol,\n shot: \"bullet\",\n twoHands: false,\n },\n];\n\nconst GUNS: Gun[] = [\n {\n name: \"crossbow\",\n w: 104,\n h: 56,\n hand: [80, 35],\n fore: [36, 28],\n aim: [32, -20],\n carry: [44, -34],\n low: 18,\n also: [\"shot\"],\n slot: SLOT.gun,\n attack: RANGED_ANIMATIONS.crossbow,\n shot: \"bolt\",\n twoHands: true,\n fire: (g) =>\n gunShot(g, {\n end: 0.85,\n raise: 0.16,\n fire: [0.32],\n kick: [5, 2, 8],\n lean: [\n [0.16, 3],\n [0.35, -2],\n [0.5, 2],\n ],\n }),\n },\n {\n name: \"musket\",\n w: 180,\n h: 40,\n hand: [148, 17],\n fore: [104, 15],\n aim: [32, -18],\n carry: [44, -34],\n low: 12,\n also: [\"shot\"],\n slot: SLOT.gun,\n attack: RANGED_ANIMATIONS.musket,\n shot: \"bullet\",\n twoHands: true,\n fire: (g) =>\n gunShot(g, {\n end: 1,\n raise: 0.2,\n fire: [0.38],\n kick: [7, 3, 10],\n lean: [\n [0.2, 3],\n [0.42, -4],\n [0.6, 2],\n ],\n }),\n },\n {\n name: \"rifle\",\n w: 160,\n h: 52,\n hand: [125, 29],\n fore: [78, 18],\n aim: [32, -22],\n carry: [44, -35],\n low: 12,\n also: [\"shot\"],\n slot: SLOT.gun,\n attack: RANGED_ANIMATIONS.burst,\n shot: \"bullet\",\n twoHands: true,\n fire: (g) =>\n gunShot(g, {\n end: 0.85,\n raise: 0.15,\n fire: [0.24, 0.32, 0.4],\n kick: [3, 2, 5],\n lean: [\n [0.15, 3],\n [0.27, 0],\n [0.32, 3],\n [0.35, 0],\n [0.4, 3],\n [0.43, 0],\n ],\n }),\n },\n];\n\n/** The animations a two-handed gun has its own stance of (the body moving as usual, the hands on the\n * gun): \"Walk\" → \"Walk@rifle\". */\nconst STANCES = [\"Idle\", \"Look\", \"Walk\", \"Run\", \"RunArmed\", \"Hit\"];\n\n/** The stance of a gun: the animation with the gun held at rest in both hands. */\nfunction stance(base: any, g: Gun): any {\n const a = JSON.parse(JSON.stringify(base));\n a.bones = {\n ...a.bones,\n ...holdBones(g, [[0, g.carry[0], g.carry[1], g.low]]),\n };\n return a;\n}\n\n/** A region holding a w × h picture at `hand` on the slot's bone: the picture's x along the bone turned\n * `turn`° — weapons sit on the weapon bone turned 90°, their x running along the aim, the left end\n * forward; a hand bone carries a gun unturned. */\nfunction heldRegion(\n path: string,\n w: number,\n h: number,\n [hx, hy]: [number, number],\n turn: number,\n) {\n const qx = hx - w / 2;\n const qy = h / 2 - hy;\n const r = (turn * Math.PI) / 180;\n const cx = -(qx * Math.cos(r) - qy * Math.sin(r));\n const cy = -(qx * Math.sin(r) + qy * Math.cos(r));\n return {\n path,\n x: +cx.toFixed(3),\n y: +cy.toFixed(3),\n rotation: turn,\n width: w,\n height: h,\n };\n}\n\nconst ALL: Held[] = [...HELD, ...GUNS];\nconst skinOf = (h: Held) => `weapon/${h.name}`;\n\n/** The attack each ranged part plays, by part name. */\nexport const RANGED_ATTACKS: Readonly<Record<string, string>> =\n Object.fromEntries(ALL.map((h) => [skinOf(h), h.attack]));\n\n/** What each ranged part shoots, by part name. */\nexport const RANGED_SHOTS: Readonly<Record<string, ShotKind>> =\n Object.fromEntries(ALL.map((h) => [skinOf(h), h.shot]));\n\n/** The ranged parts held in both hands. */\nexport const TWO_HANDED: ReadonlySet<string> = new Set(\n ALL.filter((h) => h.twoHands).map(skinOf),\n);\n\nconst GUN_BY_SKIN = new Map(GUNS.map((g) => [skinOf(g), g]));\n\n/** The animation to play for `animation` with this in the weapon hand: a gun's own stance of an idle, a\n * walk, a run, a hit (\"Walk@rifle\"); anything else as asked. */\nexport function rangedStance(animation: string, weapon: string | null): string {\n const g = weapon ? GUN_BY_SKIN.get(weapon) : undefined;\n return g && STANCES.includes(animation)\n ? `${animation}@${g.name}`\n : animation;\n}\n\n/** The data with the ranged weapons, their attacks and the guns' stances added. */\nexport function addRanged(json: any): any {\n const skins = ALL.map((h) => {\n const base = `weapon/${h.name}`;\n const turn = h.slot.turn + (h.turn ?? 0);\n const gun = h.slot === SLOT.gun;\n const atts: Record<string, any> = {\n [h.slot.key]: heldRegion(base, h.w, h.h, h.hand, turn + (h.carried ?? 0)),\n };\n if (h.slot === SLOT.bow) {\n // the bow over the body while it shoots: the same picture and its drawn and loosed ones\n const aim: Record<string, any> = {\n bow_held: heldRegion(base, h.w, h.h, h.hand, turn),\n };\n for (const v of h.also)\n aim[`bow_${v}`] = heldRegion(`${base}_${v}`, h.w, h.h, h.hand, turn);\n return {\n name: skinOf(h),\n attachments: { [h.slot.slot]: atts, [BOW_AIM]: aim },\n };\n }\n for (const v of h.also)\n atts[gun ? `gun_${v}` : `weapon_${v}`] = heldRegion(\n `${base}_${v}`,\n h.w,\n h.h,\n h.hand,\n turn,\n );\n return { name: skinOf(h), attachments: { [h.slot.slot]: atts } };\n });\n const extra: Record<string, any> = { ...animations };\n for (const g of GUNS) {\n extra[g.attack] = g.fire(g);\n for (const s of STANCES)\n if (json.animations[s])\n extra[`${s}@${g.name}`] = stance(json.animations[s], g);\n }\n return {\n ...json,\n skins: [...json.skins, ...skins],\n animations: { ...json.animations, ...extra },\n };\n}\n"
308
+ },
309
+ {
310
+ "path": "character/rigs/hero/skeleton.ts",
311
+ "content": "// The hero's skeleton: a chibi body seen three-quarter, facing left (−x), feet on y = 0, y up. Bones\n// are written where their joints are on the standing body (world px, degrees counter-clockwise from +x)\n// and turned into parent-relative values once, as the data is built. \"near\" is the side towards the\n// viewer (its arm and leg are drawn over the body), \"far\" the side behind it.\n//\n// Arms and legs are two-bone chains bent by IK: a hand target hangs from its shoulder (an arm follows\n// the body), a foot target from the root (where the ankle goes). The hand and the foot themselves are\n// part of the limb — children of the forearm / shin at its end — so a glove, a weapon, a shield or a\n// shoe moves with the limb it is on, never with the target; the hand takes the target's turn (how a\n// weapon is aimed) by a constraint. The weapon bone sits in\n// the far hand, its x along the weapon; a shield or a gun hangs from the near hand.\n\n/** [name, parent, x, y, rotation, length] — the joint in the world, standing. */\ntype BoneDef = readonly [\n name: string,\n parent: string | null,\n x: number,\n y: number,\n rotation: number,\n length?: number,\n];\n\nexport const BONES: readonly BoneDef[] = [\n [\"root\", null, 0, 0, 0],\n [\"hips\", \"root\", 0, 47, 0],\n [\"torso\", \"hips\", 0.6, 52.6, 90, 37],\n [\"head\", \"torso\", 4.2, 98.5, 90, 28.5],\n [\"face\", \"head\", -8.1, 119.2, 180],\n [\"eyes\", \"face\", -5.5, 127.5, 90, 8],\n [\"mouth\", \"face\", -9.3, 113.6, -90, 7.5],\n [\"brows\", \"face\", -6, 141, 90],\n [\"cape\", \"torso\", 23.9, 75.7, 90, 16.5],\n // arms: the shoulder unturned, so a hand target's place is its offset from the shoulder\n [\"shoulder_near\", \"torso\", 40.6, 93.4, 0],\n [\"upper_arm_near\", \"shoulder_near\", 40.6, 93.4, -91.4, 25.4],\n [\"forearm_near\", \"upper_arm_near\", 40, 68, -92.8, 24],\n [\"hand_target_near\", \"shoulder_near\", 38.8, 44, 0],\n [\"hand_near\", \"forearm_near\", 38.8, 44, 0],\n [\"shoulder_far\", \"torso\", -28.6, 92.2, 0],\n [\"upper_arm_far\", \"shoulder_far\", -28.6, 92.2, -101.9, 25.2],\n [\"forearm_far\", \"upper_arm_far\", -33.8, 67.5, -100.9, 23.7],\n [\"hand_target_far\", \"shoulder_far\", -38.3, 44.2, 0],\n [\"hand_far\", \"forearm_far\", -38.3, 44.2, 0],\n [\"weapon\", \"hand_far\", -35.3, 48.2, -90, 16.8],\n // legs: the foot target on the ground under the root, the foot at the shin's end turned forward\n [\"thigh_near\", \"hips\", 20.3, 46.2, -84, 21],\n [\"shin_near\", \"thigh_near\", 22.5, 25.3, -84.2, 17.8],\n [\"foot_target_near\", \"root\", 24.3, 7.6, 0],\n [\"foot_near\", \"shin_near\", 24.3, 7.6, 180, 12],\n [\"thigh_far\", \"hips\", -15.7, 46.2, -84.5, 21],\n [\"shin_far\", \"thigh_far\", -13.7, 25.3, -84.8, 17.8],\n [\"foot_target_far\", \"root\", -12.1, 7.6, 0],\n [\"foot_far\", \"shin_far\", -12.1, 7.6, 180, 12],\n];\n\n/** [slot, bone] in drawing order, back to front. A slot shows the attachment named like itself. */\nexport const SLOTS: ReadonlyArray<readonly [string, string]> = [\n [\"cape\", \"cape\"],\n [\"hair_back\", \"head\"],\n [\"arm_far\", \"shoulder_far\"],\n [\"sleeve_far\", \"upper_arm_far\"],\n [\"cap_far\", \"upper_arm_far\"],\n [\"glove_far\", \"forearm_far\"],\n [\"weapon\", \"weapon\"],\n [\"leg_far\", \"hips\"],\n [\"boot_far\", \"foot_far\"],\n [\"pants_far\", \"thigh_far\"],\n [\"leg_near\", \"hips\"],\n [\"boot_near\", \"foot_near\"],\n [\"pants_near\", \"thigh_near\"],\n [\"pants\", \"hips\"],\n [\"torso\", \"torso\"],\n [\"hat_back\", \"head\"],\n [\"top\", \"torso\"],\n [\"coat\", \"torso\"],\n [\"head\", \"head\"],\n [\"mouth\", \"mouth\"],\n [\"eyes\", \"eyes\"],\n [\"beard\", \"mouth\"],\n [\"brows\", \"brows\"],\n [\"hair\", \"head\"],\n [\"hair_under\", \"head\"],\n [\"hat_low\", \"head\"],\n [\"glasses\", \"face\"],\n [\"hat\", \"head\"],\n [\"offhand_low\", \"hand_near\"],\n [\"arm_near\", \"shoulder_near\"],\n [\"sleeve_near\", \"upper_arm_near\"],\n [\"cap_near\", \"upper_arm_near\"],\n [\"glove_near\", \"forearm_near\"],\n [\"offhand\", \"hand_near\"],\n // a bow while it shoots, held out over the body\n [\"weapon_aim\", \"weapon\"],\n];\n\n/** [constraint, upper bone, lower bone, target, bend] — elbows bend back, knees forward. */\nexport const IK: ReadonlyArray<\n readonly [string, string, string, string, 1 | -1]\n> = [\n [\"arm_near\", \"upper_arm_near\", \"forearm_near\", \"hand_target_near\", -1],\n [\"arm_far\", \"upper_arm_far\", \"forearm_far\", \"hand_target_far\", -1],\n [\"leg_near\", \"thigh_near\", \"shin_near\", \"foot_target_near\", 1],\n [\"leg_far\", \"thigh_far\", \"shin_far\", \"foot_target_far\", 1],\n];\n\n/** [constraint, hand, its target] — the hand turns as its target is turned. */\nexport const HAND_TURNS: ReadonlyArray<readonly [string, string, string]> = [\n [\"hand_near_turn\", \"hand_near\", \"hand_target_near\"],\n [\"hand_far_turn\", \"hand_far\", \"hand_target_far\"],\n];\n\n/** The bending pictures: which bones a mesh in a slot follows, top to bottom (core/limbMesh). */\nexport const LIMBS: Readonly<Record<string, readonly string[]>> = {\n arm_far: [\"upper_arm_far\", \"forearm_far\"],\n sleeve_far: [\"upper_arm_far\", \"forearm_far\"],\n arm_near: [\"upper_arm_near\", \"forearm_near\"],\n sleeve_near: [\"upper_arm_near\", \"forearm_near\"],\n // a leg's picture has the foot drawn in: below the ankle it goes with the foot, as a shoe does\n leg_far: [\"thigh_far\", \"shin_far\", \"foot_far\"],\n pants_far: [\"thigh_far\", \"shin_far\", \"foot_far\"],\n leg_near: [\"thigh_near\", \"shin_near\", \"foot_near\"],\n pants_near: [\"thigh_near\", \"shin_near\", \"foot_near\"],\n boot_far: [\"shin_far\", \"foot_far\"],\n boot_near: [\"shin_near\", \"foot_near\"],\n};\n\n/** Pictures that hang from the body and part over the legs below the hips (a skirt, a coat's tails). */\nexport const SKIRTS: Readonly<\n Record<string, { body: string; near: string; far: string }>\n> = {\n pants: { body: \"hips\", near: \"thigh_near\", far: \"thigh_far\" },\n coat: { body: \"torso\", near: \"thigh_near\", far: \"thigh_far\" },\n};\n\n/** The standing body's box (px): what the renderers size and frame it by. */\nexport const BOUNDS = { x: -50, y: -4, width: 126, height: 188 };\n\n/* eslint-disable @typescript-eslint/no-explicit-any -- the data is built in the export format */\n\n/** Bones, slots and constraints in the export format the runtime reads (core/data). */\nexport function skeletonJson(): any {\n const world = new Map<string, { x: number; y: number; r: number }>();\n const bones = BONES.map(([name, parent, x, y, rotation, length]) => {\n world.set(name, { x, y, r: rotation });\n const p = parent ? world.get(parent)! : { x: 0, y: 0, r: 0 };\n const a = (-p.r * Math.PI) / 180;\n const dx = x - p.x;\n const dy = y - p.y;\n return {\n name,\n ...(parent ? { parent } : {}),\n x: +(dx * Math.cos(a) - dy * Math.sin(a)).toFixed(4),\n y: +(dx * Math.sin(a) + dy * Math.cos(a)).toFixed(4),\n rotation: +(rotation - p.r).toFixed(4),\n ...(length ? { length } : {}),\n };\n });\n return {\n skeleton: { ...BOUNDS },\n bones,\n slots: SLOTS.map(([name, bone]) => ({ name, bone, attachment: name })),\n constraints: [\n ...IK.map(([name, upper, lower, target, bend]) => ({\n name,\n type: \"ik\",\n bones: [upper, lower],\n target,\n ...(bend < 0 ? { bendPositive: false } : {}),\n })),\n // the hand's turn in the world is its target's\n ...HAND_TURNS.map(([name, hand, target]) => ({\n name,\n type: \"transform\",\n source: target,\n bones: [hand],\n properties: { rotate: { to: { rotate: {} } } },\n })),\n ],\n };\n}\n"
312
+ },
313
+ {
314
+ "path": "character/rigs/hero/wear.ts",
315
+ "content": "import { BEND, type Piece } from \"./wearing\";\n\n// What every part wears: its pictures, each on a slot of the hero skeleton (rigs/hero/skeleton.ts) —\n// the picture's centre (x, y) and turn in the slot bone's space, its size; BEND — a mesh that bends with\n// its limb (core/limbMesh), { sx, sy } — drawn scaled or mirrored. A part (a skin) is offered by ‹ › in\n// this order; its pictures are drawn by the art packs (packs/*) under the same image names.\n\nexport const WEAR: Readonly<Record<string, readonly Piece[]>> = {\n \"back/1\": [[\"cape\", \"back/1\", 2.3, -11.1, -90, 84, 76]],\n \"back/2\": [[\"cape\", \"back/2\", -3.7, -12.6, -90, 75, 74]],\n \"back/3\": [[\"cape\", \"back/3\", 1.8, -9.6, -90, 71, 71]],\n \"back/4\": [[\"cape\", \"back/4\", -4.2, -13.1, -90, 74, 83]],\n \"back/5\": [[\"cape\", \"back/5\", -0.2, -10.6, -90, 81, 95]],\n \"back/6\": [[\"cape\", \"back/6\", 2.3, -18.1, -90, 76, 88]],\n \"back/7\": [[\"cape\", \"back/7\", -1.7, -7.6, -90, 79, 78]],\n \"back/8\": [[\"cape\", \"back/8\", 5.8, -12.6, -90, 55, 109]],\n \"back/9\": [[\"cape\", \"back/9\", 3.3, -10.6, -90, 85, 100]],\n \"back/10\": [[\"cape\", \"back/10\", -0.7, -22.6, -90, 63, 94]],\n \"back/11\": [[\"cape\", \"back/11\", -5.2, -9.6, -90, 105, 99]],\n \"back/12\": [[\"cape\", \"back/12\", 4.3, -18.6, -90, 73, 98]],\n \"back/13\": [[\"cape\", \"back/13\", 10.8, -11.1, -90, 92, 93]],\n \"back/14\": [[\"cape\", \"back/14\", 7.8, -13.1, -90, 76, 75]],\n \"back/15\": [[\"cape\", \"back/15\", 17.3, -15.6, -90, 95, 124]],\n \"back/16\": [[\"cape\", \"back/16\", 3.3, -18.6, -90, 79, 86]],\n \"back/17\": [[\"cape\", \"back/17\", 11.8, -15.1, -90, 76, 83]],\n \"back/18\": [[\"cape\", \"back/18\", -5.7, -5.1, -90, 96, 90]],\n \"back/19\": [[\"cape\", \"back/19\", -8.2, -12.1, -90, 102, 97]],\n \"back/20\": [[\"cape\", \"back/20\", 4.8, -20.6, -90, 65, 87]],\n \"back/21\": [[\"cape\", \"back/21\", 2.3, -11.1, -90, 84, 76]],\n \"back/22\": [[\"cape\", \"back/22\", 1.8, -9.6, -90, 71, 71]],\n \"back/23\": [[\"cape\", \"back/23\", -3.7, -12.6, -90, 75, 74]],\n \"back/24\": [[\"cape\", \"back/24\", 17.3, -15.6, -90, 95, 124]],\n \"back/25\": [[\"cape\", \"back/25\", -5.7, -5.1, -90, 96, 90]],\n \"back/26\": [[\"cape\", \"back/26\", 4.8, -20.6, -90, 65, 87]],\n \"back/27\": [[\"cape\", \"back/27\", 4.3, -18.6, -90, 73, 98]],\n \"back/28\": [[\"cape\", \"back/28\", 11.8, -15.1, -90, 76, 83]],\n \"back/29\": [[\"cape\", \"back/29\", 5.8, -12.6, -90, 55, 109]],\n \"back/30\": [[\"cape\", \"back/30\", 3.3, -18.6, -90, 79, 86]],\n \"beard/1\": [[\"beard\", \"beard/1\", 1.06, -0.16, 89.82, 43, 27]],\n \"beard/2\": [[\"beard\", \"beard/2\", 7.06, 1.33, 89.82, 42, 39]],\n \"beard/3\": [[\"beard\", \"beard/3\", 6.58, 7.83, 89.82, 89, 30]],\n \"beard/4\": [[\"beard\", \"beard/4\", 9.59, 8.82, 89.82, 85, 46]],\n \"beard/5\": [[\"beard\", \"beard/5\", 9.58, 5.82, 89.82, 73, 44]],\n \"beard/6\": [[\"beard\", \"beard/6\", 8.56, 1.82, 89.82, 39, 42]],\n \"beard/7\": [[\"beard\", \"beard/7\", -2.44, 0.36, 89.82, 54, 20]],\n \"beard/8\": [[\"beard\", \"beard/8\", -4.43, 3.36, 89.82, 80, 18]],\n \"beard/9\": [[\"beard\", \"beard/9\", 17.07, 2.79, 89.82, 29, 25]],\n \"beard/10\": [[\"beard\", \"beard/10\", 10.58, 8.31, 89.82, 88, 44]],\n \"boots/1\": [\n [\"boot_near\", \"boots/1_near\", 6.23, 1.37, 180, 31, 20],\n [\"boot_far\", \"boots/1_far\", 6.4, 1.37, 180, 31, 20],\n ],\n \"boots/2\": [\n [\"boot_near\", \"boots/2_near\", 6.3, 0.36, 180, 30, 23],\n [\"boot_far\", \"boots/2_far\", 5.9, 0.36, 180, 30, 23],\n ],\n \"boots/3\": [\n [\"boot_near\", \"boots/3_near\", 6.3, 1.1, 180, 32, 23, BEND],\n [\"boot_far\", \"boots/3_far\", 5.9, 1.1, 180, 32, 23, BEND],\n ],\n \"boots/4\": [\n [\"boot_near\", \"boots/4_near\", 6.3, 0.45, 180, 32, 22],\n [\"boot_far\", \"boots/4_far\", 5.9, 0.45, 180, 32, 22],\n ],\n \"boots/5\": [\n [\"boot_near\", \"boots/5_near\", 5.3, -2.4, 180, 26, 30, BEND],\n [\"boot_far\", \"boots/5_far\", 5.56, -2.4, 180, 26, 30, BEND],\n ],\n \"boots/6\": [\n [\"boot_near\", \"boots/6_near\", 5.3, 2.94, 180, 30, 18],\n [\"boot_far\", \"boots/6_far\", 4.9, 2.94, 180, 30, 18],\n ],\n \"boots/7\": [\n [\"boot_near\", \"boots/7_near\", 8.8, 0.1, 180, 33, 25, BEND],\n [\"boot_far\", \"boots/7_far\", 8.4, 0.1, 180, 33, 25, BEND],\n ],\n \"boots/8\": [\n [\"boot_near\", \"boots/8_near\", 7.3, 2.39, 180, 32, 20],\n [\"boot_far\", \"boots/8_far\", 6.9, 2.39, 180, 32, 20],\n ],\n \"boots/9\": [\n [\"boot_near\", \"boots/9_near\", 8.3, 1.54, 180, 32, 20],\n [\"boot_far\", \"boots/9_far\", 7.9, 1.54, 180, 32, 20],\n ],\n \"boots/10\": [\n [\"boot_near\", \"boots/10_near\", 9.8, 2.53, 180, 35, 19],\n [\"boot_far\", \"boots/10_far\", 9.4, 2.53, 180, 35, 19],\n ],\n \"boots/11\": [\n [\"boot_near\", \"boots/11_near\", 8.3, -1.4, 180, 32, 26],\n [\"boot_far\", \"boots/11_far\", 7.9, -1.4, 180, 32, 26],\n ],\n \"boots/12\": [\n [\"boot_near\", \"boots/12_near\", 6.3, 1.6, 180, 30, 22],\n [\"boot_far\", \"boots/12_far\", 5.9, 1.6, 180, 30, 22],\n ],\n \"boots/13\": [\n [\"boot_near\", \"boots/13_near\", 6.3, 0.77, 180, 28, 23],\n [\"boot_far\", \"boots/13_far\", 5.9, 0.77, 180, 28, 23],\n ],\n \"boots/14\": [\n [\"boot_near\", \"boots/14_near\", 5.8, 3.44, 180, 31, 17],\n [\"boot_far\", \"boots/14_far\", 5.4, 3.44, 180, 31, 17],\n ],\n \"boots/15\": [\n [\"boot_near\", \"boots/15_near\", 5.62, -2.36, 180, 28, 28, BEND],\n [\"boot_far\", \"boots/15_far\", 5.47, -2.35, 180, 28, 28, BEND],\n ],\n \"boots/16\": [\n [\"boot_near\", \"boots/16_near\", 5.3, -1.49, 180, 30, 25, BEND],\n [\"boot_far\", \"boots/16_far\", 4.9, -1.49, 180, 30, 25, BEND],\n ],\n \"boots/17\": [\n [\"boot_near\", \"boots/17_near\", 6.3, -0.4, 180, 30, 24],\n [\"boot_far\", \"boots/17_far\", 5.9, -0.4, 180, 30, 24],\n ],\n \"boots/18\": [\n [\"boot_near\", \"boots/18_near\", 5.8, 2.54, 180, 31, 18],\n [\"boot_far\", \"boots/18_far\", 5.4, 2.54, 180, 31, 18],\n ],\n \"boots/19\": [\n [\"boot_near\", \"boots/19_near\", 6.3, -0.32, 180, 28, 22],\n [\"boot_far\", \"boots/19_far\", 5.9, -0.32, 180, 28, 22],\n ],\n \"boots/20\": [\n [\"boot_near\", \"boots/20_near\", 9.8, 0.6, 180, 35, 24, BEND],\n [\"boot_far\", \"boots/20_far\", 9.4, 0.6, 180, 35, 24, BEND],\n ],\n \"boots/21\": [\n [\"boot_near\", \"boots/21_near\", 6.8, 0.6, 180, 31, 20],\n [\"boot_far\", \"boots/21_far\", 6.4, 0.6, 180, 31, 20],\n ],\n \"boots/22\": [\n [\"boot_near\", \"boots/22_near\", 7.3, -1.4, 180, 28, 22],\n [\"boot_far\", \"boots/22_far\", 6.9, -1.4, 180, 28, 22],\n ],\n \"boots/23\": [\n [\"boot_near\", \"boots/23_near\", 6.3, -1.4, 180, 28, 22],\n [\"boot_far\", \"boots/23_far\", 5.9, -1.4, 180, 28, 22],\n ],\n \"boots/24\": [\n [\n \"boot_near\",\n \"boots/24_near\",\n 6.07,\n -3.22,\n 180,\n 28,\n 28,\n BEND,\n { sx: 1, sy: 1.071 },\n ],\n [\n \"boot_far\",\n \"boots/24_far\",\n 5.92,\n -3.22,\n 180,\n 28,\n 28,\n BEND,\n { sx: 1, sy: 1.071 },\n ],\n ],\n \"boots/25\": [\n [\n \"boot_near\",\n \"boots/25_near\",\n 6.07,\n -3.22,\n 180,\n 28,\n 28,\n BEND,\n { sx: 1, sy: 1.071 },\n ],\n [\n \"boot_far\",\n \"boots/25_far\",\n 5.92,\n -3.22,\n 180,\n 28,\n 28,\n BEND,\n { sx: 1, sy: 1.071 },\n ],\n ],\n \"boots/26\": [\n [\"boot_near\", \"boots/26_near\", 6.3, -1.4, 180, 30, 24],\n [\"boot_far\", \"boots/26_far\", 5.9, -1.4, 180, 30, 24],\n ],\n \"boots/27\": [\n [\"boot_near\", \"boots/27_near\", 5.3, -2.4, 180, 26, 30, BEND],\n [\"boot_far\", \"boots/27_far\", 5.56, -2.4, 180, 26, 30, BEND],\n ],\n \"boots/28\": [\n [\"boot_near\", \"boots/28_near\", 5.3, -2.4, 180, 26, 30, BEND],\n [\"boot_far\", \"boots/28_far\", 5.56, -2.4, 180, 26, 30, BEND],\n ],\n \"boots/29\": [\n [\"boot_near\", \"boots/29_near\", 6.3, -0.9, 180, 30, 23],\n [\"boot_far\", \"boots/29_far\", 5.9, -0.9, 180, 30, 23],\n ],\n \"boots/30\": [\n [\"boot_near\", \"boots/30_near\", 7.3, 2.6, 180, 32, 20],\n [\"boot_far\", \"boots/30_far\", 6.9, 2.6, 180, 32, 20],\n ],\n \"pants/1\": [\n [\"pants\", \"pants/1\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/1_near\", 11.06, -1.97, 84, 25, 28, BEND],\n [\"pants_far\", \"pants/1_far\", 11.07, -1.87, 84.5, 25, 28, BEND],\n ],\n \"pants/2\": [\n [\"pants\", \"pants/2\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/2_near\", 11.06, -1.97, 84, 25, 28, BEND],\n [\"pants_far\", \"pants/2_far\", 11.07, -1.87, 84.5, 25, 28, BEND],\n ],\n \"pants/3\": [\n [\"pants\", \"pants/3\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/3_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/3_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/4\": [\n [\"pants\", \"pants/4\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/4_near\", 11.05, -1.97, 84, 31, 28, BEND],\n [\"pants_far\", \"pants/4_far\", 11.07, -1.87, 84.5, 31, 28, BEND],\n ],\n \"pants/5\": [\n [\"pants\", \"pants/5\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/5_near\", 17.52, -2.65, 84, 36, 42, BEND],\n [\"pants_far\", \"pants/5_far\", 17.54, -2.49, 84.5, 38, 43, BEND],\n ],\n \"pants/6\": [\n [\"pants\", \"pants/6\", 2.2, -6.06, 0, 92, 40, BEND, { sx: 0.993, sy: 1.002 }],\n ],\n \"pants/7\": [\n [\"pants\", \"pants/7\", 2.2, -6.06, 0, 92, 40, BEND, { sx: 0.993, sy: 1.002 }],\n ],\n \"pants/8\": [\n [\n \"pants\",\n \"pants/8\",\n 2.72,\n -9.07,\n 0,\n 93,\n 46,\n BEND,\n { sx: 0.992, sy: 1.001 },\n ],\n ],\n \"pants/9\": [\n [\n \"pants\",\n \"pants/9\",\n 2.22,\n -6.57,\n 0,\n 92,\n 41,\n BEND,\n { sx: 0.992, sy: 1.001 },\n ],\n ],\n \"pants/10\": [\n [\n \"pants\",\n \"pants/10\",\n 2.22,\n -6.57,\n 0,\n 92,\n 40,\n BEND,\n { sx: 0.992, sy: 1.026 },\n ],\n ],\n \"pants/11\": [\n [\n \"pants\",\n \"pants/11\",\n 2.2,\n -6.07,\n 0,\n 88,\n 40,\n BEND,\n { sx: 0.993, sy: 1.001 },\n ],\n ],\n \"pants/12\": [[\"pants\", \"pants/12\", 1.5, 1, 0, 79, 26]],\n \"pants/13\": [\n [\"pants\", \"pants/13\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/13_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/13_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/14\": [\n [\"pants\", \"pants/14\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/14_near\", 17.52, -2.65, 84, 36, 42, BEND],\n [\"pants_far\", \"pants/14_far\", 17.54, -2.49, 84.5, 38, 43, BEND],\n ],\n \"pants/15\": [\n [\"pants\", \"pants/15\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/15_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/15_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/16\": [\n [\"pants\", \"pants/16\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/16_near\", 11.06, -1.97, 84, 25, 28],\n [\n \"pants_far\",\n \"pants/16_far\",\n 17.54,\n -2.49,\n 84.5,\n 25,\n 40,\n BEND,\n { sx: 1, sy: 1.025 },\n ],\n ],\n \"pants/17\": [\n [\n \"pants\",\n \"pants/17\",\n 2.2,\n -6.06,\n 0,\n 92,\n 39,\n BEND,\n { sx: 0.993, sy: 1.027 },\n ],\n [\"pants_near\", \"pants/17_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/17_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/18\": [\n [\n \"pants\",\n \"pants/18\",\n 3.2,\n -4.07,\n 0,\n 96,\n 36,\n BEND,\n { sx: 0.993, sy: 1.002 },\n ],\n ],\n \"pants/19\": [\n [\"pants\", \"pants/19\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/19_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/19_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/20\": [\n [\"pants\", \"pants/20\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/20_near\", 11.06, -1.97, 84, 25, 28, BEND],\n [\"pants_far\", \"pants/20_far\", 11.07, -1.87, 84.5, 25, 28, BEND],\n ],\n \"pants/21\": [\n [\"pants\", \"pants/21\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/21_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/21_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/22\": [\n [\"pants\", \"pants/22\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/22_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/22_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/23\": [\n [\"pants\", \"pants/23\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/23_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/23_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/24\": [\n [\"pants\", \"pants/24\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/24_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/24_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/25\": [\n [\"pants\", \"pants/25\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/25_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/25_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/26\": [\n [\n \"pants\",\n \"pants/26\",\n 2.08,\n -4.02,\n 0,\n 82,\n 37,\n BEND,\n { sx: 1.014, sy: 1.01 },\n ],\n ],\n \"pants/27\": [\n [\"pants\", \"pants/27\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/27_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/27_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/28\": [\n [\"pants\", \"pants/28\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/28_near\", 11.06, -1.97, 84, 25, 28, BEND],\n [\"pants_far\", \"pants/28_far\", 11.07, -1.87, 84.5, 25, 28, BEND],\n ],\n \"pants/29\": [\n [\"pants\", \"pants/29\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/29_near\", 11.06, -1.97, 84, 25, 28, BEND],\n [\n \"pants_far\",\n \"pants/29_far\",\n 17.54,\n -2.49,\n 84.5,\n 25,\n 40,\n BEND,\n { sx: 1, sy: 1.025 },\n ],\n ],\n \"pants/30\": [\n [\"pants\", \"pants/30\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/30_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/30_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/31\": [\n [\"pants\", \"pants/31\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/31_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/31_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/32\": [\n [\"pants\", \"pants/32\", 1.5, 1, 0, 79, 26],\n [\n \"pants_near\",\n \"pants/32_near\",\n 11.06,\n -1.97,\n 84,\n 31,\n 28,\n BEND,\n { sx: 0.806, sy: 1 },\n ],\n [\n \"pants_far\",\n \"pants/32_far\",\n 11.07,\n -1.87,\n 84.5,\n 31,\n 28,\n BEND,\n { sx: 0.806, sy: 1 },\n ],\n ],\n \"pants/33\": [\n [\"pants\", \"pants/33\", 1.5, 1, 0, 79, 26],\n [\n \"pants_near\",\n \"pants/33_near\",\n 11.06,\n -1.97,\n 84,\n 31,\n 28,\n BEND,\n { sx: 0.806, sy: 1 },\n ],\n [\n \"pants_far\",\n \"pants/33_far\",\n 11.07,\n -1.87,\n 84.5,\n 31,\n 28,\n BEND,\n { sx: 0.806, sy: 1 },\n ],\n ],\n \"pants/34\": [\n [\"pants\", \"pants/34\", 1.5, 1, 0, 79, 26],\n [\n \"pants_near\",\n \"pants/34_near\",\n 11.06,\n -1.97,\n 84,\n 31,\n 28,\n BEND,\n { sx: 0.806, sy: 1 },\n ],\n [\n \"pants_far\",\n \"pants/34_far\",\n 11.07,\n -1.87,\n 84.5,\n 31,\n 28,\n BEND,\n { sx: 0.806, sy: 1 },\n ],\n ],\n \"pants/35\": [\n [\"pants\", \"pants/35\", 1.5, 1, 0, 79, 26],\n [\n \"pants_near\",\n \"pants/35_near\",\n 11.06,\n -1.97,\n 84,\n 31,\n 28,\n BEND,\n { sx: 0.806, sy: 1 },\n ],\n [\n \"pants_far\",\n \"pants/35_far\",\n 11.07,\n -1.87,\n 84.5,\n 31,\n 28,\n BEND,\n { sx: 0.806, sy: 1 },\n ],\n ],\n \"pants/36\": [\n [\n \"pants\",\n \"pants/36\",\n 2.2,\n -6.06,\n 0,\n 92,\n 39,\n BEND,\n { sx: 0.993, sy: 1.027 },\n ],\n ],\n \"pants/37\": [\n [\"pants\", \"pants/37\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/37_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/37_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/38\": [\n [\"pants\", \"pants/38\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/38_near\", 11.06, -1.97, 84, 25, 28, BEND],\n [\"pants_far\", \"pants/38_far\", 11.07, -1.87, 84.5, 25, 28, BEND],\n ],\n \"pants/39\": [\n [\"pants\", \"pants/39\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/39_near\", 11.06, -1.97, 84, 25, 28, BEND],\n [\"pants_far\", \"pants/39_far\", 11.07, -1.87, 84.5, 25, 28, BEND],\n ],\n \"pants/40\": [\n [\"pants\", \"pants/40\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/40_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/40_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/41\": [\n [\"pants\", \"pants/41\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/41_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/41_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/42\": [\n [\n \"pants\",\n \"pants/42\",\n 2.08,\n -4.02,\n 0,\n 82,\n 37,\n BEND,\n { sx: 1.014, sy: 1.01 },\n ],\n ],\n \"pants/43\": [\n [\n \"pants\",\n \"pants/43\",\n 2.08,\n -4.02,\n 0,\n 82,\n 37,\n BEND,\n { sx: 1.014, sy: 1.01 },\n ],\n ],\n \"pants/44\": [\n [\n \"pants\",\n \"pants/44\",\n 2.2,\n -6.07,\n 0,\n 88,\n 40,\n BEND,\n { sx: 0.993, sy: 1.001 },\n ],\n ],\n \"pants/45\": [\n [\"pants\", \"pants/45\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/45_near\", 17.52, -2.65, 84, 36, 42, BEND],\n [\"pants_far\", \"pants/45_far\", 17.54, -2.49, 84.5, 38, 43, BEND],\n ],\n \"pants/46\": [\n [\"pants\", \"pants/46\", 1.5, 1, 0, 79, 26],\n [\"pants_near\", \"pants/46_near\", 17.52, -2.65, 84, 36, 42, BEND],\n [\"pants_far\", \"pants/46_far\", 17.54, -2.49, 84.5, 38, 43, BEND],\n ],\n \"pants/47\": [\n [\n \"pants\",\n \"pants/47\",\n 3.2,\n -4.07,\n 0,\n 96,\n 36,\n BEND,\n { sx: 0.993, sy: 1.002 },\n ],\n [\"pants_near\", \"pants/47_near\", 17.52, -2.65, 84, 25, 41, BEND],\n [\"pants_far\", \"pants/47_far\", 17.54, -2.49, 84.5, 25, 41, BEND],\n ],\n \"pants/48\": [\n [\n \"pants\",\n \"pants/48\",\n 3.2,\n -4.07,\n 0,\n 96,\n 38,\n BEND,\n { sx: 0.993, sy: 0.949 },\n ],\n ],\n \"pants/49\": [[\"pants\", \"pants/49\", 1.5, 1, 0, 79, 26]],\n \"pants/50\": [\n [\"pants\", \"pants/50\", 1.5, 1, 0, 79, 26],\n [\n \"pants_near\",\n \"pants/50_near\",\n 11.06,\n -1.97,\n 84,\n 31,\n 28,\n BEND,\n { sx: 0.806, sy: 1 },\n ],\n [\n \"pants_far\",\n \"pants/50_far\",\n 11.07,\n -1.87,\n 84.5,\n 31,\n 28,\n BEND,\n { sx: 0.806, sy: 1 },\n ],\n ],\n \"brows/1\": [[\"brows\", \"brows/1\", 0.53, -0.64, -90.18, 51, 11]],\n \"brows/2\": [[\"brows\", \"brows/2\", 2.03, -0.64, -90.18, 65, 18]],\n \"brows/3\": [[\"brows\", \"brows/3\", 1.03, 0.36, -90.18, 61, 16]],\n \"brows/4\": [[\"brows\", \"brows/4\", 1.53, -2.14, -90.18, 66, 19]],\n \"brows/5\": [[\"brows\", \"brows/5\", -0.47, -2.13, -90.18, 72, 23]],\n \"brows/6\": [[\"brows\", \"brows/6\", 2.03, -0.14, -90.18, 44, 10]],\n \"brows/7\": [[\"brows\", \"brows/7\", 0.03, -1.13, -90.18, 62, 18]],\n \"brows/8\": [[\"brows\", \"brows/8\", 1.03, 0.36, -90.18, 57, 18]],\n \"brows/9\": [[\"brows\", \"brows/9\", 1.53, -0.64, -90.18, 53, 13]],\n \"brows/10\": [[\"brows\", \"brows/10\", 4.53, 0.35, -90.18, 17, 23]],\n \"eyes/1\": [[\"eyes\", \"eyes/1\", 1.53, -0.1, -90.18, 61, 20]],\n \"eyes/2\": [[\"eyes\", \"eyes/2\", -0.47, -0.09, -90.18, 63, 22]],\n \"eyes/3\": [[\"eyes\", \"eyes/3\", 1.53, -0.6, -90.18, 66, 30]],\n \"eyes/4\": [[\"eyes\", \"eyes/4\", 1.03, 0.4, -90.18, 64, 31]],\n \"eyes/5\": [[\"eyes\", \"eyes/5\", 1.53, -1.6, -90.18, 64, 24]],\n \"eyes/6\": [[\"eyes\", \"eyes/6\", 0.53, -0.09, -90.18, 63, 28]],\n \"eyes/7\": [[\"eyes\", \"eyes/7\", 2.02, -2.1, -90.18, 69, 29]],\n \"eyes/8\": [[\"eyes\", \"eyes/8\", 0.54, 2.41, -90.18, 44, 22]],\n \"eyes/9\": [[\"eyes\", \"eyes/9\", -0.46, 2.41, -90.18, 48, 20]],\n \"eyes/10\": [[\"eyes\", \"eyes/10\", -0.97, 0.91, -90.18, 61, 23]],\n \"eyes/11\": [[\"eyes\", \"eyes/11\", 2.03, 0.4, -90.18, 60, 21]],\n \"eyes/12\": [[\"eyes\", \"eyes/12\", -2.97, -1.08, -90.18, 65, 27]],\n \"eyes/13\": [[\"eyes\", \"eyes/13\", -0.97, 0.91, -90.18, 59, 21]],\n \"eyes/14\": [[\"eyes\", \"eyes/14\", 0.53, 0.41, -90.18, 62, 22]],\n \"eyes/15\": [[\"eyes\", \"eyes/15\", 3.03, -1.1, -90.18, 63, 23]],\n \"eyes/16\": [[\"eyes\", \"eyes/16\", 0.53, 0.91, -90.18, 59, 26]],\n \"eyes/17\": [[\"eyes\", \"eyes/17\", 0.53, -0.09, -90.18, 55, 22]],\n \"eyes/18\": [[\"eyes\", \"eyes/18\", 2.53, -0.1, -90.18, 57, 6]],\n \"eyes/19\": [[\"eyes\", \"eyes/19\", 4.03, -1.1, -90.18, 59, 23]],\n \"eyes/20\": [[\"eyes\", \"eyes/20\", 0.53, -1.09, -90.18, 59, 26]],\n \"glasses/1\": [[\"glasses\", \"glasses/1\", -10.71, -12.81, 179.82, 85, 48]],\n \"glasses/2\": [[\"glasses\", \"glasses/2\", -1.69, -8.83, 179.82, 71, 30]],\n \"glasses/3\": [[\"glasses\", \"glasses/3\", -3.19, -8.83, 179.82, 90, 38]],\n \"glasses/4\": [[\"glasses\", \"glasses/4\", -2.21, -13.83, 179.82, 96, 40]],\n \"glasses/5\": [[\"glasses\", \"glasses/5\", 0.8, -10.84, 179.82, 80, 44]],\n \"glasses/6\": [[\"glasses\", \"glasses/6\", -2.7, -10.33, 179.82, 91, 41]],\n \"glasses/7\": [[\"glasses\", \"glasses/7\", 0.8, -9.34, 179.82, 80, 35]],\n \"glasses/8\": [[\"glasses\", \"glasses/8\", 0.31, -8.84, 179.82, 58, 27]],\n \"glasses/9\": [[\"glasses\", \"glasses/9\", -2.21, -14.33, 179.82, 84, 47]],\n \"glasses/10\": [[\"glasses\", \"glasses/10\", -1.7, -11.83, 179.82, 81, 58]],\n \"glasses/11\": [[\"glasses\", \"glasses/11\", 0.31, -8.84, 179.82, 72, 21]],\n \"glasses/12\": [[\"glasses\", \"glasses/12\", -2.69, -7.83, 179.82, 83, 36]],\n \"glasses/13\": [[\"glasses\", \"glasses/13\", 3.32, -5.85, 179.82, 81, 24]],\n \"glasses/14\": [[\"glasses\", \"glasses/14\", 0.8, -9.34, 179.82, 72, 37]],\n \"glasses/15\": [[\"glasses\", \"glasses/15\", -1.69, -8.83, 179.82, 83, 44]],\n \"glasses/16\": [[\"glasses\", \"glasses/16\", -1.2, -9.34, 179.82, 86, 39]],\n \"glasses/17\": [[\"glasses\", \"glasses/17\", 1.29, -13.84, 179.82, 89, 58]],\n \"glasses/18\": [[\"glasses\", \"glasses/18\", 1.8, -10.34, 179.82, 74, 39]],\n \"glasses/19\": [[\"glasses\", \"glasses/19\", -2.69, -7.33, 179.82, 89, 37]],\n \"glasses/20\": [[\"glasses\", \"glasses/20\", -0.21, -13.84, 179.82, 90, 54]],\n \"glasses/21\": [[\"glasses\", \"glasses/21\", -1.71, -13.83, 179.82, 79, 40]],\n \"glasses/22\": [[\"glasses\", \"glasses/22\", -0.69, -6.84, 179.82, 71, 32]],\n \"glasses/23\": [[\"glasses\", \"glasses/23\", -2.2, -9.83, 179.82, 82, 38]],\n \"glasses/24\": [[\"glasses\", \"glasses/24\", 0.31, -8.84, 179.82, 77, 34]],\n \"glasses/25\": [[\"glasses\", \"glasses/25\", -0.7, -10.84, 179.82, 73, 34]],\n \"glasses/26\": [[\"glasses\", \"glasses/26\", -2.19, -8.33, 179.82, 70, 33]],\n \"glasses/27\": [[\"glasses\", \"glasses/27\", -2.19, -8.33, 179.82, 74, 64]],\n \"glasses/28\": [[\"glasses\", \"glasses/28\", -3.17, -1.33, 179.82, 86, 77]],\n \"glasses/29\": [[\"glasses\", \"glasses/29\", -0.2, -11.34, 179.82, 72, 41]],\n \"glasses/30\": [[\"glasses\", \"glasses/30\", 0.32, -4.34, 179.82, 71, 51]],\n \"offhand/1\": [[\"offhand\", \"offhand/1\", 2.54, 8.55, 1.05, 101, 83]],\n \"offhand/2\": [[\"offhand\", \"offhand/2\", 3.56, 7.57, 1.05, 79, 87]],\n \"offhand/3\": [[\"offhand\", \"offhand/3\", 0.56, 7.51, 1.05, 87, 87]],\n \"offhand/4\": [[\"offhand\", \"offhand/4\", 5.07, 6.59, 1.05, 96, 81]],\n \"offhand/5\": [[\"offhand\", \"offhand/5\", -1.37, 3.48, 1.05, 109, 87]],\n \"offhand/6\": [[\"offhand\", \"offhand/6\", 1.57, 6.53, 1.05, 73, 73]],\n \"offhand/7\": [[\"offhand\", \"offhand/7\", 0.57, 7.01, 1.05, 91, 70]],\n \"offhand/8\": [[\"offhand\", \"offhand/8\", 0.58, 6.51, 1.05, 89, 89]],\n \"offhand/9\": [[\"offhand\", \"offhand/9\", 0.58, 6.01, 1.05, 95, 70]],\n \"offhand/10\": [[\"offhand\", \"offhand/10\", 6.56, 7.12, 1.05, 85, 82]],\n \"offhand/11\": [[\"offhand_low\", \"offhand/11_low\", -7.86, 2.86, 1.05, 56, 56]],\n \"offhand/12\": [[\"offhand_low\", \"offhand/12_low\", -7.86, 2.86, 1.05, 56, 56]],\n \"offhand/13\": [[\"offhand_low\", \"offhand/13_low\", -10.4, 4.81, 1.05, 65, 64]],\n \"offhand/14\": [[\"offhand_low\", \"offhand/14_low\", -5.32, 0.9, 1.05, 61, 46]],\n \"offhand/15\": [[\"offhand_low\", \"offhand/15_low\", -9.8, -0.18, 1.05, 78, 36]],\n \"offhand/16\": [[\"offhand_low\", \"offhand/16_low\", -9.23, -4.17, 1.05, 65, 30]],\n \"offhand/17\": [[\"offhand_low\", \"offhand/17_low\", -6.21, -5.11, 1.05, 55, 44]],\n \"offhand/18\": [[\"offhand_low\", \"offhand/18_low\", -8.3, -0.65, 1.05, 37, 37]],\n \"offhand/19\": [[\"offhand_low\", \"offhand/19_low\", -5.67, -7.6, 1.05, 46, 43]],\n \"offhand/20\": [\n [\"offhand_low\", \"offhand/20_low\", -11.22, -4.71, 1.05, 43, 41],\n ],\n \"offhand/21\": [[\"offhand_low\", \"offhand/21_low\", -5.51, -16.1, 1.05, 56, 44]],\n \"offhand/22\": [\n [\"offhand_low\", \"offhand/22_low\", -0.24, -31.01, 1.05, 56, 66],\n ],\n \"offhand/23\": [\n [\"offhand_low\", \"offhand/23_low\", -5.98, -18.11, 1.05, 49, 72],\n ],\n \"offhand/24\": [[\"offhand_low\", \"offhand/24_low\", -7.63, -9.64, 1.05, 56, 55]],\n \"offhand/25\": [[\"offhand_low\", \"offhand/25_low\", -5.17, -7.6, 1.05, 77, 51]],\n \"offhand/26\": [[\"offhand_low\", \"offhand/26_low\", -4.91, 5.91, 1.05, 94, 50]],\n \"offhand/27\": [[\"offhand_low\", \"offhand/27_low\", -15.85, 2.21, 1.05, 78, 59]],\n \"offhand/28\": [[\"offhand_low\", \"offhand/28_low\", -6.13, -9.61, 1.05, 63, 35]],\n \"offhand/29\": [\n [\"offhand_low\", \"offhand/29_low\", -22.77, -1.92, 1.05, 78, 35],\n ],\n \"offhand/30\": [[\"offhand_low\", \"offhand/30_low\", 1.57, -20.47, 1.05, 46, 59]],\n \"offhand/31\": [[\"offhand_low\", \"offhand/31_low\", -8.32, 0.85, 1.05, 53, 44]],\n \"offhand/32\": [\n [\"offhand_low\", \"offhand/32_low\", -12.07, -13.22, 1.05, 57, 54],\n ],\n \"offhand/33\": [\n [\"offhand_low\", \"offhand/33_low\", -14.18, -6.76, 1.05, 65, 29],\n ],\n \"offhand/34\": [\n [\"offhand_low\", \"offhand/34_low\", -14.64, -9.27, 1.05, 92, 34],\n ],\n \"offhand/35\": [\n [\"offhand_low\", \"offhand/35_low\", -0.57, -13.01, 1.05, 54, 48],\n ],\n \"offhand/36\": [\n [\"offhand_low\", \"offhand/36_low\", -22.27, -1.91, 1.05, 83, 31],\n ],\n \"offhand/37\": [\n [\"offhand_low\", \"offhand/37_low\", -13.23, -4.24, 1.05, 51, 42],\n ],\n \"offhand/38\": [\n [\"offhand_low\", \"offhand/38_low\", -10.19, -6.19, 1.05, 65, 32],\n ],\n \"offhand/39\": [\n [\"offhand_low\", \"offhand/39_low\", -11.17, -7.71, 1.05, 53, 37],\n ],\n \"offhand/40\": [\n [\"offhand_low\", \"offhand/40_low\", -11.17, -7.71, 1.05, 53, 37],\n ],\n \"weapon/1\": [[\"weapon\", \"weapon/1\", 9.7, -39.2, 90, 111, 77]],\n \"weapon/2\": [[\"weapon\", \"weapon/2\", 4.2, -22.7, 90, 112, 92]],\n \"weapon/3\": [[\"weapon\", \"weapon/3\", 24.2, -25.2, 90, 115, 60]],\n \"weapon/4\": [[\"weapon\", \"weapon/4\", 14.2, -18.7, 90, 96, 78]],\n \"weapon/5\": [[\"weapon\", \"weapon/5\", 4.7, -47.7, 90, 144, 71]],\n \"weapon/6\": [[\"weapon\", \"weapon/6\", 7.7, -44.2, 90, 135, 43]],\n \"weapon/7\": [[\"weapon\", \"weapon/7\", 15.2, -44.2, 90, 141, 40]],\n \"weapon/10\": [[\"weapon\", \"weapon/10\", 26.2, -30.7, 90, 100, 66]],\n \"weapon/11\": [[\"weapon\", \"weapon/11\", 13.7, -31.2, 90, 101, 87]],\n \"weapon/12\": [[\"weapon\", \"weapon/12\", 5.2, -26.7, 90, 124, 58]],\n \"weapon/13\": [[\"weapon\", \"weapon/13\", 5.7, -36.7, 90, 144, 33]],\n \"weapon/14\": [[\"weapon\", \"weapon/14\", 5.7, -34.2, 90, 131, 45]],\n \"weapon/15\": [[\"weapon\", \"weapon/15\", 5.2, -41.2, 90, 143, 60]],\n \"weapon/16\": [[\"weapon\", \"weapon/16\", 0.2, -37.7, 90, 142, 44]],\n \"weapon/17\": [[\"weapon\", \"weapon/17\", 10.7, -33.2, 90, 133, 35]],\n \"weapon/18\": [[\"weapon\", \"weapon/18\", 5.7, -24.2, 90, 103, 65]],\n \"weapon/19\": [[\"weapon\", \"weapon/19\", 5.7, -37.7, 90, 132, 39]],\n \"weapon/20\": [[\"weapon\", \"weapon/20\", 10.7, -37.2, 90, 133, 33]],\n \"weapon/21\": [[\"weapon\", \"weapon/21\", 8.7, -32.7, 90, 112, 29]],\n \"weapon/22\": [[\"weapon\", \"weapon/22\", 14.7, -34.7, 90, 116, 45]],\n \"weapon/23\": [[\"weapon\", \"weapon/23\", 7.2, -16.7, 90, 88, 58]],\n \"weapon/24\": [[\"weapon\", \"weapon/24\", 4.2, -41.2, 90, 137, 28]],\n \"weapon/25\": [[\"weapon\", \"weapon/25\", 4.7, -41.2, 90, 153, 43]],\n \"weapon/26\": [[\"weapon\", \"weapon/26\", 5.2, -48.7, 90, 160, 50]],\n \"weapon/27\": [[\"weapon\", \"weapon/27\", 5.7, -45.7, 90, 156, 51]],\n \"weapon/28\": [[\"weapon\", \"weapon/28\", 6.7, -57.7, 90, 174, 47]],\n \"weapon/29\": [[\"weapon\", \"weapon/29\", 4.7, -49.7, 90, 160, 65]],\n \"weapon/30\": [[\"weapon\", \"weapon/30\", 6.2, -43.2, 90, 147, 40]],\n \"weapon/31\": [[\"weapon\", \"weapon/31\", 4.2, -22.7, 90, 112, 92]],\n \"weapon/32\": [[\"weapon\", \"weapon/32\", 23.7, -25.2, 90, 115, 59]],\n \"weapon/33\": [[\"weapon\", \"weapon/33\", 5.7, -45.2, 90, 157, 51]],\n \"weapon/34\": [[\"weapon\", \"weapon/34\", 0.2, -37.2, 90, 143, 44]],\n \"weapon/35\": [[\"weapon\", \"weapon/35\", 0.2, -37.2, 90, 143, 44]],\n \"weapon/36\": [[\"weapon\", \"weapon/36\", 6.7, -57.2, 90, 175, 47]],\n \"weapon/37\": [[\"weapon\", \"weapon/37\", 5.2, -26.7, 90, 124, 58]],\n \"weapon/38\": [[\"weapon\", \"weapon/38\", 7.7, -44.2, 90, 135, 43]],\n \"weapon/39\": [[\"weapon\", \"weapon/39\", 5.2, -41.2, 90, 143, 60]],\n \"weapon/40\": [[\"weapon\", \"weapon/40\", 7.2, -16.2, 90, 87, 58]],\n \"gloves/1\": [\n [\"glove_near\", \"gloves/1_near\", 26.42, 2.29, 92.8, 28, 25],\n [\"glove_far\", \"gloves/1_far\", 24.68, 4.04, 100.9, 27, 25],\n ],\n \"gloves/2\": [\n [\"glove_near\", \"gloves/2_near\", 27.37, 3.34, 92.8, 34, 35],\n [\"glove_far\", \"gloves/2_far\", 25.95, 2.76, 100.9, 32, 35],\n ],\n \"gloves/3\": [\n [\"glove_near\", \"gloves/3_near\", 26.42, 2.29, 92.8, 28, 25],\n [\"glove_far\", \"gloves/3_far\", 24.68, 4.04, 100.9, 27, 25],\n ],\n \"gloves/4\": [\n [\"glove_near\", \"gloves/4_near\", 23.9, 2.67, 92.8, 31, 34],\n [\"glove_far\", \"gloves/4_far\", 21.83, 2.98, 100.9, 32, 33],\n ],\n \"gloves/5\": [\n [\"glove_near\", \"gloves/5_near\", 21.88, 3.07, 92.8, 36, 36],\n [\"glove_far\", \"gloves/5_far\", 21.06, 4.36, 100.9, 35, 36],\n ],\n \"gloves/6\": [\n [\"glove_near\", \"gloves/6_near\", 22.9, 2.62, 92.8, 31, 32],\n [\"glove_far\", \"gloves/6_far\", 21.83, 2.98, 100.9, 32, 33],\n ],\n \"gloves/7\": [\n [\"glove_near\", \"gloves/7_near\", 26.85, 3.82, 92.8, 35, 34],\n [\"glove_far\", \"gloves/7_far\", 26.06, 4.81, 100.9, 34, 34],\n ],\n \"gloves/8\": [\n [\"glove_near\", \"gloves/8_near\", 21.88, 3.07, 92.8, 30, 14],\n [\"glove_far\", \"gloves/8_far\", 21.15, 3.87, 100.9, 28, 14],\n ],\n \"gloves/9\": [\n [\"glove_near\", \"gloves/9_near\", 19.88, 2.97, 92.8, 38, 18],\n [\"glove_far\", \"gloves/9_far\", 19.19, 3.49, 100.9, 38, 18],\n ],\n \"gloves/10\": [\n [\"glove_near\", \"gloves/10_near\", 27.37, 3.34, 92.8, 34, 35],\n [\"glove_far\", \"gloves/10_far\", 25.95, 2.76, 100.9, 32, 35],\n ],\n \"gloves/11\": [\n [\"glove_near\", \"gloves/11_near\", 26.37, 3.29, 92.8, 30, 23],\n [\"glove_far\", \"gloves/11_far\", 25.57, 4.72, 100.9, 28, 23],\n ],\n \"gloves/12\": [\n [\"glove_near\", \"gloves/12_near\", 23.35, 3.64, 92.8, 33, 35],\n [\"glove_far\", \"gloves/12_far\", 22.04, 4.55, 100.9, 33, 36],\n ],\n \"gloves/13\": [\n [\"glove_near\", \"gloves/13_near\", 27.92, 2.37, 92.8, 36, 36],\n [\"glove_far\", \"gloves/13_far\", 26.93, 2.95, 100.9, 32, 37],\n ],\n \"gloves/14\": [\n [\"glove_near\", \"gloves/14_near\", 17.28, 4.85, 92.8, 32, 27],\n [\"glove_far\", \"gloves/14_far\", 15.35, 2.24, 100.9, 29, 24],\n ],\n \"gloves/15\": [\n [\"glove_near\", \"gloves/15_near\", 19.88, 2.97, 92.8, 36, 40],\n [\"glove_far\", \"gloves/15_far\", 19.09, 3.98, 100.9, 35, 40],\n ],\n \"gloves/16\": [\n [\"glove_near\", \"gloves/16_near\", 22.9, 2.62, 92.8, 31, 36],\n [\"glove_far\", \"gloves/16_far\", 21.64, 3.96, 100.9, 30, 37],\n ],\n \"gloves/17\": [\n [\"glove_near\", \"gloves/17_near\", 22.9, 2.62, 92.8, 31, 36],\n [\"glove_far\", \"gloves/17_far\", 21.64, 3.96, 100.9, 30, 37],\n ],\n \"gloves/18\": [\n [\"glove_near\", \"gloves/18_near\", 27.37, 3.34, 92.8, 32, 31],\n [\"glove_far\", \"gloves/18_far\", 26.25, 3.83, 100.9, 30, 30],\n ],\n \"gloves/19\": [\n [\"glove_near\", \"gloves/19_near\", 26.82, 4.32, 92.8, 32, 34],\n [\"glove_far\", \"gloves/19_far\", 25.17, 4.13, 100.9, 29, 34],\n ],\n \"gloves/20\": [\n [\"glove_near\", \"gloves/20_near\", 24.92, 2.22, 92.8, 34, 38],\n [\"glove_far\", \"gloves/20_far\", 23.02, 4.74, 100.9, 31, 38],\n ],\n \"gloves/21\": [\n [\"glove_near\", \"gloves/21_near\", 26.42, 2.29, 92.8, 28, 25],\n [\"glove_far\", \"gloves/21_far\", 24.68, 4.04, 100.9, 27, 25],\n ],\n \"gloves/22\": [\n [\"glove_near\", \"gloves/22_near\", 26.42, 2.29, 92.8, 28, 25],\n [\"glove_far\", \"gloves/22_far\", 24.68, 4.04, 100.9, 27, 25],\n ],\n \"gloves/23\": [\n [\"glove_near\", \"gloves/23_near\", 19.88, 2.97, 92.8, 36, 40],\n [\"glove_far\", \"gloves/23_far\", 19.09, 3.98, 100.9, 35, 40],\n ],\n \"gloves/24\": [\n [\"glove_near\", \"gloves/24_near\", 19.88, 2.97, 92.8, 36, 40],\n [\"glove_far\", \"gloves/24_far\", 19.09, 3.98, 100.9, 35, 40],\n ],\n \"gloves/25\": [\n [\"glove_near\", \"gloves/25_near\", 27.92, 2.37, 92.8, 36, 36],\n [\"glove_far\", \"gloves/25_far\", 26.93, 2.95, 100.9, 32, 37],\n ],\n \"gloves/26\": [\n [\"glove_near\", \"gloves/26_near\", 21.88, 3.07, 92.8, 30, 14],\n [\"glove_far\", \"gloves/26_far\", 21.15, 3.87, 100.9, 28, 14],\n ],\n \"gloves/27\": [\n [\"glove_near\", \"gloves/27_near\", 19.88, 2.97, 92.8, 38, 18],\n [\"glove_far\", \"gloves/27_far\", 19.19, 3.49, 100.9, 38, 18],\n ],\n \"gloves/28\": [\n [\"glove_near\", \"gloves/28_near\", 19.88, 2.97, 92.8, 38, 18],\n [\"glove_far\", \"gloves/28_far\", 19.19, 3.49, 100.9, 38, 18],\n ],\n \"gloves/29\": [\n [\"glove_near\", \"gloves/29_near\", 23.9, 2.67, 92.8, 31, 34],\n [\"glove_far\", \"gloves/29_far\", 21.83, 2.98, 100.9, 32, 33],\n ],\n \"gloves/30\": [\n [\"glove_near\", \"gloves/30_near\", 17.28, 4.85, 92.8, 32, 27],\n [\"glove_far\", \"gloves/30_far\", 15.35, 2.24, 100.9, 29, 24],\n ],\n \"hair_under/1\": [[\"hair_under\", \"hair_under/1\", 56.5, -1.48, -90.18, 87, 54]],\n \"hair_under/2\": [\n [\"hair_under\", \"hair_under/2\", 54.5, -0.47, -90.18, 111, 60],\n ],\n \"hair_under/3\": [\n [\"hair_under\", \"hair_under/3\", 57.48, -5.48, -90.18, 97, 54],\n ],\n \"hair_under/4\": [\n [\"hair_under\", \"hair_under/4\", 56.99, -3.48, -90.18, 103, 55],\n ],\n \"hair_under/5\": [\n [\"hair_under\", \"hair_under/5\", 52.5, -1.47, -90.18, 101, 64],\n ],\n \"hair_under/6\": [[\"hair_under\", \"hair_under/6\", 57.51, 2.02, -90.18, 96, 54]],\n \"hair_under/7\": [[\"hair_under\", \"hair_under/7\", 58.5, 0.01, -90.18, 92, 52]],\n \"hair_under/8\": [[\"hair_under\", \"hair_under/8\", 47, 0.55, -90.18, 123, 75]],\n \"hair_under/9\": [\n [\"hair_under\", \"hair_under/9\", 45.49, -3.44, -90.18, 105, 78],\n ],\n \"hair_under/10\": [\n [\"hair_under\", \"hair_under/10\", 48.5, 0.05, -90.18, 130, 72],\n ],\n \"hair_under/11\": [\n [\"hair_under\", \"hair_under/11\", 45.99, -3.45, -90.18, 97, 87],\n ],\n \"hair_under/12\": [\n [\"hair_under\", \"hair_under/12\", 43.5, 0.06, -90.18, 126, 64],\n ],\n \"hair_under/13\": [\n [\"hair_under\", \"hair_under/13\", 49.49, -1.96, -90.18, 104, 40],\n ],\n \"hair_under/14\": [\n [\"hair_under\", \"hair_under/14\", 56.99, -3.98, -90.18, 96, 55],\n ],\n \"hair_under/15\": [[\"hair_under\", \"hair_under/15\", 60, 0.01, -90.18, 92, 49]],\n \"hair_under/16\": [[\"hair_under\", \"hair_under/16\", 57, -1.48, -90.18, 95, 55]],\n \"hair_under/17\": [[\"hair_under\", \"hair_under/17\", 56, 0.02, -90.18, 102, 57]],\n \"hair_under/18\": [\n [\"hair_under\", \"hair_under/18\", 49.99, -1.96, -90.18, 118, 69],\n ],\n \"hair_under/19\": [\n [\"hair_under\", \"hair_under/19\", 40.49, -3.93, -90.18, 118, 88],\n ],\n \"hair_under/20\": [\n [\"hair_under\", \"hair_under/20\", 58.49, -1.99, -90.18, 96, 52],\n ],\n \"hair_under/21\": [\n [\"hair_under\", \"hair_under/21\", 54.49, -2.47, -90.18, 101, 60],\n ],\n \"hair_under/22\": [[\"hair_under\", \"hair_under/22\", 58, -0.98, -90.18, 90, 53]],\n \"hair_under/23\": [\n [\"hair_under\", \"hair_under/23\", 57.99, -2.98, -90.18, 94, 53],\n ],\n \"hair_under/24\": [\n [\"hair_under\", \"hair_under/24\", 51.5, 0.04, -90.18, 122, 66],\n ],\n \"hair_under/25\": [\n [\"hair_under\", \"hair_under/25\", 50.99, -2.96, -90.18, 118, 67],\n ],\n \"hair_under/26\": [\n [\"hair_under\", \"hair_under/26\", 56.98, -5.98, -90.18, 98, 55],\n ],\n \"hair_under/27\": [\n [\"hair_under\", \"hair_under/27\", 58.5, -1.49, -90.18, 103, 52],\n ],\n \"hair_under/28\": [[\"hair_under\", \"hair_under/28\", 45, 0.06, -90.18, 122, 79]],\n \"hair_under/29\": [\n [\"hair_under\", \"hair_under/29\", 39.49, -2.43, -90.18, 99, 90],\n ],\n \"hair_under/30\": [\n [\"hair_under\", \"hair_under/30\", 36.49, -2.92, -90.18, 116, 96],\n ],\n \"hair/1\": [[\"hair\", \"hair/1\", 56.5, -1.48, -90.18, 87, 54]],\n \"hair/2\": [[\"hair\", \"hair/2\", 57.99, -2.48, -90.18, 113, 61]],\n \"hair/3\": [[\"hair\", \"hair/3\", 62.98, -5, -90.18, 98, 65]],\n \"hair/4\": [[\"hair\", \"hair/4\", 76.48, -6.54, -90.18, 119, 94]],\n \"hair/5\": [[\"hair\", \"hair/5\", 58.01, 2.02, -90.18, 108, 75]],\n \"hair/6\": [[\"hair\", \"hair/6\", 65.51, 3.49, -90.18, 115, 70]],\n \"hair/7\": [[\"hair\", \"hair/7\", 66, -1.51, -90.18, 97, 67]],\n \"hair/8\": [[\"hair\", \"hair/8\", 69.48, -7.52, -90.18, 147, 120]],\n \"hair/9\": [[\"hair\", \"hair/9\", 62.99, -3.5, -90.18, 105, 113]],\n \"hair/10\": [[\"hair\", \"hair/10\", 63.49, -3, -90.18, 136, 84]],\n \"hair/11\": [[\"hair\", \"hair/11\", 58.96, -11.49, -90.18, 133, 113]],\n \"hair/12\": [[\"hair\", \"hair/12\", 64.47, -9, -90.18, 134, 74]],\n \"hair/13\": [[\"hair\", \"hair/13\", 49.49, -2.46, -90.18, 105, 40]],\n \"hair/14\": [[\"hair\", \"hair/14\", 69.46, -13.02, -90.18, 114, 80]],\n \"hair/15\": [[\"hair\", \"hair/15\", 63.99, -2, -90.18, 96, 57]],\n \"hair/16\": [[\"hair\", \"hair/16\", 72.49, -2.53, -90.18, 97, 86]],\n \"hair/17\": [[\"hair\", \"hair/17\", 59, -1.49, -90.18, 105, 63]],\n \"hair/18\": [[\"hair\", \"hair/18\", 56.99, -3.98, -90.18, 128, 83]],\n \"hair/19\": [[\"hair\", \"hair/19\", 49.97, -8.96, -90.18, 128, 107]],\n \"hair/20\": [\n [\"hair\", \"hair/20\", 61.99, -2.5, -90.18, 97, 59],\n [\"hair_back\", \"hair/20_back\", 48.88, -38.46, -90.18, 67, 91],\n ],\n \"hair/21\": [\n [\"hair\", \"hair/21\", 56.99, -2.48, -90.18, 101, 65],\n [\"hair_back\", \"hair/21_back\", 55.89, -34.98, -90.18, 96, 105],\n ],\n \"hair/22\": [[\"hair\", \"hair/22\", 66.96, -13.01, -90.18, 114, 71]],\n \"hair/23\": [[\"hair\", \"hair/23\", 73.47, -10.03, -90.18, 124, 84]],\n \"hair/24\": [[\"hair\", \"hair/24\", 56.49, -2.48, -90.18, 127, 74]],\n \"hair/25\": [[\"hair\", \"hair/25\", 78.48, -7.55, -90.18, 135, 102]],\n \"hair/26\": [[\"hair\", \"hair/26\", 63.47, -8.5, -90.18, 103, 68]],\n \"hair/27\": [[\"hair\", \"hair/27\", 59, -1.49, -90.18, 103, 53]],\n \"hair/28\": [\n [\"hair\", \"hair/28\", 48.5, -1.45, -90.18, 125, 86],\n [\"hair_back\", \"hair/28_back\", 11.44, -19.34, -90.18, 97, 102],\n ],\n \"hair/29\": [\n [\"hair\", \"hair/29\", 49.99, -2.46, -90.18, 99, 111],\n [\"hair_back\", \"hair/29_back\", 54.38, -37.47, -90.18, 79, 112],\n ],\n \"hair/30\": [[\"hair\", \"hair/30\", 45.48, -5.44, -90.18, 119, 114]],\n \"hat/1\": [\n [\"hat\", \"hat/1\", 47.5, 0.05, -90.18, 120, 110],\n [\"hat_back\", \"hat/1_back\", 27.03, 9.61, -90.18, 95, 59],\n ],\n \"hat/2\": [[\"hat\", \"hat/2\", 69.99, -2.02, -90.18, 154, 75]],\n \"hat/3\": [[\"hat_low\", \"hat/3_low\", 72.49, -2.03, -90.18, 126, 82]],\n \"hat/4\": [[\"hat\", \"hat/4\", 71.51, 2.97, -90.18, 122, 78]],\n \"hat/5\": [[\"hat\", \"hat/5\", 43.5, -0.94, -90.18, 118, 120]],\n \"hat/6\": [[\"hat\", \"hat/6\", 72.5, 0.97, -90.18, 130, 82]],\n \"hat/7\": [\n [\"hat\", \"hat/7\", 52, -1.47, -90.18, 121, 105],\n [\"hat_back\", \"hat/7_back\", 28.14, 42.61, -90.18, 33, 53],\n ],\n \"hat/8\": [[\"hat_low\", \"hat/8_low\", 68, -0.02, -90.18, 136, 75]],\n \"hat/9\": [[\"hat_low\", \"hat/9_low\", 81.99, -4.56, -90.18, 117, 93]],\n \"hat/10\": [[\"hat_low\", \"hat/10_low\", 72.46, -13.03, -90.18, 140, 94]],\n \"hat/11\": [[\"hat_low\", \"hat/11_low\", 47.99, -3.95, -90.18, 114, 115]],\n \"hat/12\": [[\"hat_low\", \"hat/12_low\", 70, 1.48, -90.18, 145, 69]],\n \"hat/13\": [[\"hat_low\", \"hat/13_low\", 51.98, -6.46, -90.18, 117, 131]],\n \"hat/14\": [[\"hat_low\", \"hat/14_low\", 59.48, -4.99, -90.18, 140, 120]],\n \"hat/15\": [[\"hat\", \"hat/15\", 76.49, -2.54, -90.18, 145, 102]],\n \"hat/16\": [\n [\"hat\", \"hat/16\", 54.01, 2.53, -90.18, 129, 97],\n [\"hat_back\", \"hat/16_back\", 33.63, 40.09, -90.18, 36, 56],\n ],\n \"hat/17\": [[\"hat_low\", \"hat/17_low\", 76.49, -3.04, -90.18, 126, 84]],\n \"hat/18\": [[\"hat\", \"hat/18\", 68, 0.98, -90.18, 142, 93]],\n \"hat/19\": [[\"hat_low\", \"hat/19_low\", 88.5, -1.08, -90.18, 122, 106]],\n \"hat/20\": [\n [\"hat_low\", \"hat/20_low\", 68.99, -4.02, -90.18, 106, 71],\n [\"hat_back\", \"hat/20_back\", 26.4, -32.88, -90.18, 68, 44],\n ],\n \"hat/21\": [[\"hat_low\", \"hat/21_low\", 68.98, -5.52, -90.18, 117, 75]],\n \"hat/22\": [\n [\"hat_low\", \"hat/22_low\", 52.48, -5.47, -90.18, 123, 94],\n [\"hat_back\", \"hat/22_back\", 35.63, 39.59, -90.18, 37, 46],\n ],\n \"hat/23\": [\n [\"hat\", \"hat/23\", 73.49, -2.53, -90.18, 137, 110],\n [\"hat_back\", \"hat/23_back\", 48.5, 0.05, -90.18, 134, 64],\n ],\n \"hat/24\": [[\"hat\", \"hat/24\", 69.98, -5.52, -90.18, 133, 81]],\n \"hat/25\": [\n [\"hat\", \"hat/25\", 52.51, 2.03, -90.18, 126, 100],\n [\"hat_back\", \"hat/25_back\", 33.61, 35.59, -90.18, 43, 60],\n ],\n \"hat/26\": [\n [\"hat\", \"hat/26\", 64.49, -4, -90.18, 108, 70],\n [\"hat_back\", \"hat/26_back\", 20.34, -50.86, -90.18, 46, 52],\n ],\n \"hat/27\": [[\"hat_low\", \"hat/27_low\", 71.99, -2.53, -90.18, 133, 83]],\n \"hat/28\": [\n [\"hat\", \"hat/28\", 62, 0, -90.18, 126, 107],\n [\"hat_back\", \"hat/28_back\", 40.49, -1.93, -90.18, 112, 56],\n ],\n \"hat/29\": [[\"hat_low\", \"hat/29_low\", 61, 1.01, -90.18, 124, 117]],\n \"hat/30\": [[\"hat_low\", \"hat/30_low\", 70.49, -4.52, -90.18, 117, 78]],\n \"hat/31\": [[\"hat_low\", \"hat/31_low\", 68, -0.02, -90.18, 136, 75]],\n \"hat/32\": [\n [\"hat\", \"hat/32\", 47.5, 0.05, -90.18, 120, 110],\n [\"hat_back\", \"hat/32_back\", 27.03, 9.61, -90.18, 95, 59],\n ],\n \"hat/33\": [[\"hat_low\", \"hat/33_low\", 70, 1.48, -90.18, 145, 69]],\n \"hat/34\": [\n [\"hat_low\", \"hat/34_low\", 68.99, -4.02, -90.18, 106, 71],\n [\"hat_back\", \"hat/34_back\", 26.4, -32.88, -90.18, 68, 44],\n ],\n \"hat/35\": [\n [\"hat_low\", \"hat/35_low\", 68.99, -4.02, -90.18, 106, 71],\n [\"hat_back\", \"hat/35_back\", 26.4, -32.88, -90.18, 68, 44],\n ],\n \"hat/36\": [[\"hat\", \"hat/36\", 69.99, -2.02, -90.18, 154, 75]],\n \"hat/37\": [[\"hat\", \"hat/37\", 69.98, -5.52, -90.18, 133, 81]],\n \"hat/38\": [[\"hat\", \"hat/38\", 69.98, -5.52, -90.18, 133, 81]],\n \"hat/39\": [[\"hat\", \"hat/39\", 43.5, -0.94, -90.18, 118, 120]],\n \"hat/40\": [[\"hat_low\", \"hat/40_low\", 68.98, -5.52, -90.18, 117, 75]],\n \"hat/41\": [[\"hat_low\", \"hat/41_low\", 68.98, -5.52, -90.18, 117, 75]],\n \"hat/42\": [[\"hat_low\", \"hat/42_low\", 81.99, -4.56, -90.18, 117, 93]],\n \"hat/43\": [\n [\"hat_low\", \"hat/43_low\", 52.48, -5.47, -90.18, 123, 94],\n [\"hat_back\", \"hat/43_back\", 35.63, 39.59, -90.18, 37, 46],\n ],\n \"hat/44\": [[\"hat_low\", \"hat/44_low\", 69.99, -2.52, -90.18, 133, 79]],\n \"hat/45\": [[\"hat_low\", \"hat/45_low\", 47.99, -3.95, -90.18, 114, 115]],\n \"hat/46\": [[\"hat_low\", \"hat/46_low\", 47.99, -3.95, -90.18, 114, 115]],\n \"hat/47\": [[\"hat_low\", \"hat/47_low\", 72.46, -13.03, -90.18, 140, 94]],\n \"hat/48\": [[\"hat\", \"hat/48\", 71.51, 2.97, -90.18, 122, 78]],\n \"hat/49\": [\n [\"hat_low\", \"hat/49_low\", 64.49, -4.5, -90.18, 107, 70],\n [\"hat_back\", \"hat/49_back\", 20.34, -50.86, -90.18, 46, 52],\n ],\n \"hat/50\": [[\"hat_low\", \"hat/50_low\", 61, 1.01, -90.18, 124, 117]],\n \"hat/51\": [[\"hat_low\", \"hat/51_low\", 51.98, -6.46, -90.18, 117, 131]],\n \"hat/52\": [\n [\"hat\", \"hat/52\", 62, 0, -90.18, 126, 107],\n [\"hat_back\", \"hat/52_back\", 40.49, -1.93, -90.18, 112, 56],\n ],\n \"hat/53\": [[\"hat_low\", \"hat/53_low\", 59.48, -4.99, -90.18, 140, 120]],\n \"hat/54\": [[\"hat_low\", \"hat/54_low\", 59.48, -4.99, -90.18, 140, 120]],\n \"hat/55\": [\n [\"hat\", \"hat/55\", 73.49, -2.53, -90.18, 137, 110],\n [\"hat_back\", \"hat/55_back\", 48.5, 0.05, -90.18, 134, 64],\n ],\n \"hat/56\": [\n [\"hat\", \"hat/56\", 73.49, -2.53, -90.18, 137, 110],\n [\"hat_back\", \"hat/56_back\", 48.5, 0.05, -90.18, 134, 64],\n ],\n \"hat/57\": [[\"hat\", \"hat/57\", 72.5, 0.97, -90.18, 130, 82]],\n \"hat/58\": [\n [\"hat\", \"hat/58\", 54.01, 2.53, -90.18, 129, 97],\n [\"hat_back\", \"hat/58_back\", 33.63, 40.09, -90.18, 36, 56],\n ],\n \"hat/59\": [\n [\"hat\", \"hat/59\", 52, -1.47, -90.18, 121, 105],\n [\"hat_back\", \"hat/59_back\", 28.14, 42.61, -90.18, 33, 53],\n ],\n \"hat/60\": [[\"hat\", \"hat/60\", 76.49, -2.54, -90.18, 145, 102]],\n \"mouth/1\": [[\"mouth\", \"mouth/1\", 0.56, 0.35, 89.82, 15, 7]],\n \"mouth/2\": [[\"mouth\", \"mouth/2\", 2.06, -0.16, 89.82, 27, 19]],\n \"mouth/3\": [[\"mouth\", \"mouth/3\", -0.44, 0.85, 89.82, 23, 12]],\n \"mouth/4\": [[\"mouth\", \"mouth/4\", 2.06, 2.34, 89.82, 24, 11]],\n \"mouth/5\": [[\"mouth\", \"mouth/5\", 0.56, 1.85, 89.82, 33, 16]],\n \"mouth/6\": [[\"mouth\", \"mouth/6\", -0.44, 2.35, 89.82, 20, 18]],\n \"mouth/7\": [[\"mouth\", \"mouth/7\", 0.06, 1.35, 89.82, 20, 19]],\n \"mouth/8\": [[\"mouth\", \"mouth/8\", 0.07, 3.85, 89.82, 23, 13]],\n \"mouth/9\": [[\"mouth\", \"mouth/9\", 4.06, 1.34, 89.82, 24, 19]],\n \"mouth/10\": [[\"mouth\", \"mouth/10\", 1.06, 1.84, 89.82, 23, 15]],\n \"skin/1\": [\n [\"arm_near\", \"skin/1_arm_near\", 0.9, -27.4, 0, 23, 72, BEND],\n [\"arm_far\", \"skin/1_arm_far\", -2.9, -25.7, 0, 29, 71, BEND],\n [\"torso\", \"skin/1_torso\", 35.4, -4.9, -90, 77, 50],\n [\"head\", \"skin/1_head\", 40.48, -5.43, -90.18, 103, 90],\n [\"leg_near\", \"skin/1_leg\", 18.74, -22.53, 0, 27, 55, BEND],\n [\"leg_far\", \"skin/1_leg\", -17.5, -22.5, 0, 27, 55, BEND],\n ],\n \"top/1\": [[\"top\", \"top/1\", 24.9, -2.9, -90, 87, 71]],\n \"top/2\": [\n [\"top\", \"top/2\", 26.4, -3.9, -90, 91, 88],\n [\"sleeve_near\", \"top/2_sleeve_near\", 7.25, 6.08, 91.4, 37, 34],\n [\"sleeve_far\", \"top/2_sleeve_far\", 7.26, -1.95, 101.9, 34, 35],\n ],\n \"top/3\": [\n [\"top\", \"top/3\", 25.4, -2.4, -90, 90, 88],\n [\"sleeve_near\", \"top/3_sleeve_near\", 22.32, 3.45, 91.4, 31, 64, BEND],\n [\"sleeve_far\", \"top/3_sleeve_far\", 20.75, 1.92, 101.9, 32, 63, BEND],\n ],\n \"top/4\": [\n [\"top\", \"top/4\", 24.4, -2.9, -90, 87, 88],\n [\"sleeve_near\", \"top/4_sleeve_near\", 7.25, 6.08, 91.4, 37, 34],\n [\"sleeve_far\", \"top/4_sleeve_far\", 7.26, -1.95, 101.9, 34, 35],\n ],\n \"top/5\": [\n [\"top\", \"top/5\", 27.9, -2.9, -90, 87, 81],\n [\"sleeve_near\", \"top/5_sleeve_near\", 7.25, 6.08, 91.4, 37, 34],\n [\"sleeve_far\", \"top/5_sleeve_far\", 7.26, -1.95, 101.9, 34, 35],\n ],\n \"top/6\": [\n [\"top\", \"top/6\", 27.4, -2.4, -90, 88, 82],\n [\"sleeve_near\", \"top/6_sleeve_near\", 9.85, 2.14, 91.4, 29, 37],\n [\"sleeve_far\", \"top/6_sleeve_far\", 8.42, -0.17, 101.9, 29, 38],\n ],\n \"top/7\": [\n [\"top\", \"top/7\", 18.9, -2.9, -90, 87, 83],\n [\"sleeve_near\", \"top/7_sleeve_near\", 9.85, 2.14, 91.4, 29, 37],\n [\"sleeve_far\", \"top/7_sleeve_far\", 8.42, -0.17, 101.9, 29, 38],\n ],\n \"top/8\": [[\"top\", \"top/8\", 28.4, -2.9, -90, 87, 80]],\n \"top/9\": [\n [\"top\", \"top/9\", 19.9, -2.9, -90, 87, 81],\n [\"sleeve_near\", \"top/9_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"cap_near\", \"top/9_cap_near\", 6.28, 5.05, 91.4, 37, 34],\n [\n \"sleeve_far\",\n \"top/9_sleeve_far\",\n 18.79,\n 1.51,\n 101.9,\n 34,\n 59,\n BEND,\n { sx: 0.941, sy: 1 },\n ],\n [\"cap_far\", \"top/9_cap_far\", 6.77, -2.05, 101.9, 34, 36],\n ],\n \"top/10\": [\n [\"top\", \"top/10\", 19.9, 2.6, -90, 56, 59],\n [\n \"coat\",\n \"top/10_coat\",\n 16.35,\n -3.58,\n -90,\n 88,\n 106,\n BEND,\n { sx: 0.995, sy: 1.002 },\n ],\n [\"sleeve_near\", \"top/10_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/10_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/11\": [\n [\"top\", \"top/11\", 24.4, -2.9, -90, 87, 72],\n [\"sleeve_near\", \"top/11_sleeve_near\", 7.25, 6.08, 91.4, 37, 34],\n [\"sleeve_far\", \"top/11_sleeve_far\", 7.26, -1.95, 101.9, 34, 35],\n ],\n \"top/12\": [\n [\"top\", \"top/12\", 20.9, -3.4, -90, 86, 81],\n [\"sleeve_near\", \"top/12_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/12_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/13\": [\n [\"top\", \"top/13\", 28.4, -2.4, -90, 88, 82],\n [\"sleeve_near\", \"top/13_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/13_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/14\": [[\"top\", \"top/14\", 19.9, -2.9, -90, 87, 81]],\n \"top/15\": [\n [\"top\", \"top/15\", 25.4, -2.4, -90, 90, 88],\n [\"sleeve_near\", \"top/15_sleeve_near\", 22.32, 3.45, 91.4, 31, 64, BEND],\n [\"sleeve_far\", \"top/15_sleeve_far\", 20.75, 1.92, 101.9, 32, 63, BEND],\n ],\n \"top/16\": [\n [\"top\", \"top/16\", 26.4, -3.4, -90, 86, 84],\n [\"sleeve_near\", \"top/16_sleeve_near\", 19.82, 3.38, 91.4, 31, 59, BEND],\n [\"sleeve_far\", \"top/16_sleeve_far\", 19, 0.53, 101.9, 34, 59, BEND],\n ],\n \"top/17\": [\n [\"top\", \"top/17\", 26.9, -2.9, -90, 87, 83],\n [\"sleeve_near\", \"top/17_sleeve_near\", 8.81, 3.62, 91.4, 34, 37],\n [\"sleeve_far\", \"top/17_sleeve_far\", 7.64, -1.35, 101.9, 33, 36],\n ],\n \"top/18\": [\n [\"top\", \"top/18\", 22.4, -2.9, -90, 89, 74],\n [\"sleeve_near\", \"top/18_sleeve_near\", 7.25, 6.08, 91.4, 37, 34],\n [\"sleeve_far\", \"top/18_sleeve_far\", 7.26, -1.95, 101.9, 34, 35],\n ],\n \"top/19\": [\n [\"top\", \"top/19\", 14.4, -3.9, -90, 89, 92],\n [\"sleeve_near\", \"top/19_sleeve_near\", 7.25, 6.08, 91.4, 37, 34],\n [\"sleeve_far\", \"top/19_sleeve_far\", 7.26, -1.95, 101.9, 34, 35],\n ],\n \"top/20\": [\n [\"top\", \"top/20\", 24.9, -2.9, -90, 87, 71],\n [\"sleeve_near\", \"top/20_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/20_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/21\": [\n [\"top\", \"top/21\", 24.9, -2.9, -90, 87, 71],\n [\"sleeve_near\", \"top/21_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/21_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/22\": [\n [\"top\", \"top/22\", 19.9, -2.9, -90, 87, 81],\n [\"sleeve_near\", \"top/22_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/22_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/23\": [\n [\"top\", \"top/23\", 19.9, -2.9, -90, 87, 81],\n [\"sleeve_near\", \"top/23_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/23_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/24\": [\n [\"top\", \"top/24\", 19.9, -2.9, -90, 87, 81],\n [\"sleeve_near\", \"top/24_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/24_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/25\": [\n [\"top\", \"top/25\", 24.4, -2.4, -90, 88, 72],\n [\"sleeve_near\", \"top/25_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/25_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/26\": [[\"top\", \"top/26\", 23.9, -3.4, -90, 92, 93]],\n \"top/27\": [\n [\"top\", \"top/27\", 29.4, -2.9, -90, 87, 82],\n [\"sleeve_near\", \"top/27_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/27_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/28\": [[\"top\", \"top/28\", 23.4, -2.4, -90, 86, 74]],\n \"top/29\": [\n [\"top\", \"top/29\", 18.9, 1.1, -90, 63, 65],\n [\n \"coat\",\n \"top/29_coat\",\n 16.35,\n -3.58,\n -90,\n 88,\n 106,\n BEND,\n { sx: 0.995, sy: 1.002 },\n ],\n [\"sleeve_near\", \"top/29_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/29_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/30\": [\n [\"top\", \"top/30\", 28.4, -2.9, -90, 87, 80],\n [\"sleeve_near\", \"top/30_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/30_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/31\": [\n [\"top\", \"top/31\", 25.4, -2.4, -90, 90, 88],\n [\"sleeve_near\", \"top/31_sleeve_near\", 22.32, 3.45, 91.4, 31, 64, BEND],\n [\"sleeve_far\", \"top/31_sleeve_far\", 20.75, 1.92, 101.9, 32, 63, BEND],\n ],\n \"top/32\": [\n [\"top\", \"top/32\", 28.4, -2.4, -90, 88, 82],\n [\"sleeve_near\", \"top/32_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/32_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/33\": [\n [\"top\", \"top/33\", 27.9, -2.9, -90, 87, 81],\n [\"sleeve_near\", \"top/33_sleeve_near\", 7.25, 6.08, 91.4, 37, 34],\n [\"sleeve_far\", \"top/33_sleeve_far\", 7.26, -1.95, 101.9, 34, 35],\n ],\n \"top/34\": [\n [\"top\", \"top/34\", 27.9, -2.9, -90, 87, 81],\n [\"sleeve_near\", \"top/34_sleeve_near\", 7.25, 6.08, 91.4, 37, 34],\n [\"sleeve_far\", \"top/34_sleeve_far\", 7.26, -1.95, 101.9, 34, 35],\n ],\n \"top/35\": [[\"top\", \"top/35\", 19.9, -2.9, -90, 87, 81]],\n \"top/36\": [\n [\"top\", \"top/36\", 24.9, -2.9, -90, 87, 71],\n [\"sleeve_near\", \"top/36_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/36_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/37\": [\n [\"top\", \"top/37\", 19.9, -2.9, -90, 87, 81],\n [\"sleeve_near\", \"top/37_sleeve_near\", 9.85, 2.14, 91.4, 29, 37],\n [\"sleeve_far\", \"top/37_sleeve_far\", 8.42, -0.17, 101.9, 29, 38],\n ],\n \"top/38\": [\n [\"top\", \"top/38\", 29.4, -2.9, -90, 87, 82],\n [\"sleeve_near\", \"top/38_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/38_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/39\": [\n [\"top\", \"top/39\", 26.9, -2.9, -90, 87, 83],\n [\"sleeve_near\", \"top/39_sleeve_near\", 8.81, 3.62, 91.4, 34, 37],\n [\"sleeve_far\", \"top/39_sleeve_far\", 7.64, -1.35, 101.9, 33, 36],\n ],\n \"top/40\": [\n [\"top\", \"top/40\", 25.4, -2.4, -90, 90, 88],\n [\"sleeve_near\", \"top/40_sleeve_near\", 22.32, 3.45, 91.4, 31, 64, BEND],\n [\"sleeve_far\", \"top/40_sleeve_far\", 20.75, 1.92, 101.9, 32, 63, BEND],\n ],\n \"top/41\": [\n [\"top\", \"top/41\", 25.9, -3.9, -90, 91, 87],\n [\"sleeve_near\", \"top/41_sleeve_near\", 22.32, 3.45, 91.4, 31, 64, BEND],\n [\"sleeve_far\", \"top/41_sleeve_far\", 20.75, 1.92, 101.9, 32, 63, BEND],\n ],\n \"top/42\": [\n [\"top\", \"top/42\", 19.9, -2.9, -90, 87, 81],\n [\"sleeve_near\", \"top/42_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/42_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/43\": [\n [\"top\", \"top/43\", 28.9, -2.9, -90, 87, 79],\n [\"sleeve_near\", \"top/43_sleeve_near\", 9.85, 2.14, 91.4, 29, 37],\n [\"sleeve_far\", \"top/43_sleeve_far\", 8.42, -0.17, 101.9, 29, 38],\n ],\n \"top/44\": [\n [\"top\", \"top/44\", 24.4, -2.9, -90, 87, 72],\n [\"sleeve_near\", \"top/44_sleeve_near\", 22.32, 3.45, 91.4, 31, 64, BEND],\n [\"sleeve_far\", \"top/44_sleeve_far\", 20.75, 1.92, 101.9, 32, 63, BEND],\n ],\n \"top/45\": [\n [\"top\", \"top/45\", 24.4, -2.9, -90, 87, 88],\n [\"sleeve_near\", \"top/45_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/45_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/46\": [\n [\"top\", \"top/46\", 24.4, -2.9, -90, 87, 72],\n [\"sleeve_near\", \"top/46_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"cap_near\", \"top/46_cap_near\", 6.28, 5.05, 91.4, 37, 34],\n [\"sleeve_far\", \"top/46_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n [\"cap_far\", \"top/46_cap_far\", 6.77, -2.05, 101.9, 34, 35],\n ],\n \"top/47\": [\n [\"top\", \"top/47\", 27.4, -2.4, -90, 88, 82],\n [\"sleeve_near\", \"top/47_sleeve_near\", 9.85, 2.14, 91.4, 29, 37],\n [\"sleeve_far\", \"top/47_sleeve_far\", 8.42, -0.17, 101.9, 29, 38],\n ],\n \"top/48\": [\n [\"top\", \"top/48\", 24.9, -2.9, -90, 87, 71],\n [\"sleeve_near\", \"top/48_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/48_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/49\": [\n [\"top\", \"top/49\", 20.9, -3.4, -90, 86, 81],\n [\"sleeve_near\", \"top/49_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/49_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/50\": [\n [\"top\", \"top/50\", 22.4, -2.9, -90, 89, 74],\n [\"sleeve_near\", \"top/50_sleeve_near\", 7.25, 6.08, 91.4, 37, 34],\n [\"sleeve_far\", \"top/50_sleeve_far\", 7.26, -1.95, 101.9, 34, 35],\n ],\n \"top/51\": [\n [\"top\", \"top/51\", 24.4, -2.9, -90, 87, 72],\n [\"sleeve_near\", \"top/51_sleeve_near\", 22.32, 3.45, 91.4, 31, 64, BEND],\n [\"sleeve_far\", \"top/51_sleeve_far\", 20.75, 1.92, 101.9, 32, 63, BEND],\n ],\n \"top/52\": [\n [\"top\", \"top/52\", 24.4, -2.9, -90, 87, 72],\n [\"sleeve_near\", \"top/52_sleeve_near\", 9.85, 2.14, 91.4, 29, 37],\n [\"sleeve_far\", \"top/52_sleeve_far\", 8.42, -0.17, 101.9, 29, 38],\n ],\n \"top/53\": [\n [\"top\", \"top/53\", 24.4, -2.9, -90, 87, 72],\n [\"sleeve_near\", \"top/53_sleeve_near\", 9.85, 2.14, 91.4, 29, 37],\n [\"sleeve_far\", \"top/53_sleeve_far\", 8.42, -0.17, 101.9, 29, 38],\n ],\n \"top/54\": [\n [\"top\", \"top/54\", 14.4, -3.9, -90, 89, 92],\n [\"sleeve_near\", \"top/54_sleeve_near\", 7.25, 6.08, 91.4, 37, 34],\n [\"sleeve_far\", \"top/54_sleeve_far\", 7.26, -1.95, 101.9, 34, 35],\n ],\n \"top/55\": [[\"top\", \"top/55\", 19.9, -2.9, -90, 87, 81]],\n \"top/56\": [\n [\"top\", \"top/56\", 26.4, -3.4, -90, 86, 84],\n [\"sleeve_near\", \"top/56_sleeve_near\", 19.82, 3.38, 91.4, 31, 59, BEND],\n [\"sleeve_far\", \"top/56_sleeve_far\", 19, 0.53, 101.9, 34, 59, BEND],\n ],\n \"top/57\": [\n [\"top\", \"top/57\", 28.4, -2.9, -90, 87, 80],\n [\"sleeve_near\", \"top/57_sleeve_near\", 19.36, 1.87, 91.4, 28, 58, BEND],\n [\"sleeve_far\", \"top/57_sleeve_far\", 18.79, 1.51, 101.9, 32, 59, BEND],\n ],\n \"top/58\": [[\"top\", \"top/58\", 23.9, -3.4, -90, 92, 93]],\n \"top/59\": [\n [\"top\", \"top/59\", 25.4, -2.4, -90, 90, 88],\n [\"sleeve_near\", \"top/59_sleeve_near\", 22.32, 3.45, 91.4, 31, 64, BEND],\n [\"sleeve_far\", \"top/59_sleeve_far\", 20.75, 1.92, 101.9, 32, 63, BEND],\n ],\n \"top/60\": [[\"top\", \"top/60\", 28.4, -2.9, -90, 87, 80]],\n \"weapon/knight_sword\": [\n [\"weapon\", \"weapon/knight_sword\", 5.7, -45.7, 90, 156, 51],\n ],\n \"weapon/dark_blade\": [\n [\"weapon\", \"weapon/dark_blade\", 5.2, -48.7, 90, 160, 50],\n ],\n \"weapon/broadsword\": [\n [\"weapon\", \"weapon/broadsword\", 4.7, -49.7, 90, 160, 65],\n ],\n \"weapon/flame_sword\": [\n [\"weapon\", \"weapon/flame_sword\", 6.7, -57.7, 90, 174, 47],\n ],\n \"weapon/dagger\": [[\"weapon\", \"weapon/dagger\", 8.7, -32.7, 90, 112, 29]],\n \"weapon/battle_axe\": [\n [\"weapon\", \"weapon/battle_axe\", 24.2, -25.2, 90, 115, 60],\n ],\n \"weapon/double_axe\": [\n [\"weapon\", \"weapon/double_axe\", 4.2, -22.7, 90, 112, 92],\n ],\n \"weapon/mace\": [[\"weapon\", \"weapon/mace\", 4.7, -47.7, 90, 144, 71]],\n \"weapon/warhammer\": [[\"weapon\", \"weapon/warhammer\", 5.7, -24.2, 90, 103, 65]],\n \"weapon/spear\": [[\"weapon\", \"weapon/spear\", 6.2, -43.2, 90, 147, 40]],\n \"weapon/wizard_staff\": [\n [\"weapon\", \"weapon/wizard_staff\", 7.7, -44.2, 90, 135, 43],\n ],\n \"weapon/druid_staff\": [\n [\"weapon\", \"weapon/druid_staff\", 5.7, -36.7, 90, 144, 33],\n ],\n \"offhand/kite_shield\": [\n [\"offhand\", \"offhand/kite_shield\", 6.56, 7.12, 1.05, 85, 82],\n ],\n \"offhand/heater_shield\": [\n [\"offhand\", \"offhand/heater_shield\", 5.07, 6.59, 1.05, 96, 81],\n ],\n \"offhand/round_shield\": [\n [\"offhand\", \"offhand/round_shield\", 0.58, 6.51, 1.05, 89, 89],\n ],\n \"offhand/tower_shield\": [\n [\"offhand\", \"offhand/tower_shield\", -1.37, 3.48, 1.05, 109, 87],\n ],\n \"offhand/buckler\": [[\"offhand\", \"offhand/buckler\", 1.57, 6.53, 1.05, 73, 73]],\n \"hat/great_helm\": [[\"hat\", \"hat/great_helm\", 43.5, -0.94, -90.18, 118, 120]],\n \"hat/knight_helm\": [\n [\"hat_low\", \"hat/knight_helm_low\", 47.99, -3.95, -90.18, 114, 115],\n ],\n \"hat/winged_helm\": [\n [\"hat_low\", \"hat/winged_helm_low\", 71.99, -2.53, -90.18, 133, 83],\n ],\n \"hat/wizard_hat\": [[\"hat\", \"hat/wizard_hat\", 69.99, -2.02, -90.18, 154, 75]],\n \"hat/ranger_hood\": [\n [\"hat_low\", \"hat/ranger_hood_low\", 47.99, -3.95, -90.18, 114, 115],\n ],\n \"hat/iron_helm\": [[\"hat\", \"hat/iron_helm\", 72.5, 0.97, -90.18, 130, 82]],\n \"top/plate_armor\": [\n [\"top\", \"top/plate_armor\", 24.4, -2.9, -90, 87, 88],\n [\"sleeve_near\", \"top/plate_armor_sleeve_near\", 7.25, 6.08, 91.4, 37, 34],\n [\"sleeve_far\", \"top/plate_armor_sleeve_far\", 7.26, -1.95, 101.9, 34, 35],\n ],\n \"top/chainmail\": [\n [\"top\", \"top/chainmail\", 26.4, -3.9, -90, 91, 88],\n [\"sleeve_near\", \"top/chainmail_sleeve_near\", 7.25, 6.08, 91.4, 37, 34],\n [\"sleeve_far\", \"top/chainmail_sleeve_far\", 7.26, -1.95, 101.9, 34, 35],\n ],\n \"top/leather_armor\": [\n [\"top\", \"top/leather_armor\", 14.4, -3.9, -90, 89, 92],\n [\"sleeve_near\", \"top/leather_armor_sleeve_near\", 7.25, 6.08, 91.4, 37, 34],\n [\"sleeve_far\", \"top/leather_armor_sleeve_far\", 7.26, -1.95, 101.9, 34, 35],\n ],\n \"top/mage_robe\": [\n [\"top\", \"top/mage_robe\", 22.4, -2.9, -90, 89, 74],\n [\"sleeve_near\", \"top/mage_robe_sleeve_near\", 7.25, 6.08, 91.4, 37, 34],\n [\"sleeve_far\", \"top/mage_robe_sleeve_far\", 7.26, -1.95, 101.9, 34, 35],\n ],\n \"gloves/gauntlets\": [\n [\"glove_near\", \"gloves/gauntlets_near\", 27.37, 3.34, 92.8, 34, 35],\n [\"glove_far\", \"gloves/gauntlets_far\", 25.95, 2.76, 100.9, 32, 35],\n ],\n \"gloves/bracers\": [\n [\"glove_near\", \"gloves/bracers_near\", 19.88, 2.97, 92.8, 36, 40],\n [\"glove_far\", \"gloves/bracers_far\", 19.09, 3.98, 100.9, 35, 40],\n ],\n \"boots/plate_boots\": [\n [\"boot_near\", \"boots/plate_boots_near\", 6.3, 0.36, 180, 30, 23],\n [\"boot_far\", \"boots/plate_boots_far\", 5.9, 0.36, 180, 30, 23],\n ],\n \"boots/leather_boots\": [\n [\"boot_near\", \"boots/leather_boots_near\", 5.3, -2.4, 180, 26, 30, BEND],\n [\"boot_far\", \"boots/leather_boots_far\", 5.56, -2.4, 180, 26, 30, BEND],\n ],\n \"back/knight_cape\": [[\"cape\", \"back/knight_cape\", 17.3, -15.6, -90, 95, 124]],\n \"back/mage_cape\": [[\"cape\", \"back/mage_cape\", 17.3, -15.6, -90, 95, 124]],\n};\n"
316
+ },
317
+ {
318
+ "path": "character/rigs/hero/wearing.ts",
319
+ "content": "import { limbMesh, skirtMesh, type SetupBone } from \"../../core/limbMesh\";\nimport { BONES, LIMBS, SKIRTS, SLOTS } from \"./skeleton\";\n\n// How a part's pictures are put on the skeleton: a picture is either a flat region on its slot's bone,\n// or (BEND) a mesh made for it from the bones of its limb (core/limbMesh). The parts themselves are\n// listed in wear.ts.\n\n/** A picture that bends with its limb (a mesh), not a flat one. */\nexport const BEND = \"bend\";\n\n/** [slot, image, x, y, rotation, width, height, BEND?, { sx, sy }?] — on the slot bone, standing. */\nexport type Piece = readonly [\n slot: string,\n image: string,\n x: number,\n y: number,\n rotation: number,\n width: number,\n height: number,\n ...extra: Array<typeof BEND | { sx: number; sy: number }>,\n];\n\nconst SLOT_BONE = new Map(SLOTS.map(([slot, bone]) => [slot, bone]));\nconst BONE = new Map<string, SetupBone>(\n BONES.map(([name, , x, y, rotation, length], index) => [\n name,\n { index, x, y, rotation, length: length ?? 0 },\n ]),\n);\n\nconst bone = (name: string): SetupBone => {\n const b = BONE.get(name);\n if (!b) throw new Error(`hero: no bone ${name}`);\n return b;\n};\n\n/* eslint-disable @typescript-eslint/no-explicit-any -- the export format's skins */\n\n/** One part in the export format: per slot, the attachment named like the slot. */\nexport function skinJson(name: string, pieces: readonly Piece[]): any {\n const attachments: Record<string, Record<string, any>> = {};\n for (const [slot, image, x, y, rotation, width, height, ...extra] of pieces) {\n const boneName = SLOT_BONE.get(slot);\n if (!boneName) throw new Error(`hero ${name}: no slot ${slot}`);\n const scale = extra.find((e) => typeof e === \"object\") as\n { sx: number; sy: number } | undefined;\n const sx = scale?.sx ?? 1;\n const sy = scale?.sy ?? 1;\n let a: any;\n if (extra.includes(BEND)) {\n // the mesh is laid out in the world, standing: the slot bone carries the piece's place there\n const b = bone(boneName);\n const r = (b.rotation * Math.PI) / 180;\n const at = {\n x: b.x + x * Math.cos(r) - y * Math.sin(r),\n y: b.y + x * Math.sin(r) + y * Math.cos(r),\n rotation: b.rotation + rotation,\n width: width * sx,\n height: height * sy,\n canvasWidth: width,\n canvasHeight: height,\n };\n const skirt = SKIRTS[slot];\n const limb = LIMBS[slot];\n if (skirt)\n a = skirtMesh(\n image,\n at,\n bone(skirt.body),\n bone(skirt.near),\n bone(skirt.far),\n );\n else if (limb) {\n // a foot at the chain's end takes what is below the ankle\n const last = limb[limb.length - 1]!;\n const foot = last.startsWith(\"foot_\") ? bone(last) : undefined;\n a = limbMesh(\n image,\n at,\n (foot ? limb.slice(0, -1) : limb).map(bone),\n foot,\n );\n } else throw new Error(`hero ${name}: ${slot} does not bend`);\n } else {\n a = {\n path: image,\n x,\n y,\n rotation,\n width,\n height,\n ...(sx !== 1 ? { scaleX: sx } : {}),\n ...(sy !== 1 ? { scaleY: sy } : {}),\n };\n }\n (attachments[slot] ??= {})[slot] = a;\n }\n return { name, attachments };\n}\n/* eslint-enable @typescript-eslint/no-explicit-any */\n"
320
+ },
321
+ {
322
+ "path": "controller-box.ts",
323
+ "content": "// A one-slot observable that bridges the imperative scene and the React panel. The EngineScene\n// creates the Phaser controller at mount time (it needs the canvas host), which is AFTER the panel\n// first renders; the panel subscribes here and re-renders once the controller is published. This is\n// what lets one controller instance be shared by the scene and the UI without prop-drilling across\n// the imperative/React boundary.\n\nexport interface ControllerBox<T> {\n get(): T | null;\n set(value: T | null): void;\n subscribe(listener: () => void): () => void;\n}\n\nexport function createControllerBox<T>(): ControllerBox<T> {\n let value: T | null = null;\n const listeners = new Set<() => void>();\n return {\n get: () => value,\n set(next) {\n value = next;\n for (const listener of [...listeners]) listener();\n },\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n };\n}\n"
324
+ },
325
+ {
326
+ "path": "events.ts",
327
+ "content": "import { defineTopic, shape } from \"@idosgames/module-sdk\";\n\n// Topics this module EMITS. Declared in module.meta.json (`events.emits`) with the same descriptor,\n// so other modules can copy it from the catalog — they never import this file.\n\n/** A hero's level went up (after a successful `client.character.upgradeCharacterLevel`). */\nexport const characterUpgraded = defineTopic(\n \"hero-run:character-upgraded@1\",\n shape({ characterId: \"string\", level: \"number\" }),\n);\n\n/** The hero beat a level's boss: `island`-`level` is the level just cleared (\"2-4\"). */\nexport const levelCleared = defineTopic(\n \"hero-run:level-cleared@1\",\n shape({ island: \"number\", level: \"number\" }),\n);\n\n// Topics this module LISTENS to — its own copy of the emitter's descriptor (from the catalog), with\n// only the fields it reads. Optional: without the Heroes system the strongest hero fights.\n\n/** The Heroes screen's selected hero (`modules/character`). */\nexport const heroSelected = defineTopic(\n \"character:hero-selected@1\",\n shape({ characterId: \"string\" }),\n);\n"
328
+ },
329
+ {
330
+ "path": "game/battle.ts",
331
+ "content": "// One RUN of a level — a deterministic simulation with no Phaser in it, so it is tested on its own\n// (game.test.ts) and drawn by the scene from the events each tick returns.\n//\n// The road (Obsidian Knight's march), in lane widths: the hero runs right along it and the world\n// scrolls past — on screen the hero stays at HERO_X. The level's segments come one after another: a\n// wave of troops stands on the road ahead and waits (brawlers one at a time, face to face; shooters\n// fire once the hero is in their range), a camp, a fountain or a chest stands there to be reached,\n// and the boss closes the road. Kills fill the RAGE; each threshold is a run level and a choice of\n// one of three cards (cards.ts) — the run stops until the player takes one. Death ends the run.\n//\n// The cards are triggers: the battle FIRES them — on a hit, a crit, a kill, a dodge, a counter, a blow\n// taken, a heal, the ultimate, the camp, every N-th attack, a new run level — and their effects throw\n// Lightning, Axes, Spears and Arrows, stun, heal, repair the shield, give charges.\n//\n// Damage is Warcraft's (damage.ts): the hero's weapon has an attack type, every troop an armor type\n// and an armor value, and so does the hero. A shield is a pool on top of the health that takes the\n// blows first (the blue bar) — the hero's when it carries one, a troop's when it does.\n\nimport {\n armorFactor,\n armorReduction,\n dealt,\n effectOf,\n typeFactor,\n} from \"./damage\";\nimport type { ArmorType, AttackType, Effect as TypeEffect } from \"./damage\";\nimport {\n BOLT_TYPE,\n drawCards,\n modsOf,\n noMods,\n procsOf,\n type Bolt,\n type Effect,\n type Proc,\n type RunMods,\n type Trigger,\n} from \"./cards\";\nimport type { FighterStats } from \"./heroStats\";\nimport type { LevelDef, Segment } from \"./campaign\";\nimport { seededRandom } from \"./random\";\nimport {\n TROOPS,\n TROOP_RANGE,\n troopArmor,\n troopAttack,\n troopRanged,\n troopShielded,\n type TroopDef,\n type TroopId,\n} from \"./units\";\n\n/** Where the hero stands on screen (share of the lane); the road scrolls under it. */\nexport const HERO_X = 0.2;\n/** The hero's run, lane widths per second — the most; the scene lowers it to what the hero's legs\n * carry at its size (`setWalkSpeed`), so the feet never skate. */\nexport const WALK_SPEED = 0.2;\n/** A melee blow lands from this far: the hero stops here before a troop, face to face. */\nexport const MELEE_REACH = 0.13;\n/** The boss is drawn this much bigger (the scene) — face to face with it is that much farther. */\nexport const BOSS_SIZE = 1.4;\n/** A ranged weapon (a bow, a gun, a staff) shoots from this far. */\nexport const RANGED_REACH = 0.55;\n/** The first troop of a new wave stands this far ahead (just past the screen edge). */\nexport const WAVE_AHEAD = 0.85;\n/** The gap between troops waiting in line. */\nexport const MONSTER_GAP = 0.14;\nexport const PROJECTILE_SPEED = 1.9;\nexport const ENEMY_SHOT_SPEED = 1.1;\n\n/** A plain troop's health and attack at level 1 (× its troop's share × the level's power). */\nexport const BASE_HP = 26;\nexport const BASE_ATTACK = 3;\n/** A shield's pool: this share of its bearer's max health. */\nexport const SHIELD_SHARE = 0.5;\n\n/** Total rage for each run level (Obsidian Knight's table); past the last it grows on. */\nexport const RAGE_THRESHOLDS = [\n 3, 7, 14, 22, 32, 44, 58, 74, 92, 112, 134, 158,\n];\nexport const RAGE_PER_KILL = 1;\nexport const RAGE_PER_BOSS = 5;\nexport const FOUNTAIN_RAGE = 5;\nexport const FOUNTAIN_HEAL = 0.3;\nexport const CAMP_RAGE = 5;\nexport const CAMP_HEAL = 0.4;\n\nexport const POTION_HEAL = 0.35;\n/** With auto on, a potion goes down below this share of health. */\nexport const AUTO_POTION_BELOW = 0.35;\n\n/** New three cards for the same choice, a run (\"Refresh\"). */\nexport const REROLLS_PER_RUN = 1;\n\nexport type UltId = \"meteor\" | \"frenzy\";\n/**\n * The ultimate: one slot, picked before the run, ready from its start and used ONCE a run (owner's\n * rule 23.10.2026) — only a card brings it back, by a chance at a kill or a new run level.\n */\nexport const ULTS: Readonly<Record<UltId, { duration: number }>> = {\n meteor: { duration: 0 },\n frenzy: { duration: 6 },\n};\n/** With auto on, the one ultimate waits for a moment worth it: the boss, a crowd on screen, or trouble. */\nexport const AUTO_ULT_CROWD = 4;\nexport const AUTO_ULT_BELOW = 0.4;\n/** The meteor hits every troop on screen for this many hero blows (spells — through any armor). */\nexport const METEOR_POWER = 4;\nexport const FRENZY_SPEED = 2;\n\n/** A lifesteal heals this share of the blow that triggered it. */\nexport const LIFESTEAL_SHARE = 0.3;\n/** A \"Shield break\" stack: + this share of damage on its target. */\nexport const BREAK_BONUS = 0.08;\n/** At most this many \"Shield break\" stacks on one troop. */\nexport const BREAK_MAX = 4;\n/** Below this share of health the low-health cards hold. */\nexport const LOW_HP = 0.3;\n/** How deep effects may set off effects (a kill's arrow killing, its heal striking…). */\nconst MAX_PROC_DEPTH = 4;\n\nexport type Reach = \"melee\" | \"ranged\";\n\n/**\n * A line of the hero's sheet in a run (Obsidian Knight's eight): its own number (gear, rank) and the\n * number now, with the cards. Defense is the share the armor value takes off a blow; the counter and\n * the lifesteal are chances. There is no health regen in the game — health comes back from potions,\n * the camp, fountains and the cards.\n */\nexport type HeroNumber =\n | \"damage\"\n | \"attackSpeed\"\n | \"critChance\"\n | \"critMultiplier\"\n | \"defense\"\n | \"counter\"\n | \"dodge\"\n | \"lifesteal\";\n\n/** In the sheet's order: the left column, then the right. */\nexport const HERO_NUMBERS: readonly HeroNumber[] = [\n \"damage\",\n \"attackSpeed\",\n \"critChance\",\n \"critMultiplier\",\n \"defense\",\n \"counter\",\n \"dodge\",\n \"lifesteal\",\n];\n\nexport interface Monster {\n id: number;\n troop: TroopDef;\n boss: boolean;\n hp: number;\n maxHp: number;\n /** Its shield's pool (0 — none): blows take it first. */\n shield: number;\n maxShield: number;\n attack: number;\n attackType: AttackType;\n armorType: ArmorType;\n /** Its armor points; `broken` of them are knocked off (past zero with the shred cards). */\n armor: number;\n broken: number;\n /** \"Shield break\" stacks on it: + BREAK_BONUS damage each. */\n breaks: number;\n /** Seconds between its blows; how fast a brawler closes the last step (× the hero's run). */\n interval: number;\n speed: number;\n /** Lane widths it shoots from; 0 — it fights face to face. */\n range: number;\n stunned: number;\n /** Where it stands on the road (lane widths, the same axis as `Battle.heroX`). */\n x: number;\n cooldown: number;\n}\n\n/** The hero's shot (a ranged weapon only — a melee blow lands at once). */\nexport interface Projectile {\n id: number;\n x: number;\n targetId: number;\n damage: number;\n crit: boolean;\n extra: boolean;\n}\n\n/** A troop's shot flying at the hero. */\nexport interface EnemyShot {\n id: number;\n x: number;\n damage: number;\n attackType: AttackType;\n}\n\n/** A camp, a fountain or a chest standing on the road. */\nexport interface RoadFeature {\n id: number;\n kind: \"camp\" | \"fountain\" | \"chest\";\n gives?: \"heal\" | \"rage\";\n x: number;\n}\n\nexport type Pending =\n /** Choice `index` of `total` in a row of run levels (\"Upgrade: 1/5\"). */\n | { kind: \"cards\"; options: string[]; index: number; total: number }\n | { kind: \"camp\" };\n\nexport type CampChoice = \"heal\" | \"rage\" | \"both\";\n\nexport type Charge = \"focus\" | \"divine\" | \"lightning\";\n\nexport interface RunResult {\n cleared: boolean;\n /** The level played (1-based across islands). */\n level: number;\n kills: number;\n runLevel: number;\n chests: number;\n seconds: number;\n cards: string[];\n /** The player's own potions drunk — they leave the wallet. A card's potions go first and cost nothing. */\n potionsUsed: number;\n}\n\nexport type BattleEvent =\n | { type: \"spawn\"; monster: Monster }\n | { type: \"feature\"; feature: RoadFeature }\n | { type: \"reached\"; feature: RoadFeature }\n | { type: \"shoot\"; projectile: Projectile }\n | { type: \"strike\"; monsterId: number }\n | { type: \"enemyShoot\"; shot: EnemyShot; monsterId: number }\n | { type: \"enemyStrike\"; monsterId: number }\n | {\n type: \"hit\";\n monsterId: number;\n x: number;\n damage: number;\n crit: boolean;\n melee: boolean;\n /** How the hero's attack type fares against its armor (the number's colour). */\n effect: TypeEffect;\n }\n | { type: \"kill\"; monsterId: number; x: number; boss: boolean }\n /** A card's missile: from the hero to these troops (the scene draws it). */\n | { type: \"bolt\"; bolt: Bolt; targets: number[] }\n | { type: \"blast\"; monsterId: number; x: number }\n | { type: \"stun\"; monsterId: number }\n | { type: \"heroHit\"; damage: number; shield: number; healed: boolean }\n /** A blow that did not land: the divine shield took it, or the armor shrugged it off. */\n | { type: \"guard\" }\n | { type: \"dodge\" }\n | { type: \"counter\"; monsterId: number }\n | { type: \"charge\"; charge: Charge; count: number }\n | { type: \"heal\"; amount: number }\n | { type: \"heroDown\" }\n | { type: \"rage\"; amount: number }\n | { type: \"levelUp\"; level: number }\n | { type: \"card\"; id: string }\n | { type: \"potion\" }\n | { type: \"ult\"; ult: UltId }\n | { type: \"ultBack\" }\n | { type: \"bossStart\" }\n | { type: \"end\"; result: RunResult };\n\nexport interface BattleSnapshot {\n level: number;\n island: number;\n /** Segment now (0-based; === segments → the boss), of how many. */\n segment: number;\n segments: number;\n heroHp: number;\n heroMaxHp: number;\n heroShield: number;\n heroMaxShield: number;\n heroDown: boolean;\n walking: boolean;\n rage: number;\n /** Rage the current run level started at, and the next one needs. */\n rageFrom: number;\n rageTo: number;\n runLevel: number;\n pending: Pending | null;\n rerollsLeft: number;\n potions: number;\n ult: UltId;\n /** The ultimate is there to use (once a run; a card may bring it back). */\n ultReady: boolean;\n ultActiveLeft: number;\n charges: Record<Charge, number>;\n autoPlay: boolean;\n kills: number;\n chests: number;\n cards: string[];\n result: RunResult | null;\n monsters: number;\n bossHp: number;\n bossMaxHp: number;\n}\n\n/** Total rage a run level needs (level 0 → the first threshold). */\nexport function rageFor(level: number): number {\n const n = Math.max(0, Math.floor(level));\n if (n < RAGE_THRESHOLDS.length) return RAGE_THRESHOLDS[n]!;\n const last = RAGE_THRESHOLDS.length - 1;\n let total = RAGE_THRESHOLDS[last]!;\n let step = RAGE_THRESHOLDS[last]! - RAGE_THRESHOLDS[last - 1]!;\n for (let i = last; i < n; i++) {\n step += 2;\n total += step;\n }\n return total;\n}\n\nconst IDLE_STATS: FighterStats = {\n maxHp: 100,\n damage: 10,\n armor: 0,\n attackSpeed: 1,\n critChance: 0,\n critMultiplier: 1.5,\n dodge: 0,\n regen: 0,\n multiShot: 0,\n};\n\nexport interface RunOptions {\n level: LevelDef;\n stats?: FighterStats;\n /** What the hero's weapon deals and what its body wears (the session reads them off the gear). */\n heroAttack?: AttackType;\n heroArmor?: ArmorType;\n /** Does the hero carry a shield (its blue pool). */\n shield?: boolean;\n ult?: UltId;\n potions?: number;\n seed?: number;\n}\n\n/** What a fired trigger knows: who it is about, what was over. */\ninterface FireContext {\n target?: Monster;\n /** The killing blow's damage past the health; the heal past the max. */\n overflow?: number;\n /** A \"turn the blow into healing\" fired — the blow does not land. */\n converted?: boolean;\n}\n\nexport class Battle {\n readonly level: LevelDef;\n readonly heroAttack: AttackType;\n readonly heroArmorType: ArmorType;\n readonly shielded: boolean;\n readonly ult: UltId;\n\n stats: FighterStats = IDLE_STATS;\n mods: RunMods = noMods();\n procs: Proc[] = [];\n cards: string[] = [];\n heroHp = IDLE_STATS.maxHp;\n /** The hero's shield pool — takes the blows before its health. */\n heroShield = 0;\n /** How far the hero has run (lane widths). */\n heroX = 0;\n /** The hero is on the move this step (nobody and nothing within its reach). */\n walking = false;\n walkSpeed = WALK_SPEED;\n waveAhead = WAVE_AHEAD;\n monsterGap = MONSTER_GAP;\n reach: Reach = \"melee\";\n /** Seconds from the start of the hero's attack to its hit (the scene sets it from the animation). */\n windup = 0;\n /** Seconds the attack plays on after its hit — the hero does not set off before it is over. */\n recover = 0;\n /** Standing in line, nearest first. */\n monsters: Monster[] = [];\n projectiles: Projectile[] = [];\n enemyShots: EnemyShot[] = [];\n feature: RoadFeature | null = null;\n /** The segment now (-1 — before the first; === segments.length — the boss). */\n segment = -1;\n\n rage = 0;\n runLevel = 0;\n /** What the run waits for (a card, the camp); the clock stands still meanwhile. */\n pending: Pending | null = null;\n rerollsLeft = REROLLS_PER_RUN;\n readonly rerollsTotal = REROLLS_PER_RUN;\n potions: number;\n /** Of `potions`, those a card brought for this run (not the player's): drunk first. */\n cardPotions = 0;\n potionsUsed = 0;\n ultReady = true;\n ultActiveLeft = 0;\n /** Charges the cards gave: focus, divine shield, lightning strikes. */\n charges: Record<Charge, number> = { focus: 0, divine: 0, lightning: 0 };\n /** Crit chance the kills have added this run. */\n runCrit = 0;\n /** Potions and the ultimate go by themselves. */\n autoPlay = true;\n kills = 0;\n chests = 0;\n time = 0;\n result: RunResult | null = null;\n\n private queued = 0;\n private choice = { index: 0, total: 0 };\n private fired: Partial<Record<Trigger, number>> = {};\n private depth = 0;\n private heroCooldown = 0;\n private heroRecoverLeft = 0;\n private nextId = 1;\n private readonly random: () => number;\n private events: BattleEvent[] = [];\n\n constructor(opts: RunOptions) {\n this.level = opts.level;\n this.heroAttack = opts.heroAttack ?? \"normal\";\n this.heroArmorType = opts.heroArmor ?? \"unarmored\";\n this.shielded = !!opts.shield;\n this.ult = opts.ult ?? \"meteor\";\n this.potions = Math.max(0, Math.floor(opts.potions ?? 0));\n this.random = seededRandom(opts.seed ?? Date.now());\n if (opts.stats) this.stats = opts.stats;\n this.heroHp = this.heroMaxHp();\n this.heroShield = this.heroMaxShield();\n }\n\n // ── The hero's numbers with the cards ──────────────────────────────────────────────────────\n\n heroMaxHp(): number {\n return this.stats.maxHp * (1 + this.mods.maxHp);\n }\n\n heroMaxShield(): number {\n return this.shielded ? this.heroMaxHp() * SHIELD_SHARE : 0;\n }\n\n heroArmorValue(): number {\n return this.stats.armor + this.mods.armor;\n }\n\n private critChance(): number {\n return this.stats.critChance + this.mods.critChance + this.runCrit;\n }\n\n /**\n * The hero's sheet: each number as the hero came (gear, rank) and as it fights now with the cards —\n * the screen shows the difference (\"+15\"). The conditions that hold right now count (full health,\n * low health).\n */\n heroNumbers(): Record<HeroNumber, { base: number; now: number }> {\n const s = this.stats;\n const m = this.mods;\n return {\n damage: { base: s.damage, now: this.heroDamage() },\n attackSpeed: {\n base: s.attackSpeed,\n now: this.heroSpeed() / (this.ultActiveLeft > 0 ? FRENZY_SPEED : 1),\n },\n critChance: {\n base: s.critChance,\n now: Math.min(1, this.critChance()),\n },\n critMultiplier: {\n base: s.critMultiplier,\n now: s.critMultiplier + m.critDamage,\n },\n defense: {\n base: armorReduction(s.armor),\n now: armorReduction(this.heroArmorValue()),\n },\n counter: { base: 0, now: Math.min(1, m.counter) },\n dodge: { base: s.dodge, now: Math.min(1, s.dodge + m.dodge) },\n lifesteal: { base: 0, now: Math.min(1, m.lifesteal) },\n };\n }\n\n /** New numbers of the hero (gear, another hero): health and shield keep their share. */\n setStats(stats: FighterStats): void {\n const hpShare = this.heroHp / Math.max(1, this.heroMaxHp());\n const shieldMax = this.heroMaxShield();\n const shieldShare = shieldMax > 0 ? this.heroShield / shieldMax : 1;\n this.stats = stats;\n this.heroHp = Math.max(1, hpShare * this.heroMaxHp());\n this.heroShield = shieldShare * this.heroMaxShield();\n }\n\n setWalkSpeed(speed: number): void {\n this.walkSpeed = Math.max(0.01, Math.min(WALK_SPEED, speed));\n }\n\n setMonsterGap(gap: number): void {\n this.monsterGap = Math.max(0.08, Math.min(0.3, gap));\n }\n\n setWaveAhead(ahead: number): void {\n this.waveAhead = Math.max(0.6, Math.min(2, ahead));\n }\n\n setReach(reach: Reach): void {\n this.reach = reach;\n }\n\n /** How the hero's attack is timed: the swing before its hit, the follow-through after it (s, 0…1). */\n setSwing(windup: number, recover: number): void {\n this.windup = Math.max(0, Math.min(1, windup));\n this.recover = Math.max(0, Math.min(1, recover));\n }\n\n setAutoPlay(on: boolean): void {\n this.autoPlay = on;\n }\n\n // ── The player's moves ─────────────────────────────────────────────────────────────────────\n\n /** Take one of the three cards offered. `false` — no choice is open, or it is not one of them. */\n takeCard(id: string): boolean {\n const p = this.pending;\n if (p?.kind !== \"cards\" || !p.options.includes(id)) return false;\n const maxBefore = this.heroMaxHp();\n const shieldBefore = this.heroMaxShield();\n const potionsBefore = this.mods.extraPotions;\n this.cards.push(id);\n this.setCards(this.cards);\n // a card that brings potions brings them into this run\n const brought = Math.max(0, this.mods.extraPotions - potionsBefore);\n this.potions += brought;\n this.cardPotions += brought;\n // a card that raises the max raises the health by as much\n this.heroHp += Math.max(0, this.heroMaxHp() - maxBefore);\n this.heroShield += Math.max(0, this.heroMaxShield() - shieldBefore);\n this.pending = null;\n this.emit({ type: \"card\", id });\n this.offerCards();\n return true;\n }\n\n /** Put these cards on the run (what they add and what they fire) — a test sets a build this way. */\n setCards(cards: readonly string[]): void {\n this.cards = [...cards];\n this.mods = modsOf(this.cards);\n this.procs = procsOf(this.cards);\n }\n\n /** \"Refresh\": three other cards for the same choice (REROLLS_PER_RUN a run). */\n reroll(): boolean {\n const p = this.pending;\n if (p?.kind !== \"cards\" || this.rerollsLeft <= 0) return false;\n this.rerollsLeft--;\n const options = this.draw();\n if (options.length > 0) this.pending = { ...p, options };\n return true;\n }\n\n /** The camp: health or rage — or both (the session takes the gems for it first). */\n chooseCamp(choice: CampChoice): boolean {\n if (this.pending?.kind !== \"camp\") return false;\n this.pending = null;\n this.feature = null;\n if (choice !== \"rage\") this.healHero(CAMP_HEAL * this.heroMaxHp());\n if (choice !== \"heal\") this.addRage(CAMP_RAGE, false);\n this.fire(\"camp\");\n return true;\n }\n\n drinkPotion(): boolean {\n if (this.result || this.pending || this.potions <= 0) return false;\n if (this.heroHp >= this.heroMaxHp()) return false;\n this.potions--;\n if (this.cardPotions > 0) this.cardPotions--;\n else this.potionsUsed++;\n this.emit({ type: \"potion\" });\n this.healHero(POTION_HEAL * (1 + this.mods.potionPower) * this.heroMaxHp());\n return true;\n }\n\n /** The ultimate — once a run, unless a card brought it back. */\n castUlt(): boolean {\n if (this.result || this.pending || !this.ultReady) return false;\n this.ultReady = false;\n this.emit({ type: \"ult\", ult: this.ult });\n if (this.ult === \"frenzy\") this.ultActiveLeft = ULTS.frenzy.duration;\n else {\n const damage = this.heroDamage() * METEOR_POWER;\n for (const m of [...this.monsters])\n if (this.onScreen(m)) this.strikeTyped(m, damage, [[\"spells\", 1]]);\n }\n this.fire(\"ult\");\n return true;\n }\n\n /** Leave the run (it ends as lost). */\n abandon(): void {\n if (!this.result) this.finish(false);\n }\n\n // ── Reading the road ───────────────────────────────────────────────────────────────────────\n\n screenX(x: number): number {\n return HERO_X + (x - this.heroX);\n }\n\n onScreen(m: Monster): boolean {\n return this.screenX(m.x) < 1;\n }\n\n reachDistance(): number {\n if (this.reach === \"ranged\") return RANGED_REACH;\n const m = this.front();\n return m ? this.contact(m) : MELEE_REACH;\n }\n\n contact(m: Monster): number {\n return m.boss ? MELEE_REACH * BOSS_SIZE : MELEE_REACH;\n }\n\n front(): Monster | undefined {\n return this.monsters[0];\n }\n\n /** Is this troop fighting the hero now — a shooter in range, a brawler first in line face to face. */\n engaged(m: Monster): boolean {\n if (this.result) return false;\n const distance = m.x - this.heroX;\n if (m.range > 0) return distance <= m.range + 1e-6;\n return m === this.front() && distance <= this.contact(m) + 1e-6;\n }\n\n /** A brawler first in line that steps up to the hero (within the hero's reach, not yet face to face). */\n closing(m: Monster): boolean {\n return (\n m.range === 0 &&\n m === this.front() &&\n this.hasTarget() &&\n m.x - this.heroX > this.contact(m) + 1e-6\n );\n }\n\n heroNextShotIn(): number {\n return Math.max(0, this.heroCooldown) / Math.max(0.01, this.heroSpeed());\n }\n\n hasTarget(): boolean {\n const m = this.front();\n return !!m && m.x - this.heroX <= this.reachDistance() + 1e-6;\n }\n\n get heroDown(): boolean {\n return this.heroHp <= 0;\n }\n\n /** The armor value a troop stands in now (its points less what is broken off). */\n armorOf(m: Monster): number {\n return m.armor - m.broken;\n }\n\n // ── The clock ──────────────────────────────────────────────────────────────────────────────\n\n /** Advance by `dt` seconds (clamped — a hidden tab must not resolve a minute in one step). */\n tick(dt: number): BattleEvent[] {\n // what happened between the frames (a tapped ultimate, a potion, a card) comes first\n const out = this.events;\n let left = Math.min(Math.max(0, dt), 0.25);\n while (left > 0 && !this.result && !this.pending) {\n const step = Math.min(left, 1 / 30);\n this.step(step);\n left -= step;\n }\n this.events = [];\n return out;\n }\n\n private step(dt: number): void {\n this.time += dt;\n this.ultActiveLeft = Math.max(0, this.ultActiveLeft - dt);\n for (const m of this.monsters) m.stunned = Math.max(0, m.stunned - dt);\n\n if (this.autoPlay) {\n if (this.heroHp < this.heroMaxHp() * AUTO_POTION_BELOW)\n this.drinkPotion();\n if (this.ultReady && this.ultWorthIt()) this.castUlt();\n }\n\n this.heroMove(dt);\n this.heroSwing(dt);\n this.moveProjectiles(dt);\n this.monstersAct(dt);\n this.moveEnemyShots(dt);\n if (this.result || this.pending) return;\n\n if (this.monsters.length === 0 && !this.feature) this.nextSegment();\n }\n\n /** The hero runs until the first troop is within its reach, or up to what stands on the road. */\n private heroMove(dt: number): void {\n this.heroRecoverLeft = Math.max(0, this.heroRecoverLeft - dt);\n if (this.heroRecoverLeft > 0) {\n this.walking = false;\n return;\n }\n const m = this.front();\n const f = this.feature;\n const stopAt = m ? m.x - this.reachDistance() : f ? f.x - 0.04 : Infinity;\n if (this.heroX >= stopAt - 1e-6) {\n this.walking = false;\n if (!m && f) this.arrive(f);\n return;\n }\n this.walking = true;\n this.heroX = Math.min(stopAt, this.heroX + this.walkSpeed * dt);\n }\n\n private heroSpeed(): number {\n const low =\n this.heroHp < this.heroMaxHp() * LOW_HP ? this.mods.lowHpSpeed : 0;\n return (\n this.stats.attackSpeed *\n (1 + this.mods.attackSpeed + low) *\n (this.ultActiveLeft > 0 ? FRENZY_SPEED : 1)\n );\n }\n\n /** The hero's blow before the target's armor (ATK): its damage with the cards that do not care who is hit. */\n private heroDamage(): number {\n const full =\n this.heroHp >= this.heroMaxHp() - 1e-9 ? this.mods.fullHpDamage : 0;\n return this.stats.damage * (1 + this.mods.damage + full);\n }\n\n private heroSwing(dt: number): void {\n this.heroCooldown -= dt * this.heroSpeed();\n if (!this.hasTarget()) {\n // nobody at hand: the next hit lands a swing after the hero reaches someone\n this.heroCooldown = Math.max(\n this.heroCooldown,\n this.windup * this.heroSpeed(),\n );\n return;\n }\n if (this.heroCooldown > 0) return;\n const target = this.front()!;\n this.heroCooldown += 1;\n this.heroRecoverLeft = this.recover;\n this.attackOnce(target, false);\n // \"attack again at once\"\n const twice = this.stats.multiShot + this.mods.doubleHit;\n if (twice > 0 && this.random() < twice)\n this.attackOnce(this.front() ?? target, true);\n }\n\n /** Rolls a crit, the focus charge first. */\n private rollCrit(bonus = 0): boolean {\n if (this.charges.focus > 0) {\n this.charges.focus--;\n return true;\n }\n return this.random() < this.critChance() + bonus;\n }\n\n private critDamage(): number {\n return this.stats.critMultiplier + this.mods.critDamage;\n }\n\n private attackOnce(target: Monster, extra: boolean): void {\n const crit = this.rollCrit();\n const damage = this.heroDamage() * (crit ? this.critDamage() : 1);\n if (this.reach === \"melee\") {\n const m = this.front();\n if (!m || !this.hasTarget()) return;\n this.emit({ type: \"strike\", monsterId: m.id });\n this.blow(m, damage, crit, true);\n } else {\n const p: Projectile = {\n id: this.nextId++,\n x: this.heroX + 0.04,\n targetId: target.id,\n damage,\n crit,\n extra,\n };\n this.projectiles.push(p);\n this.emit({ type: \"shoot\", projectile: p });\n }\n if (!extra) this.fire(\"attack\", { target: this.front() ?? target });\n }\n\n private moveProjectiles(dt: number): void {\n for (const p of [...this.projectiles]) {\n let target = this.monsters.find((m) => m.id === p.targetId);\n if (!target) {\n target = this.monsters.find((m) => m.x > p.x);\n if (!target) {\n this.drop(this.projectiles, p);\n continue;\n }\n p.targetId = target.id;\n }\n p.x += PROJECTILE_SPEED * dt;\n if (p.x < target.x) continue;\n this.drop(this.projectiles, p);\n this.blow(target, p.damage, p.crit, false);\n }\n }\n\n private drop<T>(list: T[], item: T): void {\n const i = list.indexOf(item);\n if (i >= 0) list.splice(i, 1);\n }\n\n /** The weapon's type plus the cards' other types (each its share of the blow). */\n private components(): Array<[AttackType, number]> {\n const out: Array<[AttackType, number]> = [[this.heroAttack, 1]];\n for (const [type, share] of Object.entries(this.mods.hybrid))\n if (share) out.push([type as AttackType, share]);\n return out;\n }\n\n /**\n * The hero's blow on a troop — its weapon's types through the table and the armor value — and what a\n * blow sets off: the lifesteal, the shred, a lightning charge, the \"on hit\" and \"on crit\" cards.\n */\n private blow(\n m: Monster,\n damage: number,\n crit: boolean,\n melee: boolean,\n ): void {\n if (!this.monsters.includes(m)) return;\n // the one standing behind the target (before the blow: the target may fall)\n const behind = this.monsters[this.monsters.indexOf(m) + 1];\n const dealtNow = this.strikeTyped(\n m,\n damage,\n this.components(),\n crit,\n melee,\n );\n if (behind && this.mods.pierceShare > 0)\n this.strikeTyped(\n behind,\n damage * this.mods.pierceShare,\n this.components(),\n crit,\n melee,\n );\n if (this.mods.lifesteal > 0 && this.random() < this.mods.lifesteal)\n this.healHero(dealtNow * LIFESTEAL_SHARE);\n const alive = this.monsters.includes(m);\n if (alive && this.mods.shred > 0)\n m.broken = Math.min(\n m.armor + this.mods.shredMax,\n m.broken + this.mods.shred,\n );\n if (alive && this.charges.lightning > 0) {\n this.charges.lightning--;\n this.boltAt(\"lightning\", 0.75, [m]);\n }\n this.fire(\"hit\", { target: m });\n if (crit) this.fire(\"crit\", { target: m });\n }\n\n /**\n * Damage of the given types on a troop (the share of each), through the table and its armor value,\n * with the cards that care who is hit. Its shield takes it first. Returns what it dealt.\n */\n private strikeTyped(\n m: Monster,\n damage: number,\n components: ReadonlyArray<[AttackType, number]>,\n crit = false,\n melee = false,\n ): number {\n if (!this.monsters.includes(m)) return 0;\n const md = this.mods;\n const armor = this.armorOf(m);\n let bonus = 1 + (md.vsArmor[m.armorType] ?? 0) + m.breaks * BREAK_BONUS;\n if (m !== this.front()) bonus += md.backRow;\n if (m.stunned > 0) bonus += md.vsStunned;\n if (md.missingHpDamage > 0)\n bonus += Math.floor((1 - m.hp / m.maxHp) * 10) * md.missingHpDamage;\n let total = 0;\n for (const [type, share] of components)\n total += dealt(damage * share, type, m.armorType, armor);\n total *= bonus;\n this.damage(m, total);\n this.emit({\n type: \"hit\",\n monsterId: m.id,\n x: m.x,\n damage: total,\n crit,\n melee,\n effect: effectOf(components[0]![0], m.armorType),\n });\n if (m.hp <= 0) this.kill(m, -m.hp);\n return total;\n }\n\n private damage(m: Monster, amount: number): void {\n const absorbed = Math.min(m.shield, amount);\n m.shield -= absorbed;\n m.hp -= amount - absorbed;\n }\n\n private kill(m: Monster, overflow: number): void {\n const i = this.monsters.indexOf(m);\n if (i < 0) return;\n this.monsters.splice(i, 1);\n this.kills++;\n this.emit({ type: \"kill\", monsterId: m.id, x: m.x, boss: m.boss });\n this.addRage(m.boss ? RAGE_PER_BOSS : RAGE_PER_KILL, true);\n if (m.boss) return this.finish(true);\n this.fire(\"kill\", { target: m, overflow });\n }\n\n // ── The cards' triggers ────────────────────────────────────────────────────────────────────\n\n /** Fire a trigger: every card on it — by its chance, or on its every N-th time. */\n private fire(trigger: Trigger, ctx: FireContext = {}): void {\n const n = (this.fired[trigger] = (this.fired[trigger] ?? 0) + 1);\n if (this.result || this.depth >= MAX_PROC_DEPTH) return;\n this.depth++;\n try {\n for (const p of this.procs) {\n if (p.trigger !== trigger) continue;\n if (p.every ? n % p.every !== 0 : this.random() >= p.chance) continue;\n this.apply(p.effect, ctx);\n if (this.result) return;\n }\n } finally {\n this.depth--;\n }\n }\n\n private apply(e: Effect, ctx: FireContext): void {\n switch (e.kind) {\n case \"bolt\":\n return this.bolt(e.bolt, e.power, e.count, e.at, ctx.target);\n case \"heal\":\n return this.healHero(e.share * this.heroMaxHp());\n case \"repair\":\n return this.repair(\n e.share > 0 ? e.share * this.heroMaxShield() : (ctx.overflow ?? 0),\n );\n case \"stun\": {\n const who = e.all\n ? this.monsters.filter((m) => this.onScreen(m))\n : ctx.target && this.monsters.includes(ctx.target)\n ? [ctx.target]\n : [];\n for (const m of who) {\n m.stunned = Math.max(m.stunned, e.seconds);\n this.emit({ type: \"stun\", monsterId: m.id });\n }\n return;\n }\n case \"charge\":\n this.charges[e.charge] += e.count;\n this.emit({ type: \"charge\", charge: e.charge, count: e.count });\n return;\n case \"break\":\n if (ctx.target && this.monsters.includes(ctx.target))\n ctx.target.breaks = Math.min(BREAK_MAX, ctx.target.breaks + 1);\n return;\n case \"again\": {\n const m = this.front();\n if (m && this.hasTarget()) this.attackOnce(m, true);\n return;\n }\n case \"cleave\": {\n const t = ctx.target;\n const i = t ? this.monsters.indexOf(t) : -1;\n const next = this.monsters[i + 1];\n if (next) this.strikeTyped(next, this.heroDamage(), this.components());\n return;\n }\n case \"gainCrit\":\n this.runCrit += e.value;\n return;\n case \"ultBack\":\n if (!this.ultReady) {\n this.ultReady = true;\n this.emit({ type: \"ultBack\" });\n }\n return;\n case \"overkillShield\":\n return this.repair(ctx.overflow ?? 0, false);\n case \"hurtToHeal\":\n ctx.converted = true;\n return;\n case \"flurry\": {\n const t = ctx.target;\n for (let k = 0; k < 30 && t && this.monsters.includes(t); k++) {\n const crit = this.rollCrit();\n this.blow(\n t,\n this.heroDamage() * (crit ? this.critDamage() : 1),\n crit,\n true,\n );\n }\n return;\n }\n case \"explode\": {\n const dead = ctx.target;\n const next = this.front();\n if (!dead || !next) return;\n this.emit({ type: \"blast\", monsterId: dead.id, x: dead.x });\n this.strikeTyped(next, dead.maxHp * e.share, [[\"spells\", 1]]);\n return;\n }\n }\n }\n\n /** A card's missiles: who they are for, then the damage (no \"on hit\" cards — those are the hero's blows). */\n private bolt(\n kind: Bolt,\n power: number,\n count: number,\n at: \"target\" | \"random\" | \"farthest\" | \"next\" | \"line\",\n target: Monster | undefined,\n ): void {\n const seen = this.monsters.filter((m) => this.onScreen(m));\n if (seen.length === 0) return;\n if (at === \"line\") return this.boltAt(kind, power, seen);\n for (let k = 0; k < count; k++) {\n const live = this.monsters.filter((m) => this.onScreen(m));\n if (live.length === 0) return;\n const who =\n at === \"random\"\n ? live[Math.floor(this.random() * live.length)]!\n : at === \"farthest\"\n ? live[live.length - 1]!\n : at === \"target\" && target && this.monsters.includes(target)\n ? target\n : live[0]!;\n this.boltAt(kind, power, [who]);\n }\n }\n\n private boltAt(kind: Bolt, power: number, targets: Monster[]): void {\n this.emit({ type: \"bolt\", bolt: kind, targets: targets.map((m) => m.id) });\n const damage = this.heroDamage() * power;\n for (const m of targets)\n this.strikeTyped(m, damage, [[BOLT_TYPE[kind], 1]]);\n }\n\n /** Health back — and the \"on heal\" / \"on overheal\" cards with what it had over. */\n private healHero(amount: number): void {\n if (amount <= 0 || this.heroHp <= 0) return;\n const before = this.heroHp;\n this.heroHp = Math.min(this.heroMaxHp(), this.heroHp + amount);\n const healed = this.heroHp - before;\n const over = amount - healed;\n if (healed > 0) {\n this.emit({ type: \"heal\", amount: healed });\n this.fire(\"heal\");\n }\n if (over > 1e-9) this.fire(\"overheal\", { overflow: over });\n }\n\n /** The shield back by `amount` (the repair cards make it bigger), never past its max. */\n private repair(amount: number, boosted = true): void {\n const max = this.heroMaxShield();\n if (max <= 0 || amount <= 0) return;\n const got = amount * (boosted ? 1 + this.mods.repairPower : 1);\n this.heroShield = Math.min(max, this.heroShield + got);\n }\n\n // ── The troops ─────────────────────────────────────────────────────────────────────────────\n\n private monstersAct(dt: number): void {\n for (const m of [...this.monsters]) {\n if (this.closing(m)) {\n m.x = Math.max(\n this.heroX + this.contact(m),\n m.x - m.speed * this.walkSpeed * dt,\n );\n continue;\n }\n if (!this.engaged(m) || m.stunned > 0) continue;\n m.cooldown -= dt;\n if (m.cooldown > 0) continue;\n m.cooldown += m.interval;\n if (m.range > 0) {\n const shot: EnemyShot = {\n id: this.nextId++,\n x: m.x - 0.03,\n damage: m.attack,\n attackType: m.attackType,\n };\n this.enemyShots.push(shot);\n this.emit({ type: \"enemyShoot\", shot, monsterId: m.id });\n continue;\n }\n this.emit({ type: \"enemyStrike\", monsterId: m.id });\n this.hurtHero(m.attack, m.attackType, m);\n if (this.result) return;\n }\n }\n\n private moveEnemyShots(dt: number): void {\n for (const s of [...this.enemyShots]) {\n s.x -= ENEMY_SHOT_SPEED * dt;\n if (s.x > this.heroX + 0.02) continue;\n this.drop(this.enemyShots, s);\n this.hurtHero(s.damage, s.attackType, null);\n if (this.result) return;\n }\n }\n\n /**\n * A troop's blow on the hero: dodged, taken by the divine shield, shrugged off while the armor holds,\n * turned into healing — or through the table against the hero's armor type and its armor value, the\n * shield first. A blow face to face may be countered.\n */\n private hurtHero(raw: number, type: AttackType, from: Monster | null): void {\n if (this.random() < this.stats.dodge + this.mods.dodge) {\n this.emit({ type: \"dodge\" });\n this.fire(\"dodge\");\n return;\n }\n if (this.charges.divine > 0) {\n this.charges.divine--;\n this.emit({ type: \"guard\" });\n return this.counter(from);\n }\n if (\n this.heroShield > 0 &&\n this.mods.shieldIgnore > 0 &&\n this.random() < this.mods.shieldIgnore\n ) {\n this.emit({ type: \"guard\" });\n return this.counter(from);\n }\n let damage =\n raw *\n typeFactor(type, this.heroArmorType) *\n armorFactor(this.heroArmorValue());\n if (type === \"pierce\") damage *= 1 - Math.min(0.8, this.mods.vsPierce);\n const ctx: FireContext = {};\n this.fire(\"hurt\", ctx);\n if (ctx.converted) {\n this.emit({ type: \"heroHit\", damage, shield: 0, healed: true });\n this.healHero(damage);\n return this.counter(from);\n }\n const absorbed = Math.min(this.heroShield, damage);\n this.heroShield -= absorbed;\n this.heroHp -= damage - absorbed;\n this.emit({ type: \"heroHit\", damage, shield: absorbed, healed: false });\n if (this.heroHp > 0 || this.result) return this.counter(from);\n this.heroHp = 0;\n this.emit({ type: \"heroDown\" });\n this.finish(false);\n }\n\n /** The counter: a blow taken face to face may be answered at once with the hero's own. */\n private counter(from: Monster | null): void {\n if (!from || this.mods.counter <= 0 || !this.monsters.includes(from))\n return;\n if (this.random() >= this.mods.counter) return;\n this.emit({ type: \"counter\", monsterId: from.id });\n const crit = this.rollCrit(this.mods.counterCrit);\n this.blow(\n from,\n this.heroDamage() * (crit ? this.critDamage() : 1),\n crit,\n true,\n );\n this.fire(\"counter\", { target: from });\n }\n\n /** Auto's moment for the one ultimate: the boss in reach, a crowd on screen, or the hero in trouble. */\n private ultWorthIt(): boolean {\n if (!this.hasTarget()) return false;\n if (this.monsters.some((m) => m.boss)) return true;\n if (this.monsters.filter((m) => this.onScreen(m)).length >= AUTO_ULT_CROWD)\n return true;\n return this.heroHp < this.heroMaxHp() * AUTO_ULT_BELOW;\n }\n\n // ── Rage and run levels ────────────────────────────────────────────────────────────────────\n\n private addRage(amount: number, fromKill: boolean): void {\n const got = amount * (fromKill ? 1 + this.mods.rage : 1);\n this.rage += got;\n this.emit({ type: \"rage\", amount: got });\n while (this.rage >= rageFor(this.runLevel)) {\n this.runLevel++;\n this.queued++;\n this.emit({ type: \"levelUp\", level: this.runLevel });\n this.fire(\"level\");\n }\n if (!this.result) this.offerCards();\n }\n\n private draw(): string[] {\n return drawCards(this.random, this.level.island.index, this.cards, {\n counter: this.mods.counter > 0,\n dodge: this.stats.dodge + this.mods.dodge > 0,\n shield: this.shielded,\n });\n }\n\n /** The next choice of three, if one is owed and none is open (\"Upgrade: 2/5\" counts a row of them). */\n private offerCards(): void {\n while (this.queued > 0 && !this.pending) {\n // a new row of choices starts when the last one is done\n if (this.choice.index >= this.choice.total)\n this.choice = { index: 0, total: this.queued };\n this.queued--;\n this.choice.index++;\n const options = this.draw();\n if (options.length > 0)\n this.pending = { kind: \"cards\", options, ...this.choice };\n }\n }\n\n // ── The road ───────────────────────────────────────────────────────────────────────────────\n\n private nextSegment(): void {\n this.segment++;\n const segments = this.level.segments;\n if (this.segment < segments.length)\n return this.startSegment(segments[this.segment]!);\n if (this.segment === segments.length) {\n this.lineUp([this.level.boss], Math.min(this.waveAhead, 0.72));\n this.emit({ type: \"bossStart\" });\n }\n }\n\n private startSegment(s: Segment): void {\n if (s.kind === \"wave\") return this.lineUp(s.troops, this.waveAhead);\n this.feature = {\n id: this.nextId++,\n kind: s.kind,\n gives: s.kind === \"fountain\" ? s.gives : undefined,\n x: this.heroX + this.waveAhead,\n };\n this.emit({ type: \"feature\", feature: this.feature });\n }\n\n /** The hero is at what stands on the road. */\n private arrive(f: RoadFeature): void {\n this.emit({ type: \"reached\", feature: f });\n if (f.kind === \"camp\") {\n this.pending = { kind: \"camp\" };\n return;\n }\n this.feature = null;\n if (f.kind === \"chest\") this.chests++;\n else if (f.gives === \"rage\") this.addRage(FOUNTAIN_RAGE, false);\n else this.healHero(FOUNTAIN_HEAL * this.heroMaxHp());\n }\n\n private lineUp(ids: readonly TroopId[], ahead: number): void {\n const power = this.level.power;\n const grow = Math.floor((this.level.index - 1) / 4);\n this.monsters = ids.map((tid, i) => {\n const troop = TROOPS[tid];\n const hp = BASE_HP * power * troop.hp;\n const shield = troopShielded(troop) ? hp * SHIELD_SHARE : 0;\n const armor = troop.armor + grow;\n return {\n id: this.nextId++,\n troop,\n boss: !!troop.boss,\n hp,\n maxHp: hp,\n shield,\n maxShield: shield,\n attack: BASE_ATTACK * power * troop.attack,\n attackType: troopAttack(troop),\n armorType: troopArmor(troop),\n armor,\n broken: Math.min(armor + this.mods.shredMax, this.mods.warCry),\n breaks: 0,\n interval: troop.interval,\n speed: troop.speed,\n range: troopRanged(troop) ? TROOP_RANGE : 0,\n stunned: 0,\n x: this.heroX + ahead + i * this.monsterGap,\n cooldown: troop.interval * 0.5,\n };\n });\n for (const m of this.monsters) this.emit({ type: \"spawn\", monster: m });\n }\n\n private finish(cleared: boolean): void {\n this.walking = false;\n this.pending = null;\n this.projectiles = [];\n this.enemyShots = [];\n this.result = {\n cleared,\n level: this.level.index,\n kills: this.kills,\n runLevel: this.runLevel,\n chests: this.chests,\n seconds: Math.round(this.time),\n cards: [...this.cards],\n potionsUsed: this.potionsUsed,\n };\n this.emit({ type: \"end\", result: this.result });\n }\n\n private emit(e: BattleEvent): void {\n this.events.push(e);\n }\n\n // ── Reading ────────────────────────────────────────────────────────────────────────────────\n\n snapshot(): BattleSnapshot {\n const boss = this.monsters.find((m) => m.boss);\n return {\n level: this.level.index,\n island: this.level.island.index,\n segment: Math.max(0, this.segment),\n segments: this.level.segments.length,\n heroHp: Math.max(0, this.heroHp),\n heroMaxHp: this.heroMaxHp(),\n heroShield: Math.max(0, this.heroShield),\n heroMaxShield: this.heroMaxShield(),\n heroDown: this.heroDown,\n walking: this.walking,\n rage: this.rage,\n rageFrom: this.runLevel > 0 ? rageFor(this.runLevel - 1) : 0,\n rageTo: rageFor(this.runLevel),\n runLevel: this.runLevel,\n pending: this.pending,\n rerollsLeft: this.rerollsLeft,\n potions: this.potions,\n ult: this.ult,\n ultReady: this.ultReady,\n ultActiveLeft: this.ultActiveLeft,\n charges: { ...this.charges },\n autoPlay: this.autoPlay,\n kills: this.kills,\n chests: this.chests,\n cards: [...this.cards],\n result: this.result,\n monsters: this.monsters.length,\n bossHp: boss ? Math.max(0, boss.hp) : 0,\n bossMaxHp: boss?.maxHp ?? 0,\n };\n }\n}\n"
332
+ },
333
+ {
334
+ "path": "game/campaign.ts",
335
+ "content": "// The campaign: islands of levels, each level ONE run (Obsidian Knight). A level is a road of\n// segments — waves of troops, a camp, fountains, a chest — and a boss at its end. It is generated\n// from its number, so every player meets the same road on the same level (and a test the same one).\n//\n// The types of Warcraft come in one by one: the first island is casual — unarmored and light troops,\n// blades and bows, no scouting; every next island brings one new armor or attack type with a tip the\n// first time it is met, and from the second island on the scouting screen shows the road's armor.\n\nimport type { ArmorType, AttackType } from \"./damage\";\nimport { seededRandom } from \"./random\";\nimport { THEMES, type Theme } from \"./themes\";\nimport {\n TROOPS,\n troopArmor,\n troopAttack,\n type TroopDef,\n type TroopId,\n} from \"./units\";\n\nexport const LEVELS_PER_ISLAND = 6;\n\nexport type Segment =\n | { kind: \"wave\"; troops: TroopId[] }\n | { kind: \"camp\" }\n | { kind: \"fountain\"; gives: \"heal\" | \"rage\" }\n | { kind: \"chest\" };\n\nexport interface IslandDef {\n /** 1-based. */\n index: number;\n theme: Theme;\n /** Troops of its waves. */\n troops: TroopId[];\n /** Its bosses, by level (the last one closes the island). */\n bosses: TroopId[];\n /** The types this island brings (a tip the first time each is met). */\n introduces: Array<\n { kind: \"armor\"; type: ArmorType } | { kind: \"attack\"; type: AttackType }\n >;\n /** Does the scouting screen show the armor of the road (not on the casual first island). */\n scouting: boolean;\n}\n\nexport interface LevelDef {\n /** 1-based across islands. */\n index: number;\n island: IslandDef;\n /** 1..LEVELS_PER_ISLAND. */\n level: number;\n segments: Segment[];\n boss: TroopId;\n /** The strength multiplier of its troops (it grows level by level). */\n power: number;\n}\n\n/** The five islands of a loop; past the fifth the loop comes again, harder (power keeps growing). */\nconst ISLANDS: Array<Omit<IslandDef, \"index\" | \"theme\">> = [\n {\n troops: [\"thug\", \"archer\", \"rogue\"],\n bosses: [\"chief\"],\n introduces: [],\n scouting: false,\n },\n {\n troops: [\"rogue\", \"archer\", \"spearman\", \"crossbowman\"],\n bosses: [\"chief\", \"warlord\"],\n introduces: [{ kind: \"armor\", type: \"medium\" }],\n scouting: true,\n },\n {\n troops: [\"spearman\", \"footman\", \"acolyte\", \"mage\", \"crossbowman\"],\n bosses: [\"warlord\", \"archmage\"],\n introduces: [\n { kind: \"armor\", type: \"heavy\" },\n { kind: \"attack\", type: \"magic\" },\n { kind: \"armor\", type: \"hero\" },\n ],\n scouting: true,\n },\n {\n troops: [\"footman\", \"maceman\", \"gunner\", \"berserker\", \"warden\", \"mage\"],\n bosses: [\"warlord\", \"archmage\", \"dreadknight\"],\n introduces: [\n { kind: \"attack\", type: \"siege\" },\n { kind: \"armor\", type: \"fortified\" },\n ],\n scouting: true,\n },\n {\n troops: [\"warden\", \"hammerguard\", \"warlock\", \"gunner\", \"berserker\", \"mage\"],\n bosses: [\"archmage\", \"dreadknight\"],\n introduces: [{ kind: \"attack\", type: \"chaos\" }],\n scouting: true,\n },\n];\n\nconst POWER_GROWTH = 1.16;\n\nexport function islandOf(index: number): IslandDef {\n const i = Math.max(1, Math.floor(index));\n const def = ISLANDS[(i - 1) % ISLANDS.length]!;\n return {\n ...def,\n index: i,\n theme: THEMES[(i - 1) % THEMES.length]!,\n // past the first loop nothing is casual any more\n scouting: def.scouting || i > ISLANDS.length,\n };\n}\n\n/** Level `index` (1-based across islands): its island, its road and its boss. */\nexport function levelOf(index: number): LevelDef {\n const n = Math.max(1, Math.floor(index));\n const islandIndex = Math.ceil(n / LEVELS_PER_ISLAND);\n const level = n - (islandIndex - 1) * LEVELS_PER_ISLAND;\n const island = islandOf(islandIndex);\n const rng = seededRandom(n * 7919 + 17);\n // 6 waves, one more every 3 levels, at most 8 (owner's decision 23.10.2026: 10 made a cleared run\n // on islands 4–5 last 5.5–6 minutes)\n const waves = 6 + Math.min(2, Math.floor((n - 1) / 3));\n const segments: Segment[] = [];\n for (let w = 0; w < waves; w++) {\n const size = Math.min(6, 2 + Math.floor((n + w) / 4) + (w % 2));\n const troops: TroopId[] = [];\n for (let k = 0; k < size; k++)\n troops.push(island.troops[Math.floor(rng() * island.troops.length)]!);\n segments.push({ kind: \"wave\", troops });\n // the camp halfway, a fountain or a chest now and then\n if (w === Math.floor(waves / 2) - 1) segments.push({ kind: \"camp\" });\n else if (w > 0 && w < waves - 1 && rng() < 0.3)\n segments.push(\n rng() < 0.35\n ? { kind: \"chest\" }\n : { kind: \"fountain\", gives: rng() < 0.5 ? \"heal\" : \"rage\" },\n );\n }\n // the island's bosses take turns over its levels; the last level always ends with the last of them\n const turn = Math.floor(\n ((level - 1) * island.bosses.length) / LEVELS_PER_ISLAND,\n );\n return {\n index: n,\n island,\n level,\n segments,\n boss:\n level === LEVELS_PER_ISLAND\n ? island.bosses.at(-1)!\n : island.bosses[Math.min(island.bosses.length - 1, turn)]!,\n power: Math.pow(POWER_GROWTH, n - 1),\n };\n}\n\n/** What the scouting screen shows: the share of each armor type on the road, and the boss. */\nexport interface Scouting {\n armor: Array<{ type: ArmorType; share: number }>;\n attack: Array<{ type: AttackType; share: number }>;\n boss: { troop: TroopDef; armor: ArmorType; attack: AttackType };\n}\n\nexport function scout(level: LevelDef): Scouting {\n const armor = new Map<ArmorType, number>();\n const attack = new Map<AttackType, number>();\n let total = 0;\n for (const s of level.segments)\n if (s.kind === \"wave\")\n for (const id of s.troops) {\n const t = TROOPS[id];\n armor.set(troopArmor(t), (armor.get(troopArmor(t)) ?? 0) + 1);\n attack.set(troopAttack(t), (attack.get(troopAttack(t)) ?? 0) + 1);\n total++;\n }\n const shares = <T>(m: Map<T, number>) =>\n [...m.entries()]\n .map(([type, n]) => ({ type, share: total > 0 ? n / total : 0 }))\n .sort((a, b) => b.share - a.share);\n const boss = TROOPS[level.boss];\n return {\n armor: shares(armor),\n attack: shares(attack),\n boss: { troop: boss, armor: troopArmor(boss), attack: troopAttack(boss) },\n };\n}\n\n/** Every type the player has met by this island (what the tables and tips may talk about). */\nexport function typesKnownBy(islandIndex: number): {\n armor: Set<ArmorType>;\n attack: Set<AttackType>;\n} {\n const armor = new Set<ArmorType>();\n const attack = new Set<AttackType>();\n for (let i = 1; i <= Math.min(islandIndex, ISLANDS.length); i++) {\n const island = islandOf(i);\n for (const id of [...island.troops, ...island.bosses]) {\n armor.add(troopArmor(TROOPS[id]));\n attack.add(troopAttack(TROOPS[id]));\n }\n }\n return { armor, attack };\n}\n"
336
+ },
337
+ {
338
+ "path": "game/cards.ts",
339
+ "content": "// Rage cards — Obsidian Knight's \"upgrades\": every time the rage fills, the run stops and the player\n// takes one of three. A card is a TRIGGER and an EFFECT (\"On kill: 10% chance to restore 10% HP\",\n// \"Every 3rd attack: a double Lightning strike\") or a passive number (\"Passive: +8% Counter\"). The\n// battle (battle.ts) fires the triggers; this file is data — the passives as `RunMods`, the triggered\n// ones as `Proc`s, the text with its colours.\n//\n// The Warcraft table still rules every blow: a Lightning strike is SPELLS (through any armor), a\n// thrown Axe NORMAL, a Spear and the Arrows PIERCE; the passives that add damage of another type\n// (\"+25% of every blow as Magic\") are how a sword gets through plate.\n//\n// Text markup (ui/types.tsx renders it): **When** — the trigger, white and bold; [g]10%[/g] — a\n// number, green; [o]Lightning[/o] — an effect's name, orange.\n\nimport type { ArmorType, AttackType } from \"./damage\";\n\nexport type Rarity = \"common\" | \"rare\" | \"legendary\";\n\n/** How often a rarity is drawn, against the others. */\nexport const RARITY_WEIGHT: Readonly<Record<Rarity, number>> = {\n common: 70,\n rare: 22,\n legendary: 8,\n};\n\n/** The passive numbers the cards add (all zero — no card taken); the battle reads them. */\nexport interface RunMods {\n /** + share of the hero's damage (ATK). */\n damage: number;\n attackSpeed: number;\n critChance: number;\n /** + to the crit multiplier. */\n critDamage: number;\n /** + share of the max health. */\n maxHp: number;\n dodge: number;\n /** + armor points of the hero. */\n armor: number;\n /** Chance a blow taken face to face is answered at once (the counter). */\n counter: number;\n /** Chance a blow heals the hero for a share of its damage. */\n lifesteal: number;\n /** Chance to attack again at once (after a blow). */\n doubleHit: number;\n /** Every blow also deals this share of itself as damage of another type. */\n hybrid: Partial<Record<AttackType, number>>;\n /** + share of damage against an armor type. */\n vsArmor: Partial<Record<ArmorType, number>>;\n /** Share taken off pierce blows (WC3's Defend). */\n vsPierce: number;\n /** + share of damage on enemies behind the first in line. */\n backRow: number;\n /** Share of every blow that goes on into the enemy behind its target. */\n pierceShare: number;\n /** + share of damage on a stunned enemy. */\n vsStunned: number;\n /** + share of what a repair gives the shield. */\n repairPower: number;\n /** Potions this run, on top of those taken along; + share of what a potion heals. */\n extraPotions: number;\n potionPower: number;\n /** + counter and + max health for every card taken. */\n counterPerCard: number;\n maxHpPerCard: number;\n /** + crit chance of a counter blow. */\n counterCrit: number;\n /** + attack speed below 30 % health; + damage at full health. */\n lowHpSpeed: number;\n fullHpDamage: number;\n /** Chance to ignore a blow while the shield holds. */\n shieldIgnore: number;\n /** + share of damage for every 10 % of the target's missing health. */\n missingHpDamage: number;\n /** Armor points a blow breaks off its target, and how deep it may go below zero. */\n shred: number;\n shredMax: number;\n /** Armor points broken off every troop as a wave comes. */\n warCry: number;\n /** + share of rage from kills. */\n rage: number;\n}\n\nexport function noMods(): RunMods {\n return {\n damage: 0,\n attackSpeed: 0,\n critChance: 0,\n critDamage: 0,\n maxHp: 0,\n dodge: 0,\n armor: 0,\n counter: 0,\n lifesteal: 0,\n doubleHit: 0,\n hybrid: {},\n vsArmor: {},\n vsPierce: 0,\n backRow: 0,\n pierceShare: 0,\n vsStunned: 0,\n repairPower: 0,\n extraPotions: 0,\n potionPower: 0,\n counterPerCard: 0,\n maxHpPerCard: 0,\n counterCrit: 0,\n lowHpSpeed: 0,\n fullHpDamage: 0,\n shieldIgnore: 0,\n missingHpDamage: 0,\n shred: 0,\n shredMax: 0,\n warCry: 0,\n rage: 0,\n };\n}\n\n/** When a triggered card fires. */\nexport type Trigger =\n | \"hit\"\n | \"crit\"\n | \"kill\"\n | \"dodge\"\n | \"counter\"\n | \"hurt\"\n | \"heal\"\n | \"overheal\"\n | \"ult\"\n | \"camp\"\n | \"attack\"\n | \"level\";\n\n/** A missile an effect sends: its look, its attack type, who it hits. */\nexport type Bolt = \"lightning\" | \"axe\" | \"spear\" | \"arrow\";\n\nexport const BOLT_TYPE: Readonly<Record<Bolt, AttackType>> = {\n lightning: \"spells\",\n axe: \"normal\",\n spear: \"pierce\",\n arrow: \"pierce\",\n};\n\n/** What a triggered card does. */\nexport type Effect =\n /** Damage: `power` × ATK, `count` times, at whom (`line` — the spear goes through all of them). */\n | {\n kind: \"bolt\";\n bolt: Bolt;\n power: number;\n count: number;\n at: \"target\" | \"random\" | \"farthest\" | \"next\" | \"line\";\n }\n | { kind: \"heal\"; share: number }\n /** The shield repairs this share of its max (0 — by what the heal had over). */\n | { kind: \"repair\"; share: number }\n | { kind: \"stun\"; seconds: number; all: boolean }\n /** Charges: focus (+100% crit on the next attack), divine (blocks a blow), lightning (a strike on a hit). */\n | { kind: \"charge\"; charge: \"focus\" | \"divine\" | \"lightning\"; count: number }\n /** Shield break on the target: +10% damage on it a stack. */\n | { kind: \"break\" }\n | { kind: \"again\" }\n | { kind: \"cleave\" }\n /** + crit chance for the rest of the run. */\n | { kind: \"gainCrit\"; value: number }\n | { kind: \"ultBack\" }\n /** The killing blow's damage past the health goes into the shield. */\n | { kind: \"overkillShield\" }\n /** The blow taken heals instead. */\n | { kind: \"hurtToHeal\" }\n /** Attack with no cooldown until the target dies. */\n | { kind: \"flurry\" }\n /** The dead one blows up: this share of its max health on the next. */\n | { kind: \"explode\"; share: number };\n\nexport interface Proc {\n trigger: Trigger;\n /** The chance it fires (1 — always). */\n chance: number;\n /** Fires on every N-th time of its trigger (an attack, a counter) instead of by chance. */\n every?: number;\n effect: Effect;\n}\n\n/** What a card's octagon shows (ui/cardIcons.tsx draws them). */\nexport type CardIcon =\n | \"lightning\"\n | \"axe\"\n | \"spear\"\n | \"arrows\"\n | \"heal\"\n | \"repair\"\n | \"divine\"\n | \"stun\"\n | \"focus\"\n | \"skull\"\n | \"counter\"\n | \"daggers\"\n | \"speed\"\n | \"crit\"\n | \"sword\"\n | \"backrow\"\n | \"break\"\n | \"blood\"\n | \"potion\"\n | \"heart\"\n | \"dodge\"\n | \"rage\"\n | \"type\";\n\nexport interface CardDef {\n id: string;\n rarity: Rarity;\n /** The island it is first drawn on. */\n island: number;\n /** How many times it may be taken in one run. */\n stacks: number;\n icon: CardIcon;\n /** The text, English and Russian, with the markup (see the top). */\n text: [string, string];\n /** Passive numbers it adds. */\n apply?: (m: RunMods) => void;\n /** Its triggered effect. */\n proc?: Proc;\n /** Drawn only when the run has this (a counter card needs a counter). */\n needs?: \"counter\" | \"dodge\" | \"shield\";\n}\n\nconst add = (rec: Partial<Record<string, number>>, key: string, v: number) => {\n rec[key] = (rec[key] ?? 0) + v;\n};\n\n/** Russian first (the owner's sheet), English second; stored [en, ru]. */\nconst P = (ru: string, en: string): [string, string] => [en, ru];\n\nconst bolt = (\n b: Bolt,\n power: number,\n count: number,\n at: \"target\" | \"random\" | \"farthest\" | \"next\" | \"line\",\n): Effect => ({ kind: \"bolt\", bolt: b, power, count, at });\n\n/** The cards. Ids are stable: a saved run or a test names them. */\nexport const CARDS: readonly CardDef[] = [\n // ── Passive numbers ──\n {\n id: \"p_counter\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"counter\",\n text: P(\n \"**Пассивно**: [g]+20%[/g] Контрудар\",\n \"**Passive**: [g]+20%[/g] Counter\",\n ),\n apply: (m) => (m.counter += 0.2),\n },\n {\n id: \"p_speed\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"speed\",\n text: P(\n \"**Пассивно**: [g]+15%[/g] Скорость атаки\",\n \"**Passive**: [g]+15%[/g] Attack speed\",\n ),\n apply: (m) => (m.attackSpeed += 0.15),\n },\n {\n id: \"p_crit\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"crit\",\n text: P(\n \"**Пассивно**: [g]+12%[/g] Шанс крита\",\n \"**Passive**: [g]+12%[/g] Crit chance\",\n ),\n apply: (m) => (m.critChance += 0.12),\n },\n {\n id: \"p_critmult\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"crit\",\n text: P(\n \"**Пассивно**: [g]+60%[/g] Крит. множитель и [g]+4%[/g] Шанс крита\",\n \"**Passive**: [g]+60%[/g] Crit multiplier and [g]+4%[/g] Crit chance\",\n ),\n apply: (m) => {\n m.critDamage += 0.6;\n m.critChance += 0.04;\n },\n },\n {\n id: \"p_dodge\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"dodge\",\n text: P(\n \"**Пассивно**: [g]+12%[/g] Уклонение\",\n \"**Passive**: [g]+12%[/g] Dodge\",\n ),\n apply: (m) => (m.dodge += 0.12),\n },\n {\n id: \"p_lifesteal\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"blood\",\n text: P(\n \"**Пассивно**: [g]+15%[/g] Шанс вампиризма\",\n \"**Passive**: [g]+15%[/g] Lifesteal chance\",\n ),\n apply: (m) => (m.lifesteal += 0.15),\n },\n {\n id: \"p_hp\",\n rarity: \"common\",\n island: 1,\n stacks: 3,\n icon: \"heart\",\n text: P(\n \"**Пассивно**: [g]+15%[/g] Макс. HP\",\n \"**Passive**: [g]+15%[/g] Max HP\",\n ),\n apply: (m) => (m.maxHp += 0.15),\n },\n {\n id: \"p_atk\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"sword\",\n text: P(\"**Пассивно**: [g]+30%[/g] ATK\", \"**Passive**: [g]+30%[/g] ATK\"),\n apply: (m) => (m.damage += 0.3),\n },\n {\n id: \"p_again\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"daggers\",\n text: P(\n \"**Пассивно**: [g]+20%[/g] шанс немедленно атаковать ещё раз\",\n \"**Passive**: [g]+20%[/g] chance to attack again at once\",\n ),\n apply: (m) => (m.doubleHit += 0.2),\n },\n {\n id: \"p_backrow\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"backrow\",\n text: P(\n \"**Пассивно**: [g]30%[/g] урона каждого удара проходит во врага позади; урон по заднему ряду [g]+100%[/g]\",\n \"**Passive**: [g]30%[/g] of every blow goes on into the enemy behind; [g]+100%[/g] damage to the back row\",\n ),\n apply: (m) => {\n m.backRow += 1;\n m.pierceShare += 0.3;\n },\n },\n {\n id: \"p_repair\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"repair\",\n needs: \"shield\",\n text: P(\n \"**Пассивно**: Броня чинится эффективнее на [g]100%[/g]\",\n \"**Passive**: the Armor repairs [g]100%[/g] better\",\n ),\n apply: (m) => (m.repairPower += 1),\n },\n {\n id: \"p_counter_per_card\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"counter\",\n text: P(\n \"**Пассивно**: [g]+2%[/g] Контрудар за каждое усиление\",\n \"**Passive**: [g]+2%[/g] Counter for every upgrade\",\n ),\n apply: (m) => (m.counterPerCard += 0.02),\n },\n {\n id: \"p_hp_per_card\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"heart\",\n text: P(\n \"**Пассивно**: [g]+8%[/g] Макс. HP за каждое усиление\",\n \"**Passive**: [g]+8%[/g] Max HP for every upgrade\",\n ),\n apply: (m) => (m.maxHpPerCard += 0.08),\n },\n {\n id: \"p_counter_crit\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"counter\",\n needs: \"counter\",\n text: P(\n \"**Пассивно**: Контрудар имеет [g]+20%[/g] шанс крита\",\n \"**Passive**: a Counter has [g]+20%[/g] crit chance\",\n ),\n apply: (m) => (m.counterCrit += 0.2),\n },\n {\n id: \"p_flask\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"potion\",\n text: P(\n \"**Пассивно**: [g]+1[/g] зелье на этот забег\",\n \"**Passive**: [g]+1[/g] potion for this run\",\n ),\n apply: (m) => (m.extraPotions += 1),\n },\n {\n id: \"p_alchemist\",\n rarity: \"rare\",\n island: 1,\n stacks: 1,\n icon: \"potion\",\n text: P(\n \"**Пассивно**: зелья лечат на [g]50%[/g] больше\",\n \"**Passive**: potions heal [g]50%[/g] more\",\n ),\n apply: (m) => (m.potionPower += 0.5),\n },\n {\n id: \"p_low_hp_speed\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"speed\",\n text: P(\n \"**Когда HP ниже 30%**: [g]+100%[/g] Скорость атаки\",\n \"**Below 30% HP**: [g]+100%[/g] Attack speed\",\n ),\n apply: (m) => (m.lowHpSpeed += 1),\n },\n {\n id: \"p_full_hp_atk\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"heart\",\n text: P(\n \"**При полном HP**: [g]+100%[/g] ATK\",\n \"**At full HP**: [g]+100%[/g] ATK\",\n ),\n apply: (m) => (m.fullHpDamage += 1),\n },\n {\n id: \"p_shield_ignore\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"divine\",\n needs: \"shield\",\n text: P(\n \"**Пока есть Броня**: шанс [g]50%[/g] игнорировать урон\",\n \"**While the Armor holds**: [g]50%[/g] chance to ignore damage\",\n ),\n apply: (m) => (m.shieldIgnore = Math.max(m.shieldIgnore, 0.5)),\n },\n // ── Warcraft: damage of another type, armor broken ──\n {\n id: \"p_magic\",\n rarity: \"common\",\n island: 2,\n stacks: 2,\n icon: \"type\",\n text: P(\n \"**Пассивно**: [g]+25%[/g] урона каждого удара — [o]Магией[/o]\",\n \"**Passive**: [g]+25%[/g] of every blow as [o]Magic[/o]\",\n ),\n apply: (m) => add(m.hybrid, \"magic\", 0.25),\n },\n {\n id: \"p_siege\",\n rarity: \"common\",\n island: 3,\n stacks: 2,\n icon: \"type\",\n text: P(\n \"**Пассивно**: [g]+25%[/g] урона каждого удара — [o]Осадным[/o]\",\n \"**Passive**: [g]+25%[/g] of every blow as [o]Siege[/o]\",\n ),\n apply: (m) => add(m.hybrid, \"siege\", 0.25),\n },\n {\n id: \"p_pierce\",\n rarity: \"common\",\n island: 2,\n stacks: 2,\n icon: \"type\",\n text: P(\n \"**Пассивно**: [g]+25%[/g] урона каждого удара — [o]Проникающим[/o]\",\n \"**Passive**: [g]+25%[/g] of every blow as [o]Pierce[/o]\",\n ),\n apply: (m) => add(m.hybrid, \"pierce\", 0.25),\n },\n {\n id: \"p_chaos\",\n rarity: \"legendary\",\n island: 4,\n stacks: 1,\n icon: \"type\",\n text: P(\n \"**Пассивно**: [g]+50%[/g] урона каждого удара — [o]Хаосом[/o] (полный урон по любой броне)\",\n \"**Passive**: [g]+50%[/g] of every blow as [o]Chaos[/o] (full damage to any armor)\",\n ),\n apply: (m) => add(m.hybrid, \"chaos\", 0.5),\n },\n {\n id: \"p_vs_heavy\",\n rarity: \"rare\",\n island: 3,\n stacks: 2,\n icon: \"break\",\n text: P(\n \"**Пассивно**: Урон [g]+35%[/g] по [o]Тяжёлой[/o] броне\",\n \"**Passive**: [g]+35%[/g] damage to [o]Heavy[/o] armor\",\n ),\n apply: (m) => add(m.vsArmor, \"heavy\", 0.35),\n },\n {\n id: \"p_vs_fortified\",\n rarity: \"rare\",\n island: 4,\n stacks: 2,\n icon: \"break\",\n text: P(\n \"**Пассивно**: Урон [g]+50%[/g] по [o]Укреплённой[/o] броне\",\n \"**Passive**: [g]+50%[/g] damage to [o]Fortified[/o] armor\",\n ),\n apply: (m) => add(m.vsArmor, \"fortified\", 0.5),\n },\n {\n id: \"p_defend\",\n rarity: \"rare\",\n island: 2,\n stacks: 2,\n icon: \"repair\",\n text: P(\n \"**Пассивно**: [g]−30%[/g] урона от [o]Проникающих[/o] (стрелы, болты, пули)\",\n \"**Passive**: [g]−30%[/g] damage from [o]Pierce[/o] (arrows, bolts, bullets)\",\n ),\n apply: (m) => (m.vsPierce += 0.3),\n },\n {\n id: \"hit_shred\",\n rarity: \"common\",\n island: 2,\n stacks: 2,\n icon: \"break\",\n text: P(\n \"**При попадании**: броня цели [g]−1[/g] (до [g]−5[/g])\",\n \"**On hit**: the target's armor [g]−1[/g] (down to [g]−5[/g])\",\n ),\n apply: (m) => {\n m.shred += 1;\n m.shredMax += 5;\n },\n },\n {\n id: \"wave_cry\",\n rarity: \"rare\",\n island: 2,\n stacks: 2,\n icon: \"rage\",\n text: P(\n \"**Новая волна**: броня врагов [g]−2[/g]\",\n \"**A new wave**: the enemies' armor [g]−2[/g]\",\n ),\n apply: (m) => (m.warCry += 2),\n },\n // ── On hit ──\n {\n id: \"hit_spear\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"spear\",\n text: P(\n \"**При попадании**: шанс [g]10%[/g] метнуть [o]Пронзающее копьё[/o] ([g]60%[/g] ATK)\",\n \"**On hit**: [g]10%[/g] chance to throw a [o]Piercing spear[/o] ([g]60%[/g] ATK)\",\n ),\n proc: {\n trigger: \"hit\",\n chance: 0.1,\n effect: bolt(\"spear\", 0.6, 1, \"line\"),\n },\n },\n {\n id: \"hit_break\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"break\",\n text: P(\n \"**При попадании**: заряд «[o]Пробитие щита[/o]», увеличивающий урон по цели на [g]8%[/g] (до [g]4[/g] зарядов)\",\n \"**On hit**: a «[o]Shield break[/o]» stack, [g]+8%[/g] damage to the target (up to [g]4[/g] stacks)\",\n ),\n proc: { trigger: \"hit\", chance: 1, effect: { kind: \"break\" } },\n },\n {\n id: \"hit_missing\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"sword\",\n text: P(\n \"**При попадании**: Урон [g]+10%[/g] за каждые [g]10%[/g] недостающего HP цели\",\n \"**On hit**: [g]+10%[/g] damage for every [g]10%[/g] of the target's missing HP\",\n ),\n apply: (m) => (m.missingHpDamage += 0.1),\n },\n // ── On crit ──\n {\n id: \"crit_axe\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"axe\",\n text: P(\n \"**При крите**: метните [o]Топор[/o] ([g]150%[/g] ATK) в случайного врага\",\n \"**On crit**: throw an [o]Axe[/o] ([g]150%[/g] ATK) at a random enemy\",\n ),\n proc: { trigger: \"crit\", chance: 1, effect: bolt(\"axe\", 1.5, 1, \"random\") },\n },\n {\n id: \"crit_stun\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"stun\",\n text: P(\n \"**При крите**: [o]Обездвиживание[/o] цели на [g]3[/g] сек.\",\n \"**On crit**: [o]Stun[/o] the target for [g]3[/g] s\",\n ),\n proc: {\n trigger: \"crit\",\n chance: 1,\n effect: { kind: \"stun\", seconds: 3, all: false },\n },\n },\n {\n id: \"crit_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"heal\",\n text: P(\n \"**При крите**: восстановите [g]6%[/g] HP\",\n \"**On crit**: restore [g]6%[/g] HP\",\n ),\n proc: { trigger: \"crit\", chance: 1, effect: { kind: \"heal\", share: 0.06 } },\n },\n {\n id: \"crit_arrows\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"arrows\",\n text: P(\n \"**При крите**: выпускаются [g]2[/g] [o]Стрелы[/o] ([g]33%[/g] ATK) по случайным врагам\",\n \"**On crit**: [g]2[/g] [o]Arrows[/o] ([g]33%[/g] ATK) fly at random enemies\",\n ),\n proc: {\n trigger: \"crit\",\n chance: 1,\n effect: bolt(\"arrow\", 0.33, 2, \"random\"),\n },\n },\n // ── On kill ──\n {\n id: \"kill_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"skull\",\n text: P(\n \"**При убийстве**: шанс [g]30%[/g] восстановить [g]8%[/g] HP\",\n \"**On kill**: [g]30%[/g] chance to restore [g]8%[/g] HP\",\n ),\n proc: {\n trigger: \"kill\",\n chance: 0.3,\n effect: { kind: \"heal\", share: 0.08 },\n },\n },\n {\n id: \"kill_crit\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"skull\",\n text: P(\n \"**При убийстве**: шанс [g]50%[/g] получить [g]+2%[/g] Шанс крита\",\n \"**On kill**: [g]50%[/g] chance to gain [g]+2%[/g] Crit chance\",\n ),\n proc: {\n trigger: \"kill\",\n chance: 0.5,\n effect: { kind: \"gainCrit\", value: 0.02 },\n },\n },\n {\n id: \"kill_arrow\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"arrows\",\n text: P(\n \"**При убийстве**: [o]Стрела[/o] ([g]60%[/g] ATK) летит в следующего врага\",\n \"**On kill**: an [o]Arrow[/o] ([g]60%[/g] ATK) flies at the next enemy\",\n ),\n proc: {\n trigger: \"kill\",\n chance: 1,\n effect: bolt(\"arrow\", 0.6, 1, \"next\"),\n },\n },\n {\n id: \"kill_overkill\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"repair\",\n needs: \"shield\",\n text: P(\n \"**При убийстве**: избыточный урон засчитывается как [o]Броня[/o]\",\n \"**On kill**: the overkill damage turns into [o]Armor[/o]\",\n ),\n proc: { trigger: \"kill\", chance: 1, effect: { kind: \"overkillShield\" } },\n },\n {\n id: \"kill_explode\",\n rarity: \"rare\",\n island: 1,\n stacks: 1,\n icon: \"skull\",\n text: P(\n \"**При убийстве**: шанс [g]25%[/g] — [o]Взрыв[/o] ([g]30%[/g] макс. HP погибшего) по следующему врагу\",\n \"**On kill**: [g]25%[/g] chance of a [o]Blast[/o] ([g]30%[/g] of the dead one's max HP) on the next enemy\",\n ),\n proc: {\n trigger: \"kill\",\n chance: 0.25,\n effect: { kind: \"explode\", share: 0.3 },\n },\n },\n {\n id: \"kill_ult\",\n rarity: \"rare\",\n island: 1,\n stacks: 2,\n icon: \"rage\",\n text: P(\n \"**При убийстве**: шанс [g]15%[/g] вернуть ультимейт\",\n \"**On kill**: [g]15%[/g] chance to bring the ultimate back\",\n ),\n proc: { trigger: \"kill\", chance: 0.15, effect: { kind: \"ultBack\" } },\n },\n // ── On dodge ──\n {\n id: \"dodge_axe\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"axe\",\n needs: \"dodge\",\n text: P(\n \"**При уклонении**: метните [o]Топор[/o] ([g]50%[/g] ATK) в случайного врага\",\n \"**On dodge**: throw an [o]Axe[/o] ([g]50%[/g] ATK) at a random enemy\",\n ),\n proc: {\n trigger: \"dodge\",\n chance: 1,\n effect: bolt(\"axe\", 0.5, 1, \"random\"),\n },\n },\n {\n id: \"dodge_focus\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"focus\",\n needs: \"dodge\",\n text: P(\n \"**При уклонении**: получите заряд «[o]Концентрация[/o]» (x1, [g]+100%[/g] шанс крита на одну атаку)\",\n \"**On dodge**: gain a «[o]Focus[/o]» charge (x1, [g]+100%[/g] crit chance for one attack)\",\n ),\n proc: {\n trigger: \"dodge\",\n chance: 1,\n effect: { kind: \"charge\", charge: \"focus\", count: 1 },\n },\n },\n {\n id: \"dodge_repair\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"repair\",\n needs: \"dodge\",\n text: P(\n \"**При уклонении**: [o]Броня[/o] чинится на [g]12%[/g]\",\n \"**On dodge**: the [o]Armor[/o] repairs [g]12%[/g]\",\n ),\n proc: {\n trigger: \"dodge\",\n chance: 1,\n effect: { kind: \"repair\", share: 0.12 },\n },\n },\n // ── On counter ──\n {\n id: \"counter_again\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"daggers\",\n needs: \"counter\",\n text: P(\n \"**При контратаке**: шанс [g]33%[/g] немедленно атаковать ещё раз\",\n \"**On counter**: [g]33%[/g] chance to attack again at once\",\n ),\n proc: { trigger: \"counter\", chance: 0.33, effect: { kind: \"again\" } },\n },\n {\n id: \"counter_again_plus\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"counter\",\n needs: \"counter\",\n text: P(\n \"**При контратаке**: шанс [g]66%[/g] немедленно атаковать ещё раз\",\n \"**On counter**: [g]66%[/g] chance to attack again at once\",\n ),\n proc: { trigger: \"counter\", chance: 0.66, effect: { kind: \"again\" } },\n },\n {\n id: \"counter_cleave\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"counter\",\n needs: \"counter\",\n text: P(\n \"**При контратаке**: [o]Рассечение[/o] (удар по соседнему врагу)\",\n \"**On counter**: [o]Cleave[/o] (a blow at the enemy next to it)\",\n ),\n proc: { trigger: \"counter\", chance: 1, effect: { kind: \"cleave\" } },\n },\n {\n id: \"counter_flurry\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"rage\",\n needs: \"counter\",\n text: P(\n \"**Каждый [g]4[/g]-й Контрудар**: атакуйте без перезарядки, пока враг не умрёт\",\n \"**Every [g]4[/g]th Counter**: attack with no cooldown until the enemy dies\",\n ),\n proc: {\n trigger: \"counter\",\n chance: 1,\n every: 4,\n effect: { kind: \"flurry\" },\n },\n },\n // ── On being hit ──\n {\n id: \"hurt_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"blood\",\n text: P(\n \"**При получении удара**: шанс [g]10%[/g] превратить урон в лечение\",\n \"**When hit**: [g]10%[/g] chance to turn the damage into healing\",\n ),\n proc: { trigger: \"hurt\", chance: 0.1, effect: { kind: \"hurtToHeal\" } },\n },\n // ── On healing ──\n {\n id: \"heal_lightning\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"lightning\",\n text: P(\n \"**При лечении**: двойной [o]Удар молнии[/o] ([g]75%[/g] ATK) по самому дальнему врагу\",\n \"**On heal**: a double [o]Lightning strike[/o] ([g]75%[/g] ATK) at the farthest enemy\",\n ),\n proc: {\n trigger: \"heal\",\n chance: 1,\n effect: bolt(\"lightning\", 0.75, 2, \"farthest\"),\n },\n },\n {\n id: \"overheal_repair\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"repair\",\n needs: \"shield\",\n text: P(\n \"**При избыточном лечении**: [o]Броня[/o] чинится\",\n \"**On overheal**: the [o]Armor[/o] repairs\",\n ),\n proc: {\n trigger: \"overheal\",\n chance: 1,\n effect: { kind: \"repair\", share: 0 },\n },\n },\n {\n id: \"overheal_lightning\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"lightning\",\n text: P(\n \"**При лечении**: [o]Удар молнии[/o] ([g]75%[/g] ATK) по случайному врагу\",\n \"**On heal**: a [o]Lightning strike[/o] ([g]75%[/g] ATK) at a random enemy\",\n ),\n proc: {\n trigger: \"heal\",\n chance: 1,\n effect: bolt(\"lightning\", 0.75, 1, \"random\"),\n },\n },\n // ── On the ultimate ──\n {\n id: \"ult_divine\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"divine\",\n text: P(\n \"**При использовании активного умения**: получите [o]Божественный щит[/o] (x8), блокирует урон один раз\",\n \"**On the active skill**: gain a [o]Divine shield[/o] (x8), each blocks one blow\",\n ),\n proc: {\n trigger: \"ult\",\n chance: 1,\n effect: { kind: \"charge\", charge: \"divine\", count: 8 },\n },\n },\n {\n id: \"ult_stun\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"stun\",\n text: P(\n \"**При использовании активного умения**: [o]Обездвиживание[/o] всех врагов на [g]6[/g] сек. Обездвиженные враги получают [g]+50%[/g] урона\",\n \"**On the active skill**: [o]Stun[/o] every enemy for [g]6[/g] s. Stunned enemies take [g]+50%[/g] damage\",\n ),\n apply: (m) => (m.vsStunned += 0.5),\n proc: {\n trigger: \"ult\",\n chance: 1,\n effect: { kind: \"stun\", seconds: 6, all: true },\n },\n },\n // ── Every N-th attack ──\n {\n id: \"every3_lightning\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"lightning\",\n text: P(\n \"**Каждая [g]3[/g]-я атака**: двойной [o]Удар молнии[/o] ([g]30%[/g] ATK) по врагу\",\n \"**Every [g]3[/g]rd attack**: a double [o]Lightning strike[/o] ([g]30%[/g] ATK) at the enemy\",\n ),\n proc: {\n trigger: \"attack\",\n chance: 1,\n every: 3,\n effect: bolt(\"lightning\", 0.3, 2, \"target\"),\n },\n },\n {\n id: \"every4_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 1,\n icon: \"heal\",\n text: P(\n \"**Каждая [g]4[/g]-я атака** восстанавливает [g]2%[/g] здоровья\",\n \"**Every [g]4[/g]th attack** restores [g]2%[/g] health\",\n ),\n proc: {\n trigger: \"attack\",\n chance: 1,\n every: 4,\n effect: { kind: \"heal\", share: 0.02 },\n },\n },\n // ── The camp, a new run level ──\n {\n id: \"camp_lightning\",\n rarity: \"legendary\",\n island: 1,\n stacks: 1,\n icon: \"lightning\",\n text: P(\n \"**При отдыхе**: получите заряды «[o]Удар молнии[/o]» (x3, [g]75%[/g] ATK), которые поражают врагов **при попадании**\",\n \"**On rest**: gain «[o]Lightning strike[/o]» charges (x3, [g]75%[/g] ATK) that strike enemies **on hit**\",\n ),\n proc: {\n trigger: \"camp\",\n chance: 1,\n effect: { kind: \"charge\", charge: \"lightning\", count: 3 },\n },\n },\n {\n id: \"level_heal\",\n rarity: \"common\",\n island: 1,\n stacks: 2,\n icon: \"heal\",\n text: P(\n \"**Новый уровень забега**: восстановите [g]25%[/g] HP\",\n \"**A new run level**: restore [g]25%[/g] HP\",\n ),\n proc: {\n trigger: \"level\",\n chance: 1,\n effect: { kind: \"heal\", share: 0.25 },\n },\n },\n {\n id: \"level_ult\",\n rarity: \"rare\",\n island: 2,\n stacks: 1,\n icon: \"rage\",\n text: P(\n \"**Новый уровень забега**: шанс [g]30%[/g] вернуть ультимейт\",\n \"**A new run level**: [g]30%[/g] chance to bring the ultimate back\",\n ),\n proc: { trigger: \"level\", chance: 0.3, effect: { kind: \"ultBack\" } },\n },\n];\n\nexport const CARD_BY_ID: ReadonlyMap<string, CardDef> = new Map(\n CARDS.map((c) => [c.id, c]),\n);\n\n/** The run's passive numbers from the cards taken. */\nexport function modsOf(taken: readonly string[]): RunMods {\n const m = noMods();\n for (const id of taken) CARD_BY_ID.get(id)?.apply?.(m);\n // the \"for every upgrade\" cards count every card taken\n m.counter += m.counterPerCard * taken.length;\n m.maxHp += m.maxHpPerCard * taken.length;\n return m;\n}\n\n/** The run's triggered effects from the cards taken (a card taken twice fires twice). */\nexport function procsOf(taken: readonly string[]): Proc[] {\n const out: Proc[] = [];\n for (const id of taken) {\n const p = CARD_BY_ID.get(id)?.proc;\n if (p) out.push(p);\n }\n return out;\n}\n\n/** What the run has so far, for the cards that build on something. */\nexport interface DrawContext {\n counter: boolean;\n dodge: boolean;\n shield: boolean;\n}\n\n/**\n * Three different cards to choose from: the ones open on this island, not yet taken as many times as\n * they stack, with what they build on already there (no counter card without a counter), weighted by\n * rarity — drawn from the run's own random, so a seed replays the same picks.\n */\nexport function drawCards(\n random: () => number,\n island: number,\n taken: readonly string[],\n has: DrawContext = { counter: true, dodge: true, shield: true },\n count = 3,\n): string[] {\n const times = new Map<string, number>();\n for (const id of taken) times.set(id, (times.get(id) ?? 0) + 1);\n const pool = CARDS.filter(\n (c) =>\n c.island <= island &&\n (times.get(c.id) ?? 0) < c.stacks &&\n (!c.needs || has[c.needs]),\n );\n const out: string[] = [];\n while (out.length < count && pool.length > 0) {\n const total = pool.reduce((s, c) => s + RARITY_WEIGHT[c.rarity], 0);\n let roll = random() * total;\n let i = 0;\n for (; i < pool.length - 1; i++) {\n roll -= RARITY_WEIGHT[pool[i]!.rarity];\n if (roll < 0) break;\n }\n out.push(pool[i]!.id);\n pool.splice(i, 1);\n }\n return out;\n}\n"
340
+ },
341
+ {
342
+ "path": "game/damage.ts",
343
+ "content": "// Damage the Warcraft III way: every blow has an ATTACK TYPE (what hits — a blade, an arrow, a hammer,\n// a spell) and every fighter an ARMOR TYPE (what it wears) plus an ARMOR VALUE. The table says how\n// well a type of attack goes through a type of armor; the value then takes its share off by the WC3\n// formula (never all of it). Seven attack types × six armor types, as in The Frozen Throne — the\n// numbers are the owner's sheet of it; tune them gently, the feel of \"an arrow tears a robe, bounces\n// off a wall\" is the point.\n//\n// Pure: no Phaser, no client — the battle, the scouting screen and the tests read the same numbers.\n\nimport { heroIsMelee } from \"../character/rigs/hero\";\n\nexport type AttackType =\n \"normal\" | \"pierce\" | \"siege\" | \"magic\" | \"chaos\" | \"spells\" | \"hero\";\n\nexport type ArmorType =\n \"light\" | \"medium\" | \"heavy\" | \"fortified\" | \"hero\" | \"unarmored\";\n\nexport const ATTACK_TYPES: readonly AttackType[] = [\n \"normal\",\n \"pierce\",\n \"siege\",\n \"magic\",\n \"chaos\",\n \"spells\",\n \"hero\",\n];\n\nexport const ARMOR_TYPES: readonly ArmorType[] = [\n \"light\",\n \"medium\",\n \"heavy\",\n \"fortified\",\n \"hero\",\n \"unarmored\",\n];\n\n/** How much of a blow of this attack type goes through this armor type (1 — all of it). */\nexport const DAMAGE_TABLE: Readonly<\n Record<AttackType, Readonly<Record<ArmorType, number>>>\n> = {\n normal: {\n light: 1,\n medium: 1.5,\n heavy: 1,\n fortified: 0.7,\n hero: 1,\n unarmored: 1,\n },\n pierce: {\n light: 2,\n medium: 0.75,\n heavy: 1,\n fortified: 0.35,\n hero: 0.5,\n unarmored: 1.5,\n },\n siege: {\n light: 1,\n medium: 0.5,\n heavy: 1,\n fortified: 1.5,\n hero: 0.5,\n unarmored: 1.5,\n },\n magic: {\n light: 1.25,\n medium: 0.75,\n heavy: 2,\n fortified: 0.35,\n hero: 0.5,\n unarmored: 1,\n },\n chaos: {\n light: 1,\n medium: 1,\n heavy: 1,\n fortified: 1,\n hero: 1,\n unarmored: 1,\n },\n spells: {\n light: 1,\n medium: 1,\n heavy: 1,\n fortified: 1,\n hero: 0.7,\n unarmored: 1,\n },\n hero: {\n light: 1,\n medium: 1,\n heavy: 1,\n fortified: 0.5,\n hero: 1,\n unarmored: 1,\n },\n};\n\n/** WC3's armor constant: each point of armor is worth this much. */\nexport const ARMOR_PER_POINT = 0.06;\n\n/**\n * The share of a blow the armor VALUE takes off: `0.06·A / (1 + 0.06·A)`, the percent rounded up\n * (10 armor — 38 %, 20 — 55 %, never 100 %). Negative armor takes nothing off — see `armorFactor`.\n */\nexport function armorReduction(armor: number): number {\n if (!(armor > 0)) return 0;\n const share = (ARMOR_PER_POINT * armor) / (1 + ARMOR_PER_POINT * armor);\n return Math.ceil(share * 100 - 1e-9) / 100;\n}\n\n/**\n * What a blow is multiplied by for the armor value: below 1 for armor, above 1 for armor broken into\n * the negative (WC3: `2 − 0.94^−A`, so −10 armor takes ~46 % more, and never twice as much).\n */\nexport function armorFactor(armor: number): number {\n if (armor >= 0) return 1 - armorReduction(armor);\n return 2 - Math.pow(1 - ARMOR_PER_POINT, -armor);\n}\n\n/** The table's word on this attack against this armor. */\nexport function typeFactor(attack: AttackType, armor: ArmorType): number {\n return DAMAGE_TABLE[attack][armor];\n}\n\n/**\n * A blow of `damage` of `attack` type on a target in `armorType` with `armorValue` points: the table,\n * then the armor value. Spells go through armor of any value (WC3 — the armor value is physical);\n * only the table's type bites them.\n */\nexport function dealt(\n damage: number,\n attack: AttackType,\n armorType: ArmorType,\n armorValue: number,\n): number {\n const value = attack === \"spells\" ? 1 : armorFactor(armorValue);\n return Math.max(0, damage * typeFactor(attack, armorType) * value);\n}\n\n/** How a type of attack fares against a type of armor, as the screens colour it. */\nexport type Effect = \"strong\" | \"even\" | \"weak\";\n\nexport function effectOf(attack: AttackType, armor: ArmorType): Effect {\n const f = typeFactor(attack, armor);\n return f > 1.001 ? \"strong\" : f < 0.999 ? \"weak\" : \"even\";\n}\n\n// ── What a fighter's gear says ───────────────────────────────────────────────────────────────────\n\n/** Weapons that crush: maces and hammers — siege damage (they break walls and shields). */\nconst SIEGE_WEAPONS = new Set([\"weapon/mace\", \"weapon/warhammer\"]);\n/** Weapons that cast: staves — magic damage. */\nconst MAGIC_WEAPONS = new Set([\n \"weapon/wizard_staff\",\n \"weapon/druid_staff\",\n \"weapon/28\",\n]);\n/** Melee weapons that pierce: the spear. */\nconst PIERCING_MELEE = new Set([\"weapon/spear\"]);\n\n/**\n * The attack type of what the hand holds (when the item does not name one itself): a bow, a crossbow,\n * a gun, a spear — pierce; a mace, a hammer — siege; a staff — magic; a sword, an axe, a dagger, bare\n * hands — normal.\n */\nexport function attackTypeOf(weapon: string | null): AttackType {\n if (!weapon) return \"normal\";\n if (MAGIC_WEAPONS.has(weapon)) return \"magic\";\n if (SIEGE_WEAPONS.has(weapon)) return \"siege\";\n if (PIERCING_MELEE.has(weapon)) return \"pierce\";\n return heroIsMelee(weapon) ? \"normal\" : \"pierce\";\n}\n\n/**\n * The armor type of what the body wears (when the item does not name one itself): plate — heavy (and\n * plate with a tower shield — fortified, a walking wall), chainmail — medium, leather — light, a robe\n * or plain clothes — unarmored.\n */\nexport function armorTypeOf(\n top: string | null,\n offhand: string | null = null,\n): ArmorType {\n const t = top ?? \"\";\n if (/plate/.test(t))\n return offhand === \"offhand/tower_shield\" ? \"fortified\" : \"heavy\";\n if (/chain/.test(t)) return \"medium\";\n if (/leather/.test(t)) return \"light\";\n return \"unarmored\";\n}\n\n/** A type named in an item's CustomData (\"Pierce\", \"pierce\", \"PIERCE\") — or null when it is not one. */\nexport function parseAttackType(value: unknown): AttackType | null {\n const v = String(value ?? \"\").toLowerCase();\n return (ATTACK_TYPES as readonly string[]).includes(v)\n ? (v as AttackType)\n : null;\n}\n\nexport function parseArmorType(value: unknown): ArmorType | null {\n const v = String(value ?? \"\").toLowerCase();\n return (ARMOR_TYPES as readonly string[]).includes(v)\n ? (v as ArmorType)\n : null;\n}\n"
344
+ },
345
+ {
346
+ "path": "game/economy.ts",
347
+ "content": "// What a run costs and pays — only through the platform's own modules, so the server keeps the books:\n//\n// • ENERGY refills by itself (the currency's Recharge). A run starts by converting energy into one\n// RUN_TICKET (Currency → Conversion, Manual rate: 5 energy → 1 ticket). A ticket left over from an\n// abandoned run is used first — energy is not taken twice.\n// • The run ends by opening ONE reward chest paid with that ticket (a Lootbox priced in RUN_TICKET):\n// lost — the small chest, the boss beaten — the big one, beaten with a road chest found — the\n// treasure. The client names the tier, the server never gives more than a chest a ticket: the\n// most a cheater gets is a perfect run every time.\n// • XP never leaves the wallet: the player's level is its balance (playerLevel). Potions drunk in\n// the run and the gems of a camp feast are converted into XP (Conversion to XP) — the way the\n// server takes them.\n//\n// A title without these (an old seed, a bare title) still plays: runs are free and pay nothing.\n\nimport type { RunResult } from \"./battle\";\n\nexport const ENERGY_ID = \"ENERGY\";\nexport const TICKET_ID = \"RUN_TICKET\";\nexport const XP_ID = \"XP\";\nexport const POTION_ID = \"POTION\";\nexport const GEMS_ID = \"GEMS\";\n\nexport type ChestTier = \"defeat\" | \"victory\" | \"treasure\";\n\n/** The reward chests' lootbox ids, by tier. */\nexport const CHEST_IDS: Readonly<Record<ChestTier, string>> = {\n defeat: \"run_chest_defeat\",\n victory: \"run_chest_victory\",\n treasure: \"run_chest_treasure\",\n};\n\n/**\n * The starter chests: the tier's chest plus a sure potion. The player's first {@link FIRST_CHESTS}\n * chests come from them, so a casual start never runs out of potions (the engine has no \"first N\n * openings\" rule, so the client keeps the count in the progress).\n */\nexport const FIRST_CHEST_IDS: Readonly<Record<ChestTier, string>> = {\n defeat: \"run_chest_defeat_first\",\n victory: \"run_chest_victory_first\",\n treasure: \"run_chest_treasure_first\",\n};\nexport const FIRST_CHESTS = 3;\n\n/** Gems a camp asks for both the health and the rage. */\nexport const CAMP_FEAST_GEMS = 20;\n\n/** Potions a run takes along (more slots come later with the player's level). */\nexport const POTION_SLOTS = 3;\n\ninterface ConversionView {\n Enabled?: boolean | null;\n RateMode?: string | null;\n Targets?: Array<{\n TargetCurrencyID?: string;\n Rate?: string | number | null;\n MinAmount?: number | null;\n } | null> | null;\n}\n\nexport interface CurrencySection {\n VirtualCurrencies?: Record<\n string,\n {\n CurrencyID?: string;\n Recharge?: {\n Rate?: number | null;\n Max?: number | null;\n Period?: number | null;\n } | null;\n Conversion?: ConversionView | null;\n } | null\n > | null;\n}\n\nexport interface LootboxSection {\n Definitions?: Record<\n string,\n { PriceOptions?: Record<string, unknown> | null } | null\n > | null;\n}\n\nexport interface RunEconomy {\n /** Energy for one ticket. */\n energyPerTicket: number;\n /** The recharge: +rate every period seconds, up to max. */\n recharge: { rate: number; period: number; max: number } | null;\n /** The chests the title has, by tier. */\n chests: Partial<Record<ChestTier, string>>;\n /** The starter chests the title has, by tier. */\n firstChests: Partial<Record<ChestTier, string>>;\n /** Potions and gems turn into XP at the server (their conversions exist). */\n potionToXp: boolean;\n gemsToXp: boolean;\n}\n\n/** One unit of `from` converts into this much of `to` (0 — no such conversion). */\nfunction rateOf(\n section: CurrencySection | undefined,\n from: string,\n to: string,\n): number {\n const conv = section?.VirtualCurrencies?.[from]?.Conversion;\n if (!conv || conv.Enabled === false) return 0;\n const target = conv.Targets?.find((t) => t?.TargetCurrencyID === to);\n return Number(target?.Rate ?? 0) || 0;\n}\n\n/** What the title's config says about runs — null when energy → ticket is not set up (free runs). */\nexport function pickEconomy(\n currencies: CurrencySection | undefined,\n lootboxes: LootboxSection | undefined,\n): RunEconomy | null {\n const rate = rateOf(currencies, ENERGY_ID, TICKET_ID);\n if (!(rate > 0)) return null;\n const chests: Partial<Record<ChestTier, string>> = {};\n for (const [tier, id] of Object.entries(CHEST_IDS) as Array<\n [ChestTier, string]\n >)\n if (lootboxes?.Definitions?.[id]) chests[tier] = id;\n const firstChests: Partial<Record<ChestTier, string>> = {};\n for (const [tier, id] of Object.entries(FIRST_CHEST_IDS) as Array<\n [ChestTier, string]\n >)\n if (lootboxes?.Definitions?.[id]) firstChests[tier] = id;\n const r = currencies?.VirtualCurrencies?.[ENERGY_ID]?.Recharge;\n return {\n energyPerTicket: Math.max(1, Math.ceil(1 / rate - 1e-9)),\n recharge:\n r && Number(r.Rate) > 0 && Number(r.Period) > 0\n ? {\n rate: Number(r.Rate),\n period: Number(r.Period),\n max: Number(r.Max ?? 0),\n }\n : null,\n chests,\n firstChests,\n potionToXp: rateOf(currencies, POTION_ID, XP_ID) > 0,\n gemsToXp: rateOf(currencies, GEMS_ID, XP_ID) > 0,\n };\n}\n\n/** The chest a run earned. */\nexport function chestTier(result: RunResult): ChestTier {\n if (!result.cleared) return \"defeat\";\n return result.chests > 0 ? \"treasure\" : \"victory\";\n}\n\n/**\n * The chest to open: the earned tier, or the nearest lower one the title has. While the player has\n * opened fewer than {@link FIRST_CHESTS} chests — the tier's starter chest, when the title has it.\n */\nexport function chestFor(\n economy: RunEconomy,\n tier: ChestTier,\n opened = FIRST_CHESTS,\n): string | null {\n const order: ChestTier[] = [\"treasure\", \"victory\", \"defeat\"];\n const starter = opened < FIRST_CHESTS;\n for (const t of order.slice(order.indexOf(tier))) {\n if (starter && economy.firstChests[t]) return economy.firstChests[t]!;\n if (economy.chests[t]) return economy.chests[t]!;\n }\n return null;\n}\n\n/** Seconds until the next energy batch, from the server's recharge state (null — not recharging). */\nexport function nextEnergyIn(\n recharge: RunEconomy[\"recharge\"],\n state:\n { LastRechargeAt?: string; PendingSeconds?: number } | null | undefined,\n balance: number,\n now = Date.now(),\n): number | null {\n if (!recharge || !state?.LastRechargeAt) return null;\n if (recharge.max > 0 && balance >= recharge.max) return null;\n const elapsed =\n (now - Date.parse(state.LastRechargeAt)) / 1000 +\n Number(state.PendingSeconds ?? 0);\n const left = recharge.period - (elapsed % recharge.period);\n return Math.max(0, Math.ceil(left));\n}\n\n// ── The player's level ───────────────────────────────────────────────────────────────────────\n\n/** XP for each level: 60 for the first, growing as the level to the power 1.55. */\nexport function xpFor(level: number): number {\n return Math.round(60 * Math.pow(Math.max(1, level), 1.55));\n}\n\n/** The level an XP balance makes (from 1), with how far into the next one it is. */\nexport function playerLevel(xp: number): {\n level: number;\n into: number;\n need: number;\n} {\n let level = 1;\n let left = Math.max(0, Math.floor(xp));\n while (left >= xpFor(level) && level < 999) {\n left -= xpFor(level);\n level++;\n }\n return { level, into: left, need: xpFor(level) };\n}\n"
348
+ },
349
+ {
350
+ "path": "game/enemyLooks.ts",
351
+ "content": "// Enemies are people like the hero, on the same rig: a troop (units.ts) is dressed in exactly the gear\n// that gives its types — plate for heavy armor, a robe for none, a bow for pierce, a hammer for siege —\n// so the player reads the Warcraft table off the picture. The troop's id on the road picks its face,\n// skin and hair, so a wave is a varied crowd and one enemy looks the same on every frame.\n\n// engine-free parts of the character system only (its index brings the Phaser renderer along)\nimport { type CharacterLook } from \"../character/core/look\";\nimport { seedOf, seededRandom } from \"../character/core/random\";\nimport { VARIETY } from \"../character/looks/classLooks\";\nimport type { TroopDef } from \"./units\";\n\nconst HAIR = [\"hair/2\", \"hair/3\", \"hair/5\", \"hair/10\", \"hair/13\"];\nconst BEARD = [null, null, \"beard/4\", \"beard/10\"];\nconst EYES = [\"eyes/1\", \"eyes/10\"];\n\nexport function enemyLook(troop: TroopDef, monsterId: number): CharacterLook {\n const rng = seededRandom(seedOf(`${troop.id}:${monsterId}`));\n const pick = <T>(list: readonly T[]): T =>\n list[Math.floor(rng() * list.length)]!;\n const hair = pick(VARIETY.hairColor);\n return {\n v: 1,\n parts: {\n Skin: \"skin/1\",\n Eyes: pick(EYES),\n Brows: \"brows/1\",\n Mouth: \"mouth/1\",\n Hair: pick(HAIR),\n Beard: troop.boss ? \"beard/4\" : pick(BEARD),\n Glasses: null,\n Pants: \"pants/26\",\n ...troop.gear,\n },\n colors: {\n Skin: pick(VARIETY.skin),\n Hair: hair,\n Beard: hair,\n Brows: hair,\n },\n };\n}\n"
352
+ },
353
+ {
354
+ "path": "game/format.ts",
355
+ "content": "// Idle-game numbers: they outgrow \"1.2M\" within an hour, so after the usual K/M/B/T come the\n// two-letter units of the genre — aa, ab, … az, ba, … (Legend Slime's \"1.36J\" is the same idea).\n\nconst SMALL = [\"\", \"K\", \"M\", \"B\", \"T\"];\n\n/** 950 → \"950\", 12_300 → \"12.3K\", 4.5e15 → \"4.50aa\". Never longer than ~6 characters. */\nexport function formatBig(value: number): string {\n if (!Number.isFinite(value)) return \"∞\";\n const sign = value < 0 ? \"-\" : \"\";\n let n = Math.abs(value);\n if (n < 1000) return sign + (Number.isInteger(n) ? String(n) : trim(n, 1));\n let unit = 0;\n while (n >= 1000) {\n n /= 1000;\n unit++;\n }\n return sign + trim(n, n < 10 ? 2 : n < 100 ? 1 : 0) + unitName(unit);\n}\n\nfunction unitName(unit: number): string {\n const small = SMALL[unit];\n if (small !== undefined) return small;\n const k = unit - SMALL.length;\n const a = \"a\".charCodeAt(0);\n return (\n String.fromCharCode(a + (Math.floor(k / 26) % 26)) +\n String.fromCharCode(a + (k % 26))\n );\n}\n\nfunction trim(n: number, digits: number): string {\n return n.toFixed(digits).replace(/\\.?0+$/, \"\");\n}\n\n/** A stat value as the Enhance list shows it: chances as percent, small values with decimals. */\nexport function formatStat(value: number, percent: boolean): string {\n if (percent) return `${trim(value * 100, 1)}%`;\n if (Math.abs(value) < 100 && !Number.isInteger(value)) return trim(value, 2);\n return formatBig(Math.round(value));\n}\n\n/** 3725 s → \"1h 2m\", 95 s → \"1m 35s\". */\nexport function formatDuration(seconds: number): string {\n const s = Math.max(0, Math.floor(seconds));\n const h = Math.floor(s / 3600);\n const m = Math.floor((s % 3600) / 60);\n if (h > 0) return `${h}h ${m}m`;\n if (m > 0) return `${m}m ${s % 60}s`;\n return `${s}s`;\n}\n"
356
+ },
357
+ {
358
+ "path": "game/guide.ts",
359
+ "content": "// Which quest the strip over the Enhance list shows (Legend Slime's \"Claim Rewards: Enhance HP\").\n// A reward waiting comes first; then the next step of the guide chain; then any open quest. The\n// quests system draws the full list — this is one line of it, kept in the player's eye.\n\nexport const GUIDE_CYCLE = \"guide\";\n\nexport interface QuestDefView {\n QuestID?: string;\n Identity?: { DisplayName?: string | null } | null;\n Linking?: {\n CycleIDs?: string[] | null;\n RequiredQuestIDs?: string[] | null;\n } | null;\n Reward?: { Grant?: unknown } | null;\n Objectives?: Record<string, { TargetValue?: number | null } | null> | null;\n}\n\nexport interface QuestConfigView {\n Quests?: Record<string, QuestDefView | null> | null;\n}\n\ninterface ProgressView {\n Status?: string | null;\n Objectives?: Record<string, { CurrentValue?: number | null } | null> | null;\n}\n\nexport interface QuestStateView {\n Cycles?: Record<\n string,\n { Quests?: Record<string, ProgressView | null> | null } | null\n > | null;\n PermanentQuests?: Record<string, ProgressView | null> | null;\n}\n\nexport interface GuideView {\n questID: string;\n cycleID: string | undefined;\n name: string;\n current: number;\n target: number;\n claimable: boolean;\n grant: unknown;\n}\n\nexport function pickGuide(\n section: QuestConfigView | undefined,\n state: QuestStateView | undefined,\n): GuideView | null {\n const progressOf = (id: string, cycle: string | undefined) =>\n (cycle ? state?.Cycles?.[cycle]?.Quests?.[id] : undefined) ??\n state?.PermanentQuests?.[id] ??\n undefined;\n const claimed = (id: string) => {\n const q = section?.Quests?.[id];\n return progressOf(id, q?.Linking?.CycleIDs?.[0])?.Status === \"Claimed\";\n };\n\n const views: GuideView[] = [];\n for (const [key, q] of Object.entries(section?.Quests ?? {})) {\n if (!q) continue;\n const id = q.QuestID ?? key;\n const cycleID = q.Linking?.CycleIDs?.[0] ?? undefined;\n const p = progressOf(id, cycleID);\n const status = p?.Status ?? \"Active\";\n if (status === \"Claimed\" || status === \"Expired\") continue;\n if (!(q.Linking?.RequiredQuestIDs ?? []).every(claimed)) continue;\n const [objID, obj] = Object.entries(q.Objectives ?? {})[0] ?? [];\n const target = Math.max(1, Number(obj?.TargetValue ?? 1));\n const current = Math.min(\n target,\n Number(objID ? (p?.Objectives?.[objID]?.CurrentValue ?? 0) : 0),\n );\n views.push({\n questID: id,\n cycleID,\n name: q.Identity?.DisplayName ?? id,\n current,\n target,\n claimable: status === \"Completed\",\n grant: q.Reward?.Grant,\n });\n }\n return (\n views.find((g) => g.claimable) ??\n views.find((g) => g.cycleID === GUIDE_CYCLE) ??\n views[0] ??\n null\n );\n}\n"
360
+ },
361
+ {
362
+ "path": "game/heroGear.ts",
363
+ "content": "// What the hero fights with, read off what it wears — the same look the scene draws (character/looks):\n// the weapon in its hand gives the attack type, its top the armor type, a shield in the other hand the\n// blue pool. An item may name its types itself (CustomData \"AttackType\" / \"ArmorType\", e.g. a\n// legendary sword that deals chaos) — the item wins over the picture.\n\n// engine-free parts of the character system only (its index brings the Phaser renderer along)\nimport { DEFAULT_LOOK_CHAIN, resolveLook } from \"../character/looks/resolve\";\nimport type { LookSource, LookSubject } from \"../character/looks/sources\";\nimport { heroIsMelee } from \"../character/rigs/hero\";\nimport { TWO_HANDED } from \"../character/rigs/hero/ranged\";\nimport {\n armorTypeOf,\n attackTypeOf,\n parseArmorType,\n parseAttackType,\n type ArmorType,\n type AttackType,\n} from \"./damage\";\n\nexport interface HeroGear {\n attack: AttackType;\n armor: ArmorType;\n shield: boolean;\n /** The part in the weapon hand (null — bare hands). */\n weapon: string | null;\n melee: boolean;\n}\n\nexport interface GearItemView {\n CustomData?: Record<string, string> | null;\n}\n\nexport function heroGear(\n subject: LookSubject,\n items: ReadonlyMap<string, GearItemView | undefined>,\n chain: readonly LookSource[] = DEFAULT_LOOK_CHAIN,\n): HeroGear {\n const parts = resolveLook(subject, chain).parts ?? {};\n const weapon = parts[\"Weapon\"] ?? null;\n const offhand =\n weapon && TWO_HANDED.has(weapon) ? null : (parts[\"Offhand\"] ?? null);\n let attack = attackTypeOf(weapon);\n let armor = armorTypeOf(parts[\"Top\"] ?? null, offhand);\n for (const itemID of Object.values(subject.equipment)) {\n const data = items.get(itemID)?.CustomData;\n attack = parseAttackType(data?.[\"AttackType\"]) ?? attack;\n armor = parseArmorType(data?.[\"ArmorType\"]) ?? armor;\n }\n return {\n attack,\n armor,\n shield: !!offhand,\n weapon,\n melee: heroIsMelee(weapon),\n };\n}\n"
364
+ },
365
+ {
366
+ "path": "game/HeroRunController.ts",
367
+ "content": "import Phaser from \"phaser\";\nimport { BattleScene } from \"../phaser/BattleScene\";\nimport type { RunSession } from \"./session\";\n\n/**\n * Owns the Phaser game that draws the session's fight. Created on mount (Phaser needs the host\n * element), destroyed with the mode — the session (and with it the fight and the progress) lives on.\n */\nexport class HeroRunController {\n private readonly game: Phaser.Game;\n private readonly sizeWatch: ResizeObserver | null;\n\n constructor(\n parent: HTMLElement,\n readonly session: RunSession,\n ) {\n this.game = new Phaser.Game({\n type: Phaser.AUTO,\n parent,\n width: parent.clientWidth || 480,\n height: parent.clientHeight || 800,\n backgroundColor: \"#7ec8ff\",\n scene: new BattleScene(session),\n scale: {\n // The canvas fills the full-bleed host; the scene draws the field into its top part and the\n // module's panel covers the rest (layout.ts).\n mode: Phaser.Scale.RESIZE,\n },\n });\n\n // Makes the game observable in the AI Coder's live preview, which reads this exact global.\n //\n // Phaser sets it itself — but only in its debug build (`Game.boot` guards it behind\n // WEBGL_DEBUG), and the release build shipped by npm has that branch stripped. So a Phaser game\n // is invisible to any outside observer unless it hands itself over, which is this one line.\n // Verified live: without it the preview reports no engine; with it, scenes, object counts,\n // camera and loop fps all come through.\n (globalThis as unknown as Record<string, unknown>).PHASER_GAME = this.game;\n\n // The canvas follows its host, not only the window: the lobby lays its header and tabs around the\n // game after it mounts (and Phaser's own first measure can come before that), and a canvas left\n // at another size overflows the frame — which then scrolls, the road sliding up under the HUD.\n const fit = () => {\n const w = parent.clientWidth;\n const h = parent.clientHeight;\n if (\n w > 0 &&\n h > 0 &&\n (w !== this.game.scale.width || h !== this.game.scale.height)\n )\n this.game.scale.resize(w, h);\n };\n this.sizeWatch =\n typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(fit);\n this.sizeWatch?.observe(parent);\n this.game.scale.on(\"resize\", () => queueMicrotask(fit));\n this.game.events.once(\"ready\", fit);\n }\n\n /** Whether the Phaser loop is running (the agent's \"am I even running\" flag). */\n get running(): boolean {\n return this.game.loop.running;\n }\n\n /** Pause/resume the Phaser RAF loop. The host calls this so a suspended mode stops ticking\n * (the Mode Router invariant: only the active mode runs) — and the fight with it. */\n setRunning(running: boolean): void {\n if (running) this.game.loop.wake();\n else this.game.loop.sleep();\n }\n\n /**\n * Один кадр игры для платформенной кнопки «поделиться снимком».\n *\n * ⚠ Через `renderer.snapshot`, а НЕ `canvas.toBlob`. У WebGL-рендерера Phaser (как и у любого\n * другого) буфер отрисовки очищен к моменту, когда до канвы доберётся посторонний код, и чтение\n * снаружи отдаёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись — проверено живьём на стенде,\n * ровно так этот модуль и снимался, пока снимал его общий запасной путь хоста. `snapshot`\n * встроен в Phaser именно для этого: он забирает пиксели сразу после следующей отрисовки.\n *\n * Поэтому же сначала `wake()`: у остановленного цикла (режим только что был скрыт) следующей\n * отрисовки не случится вовсе, и обещание висело бы до таймаута.\n */\n capture(): Promise<Blob | null> {\n return new Promise<Blob | null>((resolve) => {\n let settled = false;\n const finish = (value: Blob | null) => {\n if (settled) return;\n settled = true;\n resolve(value);\n };\n\n // Кадр не пришёл (контекст потерян, игра уничтожена между вызовом и отрисовкой) — отвечаем\n // «нет», а не висим: у вызывающего за нами стоит следующий способ снять игру.\n setTimeout(() => finish(null), 1500);\n\n try {\n this.game.loop.wake();\n this.game.renderer.snapshot((result) => {\n if (!(result instanceof HTMLImageElement)) return finish(null);\n const canvas = document.createElement(\"canvas\");\n canvas.width = result.naturalWidth || result.width;\n canvas.height = result.naturalHeight || result.height;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return finish(null);\n ctx.drawImage(result, 0, 0);\n canvas.toBlob(finish, \"image/png\");\n });\n } catch {\n finish(null);\n }\n });\n }\n\n destroy(): void {\n this.sizeWatch?.disconnect();\n this.game.destroy(true);\n // Drop the debug global with the game itself: the Mode Router destroys a suspended mode, and a\n // stale reference here would show an observer a game that no longer exists.\n const globals = globalThis as unknown as Record<string, unknown>;\n if (globals[\"PHASER_GAME\"] === this.game) delete globals[\"PHASER_GAME\"];\n }\n}\n"
368
+ },
369
+ {
370
+ "path": "game/heroStats.ts",
371
+ "content": "// The fighting hero's numbers, from the title's Character config and the player's server state.\n//\n// ⚠ A MIRROR of the server's `PvPBattleEngine.CalculateStats` (iDos_Games_Engine, Match/Services) —\n// the same three layers in the same order: (1) the stat's ValueCurve from level 0 and its RankCurve\n// from rank 1, (2) gear flat bonuses scaled by the instance level, (3) percent bonuses. Health and\n// damage also take the hero's RankStatCurve and \"AllMight\"; attack speed and chances do not. The\n// Heroes screen (`modules/character/src/model.ts`) shows the same numbers — modules never import each\n// other, so this is a copy kept to the server, not to that file.\n//\n// The server never sees this fight: stages are the game's own. What it does see is the hero's Power,\n// which is what the gold income is paid on — so a stronger hero here is a richer one there.\n\nimport {\n curveMultiplier,\n evaluateCurve,\n roundAmount,\n type ScalarCurveSpec,\n} from \"@idosgames/core\";\n\n// ── Config / state views (only the fields read here) ─────────────────────────────────────────\n\ninterface Binding {\n PresetID?: string | null;\n Remove?: string[] | null;\n}\n\ntype PriceOptions =\n | Record<\n string,\n {\n Cost?: {\n Standard?: {\n Entries?: Array<{\n Type?: string | null;\n CurrencyID?: string | null;\n Amount?: number | null;\n } | null> | null;\n } | null;\n } | null;\n } | null\n >\n | null\n | undefined;\n\nexport interface StatDefView {\n StatID?: string;\n DisplayName?: string | null;\n MaxLevel?: number | null;\n BaseStatValue?: number | null;\n ValueCurve?: ScalarCurveSpec | null;\n RankCurve?: ScalarCurveSpec | null;\n CostCurve?: ScalarCurveSpec | null;\n PriceOptions?: PriceOptions;\n Requirements?: Array<{\n RequiredStatID?: string;\n RequiredLevel?: number;\n } | null> | null;\n}\n\nexport interface CharacterDefView {\n CharacterID?: string;\n Identity?: {\n DisplayName?: string | null;\n SortOrder?: number | null;\n } | null;\n Classification?: { ClassID?: string | null; RarityID?: string | null } | null;\n Unlock?: { UnlockedByDefault?: boolean | null } | null;\n Stats?: Record<string, StatDefView | null> | null;\n RankStatCurve?: ScalarCurveSpec | null;\n StatMaxLevelCurve?: ScalarCurveSpec | null;\n Presets?: { Stats?: Binding | null } | null;\n}\n\nexport interface CharacterSection {\n Definitions?: Record<string, CharacterDefView | null> | null;\n Presets?: {\n Stats?: Record<\n string,\n { Stats?: Record<string, StatDefView | null> | null } | null\n > | null;\n } | null;\n}\n\nexport interface HeroModelView {\n CharacterID?: string;\n Level?: number | null;\n Power?: number | null;\n StatLevels?: Record<string, number> | null;\n Equipment?: Record<\n string,\n {\n ItemID?: string | null;\n CatalogID?: string | null;\n ItemInstanceID?: string | null;\n Level?: number | null;\n } | null\n > | null;\n}\n\nexport interface GearDefView {\n Stats?: {\n FlatBonuses?: Record<string, number> | null;\n PercentBonuses?: Record<string, number> | null;\n } | null;\n Upgrade?: {\n FlatBonusCurve?: ScalarCurveSpec | null;\n PercentBonusCurve?: ScalarCurveSpec | null;\n } | null;\n}\n\n// ── Combat roles ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Which stat plays which part in the fight. The first ids are the engine's own (DefaultData — the\n * only ones the server's Power counts); the rest let a title with its own names still fight.\n */\nexport const ROLE_STATS = {\n health: [\"Health\", \"HP\", \"MaxHp\"],\n damage: [\"Damage\", \"Attack\", \"ATK\", \"Magic\"],\n armor: [\"Armor\", \"Defense\", \"DEF\"],\n attackSpeed: [\"AttackSpeed\"],\n critChance: [\"CritChance\", \"Crit\"],\n critDamage: [\"CritDamage\"],\n dodge: [\"Speed\", \"Dodge\"],\n regen: [\"HpRegen\", \"HealthRegen\", \"Regen\"],\n multiShot: [\"MultiShot\", \"TripleShot\"],\n allMight: [\"AllMight\"],\n} as const;\n\nexport type Role = keyof typeof ROLE_STATS;\n\n/** Chances and the crit multiplier are shown in percent. */\nexport const PERCENT_ROLES: ReadonlySet<Role> = new Set([\n \"critChance\",\n \"critDamage\",\n \"dodge\",\n \"multiShot\",\n]);\n\n/** The server's clamps (MatchCombatSettings defaults). */\nexport const MAX_CRIT_CHANCE = 0.6;\nexport const MAX_DODGE_CHANCE = 0.4;\nexport const DEFAULT_CRIT_MULTIPLIER = 1.5;\nexport const MAX_MULTI_SHOT = 1;\n\nexport interface FighterStats {\n maxHp: number;\n damage: number;\n armor: number;\n /** Attacks per second. */\n attackSpeed: number;\n critChance: number;\n critMultiplier: number;\n dodge: number;\n /** HP restored per second. */\n regen: number;\n /** Chance of one extra shot at the next enemy. */\n multiShot: number;\n}\n\n// ── Stats of a hero ──────────────────────────────────────────────────────────────────────────\n\nexport interface StatInfo {\n id: string;\n name: string;\n role: Role | null;\n base: number;\n maxLevel: number;\n valueCurve?: ScalarCurveSpec | null;\n rankCurve?: ScalarCurveSpec | null;\n costCurve?: ScalarCurveSpec | null;\n /** First way to pay (\"default cost\"), per level 1. */\n price: { currencyID: string; amount: number } | null;\n requirements: Array<{ statID: string; level: number }>;\n}\n\nexport function roleOf(statID: string): Role | null {\n for (const [role, ids] of Object.entries(ROLE_STATS))\n if ((ids as readonly string[]).includes(statID)) return role as Role;\n return null;\n}\n\n/** Preset stats (Presets.Stats) under the inline ones; Remove drops keys — the server's merge. */\nexport function resolveStats(\n section: CharacterSection | undefined,\n def: CharacterDefView | undefined,\n): StatInfo[] {\n if (!def) return [];\n const binding = def.Presets?.Stats;\n const preset = binding?.PresetID\n ? section?.Presets?.Stats?.[binding.PresetID]?.Stats\n : undefined;\n const merged: Record<string, StatDefView> = {};\n for (const [k, s] of Object.entries(preset ?? {})) if (s) merged[k] = s;\n for (const [k, s] of Object.entries(def.Stats ?? {})) if (s) merged[k] = s;\n for (const k of binding?.Remove ?? []) delete merged[k];\n return Object.entries(merged).map(([key, s]) => {\n const id = s.StatID ?? key;\n const first = Object.values(s.PriceOptions ?? {}).find((o) => o);\n const entry = first?.Cost?.Standard?.Entries?.find(\n (e) => e?.CurrencyID && Number(e.Amount ?? 0) > 0,\n );\n return {\n id,\n name: s.DisplayName || id,\n role: roleOf(id),\n base: Number(s.BaseStatValue ?? 0),\n maxLevel: Number(s.MaxLevel ?? 0),\n valueCurve: s.ValueCurve,\n rankCurve: s.RankCurve,\n costCurve: s.CostCurve,\n price: entry\n ? { currencyID: entry.CurrencyID!, amount: Number(entry.Amount) }\n : null,\n requirements: (s.Requirements ?? [])\n .filter((r) => r?.RequiredStatID)\n .map((r) => ({\n statID: r!.RequiredStatID!,\n level: Number(r!.RequiredLevel ?? 0),\n })),\n };\n });\n}\n\n/** Layer 1 of one stat: ValueCurve from level 0, then the stat's own RankCurve from rank 1. */\nexport function levelValue(\n stat: StatInfo,\n level: number,\n rank: number,\n): number {\n const own = evaluateCurve(stat.valueCurve, stat.base, Math.max(0, level), 0);\n return evaluateCurve(stat.rankCurve, own, Math.max(1, rank), 1);\n}\n\n/** The stat's cap at this rank: MaxLevel through the hero's StatMaxLevelCurve (0 = no cap). */\nexport function statCap(\n stat: StatInfo,\n rank: number,\n def: CharacterDefView | undefined,\n): number {\n if (stat.maxLevel <= 0) return 0;\n return Math.ceil(\n evaluateCurve(def?.StatMaxLevelCurve, stat.maxLevel, Math.max(1, rank), 1),\n );\n}\n\n/** What raising a stat TO `nextLevel` costs — price × CostCurve(target level), rounded up once. */\nexport function statCost(stat: StatInfo, nextLevel: number): number {\n if (!stat.price) return 0;\n return roundAmount(\n stat.price.amount *\n curveMultiplier(stat.costCurve, Math.max(1, nextLevel), 1),\n );\n}\n\n/**\n * How many levels from `level` the balance buys (at most `limit`, never past the cap), and what they\n * cost together — what \"×10\" and \"MAX\" send in one call.\n */\nexport function affordableLevels(\n stat: StatInfo,\n level: number,\n cap: number,\n balance: number,\n limit: number,\n): { levels: number; cost: number } {\n let levels = 0;\n let cost = 0;\n while (levels < limit && (cap <= 0 || level + levels < cap)) {\n const next = statCost(stat, level + levels + 1);\n if (next <= 0 || cost + next > balance) break;\n cost += next;\n levels++;\n }\n return { levels, cost };\n}\n\n// ── The fighter ──────────────────────────────────────────────────────────────────────────────\n\nexport interface HeroInput {\n section: CharacterSection | undefined;\n def: CharacterDefView | undefined;\n model: HeroModelView | undefined;\n /** Item definitions by item id. */\n items: Map<string, GearDefView>;\n /** Levels of item instances by instance id (the worn copy's own Level wins when present). */\n instanceLevels?: Record<string, number>;\n}\n\n/**\n * The rank the fight uses: the server's level, but a hero playable by default fights at rank 1 before\n * the server has materialized it (its first action does).\n */\nexport function heroRank(input: HeroInput): number {\n const level = Number(input.model?.Level ?? 0);\n if (level > 0) return level;\n return input.def?.Unlock?.UnlockedByDefault ? 1 : 0;\n}\n\nexport function fighterStats(input: HeroInput): FighterStats {\n const rank = Math.max(1, heroRank(input));\n const stats = resolveStats(input.section, input.def);\n const levels = input.model?.StatLevels ?? {};\n const rankMult = curveMultiplier(input.def?.RankStatCurve, rank, 1);\n\n const byRole = (role: Role): number => {\n for (const id of ROLE_STATS[role]) {\n const stat = stats.find((s) => s.id === id);\n if (stat) return levelValue(stat, Number(levels[stat.id] ?? 0), rank);\n }\n return 0;\n };\n\n const flat: Record<string, number> = {};\n const pct: Record<string, number> = {};\n for (const worn of Object.values(input.model?.Equipment ?? {})) {\n if (!worn?.ItemID) continue;\n const def = input.items.get(worn.ItemID);\n if (!def?.Stats) continue;\n const instLevel =\n worn.Level ??\n (worn.ItemInstanceID\n ? input.instanceLevels?.[worn.ItemInstanceID]\n : undefined);\n const level = Math.max(1, Number(instLevel ?? 1));\n const flatScale = curveMultiplier(def.Upgrade?.FlatBonusCurve, level, 1);\n const pctScale = curveMultiplier(def.Upgrade?.PercentBonusCurve, level, 1);\n for (const [k, n] of Object.entries(def.Stats.FlatBonuses ?? {}))\n flat[k] = (flat[k] ?? 0) + Number(n || 0) * flatScale;\n for (const [k, n] of Object.entries(def.Stats.PercentBonuses ?? {}))\n pct[k] = (pct[k] ?? 0) + Number(n || 0) * pctScale;\n }\n const gear = (table: Record<string, number>, role: Role): number =>\n ROLE_STATS[role].reduce((s, id) => s + (table[id] ?? 0), 0);\n\n const allMight = byRole(\"allMight\") + gear(pct, \"allMight\");\n const global = 1 + allMight;\n\n const critMultiplier =\n byRole(\"critDamage\") + gear(flat, \"critDamage\") + gear(pct, \"critDamage\");\n return {\n maxHp: Math.max(\n 1,\n (byRole(\"health\") * rankMult + gear(flat, \"health\")) * global,\n ),\n damage: (byRole(\"damage\") * rankMult + gear(flat, \"damage\")) * global,\n armor: byRole(\"armor\") * rankMult + gear(flat, \"armor\"),\n attackSpeed: Math.max(\n 0.1,\n (byRole(\"attackSpeed\") + gear(flat, \"attackSpeed\")) *\n (1 + gear(pct, \"attackSpeed\")),\n ),\n critChance: clamp(\n byRole(\"critChance\") + gear(flat, \"critChance\") + gear(pct, \"critChance\"),\n 0,\n MAX_CRIT_CHANCE,\n ),\n critMultiplier:\n critMultiplier > 0 ? critMultiplier : DEFAULT_CRIT_MULTIPLIER,\n dodge: clamp(\n byRole(\"dodge\") + gear(flat, \"dodge\") + gear(pct, \"dodge\"),\n 0,\n MAX_DODGE_CHANCE,\n ),\n regen: Math.max(0, byRole(\"regen\") * rankMult + gear(flat, \"regen\")),\n multiShot: clamp(\n byRole(\"multiShot\") + gear(flat, \"multiShot\") + gear(pct, \"multiShot\"),\n 0,\n MAX_MULTI_SHOT,\n ),\n };\n}\n\n/** The value a stat shows in the Enhance list at a given level (its role's final number). */\nexport function enhanceValue(\n input: HeroInput,\n statID: string,\n level: number,\n): number {\n const role = roleOf(statID);\n const model: HeroModelView = {\n ...input.model,\n StatLevels: { ...(input.model?.StatLevels ?? {}), [statID]: level },\n };\n const field = role ? ROLE_FIELD[role] : null;\n if (!field) {\n const stat = resolveStats(input.section, input.def).find(\n (s) => s.id === statID,\n );\n return stat ? levelValue(stat, level, Math.max(1, heroRank(input))) : 0;\n }\n return fighterStats({ ...input, model })[field];\n}\n\n/** Where a role ends up among the fighter's numbers (AllMight multiplies others — shown as its own value). */\nconst ROLE_FIELD: Record<Role, keyof FighterStats | null> = {\n health: \"maxHp\",\n damage: \"damage\",\n armor: \"armor\",\n attackSpeed: \"attackSpeed\",\n critChance: \"critChance\",\n critDamage: \"critMultiplier\",\n dodge: \"dodge\",\n regen: \"regen\",\n multiShot: \"multiShot\",\n allMight: null,\n};\n\nfunction clamp(n: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, n));\n}\n"
372
+ },
373
+ {
374
+ "path": "game/metrics.ts",
375
+ "content": "// Quests that count the game's own metrics: the game reports them with `client.quest.addQuestProgress`\n// — only to quests that count them from the client, never above their per-call cap.\n\n/** A level of the campaign was cleared (its boss beaten). */\nexport const METRIC_LEVEL_CLEARED = \"level_cleared\";\n/** A run was played to its end (cleared or not). */\nexport const METRIC_RUN_FINISHED = \"run_finished\";\n\nexport interface QuestSection {\n Quests?: Record<\n string,\n {\n Objectives?: Record<\n string,\n {\n MetricID?: string | null;\n Source?: string | null;\n MaxProgressPerCall?: number | null;\n } | null\n > | null;\n } | null\n > | null;\n}\n\n/**\n * Whether some quest counts `metricID` from the client, and the most one call may add (0 = no cap).\n * `null` — nobody counts it: the call is skipped (the server refuses an unknown metric anyway).\n *\n * ⚠ The cap is the SMALLEST one among the objectives: a call above an objective's\n * MaxProgressPerCall is treated as cheating and BANS the player, it is not clamped.\n */\nexport function questMetric(\n section: QuestSection | undefined,\n metricID: string,\n): { maxPerCall: number } | null {\n let found = false;\n let cap = 0;\n for (const q of Object.values(section?.Quests ?? {}))\n for (const o of Object.values(q?.Objectives ?? {})) {\n if (!o || o.MetricID !== metricID) continue;\n if ((o.Source ?? \"ClientApi\") !== \"ClientApi\") continue;\n found = true;\n const max = Number(o.MaxProgressPerCall ?? 0);\n if (max > 0) cap = cap === 0 ? max : Math.min(cap, max);\n }\n return found ? { maxPerCall: cap } : null;\n}\n"
376
+ },
377
+ {
378
+ "path": "game/progress.ts",
379
+ "content": "// The campaign progress saved in the player's private custom data: how far the player has cleared,\n// the tips already shown (each Warcraft type is explained once), the ultimate the player keeps.\n\nimport type { UltId } from \"./battle\";\n\n/** The private custom-data key (\".\" and \"$\" are not allowed in keys). */\nexport const PROGRESS_KEY = \"hero_run_progress\";\n\nexport interface Progress {\n /** The highest level cleared (0 — none); the next one is open. */\n cleared: number;\n /** Tips already shown (\"armor:medium\", \"attack:magic\", …). */\n tips: string[];\n ult: UltId;\n /** Run chests opened — the first ones are the starter chests. */\n chests: number;\n}\n\nexport const NEW_PROGRESS: Progress = {\n cleared: 0,\n tips: [],\n ult: \"meteor\",\n chests: 0,\n};\n\nexport function parseProgress(raw: string | null | undefined): Progress {\n if (!raw) return { ...NEW_PROGRESS, tips: [] };\n try {\n const v = JSON.parse(raw) as Partial<Progress>;\n const cleared = Math.max(0, Math.floor(Number(v.cleared) || 0));\n const tips = Array.isArray(v.tips)\n ? v.tips.filter((t): t is string => typeof t === \"string\")\n : [];\n const ult: UltId = v.ult === \"frenzy\" ? \"frenzy\" : \"meteor\";\n const chests = Math.max(0, Math.floor(Number(v.chests) || 0));\n return { cleared, tips, ult, chests };\n } catch {\n return { ...NEW_PROGRESS, tips: [] };\n }\n}\n\nexport function serializeProgress(p: Progress): string {\n return JSON.stringify({\n cleared: p.cleared,\n tips: p.tips,\n ult: p.ult,\n chests: p.chests,\n });\n}\n"
380
+ },
381
+ {
382
+ "path": "game/random.ts",
383
+ "content": "/** mulberry32 — a small seeded PRNG, so a level's road, a run's cards and a test's crits replay. */\nexport function seededRandom(seed: number): () => number {\n let a = seed >>> 0;\n return () => {\n a = (a + 0x6d2b79f5) >>> 0;\n let t = a;\n t = Math.imul(t ^ (t >>> 15), t | 1);\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n}\n"
384
+ },
385
+ {
386
+ "path": "game/session.ts",
387
+ "content": "// The game's state for the whole session — created once in setup(), outliving the Phaser scene\n// (the Mode Router destroys a hidden mode; the progress and a run in progress must not reset).\n//\n// It owns: which hero fights (the Heroes screen tells us — `character:hero-selected@1`), that hero's\n// numbers (heroStats.ts) and gear types (heroGear.ts), the campaign progress (private custom data),\n// the run — a Battle of one level, idle on the map until started — and what the run costs and pays\n// through the platform (economy.ts: energy → ticket → chest). The hero's numbers are not bought here:\n// they grow from its gear and rank (the Heroes screen) — between runs the build is the gear, in a run\n// the cards.\n\nimport {\n CurrencyType,\n type IDosGamesClient,\n type LootboxOpenResponse,\n} from \"@idosgames/core\";\nimport {\n configSection,\n itemDefinitions,\n type ItemSection,\n} from \"@idosgames/react/ui\";\nimport {\n Battle,\n type BattleEvent,\n type CampChoice,\n type RunResult,\n type UltId,\n} from \"./battle\";\nimport { levelOf, scout, type LevelDef } from \"./campaign\";\nimport {\n CAMP_FEAST_GEMS,\n ENERGY_ID,\n GEMS_ID,\n POTION_ID,\n POTION_SLOTS,\n TICKET_ID,\n XP_ID,\n chestFor,\n chestTier,\n pickEconomy,\n playerLevel,\n type ChestTier,\n type CurrencySection,\n type LootboxSection,\n type RunEconomy,\n} from \"./economy\";\nimport { heroGear, type GearItemView, type HeroGear } from \"./heroGear\";\nimport {\n fighterStats,\n heroRank,\n resolveStats,\n type CharacterDefView,\n type CharacterSection,\n type FighterStats,\n type GearDefView,\n type HeroInput,\n type HeroModelView,\n type StatInfo,\n} from \"./heroStats\";\nimport {\n METRIC_LEVEL_CLEARED,\n METRIC_RUN_FINISHED,\n questMetric,\n type QuestSection,\n} from \"./metrics\";\nimport {\n PROGRESS_KEY,\n parseProgress,\n serializeProgress,\n type Progress,\n} from \"./progress\";\n\n/** How often the HUD redraws while the fight runs. */\nconst HUD_HZ = 8;\nconst TURN_EVENTS = new Set<BattleEvent[\"type\"]>([\n \"levelUp\",\n \"card\",\n \"reached\",\n \"bossStart\",\n \"end\",\n \"heroDown\",\n \"ult\",\n \"potion\",\n]);\n\nexport interface HeroView {\n id: string;\n name: string;\n classID: string;\n rarity: string;\n rank: number;\n power: number;\n def: CharacterDefView;\n input: HeroInput;\n stats: StatInfo[];\n fighter: FighterStats;\n gear: HeroGear;\n}\n\nexport type Notice =\n { kind: \"error\"; text: string } | { kind: \"notEnough\"; currencyID: string };\n\n/** A run that ended: its result and the chest it earned (opened by `claim`). */\nexport interface Ending {\n result: RunResult;\n tier: ChestTier;\n /** The chest opened — its rewards; null until claimed (or when the title has no chests). */\n opened: LootboxOpenResponse | null;\n claiming: boolean;\n}\n\nexport interface SessionCallbacks {\n /** A level was cleared — the module turns it into its event for other modules. */\n onLevelCleared?: (island: number, level: number) => void;\n /** A hero's rank went up (anywhere — the Heroes screen too). */\n onRankUp?: (characterId: string, level: number) => void;\n}\n\ntype Listener = () => void;\n\nexport class RunSession {\n /** The run: idle on the selected level until started; ended until the chest is claimed. */\n battle: Battle;\n /** Is the run going (started and not ended). */\n running = false;\n ending: Ending | null = null;\n /** The level the map has picked (the next open one by default). */\n selected = 1;\n progress: Progress = parseProgress(null);\n /** The selected hero (from the Heroes screen), or null until it says (then the fallback fights). */\n selectedID: string | null = null;\n hero: HeroView | null = null;\n economy: RunEconomy | null = null;\n notice: (Notice & { at: number }) | null = null;\n loaded = false;\n busy = false;\n\n private version = 0;\n private listeners = new Set<Listener>();\n private hudClock = 0;\n /** The last run cleared a new level: the map moves on to the next one. */\n private progressJustMoved = false;\n private metricQueue = new Map<string, { pending: number; busy: boolean }>();\n private offClient: Array<() => void> = [];\n\n constructor(\n private readonly client: IDosGamesClient,\n private readonly callbacks: SessionCallbacks = {},\n ) {\n this.battle = new Battle({ level: levelOf(1) });\n this.offClient.push(\n client.on(\"user:anyUpdated\", () => this.refreshHero()),\n client.on(\"character:levelUpgraded\", (data) =>\n callbacks.onRankUp?.(data.CharacterID, data.NewLevel),\n ),\n );\n this.refreshHero();\n void this.load();\n }\n\n // ── React bridge ───────────────────────────────────────────────────────────────────────────\n\n subscribe = (listener: Listener): (() => void) => {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n };\n\n getVersion = (): number => this.version;\n\n private changed(): void {\n this.version++;\n for (const l of [...this.listeners]) l();\n }\n\n // ── The hero ───────────────────────────────────────────────────────────────────────────────\n\n selectHero(id: string): void {\n if (id === this.selectedID) return;\n this.selectedID = id;\n this.refreshHero();\n }\n\n /** Re-reads the hero from the cache: an upgrade, new gear or another hero changes the fighter. */\n refreshHero(): void {\n const section = configSection<CharacterSection>(this.client, \"Character\");\n const owned = (this.client.data.user.state?.Character?.Characters ??\n {}) as Record<string, HeroModelView | undefined>;\n this.economy = pickEconomy(\n configSection<CurrencySection>(this.client, \"Currency\"),\n configSection<LootboxSection>(this.client, \"Lootbox\"),\n );\n const id = pickHero(section, owned, this.selectedID);\n if (!id) {\n this.hero = null;\n return this.changed();\n }\n const def = section?.Definitions?.[id] ?? {};\n const items = itemDefinitions(\n configSection<ItemSection>(this.client, \"Item\"),\n ) as unknown as Map<string, GearDefView & GearItemView>;\n const instances = (\n this.client.data.user.state?.InventoryV2 as\n | {\n UnstackableItems?: Record<string, { Level?: number | null } | null>;\n }\n | undefined\n )?.UnstackableItems;\n const instanceLevels: Record<string, number> = {};\n for (const [k, inst] of Object.entries(instances ?? {}))\n if (inst?.Level) instanceLevels[k] = inst.Level;\n const input: HeroInput = {\n section,\n def,\n model: owned[id],\n items,\n instanceLevels,\n };\n const fighter = fighterStats(input);\n const equipment: Record<string, string> = {};\n for (const [slot, worn] of Object.entries(owned[id]?.Equipment ?? {}))\n if (worn?.ItemID) equipment[slot] = worn.ItemID;\n const classID = def.Classification?.ClassID ?? \"\";\n this.hero = {\n id,\n name: def.Identity?.DisplayName ?? id,\n classID,\n rarity: def.Classification?.RarityID ?? \"\",\n rank: heroRank(input),\n power: Number(owned[id]?.Power ?? 0),\n def,\n input,\n stats: resolveStats(section, def),\n fighter,\n gear: heroGear({ id, classId: classID, equipment }, items),\n };\n this.battle.setStats(fighter);\n this.changed();\n }\n\n balance(currencyID: string): number {\n const vc = this.client.data.user.state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number } | undefined> | undefined;\n return Number(vc?.[currencyID]?.Amount ?? 0);\n }\n\n /** The energy's recharge state from the server (the countdown to the next batch). */\n rechargeState(): { LastRechargeAt?: string; PendingSeconds?: number } | null {\n const vc = this.client.data.user.state?.InventoryV2?.VirtualCurrencies as\n | Record<\n string,\n | {\n Recharge?: {\n LastRechargeAt?: string;\n PendingSeconds?: number;\n } | null;\n }\n | undefined\n >\n | undefined;\n return vc?.[ENERGY_ID]?.Recharge ?? null;\n }\n\n playerLevel(): ReturnType<typeof playerLevel> {\n return playerLevel(this.balance(XP_ID));\n }\n\n // ── The map ────────────────────────────────────────────────────────────────────────────────\n\n /** The highest level the player may play (the one after the last cleared). */\n openLevel(): number {\n return this.progress.cleared + 1;\n }\n\n level(): LevelDef {\n return this.battle.level;\n }\n\n /** Pick a level on the map (an open one); the road shows it, idle. */\n select(index: number): void {\n if (this.running || this.ending) return;\n const n = Math.max(1, Math.min(this.openLevel(), Math.floor(index)));\n this.selected = n;\n // the road of that level, idle: the hero stands on it as it will run it (its shield's bar too)\n this.battle = new Battle({\n level: levelOf(n),\n stats: this.hero?.fighter,\n shield: this.hero?.gear.shield,\n });\n this.changed();\n }\n\n setUlt(ult: UltId): void {\n if (this.progress.ult === ult) return;\n this.progress = { ...this.progress, ult };\n this.save();\n this.changed();\n }\n\n /** The types the selected level brings that the player has not had explained yet. */\n newTips(): string[] {\n const island = this.level().island;\n return island.introduces\n .map((t) => `${t.kind}:${t.type}`)\n .filter((key) => !this.progress.tips.includes(key));\n }\n\n markTipsSeen(keys: readonly string[]): void {\n const tips = [...new Set([...this.progress.tips, ...keys])];\n if (tips.length === this.progress.tips.length) return;\n this.progress = { ...this.progress, tips };\n this.save();\n this.changed();\n }\n\n scouting(): ReturnType<typeof scout> {\n return scout(this.level());\n }\n\n /** Energy a run costs now (0 — a ticket is waiting, or the title has free runs). */\n runCost(): number {\n if (!this.economy) return 0;\n return this.balance(TICKET_ID) >= 1 ? 0 : this.economy.energyPerTicket;\n }\n\n // ── The run ────────────────────────────────────────────────────────────────────────────────\n\n /** Start the selected level: a ticket (from energy if none is waiting), then the road. */\n async start(): Promise<boolean> {\n if (this.running || this.ending || this.busy || !this.hero) return false;\n const economy = this.economy;\n if (economy && this.balance(TICKET_ID) < 1) {\n if (this.balance(ENERGY_ID) < economy.energyPerTicket) {\n this.notify({ kind: \"notEnough\", currencyID: ENERGY_ID });\n return false;\n }\n this.busy = true;\n this.changed();\n const res = await this.client.currency.convert(\n CurrencyType.Virtual,\n ENERGY_ID,\n CurrencyType.Virtual,\n TICKET_ID,\n economy.energyPerTicket,\n );\n this.busy = false;\n if (!res.ok) {\n this.notify({ kind: \"error\", text: String(res.error ?? \"\") });\n return false;\n }\n }\n const hero = this.hero;\n this.battle = new Battle({\n level: levelOf(this.selected),\n stats: hero.fighter,\n heroAttack: hero.gear.attack,\n heroArmor: hero.gear.armor,\n shield: hero.gear.shield,\n ult: this.progress.ult,\n // a title without the run economy plays free — with full potion slots\n potions: this.economy\n ? Math.min(POTION_SLOTS, this.balance(POTION_ID))\n : POTION_SLOTS,\n seed: Date.now() ^ (this.selected * 2654435761),\n });\n this.running = true;\n this.changed();\n return true;\n }\n\n takeCard(id: string): boolean {\n const ok = this.battle.takeCard(id);\n if (ok) this.changed();\n return ok;\n }\n\n /** \"Refresh\": three other cards for the same choice (once a run). */\n reroll(): boolean {\n const ok = this.battle.reroll();\n if (ok) this.changed();\n return ok;\n }\n\n /** The camp's choice; \"both\" asks for the gems first (converted into XP at the server). */\n async chooseCamp(choice: CampChoice): Promise<boolean> {\n if (this.battle.pending?.kind !== \"camp\" || this.busy) return false;\n if (choice === \"both\") {\n if (!this.economy?.gemsToXp) return false;\n if (this.balance(GEMS_ID) < CAMP_FEAST_GEMS) {\n this.notify({ kind: \"notEnough\", currencyID: GEMS_ID });\n return false;\n }\n this.busy = true;\n this.changed();\n const res = await this.client.currency.convert(\n CurrencyType.Virtual,\n GEMS_ID,\n CurrencyType.Virtual,\n XP_ID,\n CAMP_FEAST_GEMS,\n );\n this.busy = false;\n if (!res.ok) {\n this.notify({ kind: \"error\", text: String(res.error ?? \"\") });\n this.changed();\n return false;\n }\n }\n const ok = this.battle.chooseCamp(choice);\n this.changed();\n return ok;\n }\n\n /** Leave the run — it ends as lost (the small chest is still earned). */\n abandon(): void {\n if (!this.running) return;\n this.battle.abandon();\n this.ended(this.battle.result!);\n }\n\n /** Open the run's chest with its ticket, then back to the map. */\n async claim(): Promise<LootboxOpenResponse | null> {\n const ending = this.ending;\n if (!ending || ending.claiming) return null;\n const economy = this.economy;\n const chest = economy\n ? chestFor(economy, ending.tier, this.progress.chests)\n : null;\n if (!economy || !chest || this.balance(TICKET_ID) < 1) {\n this.backToMap();\n return null;\n }\n ending.claiming = true;\n this.changed();\n const res = await this.client.lootbox.open(chest, 1);\n ending.claiming = false;\n if (!res.ok) {\n this.notify({ kind: \"error\", text: String(res.error ?? \"\") });\n this.changed();\n return null;\n }\n ending.opened = res.data;\n this.progress = { ...this.progress, chests: this.progress.chests + 1 };\n this.save();\n this.backToMap();\n return res.data;\n }\n\n private backToMap(): void {\n this.ending = null;\n this.running = false;\n this.select(\n Math.min(\n this.openLevel(),\n this.selected + (this.progressJustMoved ? 1 : 0),\n ),\n );\n this.progressJustMoved = false;\n }\n\n private ended(result: RunResult): void {\n this.running = false;\n this.ending = {\n result,\n tier: chestTier(result),\n opened: null,\n claiming: false,\n };\n const level = levelOf(result.level);\n if (result.cleared) {\n this.callbacks.onLevelCleared?.(level.island.index, level.level);\n this.report(METRIC_LEVEL_CLEARED, 1);\n if (result.level > this.progress.cleared) {\n this.progress = { ...this.progress, cleared: result.level };\n this.progressJustMoved = true;\n this.save();\n }\n }\n this.report(METRIC_RUN_FINISHED, 1);\n // the potions drunk leave the wallet at the server (into XP)\n if (result.potionsUsed > 0 && this.economy?.potionToXp)\n void this.client.currency.convert(\n CurrencyType.Virtual,\n POTION_ID,\n CurrencyType.Virtual,\n XP_ID,\n result.potionsUsed,\n );\n this.changed();\n }\n\n // ── The clock ──────────────────────────────────────────────────────────────────────────────\n\n /** One frame of the fight (called by the scene). Returns what happened, for the drawing. */\n tick(dt: number): BattleEvent[] {\n if (!this.running) return [];\n const events = this.battle.tick(dt);\n for (const e of events) if (e.type === \"end\") this.ended(e.result);\n this.hudClock += dt;\n const turn = events.some((e) => TURN_EVENTS.has(e.type));\n if (turn || this.hudClock >= 1 / HUD_HZ) {\n this.hudClock = 0;\n this.changed();\n }\n return events;\n }\n\n activate(): void {}\n\n suspend(): void {}\n\n destroy(): void {\n for (const off of this.offClient) off();\n this.offClient = [];\n this.listeners.clear();\n }\n\n private notify(notice: Notice): void {\n this.notice = { ...notice, at: Date.now() };\n this.changed();\n }\n\n // ── Progress ───────────────────────────────────────────────────────────────────────────────\n\n private async load(): Promise<void> {\n let raw = this.readProgress();\n if (raw === undefined) {\n const res = await this.client.userCustomData.getMyUserCustomData();\n raw = res.ok ? (res.data.Private?.[PROGRESS_KEY]?.Value ?? null) : null;\n }\n this.progress = parseProgress(raw ?? null);\n this.loaded = true;\n this.select(this.openLevel());\n }\n\n /** `undefined` — the login state did not bring the custom data at all (ask once). */\n private readProgress(): string | null | undefined {\n const cd = this.client.data.user.state?.CustomData as\n | { Private?: Record<string, { Value?: string | null } | undefined> }\n | null\n | undefined;\n if (!cd?.Private) return undefined;\n return cd.Private[PROGRESS_KEY]?.Value ?? null;\n }\n\n private save(): void {\n if (!this.loaded) return;\n const value = serializeProgress(this.progress);\n if (value === this.readProgress()) return;\n void this.client.userCustomData.setPrivateData(PROGRESS_KEY, value);\n }\n\n // ── Quest metrics ──────────────────────────────────────────────────────────────────────────\n\n /** Report a metric to the quests that count it — one call at a time, never above their cap. */\n private report(metricID: string, amount: number): void {\n if (!questMetric(this.questSection(), metricID)) return;\n const q = this.metricQueue.get(metricID) ?? { pending: 0, busy: false };\n q.pending += amount;\n this.metricQueue.set(metricID, q);\n void this.pumpMetric(metricID);\n }\n\n private async pumpMetric(metricID: string): Promise<void> {\n const q = this.metricQueue.get(metricID);\n const metric = questMetric(this.questSection(), metricID);\n if (!q || q.busy || q.pending <= 0 || !metric) return;\n const chunk =\n metric.maxPerCall > 0\n ? Math.min(q.pending, metric.maxPerCall)\n : q.pending;\n q.busy = true;\n q.pending -= chunk;\n await this.client.quest.addQuestProgress(metricID, chunk);\n q.busy = false;\n void this.pumpMetric(metricID);\n }\n\n private questSection(): QuestSection | undefined {\n return configSection<QuestSection>(this.client, \"Quest\");\n }\n}\n\n// ── Pure helpers ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * The fighter: the selected hero while it is playable; else the strongest owned hero; else the\n * first hero playable by default (roster order).\n */\nexport function pickHero(\n section: CharacterSection | undefined,\n owned: Record<string, HeroModelView | undefined>,\n selectedID: string | null,\n): string | null {\n const defs = section?.Definitions ?? {};\n const playable = (id: string) =>\n Number(owned[id]?.Level ?? 0) > 0 || !!defs[id]?.Unlock?.UnlockedByDefault;\n if (selectedID && defs[selectedID] && playable(selectedID)) return selectedID;\n const strongest = Object.entries(owned)\n .filter(([id, m]) => defs[id] && Number(m?.Level ?? 0) > 0)\n .sort(([, a], [, b]) => Number(b?.Power ?? 0) - Number(a?.Power ?? 0))[0];\n if (strongest) return strongest[0];\n const byOrder = Object.entries(defs)\n .filter(([id, d]) => d && playable(id))\n .sort(\n ([a, x], [b, y]) =>\n Number(x?.Identity?.SortOrder ?? 0) -\n Number(y?.Identity?.SortOrder ?? 0) || a.localeCompare(b),\n )[0];\n return byOrder?.[0] ?? null;\n}\n"
388
+ },
389
+ {
390
+ "path": "game/sim.ts",
391
+ "content": "// The balance simulator: the real Battle without a screen, and a player who keeps running the\n// campaign on the title's own config — chests rolled from its lootboxes, gear upgraded with its\n// gold, the hero built by the same fighterStats/heroGear as the game. `scripts/balance.ts` prints\n// the report; the tests keep the casual start casual.\n//\n// What it does NOT know: the real time (energy is counted in runs, not hours), daily free gems, the\n// player's taste (a card picker and a spending policy stand in for it). Prices follow the server's\n// rule \"base × curve from the first paid step\" — close enough for a balance read, not a bill.\n\nimport { evaluateCurve, type ScalarCurveSpec } from \"@idosgames/core\";\nimport { Battle, type CampChoice, type RunResult, type UltId } from \"./battle\";\nimport { levelOf, scout } from \"./campaign\";\nimport { typeFactor, type ArmorType, type AttackType } from \"./damage\";\nimport { CARD_BY_ID } from \"./cards\";\nimport {\n CHEST_IDS,\n FIRST_CHESTS,\n FIRST_CHEST_IDS,\n GEMS_ID,\n POTION_ID,\n POTION_SLOTS,\n XP_ID,\n chestTier,\n} from \"./economy\";\nimport { heroGear, type GearItemView, type HeroGear } from \"./heroGear\";\nimport {\n fighterStats,\n type CharacterSection,\n type FighterStats,\n type GearDefView,\n} from \"./heroStats\";\nimport { seededRandom } from \"./random\";\n\n// ── One run ──────────────────────────────────────────────────────────────────────────────────\n\n/** What the hero brings into a run. */\nexport interface SimHero {\n stats: FighterStats;\n gear: HeroGear;\n}\n\n/** Picks one of the offered cards. */\nexport type CardPicker = (\n options: readonly string[],\n battle: Battle,\n random: () => number,\n) => string;\n\nconst RARITY_RANK = { common: 0, rare: 1, legendary: 2 } as const;\n\nexport const PICKERS: Readonly<Record<string, CardPicker>> = {\n /** The first card on the screen — a player who does not read. */\n first: (o) => o[0]!,\n /** Any card. */\n random: (o, _b, r) => o[Math.floor(r() * o.length)]!,\n /** The rarest card (a player who trusts the colour). */\n rarity: (o) =>\n [...o].sort(\n (a, b) =>\n RARITY_RANK[CARD_BY_ID.get(b)!.rarity] -\n RARITY_RANK[CARD_BY_ID.get(a)!.rarity],\n )[0]!,\n};\n\n/** The camp: health when hurt, rage otherwise. */\nexport const campByHealth = (b: Battle): CampChoice =>\n b.heroHp / Math.max(1, b.heroMaxHp()) < 0.6 ? \"heal\" : \"rage\";\n\nexport interface SimRunOptions {\n level: number;\n hero: SimHero;\n potions: number;\n ult?: UltId;\n picker?: CardPicker;\n camp?: (b: Battle) => CampChoice;\n seed: number;\n /** Cards the run starts with (the balance report measures one card this way). */\n cards?: readonly string[];\n}\n\nexport interface SimRunResult extends RunResult {\n /** Health left at the end (share of the max). */\n hpLeft: number;\n /** The run hit the time cap (a very long run — every fight does end), counted as a loss. */\n stalled: boolean;\n /** The longest fight with one segment (a wave, the boss), seconds. */\n longestFight: number;\n}\n\n/** Seconds of game time after which the simulator stops a run (a loss). Not a stalemate: troops never\n * heal and a blow always deals damage, so every fight ends — the cap only keeps the report fast. */\nexport const SIM_MAX_SECONDS = 900;\nconst SIM_STEP = 0.05;\n\n/**\n * One run, as the scene would play it: the same swing, gait and line spacing the screen sets on\n * the battle (a melee hero with the default rig — measured on the stand), potions and the\n * ultimate on auto.\n */\nexport function simulateRun(o: SimRunOptions): SimRunResult {\n const random = seededRandom(o.seed ^ 0x5bd1e995);\n const b = new Battle({\n level: levelOf(o.level),\n stats: o.hero.stats,\n heroAttack: o.hero.gear.attack,\n heroArmor: o.hero.gear.armor,\n shield: o.hero.gear.shield,\n ult: o.ult ?? \"meteor\",\n potions: o.potions,\n seed: o.seed,\n });\n b.setReach(o.hero.gear.melee ? \"melee\" : \"ranged\");\n b.setSwing(0.3, 0.55);\n b.setWalkSpeed(0.2);\n b.setMonsterGap(0.16);\n b.setWaveAhead(0.86);\n if (o.cards?.length) {\n b.setCards(o.cards);\n b.heroHp = b.heroMaxHp();\n b.heroShield = b.heroMaxShield();\n b.potions += b.mods.extraPotions;\n b.cardPotions += b.mods.extraPotions;\n }\n const picker = o.picker ?? PICKERS.random!;\n const camp = o.camp ?? campByHealth;\n let fight = 0;\n let fightSegment = b.segment;\n let longestFight = 0;\n while (!b.result && b.time < SIM_MAX_SECONDS) {\n const p = b.pending;\n if (p?.kind === \"cards\") b.takeCard(picker(p.options, b, random));\n else if (p?.kind === \"camp\") b.chooseCamp(camp(b));\n else {\n if (b.segment !== fightSegment) {\n fightSegment = b.segment;\n fight = 0;\n }\n const fighting = b.monsters.some((m) => b.engaged(m));\n b.tick(SIM_STEP);\n if (fighting) longestFight = Math.max(longestFight, (fight += SIM_STEP));\n }\n }\n const result: RunResult = b.result ?? {\n cleared: false,\n level: o.level,\n kills: b.kills,\n runLevel: b.runLevel,\n chests: b.chests,\n seconds: b.time,\n cards: [...b.cards],\n potionsUsed: b.potionsUsed,\n };\n return {\n ...result,\n hpLeft: b.result ? Math.max(0, b.heroHp) / Math.max(1, b.heroMaxHp()) : 0,\n stalled: !b.result,\n longestFight,\n };\n}\n\n// ── The title's config, as the simulator reads it ────────────────────────────────────────────\n\ninterface Entry {\n Type?: string;\n CurrencyID?: string;\n ItemID?: string;\n Amount?: number;\n}\ninterface Grant {\n Standard?: { Entries?: Entry[] | null } | null;\n}\ninterface PriceView {\n Cost?: Grant | null;\n}\n\nexport interface SimItem extends GearDefView, GearItemView {\n ItemID?: string;\n Equipment?: { AllowedSlotIDs?: string[] | null } | null;\n Upgrade?: GearDefView[\"Upgrade\"] & {\n MaxLevel?: number | null;\n PriceOptions?: Record<string, PriceView> | null;\n CostCurve?: ScalarCurveSpec | null;\n };\n}\n\n/** The sections the simulator reads (a title-seed.json has them all). */\nexport interface SimConfig {\n Character?: CharacterSection & {\n Definitions?: Record<\n string,\n {\n RankLadder?: {\n MaxRank?: number | null;\n FirstPaidRank?: number | null;\n PriceOptions?: Record<string, PriceView> | null;\n CostCurve?: ScalarCurveSpec | null;\n } | null;\n Equipment?: {\n Slots?: Record<string, { MinCharacterLevel?: number | null }> | null;\n } | null;\n }\n >;\n };\n Item?: { Catalogs?: Record<string, { Items?: Record<string, SimItem> }> };\n Lootbox?: {\n Definitions?: Record<\n string,\n {\n PriceOptions?: Record<string, PriceView> | null;\n RewardMultiplier?: {\n Source?: string;\n SourceKey?: string | null;\n Curve?: ScalarCurveSpec | null;\n Anchor?: number | null;\n IncludeRewards?: { Entries?: Entry[] | null } | null;\n } | null;\n RewardSlots?: Array<{\n MinRolls?: number;\n MaxRolls?: number;\n Pool?: Array<{ Reward?: Grant | null; Weight?: number }> | null;\n }> | null;\n PityRules?: Array<{\n RuleID?: string;\n Threshold?: number;\n Pool?: Array<{ Reward?: Grant | null; Weight?: number }> | null;\n }> | null;\n }\n >;\n };\n Store?: {\n StoreOffers?: Record<\n string,\n {\n Pricing?: { Options?: Record<string, PriceView> | null } | null;\n Reward?: { Grant?: Grant | null } | null;\n }\n >;\n };\n}\n\n/** Every item of the config by id. */\nexport function simItems(config: SimConfig): Map<string, SimItem> {\n const out = new Map<string, SimItem>();\n for (const cat of Object.values(config.Item?.Catalogs ?? {}))\n for (const [id, it] of Object.entries(cat.Items ?? {})) out.set(id, it);\n return out;\n}\n\nfunction priceIn(\n options: Record<string, PriceView> | null | undefined,\n currencyID: string,\n): number | null {\n for (const o of Object.values(options ?? {}))\n for (const e of o.Cost?.Standard?.Entries ?? [])\n if (e.CurrencyID === currencyID) return Number(e.Amount ?? 0);\n return null;\n}\n\n/**\n * Roll a lootbox: each slot rolls MinRolls..MaxRolls times over its weighted pool. A\n * `RewardMultiplier` on a currency balance (the only source the simulator knows) scales the rewards\n * it selects, as the server's RewardProgressionResolver does — never below ×1.\n */\nexport function rollLootbox(\n config: SimConfig,\n lootboxID: string,\n random: () => number,\n balance: (currencyID: string) => number = () => 0,\n /** The player's pity counters (\"{lootbox}:{rule}\" → opens since the last trigger). */\n pity?: Map<string, number>,\n): Entry[] {\n const def = config.Lootbox?.Definitions?.[lootboxID];\n const spec = def?.RewardMultiplier;\n let mult = 1;\n if (spec?.Source === \"VirtualCurrencyBalance\" && spec.SourceKey) {\n const m = evaluateCurve(\n spec.Curve,\n 1,\n balance(spec.SourceKey),\n spec.Anchor ?? 0,\n );\n mult = spec.Curve?.MinResult != null ? m : Math.max(1, m);\n }\n const selected = (e: Entry) => {\n const inc = spec?.IncludeRewards?.Entries ?? [];\n return (\n !inc.length ||\n inc.some((x) =>\n e.Type === \"Item\"\n ? x.ItemID === e.ItemID\n : x.CurrencyID === e.CurrencyID,\n )\n );\n };\n const scaled = (e: Entry): Entry =>\n mult !== 1 && selected(e)\n ? { ...e, Amount: Math.round(Number(e.Amount ?? 0) * mult) }\n : e;\n const out: Entry[] = [];\n const roll = (\n pool: ReadonlyArray<{ Reward?: Grant | null; Weight?: number }>,\n ) => {\n const total = pool.reduce((t, x) => t + Math.max(0, x.Weight ?? 0), 0);\n if (total <= 0) return;\n let pick = random() * total;\n for (const x of pool) {\n pick -= Math.max(0, x.Weight ?? 0);\n if (pick < 0) {\n out.push(...(x.Reward?.Standard?.Entries ?? []).map(scaled));\n return;\n }\n }\n };\n for (const slot of def?.RewardSlots ?? []) {\n const min = Number(slot.MinRolls ?? 1);\n const max = Number(slot.MaxRolls ?? min);\n const rolls = min + Math.floor(random() * (max - min + 1));\n for (let i = 0; i < rolls; i++) roll(slot.Pool ?? []);\n }\n // pity: every Threshold-th opening adds a roll of the rule's pool (the server's LootboxHelpers)\n for (const rule of def?.PityRules ?? []) {\n const t = Number(rule.Threshold ?? 0);\n if (!pity || !rule.RuleID || t <= 0) continue;\n const key = `${lootboxID}:${rule.RuleID}`;\n const n = (pity.get(key) ?? 0) + 1;\n if (n >= t) roll(rule.Pool ?? []);\n pity.set(key, n % t);\n }\n return out;\n}\n\n// ── The player across the campaign ───────────────────────────────────────────────────────────\n\nexport interface SimPlayer {\n heroID: string;\n rank: number;\n /** Owned items: id → level (one copy of each is enough to wear it). */\n items: Map<string, number>;\n /** Slot → item id. */\n equipment: Record<string, string>;\n gold: number;\n gems: number;\n potions: number;\n xp: number;\n cleared: number;\n chestsOpened: number;\n pity: Map<string, number>;\n}\n\nexport function newPlayer(heroID = \"Knight\"): SimPlayer {\n // the seed's starting balances (Currency InitialDeposit)\n return {\n heroID,\n rank: 1,\n items: new Map(),\n equipment: {},\n gold: 500,\n gems: 300,\n potions: 3,\n xp: 0,\n cleared: 0,\n chestsOpened: 0,\n pity: new Map(),\n };\n}\n\n/** The hero as the game builds it from this player's state. */\nexport function simHero(config: SimConfig, p: SimPlayer): SimHero {\n const section = config.Character;\n const def = section?.Definitions?.[p.heroID];\n const items = simItems(config);\n const equipment: Record<string, { ItemID: string; Level: number }> = {};\n for (const [slot, id] of Object.entries(p.equipment))\n equipment[slot] = { ItemID: id, Level: p.items.get(id) ?? 1 };\n const stats = fighterStats({\n section,\n def,\n model: { Level: p.rank, StatLevels: {}, Equipment: equipment },\n items,\n });\n const gear = heroGear(\n {\n id: p.heroID,\n classId: def?.Classification?.ClassID ?? \"\",\n equipment: { ...p.equipment },\n },\n items,\n );\n return { stats, gear };\n}\n\n/**\n * How a weapon of `attack` fares on a level, and how hard a body of `armor` is hit there — read off\n * the scouting, as the player does before the run (the boss weighs as much as the whole road).\n */\nfunction levelTypes(level: number): {\n weapon: (attack: AttackType) => number;\n body: (armor: ArmorType) => number;\n} {\n const s = scout(levelOf(level));\n return {\n weapon: (attack) =>\n 0.5 *\n s.armor.reduce((t, a) => t + a.share * typeFactor(attack, a.type), 0) +\n 0.5 * typeFactor(attack, s.boss.armor),\n body: (armor) =>\n 0.5 *\n s.attack.reduce((t, a) => t + a.share * typeFactor(a.type, armor), 0) +\n 0.5 * typeFactor(s.boss.attack, armor),\n };\n}\n\nfunction slotsOf(it: SimItem | undefined): string[] {\n return it?.Equipment?.AllowedSlotIDs ?? [];\n}\n\n/**\n * How good a whole kit is on a level: damage per second against the road's armor (the Warcraft\n * table, crits counted) times the health it fights with against the road's attacks. Without a\n * level — plain numbers.\n */\nfunction kitScore(config: SimConfig, p: SimPlayer, level?: number): number {\n const { stats, gear } = simHero(config, p);\n const types = level ? levelTypes(level) : null;\n const dps =\n stats.damage *\n stats.attackSpeed *\n (1 + stats.critChance * (stats.critMultiplier - 1)) *\n (types ? types.weapon(gear.attack) : 1);\n const hp =\n (stats.maxHp * (gear.shield ? 1.5 : 1)) /\n (types ? Math.max(0.05, types.body(gear.armor)) : 1) /\n (1 - stats.dodge);\n return dps * hp;\n}\n\n/**\n * Wear the best owned item in every slot the hero may use at its rank: slot by slot, the item that\n * makes the whole kit score best (two passes — a two-handed bow drops the shield). With a `level`,\n * for its scouting, as a player would.\n */\nexport function equipBest(\n config: SimConfig,\n p: SimPlayer,\n level?: number,\n): void {\n const items = simItems(config);\n const slots =\n config.Character?.Definitions?.[p.heroID]?.Equipment?.Slots ?? {};\n for (let pass = 0; pass < 2; pass++)\n for (const [slotID, slot] of Object.entries(slots)) {\n if (p.rank < Number(slot.MinCharacterLevel ?? 0)) continue;\n let best = p.equipment[slotID] ?? null;\n let score = kitScore(config, p, level);\n for (const id of p.items.keys()) {\n if (id === best || !slotsOf(items.get(id)).includes(slotID)) continue;\n if (\n Object.entries(p.equipment).some(([k, v]) => v === id && k !== slotID)\n )\n continue;\n const was = p.equipment[slotID];\n p.equipment[slotID] = id;\n const sc = kitScore(config, p, level);\n if (was) p.equipment[slotID] = was;\n else delete p.equipment[slotID];\n if (sc > score * 1.0001) {\n score = sc;\n best = id;\n }\n }\n if (best) p.equipment[slotID] = best;\n }\n}\n\nfunction itemUpgradeCost(it: SimItem | undefined, next: number): number | null {\n const up = it?.Upgrade;\n if (!up || next > Number(up.MaxLevel ?? 0)) return null;\n const base = priceIn(up.PriceOptions, \"GOLD\");\n if (base == null) return null;\n return Math.ceil(evaluateCurve(up.CostCurve, base, next, 2));\n}\n\nfunction rankCost(config: SimConfig, p: SimPlayer): number | null {\n const ladder = config.Character?.Definitions?.[p.heroID]?.RankLadder;\n const next = p.rank + 1;\n if (!ladder || next > Number(ladder.MaxRank ?? 0)) return null;\n const base = priceIn(ladder.PriceOptions, \"GOLD\");\n if (base == null) return null;\n return Math.ceil(\n evaluateCurve(\n ladder.CostCurve,\n base,\n next,\n Number(ladder.FirstPaidRank ?? 2),\n ),\n );\n}\n\nfunction balanceOf(p: SimPlayer, currencyID: string): number {\n if (currencyID === \"GOLD\") return p.gold;\n if (currencyID === GEMS_ID) return p.gems;\n if (currencyID === POTION_ID) return p.potions;\n if (currencyID === XP_ID) return p.xp;\n return 0;\n}\n\nfunction grant(p: SimPlayer, entries: readonly Entry[]): void {\n for (const e of entries) {\n const n = Number(e.Amount ?? 0);\n if (e.Type === \"Item\" && e.ItemID) {\n if (!p.items.has(e.ItemID)) p.items.set(e.ItemID, 1);\n continue;\n }\n if (e.CurrencyID === \"GOLD\") p.gold += n;\n else if (e.CurrencyID === GEMS_ID) p.gems += n;\n else if (e.CurrencyID === POTION_ID) p.potions += n;\n else if (e.CurrencyID === XP_ID) p.xp += n;\n }\n}\n\n/**\n * Between runs: summon gear for gems, keep potions up from the shop, then spend the gold on the\n * cheapest upgrade (worn items and the rank) — a sensible player, not an optimal one.\n */\nexport function spend(\n config: SimConfig,\n p: SimPlayer,\n random: () => number,\n /** The level played next: the kit to invest in is picked for its scouting. */\n level?: number,\n): void {\n const summon = priceIn(\n config.Lootbox?.Definitions?.[\"gear_summon\"]?.PriceOptions,\n GEMS_ID,\n );\n while (summon && p.gems >= summon) {\n p.gems -= summon;\n grant(\n p,\n rollLootbox(\n config,\n \"gear_summon\",\n random,\n (id) => balanceOf(p, id),\n p.pity,\n ),\n );\n }\n const items = simItems(config);\n // the kit to invest in: the best one with every item at its max level, for the road ahead (a\n // legendary sword at level 1 is worth levelling while a levelled hammer still hits harder; a\n // hammer is not, on a road of chainmail)\n const potential: SimPlayer = {\n ...p,\n items: new Map(\n [...p.items.keys()].map((id) => [\n id,\n Math.max(1, Number(items.get(id)?.Upgrade?.MaxLevel ?? 1)),\n ]),\n ),\n equipment: { ...p.equipment },\n };\n equipBest(config, potential, level);\n p.equipment = potential.equipment;\n const potions = config.Store?.StoreOffers?.[\"potions_3\"];\n const potionPrice = priceIn(potions?.Pricing?.Options, \"GOLD\");\n if (potionPrice && p.potions < POTION_SLOTS && p.gold >= potionPrice) {\n p.gold -= potionPrice;\n grant(p, potions?.Reward?.Grant?.Standard?.Entries ?? []);\n }\n for (;;) {\n let best: { cost: number; apply: () => void } | null = null;\n for (const id of new Set(Object.values(p.equipment))) {\n const level = p.items.get(id) ?? 1;\n const cost = itemUpgradeCost(items.get(id), level + 1);\n if (cost != null && (!best || cost < best.cost))\n best = { cost, apply: () => p.items.set(id, level + 1) };\n }\n const rc = rankCost(config, p);\n if (rc != null && (!best || rc < best.cost))\n best = { cost: rc, apply: () => (p.rank += 1) };\n if (!best || best.cost > p.gold) break;\n p.gold -= best.cost;\n best.apply();\n }\n}\n\nexport interface CampaignOptions {\n config: SimConfig;\n /** Runs the player plays (a ticket each). */\n runs: number;\n seed: number;\n picker?: CardPicker;\n /** Pick the weapon and the body armor for the level's scouting (default true). */\n scouting?: boolean;\n /**\n * After this many losses in a row on the next level, farm the last cleared one (its victory chest\n * pays more than a defeat) for `farmRuns` runs, then try again — what a stuck player does.\n * 0 — never farm. Default 3 and 2.\n */\n farmAfter?: number;\n farmRuns?: number;\n}\n\nexport interface CampaignRun {\n run: number;\n level: number;\n /** A replay of a cleared level for its chest. */\n farm: boolean;\n cleared: boolean;\n stalled: boolean;\n seconds: number;\n longestFight: number;\n /** The hero's numbers going in. */\n damage: number;\n maxHp: number;\n potionsIn: number;\n potionsUsed: number;\n chest: string;\n gold: number;\n rank: number;\n}\n\n/** One player playing `runs` runs: always the next level not cleared yet. */\nexport function simulateCampaign(o: CampaignOptions): {\n player: SimPlayer;\n runs: CampaignRun[];\n} {\n const random = seededRandom(o.seed);\n const p = newPlayer();\n const runs: CampaignRun[] = [];\n const farmAfter = o.farmAfter ?? 3;\n const farmRuns = o.farmRuns ?? 2;\n let losses = 0;\n let farming = 0;\n for (let i = 0; i < o.runs; i++) {\n if (farmAfter > 0 && losses >= farmAfter && p.cleared > 0) {\n farming = farmRuns;\n losses = 0;\n }\n const farm = farming > 0;\n if (farm) farming--;\n const level = farm ? p.cleared : p.cleared + 1;\n if (o.scouting !== false) equipBest(o.config, p, level);\n const hero = simHero(o.config, p);\n const potionsIn = Math.min(POTION_SLOTS, p.potions);\n const r = simulateRun({\n level,\n hero,\n potions: potionsIn,\n picker: o.picker,\n seed: Math.floor(random() * 2 ** 31),\n });\n p.potions -= r.potionsUsed;\n p.xp += r.potionsUsed;\n if (r.cleared) p.cleared = Math.max(p.cleared, level);\n if (!farm) losses = r.cleared ? 0 : losses + 1;\n const tier = chestTier(r);\n const ids = p.chestsOpened < FIRST_CHESTS ? FIRST_CHEST_IDS : CHEST_IDS;\n const chest = ids[tier];\n grant(\n p,\n rollLootbox(o.config, chest, random, (id) => balanceOf(p, id), p.pity),\n );\n p.chestsOpened += 1;\n runs.push({\n run: i + 1,\n level,\n farm,\n cleared: r.cleared,\n stalled: r.stalled,\n seconds: r.seconds,\n longestFight: r.longestFight,\n damage: hero.stats.damage,\n maxHp: hero.stats.maxHp,\n potionsIn,\n potionsUsed: r.potionsUsed,\n chest,\n gold: p.gold,\n rank: p.rank,\n });\n spend(o.config, p, random, p.cleared + 1);\n }\n return { player: p, runs };\n}\n"
392
+ },
393
+ {
394
+ "path": "game/themes.ts",
395
+ "content": "// The look of an island's road: the sky, the hills, the ground (phaser/backdrop.ts paints them). One\n// per island of a loop — meadow, forest, desert, snow, volcano.\n\nexport interface Theme {\n id: \"meadow\" | \"forest\" | \"desert\" | \"snow\" | \"volcano\";\n sky: [number, number];\n hillFar: number;\n hillNear: number;\n ground: number;\n grass: number;\n}\n\nexport const THEMES: Theme[] = [\n {\n id: \"meadow\",\n sky: [0x7ec8ff, 0xd8f1ff],\n hillFar: 0x8fd17a,\n hillNear: 0x5fb85a,\n ground: 0xc9a46a,\n grass: 0x4fa64a,\n },\n {\n id: \"forest\",\n sky: [0x5a9fd6, 0xbfe3d4],\n hillFar: 0x3f8a5a,\n hillNear: 0x2f6e44,\n ground: 0x8a6a45,\n grass: 0x2f7d3a,\n },\n {\n id: \"desert\",\n sky: [0xffb35c, 0xffe7b0],\n hillFar: 0xe6b86a,\n hillNear: 0xd49a4a,\n ground: 0xe8c98a,\n grass: 0xb98f45,\n },\n {\n id: \"snow\",\n sky: [0x8fb8e6, 0xeef6ff],\n hillFar: 0xdbe8f5,\n hillNear: 0xb9cfe6,\n ground: 0xf2f7fc,\n grass: 0x9fc0dd,\n },\n {\n id: \"volcano\",\n sky: [0x3a1f3f, 0xb8473a],\n hillFar: 0x5a2a2a,\n hillNear: 0x3d1d1d,\n ground: 0x4a3030,\n grass: 0xd2552d,\n },\n];\n"
396
+ },
397
+ {
398
+ "path": "game/units.ts",
399
+ "content": "// The enemies — people like the hero, each one a TROOP of a kind (WC3's units): what it holds decides\n// its attack type and whether it shoots, what it wears decides its armor type, and the player reads\n// both off the picture — plate is heavy, a robe is no armor, a bow shoots arrows. A level lists troops\n// by id (campaign.ts); enemyLooks.ts dresses them.\n\nimport {\n armorTypeOf,\n attackTypeOf,\n type ArmorType,\n type AttackType,\n} from \"./damage\";\nimport { heroIsMelee } from \"../character/rigs/hero\";\n\nexport type TroopId =\n | \"thug\"\n | \"archer\"\n | \"rogue\"\n | \"spearman\"\n | \"crossbowman\"\n | \"footman\"\n | \"acolyte\"\n | \"mage\"\n | \"maceman\"\n | \"gunner\"\n | \"berserker\"\n | \"warden\"\n | \"hammerguard\"\n | \"warlock\"\n // bosses — a hero of the enemy: hero armor, hero attack (from the third island on)\n | \"chief\"\n | \"warlord\"\n | \"archmage\"\n | \"dreadknight\";\n\nexport interface TroopDef {\n id: TroopId;\n /** What it wears and holds — the look, and through it the types. */\n gear: {\n Top: string | null;\n Hat: string | null;\n Weapon: string | null;\n Offhand: string | null;\n Gloves: string | null;\n Boots: string | null;\n Back: string | null;\n };\n /** Armor points (the WC3 value) at level 1; it grows a little with the level. */\n armor: number;\n /** Health and attack against a plain troop (1). */\n hp: number;\n attack: number;\n /** Seconds between its blows. */\n interval: number;\n /** How fast a brawler closes the last step, × the hero's run. */\n speed: number;\n boss?: boolean;\n /** The types, when the gear alone would say otherwise (a boss is a hero; a warlock casts chaos). */\n attackType?: AttackType;\n armorType?: ArmorType;\n}\n\nconst gear = (g: Partial<TroopDef[\"gear\"]>): TroopDef[\"gear\"] => ({\n Top: null,\n Hat: null,\n Weapon: null,\n Offhand: null,\n Gloves: null,\n Boots: \"boots/leather_boots\",\n Back: null,\n ...g,\n});\n\nexport const TROOPS: Readonly<Record<TroopId, TroopDef>> = {\n // ── unarmored and light: the first island ──\n thug: {\n id: \"thug\",\n gear: gear({ Weapon: \"weapon/dagger\" }),\n armor: 0,\n hp: 0.9,\n attack: 0.9,\n interval: 1.0,\n speed: 1.3,\n },\n archer: {\n id: \"archer\",\n gear: gear({\n Top: \"top/leather_armor\",\n Hat: \"hat/ranger_hood\",\n Weapon: \"weapon/hunting_bow\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 0,\n hp: 0.75,\n attack: 0.75,\n interval: 1.6,\n speed: 1.1,\n },\n rogue: {\n id: \"rogue\",\n gear: gear({\n Top: \"top/leather_armor\",\n Weapon: \"weapon/broadsword\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 1,\n hp: 1,\n attack: 1,\n interval: 1.2,\n speed: 1.2,\n },\n // ── medium: the second ──\n spearman: {\n id: \"spearman\",\n gear: gear({\n Top: \"top/chainmail\",\n Hat: \"hat/iron_helm\",\n Weapon: \"weapon/spear\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 2,\n hp: 1.1,\n attack: 1,\n interval: 1.2,\n speed: 1.1,\n },\n crossbowman: {\n id: \"crossbowman\",\n gear: gear({\n Top: \"top/chainmail\",\n Hat: \"hat/iron_helm\",\n Weapon: \"weapon/crossbow\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 2,\n hp: 0.85,\n attack: 0.85,\n interval: 1.8,\n speed: 1.1,\n },\n // ── heavy and magic: the third ──\n footman: {\n id: \"footman\",\n gear: gear({\n Top: \"top/plate_armor\",\n Hat: \"hat/knight_helm\",\n Weapon: \"weapon/knight_sword\",\n Offhand: \"offhand/heater_shield\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n }),\n armor: 4,\n hp: 1.3,\n attack: 0.9,\n interval: 1.3,\n speed: 1,\n },\n acolyte: {\n id: \"acolyte\",\n gear: gear({ Top: \"top/mage_robe\", Weapon: \"weapon/druid_staff\" }),\n armor: 0,\n hp: 0.7,\n attack: 0.9,\n interval: 1.7,\n speed: 1,\n },\n mage: {\n id: \"mage\",\n gear: gear({\n Top: \"top/mage_robe\",\n Hat: \"hat/wizard_hat\",\n Weapon: \"weapon/wizard_staff\",\n Back: \"back/mage_cape\",\n }),\n armor: 0,\n hp: 0.8,\n attack: 1.1,\n interval: 1.9,\n speed: 1,\n },\n // ── siege and fortified: the fourth ──\n maceman: {\n id: \"maceman\",\n gear: gear({\n Top: \"top/chainmail\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/mace\",\n Offhand: \"offhand/round_shield\",\n Gloves: \"gloves/gauntlets\",\n }),\n armor: 3,\n hp: 1.2,\n attack: 1.2,\n interval: 1.5,\n speed: 1,\n },\n gunner: {\n id: \"gunner\",\n gear: gear({\n Top: \"top/leather_armor\",\n Hat: \"hat/iron_helm\",\n Weapon: \"weapon/rifle\",\n Gloves: \"gloves/gauntlets\",\n }),\n armor: 1,\n hp: 0.85,\n attack: 1.1,\n interval: 2,\n speed: 1,\n },\n berserker: {\n id: \"berserker\",\n gear: gear({\n Top: \"top/leather_armor\",\n Hat: \"hat/winged_helm\",\n Weapon: \"weapon/double_axe\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 1,\n hp: 1.2,\n attack: 1.3,\n interval: 1.1,\n speed: 1.4,\n },\n warden: {\n id: \"warden\",\n gear: gear({\n Top: \"top/plate_armor\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/broadsword\",\n Offhand: \"offhand/tower_shield\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n }),\n armor: 6,\n hp: 1.8,\n attack: 0.8,\n interval: 1.6,\n speed: 0.9,\n },\n // ── the fifth: walls broken by hammers, chaos ──\n hammerguard: {\n id: \"hammerguard\",\n gear: gear({\n Top: \"top/plate_armor\",\n Hat: \"hat/knight_helm\",\n Weapon: \"weapon/warhammer\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n }),\n armor: 5,\n hp: 1.4,\n attack: 1.3,\n interval: 1.6,\n speed: 1,\n },\n warlock: {\n id: \"warlock\",\n gear: gear({\n Top: \"top/mage_robe\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/wizard_staff\",\n Back: \"back/knight_cape\",\n }),\n armor: 1,\n hp: 1,\n attack: 1.3,\n interval: 1.8,\n speed: 1,\n attackType: \"chaos\",\n },\n // ── bosses ──\n chief: {\n id: \"chief\",\n gear: gear({\n Top: \"top/leather_armor\",\n Hat: \"hat/winged_helm\",\n Weapon: \"weapon/battle_axe\",\n Offhand: \"offhand/round_shield\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 2,\n hp: 9,\n attack: 2,\n interval: 1.4,\n speed: 0.9,\n boss: true,\n },\n warlord: {\n id: \"warlord\",\n gear: gear({\n Top: \"top/chainmail\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/dark_blade\",\n Offhand: \"offhand/kite_shield\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n Back: \"back/knight_cape\",\n }),\n armor: 4,\n hp: 10,\n attack: 2.1,\n interval: 1.4,\n speed: 0.9,\n boss: true,\n },\n archmage: {\n id: \"archmage\",\n gear: gear({\n Top: \"top/mage_robe\",\n Hat: \"hat/wizard_hat\",\n Weapon: \"weapon/wizard_staff\",\n Back: \"back/mage_cape\",\n Gloves: \"gloves/bracers\",\n }),\n armor: 2,\n hp: 8,\n attack: 2.4,\n interval: 1.6,\n speed: 0.9,\n boss: true,\n attackType: \"hero\",\n armorType: \"hero\",\n },\n dreadknight: {\n id: \"dreadknight\",\n gear: gear({\n Top: \"top/plate_armor\",\n Hat: \"hat/great_helm\",\n Weapon: \"weapon/flame_sword\",\n Offhand: \"offhand/tower_shield\",\n Gloves: \"gloves/gauntlets\",\n Boots: \"boots/plate_boots\",\n Back: \"back/knight_cape\",\n }),\n armor: 6,\n hp: 12,\n attack: 2.2,\n interval: 1.5,\n speed: 0.9,\n boss: true,\n attackType: \"hero\",\n armorType: \"hero\",\n },\n};\n\n/** How far a shooter shoots (lane widths). */\nexport const TROOP_RANGE = 0.5;\n\n/** The troop's attack type: named, or by the weapon. */\nexport function troopAttack(t: TroopDef): AttackType {\n return t.attackType ?? attackTypeOf(t.gear.Weapon);\n}\n\n/** The troop's armor type: named, or by what it wears. */\nexport function troopArmor(t: TroopDef): ArmorType {\n return t.armorType ?? armorTypeOf(t.gear.Top, t.gear.Offhand);\n}\n\n/** Does it shoot (a bow, a crossbow, a gun, a staff) rather than come face to face? A boss never shoots. */\nexport function troopRanged(t: TroopDef): boolean {\n return !t.boss && !heroIsMelee(t.gear.Weapon);\n}\n\n/** Does it carry a shield — the blue bar of a pool that takes the blows first? */\nexport function troopShielded(t: TroopDef): boolean {\n return !!t.gear.Offhand;\n}\n"
400
+ },
401
+ {
402
+ "path": "heroArt.ts",
403
+ "content": "import type { CharacterArt, CharacterArtSubject } from \"@idosgames/module-sdk\";\nimport {\n CharacterCanvas,\n createCatalog,\n HERO_ANIMATIONS,\n heroAttack,\n heroRig,\n resolveLook,\n type ArtCatalog,\n} from \"./character\";\n\n// The game's hero on the other screens of the app (ctx.characterArt): the Heroes screen draws the\n// same layered hero that fights here — dressed by the same looks (class + worn gear), facing the way\n// it fights. A big view moves (breathes, blinks); a piece of gear put on shows itself — a new weapon\n// is swung once. A list of heroes gets still pictures.\n\nlet catalog: ArtCatalog | null = null;\n\nconst lookOf = (s: CharacterArtSubject) =>\n resolveLook({\n id: s.characterId,\n classId: s.classId ?? \"\",\n equipment: s.equipment,\n });\n\nexport const heroArt: CharacterArt = {\n mount(host, subject, options) {\n catalog ??= createCatalog();\n const live = options?.mode !== \"still\";\n const view = new CharacterCanvas(host, {\n rig: heroRig,\n catalog,\n look: lookOf(subject),\n facing: -1, // the art faces left; the hero fights to the right\n mode: live ? \"live\" : \"still\",\n });\n let key = JSON.stringify(lookOf(subject));\n const weapon = () =>\n view.parts.isWorn(\"Weapon\") ? view.parts.part(\"Weapon\") : null;\n let held = weapon();\n view.on(\"end\", () => view.play(HERO_ANIMATIONS.idle));\n return {\n update(next) {\n const look = lookOf(next);\n const k = JSON.stringify(look);\n if (k === key) return;\n key = k;\n view.setLook(look);\n const now = weapon();\n if (live && now !== held && now)\n view.play(heroAttack(now), { loop: false, mix: 0.1 });\n held = now;\n },\n destroy: () => view.destroy(),\n };\n },\n};\n"
404
+ },
405
+ {
406
+ "path": "i18n.ts",
407
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Hero Run\",\n loading: \"Getting ready…\",\n noHero: \"No heroes in this game yet. Add them in LiveOps → Characters.\",\n // Islands\n meadow: \"Meadow\",\n forest: \"Forest\",\n desert: \"Desert\",\n snow: \"Snowfield\",\n volcano: \"Volcano\",\n island: \"Island\",\n level: \"Level\",\n boss: \"Boss\",\n // Map\n play: \"Run\",\n free: \"Free\",\n ticketReady: \"Ticket ready\",\n nextEnergy: \"Next energy in\",\n cleared: \"Cleared\",\n locked: \"Locked\",\n scouting: \"Scouting\",\n enemyArmor: \"Enemy armor\",\n enemyAttack: \"Their attacks\",\n yourAttack: \"Your attack\",\n yourArmor: \"Your armor\",\n strong: \"strong\",\n weak: \"weak\",\n even: \"even\",\n ultimate: \"Ultimate\",\n potions: \"Potions\",\n playerLevel: \"Level\",\n table: \"Damage table\",\n tipTitle: \"Something new\",\n tipOk: \"Got it\",\n // Run\n rage: \"Rage\",\n runLevel: \"Run level\",\n camp: \"Camp\",\n campText: \"Rest here: health or rage?\",\n campHeal: \"Eat\",\n campRage: \"Train\",\n campBoth: \"Feast\",\n auto: \"Auto\",\n leave: \"Leave\",\n leaveConfirm: \"Leave the run? It ends as lost.\",\n victory: \"Victory!\",\n defeat: \"Defeated\",\n kills: \"Kills\",\n reached: \"Run level\",\n time: \"Time\",\n chestDefeat: \"Small chest\",\n chestVictory: \"Victory chest\",\n chestTreasure: \"Treasure chest\",\n open: \"Open\",\n toMap: \"To the map\",\n // Ults\n meteor: \"Meteor\",\n frenzy: \"Frenzy\",\n meteorText:\n \"Every enemy on screen takes 4 blows as spells — through any armor\",\n frenzyText: \"Attack speed ×2 for 6 seconds\",\n ultOnce: \"Once a run — only a card can bring it back\",\n // Types\n \"rarity.common\": \"Common\",\n \"rarity.rare\": \"Rare\",\n \"rarity.legendary\": \"Legendary\",\n \"attack.normal\": \"Normal\",\n \"attack.pierce\": \"Pierce\",\n \"attack.siege\": \"Siege\",\n \"attack.magic\": \"Magic\",\n \"attack.chaos\": \"Chaos\",\n \"attack.spells\": \"Spells\",\n \"attack.hero\": \"Hero\",\n \"armor.light\": \"Light\",\n \"armor.medium\": \"Medium\",\n \"armor.heavy\": \"Heavy\",\n \"armor.fortified\": \"Fortified\",\n \"armor.hero\": \"Hero\",\n \"armor.unarmored\": \"Unarmored\",\n \"tip.armor.medium\":\n \"Chainmail: MEDIUM armor. A sword or an axe (normal attack) deals 150 % to it, arrows and magic only 75 %.\",\n \"tip.armor.heavy\":\n \"Plate: HEAVY armor. MAGIC deals 200 % to it — a staff, or the fire edge card.\",\n \"tip.attack.magic\":\n \"Staves cast MAGIC: 200 % to heavy armor, but only 35 % to fortified walls.\",\n \"tip.armor.hero\":\n \"Bosses wear HERO armor: pierce, siege and magic deal only 50 % to it. Blades and chaos go through.\",\n \"tip.attack.siege\":\n \"Maces and hammers deal SIEGE: 150 % to fortified walls and to the unarmored, 50 % to chainmail.\",\n \"tip.armor.fortified\":\n \"Plate with a tower shield is FORTIFIED: arrows and magic deal 35 %. Bring a hammer.\",\n \"tip.attack.chaos\":\n \"Warlocks cast CHAOS: 100 % to any armor at all — no gear makes it weaker.\",\n // Run sheet\n sheet: \"Hero\",\n effects: \"Card effects\",\n noEffects: \"The cards' effects show here\",\n cardsTaken: \"Cards\",\n noCards: \"Cards come with the rage\",\n \"stat.damage\": \"Damage\",\n \"stat.attackSpeed\": \"Attack speed\",\n \"stat.critChance\": \"Crit chance\",\n \"stat.critMultiplier\": \"Crit multiplier\",\n \"stat.defense\": \"Defense\",\n \"stat.counter\": \"Counter\",\n \"stat.dodge\": \"Dodge\",\n \"stat.lifesteal\": \"Lifesteal chance\",\n \"stat.health\": \"Health\",\n \"stat.shield\": \"Shield\",\n info: \"Info\",\n newLevel: \"New level!\",\n chooseUpgrade: \"Choose a new upgrade\",\n upgradeN: \"Upgrade\",\n refresh: \"Refresh\",\n left: \"Left\",\n \"charge.focus\": \"Focus\",\n \"charge.divine\": \"Divine shield\",\n \"charge.lightning\": \"Lightning strikes\",\n \"fx.backRow\": \"+{v} damage to the back row\",\n \"fx.pierce\": \"{v} of every blow into the enemy behind\",\n \"fx.vsStunned\": \"+{v} damage to stunned enemies\",\n \"fx.missingHp\": \"+{v} damage per 10% of the target's missing HP\",\n \"fx.counterCrit\": \"A counter: +{v} crit chance\",\n \"fx.lowHpSpeed\": \"Below 30% HP: +{v} attack speed\",\n \"fx.fullHpDamage\": \"At full HP: +{v} ATK\",\n \"fx.shieldIgnore\": \"While the armor holds: {v} chance to ignore damage\",\n \"fx.repairPower\": \"The armor repairs +{v} better\",\n \"fx.hybrid\": \"+{v} of each blow as {type}\",\n \"fx.vsArmor\": \"+{v} damage to {type} armor\",\n \"fx.shred\": \"−{v} armor a blow, down to −{max}\",\n \"fx.warCry\": \"Waves come with −{v} armor\",\n \"fx.vsPierce\": \"−{v} from pierce\",\n \"fx.doubleHit\": \"Double strike chance {v}\",\n \"fx.maxHp\": \"Max health +{v}\",\n \"fx.potionPower\": \"Potions +{v}\",\n perSecond: \"/s\",\n notEnough: \"Not enough\",\n // Nav\n heroes: \"Hero\",\n summon: \"Summon\",\n quests: \"Quests\",\n shop: \"Shop\",\n // Guide\n guide: \"Quest\",\n claim: \"Claim\",\n power: \"Power\",\n rank: \"Rank\",\n} as const;\n\nexport type Key = keyof typeof EN;\n\nexport const t = makeT<Key>(EN, {\n title: \"Hero Run\",\n loading: \"Готовимся…\",\n noHero: \"В игре пока нет героев. Добавьте их в LiveOps → Персонажи.\",\n meadow: \"Луг\",\n forest: \"Лес\",\n desert: \"Пустыня\",\n snow: \"Снега\",\n volcano: \"Вулкан\",\n island: \"Остров\",\n level: \"Уровень\",\n boss: \"Босс\",\n play: \"В забег\",\n free: \"Бесплатно\",\n ticketReady: \"Билет есть\",\n nextEnergy: \"Энергия через\",\n cleared: \"Пройден\",\n locked: \"Закрыт\",\n scouting: \"Разведка\",\n enemyArmor: \"Броня врагов\",\n enemyAttack: \"Их атаки\",\n yourAttack: \"Ваша атака\",\n yourArmor: \"Ваша броня\",\n strong: \"сильно\",\n weak: \"слабо\",\n even: \"обычно\",\n ultimate: \"Ультимейт\",\n potions: \"Зелья\",\n playerLevel: \"Уровень\",\n table: \"Таблица урона\",\n tipTitle: \"Новое\",\n tipOk: \"Понятно\",\n rage: \"Ярость\",\n runLevel: \"Уровень забега\",\n camp: \"Лагерь\",\n campText: \"Привал: здоровье или ярость?\",\n campHeal: \"Поесть\",\n campRage: \"Потренироваться\",\n campBoth: \"Пир\",\n auto: \"Авто\",\n leave: \"Выйти\",\n leaveConfirm: \"Покинуть забег? Он засчитается как проигранный.\",\n victory: \"Победа!\",\n defeat: \"Поражение\",\n kills: \"Убито\",\n reached: \"Уровень забега\",\n time: \"Время\",\n chestDefeat: \"Малый сундук\",\n chestVictory: \"Сундук победы\",\n chestTreasure: \"Сундук сокровищ\",\n open: \"Открыть\",\n toMap: \"На карту\",\n meteor: \"Метеор\",\n frenzy: \"Неистовство\",\n meteorText:\n \"Все враги на экране получают 4 удара заклинанием — сквозь любую броню\",\n frenzyText: \"Скорость атаки ×2 на 6 секунд\",\n ultOnce: \"Один раз за забег — вернуть его может только карточка\",\n \"rarity.common\": \"Обычный\",\n \"rarity.rare\": \"Редкий\",\n \"rarity.legendary\": \"Легендарный\",\n \"attack.normal\": \"Обычная\",\n \"attack.pierce\": \"Проникающая\",\n \"attack.siege\": \"Осадная\",\n \"attack.magic\": \"Магическая\",\n \"attack.chaos\": \"Хаос\",\n \"attack.spells\": \"Заклинания\",\n \"attack.hero\": \"Атака героев\",\n \"armor.light\": \"Лёгкая\",\n \"armor.medium\": \"Средняя\",\n \"armor.heavy\": \"Тяжёлая\",\n \"armor.fortified\": \"Укреплённая\",\n \"armor.hero\": \"Броня героев\",\n \"armor.unarmored\": \"Без брони\",\n \"tip.armor.medium\":\n \"Кольчуга — СРЕДНЯЯ броня. Меч или топор (обычная атака) бьют по ней на 150 %, стрелы и магия — лишь на 75 %.\",\n \"tip.armor.heavy\":\n \"Латы — ТЯЖЁЛАЯ броня. МАГИЯ бьёт по ним на 200 % — посох или карточка «Огненная кромка».\",\n \"tip.attack.magic\":\n \"Посохи бьют МАГИЕЙ: 200 % по тяжёлой броне, но лишь 35 % по укреплённой.\",\n \"tip.armor.hero\":\n \"У боссов БРОНЯ ГЕРОЕВ: проникающая, осадная и магическая атаки бьют по ней на 50 %. Клинки и хаос проходят полностью.\",\n \"tip.attack.siege\":\n \"Булавы и молоты наносят ОСАДНЫЙ урон: 150 % по укреплённой броне и без брони, 50 % по кольчуге.\",\n \"tip.armor.fortified\":\n \"Латы с башенным щитом — УКРЕПЛЁННАЯ броня: стрелы и магия — лишь 35 %. Берите молот.\",\n \"tip.attack.chaos\":\n \"Чернокнижники бьют ХАОСОМ: 100 % по любой броне — никакая экипировка его не ослабит.\",\n sheet: \"Герой\",\n effects: \"Эффекты карточек\",\n noEffects: \"Здесь появятся эффекты карточек\",\n cardsTaken: \"Карточки\",\n noCards: \"Карточки приходят с яростью\",\n \"stat.damage\": \"Урон\",\n \"stat.attackSpeed\": \"Скорость атаки\",\n \"stat.critChance\": \"Шанс крита\",\n \"stat.critMultiplier\": \"Крит. множитель\",\n \"stat.defense\": \"Защита\",\n \"stat.counter\": \"Контрудар\",\n \"stat.dodge\": \"Уклонение\",\n \"stat.lifesteal\": \"Шанс вампиризма\",\n \"stat.health\": \"Здоровье\",\n \"stat.shield\": \"Щит\",\n info: \"Инфо\",\n newLevel: \"Новый уровень!\",\n chooseUpgrade: \"Выберите новое усиление\",\n upgradeN: \"Усиление\",\n refresh: \"Обновить\",\n left: \"Осталось\",\n \"charge.focus\": \"Концентрация\",\n \"charge.divine\": \"Божественный щит\",\n \"charge.lightning\": \"Заряды молнии\",\n \"fx.backRow\": \"+{v} урона по заднему ряду\",\n \"fx.pierce\": \"{v} урона каждого удара — во врага позади\",\n \"fx.vsStunned\": \"+{v} урона по обездвиженным врагам\",\n \"fx.missingHp\": \"+{v} урона за каждые 10% недостающего HP цели\",\n \"fx.counterCrit\": \"Контрудар: +{v} шанс крита\",\n \"fx.lowHpSpeed\": \"HP ниже 30%: +{v} скорости атаки\",\n \"fx.fullHpDamage\": \"При полном HP: +{v} ATK\",\n \"fx.shieldIgnore\": \"Пока есть броня: шанс {v} игнорировать урон\",\n \"fx.repairPower\": \"Броня чинится на {v} лучше\",\n \"fx.hybrid\": \"+{v} каждого удара — {type}\",\n \"fx.vsArmor\": \"+{v} урона по броне «{type}»\",\n \"fx.shred\": \"−{v} брони за удар, до −{max}\",\n \"fx.warCry\": \"Волны приходят с бронёй −{v}\",\n \"fx.vsPierce\": \"−{v} от проникающих\",\n \"fx.doubleHit\": \"Шанс двойного удара {v}\",\n \"fx.maxHp\": \"Макс. здоровье +{v}\",\n \"fx.potionPower\": \"Зелья +{v}\",\n perSecond: \"/с\",\n notEnough: \"Не хватает\",\n heroes: \"Герой\",\n summon: \"Призыв\",\n quests: \"Задания\",\n shop: \"Магазин\",\n guide: \"Задание\",\n claim: \"Забрать\",\n power: \"Сила\",\n rank: \"Ранг\",\n});\n"
408
+ },
409
+ {
410
+ "path": "index.ts",
411
+ "content": "// @idosgames/mod-hero-run — Hero Run (Phaser + React) for the host shell: Obsidian Knight's runs with\n// Warcraft III's damage — the hero runs a level on its own, rage cards build the run, attack types\n// meet armor types. Heroes, gear, summons, quests and the shop are the platform's systems.\n//\n// Primary export is the module manifest; the rest is exposed so a project can recompose the pieces\n// (a different screen over the same run, the run under a different screen) after copying this into\n// src/modules/.\n\nexport { heroRunModule } from \"./module\";\n\nexport { RunSession, pickHero } from \"./game/session\";\nexport {\n Battle,\n ULTS,\n rageFor,\n type BattleEvent,\n type BattleSnapshot,\n type RunResult,\n} from \"./game/battle\";\nexport {\n DAMAGE_TABLE,\n armorFactor,\n armorReduction,\n dealt,\n type ArmorType,\n type AttackType,\n} from \"./game/damage\";\nexport { CARDS, drawCards, modsOf, procsOf, type CardDef } from \"./game/cards\";\nexport { TROOPS, type TroopDef, type TroopId } from \"./game/units\";\nexport { levelOf, islandOf, scout, LEVELS_PER_ISLAND } from \"./game/campaign\";\nexport {\n pickEconomy,\n chestTier,\n playerLevel,\n POTION_SLOTS,\n} from \"./game/economy\";\nexport { PROGRESS_KEY } from \"./game/progress\";\nexport {\n fighterStats,\n resolveStats,\n statCost,\n statCap,\n type FighterStats,\n} from \"./game/heroStats\";\nexport { THEMES } from \"./game/themes\";\nexport { formatBig } from \"./game/format\";\nexport { HeroRunController } from \"./game/HeroRunController\";\nexport { BattleScene, type BattleSceneOptions } from \"./phaser/BattleScene\";\nexport { createHeroRunScene } from \"./scene\";\nexport { makeGamePanel } from \"./ui/GamePanel\";\nexport { characterUpgraded, levelCleared } from \"./events\";\n\n// The character system (layered 2D characters: skeleton, parts, colours, clips) — see character/index.ts.\nexport * from \"./character\";\n"
412
+ },
413
+ {
414
+ "path": "layout.ts",
415
+ "content": "// One screen split in two: the fight on top (Phaser draws it on the full-bleed canvas) and the\n// Enhance panel below (React). Both sides compute the split from the SAME numbers — the canvas draws\n// the field into the top part, the panel starts right under it.\n\n/** The fight takes this share of the screen height… */\nexport const BATTLE_SHARE = 0.44;\n/** …but never more than this many pixels (a tall desktop window gets a bigger panel, not a stretched field). */\nexport const BATTLE_MAX_PX = 430;\n/** The lane is at most this wide; a wider screen centres it. */\nexport const LANE_MAX_PX = 980;\n\nexport function battleHeight(screenHeight: number): number {\n return Math.round(Math.min(screenHeight * BATTLE_SHARE, BATTLE_MAX_PX));\n}\n\n/** The same split as a CSS length, for the React side. */\nexport const BATTLE_HEIGHT_CSS = `min(${BATTLE_SHARE * 100}%, ${BATTLE_MAX_PX}px)`;\n"
416
+ },
417
+ {
418
+ "path": "module.ts",
419
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { HeroRunController } from \"./game/HeroRunController\";\nimport { RunSession } from \"./game/session\";\nimport type { CampChoice, UltId } from \"./game/battle\";\nimport { ENERGY_ID, TICKET_ID, XP_ID } from \"./game/economy\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createHeroRunScene } from \"./scene\";\nimport { makeGamePanel } from \"./ui/GamePanel\";\nimport { characterUpgraded, heroSelected, levelCleared } from \"./events\";\nimport { t } from \"./i18n\";\nimport { heroArt } from \"./heroArt\";\n\n// Hero Run: Obsidian Knight's runs with Warcraft III's damage. The hero runs a level on its own —\n// waves of troops, a camp, fountains, a boss; kills fill the rage, and every run level is a choice of\n// one of three cards. A weapon's attack type against a troop's armor type decides how hard a blow\n// lands. A run costs energy (a ticket) and ends in a reward chest — both the server's (economy.ts).\n// Heroes, gear, summons, quests and the shop are the platform's systems (module.meta.json → uses).\nexport const heroRunModule: Module = defineModule({\n id: \"hero-run\",\n meta: {\n name: \"Hero Run\",\n type: \"game\",\n genre: \"roguelike-rpg\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const session = new RunSession(ctx.client, {\n onLevelCleared: (island, level) =>\n ctx.events.emit(levelCleared, { island, level }),\n // A hero's rank went up anywhere (the Heroes screen) — the signal other modules know this game by.\n onRankUp: (characterId, level) =>\n ctx.events.emit(characterUpgraded, { characterId, level }),\n });\n const box = createControllerBox<HeroRunController>();\n\n // The hero the player picked on the Heroes screen fights. Subscribed here, in setup(): the Heroes\n // system announces its saved choice as soon as its watcher mounts, which is after every setup().\n ctx.events.on(heroSelected, ({ characterId }) =>\n session.selectHero(characterId),\n );\n\n // The Heroes screen (and any other) draws this game's hero, not a picture of its own.\n ctx.characterArt.set(heroArt);\n\n ctx.registerScene(createHeroRunScene(box, session));\n ctx.registerPanel({\n id: \"game\",\n slot: \"overlay\",\n component: makeGamePanel(\n session,\n ctx.features,\n ctx.sharedUi.shouldDraw(\"menu\"),\n ),\n });\n // Lives inside the lobby: its balances and tabs around the fight, the app starts here.\n ctx.registerRoute({\n id: \"hero-run\",\n label: t(\"title\"),\n icon: \"⚔️\",\n inLobby: true,\n });\n\n // The fight is drawn on a canvas — the agent cannot read it from the DOM, so it is published\n // here: the level, the run, the hero and the troops, plus the actions the player's own buttons take.\n ctx.exposeToAgent({\n state: () => {\n const b = session.battle;\n const snap = b.snapshot();\n const hero = session.hero;\n return {\n mounted: box.get() !== null,\n playing: box.get()?.running ?? false,\n openLevel: session.openLevel(),\n selected: session.selected,\n running: session.running,\n ending: session.ending\n ? { ...session.ending.result, tier: session.ending.tier }\n : null,\n energy: session.balance(ENERGY_ID),\n tickets: session.balance(TICKET_ID),\n xp: session.balance(XP_ID),\n runCost: session.runCost(),\n economy: session.economy,\n run: {\n island: snap.island,\n segment: `${snap.segment + 1}/${snap.segments + 1}`,\n rage: Math.round(snap.rage * 10) / 10,\n rageTo: snap.rageTo,\n runLevel: snap.runLevel,\n pending: snap.pending,\n cards: snap.cards,\n potions: snap.potions,\n ult: snap.ult,\n ultReady: snap.ultReady,\n autoPlay: snap.autoPlay,\n kills: snap.kills,\n chests: snap.chests,\n },\n hero: hero\n ? {\n id: hero.id,\n rank: hero.rank,\n power: hero.power,\n gear: hero.gear,\n hp: Math.round(snap.heroHp),\n maxHp: Math.round(snap.heroMaxHp),\n shield: Math.round(snap.heroShield),\n fighter: hero.fighter,\n stats: hero.stats.map((s) => ({\n id: s.id,\n level: hero.input.model?.StatLevels?.[s.id] ?? 0,\n })),\n }\n : null,\n troops: b.monsters.map((m) => ({\n troop: m.troop.id,\n boss: m.boss,\n hp: Math.round(m.hp),\n shield: Math.round(m.shield),\n armorType: m.armorType,\n armor: b.armorOf(m),\n attackType: m.attackType,\n ranged: m.range > 0,\n stunned: m.stunned > 0,\n breaks: m.breaks,\n // how far ahead of the hero it stands on the road (lane widths)\n ahead: Math.round((m.x - b.heroX) * 100) / 100,\n })),\n feature: b.feature,\n };\n },\n actions: {\n select: async (args) => session.select(Number(args?.[\"level\"] ?? 1)),\n start: async () => {\n if (!(await session.start()))\n throw new Error(\n \"not now — a run is on or ending, no hero, or not enough energy\",\n );\n },\n takeCard: async (args) => {\n const id = String(args?.[\"id\"] ?? \"\");\n if (!session.takeCard(id))\n throw new Error(\n `no card choice open, or \"${id}\" is not offered — offered: ${JSON.stringify(session.battle.pending)}`,\n );\n },\n camp: async (args) => {\n const choice = String(args?.[\"choice\"] ?? \"heal\") as CampChoice;\n if (!(await session.chooseCamp(choice)))\n throw new Error(\"no camp now, or the gems are short\");\n },\n potion: async () => {\n if (!session.battle.drinkPotion())\n throw new Error(\"no potion, full health, or the run waits\");\n },\n ult: async () => {\n if (!session.battle.castUlt())\n throw new Error(\n \"the ultimate is spent (once a run) or the run waits\",\n );\n },\n setUlt: async (args) =>\n session.setUlt(\n (String(args?.[\"ult\"] ?? \"meteor\") === \"frenzy\"\n ? \"frenzy\"\n : \"meteor\") as UltId,\n ),\n setAuto: async (args) =>\n session.battle.setAutoPlay(Boolean(args?.[\"on\"])),\n reroll: async () => {\n if (!session.reroll())\n throw new Error(\"no card choice open, or no refresh left\");\n },\n abandon: async () => session.abandon(),\n claim: async () => {\n await session.claim();\n },\n },\n describeActions: {\n select: \"Pick a level on the map (an open one): { level: number }.\",\n start:\n \"Start the selected level (spends energy for a run ticket when none is waiting).\",\n takeCard:\n \"Take one of the three rage cards offered: { id } (state.run.pending.options).\",\n camp: \"The camp: { choice: heal | rage | both } (both costs gems).\",\n potion: \"Drink a potion now.\",\n ult: \"Cast the ultimate — once a run (a card may bring it back).\",\n setUlt: \"The ultimate for the next runs: { ult: meteor | frenzy }.\",\n setAuto: \"Potions and the ultimate by themselves: { on: boolean }.\",\n reroll: \"Refresh: three other cards for the open choice (once a run).\",\n abandon: \"Leave the run (it ends as lost).\",\n claim: \"Open the ended run's reward chest and go back to the map.\",\n },\n });\n },\n});\n"
420
+ },
421
+ {
422
+ "path": "phaser/art.ts",
423
+ "content": "import Phaser from \"phaser\";\n\n// The fight's art is drawn here in code and baked into textures once (Graphics → generateTexture): the\n// module ships no image files, so it builds offline and runs in the preview as is. Cute and chunky,\n// thick dark outlines. The hero is not here — it is the character system's (../character, hero.ts).\n\nconst OUTLINE = 0x2b1d33;\n\ntype G = Phaser.GameObjects.Graphics;\n\nfunction bake(\n scene: Phaser.Scene,\n key: string,\n w: number,\n h: number,\n draw: (g: G) => void,\n): string {\n if (scene.textures.exists(key)) return key;\n const g = scene.make.graphics({ x: 0, y: 0 }, false);\n draw(g);\n g.generateTexture(key, w, h);\n g.destroy();\n return key;\n}\n\n/** A darker / lighter shade of a colour. */\nexport function shade(color: number, k: number): number {\n const c = Phaser.Display.Color.IntegerToColor(color);\n const f = (v: number) =>\n Math.max(\n 0,\n Math.min(255, Math.round(k < 0 ? v * (1 + k) : v + (255 - v) * k)),\n );\n return Phaser.Display.Color.GetColor(f(c.red), f(c.green), f(c.blue));\n}\n\n// ── The hero's colour (its shots) ─────────────────────────────────────────────────────────────\n\nexport const CLASS_COLORS: Record<string, number> = {\n Warrior: 0x4fa8ff,\n Ranger: 0x5fd36a,\n Mage: 0xb36bff,\n};\n\nexport function heroColor(classID: string, heroID: string): number {\n const known = CLASS_COLORS[classID];\n if (known) return known;\n let h = 0;\n for (const ch of heroID) h = (h * 31 + ch.charCodeAt(0)) >>> 0;\n return Phaser.Display.Color.HSVToRGB((h % 360) / 360, 0.55, 0.95).color;\n}\n\n// ── Small things ─────────────────────────────────────────────────────────────────────────────\n\nexport function bakeCommon(scene: Phaser.Scene): void {\n bake(scene, \"coin\", 26, 26, (g) => {\n g.fillStyle(OUTLINE, 1);\n g.fillCircle(13, 13, 12);\n g.fillStyle(0xf2a516, 1);\n g.fillCircle(13, 13, 10);\n g.fillStyle(0xffd34d, 1);\n g.fillCircle(13, 13, 7);\n g.fillStyle(0xfff3b0, 1);\n g.fillCircle(10, 10, 2.5);\n });\n bake(scene, \"spark\", 16, 16, (g) => {\n g.fillStyle(0xffffff, 0.35);\n g.fillCircle(8, 8, 8);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(8, 8, 4);\n });\n bake(scene, \"puff\", 32, 32, (g) => {\n g.fillStyle(0xffffff, 0.9);\n g.fillCircle(16, 16, 14);\n });\n bake(scene, \"cloud\", 170, 70, (g) => {\n g.fillStyle(0xffffff, 0.95);\n g.fillCircle(40, 44, 24);\n g.fillCircle(72, 32, 30);\n g.fillCircle(108, 38, 26);\n g.fillCircle(136, 48, 20);\n g.fillRoundedRect(30, 44, 120, 24, 12);\n });\n bake(scene, \"meteor\", 48, 48, (g) => {\n g.fillStyle(0xff7a1a, 0.4);\n g.fillCircle(24, 24, 22);\n g.fillStyle(0xffb03a, 1);\n g.fillCircle(24, 24, 14);\n g.fillStyle(0xfff0a0, 1);\n g.fillCircle(20, 20, 6);\n });\n}\n\n/** The hero's shot: an orb in the hero's colour. */\n/** A soft round shadow (drawn squashed under a character's feet). */\nexport function shadowTexture(scene: Phaser.Scene): string {\n const key = \"idle-shadow\";\n if (scene.textures.exists(key)) return key;\n const tex = scene.textures.createCanvas(key, 128, 128)!;\n const ctx = tex.getContext();\n const g = ctx.createRadialGradient(64, 64, 6, 64, 64, 62);\n g.addColorStop(0, \"rgba(20, 12, 24, .7)\");\n g.addColorStop(0.5, \"rgba(20, 12, 24, .45)\");\n g.addColorStop(1, \"rgba(20, 12, 24, 0)\");\n ctx.fillStyle = g;\n ctx.fillRect(0, 0, 128, 128);\n tex.refresh();\n return key;\n}\n\nexport function shotTexture(scene: Phaser.Scene, color: number): string {\n return bake(scene, `shot-${color.toString(16)}`, 28, 28, (g) => {\n g.fillStyle(color, 0.35);\n g.fillCircle(14, 14, 13);\n g.fillStyle(shade(color, 0.3), 1);\n g.fillCircle(14, 14, 8);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(12, 12, 3.5);\n });\n}\n\n/**\n * What stands on the road between the waves (no outline, like the backdrop — light from the upper\n * left): the camp — a tent by a fire; a fountain — a stone basin, its water green for health, red for\n * rage; a chest — wood bound in gold. Its foot is the texture's bottom middle.\n */\nexport function featureTexture(\n scene: Phaser.Scene,\n kind: \"camp\" | \"fountain\" | \"chest\",\n gives?: \"heal\" | \"rage\",\n): string {\n if (kind === \"camp\")\n return bake(scene, \"feature-camp\", 150, 110, (g) => {\n // the tent\n g.fillStyle(0x8a5a34, 1);\n g.fillTriangle(10, 104, 62, 18, 114, 104);\n g.fillStyle(0xb07a46, 1);\n g.fillTriangle(10, 104, 62, 18, 62, 104);\n g.fillStyle(0x3a2418, 1);\n g.fillTriangle(48, 104, 62, 60, 76, 104);\n g.fillStyle(0x6b4428, 1);\n g.fillRect(60, 10, 4, 12);\n // the fire: logs, flames, a glow\n g.fillStyle(0xffb03a, 0.25);\n g.fillCircle(130, 92, 18);\n g.fillStyle(0x5a3a22, 1);\n g.fillRoundedRect(114, 100, 34, 6, 3);\n g.fillStyle(0xff6a1a, 1);\n g.fillTriangle(118, 100, 130, 70, 142, 100);\n g.fillStyle(0xffd24a, 1);\n g.fillTriangle(123, 100, 130, 82, 137, 100);\n });\n if (kind === \"fountain\") {\n const water = gives === \"rage\" ? 0xff4d3a : 0x4ad8a0;\n return bake(scene, `feature-fountain-${gives ?? \"heal\"}`, 110, 110, (g) => {\n // the glow of what it gives\n g.fillStyle(water, 0.22);\n g.fillCircle(55, 40, 34);\n // the basin and the column\n g.fillStyle(0x8f96a8, 1);\n g.fillRoundedRect(8, 74, 94, 30, 10);\n g.fillStyle(0xb8bfd0, 1);\n g.fillRoundedRect(8, 74, 94, 12, { tl: 10, tr: 10, bl: 0, br: 0 });\n g.fillStyle(0x7d8496, 1);\n g.fillRect(47, 40, 16, 36);\n g.fillStyle(0xa9b0c2, 1);\n g.fillRoundedRect(32, 32, 46, 12, 6);\n // the water\n g.fillStyle(shade(water, -0.15), 1);\n g.fillRoundedRect(14, 76, 82, 8, 4);\n g.fillStyle(water, 1);\n g.fillCircle(55, 24, 9);\n g.fillStyle(shade(water, 0.5), 1);\n g.fillCircle(52, 21, 3);\n g.fillStyle(water, 0.8);\n g.fillRect(53, 30, 4, 10);\n });\n }\n return bake(scene, \"feature-chest\", 90, 74, (g) => {\n g.fillStyle(0x7a4a26, 1);\n g.fillRoundedRect(6, 30, 78, 42, 6);\n g.fillStyle(0x9a5f30, 1);\n g.fillRoundedRect(6, 8, 78, 30, { tl: 16, tr: 16, bl: 0, br: 0 });\n g.fillStyle(0xb87840, 1);\n g.fillRoundedRect(10, 10, 34, 10, 5);\n // the gold bands and the lock\n g.fillStyle(0xe8b33a, 1);\n g.fillRect(6, 34, 78, 6);\n g.fillRect(20, 8, 6, 64);\n g.fillRect(64, 8, 6, 64);\n g.fillStyle(0xffe08a, 1);\n g.fillRoundedRect(38, 30, 14, 16, 3);\n g.fillStyle(0x7a4a26, 1);\n g.fillCircle(45, 38, 2.5);\n });\n}\n\n/** A thrown axe (the cards' Axe): a wooden haft and a double steel head; the scene spins it. */\nexport function axeTexture(scene: Phaser.Scene): string {\n return bake(scene, \"card-axe\", 44, 44, (g) => {\n g.fillStyle(0x8a5a34, 1);\n g.fillRoundedRect(20, 4, 5, 38, 2);\n g.fillStyle(0xc9ced8, 1);\n g.fillTriangle(24, 8, 40, 4, 38, 22);\n g.fillTriangle(21, 8, 4, 4, 6, 22);\n g.fillStyle(0xf2f4f8, 1);\n g.fillTriangle(25, 9, 36, 7, 35, 15);\n });\n}\n\n/** A shot that is not an orb, flying right (towards the monsters): an arrow, a crossbow bolt, a\n * bullet with a streak of light behind it. Crits are tinted by the scene. */\nexport function missileTexture(\n scene: Phaser.Scene,\n kind: \"arrow\" | \"bolt\" | \"bullet\" | \"slash\" | \"thrust\",\n): string {\n if (kind === \"slash\")\n // The wave of a swing: a crescent of air, its round side forward, a white edge in a pale glow.\n return bake(scene, \"shot-slash\", 60, 100, (g) => {\n const crescent = (\n r: number,\n back: number,\n alpha: number,\n color: number,\n ) => {\n g.fillStyle(color, alpha);\n g.beginPath();\n g.arc(14, 50, r, -1.25, 1.25, false);\n g.arc(14 - back, 50, r + back * 0.35, 1.05, -1.05, true);\n g.closePath();\n g.fillPath();\n };\n crescent(45, 30, 0.3, 0x9fe8ff);\n crescent(42, 22, 0.6, 0xc8f4ff);\n crescent(39, 14, 1, 0xffffff);\n });\n if (kind === \"thrust\")\n // The wave of a stab: a narrow spike of air with a bright point.\n return bake(scene, \"shot-thrust\", 50, 18, (g) => {\n g.fillStyle(0x9fe8ff, 0.3);\n g.fillTriangle(0, 3, 49, 9, 0, 15);\n g.fillStyle(0xc8f4ff, 0.6);\n g.fillTriangle(8, 6, 48, 9, 8, 12);\n g.fillStyle(0xffffff, 1);\n g.fillTriangle(22, 7.5, 47, 9, 22, 10.5);\n });\n if (kind === \"arrow\")\n return bake(scene, \"shot-arrow\", 52, 14, (g) => {\n g.fillStyle(OUTLINE, 1);\n g.fillRect(6, 5, 36, 4);\n g.fillTriangle(40, 1, 51, 7, 40, 13);\n g.fillStyle(0xc8a070, 1);\n g.fillRect(7, 6, 34, 2);\n g.fillStyle(0xd8dde4, 1);\n g.fillTriangle(41, 3, 48.5, 7, 41, 11);\n g.fillStyle(0xe04040, 1);\n g.fillTriangle(1, 1, 12, 7, 5, 7);\n g.fillTriangle(1, 13, 12, 7, 5, 7);\n });\n if (kind === \"bolt\")\n return bake(scene, \"shot-bolt\", 38, 12, (g) => {\n g.fillStyle(OUTLINE, 1);\n g.fillRect(4, 4, 26, 4);\n g.fillTriangle(28, 0, 37, 6, 28, 12);\n g.fillStyle(0xb08860, 1);\n g.fillRect(5, 5, 24, 2);\n g.fillStyle(0xc0c8d0, 1);\n g.fillTriangle(29, 2, 35, 6, 29, 10);\n g.fillStyle(0xe04040, 1);\n g.fillTriangle(0, 2, 9, 6, 3, 6);\n g.fillTriangle(0, 10, 9, 6, 3, 6);\n });\n return bake(scene, \"shot-bullet\", 34, 10, (g) => {\n g.fillStyle(0xffe08a, 0.25);\n g.fillRect(0, 3, 24, 4);\n g.fillStyle(0xffe08a, 0.55);\n g.fillRect(10, 3.5, 16, 3);\n g.fillStyle(OUTLINE, 1);\n g.fillEllipse(28, 5, 12, 8);\n g.fillStyle(0xf2c040, 1);\n g.fillEllipse(28, 5, 8, 4.6);\n g.fillStyle(0xffffff, 1);\n g.fillCircle(29.5, 4, 1.2);\n });\n}\n"
424
+ },
425
+ {
426
+ "path": "phaser/backdrop.ts",
427
+ "content": "import type Phaser from \"phaser\";\nimport { seededRandom } from \"../game/random\";\nimport type { Theme } from \"../game/themes\";\n\n// The world behind the fight — a painted casual landscape per chapter theme, with no outlines: soft\n// gradients, light from the upper left (lit faces, shaded sides), a haze that fades the far planes\n// into the sky and contact shadows on the ground. Drawn once per theme and field size on canvases and\n// tiled sideways, so the road scrolls without end; the layers move at their own pace as the hero runs\n// (parallax): the sky with its sun stays, the far skyline barely moves, the hills behind and the near\n// props (cacti, palms, trees, rocks) faster, the road exactly with the world — the fighters stand on\n// it — and a strip of foreground at the bottom edge faster still.\n//\n// Every scrolling layer is one TILE wide and repeats seamlessly: skylines are periodic over the tile\n// and props keep clear of its edges.\n\nconst TILE = 1536;\n/** The sky does not scroll: wide enough for any screen. */\nconst SKY_W = 2560;\n\nexport interface BackdropLayout {\n w: number;\n h: number;\n /** The top edge of the road (the line the near props stand on). */\n horizon: number;\n}\n\ninterface Layer {\n sprite: Phaser.GameObjects.TileSprite;\n factor: number;\n}\n\ntype Painter = (\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n) => void;\n\nconst LAYERS: ReadonlyArray<{\n name: string;\n factor: number;\n width: number;\n paint: Painter;\n /** Over the fighters (the foreground strip) rather than behind them. */\n front?: boolean;\n}> = [\n { name: \"sky\", factor: 0, width: SKY_W, paint: paintSky },\n { name: \"far\", factor: 0.08, width: TILE, paint: paintFar },\n { name: \"hills\", factor: 0.22, width: TILE, paint: paintHills },\n { name: \"near\", factor: 0.45, width: TILE, paint: paintNear },\n { name: \"road\", factor: 1, width: TILE, paint: paintRoad },\n { name: \"front\", factor: 1.35, width: TILE, paint: paintFront, front: true },\n];\n\nexport class Backdrop {\n private layers: Layer[] = [];\n private key = \"\";\n\n constructor(private readonly scene: Phaser.Scene) {}\n\n /**\n * (Re)builds the layers for a theme and a field size; cheap when nothing changed. `depth` — the\n * layers behind the fighters start there, `frontDepth` — the foreground strip over them.\n */\n layout(\n theme: Theme,\n at: BackdropLayout,\n depth: number,\n frontDepth: number,\n ): void {\n const key = `${theme.id}-${Math.round(at.h)}-${Math.round(at.horizon)}`;\n if (key === this.key) {\n for (const l of this.layers) l.sprite.setSize(at.w, at.h);\n return;\n }\n this.key = key;\n for (const l of this.layers) l.sprite.destroy();\n this.layers = LAYERS.map((layer, i) => {\n const tex = `idle-bd2-${layer.name}-${key}`;\n if (!this.scene.textures.exists(tex)) {\n const canvas = this.scene.textures.createCanvas(\n tex,\n layer.width,\n at.h,\n )!;\n layer.paint(canvas.getContext(), theme, at.h, at.horizon);\n canvas.refresh();\n }\n const sprite = this.scene.add\n .tileSprite(0, 0, at.w, at.h, tex)\n .setOrigin(0, 0)\n .setDepth(layer.front ? frontDepth : depth + i * 0.1);\n return { sprite, factor: layer.factor };\n });\n }\n\n /** Scrolls the layers: `px` — how far the road has moved, in screen pixels. */\n scroll(px: number): void {\n for (const l of this.layers) l.sprite.tilePositionX = px * l.factor;\n }\n}\n\n// ── Colour helpers ───────────────────────────────────────────────────────────────────────────\n\ntype Rgb = [number, number, number];\nconst rgbOf = (n: number): Rgb => [(n >> 16) & 255, (n >> 8) & 255, n & 255];\nconst css = ([r, g, b]: Rgb, a = 1): string =>\n `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${a})`;\n\n/** `a` blended towards `b` by `t` (0 — a, 1 — b). */\nfunction mix(a: number, b: number, t: number): Rgb {\n const x = rgbOf(a);\n const y = rgbOf(b);\n return [0, 1, 2].map((i) => x[i]! + (y[i]! - x[i]!) * t) as Rgb;\n}\n\n/** Lighter (+) or darker (−) by a share. */\nfunction shade(c: number, amount: number): Rgb {\n return amount >= 0 ? mix(c, 0xffffff, amount) : mix(c, 0x000000, -amount);\n}\nconst hex = ([r, g, b]: Rgb): number =>\n (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b);\n\n/** A far colour: the theme's colour sunk into the sky's haze. */\nfunction hazed(c: number, theme: Theme, amount: number): number {\n return hex(mix(c, theme.sky[1], amount));\n}\n\nfunction vertical(\n ctx: CanvasRenderingContext2D,\n y0: number,\n y1: number,\n stops: ReadonlyArray<[number, string]>,\n): CanvasGradient {\n const g = ctx.createLinearGradient(0, y0, 0, y1);\n for (const [at, c] of stops) g.addColorStop(at, c);\n return g;\n}\n\n/** A soft dark ellipse on the ground under a prop. */\nfunction contactShadow(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n w: number,\n alpha = 0.28,\n): void {\n ctx.save();\n ctx.translate(x, y);\n ctx.scale(1, 0.22);\n const g = ctx.createRadialGradient(0, 0, 0, 0, 0, w);\n g.addColorStop(0, `rgba(30, 18, 28, ${alpha})`);\n g.addColorStop(1, \"rgba(30, 18, 28, 0)\");\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(0, 0, w, 0, Math.PI * 2);\n ctx.fill();\n ctx.restore();\n}\n\n/** A height profile periodic over the tile (whole waves only), so the skyline joins at the seam. */\nfunction profile(\n seed: number,\n waves: ReadonlyArray<[cycles: number, amp: number]>,\n): (x: number) => number {\n const rnd = seededRandom(seed);\n const phases = waves.map(() => rnd() * Math.PI * 2);\n return (x) =>\n waves.reduce(\n (sum, [k, a], i) =>\n sum + a * Math.sin((x / TILE) * Math.PI * 2 * k + phases[i]!),\n 0,\n );\n}\n\n/** A band from `base` up to a periodic top, filled with a vertical gradient, a soft lit rim on top. */\nfunction band(\n ctx: CanvasRenderingContext2D,\n top: (x: number) => number,\n base: number,\n fill: string | CanvasGradient,\n rim: string | null,\n): void {\n ctx.beginPath();\n ctx.moveTo(-10, base + 10);\n for (let x = -10; x <= TILE + 10; x += 6) ctx.lineTo(x, top(x));\n ctx.lineTo(TILE + 10, base + 10);\n ctx.closePath();\n ctx.fillStyle = fill;\n ctx.fill();\n if (!rim) return;\n ctx.beginPath();\n ctx.moveTo(-10, top(-10) + 1.5);\n for (let x = -4; x <= TILE + 10; x += 6) ctx.lineTo(x, top(x) + 1.5);\n ctx.lineWidth = 3;\n ctx.strokeStyle = rim;\n ctx.stroke();\n}\n\n/** Spots for props along the tile, clear of its edges (a prop never straddles the seam). */\nfunction spots(seed: number, count: number, margin: number): number[] {\n const rnd = seededRandom(seed);\n const step = (TILE - margin * 2) / count;\n return Array.from(\n { length: count },\n (_, i) => margin + step * (i + 0.2 + rnd() * 0.6),\n );\n}\n\n// ── The sky ──────────────────────────────────────────────────────────────────────────────────\n\nfunction paintSky(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const [top, low] = theme.sky;\n ctx.fillStyle = vertical(ctx, 0, horizon, [\n [0, css(rgbOf(top))],\n [0.7, css(mix(top, low, 0.75))],\n [1, css(rgbOf(low))],\n ]);\n ctx.fillRect(0, 0, SKY_W, horizon + 2);\n // the sun (a red moon over the volcano) with a wide soft glow\n const night = theme.id === \"volcano\";\n const sx = SKY_W * 0.36;\n const sy = horizon * 0.42;\n const r = Math.max(18, h * 0.07);\n const glow = ctx.createRadialGradient(sx, sy, r * 0.6, sx, sy, r * 6);\n glow.addColorStop(\n 0,\n night ? \"rgba(255, 120, 80, .45)\" : \"rgba(255, 252, 225, .7)\",\n );\n glow.addColorStop(\n 1,\n night ? \"rgba(255, 120, 80, 0)\" : \"rgba(255, 252, 225, 0)\",\n );\n ctx.fillStyle = glow;\n ctx.fillRect(sx - r * 6, sy - r * 6, r * 12, r * 12);\n const disc = ctx.createRadialGradient(\n sx - r * 0.3,\n sy - r * 0.3,\n 0,\n sx,\n sy,\n r,\n );\n disc.addColorStop(0, night ? \"#ffd2b0\" : \"#fffef2\");\n disc.addColorStop(1, night ? \"#ff7a4a\" : \"#fff1b0\");\n ctx.fillStyle = disc;\n ctx.beginPath();\n ctx.arc(sx, sy, r, 0, Math.PI * 2);\n ctx.fill();\n}\n\n// ── The far skyline ──────────────────────────────────────────────────────────────────────────\n\nfunction paintFar(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const far = hazed(theme.hillFar, theme, 0.42);\n const rnd = seededRandom(11);\n if (theme.id === \"desert\") {\n // Mesas: flat-topped plateaus, the lit top face, the banded cliffs, the shaded right side.\n for (const x of spots(12, 5, 150)) {\n const w = 150 + rnd() * 150;\n const top = horizon - h * (0.17 + rnd() * 0.14);\n const slant = 16 + rnd() * 18;\n const left = x - w / 2;\n const right = x + w / 2;\n ctx.beginPath();\n ctx.moveTo(left - slant, horizon + 4);\n ctx.lineTo(left, top + 6);\n ctx.quadraticCurveTo(left + 3, top, left + 14, top);\n ctx.lineTo(right - 14, top);\n ctx.quadraticCurveTo(right - 3, top, right, top + 6);\n ctx.lineTo(right + slant, horizon + 4);\n ctx.closePath();\n const side = ctx.createLinearGradient(left - slant, 0, right + slant, 0);\n side.addColorStop(0, css(shade(far, 0.1)));\n side.addColorStop(0.62, css(rgbOf(far)));\n side.addColorStop(1, css(shade(far, -0.16)));\n ctx.fillStyle = side;\n ctx.fill();\n ctx.save();\n ctx.clip();\n for (let y = top + 16, i = 0; y < horizon; y += 20 + (i % 2) * 6, i++) {\n ctx.fillStyle = css(shade(far, -0.1), 0.55);\n ctx.fillRect(left - slant, y, w + slant * 2, 5);\n }\n ctx.fillStyle = css(shade(far, 0.22));\n ctx.fillRect(left - 2, top, w + 4, 5);\n ctx.restore();\n }\n band(\n ctx,\n (x) =>\n horizon -\n 12 +\n profile(13, [\n [3, 5],\n [7, 3],\n ])(x),\n horizon,\n css(rgbOf(hazed(theme.hillFar, theme, 0.25))),\n null,\n );\n return;\n }\n if (theme.id === \"snow\" || theme.id === \"volcano\") {\n // Mountains lit from the left: a light face and a shaded one split along the ridge.\n for (const x of spots(22, 6, 130)) {\n const w = 190 + rnd() * 170;\n const peak = horizon - h * (0.22 + rnd() * 0.18);\n const px = x + (rnd() - 0.5) * w * 0.2;\n ctx.beginPath();\n ctx.moveTo(x - w / 2, horizon + 4);\n ctx.quadraticCurveTo(px - w * 0.12, peak + 18, px, peak);\n ctx.quadraticCurveTo(px + w * 0.14, peak + 22, x + w / 2, horizon + 4);\n ctx.closePath();\n ctx.fillStyle = css(shade(far, 0.06));\n ctx.fill();\n ctx.beginPath();\n ctx.moveTo(px, peak);\n ctx.quadraticCurveTo(px + w * 0.14, peak + 22, x + w / 2, horizon + 4);\n ctx.lineTo(px + w * 0.05, horizon + 4);\n ctx.quadraticCurveTo(px + w * 0.02, peak + 40, px, peak);\n ctx.fillStyle = css(shade(far, -0.14), 0.9);\n ctx.fill();\n if (theme.id === \"snow\") {\n ctx.beginPath();\n ctx.moveTo(px, peak);\n ctx.quadraticCurveTo(\n px - w * 0.08,\n peak + 22,\n px - w * 0.16,\n peak + 40,\n );\n ctx.quadraticCurveTo(px - w * 0.05, peak + 34, px, peak + 44);\n ctx.quadraticCurveTo(\n px + w * 0.06,\n peak + 30,\n px + w * 0.15,\n peak + 42,\n );\n ctx.quadraticCurveTo(px + w * 0.08, peak + 20, px, peak);\n ctx.fillStyle = \"rgba(255, 255, 255, .95)\";\n ctx.fill();\n } else {\n const lava = ctx.createRadialGradient(\n px,\n peak + 4,\n 0,\n px,\n peak + 4,\n 40,\n );\n lava.addColorStop(0, \"rgba(255, 170, 70, .95)\");\n lava.addColorStop(1, \"rgba(255, 90, 40, 0)\");\n ctx.fillStyle = lava;\n ctx.fillRect(px - 40, peak - 36, 80, 80);\n for (let i = 0; i < 4; i++) {\n ctx.beginPath();\n ctx.arc(\n px + i * 9 - 8,\n peak - 18 - i * 16,\n 12 + i * 5,\n 0,\n Math.PI * 2,\n );\n ctx.fillStyle = \"rgba(60, 40, 50, .28)\";\n ctx.fill();\n }\n }\n }\n band(\n ctx,\n (x) =>\n horizon -\n 16 +\n profile(23, [\n [4, 7],\n [9, 3],\n ])(x),\n horizon,\n css(rgbOf(hazed(theme.hillFar, theme, 0.25))),\n null,\n );\n return;\n }\n // Meadow and forest: a far row of soft rolling hills (the forest's crowned with pines).\n const top = (x: number) =>\n horizon -\n h * 0.19 +\n profile(31, [\n [2, h * 0.05],\n [5, h * 0.025],\n ])(x);\n band(\n ctx,\n top,\n horizon,\n vertical(ctx, horizon - h * 0.26, horizon, [\n [0, css(shade(far, 0.1))],\n [1, css(rgbOf(far))],\n ]),\n css(shade(far, 0.25), 0.6),\n );\n if (theme.id === \"forest\")\n for (let x = 20; x < TILE - 20; x += 22 + (x % 3) * 4)\n pine(ctx, x, top(x) + 10, 24, hazed(theme.grass, theme, 0.4), false);\n}\n\n// ── The hills behind the road ────────────────────────────────────────────────────────────────\n\nfunction paintHills(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const c = hazed(theme.hillNear, theme, 0.18);\n const top = (x: number) =>\n horizon -\n h * 0.085 +\n profile(35, [\n [3, h * 0.035],\n [7, h * 0.015],\n ])(x);\n band(\n ctx,\n top,\n horizon,\n vertical(ctx, horizon - h * 0.14, horizon, [\n [0, css(shade(c, 0.12))],\n [1, css(shade(c, -0.08))],\n ]),\n css(shade(c, 0.3), 0.55),\n );\n // small hazed silhouettes on the crest — distance in between\n for (const x of spots(36, 14, 40)) {\n const y = top(x) + 3;\n const size = h * 0.06;\n const tint = hazed(\n theme.id === \"desert\"\n ? 0x5fa84a\n : theme.id === \"volcano\"\n ? 0x4a3436\n : theme.grass,\n theme,\n 0.3,\n );\n if (theme.id === \"desert\") cactus(ctx, x, y, size, tint, false);\n else if (theme.id === \"meadow\")\n roundTree(ctx, x, y, size * 1.3, tint, false);\n else if (theme.id === \"volcano\") rock(ctx, x, y, size * 0.5, tint, false);\n else\n pine(ctx, x, y, size * 1.4, theme.id === \"snow\" ? 0x6f9a8c : tint, false);\n }\n}\n\n// ── The near props ───────────────────────────────────────────────────────────────────────────\n\nfunction paintNear(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n // a low dune / bank right behind the road\n const c = theme.hillNear;\n band(\n ctx,\n (x) =>\n horizon -\n h * 0.035 +\n profile(41, [\n [4, h * 0.018],\n [11, 3],\n ])(x),\n horizon,\n vertical(ctx, horizon - h * 0.06, horizon, [\n [0, css(shade(c, 0.08))],\n [1, css(shade(c, -0.12))],\n ]),\n css(shade(c, 0.3), 0.6),\n );\n const rnd = seededRandom(43);\n const base = horizon + 3;\n spots(44, 8, 110).forEach((x, i) => {\n const size = h * (0.17 + rnd() * 0.09);\n const lean: 1 | -1 = rnd() > 0.5 ? 1 : -1;\n switch (theme.id) {\n case \"desert\":\n if (i % 3 === 0) palm(ctx, x, base, size * 1.6, lean);\n else if (i % 3 === 1) cactus(ctx, x, base, size, 0x5fb04a, true);\n else rock(ctx, x, base, size * 0.42, 0xd8a86a, true);\n break;\n case \"meadow\":\n if (i % 3 === 2) bush(ctx, x, base, size * 0.5, theme.grass);\n else roundTree(ctx, x, base, size * 1.25, theme.grass, true);\n break;\n case \"forest\":\n if (i % 4 === 3) rock(ctx, x, base, size * 0.4, 0x8a8a96, true);\n else pine(ctx, x, base, size * 1.45, theme.grass, true);\n break;\n case \"snow\":\n if (i % 3 === 2) rock(ctx, x, base, size * 0.45, 0xb9d3ec, true);\n else snowyPine(ctx, x, base, size * 1.3);\n break;\n case \"volcano\":\n if (i % 3 === 0) deadTree(ctx, x, base, size * 1.15);\n else rock(ctx, x, base, size * 0.5, 0x4a3a40, true, true);\n break;\n }\n });\n}\n\n// ── The road ─────────────────────────────────────────────────────────────────────────────────\n\nfunction paintRoad(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n horizon: number,\n): void {\n const g = theme.ground;\n ctx.fillStyle = vertical(ctx, horizon, h, [\n [0, css(shade(g, 0.1))],\n [0.5, css(rgbOf(g))],\n [1, css(shade(g, -0.1))],\n ]);\n ctx.fillRect(0, horizon, TILE, h - horizon);\n // the far edge of the road, softly shaded by the bank behind it\n ctx.fillStyle = vertical(ctx, horizon, horizon + 14, [\n [0, css(shade(g, -0.18), 0.55)],\n [1, css(shade(g, -0.18), 0)],\n ]);\n ctx.fillRect(0, horizon, TILE, 14);\n const rnd = seededRandom(51);\n const span = h - horizon;\n if (theme.id === \"desert\" || theme.id === \"snow\") {\n // wind ripples in the sand / the snow\n for (let i = 0; i < 26; i++) {\n const x = 40 + rnd() * (TILE - 160);\n const y = horizon + 14 + rnd() * (span - 24);\n const w = 40 + rnd() * 70;\n ctx.beginPath();\n ctx.moveTo(x, y);\n ctx.quadraticCurveTo(x + w / 2, y - 4, x + w, y);\n ctx.lineWidth = 2.5;\n ctx.strokeStyle = css(shade(g, theme.id === \"snow\" ? -0.08 : 0.18), 0.7);\n ctx.stroke();\n ctx.beginPath();\n ctx.moveTo(x + 2, y + 3);\n ctx.quadraticCurveTo(x + w / 2, y - 1, x + w - 2, y + 3);\n ctx.lineWidth = 2;\n ctx.strokeStyle = css(shade(g, -0.14), 0.45);\n ctx.stroke();\n }\n } else if (theme.id === \"volcano\") {\n for (const x of spots(54, 7, 90)) {\n const y = horizon + 18 + rnd() * (span - 30);\n ctx.beginPath();\n ctx.moveTo(x - 30, y);\n ctx.lineTo(x - 10, y - 4);\n ctx.lineTo(x + 6, y + 2);\n ctx.lineTo(x + 28, y - 3);\n ctx.lineWidth = 3;\n ctx.strokeStyle = \"rgba(255, 110, 40, .75)\";\n ctx.stroke();\n }\n } else {\n // a grassy fringe along the far edge, worn patches on the path\n for (let x = 4; x < TILE - 4; x += 7) {\n const tall = 5 + ((x * 7) % 9);\n ctx.beginPath();\n ctx.moveTo(x - 3, horizon + 3);\n ctx.lineTo(x, horizon + 3 - tall);\n ctx.lineTo(x + 3, horizon + 3);\n ctx.fillStyle = css(shade(theme.grass, (x % 3) * 0.08));\n ctx.fill();\n }\n for (const x of spots(55, 10, 80)) {\n const y = horizon + 16 + rnd() * (span - 30);\n contactShadow(ctx, x, y, 40 + rnd() * 30, 0.12);\n }\n }\n // pebbles and tufts, clear of the tile's edges\n for (const x of spots(52, 24, 30)) {\n const y = horizon + 12 + rnd() * (span - 20);\n if (rnd() < 0.6) pebble(ctx, x, y, 3 + rnd() * 5, g);\n else\n tuft(\n ctx,\n x,\n y + 4,\n theme.id === \"snow\" ? 0x9fc0dd : theme.grass,\n 0.8 + rnd() * 0.5,\n );\n }\n}\n\n// ── The foreground strip ─────────────────────────────────────────────────────────────────────\n\nfunction paintFront(\n ctx: CanvasRenderingContext2D,\n theme: Theme,\n h: number,\n): void {\n // a few large, darker shapes cut by the bottom edge — close to the eye, moving fastest\n const rnd = seededRandom(61);\n const c =\n theme.id === \"snow\"\n ? 0xc9dcef\n : theme.id === \"desert\"\n ? 0xc99a58\n : theme.grass;\n for (const x of spots(62, 5, 140)) {\n const r = h * (0.07 + rnd() * 0.05);\n const y = h + r * 0.35;\n if (theme.id === \"meadow\" || theme.id === \"forest\") {\n for (let i = -3; i <= 3; i++) {\n ctx.beginPath();\n ctx.moveTo(x + i * r * 0.28 - r * 0.12, h + 2);\n ctx.quadraticCurveTo(\n x + i * r * 0.3,\n h - r * (1.1 - Math.abs(i) * 0.14),\n x + i * r * 0.34 + r * 0.18,\n h - r * (1.25 - Math.abs(i) * 0.14),\n );\n ctx.quadraticCurveTo(\n x + i * r * 0.3 + r * 0.06,\n h - r * 0.4,\n x + i * r * 0.28 + r * 0.12,\n h + 2,\n );\n ctx.fillStyle = css(shade(c, -0.25 + (i % 2) * 0.06));\n ctx.fill();\n }\n } else {\n const gr = ctx.createRadialGradient(\n x - r * 0.3,\n y - r * 0.6,\n r * 0.1,\n x,\n y,\n r * 1.2,\n );\n gr.addColorStop(0, css(shade(c, -0.05)));\n gr.addColorStop(1, css(shade(c, -0.35)));\n ctx.fillStyle = gr;\n ctx.beginPath();\n ctx.ellipse(x, y, r * 1.4, r, 0, Math.PI, 0);\n ctx.fill();\n }\n }\n}\n\n// ── Props (light from the upper left, a contact shadow under each) ───────────────────────────\n\nfunction cactus(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n color: number,\n detail: boolean,\n): void {\n const w = size * 0.24;\n if (detail) contactShadow(ctx, x + w * 0.4, base, size * 0.5);\n const body = (x0: number, x1: number): CanvasGradient => {\n const g = ctx.createLinearGradient(x0, 0, x1, 0);\n g.addColorStop(0, css(shade(color, 0.18)));\n g.addColorStop(0.45, css(rgbOf(color)));\n g.addColorStop(1, css(shade(color, -0.25)));\n return g;\n };\n const arm = (dir: 1 | -1, at: number, up: number) => {\n const ax = x + dir * w * 0.45;\n const tip = ax + dir * w * 1.3;\n ctx.beginPath();\n ctx.moveTo(ax, base - at);\n ctx.lineTo(tip - dir * w * 0.3, base - at);\n ctx.quadraticCurveTo(tip, base - at, tip, base - at - w * 0.4);\n ctx.lineTo(tip, base - at - up);\n ctx.arc(\n tip - dir * w * 0.3,\n base - at - up,\n w * 0.3,\n dir > 0 ? 0 : Math.PI,\n dir > 0 ? Math.PI : 0,\n dir > 0,\n );\n ctx.lineTo(tip - dir * w * 0.6, base - at - w * 0.45);\n ctx.lineTo(ax, base - at - w * 0.45);\n ctx.closePath();\n ctx.fillStyle = body(Math.min(ax, tip) - 2, Math.max(ax, tip) + 2);\n ctx.fill();\n };\n arm(-1, size * 0.42, size * 0.28);\n arm(1, size * 0.58, size * 0.22);\n ctx.beginPath();\n ctx.moveTo(x - w / 2, base);\n ctx.lineTo(x - w / 2, base - size + w / 2);\n ctx.arc(x, base - size + w / 2, w / 2, Math.PI, 0);\n ctx.lineTo(x + w / 2, base);\n ctx.closePath();\n ctx.fillStyle = body(x - w / 2, x + w / 2);\n ctx.fill();\n if (!detail) return;\n // ribs: lit lines, then the flower\n for (const dx of [-0.22, 0.12]) {\n ctx.beginPath();\n ctx.moveTo(x + dx * w, base - 4);\n ctx.lineTo(x + dx * w, base - size + w * 0.8);\n ctx.lineWidth = 1.6;\n ctx.strokeStyle = css(shade(color, dx < 0 ? 0.35 : -0.35), 0.6);\n ctx.stroke();\n }\n const fx = x;\n const fy = base - size + 1;\n for (let i = 0; i < 5; i++) {\n const a = (i / 5) * Math.PI * 2;\n ctx.beginPath();\n ctx.ellipse(\n fx + Math.cos(a) * w * 0.18,\n fy + Math.sin(a) * w * 0.12,\n w * 0.16,\n w * 0.1,\n a,\n 0,\n Math.PI * 2,\n );\n ctx.fillStyle = \"#ff6f96\";\n ctx.fill();\n }\n ctx.beginPath();\n ctx.arc(fx, fy, w * 0.09, 0, Math.PI * 2);\n ctx.fillStyle = \"#ffe066\";\n ctx.fill();\n}\n\nfunction palm(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n lean: 1 | -1,\n): void {\n const topX = x + lean * size * 0.24;\n const topY = base - size;\n contactShadow(ctx, x + size * 0.1, base, size * 0.32);\n // the trunk: curved, banded, lit on the left\n ctx.beginPath();\n ctx.moveTo(x - 9, base);\n ctx.quadraticCurveTo(\n x + lean * size * 0.04,\n base - size * 0.5,\n topX - 5,\n topY,\n );\n ctx.lineTo(topX + 5, topY);\n ctx.quadraticCurveTo(x + lean * size * 0.13, base - size * 0.5, x + 9, base);\n ctx.closePath();\n const trunk = ctx.createLinearGradient(x - 10, 0, x + 12, 0);\n trunk.addColorStop(0, \"#e2a868\");\n trunk.addColorStop(1, \"#9a6236\");\n ctx.fillStyle = trunk;\n ctx.fill();\n for (let i = 1; i < 8; i++) {\n const t = i / 8;\n const sx = x + (topX - x) * t * t;\n const sy = base - size * t;\n ctx.beginPath();\n ctx.ellipse(sx, sy, 8 - t * 3, 2.2, lean * 0.15 * t, 0, Math.PI);\n ctx.fillStyle = \"rgba(110, 60, 30, .45)\";\n ctx.fill();\n }\n // fronds: two-tone leaves arching out of the crown\n const leaves: Array<[number, number]> = [\n [-1, 0.32],\n [-0.85, -0.3],\n [-0.25, -0.62],\n [0.35, -0.55],\n [0.9, -0.22],\n [1, 0.36],\n ];\n for (const [dx, dy] of leaves) {\n const ex = topX + dx * size * 0.46;\n const ey = topY + dy * size * 0.3 + size * 0.13;\n const cx = topX + dx * size * 0.25;\n const cy = topY + dy * size * 0.36 - size * 0.12;\n ctx.beginPath();\n ctx.moveTo(topX, topY);\n ctx.quadraticCurveTo(cx, cy, ex, ey);\n ctx.quadraticCurveTo(\n topX + dx * size * 0.22,\n topY + dy * size * 0.2 + 4,\n topX,\n topY + 7,\n );\n ctx.closePath();\n const leaf = ctx.createLinearGradient(topX, topY - size * 0.2, ex, ey + 6);\n leaf.addColorStop(0, \"#7fd660\");\n leaf.addColorStop(1, \"#3b8c3a\");\n ctx.fillStyle = leaf;\n ctx.fill();\n ctx.beginPath();\n ctx.moveTo(topX, topY + 2);\n ctx.quadraticCurveTo(cx, cy + 5, ex, ey);\n ctx.lineWidth = 1.4;\n ctx.strokeStyle = \"rgba(240, 255, 200, .45)\";\n ctx.stroke();\n }\n for (const [dx, dy] of [\n [-4, 7],\n [5, 9],\n [0, 12],\n ] as const) {\n const g = ctx.createRadialGradient(\n topX + dx - 1.5,\n topY + dy - 1.5,\n 0.5,\n topX + dx,\n topY + dy,\n 5,\n );\n g.addColorStop(0, \"#b98050\");\n g.addColorStop(1, \"#6a4022\");\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(topX + dx, topY + dy, 5, 0, Math.PI * 2);\n ctx.fill();\n }\n}\n\nfunction rock(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n color: number,\n detail: boolean,\n glow = false,\n): void {\n if (detail) contactShadow(ctx, x + size * 0.15, base, size * 1.3, 0.3);\n ctx.beginPath();\n ctx.moveTo(x - size, base);\n ctx.quadraticCurveTo(\n x - size * 1.05,\n base - size * 0.7,\n x - size * 0.4,\n base - size * 0.88,\n );\n ctx.quadraticCurveTo(\n x + size * 0.2,\n base - size * 1.08,\n x + size * 0.75,\n base - size * 0.6,\n );\n ctx.quadraticCurveTo(x + size * 1.05, base - size * 0.3, x + size, base);\n ctx.closePath();\n const g = ctx.createRadialGradient(\n x - size * 0.4,\n base - size * 0.8,\n size * 0.1,\n x,\n base - size * 0.3,\n size * 1.3,\n );\n g.addColorStop(0, css(shade(color, 0.22)));\n g.addColorStop(0.6, css(rgbOf(color)));\n g.addColorStop(1, css(shade(color, -0.3)));\n ctx.fillStyle = g;\n ctx.fill();\n if (!detail) return;\n ctx.beginPath();\n ctx.ellipse(\n x - size * 0.3,\n base - size * 0.7,\n size * 0.26,\n size * 0.1,\n -0.35,\n 0,\n Math.PI * 2,\n );\n ctx.fillStyle = \"rgba(255, 255, 255, .28)\";\n ctx.fill();\n if (glow) {\n ctx.beginPath();\n ctx.moveTo(x - size * 0.5, base - size * 0.2);\n ctx.lineTo(x - size * 0.1, base - size * 0.45);\n ctx.lineTo(x + size * 0.3, base - size * 0.25);\n ctx.lineWidth = 3;\n ctx.strokeStyle = \"#ff8a3a\";\n ctx.stroke();\n }\n}\n\nfunction pebble(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n r: number,\n ground: number,\n): void {\n contactShadow(ctx, x + r * 0.4, y + r * 0.4, r * 1.6, 0.25);\n const g = ctx.createRadialGradient(\n x - r * 0.4,\n y - r * 0.4,\n 0,\n x,\n y,\n r * 1.2,\n );\n g.addColorStop(0, css(shade(ground, 0.12)));\n g.addColorStop(1, css(shade(ground, -0.28)));\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.ellipse(x, y, r * 1.3, r * 0.75, 0, 0, Math.PI * 2);\n ctx.fill();\n}\n\nfunction roundTree(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n green: number,\n detail: boolean,\n): void {\n if (detail) contactShadow(ctx, x + size * 0.08, base, size * 0.42);\n ctx.beginPath();\n ctx.moveTo(x - size * 0.05, base);\n ctx.lineTo(x - size * 0.035, base - size * 0.52);\n ctx.lineTo(x + size * 0.035, base - size * 0.52);\n ctx.lineTo(x + size * 0.05, base);\n ctx.closePath();\n const trunk = ctx.createLinearGradient(\n x - size * 0.05,\n 0,\n x + size * 0.05,\n 0,\n );\n trunk.addColorStop(0, \"#b07a48\");\n trunk.addColorStop(1, \"#6e4626\");\n ctx.fillStyle = trunk;\n ctx.fill();\n const r = size * 0.3;\n const blobs: Array<[number, number, number]> = [\n [-0.62, -0.58, 0.8],\n [0.62, -0.6, 0.8],\n [0, -0.82, 1],\n [-0.3, -0.98, 0.62],\n [0.32, -0.96, 0.6],\n ];\n for (const [dx, dy, k] of blobs) {\n const cx = x + r * dx;\n const cy = base + size * dy;\n const g = ctx.createRadialGradient(\n cx - r * k * 0.35,\n cy - r * k * 0.4,\n r * k * 0.1,\n cx,\n cy,\n r * k,\n );\n g.addColorStop(0, css(shade(green, 0.28)));\n g.addColorStop(0.7, css(shade(green, 0.02)));\n g.addColorStop(1, css(shade(green, -0.22)));\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(cx, cy, r * k, 0, Math.PI * 2);\n ctx.fill();\n }\n}\n\nfunction bush(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n green: number,\n): void {\n contactShadow(ctx, x + size * 0.1, base, size * 1.3);\n const blobs: Array<[number, number, number]> = [\n [-0.5, -0.32, 0.45],\n [0.2, -0.46, 0.55],\n [0.8, -0.3, 0.38],\n ];\n for (const [dx, dy, k] of blobs) {\n const cx = x + size * dx;\n const cy = base + size * dy;\n const r = size * k;\n const g = ctx.createRadialGradient(\n cx - r * 0.35,\n cy - r * 0.4,\n r * 0.1,\n cx,\n cy,\n r,\n );\n g.addColorStop(0, css(shade(green, 0.3)));\n g.addColorStop(1, css(shade(green, -0.18)));\n ctx.fillStyle = g;\n ctx.beginPath();\n ctx.arc(cx, cy, r, Math.PI, 0);\n ctx.lineTo(cx + r, base);\n ctx.lineTo(cx - r, base);\n ctx.closePath();\n ctx.fill();\n }\n for (const [dx, c] of [\n [-0.45, \"#ffffff\"],\n [0.25, \"#ffd34d\"],\n [0.75, \"#ff7aa8\"],\n ] as const) {\n ctx.beginPath();\n ctx.arc(x + size * dx, base - size * 0.52, 3, 0, Math.PI * 2);\n ctx.fillStyle = c;\n ctx.fill();\n }\n}\n\nfunction pine(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n color: number,\n detail: boolean,\n): void {\n if (detail) {\n contactShadow(ctx, x + size * 0.06, base, size * 0.34);\n ctx.fillStyle = \"#7a5a3a\";\n ctx.fillRect(x - size * 0.035, base - size * 0.2, size * 0.07, size * 0.2);\n }\n for (let i = 0; i < 3; i++) {\n const y = base - size * (0.15 + i * 0.27);\n const w = size * (0.42 - i * 0.1);\n const tip = y - size * 0.42;\n ctx.beginPath();\n ctx.moveTo(x - w, y);\n ctx.lineTo(x, tip);\n ctx.lineTo(x + w, y);\n ctx.quadraticCurveTo(x, y + size * 0.05, x - w, y);\n ctx.closePath();\n if (detail) {\n const g = ctx.createLinearGradient(x - w, 0, x + w, 0);\n g.addColorStop(0, css(shade(color, 0.2)));\n g.addColorStop(0.5, css(rgbOf(color)));\n g.addColorStop(0.5, css(shade(color, -0.16)));\n g.addColorStop(1, css(shade(color, -0.28)));\n ctx.fillStyle = g;\n } else ctx.fillStyle = css(rgbOf(color));\n ctx.fill();\n }\n}\n\nfunction snowyPine(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n): void {\n pine(ctx, x, base, size, 0x3f7a62, true);\n for (let i = 0; i < 3; i++) {\n const y = base - size * (0.15 + i * 0.27) - size * 0.31;\n const w = size * (0.2 - i * 0.045);\n ctx.beginPath();\n ctx.moveTo(x - w, y + size * 0.09);\n ctx.lineTo(x, y - size * 0.11);\n ctx.lineTo(x + w, y + size * 0.09);\n ctx.quadraticCurveTo(x, y + size * 0.03, x - w, y + size * 0.09);\n ctx.fillStyle = \"rgba(255, 255, 255, .95)\";\n ctx.fill();\n }\n}\n\nfunction deadTree(\n ctx: CanvasRenderingContext2D,\n x: number,\n base: number,\n size: number,\n): void {\n contactShadow(ctx, x + size * 0.1, base, size * 0.34);\n ctx.lineCap = \"round\";\n const branch = (\n x0: number,\n y0: number,\n x1: number,\n y1: number,\n w: number,\n ) => {\n const g = ctx.createLinearGradient(x0 - w, y0, x0 + w, y0);\n g.addColorStop(0, \"#8a6a58\");\n g.addColorStop(1, \"#4a3430\");\n ctx.beginPath();\n ctx.moveTo(x0, y0);\n ctx.lineTo(x1, y1);\n ctx.lineWidth = w;\n ctx.strokeStyle = g;\n ctx.stroke();\n };\n branch(x, base, x + size * 0.05, base - size * 0.75, 10);\n branch(\n x + size * 0.03,\n base - size * 0.45,\n x - size * 0.3,\n base - size * 0.72,\n 6,\n );\n branch(\n x + size * 0.05,\n base - size * 0.6,\n x + size * 0.32,\n base - size * 0.85,\n 6,\n );\n branch(\n x - size * 0.2,\n base - size * 0.64,\n x - size * 0.28,\n base - size * 0.9,\n 4,\n );\n}\n\nfunction tuft(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n color: number,\n k: number,\n): void {\n const blades: Array<[number, number]> = [\n [-6, 8],\n [-2.5, 12],\n [1, 10],\n [4.5, 13],\n [7, 7],\n ];\n for (const [dx, tall] of blades) {\n ctx.beginPath();\n ctx.moveTo(x + dx * k - 2, y);\n ctx.quadraticCurveTo(\n x + dx * k,\n y - tall * k * 0.6,\n x + dx * k + dx * 0.25,\n y - tall * k,\n );\n ctx.quadraticCurveTo(x + dx * k + 1, y - tall * k * 0.5, x + dx * k + 2, y);\n ctx.closePath();\n ctx.fillStyle = css(shade(color, dx < 0 ? 0.15 : -0.12));\n ctx.fill();\n }\n}\n"
428
+ },
429
+ {
430
+ "path": "phaser/bars.ts",
431
+ "content": "import type Phaser from \"phaser\";\nimport { formatBig } from \"../game/format\";\n\n// The bars under a fighter's feet (Obsidian Knight's sheet): the red health bar with the health left\n// on it (the number alone, no \"/max\"), and — for the hero and for an enemy in plate, while any armor\n// is left — the blue armor bar right under it, which takes the blows first. Drawn on one Graphics\n// plus a text per bar; the texts change only when the numbers do.\n\n// the hero's sheet under the road (ui/RunStats.tsx) draws its health and armor bars with these too\nexport const INK = 0x2b2230;\nexport const HP = { fill: 0xe8433f, light: 0xff7a6a, track: 0x5a2230 };\nexport const ARMOR = { fill: 0x3d8ef5, light: 0x8fc3ff, track: 0x1f2f5a };\n\nexport interface BarValues {\n hp: number;\n maxHp: number;\n armor: number;\n maxArmor: number;\n}\n\nexport class StatusBars {\n private readonly g: Phaser.GameObjects.Graphics;\n private readonly hpText: Phaser.GameObjects.Text;\n private readonly armorText: Phaser.GameObjects.Text;\n private shown = { hp: \"\", armor: \"\" };\n\n constructor(scene: Phaser.Scene, depth: number) {\n this.g = scene.add.graphics().setDepth(depth);\n const text = () =>\n scene.add\n .text(0, 0, \"\", {\n fontFamily: \"Rubik, system-ui, sans-serif\",\n fontStyle: \"900\",\n fontSize: \"11px\",\n color: \"#ffffff\",\n stroke: \"#2b2230\",\n strokeThickness: 3,\n })\n .setOrigin(0.5, 0.5)\n .setDepth(depth + 0.1);\n this.hpText = text();\n this.armorText = text();\n }\n\n /**\n * Draws the bars centred at `x`, their top at `y`, `w` wide. The armor bar shows only while there is\n * armor left — a broken one is gone until the armor is whole again.\n */\n draw(x: number, y: number, w: number, v: BarValues): void {\n const g = this.g.clear();\n const h = 13;\n this.bar(g, x, y, w, h, v.hp / Math.max(1, v.maxHp), HP);\n this.label(this.hpText, \"hp\", num(v.hp), x, y + h / 2);\n const armored = v.maxArmor > 0 && v.armor > 0;\n this.armorText.setVisible(armored);\n if (!armored) return;\n const ay = y + h + 3;\n this.bar(g, x, ay, w, h - 2, v.armor / v.maxArmor, ARMOR);\n this.label(this.armorText, \"armor\", num(v.armor), x, ay + (h - 2) / 2);\n }\n\n setVisible(visible: boolean): void {\n this.g.setVisible(visible);\n this.hpText.setVisible(visible);\n if (!visible) this.armorText.setVisible(false);\n }\n\n destroy(): void {\n this.g.destroy();\n this.hpText.destroy();\n this.armorText.destroy();\n }\n\n private bar(\n g: Phaser.GameObjects.Graphics,\n x: number,\n y: number,\n w: number,\n h: number,\n share: number,\n c: { fill: number; light: number; track: number },\n ): void {\n const s = Math.max(0, Math.min(1, share));\n const left = x - w / 2;\n g.fillStyle(INK, 1);\n g.fillRoundedRect(left - 2, y - 2, w + 4, h + 4, 5);\n g.fillStyle(c.track, 1);\n g.fillRoundedRect(left, y, w, h, 3);\n if (s <= 0) return;\n const fw = Math.max(4, w * s);\n g.fillStyle(c.fill, 1);\n g.fillRoundedRect(left, y, fw, h, 3);\n // the lit top half\n g.fillStyle(c.light, 0.55);\n g.fillRoundedRect(left + 1, y + 1, Math.max(2, fw - 2), h * 0.4, 2);\n }\n\n private label(\n t: Phaser.GameObjects.Text,\n key: \"hp\" | \"armor\",\n value: string,\n x: number,\n y: number,\n ): void {\n if (this.shown[key] !== value) {\n this.shown[key] = value;\n t.setText(value);\n }\n t.setPosition(x, y);\n }\n}\n\nconst num = (n: number): string => formatBig(Math.max(0, Math.ceil(n)));\n"
432
+ },
433
+ {
434
+ "path": "phaser/BattleScene.ts",
435
+ "content": "import Phaser from \"phaser\";\nimport {\n BOSS_SIZE,\n HERO_X,\n type Battle,\n type BattleEvent,\n type Monster,\n} from \"../game/battle\";\nimport type { Bolt } from \"../game/cards\";\nimport type { Effect } from \"../game/damage\";\nimport { formatBig } from \"../game/format\";\nimport type { RunSession } from \"../game/session\";\nimport type { Theme } from \"../game/themes\";\nimport { LANE_MAX_PX, battleHeight } from \"../layout\";\nimport {\n axeTexture,\n bakeCommon,\n featureTexture,\n heroColor,\n missileTexture,\n shadowTexture,\n shotTexture,\n} from \"./art\";\nimport { heroRig, type LookSource } from \"../character\";\nimport { Backdrop } from \"./backdrop\";\nimport { StatusBars } from \"./bars\";\nimport { EnemyActor } from \"./enemy\";\nimport { footPixels } from \"./gait\";\nimport { HERO_SCALE, HeroActor } from \"./hero\";\n\n// Draws the run the session simulates: it owns no rules. Each frame it advances the session's\n// battle, turns the returned events into effects (numbers, coins, bursts) and puts the sprites where\n// the simulation says things are. The road scrolls under the hero (it stays at HERO_X on screen):\n// the backdrop's layers follow it at their own pace (parallax), the troops — people dressed in the\n// gear that gives their types — stand on the road, and so do the camp, the fountains and the chests.\n// A new run (a new Battle) clears the road. The field is the top part of the canvas (layout.ts); the\n// panel covers the rest. A blow's number is coloured by the Warcraft table: green — the attack type\n// is strong against that armor, grey — weak.\n\ninterface EnemyView {\n actor: EnemyActor;\n bars: StatusBars;\n /** Where it last stood on the road — a fallen enemy stays there while the road moves on. */\n x: number;\n /** Who it is (a fallen one keeps its size through a resize). */\n monster: Monster;\n}\n\nexport interface BattleSceneOptions {\n /** How the hero is dressed (character/looks): the default chain when omitted; a project or a dev\n * page adds a source (the player's saved choice, a weapon picked in a preview). */\n lookChain?: readonly LookSource[];\n}\n\n/** Enemies stand a size bigger than the hero only when they are the boss (the battle keeps them that much farther apart). */\nconst BOSS_SCALE = BOSS_SIZE;\n/** The run is played at most this fast: a wider road walks the hero slower, never skating. */\nconst MAX_GAIT = 2.2;\n/** Characters stand at most this share of the field's height. */\nconst HERO_SHARE = 0.42;\n/** The bars start this far under the feet (below the shadow). */\nconst BARS_BELOW = 10;\n/** The rig's height, skeleton units (× scale = a character's height on screen). */\nconst RIG_HEIGHT = heroRig.data.height;\n/** Screen pixels a lane width of the road takes at unit 1 (the size the sprites were drawn for). */\nconst ROAD_PX = 620;\n\ntype FloatKind = \"hit\" | \"strong\" | \"weak\" | \"crit\" | \"hurt\" | \"armor\";\nconst FLOAT_COLORS: Record<FloatKind, string> = {\n hit: \"#ffffff\",\n strong: \"#8dff6a\",\n weak: \"#b9b6c8\",\n crit: \"#ffb02e\",\n hurt: \"#ff5a4f\",\n armor: \"#7fc0ff\",\n};\n/** A blow's number by how its attack type fares against the armor (damage.ts). */\nconst EFFECT_KIND: Record<Effect, FloatKind> = {\n strong: \"strong\",\n even: \"hit\",\n weak: \"weak\",\n};\n\nexport class BattleScene extends Phaser.Scene {\n private field = {\n w: 480,\n h: 360,\n laneLeft: 0,\n laneW: 480,\n horizon: 220,\n ground: 290,\n unit: 1,\n road: ROAD_PX,\n };\n private sky?: Phaser.GameObjects.Graphics;\n private backdrop?: Backdrop;\n private clouds: Phaser.GameObjects.Image[] = [];\n private theme: Theme | null = null;\n private hero?: HeroActor;\n private heroBars?: StatusBars;\n private enemies = new Map<number, EnemyView>();\n /** Killed (or left) enemies still on the road until they fade. */\n private corpses: EnemyView[] = [];\n private killed = new Set<number>();\n private features = new Map<number, Phaser.GameObjects.Image>();\n /** The run the views were made for — a new one clears the road. */\n private drawn: Battle | null = null;\n private heroShadow?: Phaser.GameObjects.Image;\n private shots = new Map<number, Phaser.GameObjects.Image>();\n private enemyShots = new Map<number, Phaser.GameObjects.Image>();\n private sparks?: Phaser.GameObjects.Particles.ParticleEmitter;\n private puffs?: Phaser.GameObjects.Particles.ParticleEmitter;\n private heals?: Phaser.GameObjects.Particles.ParticleEmitter;\n private fire?: Phaser.GameObjects.Particles.ParticleEmitter;\n private frenzyTween?: Phaser.Tweens.Tween;\n /** Pixels the hero is pushed back by its own blow; decays every frame. */\n private recoil = 0;\n\n constructor(\n private readonly session: RunSession,\n private readonly options: BattleSceneOptions = {},\n ) {\n super(\"hero-run-battle\");\n }\n\n create(): void {\n bakeCommon(this);\n this.sky = this.add.graphics().setDepth(0);\n this.backdrop = new Backdrop(this);\n for (let i = 0; i < 4; i++)\n this.clouds.push(this.add.image(0, 0, \"cloud\").setDepth(1).setAlpha(0.9));\n this.heroBars = new StatusBars(this, 30);\n this.heroShadow = this.add.image(0, 0, shadowTexture(this)).setDepth(12);\n this.hero = new HeroActor(this, this.options.lookChain);\n this.hero.view.setDepth(20);\n\n const particles = (\n texture: string,\n config: Phaser.Types.GameObjects.Particles.ParticleEmitterConfig,\n ) =>\n this.add\n .particles(0, 0, texture, { emitting: false, ...config })\n .setDepth(40);\n this.sparks = particles(\"spark\", {\n speed: { min: 80, max: 260 },\n lifespan: 420,\n scale: { start: 0.9, end: 0 },\n tint: [0xffffff, 0xffe27a, 0xffa53d],\n });\n this.puffs = particles(\"puff\", {\n speed: { min: 30, max: 120 },\n lifespan: 520,\n scale: { start: 0.9, end: 0.1 },\n alpha: { start: 0.8, end: 0 },\n });\n this.heals = particles(\"spark\", {\n speedY: { min: -140, max: -60 },\n speedX: { min: -40, max: 40 },\n lifespan: 900,\n scale: { start: 1, end: 0 },\n tint: 0x6dff8a,\n });\n this.fire = particles(\"spark\", {\n speed: { min: 120, max: 340 },\n lifespan: 520,\n scale: { start: 1.6, end: 0 },\n tint: [0xff4d1a, 0xffa53d, 0xffe27a],\n });\n\n this.scale.on(\"resize\", () => this.layout());\n this.layout();\n }\n\n // ── Layout ─────────────────────────────────────────────────────────────────────────────────\n\n private layout(): void {\n const w = this.scale.width;\n const h = battleHeight(this.scale.height);\n const laneW = Math.min(w, LANE_MAX_PX);\n this.field = {\n w,\n h,\n laneLeft: (w - laneW) / 2,\n laneW,\n // the road starts a little below the middle; the fighters stand on its far third\n horizon: Math.round(h * 0.56),\n ground: Math.round(h * 0.8),\n // Sprites are drawn for a ~620 px lane: a phone shrinks them so the hero does not cover the\n // first enemy, a wide field grows them — never taller than HERO_SHARE of the field.\n unit: Math.min(\n 1.35,\n Math.max(0.6, laneW / 620),\n (h * HERO_SHARE) / (RIG_HEIGHT * HERO_SCALE),\n ),\n road: ROAD_PX,\n };\n // The road is measured in the people standing on it, not in the screen: a lane width is as many\n // pixels as the characters' size makes it, so the line, the reach and the gaps keep their look on\n // any screen and through a resize (a narrow screen shows fewer enemies, never squeezed ones).\n this.field.road = ROAD_PX * this.field.unit;\n this.theme = null; // repaint the background for the new size\n this.paintBackground();\n this.clouds.forEach((c, i) =>\n c.setPosition(\n ((i + 0.3) / this.clouds.length) * w,\n h * (0.1 + 0.08 * (i % 2)),\n ),\n );\n }\n\n /** A point of the road on screen. */\n private roadX(x: number): number {\n return this.heroPos().x + (x - this.session.battle.heroX) * this.field.road;\n }\n\n private paintBackground(): void {\n const theme = this.session.battle.level.island.theme;\n if (theme === this.theme || !this.sky || !this.backdrop) return;\n this.theme = theme;\n const { w, h, horizon } = this.field;\n this.cameras.main.setBackgroundColor(theme.sky[0]);\n const sky = this.sky.clear();\n sky.fillGradientStyle(\n theme.sky[0],\n theme.sky[0],\n theme.sky[1],\n theme.sky[1],\n 1,\n );\n sky.fillRect(0, 0, w, horizon + 4);\n // behind everything from depth 2; the foreground strip over the fighters, under shots and bars\n this.backdrop.layout(theme, { w, h, horizon }, 2, 22);\n }\n\n // ── Frame ──────────────────────────────────────────────────────────────────────────────────\n\n override update(_time: number, deltaMs: number): void {\n const dt = deltaMs / 1000;\n const battle = this.session.battle;\n if (battle !== this.drawn) this.clearRoad(battle);\n if (this.hero) {\n // what the hero holds decides how close it fights (a sword at the enemy, a bow from afar)\n battle.setReach(this.hero.isMelee() ? \"melee\" : \"ranged\");\n // the first blow on someone comes a whole swing after the stop, and plays out before the run\n const swing = this.hero.swing();\n battle.setSwing(swing.windup, swing.recover);\n // the road moves no faster than the legs of the hero carry it at its size (no skating)\n battle.setWalkSpeed(\n (footPixels(this.hero.view) * MAX_GAIT) / this.field.road,\n );\n }\n // the line keeps a person's width and a bit between its people (their weapons stick out)\n battle.setMonsterGap(\n (HERO_SCALE * this.field.unit * RIG_HEIGHT * 0.85) / this.field.road,\n );\n // a new wave stands just past the right edge of what the screen shows\n battle.setWaveAhead(\n (this.field.w - this.heroPos().x) / this.field.road + 0.06,\n );\n const events = this.session.tick(dt);\n this.paintBackground();\n this.backdrop?.scroll(this.session.battle.heroX * this.field.road);\n this.syncHero();\n for (const e of events) this.react(e);\n this.syncEnemies();\n this.syncShots();\n this.syncEnemyShots();\n this.syncFeatures();\n this.driftClouds(dt);\n }\n\n /** A new run: the old road's troops, bodies, shots and camps are gone; the hero stands again. */\n private clearRoad(battle: Battle): void {\n this.drawn = battle;\n for (const v of this.enemies.values()) {\n v.bars.destroy();\n v.actor.destroy();\n }\n for (const c of this.corpses) c.actor.destroy();\n for (const img of [\n ...this.shots.values(),\n ...this.enemyShots.values(),\n ...this.features.values(),\n ])\n img.destroy();\n this.enemies.clear();\n this.corpses = [];\n this.killed.clear();\n this.shots.clear();\n this.enemyShots.clear();\n this.features.clear();\n this.hero?.rise();\n this.theme = null;\n }\n\n /** The camp, the fountain, the chest on the road ahead. */\n private syncFeatures(): void {\n const f = this.session.battle.feature;\n for (const [id, img] of this.features)\n if (f?.id !== id) {\n this.tweens.add({\n targets: img,\n alpha: 0,\n y: img.y - 20,\n duration: 300,\n onComplete: () => img.destroy(),\n });\n this.features.delete(id);\n }\n if (!f) return;\n let img = this.features.get(f.id);\n if (!img) {\n img = this.add\n .image(0, 0, featureTexture(this, f.kind, f.gives))\n .setOrigin(0.5, 1)\n .setDepth(14);\n this.features.set(f.id, img);\n }\n img\n .setScale(this.field.unit)\n .setPosition(this.roadX(f.x), this.field.ground + 4);\n }\n\n private heroPos(): { x: number; y: number } {\n return {\n x: this.field.laneLeft + HERO_X * this.field.laneW,\n y: this.field.ground,\n };\n }\n\n /** The hero's height on screen. */\n private heroHeight(): number {\n return (this.hero?.view.displayHeight ?? 110) * 1.02;\n }\n\n private syncHero(): void {\n const hero = this.session.hero;\n const actor = this.hero;\n if (!actor) return;\n const { x, y } = this.heroPos();\n if (actor.setHero(hero)) this.burstAt(this.puffs, x, y - 40, 14);\n actor.tick(\n this.session.battle,\n this.session.battle.walkSpeed * this.field.road,\n );\n this.recoil *= 0.75;\n actor.view.setPosition(x - this.recoil, y);\n actor.view.setScale(HERO_SCALE * this.field.unit);\n actor.view.setVisible(!!hero);\n const sw = actor.view.displayHeight * 0.62;\n this.heroShadow\n ?.setPosition(x - this.recoil, y + 2)\n .setDisplaySize(sw, sw * 0.24)\n .setVisible(!!hero);\n\n // health and armor under the feet, with their numbers\n const b = this.session.battle;\n const bars = this.heroBars!;\n bars.setVisible(!!hero && !b.heroDown);\n if (!hero || b.heroDown) return;\n bars.draw(x, y + BARS_BELOW, this.barWidth(actor.view.displayHeight), {\n hp: b.heroHp,\n maxHp: b.heroMaxHp(),\n armor: b.heroShield,\n maxArmor: b.heroMaxShield(),\n });\n }\n\n /** The bars are about a character wide. */\n private barWidth(height: number): number {\n return Math.max(58, Math.round(height * 0.55));\n }\n\n private syncEnemies(): void {\n const battle = this.session.battle;\n const alive = new Set<number>();\n battle.monsters.forEach((m, i) => {\n alive.add(m.id);\n const view = this.enemies.get(m.id) ?? this.spawnView(m);\n const x = this.roadX(m.x);\n view.x = m.x;\n // the size follows the screen like the hero's (a resize must not leave the line at its old size)\n view.actor.view.setScale(this.enemyScale(m));\n // the line stands a touch staggered in depth, the nearer one in front\n const y = this.field.ground + (i % 2) * 3;\n view.actor.place(x, y);\n view.actor.view.setDepth(m.boss ? 18 : 16 - i * 0.01);\n view.actor.tick(battle, m, m.speed * battle.walkSpeed * this.field.road);\n view.bars.draw(\n x,\n y + BARS_BELOW,\n this.barWidth(view.actor.view.displayHeight),\n { hp: m.hp, maxHp: m.maxHp, armor: m.shield, maxArmor: m.maxShield },\n );\n // a stunned troop is tinted until it shakes it off\n view.actor.setMarked(m.stunned > 0);\n });\n for (const [id, view] of this.enemies)\n if (!alive.has(id)) {\n view.bars.destroy();\n // killed — it falls where it stood; gone without a fight — it fades there\n if (this.killed.delete(id)) view.actor.die();\n else view.actor.vanish();\n this.enemies.delete(id);\n this.corpses.push(view);\n }\n // the fallen stay on their spot of the road while the hero runs on\n this.corpses = this.corpses.filter((c) => !c.actor.gone);\n for (const c of this.corpses) {\n c.actor.view.setScale(this.enemyScale(c.monster));\n c.actor.place(this.roadX(c.x), this.field.ground);\n }\n }\n\n private enemyScale(m: Monster): number {\n return HERO_SCALE * this.field.unit * (m.boss ? BOSS_SCALE : 1);\n }\n\n private spawnView(m: Monster): EnemyView {\n const actor = new EnemyActor(this, m, shadowTexture(this));\n actor.view\n .setScale(this.enemyScale(m))\n .setPosition(this.roadX(m.x), this.field.ground);\n const view: EnemyView = {\n actor,\n bars: new StatusBars(this, 31),\n x: m.x,\n monster: m,\n };\n this.enemies.set(m.id, view);\n return view;\n }\n\n private syncShots(): void {\n const live = new Set<number>();\n const y = this.hero?.handY() ?? this.field.ground - 50;\n for (const p of this.session.battle.projectiles) {\n live.add(p.id);\n let img = this.shots.get(p.id);\n if (!img) {\n const hero = this.session.hero;\n // What the weapon in hand shoots: an arrow, a bolt, a bullet — or the class's glowing orb.\n const kind = this.hero?.shotKind() ?? \"orb\";\n const color = p.crit\n ? 0xff7a1a\n : heroColor(hero?.classID ?? \"\", hero?.id ?? \"\");\n img = this.add\n .image(\n this.roadX(p.x),\n y,\n kind === \"orb\"\n ? shotTexture(this, color)\n : missileTexture(this, kind),\n )\n .setDepth(25)\n .setScale(p.crit ? 1.35 : p.extra ? 0.75 : 1)\n .setData(\"spin\", kind === \"orb\");\n if (kind !== \"orb\" && p.crit) img.setTint(0xffb070);\n this.shots.set(p.id, img);\n }\n img.setPosition(this.roadX(p.x), y + (p.extra ? 10 : 0));\n if (img.getData(\"spin\")) img.rotation += 0.3;\n }\n for (const [id, img] of this.shots)\n if (!live.has(id)) {\n img.destroy();\n this.shots.delete(id);\n }\n }\n\n /** The enemies' shots fly left, at the hero — what their weapon shoots. */\n private syncEnemyShots(): void {\n const live = new Set<number>();\n for (const s of this.session.battle.enemyShots) {\n live.add(s.id);\n let img = this.enemyShots.get(s.id);\n if (!img) {\n img = this.add.image(this.roadX(s.x), 0, shotTexture(this, 0xff5a3c));\n img.setDepth(25).setData(\"spin\", true);\n this.enemyShots.set(s.id, img);\n }\n img.setPosition(\n this.roadX(s.x),\n img.getData(\"y\") ?? this.field.ground - 46,\n );\n if (img.getData(\"spin\")) img.rotation -= 0.3;\n }\n for (const [id, img] of this.enemyShots)\n if (!live.has(id)) {\n img.destroy();\n this.enemyShots.delete(id);\n }\n }\n\n private driftClouds(dt: number): void {\n // the clouds drift on their own and a little with the road (the farthest layer of all)\n const walking = this.session.battle.walking ? 1 : 0;\n for (const [i, c] of this.clouds.entries()) {\n c.x -= (6 + i * 3 + walking * this.field.road * 0.02) * dt;\n if (c.x < -100) c.x = this.field.w + 100;\n }\n }\n\n // ── Effects ────────────────────────────────────────────────────────────────────────────────\n\n private react(e: BattleEvent): void {\n const { ground } = this.field;\n switch (e.type) {\n case \"shoot\":\n case \"strike\":\n this.recoil = e.type === \"strike\" ? -5 : 3;\n break;\n case \"enemyShoot\": {\n const view = this.enemies.get(e.monsterId);\n // a bow looses an arrow, a crossbow a bolt, a gun a bullet; a staff — a red orb\n const kind = view?.actor.shotKind() ?? \"orb\";\n const missile =\n kind === \"arrow\" || kind === \"bolt\" || kind === \"bullet\";\n const img = this.add\n .image(\n this.roadX(e.shot.x),\n 0,\n missile ? missileTexture(this, kind) : shotTexture(this, 0xff5a3c),\n )\n .setDepth(25)\n .setFlipX(true);\n // the enemy's shots run red, so a bullet is never taken for a coin\n if (missile) img.setTint(0xff6a5a);\n img.setData(\"spin\", !missile);\n img.setData(\"y\", view?.actor.handY() ?? ground - 46);\n this.enemyShots.set(e.shot.id, img);\n break;\n }\n case \"hit\": {\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n const top = ground - (view?.actor.view.displayHeight ?? 70) * 0.75;\n this.floatText(\n formatBig(Math.round(e.damage)),\n x,\n top,\n e.crit ? \"crit\" : EFFECT_KIND[e.effect],\n );\n this.burstAt(this.sparks, x - 10, top + 30, e.crit ? 10 : 4);\n view?.actor.view.flash(0xffffff, 80);\n break;\n }\n case \"kill\": {\n this.killed.add(e.monsterId);\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n this.burstAt(this.puffs, x, ground - 30, e.boss ? 30 : 10);\n this.coinBurst(x, ground - 30, e.boss ? 14 : 3);\n if (e.boss) this.cameras.main.shake(260, 0.012);\n break;\n }\n case \"heroHit\":\n if (this.hero) {\n const { x } = this.heroPos();\n // a blow the shield took whole shows blue, one that reached the health red, one turned into\n // healing green\n const onShield = e.shield >= e.damage - 1e-9;\n this.floatText(\n `${e.healed ? \"+\" : \"-\"}${formatBig(Math.round(e.damage))}`,\n x,\n ground - this.heroHeight(),\n e.healed ? \"strong\" : onShield ? \"armor\" : \"hurt\",\n );\n this.hero.view.flash(\n e.healed ? 0x8dff6a : onShield ? 0x9fc8ff : 0xff6060,\n 90,\n );\n }\n break;\n case \"blast\": {\n const view = this.enemies.get(e.monsterId);\n const x = view?.actor.view.root.x ?? this.roadX(e.x);\n this.burstAt(this.fire, x, ground - 30, 22);\n this.cameras.main.shake(140, 0.006);\n break;\n }\n case \"stun\": {\n const view = this.enemies.get(e.monsterId);\n if (view)\n this.burstAt(\n this.sparks,\n view.actor.view.root.x,\n ground - view.actor.view.displayHeight,\n 6,\n );\n break;\n }\n case \"bolt\":\n for (const id of e.targets) this.boltFx(e.bolt, id);\n break;\n case \"guard\": {\n const { x } = this.heroPos();\n this.floatText(\"🛡\", x, ground - this.heroHeight(), \"armor\");\n break;\n }\n case \"charge\":\n this.burstAt(this.sparks, this.heroPos().x, ground - 70, 10);\n break;\n case \"potion\":\n this.burstAt(this.heals, this.heroPos().x, ground - 40, 18);\n break;\n case \"levelUp\":\n this.burstAt(this.sparks, this.heroPos().x, ground - 60, 16);\n break;\n case \"dodge\": {\n const { x } = this.heroPos();\n this.floatText(\"MISS\", x, ground - this.heroHeight(), \"hit\");\n break;\n }\n case \"heroDown\":\n this.hero?.fall();\n this.burstAt(this.puffs, this.heroPos().x, ground - 30, 16);\n break;\n case \"bossStart\":\n this.cameras.main.flash(200, 255, 80, 60);\n break;\n case \"end\":\n if (e.result.cleared) {\n this.cameras.main.flash(260, 255, 240, 160);\n this.coinBurst(this.field.w / 2, ground - 60, 10);\n }\n break;\n case \"ult\":\n this.skillFx(e.ult);\n break;\n case \"ultBack\":\n // a card brought the ultimate back\n this.burstAt(this.sparks, this.heroPos().x, ground - 70, 20);\n this.cameras.main.flash(160, 200, 160, 255);\n break;\n default:\n break;\n }\n }\n\n private skillFx(skill: \"meteor\" | \"frenzy\"): void {\n const { ground } = this.field;\n if (skill === \"meteor\") {\n for (const [, view] of this.enemies) {\n const tx = view.actor.view.root.x;\n if (tx > this.field.w) continue;\n const m = this.add\n .image(tx + 120, -40, \"meteor\")\n .setDepth(35)\n .setScale(1.2);\n this.tweens.add({\n targets: m,\n x: tx,\n y: ground - 30,\n duration: 280,\n ease: \"Quad.easeIn\",\n onComplete: () => {\n this.burstAt(this.fire, tx, ground - 30, 16);\n m.destroy();\n },\n });\n }\n this.cameras.main.shake(200, 0.008);\n } else if (skill === \"frenzy\" && this.hero) {\n this.frenzyTween?.stop();\n const view = this.hero.view;\n view.setOverlay(0xffa0a0);\n const glow = { a: 1 };\n this.frenzyTween = this.tweens.add({\n targets: glow,\n a: 0.75,\n yoyo: true,\n repeat: 11,\n duration: 250,\n onUpdate: () => view.setAlpha(glow.a),\n onComplete: () => {\n view.setOverlay(null);\n view.setAlpha(1);\n },\n });\n }\n }\n\n /** A card's missile on its way to a troop: the damage is already dealt, this is how it looks. */\n private boltFx(kind: Bolt, monsterId: number): void {\n const view = this.enemies.get(monsterId);\n const { ground } = this.field;\n const hero = this.heroPos();\n const tx = view?.actor.view.root.x ?? this.field.w * 0.8;\n const ty = ground - (view?.actor.view.displayHeight ?? 70) * 0.55;\n if (kind === \"lightning\") {\n // a zigzag from the sky onto the troop, white in blue, gone in a blink\n const g = this.add.graphics().setDepth(36);\n const points: Array<[number, number]> = [];\n for (let k = 0; k <= 6; k++)\n points.push([\n tx + (k % 2 ? 12 : -12) * (k < 6 ? 1 : 0),\n -10 + ((ty + 10) * k) / 6,\n ]);\n for (const [w, c, a] of [\n [9, 0x3aa0ff, 0.55],\n [4, 0xffffff, 1],\n ] as const) {\n g.lineStyle(w, c, a);\n g.beginPath();\n g.moveTo(points[0]![0], points[0]![1]);\n for (const [x, y] of points.slice(1)) g.lineTo(x, y);\n g.strokePath();\n }\n this.burstAt(this.sparks, tx, ty, 10);\n this.tweens.add({\n targets: g,\n alpha: 0,\n duration: 260,\n onComplete: () => g.destroy(),\n });\n return;\n }\n const texture =\n kind === \"axe\"\n ? axeTexture(this)\n : missileTexture(this, kind === \"spear\" ? \"thrust\" : \"arrow\");\n const img = this.add.image(hero.x + 20, ground - 50, texture).setDepth(36);\n if (kind === \"spear\") {\n // through the whole line, off the screen's edge\n img.setScale(1.4, 1);\n this.tweens.add({\n targets: img,\n x: this.field.w + 60,\n duration: 320,\n ease: \"Quad.easeIn\",\n onComplete: () => img.destroy(),\n });\n return;\n }\n this.tweens.add({\n targets: img,\n x: tx,\n y: ty,\n angle: kind === \"axe\" ? 720 : 0,\n duration: kind === \"axe\" ? 300 : 220,\n ease: \"Quad.easeIn\",\n onComplete: () => {\n this.burstAt(this.sparks, tx, ty, 6);\n img.destroy();\n },\n });\n }\n\n private burstAt(\n emitter: Phaser.GameObjects.Particles.ParticleEmitter | undefined,\n x: number,\n y: number,\n count: number,\n ): void {\n emitter?.explode(count, x, y);\n }\n\n /** Coins pop out of a kill and fly down into the gold counter of the panel. */\n private coinBurst(x: number, y: number, count: number): void {\n const target = { x: this.field.w / 2, y: this.field.h + 24 };\n for (let i = 0; i < count; i++) {\n const coin = this.add.image(x, y, \"coin\").setDepth(45).setScale(0.9);\n const ox = Phaser.Math.Between(-50, 50);\n const oy = Phaser.Math.Between(-70, -30);\n this.tweens.add({\n targets: coin,\n x: x + ox,\n y: y + oy,\n duration: 260,\n ease: \"Quad.easeOut\",\n onComplete: () =>\n this.tweens.add({\n targets: coin,\n x: target.x,\n y: target.y,\n scale: 0.5,\n delay: 120 + i * 30,\n duration: 520,\n ease: \"Cubic.easeIn\",\n onComplete: () => coin.destroy(),\n }),\n });\n }\n }\n\n private floatText(text: string, x: number, y: number, kind: FloatKind): void {\n const t = this.add\n .text(x + Phaser.Math.Between(-12, 12), y, text, {\n fontFamily: \"system-ui, sans-serif\",\n fontSize: kind === \"crit\" ? \"26px\" : \"18px\",\n fontStyle: \"bold\",\n color: FLOAT_COLORS[kind],\n stroke: \"#2b1d33\",\n strokeThickness: 4,\n })\n .setOrigin(0.5, 1)\n .setDepth(50);\n if (kind === \"crit\") t.setScale(1.4);\n this.tweens.add({\n targets: t,\n y: y - 46,\n scale: 1,\n alpha: 0,\n duration: 750,\n ease: \"Cubic.easeOut\",\n onComplete: () => t.destroy(),\n });\n }\n}\n"
436
+ },
437
+ {
438
+ "path": "phaser/enemy.ts",
439
+ "content": "import type Phaser from \"phaser\";\nimport {\n HERO_ANIMATIONS,\n heroAttack,\n heroShot,\n CharacterView,\n heroRig,\n vectorArt,\n type ShotKind,\n} from \"../character\";\nimport type { Battle, Monster } from \"../game/battle\";\nimport { enemyLook } from \"../game/enemyLooks\";\nimport { isRunning, run } from \"./gait\";\nimport { heroCatalog } from \"./hero\";\n\n// An enemy on the road — a person on the hero's own rig (game/enemyLooks dresses it), facing the\n// hero, with its shadow under its feet. It waits in its stance, runs up when it closes the gap (feet in\n// step with the road, gait.ts), swings or shoots so the hit frame of its attack lands when the battle\n// deals the blow. Killed, it falls where it stood on the road; gone without a fight (a boss that won)\n// it fades away.\n\nexport class EnemyActor {\n readonly view: CharacterView;\n readonly boss: boolean;\n private readonly attack: string;\n private readonly shot: ShotKind;\n private readonly hitAt: number;\n private dying = false;\n private marked = false;\n /** Faded out and destroyed. */\n gone = false;\n readonly shadow: Phaser.GameObjects.Image;\n\n constructor(\n private readonly scene: Phaser.Scene,\n monster: Monster,\n shadowTexture: string,\n ) {\n this.boss = monster.boss;\n this.view = new CharacterView(scene, {\n rig: heroRig,\n art: vectorArt(scene.textures, heroCatalog, 2),\n look: enemyLook(monster.troop, monster.id),\n });\n // the art faces left — towards the hero coming from the left\n this.view.setFacing(1);\n const parts = this.view.parts;\n const held = parts.isWorn(\"Weapon\") ? parts.part(\"Weapon\") : null;\n this.attack = heroAttack(held);\n this.shot = heroShot(held);\n const event = heroRig.data.animations\n .get(this.attack)\n ?.events.find((e) => e.name === \"Attack\");\n this.hitAt = event?.time ?? 0.25;\n this.view.on(\"end\", (e) => {\n if (!this.dying && e.animation !== HERO_ANIMATIONS.die)\n this.view.play(HERO_ANIMATIONS.idle);\n });\n this.shadow = scene.add.image(0, 0, shadowTexture).setDepth(12);\n // it comes into view softly (on a wide screen the line can stand inside the field already)\n this.view.setAlpha(0);\n scene.tweens.add({\n targets: { a: 0 },\n a: 1,\n duration: 350,\n onUpdate: (tw) => this.setAlpha(tw.getValue() ?? 1),\n });\n }\n\n /** Where its feet are on screen (the shadow follows). */\n place(x: number, y: number): void {\n this.view.setPosition(x, y);\n const w = this.view.displayHeight * 0.62;\n this.shadow.setPosition(x, y + 2).setDisplaySize(w, w * 0.24);\n }\n\n private setAlpha(a: number): void {\n this.view.setAlpha(a);\n this.shadow.setAlpha(a);\n }\n\n /**\n * Each frame: run while closing the gap (`closingPx` — how fast it moves along the road, screen\n * pixels a second), swing so the blow lands with the battle's hit.\n */\n tick(battle: Battle, monster: Monster, closingPx: number): void {\n if (this.dying) return;\n if (battle.closing(monster)) return run(this.view, closingPx);\n if (isRunning(this.view))\n this.view.play(HERO_ANIMATIONS.idle, { mix: 0.12 });\n // stunned, it skips its swing\n if (!battle.engaged(monster) || monster.stunned > 0) return;\n const next = Math.max(0, monster.cooldown);\n const current = this.view.animator.current();\n const swinging = current === this.attack && !this.view.animator.finished();\n if (swinging && this.view.animator.time() < this.hitAt) return;\n if (next <= this.hitAt)\n this.view.play(this.attack, {\n loop: false,\n from: this.hitAt - next,\n mix: 0.08,\n });\n }\n\n /** Marked (the chain cards): it glows until it dies — and blows up. */\n setMarked(marked: boolean): void {\n if (marked === this.marked || this.dying) return;\n this.marked = marked;\n this.view.setOverlay(marked ? 0xffa060 : null);\n }\n\n /** What its shots look like in flight (by the weapon in its hand). */\n shotKind(): ShotKind {\n return this.shot;\n }\n\n /** The weapon hand's y on screen (where its shots leave). */\n handY(): number {\n return this.view.worldPoint(\"hand\").y;\n }\n\n /** Killed: falls on the spot, lies a moment, fades and is gone. */\n die(): void {\n this.dying = true;\n this.view.play(HERO_ANIMATIONS.die, { loop: false, mix: 0.05 });\n this.fadeOut(700, 400);\n }\n\n /** Left the road without a fight (a boss that won): fades where it stands. */\n vanish(): void {\n this.dying = true;\n this.fadeOut(0, 350);\n }\n\n private fadeOut(delay: number, duration: number): void {\n this.scene.tweens.add({\n targets: { a: 1 },\n a: 0,\n delay,\n duration,\n onUpdate: (tw) => this.setAlpha(tw.getValue() ?? 0),\n onComplete: () => this.destroy(),\n });\n }\n\n destroy(): void {\n if (this.gone) return;\n this.gone = true;\n this.view.destroy();\n this.shadow.destroy();\n }\n}\n"
440
+ },
441
+ {
442
+ "path": "phaser/gait.ts",
443
+ "content": "import {\n GAIT_GROUND_SPEED,\n HERO_ANIMATIONS,\n type CharacterView,\n} from \"../character\";\n\n// Running on the road without skating: a planted foot slides back at the gait's ground speed\n// (GAIT_GROUND_SPEED, skeleton units a second) × the character's scale × the animation's speed, so the\n// run is played at exactly the speed that matches how fast the road moves under it.\n\n/** The run a character plays: weapon held ready when it holds one, arms swinging when it does not. */\nexport function gaitOf(view: CharacterView): string {\n return view.parts.isWorn(\"Weapon\")\n ? HERO_ANIMATIONS.runArmed\n : HERO_ANIMATIONS.run;\n}\n\n/** Screen pixels a second the road passes under a planted foot at animation speed 1. */\nexport function footPixels(view: CharacterView): number {\n const scale = view.displayHeight / view.rig.data.height;\n return (GAIT_GROUND_SPEED[gaitOf(view)] ?? 1) * scale;\n}\n\n/** Plays the run so its feet keep pace with ground moving `groundPx` screen pixels a second. */\nexport function run(view: CharacterView, groundPx: number): void {\n const gait = gaitOf(view);\n if (view.animation() !== gait) view.play(gait, { loop: true, mix: 0.1 });\n view.animator.setSpeed(groundPx / Math.max(1e-3, footPixels(view)));\n}\n\n/** Is it running now (so a stop goes back to its stance)? */\nexport function isRunning(view: CharacterView): boolean {\n const asked = view.animation();\n return asked === HERO_ANIMATIONS.run || asked === HERO_ANIMATIONS.runArmed;\n}\n"
444
+ },
445
+ {
446
+ "path": "phaser/hero.ts",
447
+ "content": "import type Phaser from \"phaser\";\nimport {\n HERO_ANIMATIONS,\n heroAttack,\n heroIsMelee,\n heroShot,\n CharacterView,\n heroRig,\n createCatalog,\n DEFAULT_LOOK_CHAIN,\n resolveLook,\n vectorArt,\n type LookSource,\n type LookSubject,\n type ShotKind,\n} from \"../character\";\nimport type { Battle } from \"../game/battle\";\nimport type { HeroView } from \"../game/session\";\nimport { isRunning, run } from \"./gait\";\n\n// The fighting hero, drawn by the character system (character/rigs/hero): dressed from its class and\n// gear (character/looks), playing its animations in time with the fight — the run while it heads down\n// the road (its feet kept in step with the road, gait.ts), the attack landing on the hit once a monster\n// is within reach, Die when it falls, Idle otherwise. Which attack it plays follows what is in its hand (heroAttack): fists punch, a spear\n// stabs, a sword or an axe swings — face to face (heroIsMelee) — a bow or a gun shoots from afar.\n\n/** The hero's height on screen at unit 1 (the art was laid out for ~110 px). */\nexport const HERO_SCALE = 0.62;\n\n/** The part catalog the hero and the enemies are drawn from (one, so their art is baked once). */\nexport const heroCatalog = createCatalog();\n\nexport function lookSubject(hero: HeroView): LookSubject {\n const equipment: Record<string, string> = {};\n for (const [slot, worn] of Object.entries(hero.input.model?.Equipment ?? {}))\n if (worn?.ItemID) equipment[slot] = worn.ItemID;\n return { id: hero.id, classId: hero.classID, equipment };\n}\n\nexport class HeroActor {\n readonly view: CharacterView;\n private key = \"\";\n private attack: string = heroAttack(null);\n private shot: ShotKind = \"orb\";\n private melee = true;\n private hitAt = new Map<string, number>();\n private length = new Map<string, number>();\n private down = false;\n\n constructor(\n scene: Phaser.Scene,\n private readonly lookChain: readonly LookSource[] = DEFAULT_LOOK_CHAIN,\n ) {\n this.view = new CharacterView(scene, {\n rig: heroRig,\n art: vectorArt(scene.textures, heroCatalog, 2),\n });\n this.view.setFacing(-1); // the art faces left; the hero fights to the right\n for (const [name, a] of heroRig.data.animations) {\n const e = a.events.find((x) => x.name === \"Attack\");\n if (e) this.hitAt.set(name, e.time);\n this.length.set(name, a.duration);\n }\n this.view.on(\"end\", (e) => {\n if (!this.down && e.animation !== HERO_ANIMATIONS.die)\n this.view.play(HERO_ANIMATIONS.idle);\n });\n }\n\n /** Dresses the hero; true when the look changed (the scene puffs). */\n setHero(hero: HeroView | null): boolean {\n if (!hero) return false;\n const subject = lookSubject(hero);\n const key = JSON.stringify(subject);\n if (key === this.key) return false;\n this.key = key;\n this.view.parts.reset();\n this.view.setLook(resolveLook(subject, this.lookChain));\n const parts = this.view.parts;\n const held = parts.isWorn(\"Weapon\") ? parts.part(\"Weapon\") : null;\n this.attack = heroAttack(held);\n this.shot = heroShot(held);\n this.melee = heroIsMelee(held);\n return true;\n }\n\n /**\n * Runs while the road is clear (`groundPx` — how fast the road moves under it, screen pixels a\n * second); at a monster, starts the swing so its hit frame lands on the hit.\n */\n tick(battle: Battle, groundPx: number): void {\n if (this.down) return;\n if (battle.walking) return run(this.view, groundPx);\n if (isRunning(this.view))\n this.view.play(HERO_ANIMATIONS.idle, { mix: 0.12 });\n if (!battle.hasTarget()) return;\n const hit = this.hitAt.get(this.attack) ?? 0.25;\n const next = battle.heroNextShotIn();\n const current = this.view.animator.current();\n const swinging = current === this.attack && !this.view.animator.finished();\n if (swinging && this.view.animator.time() < hit) return;\n if (next <= hit)\n this.view.play(this.attack, { loop: false, from: hit - next, mix: 0.08 });\n }\n\n /** The attack in hand, timed: seconds of the swing before its hit and of the follow-through after it. */\n swing(): { windup: number; recover: number } {\n const hit = this.hitAt.get(this.attack) ?? 0.25;\n return {\n windup: hit,\n recover: Math.max(0, (this.length.get(this.attack) ?? hit) - hit),\n };\n }\n\n fall(): void {\n this.down = true;\n this.view.play(HERO_ANIMATIONS.die, { loop: false, mix: 0.05 });\n }\n\n rise(): void {\n this.down = false;\n this.view.play(HERO_ANIMATIONS.idle, { mix: 0.15 });\n }\n\n /** Does it fight face to face with what it holds (the battle's reach follows it)? */\n isMelee(): boolean {\n return this.melee;\n }\n\n /** What the hero's shots look like in flight (by the weapon in hand). */\n shotKind(): ShotKind {\n return this.shot;\n }\n\n /** The weapon hand's y on screen (where shots leave). */\n handY(): number {\n return this.view.worldPoint(\"hand\").y;\n }\n}\n"
448
+ },
449
+ {
450
+ "path": "scene.ts",
451
+ "content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\nimport { HeroRunController } from \"./game/HeroRunController\";\nimport type { ControllerBox } from \"./controller-box\";\nimport type { RunSession } from \"./game/session\";\n\n// Wraps the Phaser controller as a host-driven EngineScene. Created lazily on mount (Phaser needs a\n// parent element), published into the shared box for the React panel, and paused/resumed by the\n// Mode Router via activate/suspend so a hidden mode stops ticking — and stops collecting gold, which\n// the next activation picks up as \"while you were away\".\nexport function createHeroRunScene(\n box: ControllerBox<HeroRunController>,\n session: RunSession,\n): EngineScene {\n let controller: HeroRunController | null = null;\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n controller = new HeroRunController(ctx.host, session);\n box.set(controller);\n },\n activate(): void {\n controller?.setRunning(true);\n session.activate();\n },\n suspend(): void {\n controller?.setRunning(false);\n session.suspend();\n },\n capture(): Promise<Blob | null> {\n return controller?.capture() ?? Promise.resolve(null);\n },\n // The host destroys scenes only when it unmounts (a logout): the session goes with them — the next\n // login runs setup() again and builds a new one.\n destroy(): void {\n session.destroy();\n box.set(null);\n controller?.destroy();\n controller = null;\n },\n };\n}\n"
452
+ },
453
+ {
454
+ "path": "ui/BattleHud.tsx",
455
+ "content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { Icon, outlined, toneSurface, useUiKit, v } from \"@idosgames/react/ui\";\nimport type { RunSession } from \"../game/session\";\nimport { formatBig } from \"../game/format\";\nimport { t } from \"../i18n\";\n\n// What sits on top of the road: the level's title, the road's segments with the boss at their end,\n// the boss's health when it stands, and short banners when the run turns (the rage bar is in the\n// hero's sheet under the road, RunStats). Everything else is on the canvas. The top-left\n// corner stays free — the base's \"To the lobby\" button lives there.\n\ntype Banner = { text: string; tone: \"gold\" | \"red\" | \"blue\"; key: number };\n\nexport function BattleHud({ session }: { session: RunSession }): ReactNode {\n const snap = session.battle.snapshot();\n const level = session.level();\n const banner = useBanner(session);\n return (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n fontFamily: v.font,\n color: v.text,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n top: \"calc(10px + var(--idos-safe-top, 0px))\",\n left: 64,\n right: 64,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n }}\n >\n <div\n data-tutorial-anchor=\"hero:level\"\n style={{\n ...outlined,\n fontSize: 18,\n padding: \"3px 14px\",\n borderRadius: 999,\n background: v.backdrop,\n whiteSpace: \"nowrap\",\n }}\n >\n {t(\"level\")} {level.island.index}-{level.level}\n <span style={{ fontSize: 12, color: v.textDim }}>\n {\" \"}\n · {t(level.island.theme.id)}\n </span>\n </div>\n {session.running || session.ending ? (\n <>\n <Segments done={snap.segment} total={snap.segments} />\n {snap.bossMaxHp > 0 ? (\n <BossBar hp={snap.bossHp} maxHp={snap.bossMaxHp} />\n ) : null}\n </>\n ) : null}\n </div>\n\n {banner ? (\n <div\n key={banner.key}\n style={{\n position: \"absolute\",\n top: \"38%\",\n left: 0,\n right: 0,\n display: \"grid\",\n justifyItems: \"center\",\n }}\n >\n <div\n style={{\n ...outlined,\n ...toneSurface(banner.tone),\n fontSize: 22,\n padding: \"6px 22px\",\n borderRadius: 14,\n animation: `idos-pop-in ${v.slow} ease-out both`,\n }}\n >\n {banner.text}\n </div>\n </div>\n ) : null}\n </div>\n );\n}\n\n/** The road's segments as pips, the boss's crown at their end. */\nfunction Segments({ done, total }: { done: number; total: number }): ReactNode {\n return (\n <div style={{ display: \"flex\", gap: 3, alignItems: \"center\" }}>\n {Array.from({ length: total }, (_, i) => (\n <span\n key={i}\n style={{\n width: 12,\n height: 7,\n borderRadius: 4,\n border: `2px solid ${v.backdrop}`,\n background: i < done ? v.gold : i === done ? v.green : v.well,\n }}\n />\n ))}\n <span style={{ opacity: done >= total ? 1 : 0.45 }}>\n <Icon glyph=\"crown\" size={18} />\n </span>\n </div>\n );\n}\n\nfunction BossBar({ hp, maxHp }: { hp: number; maxHp: number }): ReactNode {\n const share = maxHp > 0 ? hp / maxHp : 1;\n return (\n <div style={{ width: \"min(320px, 100%)\", display: \"grid\", gap: 3 }}>\n <div\n style={{\n height: 12,\n borderRadius: 6,\n background: v.well,\n border: `2px solid ${v.backdrop}`,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n width: `${Math.max(0, Math.min(1, share)) * 100}%`,\n height: \"100%\",\n background: `linear-gradient(180deg, ${v.red}, ${v.redDeep})`,\n transition: `width ${v.fast} linear`,\n }}\n />\n </div>\n <div style={{ ...outlined, fontSize: 11, textAlign: \"center\" }}>\n <Icon glyph=\"crown\" size={12} /> {t(\"boss\")} ·{\" \"}\n {formatBig(Math.ceil(hp))}\n </div>\n </div>\n );\n}\n\n/** A short banner when the run turns: a new run level, the boss. */\nfunction useBanner(session: RunSession): Banner | null {\n const snap = session.battle.snapshot();\n const prev = useRef(snap);\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const [banner, setBanner] = useState<Banner | null>(null);\n const kit = useUiKit();\n\n useEffect(\n () => () => {\n if (timer.current) clearTimeout(timer.current);\n },\n [],\n );\n\n // Compared on every render (the HUD redraws a few times a second): a turn shows once.\n useEffect(() => {\n const before = prev.current;\n prev.current = snap;\n let next: Omit<Banner, \"key\"> | null = null;\n if (snap.runLevel > before.runLevel && session.running) {\n next = { text: `${t(\"runLevel\")} ${snap.runLevel}`, tone: \"gold\" };\n kit.play(\"levelUp\");\n } else if (snap.bossMaxHp > 0 && before.bossMaxHp === 0)\n next = { text: t(\"boss\"), tone: \"red\" };\n if (!next) return;\n setBanner({ ...next, key: Date.now() });\n if (timer.current) clearTimeout(timer.current);\n timer.current = setTimeout(() => setBanner(null), 1400);\n });\n\n return banner;\n}\n"
456
+ },
457
+ {
458
+ "path": "ui/BottomNav.tsx",
459
+ "content": "import { useSyncExternalStore, type ReactNode } from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport {\n Badge,\n Icon,\n outlined,\n useUiKit,\n v,\n type Glyph,\n} from \"@idosgames/react/ui\";\nimport { t, type Key } from \"../i18n\";\n\n// The game's bottom bar (Legend Slime's row of big icons): Enhance is this screen; the rest open the\n// systems the game brings — the hero hub (`character`), gear summons (`lootboxes`), the quests and\n// the shop — through ctx.features. A system that is missing or has nothing on this title has no button.\n\nconst ENTRIES: Array<{ feature: string; glyph: Glyph; label: Key }> = [\n { feature: \"character\", glyph: \"hero\", label: \"heroes\" },\n { feature: \"lootboxes\", glyph: \"chest\", label: \"summon\" },\n { feature: \"quests\", glyph: \"quest\", label: \"quests\" },\n { feature: \"store\", glyph: \"shop\", label: \"shop\" },\n];\n\nexport function BottomNav({\n features,\n}: {\n features: FeatureRegistry;\n}): ReactNode {\n // Badges and availability change while the game runs (a reward waits, a tab appears).\n useSyncExternalStore(features.subscribe, () => featureKey(features));\n const kit = useUiKit();\n const shown = ENTRIES.flatMap((e) => {\n const entry = features.get(e.feature);\n return entry?.available ? [{ ...e, badge: entry.badge }] : [];\n });\n\n return (\n <nav\n style={{\n display: \"flex\",\n justifyContent: \"space-around\",\n gap: 4,\n padding: \"6px 6px calc(6px + var(--idos-safe-bottom, 0px))\",\n background: v.panelDeep,\n borderTop: `2px solid ${v.panelEdge}`,\n }}\n >\n <NavButton glyph=\"swords\" label={t(\"title\")} active onClick={() => {}} />\n {shown.map((e) => (\n <NavButton\n key={e.feature}\n glyph={e.glyph}\n label={t(e.label)}\n badge={e.badge}\n anchor={`hero:nav:${e.feature}`}\n onClick={() => {\n kit.play(\"tab\");\n features.open(e.feature);\n }}\n />\n ))}\n </nav>\n );\n}\n\nfunction NavButton({\n glyph,\n label,\n badge = 0,\n active = false,\n anchor,\n onClick,\n}: {\n glyph: Glyph;\n label: string;\n badge?: number;\n active?: boolean;\n anchor?: string;\n onClick: () => void;\n}): ReactNode {\n return (\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={onClick}\n data-tutorial-anchor={anchor}\n aria-current={active ? \"page\" : undefined}\n style={{\n position: \"relative\",\n flex: \"1 1 0\",\n maxWidth: 96,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n padding: \"6px 2px 4px\",\n borderRadius: 12,\n border: `2px solid ${active ? v.gold : \"transparent\"}`,\n background: active ? v.wellSoft : \"transparent\",\n cursor: \"pointer\",\n }}\n >\n <Icon glyph={glyph} size={30} />\n <span style={{ ...outlined, fontSize: 11 }}>{label}</span>\n {badge > 0 ? (\n <span style={{ position: \"absolute\", top: 0, right: 8 }}>\n <Badge count={badge} />\n </span>\n ) : null}\n </button>\n );\n}\n\nfunction featureKey(features: FeatureRegistry): string {\n return ENTRIES.map((e) => {\n const f = features.get(e.feature);\n return f ? `${f.available ? 1 : 0}:${f.badge}` : \"-\";\n }).join(\"|\");\n}\n"
460
+ },
461
+ {
462
+ "path": "ui/cardIcons.tsx",
463
+ "content": "import type { ReactNode } from \"react\";\nimport type { CardIcon, Rarity } from \"../game/cards\";\n\n// A card's picture (Obsidian Knight's upgrade icons): a dark pictogram on an octagon in the colour of\n// the card's rarity — gold for legendary, blue for rare, silver for common. Drawn in SVG here (the\n// game ships no image files); each pictogram is a few simple shapes on a 64×64 box.\n\nconst INK = \"#1d1b24\";\nconst ACCENT = {\n red: \"#e0443a\",\n blue: \"#3aa0ff\",\n gold: \"#ffc21a\",\n green: \"#47c75a\",\n white: \"#ffffff\",\n};\n\nconst FACE: Record<Rarity, [string, string, string]> = {\n // light, deep, rim\n legendary: [\"#ffe66b\", \"#f2a50c\", \"#8a5a00\"],\n rare: [\"#9fd4ff\", \"#3d8ef5\", \"#173d80\"],\n common: [\"#eef0f4\", \"#9aa1ae\", \"#3d4150\"],\n};\n\nconst OCTAGON = \"M20 2 H44 L62 20 V44 L44 62 H20 L2 44 V20 Z\";\n\nexport function CardIconBadge({\n icon,\n rarity,\n size = 84,\n}: {\n icon: CardIcon;\n rarity: Rarity;\n size?: number;\n}): ReactNode {\n const [light, deep, rim] = FACE[rarity];\n const id = `card-${rarity}`;\n return (\n <svg\n width={size}\n height={size}\n viewBox=\"0 0 64 64\"\n aria-hidden\n style={{ flex: \"0 0 auto\" }}\n >\n <defs>\n <linearGradient id={id} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0\" stopColor={light} />\n <stop offset=\"1\" stopColor={deep} />\n </linearGradient>\n </defs>\n <path d={OCTAGON} fill={rim} />\n <path\n d={OCTAGON}\n fill={`url(#${id})`}\n transform=\"translate(4 4) scale(0.875)\"\n />\n <path\n d={OCTAGON}\n fill=\"none\"\n stroke=\"rgba(255,255,255,.55)\"\n strokeWidth=\"1.5\"\n transform=\"translate(6 6) scale(0.8125)\"\n />\n <g transform=\"translate(12 12) scale(0.625)\">{PICTO[icon]}</g>\n </svg>\n );\n}\n\n/** The pictograms, each on a 64×64 box. */\nconst PICTO: Record<CardIcon, ReactNode> = {\n lightning: (\n <>\n <path\n d=\"M36 2 L14 36 H30 L24 62 L50 24 H34 Z\"\n fill={ACCENT.blue}\n stroke={INK}\n strokeWidth=\"3\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M33 10 L22 30\"\n stroke={ACCENT.white}\n strokeWidth=\"3\"\n strokeLinecap=\"round\"\n />\n </>\n ),\n axe: (\n <>\n <rect\n x=\"29\"\n y=\"8\"\n width=\"6\"\n height=\"52\"\n rx=\"3\"\n fill=\"#8a5a34\"\n stroke={INK}\n strokeWidth=\"2.5\"\n />\n <path\n d=\"M33 12 C50 8 58 20 56 34 C46 30 40 30 33 32 Z\"\n fill=\"#c9ced8\"\n stroke={INK}\n strokeWidth=\"3\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M31 12 C16 10 8 20 10 30 C18 27 24 27 31 28 Z\"\n fill=\"#c9ced8\"\n stroke={INK}\n strokeWidth=\"3\"\n strokeLinejoin=\"round\"\n />\n </>\n ),\n spear: (\n <>\n <path\n d=\"M6 58 L48 16\"\n stroke=\"#8a5a34\"\n strokeWidth=\"6\"\n strokeLinecap=\"round\"\n />\n <path\n d=\"M6 58 L48 16\"\n stroke={INK}\n strokeWidth=\"1.5\"\n strokeLinecap=\"round\"\n opacity=\"0.4\"\n />\n <path\n d=\"M44 6 L60 4 L58 20 L50 24 L40 14 Z\"\n fill=\"#dfe3ea\"\n stroke={INK}\n strokeWidth=\"3\"\n strokeLinejoin=\"round\"\n />\n </>\n ),\n arrows: (\n <>\n {[0, 14].map((dy) => (\n <g key={dy} transform={`translate(0 ${dy})`}>\n <path\n d=\"M6 44 L52 14\"\n stroke=\"#8a5a34\"\n strokeWidth=\"4\"\n strokeLinecap=\"round\"\n />\n <path\n d=\"M52 14 L58 8 L48 10 Z\"\n fill=\"#dfe3ea\"\n stroke={INK}\n strokeWidth=\"2.5\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M6 44 L4 36 M6 44 L14 46\"\n stroke={ACCENT.red}\n strokeWidth=\"3\"\n strokeLinecap=\"round\"\n />\n </g>\n ))}\n </>\n ),\n heal: (\n <>\n <path\n d=\"M32 56 C10 42 4 30 8 20 C12 10 26 8 32 20 C38 8 52 10 56 20 C60 30 54 42 32 56 Z\"\n fill={ACCENT.red}\n stroke={INK}\n strokeWidth=\"3\"\n />\n <path\n d=\"M32 24 V44 M22 34 H42\"\n stroke={ACCENT.white}\n strokeWidth=\"6\"\n strokeLinecap=\"round\"\n />\n </>\n ),\n heart: (\n <>\n <path\n d=\"M32 56 C10 42 4 30 8 20 C12 10 26 8 32 20 C38 8 52 10 56 20 C60 30 54 42 32 56 Z\"\n fill={ACCENT.red}\n stroke={INK}\n strokeWidth=\"3\"\n />\n <path\n d=\"M16 22 C18 16 24 15 27 19\"\n stroke={ACCENT.white}\n strokeWidth=\"3\"\n strokeLinecap=\"round\"\n fill=\"none\"\n />\n </>\n ),\n repair: (\n <>\n <path\n d=\"M32 4 L54 12 V30 C54 44 44 54 32 60 C20 54 10 44 10 30 V12 Z\"\n fill=\"#9aa6b8\"\n stroke={INK}\n strokeWidth=\"3\"\n />\n <path\n d=\"M22 30 L30 38 L44 22\"\n stroke={ACCENT.green}\n strokeWidth=\"6\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n fill=\"none\"\n />\n </>\n ),\n divine: (\n <>\n <circle cx=\"32\" cy=\"32\" r=\"28\" fill=\"rgba(255,240,150,.55)\" />\n <path\n d=\"M32 6 L52 14 V30 C52 42 43 51 32 57 C21 51 12 42 12 30 V14 Z\"\n fill={ACCENT.gold}\n stroke={INK}\n strokeWidth=\"3\"\n />\n <path\n d=\"M32 16 V44 M22 26 H42\"\n stroke={ACCENT.white}\n strokeWidth=\"5\"\n strokeLinecap=\"round\"\n />\n </>\n ),\n stun: (\n <>\n <circle cx=\"32\" cy=\"38\" r=\"14\" fill={INK} />\n <path\n d=\"M32 6 L35 14 L43 14 L37 19 L39 27 L32 22 L25 27 L27 19 L21 14 L29 14 Z\"\n fill={ACCENT.gold}\n stroke={INK}\n strokeWidth=\"2\"\n />\n <path\n d=\"M12 16 L15 21 M52 16 L49 21\"\n stroke={ACCENT.gold}\n strokeWidth=\"3\"\n strokeLinecap=\"round\"\n />\n </>\n ),\n focus: (\n <>\n <circle cx=\"32\" cy=\"32\" r=\"24\" fill=\"none\" stroke={INK} strokeWidth=\"5\" />\n <circle\n cx=\"32\"\n cy=\"32\"\n r=\"14\"\n fill=\"none\"\n stroke={ACCENT.red}\n strokeWidth=\"5\"\n />\n <circle cx=\"32\" cy=\"32\" r=\"5\" fill={INK} />\n <path\n d=\"M32 2 V14 M32 50 V62 M2 32 H14 M50 32 H62\"\n stroke={INK}\n strokeWidth=\"4\"\n strokeLinecap=\"round\"\n />\n </>\n ),\n skull: (\n <>\n <path\n d=\"M32 6 C16 6 8 18 10 32 C11 40 16 42 18 44 V54 H46 V44 C48 42 53 40 54 32 C56 18 48 6 32 6 Z\"\n fill=\"#f3efe6\"\n stroke={INK}\n strokeWidth=\"3\"\n />\n <circle cx=\"23\" cy=\"30\" r=\"6\" fill={INK} />\n <circle cx=\"41\" cy=\"30\" r=\"6\" fill={INK} />\n <path\n d=\"M26 54 V46 M32 54 V46 M38 54 V46\"\n stroke={INK}\n strokeWidth=\"2.5\"\n />\n <path\n d=\"M50 46 C56 42 60 46 58 52 C56 56 50 56 50 50 Z\"\n fill={ACCENT.red}\n stroke={INK}\n strokeWidth=\"2\"\n />\n </>\n ),\n counter: (\n <>\n <path\n d=\"M10 54 L44 12 M54 54 L20 12\"\n stroke=\"#dfe3ea\"\n strokeWidth=\"6\"\n strokeLinecap=\"round\"\n />\n <path\n d=\"M10 54 L44 12 M54 54 L20 12\"\n stroke={INK}\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n opacity=\"0.35\"\n />\n <path\n d=\"M6 50 L16 58 M58 50 L48 58\"\n stroke=\"#8a5a34\"\n strokeWidth=\"6\"\n strokeLinecap=\"round\"\n />\n <circle\n cx=\"32\"\n cy=\"34\"\n r=\"6\"\n fill={ACCENT.gold}\n stroke={INK}\n strokeWidth=\"2.5\"\n />\n </>\n ),\n daggers: (\n <>\n {[-1, 1].map((s) => (\n <g key={s} transform={`translate(${s * 9} 0) rotate(${s * 18} 32 32)`}>\n <path\n d=\"M32 4 L37 36 H27 Z\"\n fill=\"#dfe3ea\"\n stroke={INK}\n strokeWidth=\"2.5\"\n strokeLinejoin=\"round\"\n />\n <rect\n x=\"24\"\n y=\"36\"\n width=\"16\"\n height=\"5\"\n rx=\"2\"\n fill={ACCENT.gold}\n stroke={INK}\n strokeWidth=\"2\"\n />\n <rect\n x=\"29\"\n y=\"41\"\n width=\"6\"\n height=\"16\"\n rx=\"2\"\n fill=\"#8a5a34\"\n stroke={INK}\n strokeWidth=\"2\"\n />\n </g>\n ))}\n </>\n ),\n speed: (\n <>\n <path\n d=\"M14 40 L34 8 L30 28 H46 L26 58 L30 40 Z\"\n fill={ACCENT.gold}\n stroke={INK}\n strokeWidth=\"3\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M4 22 H16 M2 34 H12 M6 46 H16\"\n stroke={INK}\n strokeWidth=\"3.5\"\n strokeLinecap=\"round\"\n />\n </>\n ),\n crit: (\n <>\n <path\n d=\"M32 4 L38 22 L58 22 L42 34 L48 54 L32 42 L16 54 L22 34 L6 22 L26 22 Z\"\n fill={ACCENT.red}\n stroke={INK}\n strokeWidth=\"3\"\n strokeLinejoin=\"round\"\n />\n <text\n x=\"32\"\n y=\"37\"\n textAnchor=\"middle\"\n fontSize=\"16\"\n fontWeight=\"900\"\n fill={ACCENT.white}\n fontFamily=\"system-ui\"\n >\n %\n </text>\n </>\n ),\n sword: (\n <>\n <path\n d=\"M48 4 L60 4 L60 16 L24 48 L16 40 Z\"\n fill=\"#dfe3ea\"\n stroke={INK}\n strokeWidth=\"3\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M12 34 L30 52\"\n stroke={ACCENT.gold}\n strokeWidth=\"6\"\n strokeLinecap=\"round\"\n />\n <path\n d=\"M18 46 L8 56\"\n stroke=\"#8a5a34\"\n strokeWidth=\"7\"\n strokeLinecap=\"round\"\n />\n </>\n ),\n backrow: (\n <>\n {[14, 32, 50].map((x, i) => (\n <g key={x} opacity={i === 0 ? 0.45 : 1}>\n <circle cx={x} cy=\"20\" r=\"7\" fill={INK} />\n <path\n d={`M${x - 9} 54 C${x - 9} 36 ${x + 9} 36 ${x + 9} 54 Z`}\n fill={INK}\n />\n </g>\n ))}\n <path\n d=\"M44 46 L58 60\"\n stroke={ACCENT.red}\n strokeWidth=\"4\"\n strokeLinecap=\"round\"\n />\n </>\n ),\n break: (\n <>\n <path\n d=\"M32 4 L54 12 V30 C54 44 44 54 32 60 C20 54 10 44 10 30 V12 Z\"\n fill=\"#5a6070\"\n stroke={INK}\n strokeWidth=\"3\"\n />\n <path\n d=\"M34 6 L26 26 L36 32 L28 58\"\n stroke={ACCENT.white}\n strokeWidth=\"4\"\n strokeLinejoin=\"round\"\n fill=\"none\"\n />\n </>\n ),\n blood: (\n <>\n <path\n d=\"M32 4 C40 18 50 28 50 40 C50 52 42 60 32 60 C22 60 14 52 14 40 C14 28 24 18 32 4 Z\"\n fill={ACCENT.red}\n stroke={INK}\n strokeWidth=\"3\"\n />\n <path\n d=\"M24 40 C24 34 27 30 30 28\"\n stroke={ACCENT.white}\n strokeWidth=\"3.5\"\n strokeLinecap=\"round\"\n fill=\"none\"\n />\n </>\n ),\n potion: (\n <>\n <rect\n x=\"25\"\n y=\"4\"\n width=\"14\"\n height=\"10\"\n rx=\"2\"\n fill=\"#8a5a34\"\n stroke={INK}\n strokeWidth=\"2.5\"\n />\n <path\n d=\"M26 14 H38 V22 C48 26 54 34 54 42 C54 54 44 60 32 60 C20 60 10 54 10 42 C10 34 16 26 26 22 Z\"\n fill=\"#dfe3ea\"\n stroke={INK}\n strokeWidth=\"3\"\n />\n <path\n d=\"M13 40 H51 C51 52 42 57 32 57 C22 57 13 52 13 40 Z\"\n fill={ACCENT.red}\n />\n </>\n ),\n dodge: (\n <>\n {[0, 10, 20].map((dx, i) => (\n <g\n key={dx}\n transform={`translate(${dx - 10} 0)`}\n opacity={0.35 + i * 0.32}\n >\n <circle cx=\"32\" cy=\"16\" r=\"7\" fill={INK} />\n <path\n d=\"M22 58 L26 34 L20 26 L32 24 L44 30 L38 36 L40 58\"\n stroke={INK}\n strokeWidth=\"6\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n fill=\"none\"\n />\n </g>\n ))}\n </>\n ),\n rage: (\n <>\n <path\n d=\"M32 4 C36 16 48 20 48 36 C48 50 40 60 32 60 C24 60 16 50 16 36 C16 26 22 22 24 14 C28 20 30 22 32 24 C32 16 30 10 32 4 Z\"\n fill=\"#ff6a1a\"\n stroke={INK}\n strokeWidth=\"3\"\n />\n <path\n d=\"M32 30 C36 36 40 40 40 46 C40 52 36 56 32 56 C28 56 24 52 24 46 C24 40 30 36 32 30 Z\"\n fill={ACCENT.gold}\n />\n </>\n ),\n type: (\n <>\n <circle cx=\"32\" cy=\"32\" r=\"26\" fill=\"rgba(180,110,255,.45)\" />\n <path\n d=\"M48 6 L58 6 L58 16 L26 46 L18 38 Z\"\n fill=\"#dfe3ea\"\n stroke={INK}\n strokeWidth=\"3\"\n strokeLinejoin=\"round\"\n />\n <path\n d=\"M14 34 L30 50\"\n stroke={ACCENT.gold}\n strokeWidth=\"6\"\n strokeLinecap=\"round\"\n />\n <path\n d=\"M20 46 L10 56\"\n stroke=\"#8a5a34\"\n strokeWidth=\"7\"\n strokeLinecap=\"round\"\n />\n <path\n d=\"M46 34 L50 42 L58 44 L50 46 L46 54 L42 46 L34 44 L42 42 Z\"\n fill=\"#b46bff\"\n stroke={INK}\n strokeWidth=\"2\"\n />\n </>\n ),\n};\n"
464
+ },
465
+ {
466
+ "path": "ui/GamePanel.tsx",
467
+ "content": "import {\n useEffect,\n useSyncExternalStore,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport {\n EmptyState,\n errorText,\n outlined,\n screenBackground,\n useCatalog,\n useNotify,\n v,\n} from \"@idosgames/react/ui\";\nimport type { RunSession } from \"../game/session\";\nimport { BATTLE_HEIGHT_CSS } from \"../layout\";\nimport { t } from \"../i18n\";\nimport { BattleHud } from \"./BattleHud\";\nimport { BottomNav } from \"./BottomNav\";\nimport { GuideQuest } from \"./GuideQuest\";\nimport { MapPanel, TipPopup } from \"./MapPanel\";\nimport { RunBar } from \"./RunBar\";\nimport { RunStats } from \"./RunStats\";\nimport { CampPopup, CardPick, RunEnd } from \"./RunPopups\";\n\n// The game's whole interface over the canvas: the HUD on top of the road, and under it the panel —\n// between runs the map (levels, scouting, the ultimate, Run); during a run its controls and the\n// hero's sheet (its numbers with what the cards add, the cards' effects, the cards taken); the popups\n// that stop a run (a card, the camp, the end). The hero grows between runs from its gear and rank —\n// the Heroes screen, not this one. Next to the base the game sits\n// inside the lobby, whose tabs open the systems — then the game's own bar is not drawn (`nav`).\n\nexport function makeGamePanel(\n session: RunSession,\n features: FeatureRegistry,\n nav = true,\n): ComponentType {\n return function HeroRunPanel(): ReactNode {\n useSyncExternalStore(session.subscribe, session.getVersion);\n useNotices(session);\n const hero = session.hero;\n\n return (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n fontFamily: v.font,\n color: v.text,\n }}\n >\n <div\n style={{\n position: \"absolute\",\n top: 0,\n left: 0,\n right: 0,\n height: BATTLE_HEIGHT_CSS,\n }}\n >\n <BattleHud session={session} />\n </div>\n <div\n className=\"idos-screen\"\n style={{\n position: \"absolute\",\n top: BATTLE_HEIGHT_CSS,\n left: 0,\n right: 0,\n bottom: 0,\n display: \"flex\",\n flexDirection: \"column\",\n background: screenBackground,\n pointerEvents: \"auto\",\n }}\n >\n <div\n className=\"idos-scroll\"\n style={{\n flex: 1,\n minHeight: 0,\n overflowY: \"auto\",\n padding: \"8px 10px 12px\",\n }}\n >\n <div\n style={{\n maxWidth: 1120,\n margin: \"0 auto\",\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 8,\n }}\n >\n {!hero ? (\n session.loaded ? (\n <EmptyState glyph=\"hero\" text={t(\"noHero\")} />\n ) : (\n <div\n style={{\n ...outlined,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"loading\")}\n </div>\n )\n ) : session.running ? (\n <>\n <RunBar session={session} />\n <RunStats session={session} />\n </>\n ) : (\n <>\n <GuideQuest features={features} />\n <MapPanel session={session} />\n </>\n )}\n </div>\n </div>\n {nav ? <BottomNav features={features} /> : null}\n </div>\n <CardPick session={session} />\n <CampPopup session={session} />\n <RunEnd session={session} />\n <TipPopup session={session} />\n </div>\n );\n };\n}\n\n/** The session's refusals as toasts (\"not enough energy\", a server error). */\nfunction useNotices(session: RunSession): void {\n const notify = useNotify();\n const catalog = useCatalog();\n const notice = session.notice;\n useEffect(() => {\n if (!notice) return;\n if (notice.kind === \"notEnough\")\n notify.toast({\n level: \"warning\",\n key: \"hero-run-not-enough\",\n title: `${t(\"notEnough\")}: ${catalog.currencyName(notice.currencyID)}`,\n });\n else notify.toast({ level: \"error\", message: errorText(notice.text) });\n // Keyed on the notice's time: each refusal shows once.\n }, [notice?.at]);\n}\n"
468
+ },
469
+ {
470
+ "path": "ui/GuideQuest.tsx",
471
+ "content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n ResourceList,\n configSection,\n errorText,\n grantLines,\n grantedBy,\n outlined,\n useCatalog,\n useCelebrate,\n useNotify,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n pickGuide,\n type QuestConfigView,\n type QuestStateView,\n} from \"../game/guide\";\nimport { t } from \"../i18n\";\n\n/** The quest strip: name, progress, reward and \"Claim\" — a tap elsewhere opens the quests screen. */\nexport function GuideQuest({\n features,\n}: {\n features: FeatureRegistry;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const celebrate = useCelebrate();\n const catalog = useCatalog();\n const notify = useNotify();\n const [busy, setBusy] = useState(false);\n\n useEffect(() => {\n // Quest progress comes with the login; a new cycle is the only reason to ask the server.\n void client.cache.ensureState([\"Quest\"]);\n }, [client]);\n\n const guide = pickGuide(\n configSection<QuestConfigView>(client, \"Quest\"),\n state?.Quest as QuestStateView | undefined,\n );\n if (!guide) return null;\n\n const claim = async () => {\n setBusy(true);\n const res = await client.quest.claimQuestReward(\n guide.questID,\n guide.cycleID,\n );\n setBusy(false);\n if (!res.ok)\n return void notify.toast({\n level: \"error\",\n message: errorText(res.error),\n });\n celebrate(grantedBy(res.data));\n };\n const reward = grantLines(guide.grant as ResourceGrant | null | undefined);\n const quests = features.get(\"quests\");\n\n return (\n <div\n role={quests?.available ? \"button\" : undefined}\n onClick={() => {\n if (quests?.available && !guide.claimable) features.open(\"quests\");\n }}\n data-tutorial-anchor=\"hero:guide\"\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n padding: \"6px 8px 6px 10px\",\n borderRadius: 12,\n background: guide.claimable\n ? `linear-gradient(90deg, color-mix(in srgb, ${v.gold} 35%, ${v.panelDeep}), ${v.panelDeep})`\n : v.panelDeep,\n border: `2px solid ${guide.claimable ? v.gold : v.panelEdge}`,\n cursor: quests?.available ? \"pointer\" : \"default\",\n minWidth: 0,\n }}\n >\n <Icon glyph=\"quest\" size={22} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n whiteSpace: \"nowrap\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n }}\n >\n {catalog.localize(guide.name)}\n </div>\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {guide.current} / {guide.target}\n </div>\n </div>\n {reward.length > 0 ? <ResourceList lines={reward} size={16} /> : null}\n {guide.claimable ? (\n <Button\n size=\"sm\"\n tone=\"gold\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n ) : null}\n </div>\n );\n}\n"
472
+ },
473
+ {
474
+ "path": "ui/MapPanel.tsx",
475
+ "content": "import { useState, type ReactNode } from \"react\";\nimport {\n Button,\n Card,\n Icon,\n InfoButton,\n LevelShield,\n Popup,\n ResourceCounter,\n buttonStyle,\n outlined,\n useNow,\n v,\n} from \"@idosgames/react/ui\";\nimport type { RunSession } from \"../game/session\";\nimport { POTION_SLOTS } from \"../game/economy\";\nimport { ULTS, type UltId } from \"../game/battle\";\nimport { LEVELS_PER_ISLAND, levelOf, typesKnownBy } from \"../game/campaign\";\nimport {\n DAMAGE_TABLE,\n effectOf,\n type ArmorType,\n type AttackType,\n type Effect,\n} from \"../game/damage\";\nimport { ENERGY_ID, POTION_ID, TICKET_ID, nextEnergyIn } from \"../game/economy\";\nimport { formatDuration } from \"../game/format\";\nimport { t } from \"../i18n\";\nimport { TypeChip } from \"./types\";\n\n// The map between runs: the island's levels, the scouting of the picked one (what armor the road\n// wears against YOUR attack, what attacks it brings against YOUR armor — the colours say who wins),\n// the ultimate for the run, and the Run button with what it costs.\n\n/** An enemy attack that is strong against you is bad news: the chip shows it from your side. */\nconst MINE: Record<Effect, Effect> = {\n strong: \"weak\",\n even: \"even\",\n weak: \"strong\",\n};\n\nexport function MapPanel({ session }: { session: RunSession }): ReactNode {\n const level = session.level();\n const island = level.island;\n const hero = session.hero;\n const open = session.openLevel();\n const first = (island.index - 1) * LEVELS_PER_ISLAND + 1;\n const scout = session.scouting();\n const [table, setTable] = useState(false);\n const pl = session.playerLevel();\n\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <LevelShield level={pl.level} size={40} />\n <div style={{ ...outlined, fontSize: 16, flex: 1 }}>\n {t(\"island\")} {island.index}\n <span style={{ color: v.textDim, fontSize: 13 }}>\n {\" \"}\n · {t(island.theme.id)}\n </span>\n </div>\n <IslandArrow\n dir={-1}\n disabled={island.index <= 1}\n onClick={() => session.select(first - LEVELS_PER_ISLAND)}\n />\n <IslandArrow\n dir={1}\n disabled={first + LEVELS_PER_ISLAND > open}\n onClick={() => session.select(first + LEVELS_PER_ISLAND)}\n />\n </div>\n\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(${LEVELS_PER_ISLAND}, minmax(0, 1fr))`,\n gap: 6,\n }}\n >\n {Array.from({ length: LEVELS_PER_ISLAND }, (_, i) => {\n const n = first + i;\n const cleared = n <= session.progress.cleared;\n const locked = n > open;\n const picked = n === session.selected;\n const boss = i === LEVELS_PER_ISLAND - 1;\n return (\n <button\n key={n}\n type=\"button\"\n className=\"idos-press\"\n disabled={locked}\n onClick={() => session.select(n)}\n data-tutorial-anchor={`hero:level:${n}`}\n style={{\n ...buttonStyle(\n picked\n ? \"gold\"\n : cleared\n ? \"green\"\n : locked\n ? \"grey\"\n : \"blue\",\n false,\n \"sm\",\n ),\n padding: \"6px 0\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n opacity: locked ? 0.6 : 1,\n }}\n >\n <span style={{ fontSize: 15 }}>\n {island.index}-{i + 1}\n </span>\n <Icon\n glyph={\n locked\n ? \"lock\"\n : cleared\n ? \"check\"\n : boss\n ? \"crown\"\n : \"swords\"\n }\n size={14}\n />\n </button>\n );\n })}\n </div>\n\n <Card style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n <span style={{ ...outlined, fontSize: 15, flex: 1 }}>\n {island.scouting\n ? t(\"scouting\")\n : `${t(\"level\")} ${island.index}-${level.level}`}\n </span>\n {island.scouting ? (\n <InfoButton label={t(\"table\")} onClick={() => setTable(true)} />\n ) : null}\n </div>\n {island.scouting && hero ? (\n <>\n <Row label={t(\"enemyArmor\")}>\n {scout.armor.map((a) => (\n <TypeChip\n key={a.type}\n kind=\"armor\"\n type={a.type}\n share={a.share}\n effect={effectOf(hero.gear.attack, a.type)}\n />\n ))}\n </Row>\n <Row label={t(\"enemyAttack\")}>\n {scout.attack.map((a) => (\n <TypeChip\n key={a.type}\n kind=\"attack\"\n type={a.type}\n share={a.share}\n effect={MINE[effectOf(a.type, hero.gear.armor)]}\n />\n ))}\n </Row>\n <Row label={t(\"boss\")}>\n <TypeChip\n kind=\"armor\"\n type={scout.boss.armor}\n effect={effectOf(hero.gear.attack, scout.boss.armor)}\n />\n <TypeChip\n kind=\"attack\"\n type={scout.boss.attack}\n effect={MINE[effectOf(scout.boss.attack, hero.gear.armor)]}\n />\n </Row>\n </>\n ) : null}\n {hero ? (\n <Row label={t(\"yourAttack\")}>\n <TypeChip kind=\"attack\" type={hero.gear.attack} />\n <TypeChip kind=\"armor\" type={hero.gear.armor} />\n {hero.gear.shield ? <Icon glyph=\"shield\" size={18} /> : null}\n </Row>\n ) : null}\n </Card>\n\n <UltPicker session={session} />\n <StartRow session={session} />\n\n {table ? (\n <TablePopup island={island.index} onClose={() => setTable(false)} />\n ) : null}\n </div>\n );\n}\n\nfunction IslandArrow({\n dir,\n disabled,\n onClick,\n}: {\n dir: 1 | -1;\n disabled: boolean;\n onClick: () => void;\n}): ReactNode {\n return (\n <button\n type=\"button\"\n className=\"idos-press\"\n disabled={disabled}\n onClick={onClick}\n aria-label={dir < 0 ? \"◀\" : \"▶\"}\n style={{\n ...buttonStyle(\"slate\", false, \"sm\"),\n width: 36,\n padding: \"4px 0\",\n opacity: disabled ? 0.45 : 1,\n }}\n >\n {dir < 0 ? \"◀\" : \"▶\"}\n </button>\n );\n}\n\nfunction Row({\n label,\n children,\n}: {\n label: string;\n children: ReactNode;\n}): ReactNode {\n return (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n alignItems: \"center\",\n gap: 6,\n }}\n >\n <span\n style={{ ...outlined, fontSize: 12, color: v.textDim, minWidth: 92 }}\n >\n {label}\n </span>\n {children}\n </div>\n );\n}\n\nfunction UltPicker({ session }: { session: RunSession }): ReactNode {\n const picked = session.progress.ult;\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ display: \"flex\", gap: 6, alignItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 13, minWidth: 92 }}>\n {t(\"ultimate\")}\n </span>\n {(Object.keys(ULTS) as UltId[]).map((u) => (\n <button\n key={u}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => session.setUlt(u)}\n data-tutorial-anchor={`hero:ult:${u}`}\n style={{\n ...buttonStyle(u === picked ? \"purple\" : \"grey\", false, \"sm\"),\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n }}\n >\n <Icon glyph={u === \"meteor\" ? \"flame\" : \"energy\"} size={16} />\n {t(u)}\n </button>\n ))}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(picked === \"meteor\" ? \"meteorText\" : \"frenzyText\")}. {t(\"ultOnce\")}\n </div>\n </div>\n );\n}\n\nfunction StartRow({ session }: { session: RunSession }): ReactNode {\n const now = useNow(1000);\n const economy = session.economy;\n const cost = session.runCost();\n const energy = session.balance(ENERGY_ID);\n const wait = economy\n ? nextEnergyIn(economy.recharge, session.rechargeState(), energy, now)\n : null;\n const potions = economy\n ? Math.min(POTION_SLOTS, session.balance(POTION_ID))\n : POTION_SLOTS;\n const canPay = cost === 0 || energy >= cost;\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n flexWrap: \"wrap\",\n }}\n >\n {economy ? (\n <ResourceCounter currencyID={ENERGY_ID} amount={energy} size={24} />\n ) : null}\n <span\n style={{\n ...outlined,\n fontSize: 13,\n display: \"inline-flex\",\n gap: 4,\n alignItems: \"center\",\n }}\n title={t(\"potions\")}\n >\n <Icon glyph=\"potion\" size={18} /> {potions}/{POTION_SLOTS}\n </span>\n {wait !== null ? (\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"nextEnergy\")} {formatDuration(wait)}\n </span>\n ) : null}\n <span style={{ flex: 1 }} />\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract={canPay && !!session.hero}\n disabled={!session.hero || session.busy}\n busy={session.busy}\n onClick={() => void session.start()}\n data-tutorial-anchor=\"hero:start\"\n >\n <Icon glyph=\"swords\" size={20} /> {t(\"play\")}\n <span style={{ fontSize: 13, marginLeft: 6 }}>\n {!economy ? (\n t(\"free\")\n ) : cost === 0 && session.balance(TICKET_ID) >= 1 ? (\n t(\"ticketReady\")\n ) : (\n <>\n <Icon glyph=\"energy\" size={14} /> {cost}\n </>\n )}\n </span>\n </Button>\n </div>\n );\n}\n\n/** The Warcraft table — only the types the player has met so far (they come island by island). */\nfunction TablePopup({\n island,\n onClose,\n}: {\n island: number;\n onClose: () => void;\n}): ReactNode {\n const known = typesKnownBy(island);\n const attacks = (Object.keys(DAMAGE_TABLE) as AttackType[]).filter(\n (a) => known.attack.has(a) || a === \"spells\",\n );\n const armors = (Object.keys(DAMAGE_TABLE.normal) as ArmorType[]).filter((a) =>\n known.armor.has(a),\n );\n return (\n <Popup title={t(\"table\")} onClose={onClose} width={520}>\n <div style={{ overflowX: \"auto\" }}>\n <table\n style={{\n borderCollapse: \"collapse\",\n width: \"100%\",\n ...outlined,\n fontSize: 12,\n }}\n >\n <thead>\n <tr>\n <th />\n {armors.map((a) => (\n <th key={a} style={{ padding: 4, color: v.textDim }}>\n {t(`armor.${a}` as Parameters<typeof t>[0])}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {attacks.map((atk) => (\n <tr key={atk}>\n <th style={{ padding: 4, textAlign: \"left\" }}>\n {t(`attack.${atk}` as Parameters<typeof t>[0])}\n </th>\n {armors.map((a) => {\n const f = DAMAGE_TABLE[atk][a];\n return (\n <td\n key={a}\n style={{\n padding: 4,\n textAlign: \"center\",\n color: f > 1 ? v.green : f < 1 ? v.textDim : v.text,\n }}\n >\n {Math.round(f * 100)}%\n </td>\n );\n })}\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n </Popup>\n );\n}\n\n/** The tips of a level's new types, once each. */\nexport function TipPopup({ session }: { session: RunSession }): ReactNode {\n const tips = session.newTips();\n if (session.running || session.ending || tips.length === 0) return null;\n return (\n <Popup\n title={t(\"tipTitle\")}\n onClose={() => session.markTipsSeen(tips)}\n width={420}\n variant=\"center\"\n queued\n >\n <div style={{ display: \"grid\", gap: 10 }}>\n {tips.map((key) => (\n <div\n key={key}\n style={{ ...outlined, fontSize: 14, lineHeight: 1.35 }}\n >\n {t(`tip.${key.replace(\":\", \".\")}` as Parameters<typeof t>[0])}\n </div>\n ))}\n <Button tone=\"gold\" onClick={() => session.markTipsSeen(tips)}>\n {t(\"tipOk\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\n/** The level a level number names, for the HUD (`levelOf` without the road). */\nexport function levelTitle(index: number): string {\n const l = levelOf(index);\n return `${l.island.index}-${l.level}`;\n}\n"
476
+ },
477
+ {
478
+ "path": "ui/RunBar.tsx",
479
+ "content": "import { useState, type ReactNode } from \"react\";\nimport { ConfirmPopup, Icon, outlined, useUiKit, v } from \"@idosgames/react/ui\";\nimport type { RunSession } from \"../game/session\";\nimport { t } from \"../i18n\";\n\n// The row under the road while a run goes: Auto, the ultimate with its charge sweep, the potions and\n// Leave. The hero's sheet and the cards taken are under it (RunStats).\n\nexport function RunBar({ session }: { session: RunSession }): ReactNode {\n const snap = session.battle.snapshot();\n const kit = useUiKit();\n const [leaving, setLeaving] = useState(false);\n // once a run: ready from the start, spent after; a card may bring it back\n const ready = snap.ultReady && !snap.pending;\n\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", gap: 8, alignItems: \"center\" }}>\n <Round\n active={snap.autoPlay}\n label={t(\"auto\")}\n anchor=\"hero:auto\"\n onClick={() => {\n kit.play(\"tab\");\n session.battle.setAutoPlay(!snap.autoPlay);\n }}\n >\n <span style={{ fontSize: 11, lineHeight: 1.05 }}>\n {t(\"auto\")}\n <br />\n {snap.autoPlay ? \"ON\" : \"OFF\"}\n </span>\n </Round>\n <Round\n active={snap.ultActiveLeft > 0}\n label={t(snap.ult)}\n anchor=\"hero:ult\"\n disabled={!ready}\n onClick={() => {\n if (session.battle.castUlt()) kit.play(\"claim\");\n }}\n >\n <Icon glyph={snap.ult === \"meteor\" ? \"flame\" : \"energy\"} size={26} />\n {!snap.ultReady ? (\n <span\n style={{\n position: \"absolute\",\n inset: 0,\n background: v.backdrop,\n opacity: 0.7,\n }}\n />\n ) : null}\n </Round>\n <Round\n active={false}\n label={t(\"potions\")}\n anchor=\"hero:potion\"\n disabled={snap.potions <= 0 || !!snap.pending}\n onClick={() => {\n if (session.battle.drinkPotion()) kit.play(\"claim\");\n }}\n >\n <Icon glyph=\"potion\" size={24} />\n <span\n style={{\n ...outlined,\n position: \"absolute\",\n right: 2,\n bottom: 0,\n fontSize: 12,\n }}\n >\n {snap.potions}\n </span>\n </Round>\n <span style={{ flex: 1 }} />\n <Round\n active={false}\n label={t(\"leave\")}\n anchor=\"hero:leave\"\n onClick={() => setLeaving(true)}\n >\n <Icon glyph=\"logout\" size={20} />\n </Round>\n </div>\n {leaving ? (\n <ConfirmPopup\n title={t(\"leave\")}\n tone=\"red\"\n confirmLabel={t(\"leave\")}\n onConfirm={() => {\n setLeaving(false);\n session.abandon();\n }}\n onClose={() => setLeaving(false)}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"leaveConfirm\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </div>\n );\n}\n\nfunction Round({\n children,\n active,\n disabled = false,\n label,\n anchor,\n onClick,\n}: {\n children: ReactNode;\n active: boolean;\n disabled?: boolean;\n label: string;\n anchor: string;\n onClick: () => void;\n}): ReactNode {\n return (\n <button\n type=\"button\"\n className=\"idos-press\"\n title={label}\n aria-label={label}\n disabled={disabled}\n data-tutorial-anchor={anchor}\n onClick={onClick}\n style={{\n ...outlined,\n position: \"relative\",\n width: 50,\n height: 50,\n borderRadius: \"50%\",\n border: `3px solid ${active ? v.gold : v.panelEdge}`,\n background: active\n ? `linear-gradient(180deg, ${v.gold}, ${v.goldDeep})`\n : v.panelDeep,\n color: active ? v.onGold : v.text,\n display: \"grid\",\n placeItems: \"center\",\n cursor: disabled ? \"default\" : \"pointer\",\n opacity: disabled ? 0.6 : 1,\n overflow: \"hidden\",\n padding: 0,\n flex: \"0 0 auto\",\n }}\n >\n {children}\n </button>\n );\n}\n"
480
+ },
481
+ {
482
+ "path": "ui/RunPopups.tsx",
483
+ "content": "import { useState, type ReactNode } from \"react\";\nimport {\n Button,\n Icon,\n KitPortal,\n Popup,\n Ribbon,\n grantedBy,\n outlined,\n useCelebrate,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport type { RunSession } from \"../game/session\";\nimport { CARD_BY_ID, type Rarity } from \"../game/cards\";\nimport { CAMP_FEAST_GEMS, GEMS_ID } from \"../game/economy\";\nimport { formatDuration } from \"../game/format\";\nimport { t, type Key } from \"../i18n\";\nimport { levelTitle } from \"./MapPanel\";\nimport { CardIconBadge } from \"./cardIcons\";\nimport { InfoPopup, StatStrip } from \"./RunStats\";\nimport { CardText } from \"./types\";\n\n// The run's moments that stop it: the choice of an upgrade when the rage fills (Obsidian Knight's\n// \"New level!\" — the hero's numbers on top, three wide cards, Refresh and Info under them), the camp,\n// and the end — victory or defeat, and the chest the run earned.\n\n/** A card's frame and its rarity tag by rarity (the octagon is cardIcons.tsx). */\nexport const RARITY_LOOK: Readonly<\n Record<Rarity, { tag: string; ink: string; glow: string }>\n> = {\n common: { tag: \"#b9bec9\", ink: \"#2b2e36\", glow: \"rgba(255,255,255,.55)\" },\n rare: { tag: \"#5fb0ff\", ink: \"#0f2a52\", glow: \"rgba(120,190,255,.6)\" },\n legendary: { tag: \"#f2c443\", ink: \"#4a3000\", glow: \"rgba(255,214,64,.75)\" },\n};\n\nexport function CardPick({ session }: { session: RunSession }): ReactNode {\n const pending = session.battle.pending;\n const kit = useUiKit();\n const [info, setInfo] = useState(false);\n if (pending?.kind !== \"cards\") return null;\n const snap = session.battle.snapshot();\n const cards = pending.options\n .map((id) => CARD_BY_ID.get(id)!)\n .filter(Boolean);\n const best = cards.some((c) => c.rarity === \"legendary\")\n ? \"legendary\"\n : cards.some((c) => c.rarity === \"rare\")\n ? \"rare\"\n : \"common\";\n return (\n <KitPortal>\n <div\n style={{\n position: \"fixed\",\n inset: 0,\n // under the kit's popups (20): \"Info\" opens on top of the choice, not behind it\n zIndex: 19,\n pointerEvents: \"auto\",\n overflowY: \"auto\",\n background: \"rgba(12, 12, 20, .82)\",\n display: \"grid\",\n justifyItems: \"center\",\n alignContent: \"start\",\n padding:\n \"max(14px, env(safe-area-inset-top)) 12px max(18px, env(safe-area-inset-bottom))\",\n fontFamily: v.font,\n }}\n >\n <div\n style={{\n width: \"min(560px, 100%)\",\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n }}\n >\n <StatStrip session={session} />\n <div\n style={{\n width: \"100%\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 6,\n paddingTop: 34,\n // the burst behind the ribbon: gold when a legendary is among the three\n background: `radial-gradient(ellipse 50% 60% at 50% 30%, ${RARITY_LOOK[best].glow}, transparent 70%)`,\n }}\n >\n <Ribbon tone=\"slate\" stars>\n {t(\"newLevel\")}\n </Ribbon>\n <div style={{ ...outlined, fontSize: 22, textAlign: \"center\" }}>\n {t(\"chooseUpgrade\")}\n </div>\n <div style={{ ...outlined, fontSize: 15 }}>\n {t(\"upgradeN\")}:{\" \"}\n <span style={{ color: \"#ff9a2e\" }}>\n {pending.index}/{pending.total}\n </span>\n </div>\n </div>\n <div style={{ display: \"grid\", gap: 12, width: \"100%\" }}>\n {cards.map((card, i) => {\n const look = RARITY_LOOK[card.rarity];\n return (\n <button\n key={`${card.id}:${i}`}\n type=\"button\"\n className=\"idos-press idos-pop-in\"\n data-tutorial-anchor={`hero:card:${card.id}`}\n onClick={() => {\n if (session.takeCard(card.id)) kit.play(\"claim\");\n }}\n style={{\n position: \"relative\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n width: \"100%\",\n minHeight: 104,\n padding: \"18px 14px 12px 12px\",\n borderRadius: 8,\n background: \"linear-gradient(180deg, #3e414a, #33363e)\",\n border: \"4px solid #17181d\",\n boxShadow: \"inset 0 0 0 2px #4d505a, 0 4px 0 #0d0e12\",\n textAlign: \"left\",\n cursor: \"pointer\",\n animationDelay: `${i * 70}ms`,\n }}\n >\n <span\n style={{\n position: \"absolute\",\n top: -4,\n left: -4,\n padding: \"2px 22px 2px 92px\",\n background: look.tag,\n color: look.ink,\n fontWeight: 900,\n fontSize: 13,\n letterSpacing: 0.3,\n textTransform: \"uppercase\",\n clipPath:\n \"polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%)\",\n border: \"2px solid #17181d\",\n }}\n >\n {t(`rarity.${card.rarity}` as Key)}\n </span>\n <CardIconBadge\n icon={card.icon}\n rarity={card.rarity}\n size={84}\n />\n <CardText card={card} size={16} />\n </button>\n );\n })}\n </div>\n <div\n style={{\n position: \"relative\",\n width: \"100%\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 4,\n marginTop: 6,\n }}\n >\n <Button\n tone=\"blue\"\n size=\"lg\"\n disabled={snap.rerollsLeft <= 0}\n onClick={() => {\n if (session.reroll()) kit.play(\"tab\");\n }}\n data-tutorial-anchor=\"hero:reroll\"\n >\n {t(\"refresh\")} <Icon glyph=\"clapper\" size={22} />\n </Button>\n <span style={{ ...outlined, fontSize: 14 }}>\n {t(\"left\")}:{\" \"}\n <span style={{ color: \"#ff9a2e\" }}>\n {snap.rerollsLeft}/{session.battle.rerollsTotal}\n </span>\n </span>\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() => setInfo(true)}\n style={{\n ...outlined,\n position: \"absolute\",\n right: 0,\n top: 0,\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n background: \"none\",\n border: \"none\",\n cursor: \"pointer\",\n fontSize: 13,\n }}\n >\n <Icon glyph=\"scroll\" size={40} />\n {t(\"info\")}\n </button>\n </div>\n </div>\n </div>\n {info ? (\n <InfoPopup session={session} onClose={() => setInfo(false)} />\n ) : null}\n </KitPortal>\n );\n}\n\nexport function CampPopup({ session }: { session: RunSession }): ReactNode {\n if (session.battle.pending?.kind !== \"camp\") return null;\n const feast = !!session.economy?.gemsToXp;\n return (\n <Popup title={t(\"camp\")} width={420} variant=\"center\" dismissible={false}>\n <div style={{ display: \"grid\", gap: 10, justifyItems: \"center\" }}>\n <Icon glyph=\"flame\" size={52} />\n <div style={{ ...outlined, fontSize: 14 }}>{t(\"campText\")}</div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n <Button tone=\"green\" onClick={() => void session.chooseCamp(\"heal\")}>\n <Icon glyph=\"heart\" size={18} /> {t(\"campHeal\")} +40%\n </Button>\n <Button tone=\"red\" onClick={() => void session.chooseCamp(\"rage\")}>\n <Icon glyph=\"flame\" size={18} /> {t(\"campRage\")} +5\n </Button>\n </div>\n {feast ? (\n <Button\n tone=\"purple\"\n busy={session.busy}\n disabled={session.balance(GEMS_ID) < CAMP_FEAST_GEMS}\n onClick={() => void session.chooseCamp(\"both\")}\n >\n {t(\"campBoth\")}: <Icon glyph=\"heart\" size={16} />+\n <Icon glyph=\"flame\" size={16} /> · <Icon glyph=\"gem\" size={16} />\n {CAMP_FEAST_GEMS}\n </Button>\n ) : null}\n </div>\n </Popup>\n );\n}\n\nconst CHEST_NAME = {\n defeat: \"chestDefeat\",\n victory: \"chestVictory\",\n treasure: \"chestTreasure\",\n} as const;\n\nexport function RunEnd({ session }: { session: RunSession }): ReactNode {\n const ending = session.ending;\n const celebrate = useCelebrate();\n const kit = useUiKit();\n if (!ending) return null;\n const r = ending.result;\n const open = async () => {\n const data = await session.claim();\n if (data) celebrate(grantedBy(data), { sound: \"levelUp\" });\n };\n return (\n <Popup\n title={r.cleared ? t(\"victory\") : t(\"defeat\")}\n width={400}\n variant=\"center\"\n dismissible={false}\n >\n <div\n style={{\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n textAlign: \"center\",\n }}\n >\n <div style={{ ...outlined, fontSize: 15, color: v.textDim }}>\n {t(\"level\")} {levelTitle(r.level)}\n </div>\n <div style={{ display: \"flex\", gap: 16, ...outlined, fontSize: 14 }}>\n <span>\n <Icon glyph=\"swords\" size={16} /> {t(\"kills\")}: {r.kills}\n </span>\n <span>\n <Icon glyph=\"flame\" size={16} /> {t(\"reached\")}: {r.runLevel}\n </span>\n <span>\n <Icon glyph=\"clock\" size={16} /> {formatDuration(r.seconds)}\n </span>\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph=\"chest\" size={72} />\n </div>\n <div style={{ ...outlined, fontSize: 18, color: v.gold }}>\n {t(CHEST_NAME[ending.tier])}\n </div>\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={ending.claiming}\n onClick={() => {\n kit.play(\"click\");\n void open();\n }}\n data-tutorial-anchor=\"hero:claim\"\n >\n {session.economy ? t(\"open\") : t(\"toMap\")}\n </Button>\n </div>\n </Popup>\n );\n}\n"
484
+ },
485
+ {
486
+ "path": "ui/RunStats.tsx",
487
+ "content": "import { useState, type ReactNode } from \"react\";\nimport {\n Icon,\n Popup,\n outlined,\n pickLanguage,\n toneSurface,\n v,\n type Glyph,\n} from \"@idosgames/react/ui\";\nimport type { RunSession } from \"../game/session\";\nimport { HERO_NUMBERS, type HeroNumber } from \"../game/battle\";\nimport { CARD_BY_ID, type RunMods } from \"../game/cards\";\nimport type { ArmorType, AttackType } from \"../game/damage\";\nimport { formatBig } from \"../game/format\";\nimport { t, type Key } from \"../i18n\";\nimport { ARMOR, HP, INK } from \"../phaser/bars\";\nimport { CardIconBadge } from \"./cardIcons\";\nimport { CardText, TypeChip, cardPlain } from \"./types\";\n\n// The hero's sheet during a run, under the road — Obsidian Knight's: three bars (the rage to the next\n// run level, the health, the shield), the eight numbers in two columns (a number the cards raised is\n// green, with what they added), and the cards taken as tiles with \"Info\" — every card's text, the\n// effects that are not a number of the sheet, the bonds.\n\nconst GLYPH: Record<HeroNumber, Glyph> = {\n damage: \"sword\",\n attackSpeed: \"energy\",\n critChance: \"star\",\n critMultiplier: \"flame\",\n defense: \"shield\",\n counter: \"swords\",\n dodge: \"bulb\",\n lifesteal: \"heart\",\n};\n\n/** A colour of the scene's bars (a Phaser number) as CSS. */\nconst css = (c: number) => `#${c.toString(16).padStart(6, \"0\")}`;\n\nconst pct = (x: number) => `${Math.round(x * 1000) / 10}%`;\nconst decimal = (x: number, digits: number) => {\n const s = (Math.round(x * 10 ** digits) / 10 ** digits).toString();\n return pickLanguage() === \"ru\" ? s.replace(\".\", \",\") : s;\n};\n\nfunction show(id: HeroNumber, x: number): string {\n if (id === \"damage\") return formatBig(Math.round(x));\n if (id === \"attackSpeed\") return `${decimal(x, 2)}/s`;\n if (id === \"critMultiplier\") return `${Math.round(x * 100)}%`;\n return pct(x);\n}\n\nfunction delta(id: HeroNumber, d: number): string {\n if (id === \"damage\") return `+${formatBig(Math.round(d))}`;\n if (id === \"attackSpeed\") return `+${decimal(d, 2)}`;\n if (id === \"critMultiplier\") return `+${Math.round(d * 100)}%`;\n return `+${pct(d)}`;\n}\n\nexport function RunStats({ session }: { session: RunSession }): ReactNode {\n const b = session.battle;\n const snap = b.snapshot();\n const numbers = b.heroNumbers();\n const [info, setInfo] = useState(false);\n const stacks = new Map<string, number>();\n for (const id of b.cards) stacks.set(id, (stacks.get(id) ?? 0) + 1);\n\n return (\n <div\n style={{\n display: \"grid\",\n gap: 8,\n maxWidth: 720,\n width: \"100%\",\n margin: \"0 auto\",\n }}\n >\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(3, minmax(0, 1fr))\",\n gap: 6,\n }}\n >\n <Bar\n badge={String(snap.runLevel)}\n badgeTone=\"green\"\n value={snap.rage - snap.rageFrom}\n max={snap.rageTo - snap.rageFrom}\n fill=\"linear-gradient(180deg, #7be35a, #2f9e2a)\"\n anchor=\"hero:rage\"\n title={t(\"rage\")}\n />\n <Bar\n glyph=\"heart\"\n value={snap.heroHp}\n max={snap.heroMaxHp}\n scene={HP}\n title={t(\"stat.health\")}\n />\n <Bar\n glyph=\"shield\"\n value={snap.heroShield}\n max={snap.heroMaxShield}\n scene={ARMOR}\n title={t(\"stat.shield\")}\n />\n </div>\n\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(2, minmax(0, 1fr))\",\n gridAutoFlow: \"column\",\n gridTemplateRows: \"repeat(4, auto)\",\n gap: 6,\n }}\n >\n {HERO_NUMBERS.map((id) => {\n const { base, now } = numbers[id];\n const gain = now - base;\n const up = gain > 1e-9;\n return (\n <div\n key={id}\n data-tutorial-anchor={`hero:stat:${id}`}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n padding: \"5px 8px\",\n borderRadius: 10,\n background: v.panelDeep,\n border: `2px solid ${v.backdrop}`,\n minWidth: 0,\n }}\n >\n <span\n style={{\n width: 34,\n height: 34,\n borderRadius: 8,\n background: v.backdrop,\n display: \"grid\",\n placeItems: \"center\",\n flex: \"0 0 auto\",\n }}\n >\n <Icon glyph={GLYPH[id]} size={24} />\n </span>\n <span style={{ display: \"grid\", minWidth: 0 }}>\n <span style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(`stat.${id}` as Key)}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 16,\n color: up ? v.green : v.text,\n whiteSpace: \"nowrap\",\n }}\n >\n {show(id, now)}\n {up ? (\n <span style={{ fontSize: 11, marginLeft: 4 }}>\n ({delta(id, gain)})\n </span>\n ) : null}\n </span>\n </span>\n </div>\n );\n })}\n </div>\n\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 5,\n flex: 1,\n minWidth: 0,\n }}\n >\n {[...stacks.entries()].map(([id, n]) => {\n const card = CARD_BY_ID.get(id);\n if (!card) return null;\n return (\n <span\n key={id}\n title={cardPlain(card)}\n style={{ position: \"relative\", display: \"inline-grid\" }}\n >\n <CardIconBadge\n icon={card.icon}\n rarity={card.rarity}\n size={40}\n />\n {n > 1 ? (\n <span\n style={{\n ...outlined,\n position: \"absolute\",\n right: 0,\n bottom: -2,\n fontSize: 11,\n }}\n >\n ×{n}\n </span>\n ) : null}\n </span>\n );\n })}\n {b.cards.length === 0 ? (\n <span\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n alignSelf: \"center\",\n }}\n >\n {t(\"noCards\")}\n </span>\n ) : null}\n </div>\n <button\n type=\"button\"\n className=\"idos-press\"\n onClick={() => setInfo(true)}\n data-tutorial-anchor=\"hero:info\"\n style={{\n ...outlined,\n ...toneSurface(\"purple\"),\n display: \"grid\",\n justifyItems: \"center\",\n gap: 1,\n padding: \"4px 8px\",\n borderRadius: 10,\n fontSize: 11,\n cursor: \"pointer\",\n }}\n >\n <Icon glyph=\"scroll\" size={22} />\n {t(\"info\")}\n </button>\n </div>\n\n {info ? (\n <InfoPopup session={session} onClose={() => setInfo(false)} />\n ) : null}\n </div>\n );\n}\n\n/** One of the three bars: an icon (or the run level's badge), the bar, \"current/max\" on it. */\nfunction Bar({\n glyph,\n badge,\n badgeTone,\n value,\n max,\n fill,\n scene,\n title,\n anchor,\n}: {\n glyph?: Glyph;\n badge?: string;\n badgeTone?: \"green\";\n value: number;\n max: number;\n /** A CSS fill of its own… */\n fill?: string;\n /** …or drawn as the bar under the hero in the scene (phaser/bars.ts): a flat fill, a lit top, its track. */\n scene?: { fill: number; light: number; track: number };\n title: string;\n anchor?: string;\n}): ReactNode {\n const share = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;\n return (\n <div\n title={title}\n data-tutorial-anchor={anchor}\n style={{ display: \"flex\", alignItems: \"center\", gap: 3, minWidth: 0 }}\n >\n {badge !== undefined ? (\n <span\n style={{\n ...outlined,\n ...toneSurface(badgeTone ?? \"green\"),\n minWidth: 26,\n height: 24,\n borderRadius: 7,\n display: \"grid\",\n placeItems: \"center\",\n fontSize: 13,\n flex: \"0 0 auto\",\n }}\n >\n {badge}\n </span>\n ) : (\n <Icon glyph={glyph ?? \"heart\"} size={24} />\n )}\n <div\n style={{\n position: \"relative\",\n flex: 1,\n minWidth: 0,\n height: 20,\n borderRadius: 6,\n background: scene ? css(scene.track) : v.well,\n border: `2px solid ${scene ? css(INK) : v.backdrop}`,\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n position: \"relative\",\n width: `${share * 100}%`,\n height: \"100%\",\n background: scene ? css(scene.fill) : fill,\n transition: `width ${v.fast} linear`,\n }}\n >\n {scene && share > 0 ? (\n // the lit top of the bar, as the scene draws it\n <div\n style={{\n position: \"absolute\",\n top: 1,\n left: 1,\n right: 1,\n height: \"40%\",\n borderRadius: 2,\n background: css(scene.light),\n opacity: 0.55,\n }}\n />\n ) : null}\n </div>\n <span\n style={{\n ...outlined,\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n fontSize: 11,\n whiteSpace: \"nowrap\",\n }}\n >\n {formatBig(Math.max(0, Math.ceil(value)))}/{formatBig(Math.ceil(max))}\n </span>\n </div>\n </div>\n );\n}\n\n/** The hero's eight numbers as a strip of tiles (the choice screen's top): green — raised by the cards. */\nexport function StatStrip({ session }: { session: RunSession }): ReactNode {\n const numbers = session.battle.heroNumbers();\n return (\n <div\n style={{\n width: \"100%\",\n display: \"grid\",\n gridTemplateColumns: \"repeat(8, minmax(0, 1fr))\",\n gap: 4,\n }}\n >\n {HERO_NUMBERS.map((id) => {\n const { base, now } = numbers[id];\n const color =\n now > base + 1e-9\n ? \"#5dea5a\"\n : now < base - 1e-9\n ? \"#ff4d42\"\n : \"#ffffff\";\n return (\n <div\n key={id}\n title={t(`stat.${id}` as Key)}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n padding: \"6px 2px 4px\",\n borderRadius: 6,\n background: \"rgba(255,255,255,.08)\",\n }}\n >\n <Icon glyph={GLYPH[id]} size={26} />\n <span\n style={{ ...outlined, fontSize: 13, color, whiteSpace: \"nowrap\" }}\n >\n {show(id, now)}\n </span>\n </div>\n );\n })}\n </div>\n );\n}\n\n/** \"Info\": every card taken with its text, the effects that are not a number of the sheet, the charges. */\nexport function InfoPopup({\n session,\n onClose,\n}: {\n session: RunSession;\n onClose: () => void;\n}): ReactNode {\n const b = session.battle;\n const stacks = new Map<string, number>();\n for (const id of b.cards) stacks.set(id, (stacks.get(id) ?? 0) + 1);\n const effects = effectLines(b.mods);\n const charges = Object.entries(b.charges).filter(([, n]) => n > 0);\n return (\n <Popup title={t(\"info\")} onClose={onClose} width={480}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n }}\n >\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"yourAttack\")}\n </span>\n <TypeChip kind=\"attack\" type={b.heroAttack} />\n <TypeChip kind=\"armor\" type={b.heroArmorType} />\n </div>\n {[...stacks.entries()].map(([id, n]) => {\n const card = CARD_BY_ID.get(id);\n if (!card) return null;\n return (\n <div\n key={id}\n style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}\n >\n <CardIconBadge icon={card.icon} rarity={card.rarity} size={44} />\n <span>\n <CardText card={card} size={13} />\n {n > 1 ? (\n <span style={{ ...outlined, fontSize: 13 }}> ×{n}</span>\n ) : null}\n </span>\n </div>\n );\n })}\n {effects.length > 0 || charges.length > 0 ? (\n <div style={{ display: \"grid\", gap: 4 }}>\n <span style={{ ...outlined, fontSize: 14 }}>{t(\"effects\")}</span>\n {effects.map((line) => (\n <span key={line} style={{ ...outlined, fontSize: 12 }}>\n • {line}\n </span>\n ))}\n {charges.map(([charge, n]) => (\n <span\n key={charge}\n style={{ ...outlined, fontSize: 12, color: \"#ff8a3a\" }}\n >\n • {t(`charge.${charge}` as Key)} ×{n}\n </span>\n ))}\n </div>\n ) : null}\n {b.cards.length === 0 ? (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"noCards\")}\n </span>\n ) : null}\n </div>\n </Popup>\n );\n}\n\n/** The cards' passive effects that are not a number of the sheet, each as a line of text. */\nexport function effectLines(m: RunMods): string[] {\n const out: string[] = [];\n const say = (key: Key, value: string, more: Record<string, string> = {}) => {\n let text = t(key).replace(\"{v}\", value);\n for (const [k, x] of Object.entries(more)) text = text.replace(`{${k}}`, x);\n out.push(text);\n };\n for (const [type, share] of Object.entries(m.hybrid))\n if (share)\n say(\"fx.hybrid\", pct(share), {\n type: t(`attack.${type as AttackType}` as Key),\n });\n for (const [type, share] of Object.entries(m.vsArmor))\n if (share)\n say(\"fx.vsArmor\", pct(share), {\n type: t(`armor.${type as ArmorType}` as Key),\n });\n if (m.shred > 0)\n say(\"fx.shred\", String(m.shred), { max: String(m.shredMax) });\n if (m.warCry > 0) say(\"fx.warCry\", String(m.warCry));\n if (m.maxHp > 0) say(\"fx.maxHp\", pct(m.maxHp));\n const shares: Array<[keyof RunMods, Key]> = [\n [\"doubleHit\", \"fx.doubleHit\"],\n [\"backRow\", \"fx.backRow\"],\n [\"pierceShare\", \"fx.pierce\"],\n [\"vsStunned\", \"fx.vsStunned\"],\n [\"missingHpDamage\", \"fx.missingHp\"],\n [\"vsPierce\", \"fx.vsPierce\"],\n [\"counterCrit\", \"fx.counterCrit\"],\n [\"lowHpSpeed\", \"fx.lowHpSpeed\"],\n [\"fullHpDamage\", \"fx.fullHpDamage\"],\n [\"shieldIgnore\", \"fx.shieldIgnore\"],\n [\"repairPower\", \"fx.repairPower\"],\n [\"potionPower\", \"fx.potionPower\"],\n ];\n for (const [field, key] of shares) {\n const x = m[field] as number;\n if (x > 0) say(key, pct(x));\n }\n return out;\n}\n"
488
+ },
489
+ {
490
+ "path": "ui/types.tsx",
491
+ "content": "import type { CSSProperties, ReactNode } from \"react\";\nimport { Icon, outlined, pickLanguage, v } from \"@idosgames/react/ui\";\nimport type { ArmorType, AttackType, Effect } from \"../game/damage\";\nimport type { CardDef } from \"../game/cards\";\nimport { t } from \"../i18n\";\n\n// Small pieces the screens share: a type of attack or armor as a chip (its colour says how YOUR attack\n// fares against it), and a card's text in the interface language.\n\nconst EFFECT_COLOR: Record<Effect, string> = {\n strong: \"#4fd16a\",\n even: \"#5a6a9a\",\n weak: \"#8a7f96\",\n};\n\nexport function TypeChip({\n kind,\n type,\n effect,\n share,\n style,\n}: {\n kind: \"attack\" | \"armor\";\n type: AttackType | ArmorType;\n /** How your attack fares against this armor (colours the chip). */\n effect?: Effect;\n /** Its share of the road (shown as a percent). */\n share?: number;\n style?: CSSProperties;\n}): ReactNode {\n const name = t(`${kind}.${type}` as Parameters<typeof t>[0]);\n return (\n <span\n title={effect ? t(effect) : undefined}\n style={{\n ...outlined,\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n padding: \"2px 8px 2px 4px\",\n borderRadius: 999,\n fontSize: 12,\n whiteSpace: \"nowrap\",\n background: effect ? EFFECT_COLOR[effect] : v.backdrop,\n ...style,\n }}\n >\n <Icon glyph={kind === \"attack\" ? \"sword\" : \"shield\"} size={16} />\n {name}\n {share !== undefined ? (\n <span style={{ color: v.textDim }}>{Math.round(share * 100)}%</span>\n ) : null}\n </span>\n );\n}\n\n/** A card's text in the interface language, with its markup (cards.ts). */\nexport function cardMarkup(card: CardDef): string {\n return pickLanguage() === \"ru\" ? card.text[1] : card.text[0];\n}\n\n/** The text without the markup (a tooltip, the agent). */\nexport function cardPlain(card: CardDef): string {\n return cardMarkup(card).replace(/\\*\\*|\\[\\/?[go]\\]/g, \"\");\n}\n\nconst BOLD = /(\\*\\*.+?\\*\\*)/g;\nconst COLOURED = /(\\[g\\].*?\\[\\/g\\]|\\[o\\].*?\\[\\/o\\])/g;\n\n/** Green numbers and orange names inside a run of text (the trigger's bold one too). */\nfunction coloured(text: string, key: string): ReactNode[] {\n return text\n .split(COLOURED)\n .filter(Boolean)\n .map((part, i) =>\n part.startsWith(\"[g]\") ? (\n <span key={`${key}${i}`} style={{ color: \"#5dea5a\" }}>\n {part.slice(3, -4)}\n </span>\n ) : part.startsWith(\"[o]\") ? (\n <span key={`${key}${i}`} style={{ color: \"#ff8a3a\" }}>\n {part.slice(3, -4)}\n </span>\n ) : (\n <span key={`${key}${i}`}>{part}</span>\n ),\n );\n}\n\n/**\n * A card's text as Obsidian Knight writes it: the trigger white and bold, the numbers green, the names\n * of effects orange, the rest light grey.\n */\nexport function CardText({\n card,\n size = 15,\n}: {\n card: CardDef;\n size?: number;\n}): ReactNode {\n const parts = cardMarkup(card).split(BOLD).filter(Boolean);\n return (\n <span\n style={{ ...outlined, fontSize: size, lineHeight: 1.3, color: \"#d9dbe3\" }}\n >\n {parts.map((part, i) =>\n part.startsWith(\"**\") ? (\n <span key={i} style={{ color: \"#ffffff\", fontWeight: 900 }}>\n {coloured(part.slice(2, -2), `b${i}-`)}\n </span>\n ) : (\n coloured(part, `t${i}-`)\n ),\n )}\n </span>\n );\n}\n\nexport function pairText(pair: [string, string]): string {\n return pickLanguage() === \"ru\" ? pair[1] : pair[0];\n}\n"
492
+ },
493
+ {
494
+ "path": "module.meta.json",
495
+ "content": "{\n \"id\": \"hero-run\",\n \"type\": \"template\",\n \"uses\": [\"character\", \"lootboxes\", \"store\", \"inventory\"],\n \"summary\": \"Roguelike runs in the style of Obsidian Knight with Warcraft III's damage: the hero runs a level on its own, rage fills and each run level is a choice of one of three cards; a weapon's attack type meets a troop's armor type.\",\n \"description\": \"A roguelike auto-runner on Phaser with a React screen. The campaign is islands of six levels; a level is ONE run along a road (Obsidian Knight style: the hero runs, its feet in step with the road, the world scrolls in painted parallax per island theme): waves of troops standing in line (brawlers one at a time face to face, shooters fire once the hero is in their range), a camp (health or rage, both for gems), fountains, a chest, a boss at the end. Kills fill the RAGE; every threshold (3, 7, 14, 22, … as in Obsidian Knight) is a run level and a pause to take one of three cards — 58 upgrades in Obsidian Knight's form, a trigger and an effect (passive; on hit, crit, kill, dodge, counter, being hit, heal, overheal, the active skill, rest; every N-th attack; a new run level) throwing Lightning, Axes, Piercing spears and Arrows, stunning, healing, repairing the armor, giving charges (Focus, Divine shield, lightning); one Refresh a run. Death ends the run. Damage is Warcraft III's: seven attack types (normal, pierce, siege, magic, chaos, spells, hero) × six armor types (light, medium, heavy, fortified, hero, unarmored) by the Frozen Throne table, and armor points by the WC3 formula 0.06·A/(1+0.06·A) — negative armor amplifies. Types come from the gear and read off the picture: troops (people on the hero's rig) are dressed in what gives their types, the hero's weapon and top give its own (an item may name them in CustomData AttackType / ArmorType), a shield is a blue pool that takes the blows first. The types come in island by island (the first is casual), with a scouting screen and one tip per new type. One ultimate slot (meteor or frenzy) used once a run — only a card's chance (at a kill, at a new run level) brings it back; potions taken into the run; no health regen. A run costs ENERGY (Recharge) converted into a RUN_TICKET and ends in a reward chest opened with that ticket (lootboxes run_chest_defeat / victory / treasure); XP is a currency, the player's level its balance. Without these currencies the runs are free and pay nothing. The hero's fight numbers are the server's character stats (a mirror of PvPBattleEngine.CalculateStats) and are NOT sold: between runs they grow from gear (item levels) and rank on the Heroes screen, in a run from the cards; during a run the panel under the road shows the hero's sheet — every number with what the cards add (+15), the cards taken and their effects. Progress is saved in the player's private custom data (hero_run_progress); cleared levels and finished runs are reported to quests that count level_cleared / run_finished.\",\n \"provides\": [\n \"roguelike runs of levels: waves, camp, fountains, chest, boss\",\n \"rage and run levels with a choice of 1 of 3 cards (58 trigger-and-effect upgrades, a refresh)\",\n \"Warcraft III attack types × armor types and the armor formula\",\n \"types from the gear: troops and the hero read off their look\",\n \"scouting of a level and type tips introduced island by island\",\n \"one ultimate a run (cards may bring it back) and potions\",\n \"energy → run ticket → reward chest (server-side through currency and lootboxes)\",\n \"2D procedural art (Phaser) with a layered 2D character\",\n \"the run's hero sheet: numbers with the cards' bonuses, card effects\"\n ],\n \"tags\": [\n \"roguelike\",\n \"rpg\",\n \"auto-battler\",\n \"obsidian-knight\",\n \"warcraft\",\n \"cards\",\n \"phaser\",\n \"2d\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\",\n \"events\": {\n \"emits\": [\n {\n \"topic\": \"hero-run:character-upgraded@1\",\n \"when\": \"a hero's rank went up (client.character.upgradeCharacterLevel, from any screen)\",\n \"payload\": {\n \"characterId\": \"string\",\n \"level\": \"number\"\n }\n },\n {\n \"topic\": \"hero-run:level-cleared@1\",\n \"when\": \"the hero beat a level's boss; island-level is the level just cleared\",\n \"payload\": {\n \"island\": \"number\",\n \"level\": \"number\"\n }\n }\n ],\n \"listens\": [\n {\n \"topic\": \"character:hero-selected@1\",\n \"why\": \"fields the hero picked on the Heroes screen (optional — without it the strongest hero fights)\"\n }\n ]\n }\n}\n"
496
+ }
497
+ ]
498
+ }