@idosgames/mcp 0.1.44 → 0.1.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1597 -1542
- package/package.json +1 -1
- package/registry/host.json +3 -3
- package/registry/index.json +172 -125
- package/registry/modules/ai.json +5 -5
- package/registry/modules/board-game.json +6 -6
- package/registry/modules/boosts.json +5 -5
- package/registry/modules/character.json +9 -9
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +5 -5
- package/registry/modules/coop-events.json +6 -6
- package/registry/modules/craft.json +5 -5
- package/registry/modules/deals.json +6 -6
- package/registry/modules/events.json +3 -3
- package/registry/modules/friends.json +3 -3
- package/registry/modules/hero-run.json +15 -234
- package/registry/modules/idle-rpg.json +8 -8
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +3 -3
- package/registry/modules/kit-game.json +171 -0
- package/registry/modules/leaderboard.json +3 -3
- package/registry/modules/lootboxes.json +5 -5
- package/registry/modules/mailbox.json +3 -3
- package/registry/modules/marketplace.json +3 -3
- package/registry/modules/mini-game.json +3 -3
- package/registry/modules/multiplayer.json +3 -3
- package/registry/modules/premium.json +5 -5
- package/registry/modules/profile.json +3 -3
- package/registry/modules/pvp.json +3 -3
- package/registry/modules/quests.json +3 -3
- package/registry/modules/rewards.json +3 -3
- package/registry/modules/season.json +3 -3
- package/registry/modules/store.json +6 -6
- package/registry/modules/tutorials.json +3 -3
- package/registry/modules/video-studio.json +9 -9
- package/registry/modules/voxelcraft.json +6 -6
- package/registry/modules/workshop.json +6 -6
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +1 -1
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/store-system.json +1 -1
- package/registry/video.json +1 -1
|
@@ -69,253 +69,34 @@
|
|
|
69
69
|
}
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
|
-
"@idosgames/
|
|
73
|
-
"@idosgames/
|
|
74
|
-
"@idosgames/
|
|
72
|
+
"@idosgames/characters": "0.1.3",
|
|
73
|
+
"@idosgames/core": "0.26.1",
|
|
74
|
+
"@idosgames/module-sdk": "0.13.3",
|
|
75
|
+
"@idosgames/react": "0.7.1",
|
|
75
76
|
"phaser": "4.2.1",
|
|
76
77
|
"react": "19.2.7"
|
|
77
78
|
},
|
|
78
|
-
"contentHash": "
|
|
79
|
+
"contentHash": "41609f618d1e9d5deeceb1c05d6eb3f7ad110cfa49ff8fae21c05153f0b009f8",
|
|
79
80
|
"files": [
|
|
80
|
-
{
|
|
81
|
-
"path": "character/art/svg.ts",
|
|
82
|
-
"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"
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
"path": "character/core/animation.ts",
|
|
86
|
-
"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"
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
"path": "character/core/animator.ts",
|
|
90
|
-
"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"
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
"path": "character/core/attachments.ts",
|
|
94
|
-
"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"
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
"path": "character/core/catalog.ts",
|
|
98
|
-
"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"
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
"path": "character/core/constraints.ts",
|
|
102
|
-
"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"
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
"path": "character/core/curves.ts",
|
|
106
|
-
"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"
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
"path": "character/core/data.ts",
|
|
110
|
-
"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"
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
"path": "character/core/limbMesh.ts",
|
|
114
|
-
"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"
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
"path": "character/core/look.ts",
|
|
118
|
-
"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"
|
|
119
|
-
},
|
|
120
|
-
{
|
|
121
|
-
"path": "character/core/PartsManager.ts",
|
|
122
|
-
"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"
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
"path": "character/core/random.ts",
|
|
126
|
-
"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"
|
|
127
|
-
},
|
|
128
|
-
{
|
|
129
|
-
"path": "character/core/rig.ts",
|
|
130
|
-
"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"
|
|
131
|
-
},
|
|
132
|
-
{
|
|
133
|
-
"path": "character/core/rules.ts",
|
|
134
|
-
"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"
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
"path": "character/core/shading.ts",
|
|
138
|
-
"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"
|
|
139
|
-
},
|
|
140
|
-
{
|
|
141
|
-
"path": "character/core/skeleton.ts",
|
|
142
|
-
"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"
|
|
143
|
-
},
|
|
144
|
-
{
|
|
145
|
-
"path": "character/core/types.ts",
|
|
146
|
-
"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"
|
|
147
|
-
},
|
|
148
|
-
{
|
|
149
|
-
"path": "character/editor/CharacterEditor.tsx",
|
|
150
|
-
"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"
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
"path": "character/editor/ColorPicker.tsx",
|
|
154
|
-
"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"
|
|
155
|
-
},
|
|
156
|
-
{
|
|
157
|
-
"path": "character/editor/styles.ts",
|
|
158
|
-
"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: none; }\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: none; }\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"
|
|
159
|
-
},
|
|
160
81
|
{
|
|
161
82
|
"path": "character/index.ts",
|
|
162
|
-
"content": "//
|
|
83
|
+
"content": "// Персонаж игры: 2D-герой из пакета @idosgames/characters (риг, анимации, весь гардероб, рендер) +\n// то, что знает только эта игра — откуда берётся вид героя (класс, надетая экипировка, разнообразие\n// по id). Игра импортирует персонажа только отсюда.\n\nexport * from \"@idosgames/characters/rig2d\";\nexport {\n CharacterView,\n createCharacterStage,\n vectorArt,\n type ArtSource,\n type CharacterStage,\n type CharacterStageOptions,\n type CharacterViewOptions,\n} from \"@idosgames/characters/rig2d/phaser\";\nexport {\n CharacterEditor,\n type CharacterEditorProps,\n} from \"@idosgames/characters/rig2d/editor\";\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\";\n"
|
|
163
84
|
},
|
|
164
85
|
{
|
|
165
86
|
"path": "character/looks/classLooks.ts",
|
|
166
|
-
"content": "import { LOOK_VERSION, type CharacterLook } from \"
|
|
87
|
+
"content": "import { LOOK_VERSION, type CharacterLook } from \"@idosgames/characters/rig2d\";\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"
|
|
167
88
|
},
|
|
168
89
|
{
|
|
169
90
|
"path": "character/looks/itemLooks.ts",
|
|
170
|
-
"content": "import { LOOK_VERSION, type CharacterLook } from \"
|
|
171
|
-
},
|
|
172
|
-
{
|
|
173
|
-
"path": "character/looks/presets.ts",
|
|
174
|
-
"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"
|
|
91
|
+
"content": "import { LOOK_VERSION, type CharacterLook } from \"@idosgames/characters/rig2d\";\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"
|
|
175
92
|
},
|
|
176
93
|
{
|
|
177
94
|
"path": "character/looks/resolve.ts",
|
|
178
|
-
"content": "import {
|
|
95
|
+
"content": "import {\n LOOK_VERSION,\n mergeLooks,\n pick,\n seededRandom,\n seedOf,\n type CharacterLook,\n} from \"@idosgames/characters/rig2d\";\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"
|
|
179
96
|
},
|
|
180
97
|
{
|
|
181
98
|
"path": "character/looks/sources.ts",
|
|
182
|
-
"content": "import type { CharacterLook } from \"
|
|
183
|
-
},
|
|
184
|
-
{
|
|
185
|
-
"path": "character/packs/base/beards.ts",
|
|
186
|
-
"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"
|
|
187
|
-
},
|
|
188
|
-
{
|
|
189
|
-
"path": "character/packs/base/body.ts",
|
|
190
|
-
"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"
|
|
191
|
-
},
|
|
192
|
-
{
|
|
193
|
-
"path": "character/packs/base/brows.ts",
|
|
194
|
-
"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"
|
|
195
|
-
},
|
|
196
|
-
{
|
|
197
|
-
"path": "character/packs/base/clothes.ts",
|
|
198
|
-
"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"
|
|
199
|
-
},
|
|
200
|
-
{
|
|
201
|
-
"path": "character/packs/base/eyes.ts",
|
|
202
|
-
"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"
|
|
203
|
-
},
|
|
204
|
-
{
|
|
205
|
-
"path": "character/packs/base/eyewear.ts",
|
|
206
|
-
"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"
|
|
207
|
-
},
|
|
208
|
-
{
|
|
209
|
-
"path": "character/packs/base/gear.ts",
|
|
210
|
-
"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"
|
|
211
|
-
},
|
|
212
|
-
{
|
|
213
|
-
"path": "character/packs/base/hair.ts",
|
|
214
|
-
"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"
|
|
215
|
-
},
|
|
216
|
-
{
|
|
217
|
-
"path": "character/packs/base/headwear.ts",
|
|
218
|
-
"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"
|
|
219
|
-
},
|
|
220
|
-
{
|
|
221
|
-
"path": "character/packs/base/index.ts",
|
|
222
|
-
"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"
|
|
223
|
-
},
|
|
224
|
-
{
|
|
225
|
-
"path": "character/packs/base/mouths.ts",
|
|
226
|
-
"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"
|
|
227
|
-
},
|
|
228
|
-
{
|
|
229
|
-
"path": "character/packs/base/shapes.ts",
|
|
230
|
-
"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"
|
|
231
|
-
},
|
|
232
|
-
{
|
|
233
|
-
"path": "character/packs/fantasy/armor.ts",
|
|
234
|
-
"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"
|
|
235
|
-
},
|
|
236
|
-
{
|
|
237
|
-
"path": "character/packs/fantasy/helmets.ts",
|
|
238
|
-
"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"
|
|
239
|
-
},
|
|
240
|
-
{
|
|
241
|
-
"path": "character/packs/fantasy/index.ts",
|
|
242
|
-
"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"
|
|
243
|
-
},
|
|
244
|
-
{
|
|
245
|
-
"path": "character/packs/fantasy/kit.ts",
|
|
246
|
-
"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"
|
|
247
|
-
},
|
|
248
|
-
{
|
|
249
|
-
"path": "character/packs/fantasy/ranged.ts",
|
|
250
|
-
"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"
|
|
251
|
-
},
|
|
252
|
-
{
|
|
253
|
-
"path": "character/packs/fantasy/shields.ts",
|
|
254
|
-
"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"
|
|
255
|
-
},
|
|
256
|
-
{
|
|
257
|
-
"path": "character/packs/fantasy/weapons.ts",
|
|
258
|
-
"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"
|
|
259
|
-
},
|
|
260
|
-
{
|
|
261
|
-
"path": "character/packs/index.ts",
|
|
262
|
-
"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"
|
|
263
|
-
},
|
|
264
|
-
{
|
|
265
|
-
"path": "character/render/canvas/live.ts",
|
|
266
|
-
"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 // × the CSS zoom it is drawn at: inside a zoomed kit surface (a small phone, the UI kit's fit.ts)\n // clientWidth is the unzoomed box, and the canvas took up to 1.8× the pixels it shows. Not\n // getBoundingClientRect: that one also shrinks with a popup's opening scale, and the canvas would\n // stay at that size.\n const zoom =\n (this.canvas as HTMLCanvasElement & { currentCSSZoom?: number })\n .currentCSSZoom || 1;\n const w = Math.max(1, Math.round(this.canvas.clientWidth * zoom * dpr));\n const h = Math.max(1, Math.round(this.canvas.clientHeight * zoom * 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"
|
|
267
|
-
},
|
|
268
|
-
{
|
|
269
|
-
"path": "character/render/canvas/thumbnail.ts",
|
|
270
|
-
"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"
|
|
271
|
-
},
|
|
272
|
-
{
|
|
273
|
-
"path": "character/render/phaser/artSource.ts",
|
|
274
|
-
"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"
|
|
275
|
-
},
|
|
276
|
-
{
|
|
277
|
-
"path": "character/render/phaser/CharacterView.ts",
|
|
278
|
-
"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"
|
|
279
|
-
},
|
|
280
|
-
{
|
|
281
|
-
"path": "character/render/phaser/stage.ts",
|
|
282
|
-
"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 // Drawn at the screen's density (at most 2), the camera zoomed by it — the stage keeps thinking in\n // CSS pixels. Phaser alone draws in CSS pixels, and a phone stretches that canvas: soap.\n const k = Math.min(\n 2,\n Math.max(\n 1,\n (typeof devicePixelRatio === \"number\" && devicePixelRatio) || 1,\n ),\n );\n class StageScene extends Phaser.Scene {\n create(): void {\n this.cameras.main.setOrigin(0, 0).setZoom(k);\n const scale = options.scale ?? 1;\n const vector = vectorArt(\n this.textures,\n options.catalog,\n Math.max(2, Math.ceil(scale * k * 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: Math.ceil(width * k),\n height: Math.ceil(height * k),\n scale: { mode: Phaser.Scale.NONE, zoom: 1 / k },\n // every part of a character is its own texture; one texture per draw (Phaser's phone default)\n // made each part a separate draw call\n render: { autoMobileTextures: false },\n transparent: !options.background,\n ...(options.background ? { backgroundColor: options.background } : {}),\n scene: StageScene,\n banner: false,\n audio: { noAudio: true },\n });\n });\n}\n"
|
|
283
|
-
},
|
|
284
|
-
{
|
|
285
|
-
"path": "character/render/phaser/textures.ts",
|
|
286
|
-
"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"
|
|
287
|
-
},
|
|
288
|
-
{
|
|
289
|
-
"path": "character/rigs/hero/animations.ts",
|
|
290
|
-
"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"
|
|
291
|
-
},
|
|
292
|
-
{
|
|
293
|
-
"path": "character/rigs/hero/index.ts",
|
|
294
|
-
"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"
|
|
295
|
-
},
|
|
296
|
-
{
|
|
297
|
-
"path": "character/rigs/hero/motion.ts",
|
|
298
|
-
"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"
|
|
299
|
-
},
|
|
300
|
-
{
|
|
301
|
-
"path": "character/rigs/hero/parts.ts",
|
|
302
|
-
"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"
|
|
303
|
-
},
|
|
304
|
-
{
|
|
305
|
-
"path": "character/rigs/hero/ranged.ts",
|
|
306
|
-
"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"
|
|
307
|
-
},
|
|
308
|
-
{
|
|
309
|
-
"path": "character/rigs/hero/skeleton.ts",
|
|
310
|
-
"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"
|
|
311
|
-
},
|
|
312
|
-
{
|
|
313
|
-
"path": "character/rigs/hero/wear.ts",
|
|
314
|
-
"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"
|
|
315
|
-
},
|
|
316
|
-
{
|
|
317
|
-
"path": "character/rigs/hero/wearing.ts",
|
|
318
|
-
"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"
|
|
99
|
+
"content": "import type { CharacterLook } from \"@idosgames/characters/rig2d\";\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"
|
|
319
100
|
},
|
|
320
101
|
{
|
|
321
102
|
"path": "controller-box.ts",
|
|
@@ -339,7 +120,7 @@
|
|
|
339
120
|
},
|
|
340
121
|
{
|
|
341
122
|
"path": "game/damage.ts",
|
|
342
|
-
"content": "// Damage by type: 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\n// formula (never all of it). Seven attack types × six armor types — 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 \"
|
|
123
|
+
"content": "// Damage by type: 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\n// formula (never all of it). Seven attack types × six armor types — 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 \"@idosgames/characters/rig2d\";\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/** The 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 (`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 (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"
|
|
343
124
|
},
|
|
344
125
|
{
|
|
345
126
|
"path": "game/economy.ts",
|
|
@@ -347,7 +128,7 @@
|
|
|
347
128
|
},
|
|
348
129
|
{
|
|
349
130
|
"path": "game/enemyLooks.ts",
|
|
350
|
-
"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 damage 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 {
|
|
131
|
+
"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 damage 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 { seedOf, seededRandom, type CharacterLook } from \"@idosgames/characters/rig2d\";\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"
|
|
351
132
|
},
|
|
352
133
|
{
|
|
353
134
|
"path": "game/format.ts",
|
|
@@ -359,7 +140,7 @@
|
|
|
359
140
|
},
|
|
360
141
|
{
|
|
361
142
|
"path": "game/heroGear.ts",
|
|
362
|
-
"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 {
|
|
143
|
+
"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 { heroIsMelee, TWO_HANDED } from \"@idosgames/characters/rig2d\";\nimport { DEFAULT_LOOK_CHAIN, resolveLook } from \"../character/looks/resolve\";\nimport type { LookSource, LookSubject } from \"../character/looks/sources\";\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"
|
|
363
144
|
},
|
|
364
145
|
{
|
|
365
146
|
"path": "game/HeroRunController.ts",
|
|
@@ -383,7 +164,7 @@
|
|
|
383
164
|
},
|
|
384
165
|
{
|
|
385
166
|
"path": "game/session.ts",
|
|
386
|
-
"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"
|
|
167
|
+
"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 payWithCredits,\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 // The chest's price is the publisher's: in Aspis the player confirms it on idosgames.com (the kit shows it).\n const res = await payWithCredits(this.client, (creditsConfirmationID) =>\n this.client.lootbox.open(chest, 1, undefined, undefined, creditsConfirmationID),\n );\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"
|
|
387
168
|
},
|
|
388
169
|
{
|
|
389
170
|
"path": "game/sim.ts",
|
|
@@ -395,7 +176,7 @@
|
|
|
395
176
|
},
|
|
396
177
|
{
|
|
397
178
|
"path": "game/units.ts",
|
|
398
|
-
"content": "// The enemies — people like the hero, each one a TROOP of a kind: 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 \"
|
|
179
|
+
"content": "// The enemies — people like the hero, each one a TROOP of a kind: 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 \"@idosgames/characters/rig2d\";\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 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"
|
|
399
180
|
},
|
|
400
181
|
{
|
|
401
182
|
"path": "heroArt.ts",
|