@botharness/pixel-avatar 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/index.d.ts +119 -15
- package/dist/index.js +513 -220
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,191 +1,5 @@
|
|
|
1
1
|
import { escapeAttribute } from "@botharness/pixel-morph";
|
|
2
2
|
|
|
3
|
-
//#region src/part.ts
|
|
4
|
-
/**
|
|
5
|
-
* Custom Part slots. A headpiece covers the top of the tile, around the hair: its back layer is
|
|
6
|
-
* drawn behind the hair and its front layer over it. Cells are in tile coordinates, so the
|
|
7
|
-
* Avatar centerline sits between x = 15 and x = 16.
|
|
8
|
-
*/
|
|
9
|
-
const PART_SLOTS = { headpiece: {
|
|
10
|
-
width: 32,
|
|
11
|
-
height: 16
|
|
12
|
-
} };
|
|
13
|
-
/** Tone steps on the rig's shade ramp: two darker, the color itself, two lighter. */
|
|
14
|
-
const PART_TONES = [
|
|
15
|
-
-2,
|
|
16
|
-
-1,
|
|
17
|
-
0,
|
|
18
|
-
1,
|
|
19
|
-
2
|
|
20
|
-
];
|
|
21
|
-
const PART_LAYERS = ["front", "back"];
|
|
22
|
-
/** Most fixed colors one part may use. */
|
|
23
|
-
const MAX_PART_FIXED_COLORS = 16;
|
|
24
|
-
const FIXED = /^#[\da-f]{6}$/iu;
|
|
25
|
-
const isColor = (value) => typeof value === "string" && (AVATAR_COLORS.includes(value) || FIXED.test(value));
|
|
26
|
-
function isPixelCustomPart(value) {
|
|
27
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
28
|
-
const r = value;
|
|
29
|
-
if (Object.keys(r).length !== 3 || !Object.hasOwn(PART_SLOTS, r["slot"])) return false;
|
|
30
|
-
const { width, height } = PART_SLOTS[r["slot"]];
|
|
31
|
-
const fixed = /* @__PURE__ */ new Set();
|
|
32
|
-
for (const name of PART_LAYERS) {
|
|
33
|
-
const cells = r[name];
|
|
34
|
-
if (!Array.isArray(cells) || cells.length > width * height) return false;
|
|
35
|
-
const seen = /* @__PURE__ */ new Set();
|
|
36
|
-
for (const cell of cells) {
|
|
37
|
-
if (!Array.isArray(cell) || cell.length !== 4) return false;
|
|
38
|
-
const [x, y, color, tone] = cell;
|
|
39
|
-
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= width || y < 0 || y >= height || !isColor(color) || !PART_TONES.includes(tone)) return false;
|
|
40
|
-
const key = y * width + x;
|
|
41
|
-
if (seen.has(key)) return false;
|
|
42
|
-
seen.add(key);
|
|
43
|
-
if (color.startsWith("#")) fixed.add(color.toLowerCase());
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return fixed.size <= MAX_PART_FIXED_COLORS;
|
|
47
|
-
}
|
|
48
|
-
const canonicalCells = (cells) => cells.map(([x, y, color, tone]) => [
|
|
49
|
-
x,
|
|
50
|
-
y,
|
|
51
|
-
color.startsWith("#") ? color.toLowerCase() : color,
|
|
52
|
-
tone
|
|
53
|
-
]).sort((a, b) => a[1] - b[1] || a[0] - b[0]);
|
|
54
|
-
/** The part with cells in row order and fixed colors in lowercase. */
|
|
55
|
-
function canonicalCustomPart(part) {
|
|
56
|
-
return {
|
|
57
|
-
slot: part.slot,
|
|
58
|
-
front: canonicalCells(part.front),
|
|
59
|
-
back: canonicalCells(part.back)
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
/** Content identity: a SHA-256 of the canonical slot, cells and color references. */
|
|
63
|
-
function customPartId(part) {
|
|
64
|
-
return sha256(JSON.stringify(canonicalCustomPart(part)));
|
|
65
|
-
}
|
|
66
|
-
function emptyPartLayer(slot) {
|
|
67
|
-
const { width, height } = PART_SLOTS[slot];
|
|
68
|
-
return Array.from({ length: height }, () => Array(width).fill(null));
|
|
69
|
-
}
|
|
70
|
-
function partLayer(slot, cells) {
|
|
71
|
-
const layer = emptyPartLayer(slot);
|
|
72
|
-
for (const [x, y, color, tone] of cells) layer[y][x] = {
|
|
73
|
-
color,
|
|
74
|
-
tone
|
|
75
|
-
};
|
|
76
|
-
return layer;
|
|
77
|
-
}
|
|
78
|
-
function partCells(layer) {
|
|
79
|
-
return layer.flatMap((row, y) => row.flatMap((ink, x) => ink ? [[
|
|
80
|
-
x,
|
|
81
|
-
y,
|
|
82
|
-
ink.color,
|
|
83
|
-
ink.tone
|
|
84
|
-
]] : []));
|
|
85
|
-
}
|
|
86
|
-
/** Builds a canonical part from two drawn layers. */
|
|
87
|
-
function createCustomPart(slot, layers) {
|
|
88
|
-
return canonicalCustomPart({
|
|
89
|
-
slot,
|
|
90
|
-
front: partCells(layers.front),
|
|
91
|
-
back: partCells(layers.back)
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
/** The column mirrored across the Avatar centerline. */
|
|
95
|
-
function mirrorPartX(slot, x) {
|
|
96
|
-
return PART_SLOTS[slot].width - 1 - x;
|
|
97
|
-
}
|
|
98
|
-
const same = (a, b) => a === b || a !== null && b !== null && a.color === b.color && a.tone === b.tone;
|
|
99
|
-
/** Sets cells (pencil with ink, eraser with `null`), optionally mirrored across the centerline. */
|
|
100
|
-
function paintPartLayer(slot, layer, points, ink, mirror = false) {
|
|
101
|
-
const out = layer.map((row) => [...row]);
|
|
102
|
-
const { width, height } = PART_SLOTS[slot];
|
|
103
|
-
for (const [x, y] of points) for (const px of mirror ? [x, mirrorPartX(slot, x)] : [x]) if (px >= 0 && px < width && y >= 0 && y < height) out[y][px] = ink;
|
|
104
|
-
return out;
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* 4-connected flood fill from a cell: every cell reachable through cells equal to the start
|
|
108
|
-
* becomes `ink`. With `mirror`, the mirrored start is filled too.
|
|
109
|
-
*/
|
|
110
|
-
function fillPartLayer(slot, layer, x, y, ink, mirror = false) {
|
|
111
|
-
const { width, height } = PART_SLOTS[slot];
|
|
112
|
-
let out = layer.map((row) => [...row]);
|
|
113
|
-
for (const sx of mirror ? [x, mirrorPartX(slot, x)] : [x]) {
|
|
114
|
-
if (sx < 0 || sx >= width || y < 0 || y >= height) continue;
|
|
115
|
-
const target = out[y][sx];
|
|
116
|
-
if (same(target, ink)) continue;
|
|
117
|
-
const next = out.map((row) => [...row]);
|
|
118
|
-
const stack = [[sx, y]];
|
|
119
|
-
while (stack.length) {
|
|
120
|
-
const [cx, cy] = stack.pop();
|
|
121
|
-
if (cx < 0 || cx >= width || cy < 0 || cy >= height) continue;
|
|
122
|
-
if (!same(next[cy][cx], target)) continue;
|
|
123
|
-
next[cy][cx] = ink;
|
|
124
|
-
stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
|
|
125
|
-
}
|
|
126
|
-
out = next;
|
|
127
|
-
}
|
|
128
|
-
return out;
|
|
129
|
-
}
|
|
130
|
-
const K = Uint32Array.from("428a2f98 71374491 b5c0fbcf e9b5dba5 3956c25b 59f111f1 923f82a4 ab1c5ed5 d807aa98 12835b01 243185be 550c7dc3 72be5d74 80deb1fe 9bdc06a7 c19bf174 e49b69c1 efbe4786 0fc19dc6 240ca1cc 2de92c6f 4a7484aa 5cb0a9dc 76f988da 983e5152 a831c66d b00327c8 bf597fc7 c6e00bf3 d5a79147 06ca6351 14292967 27b70a85 2e1b2138 4d2c6dfc 53380d13 650a7354 766a0abb 81c2c92e 92722c85 a2bfe8a1 a81a664b c24b8b70 c76c51a3 d192e819 d6990624 f40e3585 106aa070 19a4c116 1e376c08 2748774c 34b0bcb5 391c0cb3 4ed8aa4a 5b9cca4f 682e6ff3 748f82ee 78a5636f 84c87814 8cc70208 90befffa a4506ceb bef9a3f7 c67178f2".split(" ").map((h) => Number.parseInt(h, 16)));
|
|
131
|
-
function sha256(text) {
|
|
132
|
-
const bytes = new TextEncoder().encode(text);
|
|
133
|
-
const length = Math.ceil((bytes.length + 9) / 64) * 64;
|
|
134
|
-
const data = new Uint8Array(length);
|
|
135
|
-
data.set(bytes);
|
|
136
|
-
data[bytes.length] = 128;
|
|
137
|
-
const view = new DataView(data.buffer);
|
|
138
|
-
view.setUint32(length - 8, Math.floor(bytes.length * 8 / 2 ** 32));
|
|
139
|
-
view.setUint32(length - 4, bytes.length * 8 >>> 0);
|
|
140
|
-
const h = Uint32Array.from([
|
|
141
|
-
1779033703,
|
|
142
|
-
3144134277,
|
|
143
|
-
1013904242,
|
|
144
|
-
2773480762,
|
|
145
|
-
1359893119,
|
|
146
|
-
2600822924,
|
|
147
|
-
528734635,
|
|
148
|
-
1541459225
|
|
149
|
-
]);
|
|
150
|
-
const w = new Uint32Array(64);
|
|
151
|
-
const rotr = (v, n) => v >>> n | v << 32 - n;
|
|
152
|
-
for (let offset = 0; offset < length; offset += 64) {
|
|
153
|
-
for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
|
|
154
|
-
for (let i = 16; i < 64; i++) {
|
|
155
|
-
const a = w[i - 15];
|
|
156
|
-
const b = w[i - 2];
|
|
157
|
-
const s0 = rotr(a, 7) ^ rotr(a, 18) ^ a >>> 3;
|
|
158
|
-
const s1 = rotr(b, 17) ^ rotr(b, 19) ^ b >>> 10;
|
|
159
|
-
w[i] = w[i - 16] + s0 + w[i - 7] + s1 >>> 0;
|
|
160
|
-
}
|
|
161
|
-
let [a, b, c, d, e, f, g, k] = h;
|
|
162
|
-
for (let i = 0; i < 64; i++) {
|
|
163
|
-
const t1 = k + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + (e & f ^ ~e & g) + K[i] + w[i] >>> 0;
|
|
164
|
-
const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + (a & b ^ a & c ^ b & c) >>> 0;
|
|
165
|
-
k = g;
|
|
166
|
-
g = f;
|
|
167
|
-
f = e;
|
|
168
|
-
e = d + t1 >>> 0;
|
|
169
|
-
d = c;
|
|
170
|
-
c = b;
|
|
171
|
-
b = a;
|
|
172
|
-
a = t1 + t2 >>> 0;
|
|
173
|
-
}
|
|
174
|
-
[
|
|
175
|
-
a,
|
|
176
|
-
b,
|
|
177
|
-
c,
|
|
178
|
-
d,
|
|
179
|
-
e,
|
|
180
|
-
f,
|
|
181
|
-
g,
|
|
182
|
-
k
|
|
183
|
-
].forEach((v, i) => h[i] = h[i] + v >>> 0);
|
|
184
|
-
}
|
|
185
|
-
return [...h].map((v) => v.toString(16).padStart(8, "0")).join("");
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
//#endregion
|
|
189
3
|
//#region src/random.ts
|
|
190
4
|
function seededRandom(seed) {
|
|
191
5
|
let h = 2166136261;
|
|
@@ -551,6 +365,23 @@ const AVATAR_EXTRA_PARTS = {
|
|
|
551
365
|
],
|
|
552
366
|
flowerBase: ["leaves", "pot"]
|
|
553
367
|
};
|
|
368
|
+
/** The recipe key that embeds the Custom Part worn in each slot. */
|
|
369
|
+
const CUSTOM_PART_KEYS = {
|
|
370
|
+
headpiece: "headpiece",
|
|
371
|
+
bangs: "bangsPart",
|
|
372
|
+
leftSideHair: "leftSideHairPart",
|
|
373
|
+
rightSideHair: "rightSideHairPart",
|
|
374
|
+
backHair: "backHairPart",
|
|
375
|
+
outfit: "outfitPart",
|
|
376
|
+
accessory: "accessoryPart",
|
|
377
|
+
beard: "beardPart",
|
|
378
|
+
glasses: "glassesPart",
|
|
379
|
+
nose: "nosePart",
|
|
380
|
+
cheeks: "cheeksPart",
|
|
381
|
+
petals: "petalsPart",
|
|
382
|
+
flowerBase: "flowerBasePart"
|
|
383
|
+
};
|
|
384
|
+
const PART_KEY_ENTRIES = Object.entries(CUSTOM_PART_KEYS);
|
|
554
385
|
const DEFAULT_RECIPE = {
|
|
555
386
|
schemaVersion: 1,
|
|
556
387
|
family: "illustrated",
|
|
@@ -581,10 +412,15 @@ function isPixelAvatarRecipe(value) {
|
|
|
581
412
|
if (detailed !== 0 && detailed !== DETAIL_KEYS.length) return false;
|
|
582
413
|
const v3 = r["assetVersion"] === 3;
|
|
583
414
|
const v2 = r["assetVersion"] === 2 || v3;
|
|
584
|
-
|
|
415
|
+
const worn = PART_KEY_ENTRIES.filter(([, key]) => Object.hasOwn(r, key));
|
|
416
|
+
if (v3 ? worn.length === 0 : worn.length !== 0) return false;
|
|
417
|
+
for (const [slot, key] of worn) {
|
|
418
|
+
const part = r[key];
|
|
419
|
+
if (!isPixelCustomPart(part) || part.slot !== slot) return false;
|
|
420
|
+
}
|
|
585
421
|
const pieces = AVATAR_PIECE_COLORS.filter((key) => Object.hasOwn(r, key));
|
|
586
422
|
const extras = Object.keys(AVATAR_EXTRA_PARTS).filter((key) => Object.hasOwn(r, key));
|
|
587
|
-
const v2Keys = v2 ? 2 + pieces.length + extras.length +
|
|
423
|
+
const v2Keys = v2 ? 2 + pieces.length + extras.length + worn.length : 0;
|
|
588
424
|
if (Object.keys(r).length !== Object.keys(DEFAULT_RECIPE).length + detailed + v2Keys) return false;
|
|
589
425
|
if (v2 && (detailed === 0 || !AVATAR_SPECIES.includes(r["species"]) || !AVATAR_HAIR_PARTS.sideHair.includes(r["rightSideHair"]) || !pieces.every((key) => typeof r[key] === "string" && /^#[\da-f]{6}$/iu.test(r[key])) || !extras.every((key) => AVATAR_EXTRA_PARTS[key].includes(r[key])))) return false;
|
|
590
426
|
const catalog = v2 ? AVATAR_PARTS_V2 : AVATAR_PARTS;
|
|
@@ -609,7 +445,10 @@ function canonicalRecipe(recipe) {
|
|
|
609
445
|
canonical["rightSideHair"] = recipe.rightSideHair;
|
|
610
446
|
for (const key of AVATAR_PIECE_COLORS) if (recipe[key] !== void 0) canonical[key] = recipe[key].toLowerCase();
|
|
611
447
|
for (const key of Object.keys(AVATAR_EXTRA_PARTS)) if (recipe[key] !== void 0) canonical[key] = recipe[key];
|
|
612
|
-
if (recipe.assetVersion === 3)
|
|
448
|
+
if (recipe.assetVersion === 3) for (const [, key] of PART_KEY_ENTRIES) {
|
|
449
|
+
const part = recipe[key];
|
|
450
|
+
if (part !== void 0) canonical[key] = canonicalCustomPart(part);
|
|
451
|
+
}
|
|
613
452
|
}
|
|
614
453
|
return canonical;
|
|
615
454
|
}
|
|
@@ -634,10 +473,10 @@ function hiddenChoices(recipe) {
|
|
|
634
473
|
"nose",
|
|
635
474
|
"cheeks",
|
|
636
475
|
"glasses",
|
|
637
|
-
...recipe.beard ? ["beard"] : []
|
|
476
|
+
...recipe.beard || wornPart(recipe, "beard") ? ["beard"] : []
|
|
638
477
|
];
|
|
639
478
|
const hidden = [];
|
|
640
|
-
if (recipe.accessory === "helmet" || recipe.accessory === "hood") hidden.push(...HAIR_PIECES);
|
|
479
|
+
if ((recipe.accessory === "helmet" || recipe.accessory === "hood") && !wornPart(recipe, "accessory")) hidden.push(...HAIR_PIECES);
|
|
641
480
|
if (recipe.species === "dwarf") hidden.push("nose");
|
|
642
481
|
return hidden;
|
|
643
482
|
}
|
|
@@ -654,21 +493,31 @@ function withSpecies(recipe, species) {
|
|
|
654
493
|
};
|
|
655
494
|
}
|
|
656
495
|
/**
|
|
657
|
-
* Returns the recipe wearing
|
|
658
|
-
*
|
|
496
|
+
* Returns the recipe wearing `part` in `slot`, or with that slot's part taken off. The recipe
|
|
497
|
+
* embeds its own copy; it is asset version 3 while it wears any part and version 2 otherwise.
|
|
659
498
|
*/
|
|
660
|
-
function
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
499
|
+
function withCustomPart(recipe, slot, part) {
|
|
500
|
+
const base = { ...recipe.assetVersion === 1 ? withSpecies(recipe, "human") : recipe };
|
|
501
|
+
const key = CUSTOM_PART_KEYS[slot];
|
|
502
|
+
delete base[key];
|
|
503
|
+
if (part !== void 0) base[key] = canonicalCustomPart({
|
|
504
|
+
...part,
|
|
505
|
+
slot
|
|
506
|
+
});
|
|
507
|
+
const wearing = PART_KEY_ENTRIES.some(([, k]) => base[k] !== void 0);
|
|
666
508
|
return {
|
|
667
|
-
...
|
|
668
|
-
assetVersion: 3
|
|
669
|
-
headpiece: canonicalCustomPart(part)
|
|
509
|
+
...base,
|
|
510
|
+
assetVersion: wearing ? 3 : 2
|
|
670
511
|
};
|
|
671
512
|
}
|
|
513
|
+
/** `withCustomPart` for the headpiece slot. */
|
|
514
|
+
function withHeadpiece(recipe, part) {
|
|
515
|
+
return withCustomPart(recipe, "headpiece", part);
|
|
516
|
+
}
|
|
517
|
+
/** The Custom Part worn in a slot, if any. */
|
|
518
|
+
function wornPart(recipe, slot) {
|
|
519
|
+
return recipe.assetVersion === 3 ? recipe[CUSTOM_PART_KEYS[slot]] : void 0;
|
|
520
|
+
}
|
|
672
521
|
function detailedRecipe(recipe) {
|
|
673
522
|
if (recipe.bangs !== void 0) return recipe;
|
|
674
523
|
const pick = (part) => AVATAR_HAIR_PARTS[part].includes(recipe.hair) ? recipe.hair : HAIR_SPLIT[recipe.hair][part];
|
|
@@ -1025,6 +874,270 @@ const AVATAR_PRESETS = [
|
|
|
1025
874
|
})
|
|
1026
875
|
];
|
|
1027
876
|
|
|
877
|
+
//#endregion
|
|
878
|
+
//#region src/part.ts
|
|
879
|
+
/**
|
|
880
|
+
* Custom Part slots. A headpiece covers the top of the tile, around the hair: its back layer is
|
|
881
|
+
* drawn behind the hair and its front layer over it. Hair slots replace one built-in hair piece
|
|
882
|
+
* and use only the front layer. Cells are in tile coordinates, so the Avatar centerline sits
|
|
883
|
+
* between x = 15 and x = 16.
|
|
884
|
+
*/
|
|
885
|
+
const PART_SLOTS = {
|
|
886
|
+
headpiece: {
|
|
887
|
+
width: 32,
|
|
888
|
+
height: 16
|
|
889
|
+
},
|
|
890
|
+
bangs: {
|
|
891
|
+
width: 32,
|
|
892
|
+
height: 32
|
|
893
|
+
},
|
|
894
|
+
leftSideHair: {
|
|
895
|
+
width: 32,
|
|
896
|
+
height: 32
|
|
897
|
+
},
|
|
898
|
+
rightSideHair: {
|
|
899
|
+
width: 32,
|
|
900
|
+
height: 32
|
|
901
|
+
},
|
|
902
|
+
backHair: {
|
|
903
|
+
width: 32,
|
|
904
|
+
height: 32
|
|
905
|
+
},
|
|
906
|
+
outfit: {
|
|
907
|
+
width: 32,
|
|
908
|
+
height: 32
|
|
909
|
+
},
|
|
910
|
+
accessory: {
|
|
911
|
+
width: 32,
|
|
912
|
+
height: 32
|
|
913
|
+
},
|
|
914
|
+
beard: {
|
|
915
|
+
width: 32,
|
|
916
|
+
height: 32
|
|
917
|
+
},
|
|
918
|
+
glasses: {
|
|
919
|
+
width: 32,
|
|
920
|
+
height: 32
|
|
921
|
+
},
|
|
922
|
+
nose: {
|
|
923
|
+
width: 32,
|
|
924
|
+
height: 32
|
|
925
|
+
},
|
|
926
|
+
cheeks: {
|
|
927
|
+
width: 32,
|
|
928
|
+
height: 32
|
|
929
|
+
},
|
|
930
|
+
petals: {
|
|
931
|
+
width: 32,
|
|
932
|
+
height: 32
|
|
933
|
+
},
|
|
934
|
+
flowerBase: {
|
|
935
|
+
width: 32,
|
|
936
|
+
height: 32
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
/**
|
|
940
|
+
* Hair slots. In a hair part, a `hairColor` cell at tone 0 is live hair: it is shaded with the
|
|
941
|
+
* rest of the hair, exactly like a built-in piece. Every other cell shows its own color.
|
|
942
|
+
*/
|
|
943
|
+
const HAIR_PART_SLOTS = [
|
|
944
|
+
"bangs",
|
|
945
|
+
"leftSideHair",
|
|
946
|
+
"rightSideHair",
|
|
947
|
+
"backHair"
|
|
948
|
+
];
|
|
949
|
+
const isHairPartSlot = (slot) => HAIR_PART_SLOTS.includes(slot);
|
|
950
|
+
/**
|
|
951
|
+
* Slots whose drawn part replaces a built-in part pixel for pixel: the part's cells are painted
|
|
952
|
+
* where the built-in part would be, and every cell shows its own color and tone.
|
|
953
|
+
*/
|
|
954
|
+
const REPLACE_PART_SLOTS = [
|
|
955
|
+
"outfit",
|
|
956
|
+
"accessory",
|
|
957
|
+
"beard",
|
|
958
|
+
"glasses",
|
|
959
|
+
"nose",
|
|
960
|
+
"cheeks",
|
|
961
|
+
"petals",
|
|
962
|
+
"flowerBase"
|
|
963
|
+
];
|
|
964
|
+
const isReplacePartSlot = (slot) => REPLACE_PART_SLOTS.includes(slot);
|
|
965
|
+
/** Tone steps on the rig's shade ramp: two darker, the color itself, two lighter. */
|
|
966
|
+
const PART_TONES = [
|
|
967
|
+
-2,
|
|
968
|
+
-1,
|
|
969
|
+
0,
|
|
970
|
+
1,
|
|
971
|
+
2
|
|
972
|
+
];
|
|
973
|
+
const PART_LAYERS = ["front", "back"];
|
|
974
|
+
/** Most fixed colors one part may use. */
|
|
975
|
+
const MAX_PART_FIXED_COLORS = 32;
|
|
976
|
+
const FIXED = /^#[\da-f]{6}$/iu;
|
|
977
|
+
const isColor = (value) => typeof value === "string" && (AVATAR_COLORS.includes(value) || FIXED.test(value));
|
|
978
|
+
function isPixelCustomPart(value) {
|
|
979
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
980
|
+
const r = value;
|
|
981
|
+
if (Object.keys(r).length !== 3 || !Object.hasOwn(PART_SLOTS, r["slot"])) return false;
|
|
982
|
+
const { width, height } = PART_SLOTS[r["slot"]];
|
|
983
|
+
if (r["slot"] !== "headpiece" && r["back"]?.length !== 0) return false;
|
|
984
|
+
const fixed = /* @__PURE__ */ new Set();
|
|
985
|
+
for (const name of PART_LAYERS) {
|
|
986
|
+
const cells = r[name];
|
|
987
|
+
if (!Array.isArray(cells) || cells.length > width * height) return false;
|
|
988
|
+
const seen = /* @__PURE__ */ new Set();
|
|
989
|
+
for (const cell of cells) {
|
|
990
|
+
if (!Array.isArray(cell) || cell.length !== 4) return false;
|
|
991
|
+
const [x, y, color, tone] = cell;
|
|
992
|
+
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= width || y < 0 || y >= height || !isColor(color) || !PART_TONES.includes(tone)) return false;
|
|
993
|
+
const key = y * width + x;
|
|
994
|
+
if (seen.has(key)) return false;
|
|
995
|
+
seen.add(key);
|
|
996
|
+
if (color.startsWith("#")) fixed.add(color.toLowerCase());
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
return fixed.size <= MAX_PART_FIXED_COLORS;
|
|
1000
|
+
}
|
|
1001
|
+
const canonicalCells = (cells) => cells.map(([x, y, color, tone]) => [
|
|
1002
|
+
x,
|
|
1003
|
+
y,
|
|
1004
|
+
color.startsWith("#") ? color.toLowerCase() : color,
|
|
1005
|
+
tone
|
|
1006
|
+
]).sort((a, b) => a[1] - b[1] || a[0] - b[0]);
|
|
1007
|
+
/** The part with cells in row order and fixed colors in lowercase. */
|
|
1008
|
+
function canonicalCustomPart(part) {
|
|
1009
|
+
return {
|
|
1010
|
+
slot: part.slot,
|
|
1011
|
+
front: canonicalCells(part.front),
|
|
1012
|
+
back: canonicalCells(part.back)
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
/** Content identity: a SHA-256 of the canonical slot, cells and color references. */
|
|
1016
|
+
function customPartId(part) {
|
|
1017
|
+
return sha256(JSON.stringify(canonicalCustomPart(part)));
|
|
1018
|
+
}
|
|
1019
|
+
function emptyPartLayer(slot) {
|
|
1020
|
+
const { width, height } = PART_SLOTS[slot];
|
|
1021
|
+
return Array.from({ length: height }, () => Array(width).fill(null));
|
|
1022
|
+
}
|
|
1023
|
+
function partLayer(slot, cells) {
|
|
1024
|
+
const layer = emptyPartLayer(slot);
|
|
1025
|
+
for (const [x, y, color, tone] of cells) layer[y][x] = {
|
|
1026
|
+
color,
|
|
1027
|
+
tone
|
|
1028
|
+
};
|
|
1029
|
+
return layer;
|
|
1030
|
+
}
|
|
1031
|
+
function partCells(layer) {
|
|
1032
|
+
return layer.flatMap((row, y) => row.flatMap((ink, x) => ink ? [[
|
|
1033
|
+
x,
|
|
1034
|
+
y,
|
|
1035
|
+
ink.color,
|
|
1036
|
+
ink.tone
|
|
1037
|
+
]] : []));
|
|
1038
|
+
}
|
|
1039
|
+
/** Builds a canonical part from two drawn layers. */
|
|
1040
|
+
function createCustomPart(slot, layers) {
|
|
1041
|
+
return canonicalCustomPart({
|
|
1042
|
+
slot,
|
|
1043
|
+
front: partCells(layers.front),
|
|
1044
|
+
back: partCells(layers.back)
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
/** The column mirrored across the Avatar centerline. */
|
|
1048
|
+
function mirrorPartX(slot, x) {
|
|
1049
|
+
return PART_SLOTS[slot].width - 1 - x;
|
|
1050
|
+
}
|
|
1051
|
+
const same = (a, b) => a === b || a !== null && b !== null && a.color === b.color && a.tone === b.tone;
|
|
1052
|
+
/** Sets cells (pencil with ink, eraser with `null`), optionally mirrored across the centerline. */
|
|
1053
|
+
function paintPartLayer(slot, layer, points, ink, mirror = false) {
|
|
1054
|
+
const out = layer.map((row) => [...row]);
|
|
1055
|
+
const { width, height } = PART_SLOTS[slot];
|
|
1056
|
+
for (const [x, y] of points) for (const px of mirror ? [x, mirrorPartX(slot, x)] : [x]) if (px >= 0 && px < width && y >= 0 && y < height) out[y][px] = ink;
|
|
1057
|
+
return out;
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* 4-connected flood fill from a cell: every cell reachable through cells equal to the start
|
|
1061
|
+
* becomes `ink`. With `mirror`, the mirrored start is filled too.
|
|
1062
|
+
*/
|
|
1063
|
+
function fillPartLayer(slot, layer, x, y, ink, mirror = false) {
|
|
1064
|
+
const { width, height } = PART_SLOTS[slot];
|
|
1065
|
+
let out = layer.map((row) => [...row]);
|
|
1066
|
+
for (const sx of mirror ? [x, mirrorPartX(slot, x)] : [x]) {
|
|
1067
|
+
if (sx < 0 || sx >= width || y < 0 || y >= height) continue;
|
|
1068
|
+
const target = out[y][sx];
|
|
1069
|
+
if (same(target, ink)) continue;
|
|
1070
|
+
const next = out.map((row) => [...row]);
|
|
1071
|
+
const stack = [[sx, y]];
|
|
1072
|
+
while (stack.length) {
|
|
1073
|
+
const [cx, cy] = stack.pop();
|
|
1074
|
+
if (cx < 0 || cx >= width || cy < 0 || cy >= height) continue;
|
|
1075
|
+
if (!same(next[cy][cx], target)) continue;
|
|
1076
|
+
next[cy][cx] = ink;
|
|
1077
|
+
stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
|
|
1078
|
+
}
|
|
1079
|
+
out = next;
|
|
1080
|
+
}
|
|
1081
|
+
return out;
|
|
1082
|
+
}
|
|
1083
|
+
const K = Uint32Array.from("428a2f98 71374491 b5c0fbcf e9b5dba5 3956c25b 59f111f1 923f82a4 ab1c5ed5 d807aa98 12835b01 243185be 550c7dc3 72be5d74 80deb1fe 9bdc06a7 c19bf174 e49b69c1 efbe4786 0fc19dc6 240ca1cc 2de92c6f 4a7484aa 5cb0a9dc 76f988da 983e5152 a831c66d b00327c8 bf597fc7 c6e00bf3 d5a79147 06ca6351 14292967 27b70a85 2e1b2138 4d2c6dfc 53380d13 650a7354 766a0abb 81c2c92e 92722c85 a2bfe8a1 a81a664b c24b8b70 c76c51a3 d192e819 d6990624 f40e3585 106aa070 19a4c116 1e376c08 2748774c 34b0bcb5 391c0cb3 4ed8aa4a 5b9cca4f 682e6ff3 748f82ee 78a5636f 84c87814 8cc70208 90befffa a4506ceb bef9a3f7 c67178f2".split(" ").map((h) => Number.parseInt(h, 16)));
|
|
1084
|
+
function sha256(text) {
|
|
1085
|
+
const bytes = new TextEncoder().encode(text);
|
|
1086
|
+
const length = Math.ceil((bytes.length + 9) / 64) * 64;
|
|
1087
|
+
const data = new Uint8Array(length);
|
|
1088
|
+
data.set(bytes);
|
|
1089
|
+
data[bytes.length] = 128;
|
|
1090
|
+
const view = new DataView(data.buffer);
|
|
1091
|
+
view.setUint32(length - 8, Math.floor(bytes.length * 8 / 2 ** 32));
|
|
1092
|
+
view.setUint32(length - 4, bytes.length * 8 >>> 0);
|
|
1093
|
+
const h = Uint32Array.from([
|
|
1094
|
+
1779033703,
|
|
1095
|
+
3144134277,
|
|
1096
|
+
1013904242,
|
|
1097
|
+
2773480762,
|
|
1098
|
+
1359893119,
|
|
1099
|
+
2600822924,
|
|
1100
|
+
528734635,
|
|
1101
|
+
1541459225
|
|
1102
|
+
]);
|
|
1103
|
+
const w = new Uint32Array(64);
|
|
1104
|
+
const rotr = (v, n) => v >>> n | v << 32 - n;
|
|
1105
|
+
for (let offset = 0; offset < length; offset += 64) {
|
|
1106
|
+
for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
|
|
1107
|
+
for (let i = 16; i < 64; i++) {
|
|
1108
|
+
const a = w[i - 15];
|
|
1109
|
+
const b = w[i - 2];
|
|
1110
|
+
const s0 = rotr(a, 7) ^ rotr(a, 18) ^ a >>> 3;
|
|
1111
|
+
const s1 = rotr(b, 17) ^ rotr(b, 19) ^ b >>> 10;
|
|
1112
|
+
w[i] = w[i - 16] + s0 + w[i - 7] + s1 >>> 0;
|
|
1113
|
+
}
|
|
1114
|
+
let [a, b, c, d, e, f, g, k] = h;
|
|
1115
|
+
for (let i = 0; i < 64; i++) {
|
|
1116
|
+
const t1 = k + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + (e & f ^ ~e & g) + K[i] + w[i] >>> 0;
|
|
1117
|
+
const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + (a & b ^ a & c ^ b & c) >>> 0;
|
|
1118
|
+
k = g;
|
|
1119
|
+
g = f;
|
|
1120
|
+
f = e;
|
|
1121
|
+
e = d + t1 >>> 0;
|
|
1122
|
+
d = c;
|
|
1123
|
+
c = b;
|
|
1124
|
+
b = a;
|
|
1125
|
+
a = t1 + t2 >>> 0;
|
|
1126
|
+
}
|
|
1127
|
+
[
|
|
1128
|
+
a,
|
|
1129
|
+
b,
|
|
1130
|
+
c,
|
|
1131
|
+
d,
|
|
1132
|
+
e,
|
|
1133
|
+
f,
|
|
1134
|
+
g,
|
|
1135
|
+
k
|
|
1136
|
+
].forEach((v, i) => h[i] = h[i] + v >>> 0);
|
|
1137
|
+
}
|
|
1138
|
+
return [...h].map((v) => v.toString(16).padStart(8, "0")).join("");
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1028
1141
|
//#endregion
|
|
1029
1142
|
//#region src/figure.ts
|
|
1030
1143
|
const N = 32;
|
|
@@ -1382,10 +1495,18 @@ function detailedHair(recipe, s, b) {
|
|
|
1382
1495
|
const length = (recipe.hairLength ?? 0) * 2;
|
|
1383
1496
|
const lengthen = (mask) => (x, y) => mask(x, y > 18 ? Math.round(y - length) : y);
|
|
1384
1497
|
const sideOf = (sign) => (x, y) => y > 7 && sign * (x + .5 - C - s) >= 7 && lengthen((sign < 0 ? side : right).front)(x, y);
|
|
1498
|
+
const fringe = (x, y) => y <= 7 ? bangs.front(x, y) || back.front(x, y) : Math.abs(x + .5 - C - s) < 7 && bangs.front(x, y);
|
|
1499
|
+
const behind = length === 0 ? back.back : lengthen(back.back);
|
|
1385
1500
|
return {
|
|
1386
|
-
front: (x, y) =>
|
|
1501
|
+
front: (x, y) => fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y),
|
|
1387
1502
|
sides: [sideOf(-1), sideOf(1)],
|
|
1388
|
-
|
|
1503
|
+
pieces: {
|
|
1504
|
+
bangs: fringe,
|
|
1505
|
+
leftSideHair: sideOf(-1),
|
|
1506
|
+
rightSideHair: sideOf(1),
|
|
1507
|
+
backHair: behind
|
|
1508
|
+
},
|
|
1509
|
+
back: behind,
|
|
1389
1510
|
...back.ties ? { ties: back.ties } : {},
|
|
1390
1511
|
...back.bands ? { bands: back.bands } : {}
|
|
1391
1512
|
};
|
|
@@ -1813,6 +1934,15 @@ function pixelTileColor(hair) {
|
|
|
1813
1934
|
].map((v) => v * 255));
|
|
1814
1935
|
}
|
|
1815
1936
|
function pixelFigure(recipe, yawDeg, options = {}) {
|
|
1937
|
+
const { grids: _, ...out } = renderFigure(recipe, yawDeg, options, /* @__PURE__ */ new Set());
|
|
1938
|
+
return out;
|
|
1939
|
+
}
|
|
1940
|
+
const FACE_SLOTS = [
|
|
1941
|
+
"nose",
|
|
1942
|
+
"cheeks",
|
|
1943
|
+
"glasses"
|
|
1944
|
+
];
|
|
1945
|
+
function renderFigure(recipe, yawDeg, options, omit) {
|
|
1816
1946
|
const yaw = yawDeg * Math.PI / 180;
|
|
1817
1947
|
const sinY = Math.sin(yaw);
|
|
1818
1948
|
const s = Math.round(sinY * 3);
|
|
@@ -1829,10 +1959,41 @@ function pixelFigure(recipe, yawDeg, options = {}) {
|
|
|
1829
1959
|
return front(x - Math.round(d * (1 + Math.max(0, y - 20) * .4) - d * tuck), y);
|
|
1830
1960
|
} : front;
|
|
1831
1961
|
const flower = recipe.species === "flower";
|
|
1832
|
-
const
|
|
1962
|
+
const hiddenNow = hiddenChoices(recipe);
|
|
1963
|
+
const drawnParts = new Map(REPLACE_PART_SLOTS.flatMap((slot) => {
|
|
1964
|
+
const part = wornPart(recipe, slot);
|
|
1965
|
+
const shown = slot === "petals" || slot === "flowerBase" ? flower : !hiddenNow.includes(slot);
|
|
1966
|
+
return part && shown ? [[slot, part.front]] : [];
|
|
1967
|
+
}));
|
|
1968
|
+
const skip = (slot) => omit.has(slot) || drawnParts.has(slot);
|
|
1969
|
+
const covered = !skip("accessory") && (recipe.accessory === "helmet" || recipe.accessory === "hood");
|
|
1833
1970
|
const bare = () => false;
|
|
1834
|
-
const petalRing = flower ? petals(recipe.petals ?? "trumpet", s) : bare;
|
|
1835
|
-
const
|
|
1971
|
+
const petalRing = flower && !skip("petals") ? petals(recipe.petals ?? "trumpet", s) : bare;
|
|
1972
|
+
const faceShift = Math.round(fs * .6);
|
|
1973
|
+
const mouthCells = /* @__PURE__ */ new Set();
|
|
1974
|
+
for (const rows of [...Object.values(MOUTHS), [
|
|
1975
|
+
".K.",
|
|
1976
|
+
"KMK",
|
|
1977
|
+
".K."
|
|
1978
|
+
]]) {
|
|
1979
|
+
const left = Math.round(C - .5 + fs * .6 - (rows[0].length - 1) / 2);
|
|
1980
|
+
rows.forEach((row, dy) => [...row].forEach((code, dx) => {
|
|
1981
|
+
if (code !== ".") mouthCells.add(`${left + dx},${20 + (recipe.height ?? 0) + dy}`);
|
|
1982
|
+
}));
|
|
1983
|
+
}
|
|
1984
|
+
const mouthBox = (x, y) => mouthCells.has(`${x},${y}`);
|
|
1985
|
+
const overlay = (grid, slot, keepOutMouth = false) => {
|
|
1986
|
+
const cells = drawnParts.get(slot);
|
|
1987
|
+
if (!cells) return;
|
|
1988
|
+
const shift = FACE_SLOTS.includes(slot) ? faceShift : s;
|
|
1989
|
+
for (const [x, y, color, tone] of cells) {
|
|
1990
|
+
const px = x + shift;
|
|
1991
|
+
if (px < 0 || px >= N || keepOutMouth && mouthBox(px, y)) continue;
|
|
1992
|
+
const base = color.startsWith("#") ? color.toLowerCase() : recipe[color];
|
|
1993
|
+
grid[y][px] = TONES[tone](base);
|
|
1994
|
+
}
|
|
1995
|
+
};
|
|
1996
|
+
const builtin = flower ? {
|
|
1836
1997
|
back: petalRing,
|
|
1837
1998
|
front: bare,
|
|
1838
1999
|
sides: [(x, y) => petalRing(x, y) && x + .5 < C + s, (x, y) => petalRing(x, y) && x + .5 >= C + s]
|
|
@@ -1840,9 +2001,48 @@ function pixelFigure(recipe, yawDeg, options = {}) {
|
|
|
1840
2001
|
back: bare,
|
|
1841
2002
|
front: bare
|
|
1842
2003
|
} : recipe.bangs === void 0 ? hair(recipe, s, b) : detailedHair(recipe, s, b);
|
|
2004
|
+
const drawnHair = HAIR_PART_SLOTS.flatMap((slot) => {
|
|
2005
|
+
const part = builtin.pieces ? wornPart(recipe, slot) : void 0;
|
|
2006
|
+
return part ? [{
|
|
2007
|
+
slot,
|
|
2008
|
+
part
|
|
2009
|
+
}] : [];
|
|
2010
|
+
});
|
|
2011
|
+
const live = (cells, shift) => {
|
|
2012
|
+
const keys = new Set(cells.flatMap(([x, y, color, tone]) => color === "hairColor" && tone === 0 ? [`${x + shift},${y}`] : []));
|
|
2013
|
+
return (x, y) => keys.has(`${x},${y}`);
|
|
2014
|
+
};
|
|
2015
|
+
const backShift = () => {
|
|
2016
|
+
if (s === b || !builtin.pieces) return s;
|
|
2017
|
+
const front = detailedHair(detailedRecipe(recipe), 0, 0).pieces.backHair;
|
|
2018
|
+
const misses = (shift) => {
|
|
2019
|
+
let count = 0;
|
|
2020
|
+
for (let y = 0; y < N; y++) for (let x = -4; x < N + 4; x++) if (front(x, y) !== builtin.pieces.backHair(x + shift, y)) count++;
|
|
2021
|
+
return count;
|
|
2022
|
+
};
|
|
2023
|
+
return misses(b) < misses(s) ? b : s;
|
|
2024
|
+
};
|
|
2025
|
+
const piece = (slot) => {
|
|
2026
|
+
const part = drawnHair.find((drawn) => drawn.slot === slot)?.part;
|
|
2027
|
+
if (!part) return builtin.pieces[slot];
|
|
2028
|
+
return live(part.front, slot === "backHair" ? backShift() : s);
|
|
2029
|
+
};
|
|
2030
|
+
const masks = drawnHair.length === 0 ? builtin : {
|
|
2031
|
+
...builtin,
|
|
2032
|
+
front: (x, y) => piece("bangs")(x, y) || piece("leftSideHair")(x, y) || piece("rightSideHair")(x, y),
|
|
2033
|
+
sides: [piece("leftSideHair"), piece("rightSideHair")],
|
|
2034
|
+
back: piece("backHair"),
|
|
2035
|
+
...drawnHair.some((drawn) => drawn.slot === "backHair") ? { ties: [] } : {}
|
|
2036
|
+
};
|
|
2037
|
+
const inked = (slot) => drawnHair.filter((drawn) => slot === "all-front" ? drawn.slot !== "backHair" : drawn.slot === slot).flatMap((drawn) => drawn.part.front.filter(([, , color, tone]) => !(color === "hairColor" && tone === 0)));
|
|
1843
2038
|
const body = blank();
|
|
1844
|
-
if (flower)
|
|
1845
|
-
|
|
2039
|
+
if (flower) {
|
|
2040
|
+
if (!skip("flowerBase")) flowerBase(recipe, body, s);
|
|
2041
|
+
overlay(body, "flowerBase");
|
|
2042
|
+
} else {
|
|
2043
|
+
if (!skip("outfit")) outfit(recipe, body, s);
|
|
2044
|
+
overlay(body, "outfit");
|
|
2045
|
+
}
|
|
1846
2046
|
const back = blank();
|
|
1847
2047
|
if (flower) {
|
|
1848
2048
|
paint(back, petalRing, hairColor);
|
|
@@ -1850,11 +2050,11 @@ function pixelFigure(recipe, yawDeg, options = {}) {
|
|
|
1850
2050
|
paint(back, (x, y) => petalRing(x, y) && x + .5 < C + s - 3 && y < 10, mix$1(hairColor, WHITE, .3));
|
|
1851
2051
|
} else paint(back, masks.back, shade(hairColor, .82));
|
|
1852
2052
|
if (masks.bands) paint(back, (x, y) => masks.back(x, y) && (y + Math.floor(x / 2)) % 4 === 0, shade(hairColor, .68));
|
|
1853
|
-
const headpiece = recipe
|
|
1854
|
-
const placeCells = (grid, cells) => {
|
|
2053
|
+
const headpiece = wornPart(recipe, "headpiece");
|
|
2054
|
+
const placeCells = (grid, cells, compress = true) => {
|
|
1855
2055
|
const placed = cells.map(([x, y, color, tone]) => {
|
|
1856
2056
|
const dx = x + .5 - C;
|
|
1857
|
-
const far = turned && d * dx < 0 && Math.abs(dx) >= 5;
|
|
2057
|
+
const far = compress && turned && d * dx < 0 && Math.abs(dx) >= 5;
|
|
1858
2058
|
const base = color.startsWith("#") ? color.toLowerCase() : recipe[color];
|
|
1859
2059
|
return {
|
|
1860
2060
|
x: x + s + (far ? d : 0),
|
|
@@ -1865,11 +2065,13 @@ function pixelFigure(recipe, yawDeg, options = {}) {
|
|
|
1865
2065
|
});
|
|
1866
2066
|
for (const cell of [...placed.filter((p) => p.far), ...placed.filter((p) => !p.far)]) if (cell.x >= 0 && cell.x < N) grid[cell.y][cell.x] = cell.c;
|
|
1867
2067
|
};
|
|
2068
|
+
placeCells(back, inked("backHair"), false);
|
|
2069
|
+
overlay(back, "petals");
|
|
1868
2070
|
if (headpiece) placeCells(back, headpiece.back);
|
|
1869
2071
|
const head = blank();
|
|
1870
2072
|
paint(head, face, skin);
|
|
1871
2073
|
const h = recipe.height ?? 0;
|
|
1872
|
-
if (recipe.beard && !flower) {
|
|
2074
|
+
if (recipe.beard && !flower && !skip("beard")) {
|
|
1873
2075
|
const centre = C + fs * .6;
|
|
1874
2076
|
const mouthX = Math.round(C - .5 + fs * .6);
|
|
1875
2077
|
const chin = (x, y) => face(x, y) || face(x, y - 2);
|
|
@@ -1913,7 +2115,7 @@ function pixelFigure(recipe, yawDeg, options = {}) {
|
|
|
1913
2115
|
});
|
|
1914
2116
|
if (!pointed && !flower) paint(head, masks.front, hairColor);
|
|
1915
2117
|
for (const [x, y] of masks.ties ?? []) sprite(back, ["T"], Math.round(x), y, { T: shade(recipe.shirtColor, .8) });
|
|
1916
|
-
if (!flower) accessory(recipe, head, s, face);
|
|
2118
|
+
if (!flower && !skip("accessory")) accessory(recipe, head, s, face);
|
|
1917
2119
|
const shadeHair = (g) => {
|
|
1918
2120
|
const out = g.map((r) => [...r]);
|
|
1919
2121
|
const cells = g.flatMap((row, y) => row.flatMap((c, x) => c === hairColor ? [[x, y]] : []));
|
|
@@ -1954,13 +2156,18 @@ function pixelFigure(recipe, yawDeg, options = {}) {
|
|
|
1954
2156
|
}
|
|
1955
2157
|
return out;
|
|
1956
2158
|
};
|
|
2159
|
+
const drawnOver = blank();
|
|
2160
|
+
overlay(drawnOver, "beard", true);
|
|
2161
|
+
overlay(drawnOver, "accessory");
|
|
1957
2162
|
const crown = blank();
|
|
2163
|
+
placeCells(crown, inked("all-front"), false);
|
|
1958
2164
|
if (headpiece) placeCells(crown, headpiece.front);
|
|
1959
2165
|
const figure = blank();
|
|
1960
2166
|
for (const layer of [
|
|
1961
2167
|
back,
|
|
1962
2168
|
body,
|
|
1963
2169
|
shadeHair(head),
|
|
2170
|
+
drawnOver,
|
|
1964
2171
|
crown
|
|
1965
2172
|
]) for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (layer[y][x]) figure[y][x] = layer[y][x];
|
|
1966
2173
|
const features = blank();
|
|
@@ -2028,6 +2235,7 @@ function pixelFigure(recipe, yawDeg, options = {}) {
|
|
|
2028
2235
|
N: shade(skin, .84),
|
|
2029
2236
|
n: shade(skin, .72)
|
|
2030
2237
|
});
|
|
2238
|
+
else if (skip("nose")) overlay(features, "nose", true);
|
|
2031
2239
|
else if (recipe.nose === "dot") sprite(features, ["N"], cx, 18 + h, { N: shade(skin, .78) });
|
|
2032
2240
|
else if (recipe.nose === "button") sprite(features, ["N"], cx, 18 + h, { N: shade(skin, .88) });
|
|
2033
2241
|
else if (recipe.nose === "line") sprite(features, ["N", "N"], cx, 17 + h, { N: shade(skin, .82) });
|
|
@@ -2042,11 +2250,12 @@ function pixelFigure(recipe, yawDeg, options = {}) {
|
|
|
2042
2250
|
});
|
|
2043
2251
|
if (recipe.species === "goblin") for (const tx of [Math.round(mx - 2.5), Math.round(mx + 2.5)]) sprite(layer, ["T"], tx, 21 + h, { T: TUSK });
|
|
2044
2252
|
if (recipe.species === "orc") for (const tx of [Math.round(mx - 2.5), Math.round(mx + 2.5)]) sprite(layer, ["T", "T"], tx, 20 + h, { T: TUSK });
|
|
2045
|
-
if (
|
|
2253
|
+
if (skip("cheeks")) overlay(layer, "cheeks", true);
|
|
2254
|
+
if (recipe.cheeks === "blush" && !flower && !skip("cheeks")) {
|
|
2046
2255
|
sprite(layer, ["PP"], left.x - 1, 19 + h, { P: mix$1(BLUSH, skin, .25) });
|
|
2047
2256
|
sprite(layer, ["PP"], right.x + right.w - 1, 19 + h, { P: mix$1(BLUSH, skin, .25) });
|
|
2048
2257
|
}
|
|
2049
|
-
if (recipe.cheeks === "freckles" && !flower) {
|
|
2258
|
+
if (recipe.cheeks === "freckles" && !flower && !skip("cheeks")) {
|
|
2050
2259
|
sprite(layer, ["F.F"], left.x, 19 + h, { F: shade(skin, .7) });
|
|
2051
2260
|
sprite(layer, ["F.F"], right.x + 1, 19 + h, { F: shade(skin, .7) });
|
|
2052
2261
|
}
|
|
@@ -2069,7 +2278,8 @@ function pixelFigure(recipe, yawDeg, options = {}) {
|
|
|
2069
2278
|
const corner = (x === fx - 1 || x === fx + w) && (y === eyeTop - 1 || y === eyeTop + 4);
|
|
2070
2279
|
return box && !inside && !(round && corner);
|
|
2071
2280
|
}, INK$1);
|
|
2072
|
-
const lenses = flower ? "none" : recipe.glasses;
|
|
2281
|
+
const lenses = flower || skip("glasses") ? "none" : recipe.glasses;
|
|
2282
|
+
overlay(glasses, "glasses", true);
|
|
2073
2283
|
if (lenses === "round" || lenses === "square") {
|
|
2074
2284
|
frame(left.x, left.w, lenses === "round");
|
|
2075
2285
|
frame(right.x, right.w, lenses === "round");
|
|
@@ -2099,7 +2309,7 @@ function pixelFigure(recipe, yawDeg, options = {}) {
|
|
|
2099
2309
|
hairColor: color
|
|
2100
2310
|
};
|
|
2101
2311
|
for (const k of AVATAR_PIECE_COLORS) delete plain[k];
|
|
2102
|
-
for (const cell of
|
|
2312
|
+
for (const cell of renderFigure(plain, yawDeg, {}, omit).cells) variant[cell.y][cell.x] = cell.c;
|
|
2103
2313
|
const near = (x, y) => mask(x, y) || mask(x - 1, y) || mask(x + 1, y) || mask(x, y - 1) || mask(x, y + 1);
|
|
2104
2314
|
for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (full[y][x] && near(x, y) && !eyes[y][x] && !glasses[y][x]) full[y][x] = variant[y][x];
|
|
2105
2315
|
});
|
|
@@ -2108,7 +2318,7 @@ function pixelFigure(recipe, yawDeg, options = {}) {
|
|
|
2108
2318
|
for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
|
|
2109
2319
|
const c = full[y][x];
|
|
2110
2320
|
if (!c) continue;
|
|
2111
|
-
if (y >= 22 && !head[y][x] && !back[y][x] && !crown[y][x]) bodyCells[y][x] = c;
|
|
2321
|
+
if (y >= 22 && !head[y][x] && !back[y][x] && !crown[y][x] && !drawnOver[y][x]) bodyCells[y][x] = c;
|
|
2112
2322
|
else headCells[y][x] = c;
|
|
2113
2323
|
}
|
|
2114
2324
|
const cells = [];
|
|
@@ -2121,6 +2331,13 @@ function pixelFigure(recipe, yawDeg, options = {}) {
|
|
|
2121
2331
|
});
|
|
2122
2332
|
}
|
|
2123
2333
|
return {
|
|
2334
|
+
grids: {
|
|
2335
|
+
figure,
|
|
2336
|
+
features: savedFeatures,
|
|
2337
|
+
glasses,
|
|
2338
|
+
back,
|
|
2339
|
+
body
|
|
2340
|
+
},
|
|
2124
2341
|
cells,
|
|
2125
2342
|
tile: `<rect width="32" height="32" rx="6" fill="${pixelTileColor(recipe.hairColor)}"/>`,
|
|
2126
2343
|
body: `<g class="bh-illustrated-body">${rects(bodyCells)}</g>`,
|
|
@@ -2133,6 +2350,82 @@ function pixelFigure(recipe, yawDeg, options = {}) {
|
|
|
2133
2350
|
].join("")
|
|
2134
2351
|
};
|
|
2135
2352
|
}
|
|
2353
|
+
/**
|
|
2354
|
+
* The hair piece in `slot` as a Custom Part to start drawing from: the drawn part worn there,
|
|
2355
|
+
* or the built-in piece flattened for this recipe in the front pose as live `hairColor` cells.
|
|
2356
|
+
* A flattened piece keeps this shape and no longer follows face shape or hair length.
|
|
2357
|
+
*/
|
|
2358
|
+
function hairPieceStart(recipe, slot) {
|
|
2359
|
+
const worn = wornPart(recipe, slot);
|
|
2360
|
+
if (worn) return {
|
|
2361
|
+
slot,
|
|
2362
|
+
front: [...worn.front],
|
|
2363
|
+
back: []
|
|
2364
|
+
};
|
|
2365
|
+
const built = detailedHair(detailedRecipe(recipe), 0, 0);
|
|
2366
|
+
const mask = built.pieces[slot];
|
|
2367
|
+
const ties = new Set(slot === "backHair" ? (built.ties ?? []).map(([x, y]) => `${Math.round(x)},${y}`) : []);
|
|
2368
|
+
const front = [];
|
|
2369
|
+
for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) if (ties.has(`${x},${y}`)) front.push([
|
|
2370
|
+
x,
|
|
2371
|
+
y,
|
|
2372
|
+
"shirtColor",
|
|
2373
|
+
-1
|
|
2374
|
+
]);
|
|
2375
|
+
else if (mask(x, y)) front.push([
|
|
2376
|
+
x,
|
|
2377
|
+
y,
|
|
2378
|
+
"hairColor",
|
|
2379
|
+
0
|
|
2380
|
+
]);
|
|
2381
|
+
return {
|
|
2382
|
+
slot,
|
|
2383
|
+
front,
|
|
2384
|
+
back: []
|
|
2385
|
+
};
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2388
|
+
* The part in a replacement slot as a Custom Part to start drawing from: the drawn part worn
|
|
2389
|
+
* there, or the built-in part flattened for this recipe in the front pose. Each pixel becomes a
|
|
2390
|
+
* color slot and tone when it is exactly one, so it recolors with the Avatar, and a fixed color
|
|
2391
|
+
* otherwise; an unchanged copy renders identically. A flattened helmet or hood no longer hides
|
|
2392
|
+
* the hair: only the built-in ones do.
|
|
2393
|
+
*/
|
|
2394
|
+
function replacePartStart(recipe, slot) {
|
|
2395
|
+
const worn = wornPart(recipe, slot);
|
|
2396
|
+
if (worn) return {
|
|
2397
|
+
slot,
|
|
2398
|
+
front: [...worn.front],
|
|
2399
|
+
back: []
|
|
2400
|
+
};
|
|
2401
|
+
const layer = slot === "glasses" ? "glasses" : slot === "nose" || slot === "cheeks" ? "features" : slot === "petals" ? "back" : slot === "outfit" || slot === "flowerBase" ? "body" : "figure";
|
|
2402
|
+
const shown = renderFigure(recipe, 0, {}, /* @__PURE__ */ new Set()).grids[layer];
|
|
2403
|
+
const without = renderFigure(recipe, 0, {}, new Set([slot])).grids[layer];
|
|
2404
|
+
const source = (c) => {
|
|
2405
|
+
for (const key of AVATAR_COLORS) for (const tone of [
|
|
2406
|
+
0,
|
|
2407
|
+
-1,
|
|
2408
|
+
1,
|
|
2409
|
+
-2,
|
|
2410
|
+
2
|
|
2411
|
+
]) if (TONES[tone](recipe[key].toLowerCase()) === c) return [key, tone];
|
|
2412
|
+
return [c, 0];
|
|
2413
|
+
};
|
|
2414
|
+
const front = [];
|
|
2415
|
+
for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
|
|
2416
|
+
const c = shown[y][x];
|
|
2417
|
+
if (c && c !== without[y][x]) front.push([
|
|
2418
|
+
x,
|
|
2419
|
+
y,
|
|
2420
|
+
...source(c)
|
|
2421
|
+
]);
|
|
2422
|
+
}
|
|
2423
|
+
return {
|
|
2424
|
+
slot,
|
|
2425
|
+
front,
|
|
2426
|
+
back: []
|
|
2427
|
+
};
|
|
2428
|
+
}
|
|
2136
2429
|
|
|
2137
2430
|
//#endregion
|
|
2138
2431
|
//#region src/svg.ts
|
|
@@ -2656,5 +2949,5 @@ function symbolArtCells(rows, color) {
|
|
|
2656
2949
|
}
|
|
2657
2950
|
|
|
2658
2951
|
//#endregion
|
|
2659
|
-
export { AVATAR_COLORS, AVATAR_EXTRA_PARTS, AVATAR_HAIR_PARTS, AVATAR_PARTS, AVATAR_PARTS_V2, AVATAR_PIECE_COLORS, AVATAR_PRESETS, AVATAR_RANGES, AVATAR_SPECIES, AVATAR_SPECIES_SWATCHES, AVATAR_SWATCHES, AVATAR_TURNS, DEFAULT_RECIPE, MAX_PART_FIXED_COLORS, PART_LAYERS, PART_SLOTS, PART_TONES, PIXEL_SYMBOLS, canonicalCustomPart, canonicalRecipe, createCustomPart, createSeededRecipe, createSeededRecipeV2, customPartId, detailedRecipe, emptyPartLayer, faceCells, fillPartLayer, hiddenChoices, isPixelAvatarRecipe, isPixelCustomPart, mirrorPartX, paintPartLayer, partCells, partLayer, partToneColor, pixelAvatarSvg, pixelFigure, pixelSymbolCells, pixelTileColor, seededRandom, seededRecipe, seededRecipeV2, symbolArtCells, withHeadpiece, withSpecies };
|
|
2952
|
+
export { AVATAR_COLORS, AVATAR_EXTRA_PARTS, AVATAR_HAIR_PARTS, AVATAR_PARTS, AVATAR_PARTS_V2, AVATAR_PIECE_COLORS, AVATAR_PRESETS, AVATAR_RANGES, AVATAR_SPECIES, AVATAR_SPECIES_SWATCHES, AVATAR_SWATCHES, AVATAR_TURNS, CUSTOM_PART_KEYS, DEFAULT_RECIPE, HAIR_PART_SLOTS, MAX_PART_FIXED_COLORS, PART_LAYERS, PART_SLOTS, PART_TONES, PIXEL_SYMBOLS, REPLACE_PART_SLOTS, canonicalCustomPart, canonicalRecipe, createCustomPart, createSeededRecipe, createSeededRecipeV2, customPartId, detailedRecipe, emptyPartLayer, faceCells, fillPartLayer, hairPieceStart, hiddenChoices, isHairPartSlot, isPixelAvatarRecipe, isPixelCustomPart, isReplacePartSlot, mirrorPartX, paintPartLayer, partCells, partLayer, partToneColor, pixelAvatarSvg, pixelFigure, pixelSymbolCells, pixelTileColor, replacePartStart, seededRandom, seededRecipe, seededRecipeV2, symbolArtCells, withCustomPart, withHeadpiece, withSpecies, wornPart };
|
|
2660
2953
|
//# sourceMappingURL=index.js.map
|