cyberia 3.1.3 → 3.2.5
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/.env.example +0 -2
- package/.github/workflows/engine-cyberia.cd.yml +10 -8
- package/.github/workflows/engine-cyberia.ci.yml +12 -29
- package/.github/workflows/ghpkg.ci.yml +4 -4
- package/.github/workflows/npmpkg.ci.yml +28 -11
- package/.github/workflows/publish.ci.yml +21 -2
- package/.github/workflows/pwa-microservices-template-page.cd.yml +4 -5
- package/.github/workflows/pwa-microservices-template-test.ci.yml +3 -3
- package/.github/workflows/release.cd.yml +13 -8
- package/CHANGELOG.md +433 -1
- package/CLI-HELP.md +57 -7
- package/Dockerfile +4 -2
- package/README.md +347 -22
- package/bin/build.js +5 -2
- package/bin/cyberia.js +1789 -112
- package/bin/deploy.js +177 -124
- package/bin/file.js +3 -0
- package/bin/index.js +1789 -112
- package/conf.js +64 -8
- package/deployment.yaml +92 -20
- package/hardhat/hardhat.config.js +13 -13
- package/hardhat/ignition/modules/ObjectLayerToken.js +1 -1
- package/hardhat/package-lock.json +2554 -5859
- package/hardhat/package.json +13 -22
- package/hardhat/scripts/deployObjectLayerToken.js +1 -1
- package/hardhat/test/ObjectLayerToken.js +4 -2
- package/hardhat/types/ethers-contracts/ObjectLayerToken.ts +690 -0
- package/hardhat/types/ethers-contracts/common.ts +92 -0
- package/hardhat/types/ethers-contracts/factories/ObjectLayerToken__factory.ts +1055 -0
- package/hardhat/types/ethers-contracts/factories/index.ts +4 -0
- package/hardhat/types/ethers-contracts/hardhat.d.ts +47 -0
- package/hardhat/types/ethers-contracts/index.ts +6 -0
- package/jsdoc.dd-cyberia.json +64 -55
- package/jsdoc.json +64 -55
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -4
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +5 -4
- package/manifests/deployment/dd-cyberia-development/deployment.yaml +92 -20
- package/manifests/deployment/dd-cyberia-development/proxy.yaml +54 -18
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/dd-test-development/deployment.yaml +88 -74
- package/manifests/deployment/dd-test-development/proxy.yaml +13 -4
- package/manifests/deployment/playwright/deployment.yaml +1 -1
- package/nodemon.json +1 -1
- package/package.json +22 -16
- package/proxy.yaml +54 -18
- package/scripts/rhel-grpc-setup.sh +56 -0
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +44 -0
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +16 -0
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +5 -0
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +80 -7
- package/src/api/cyberia-dialogue/cyberia-dialogue.controller.js +93 -0
- package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +36 -0
- package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +29 -0
- package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +51 -0
- package/src/api/cyberia-entity/cyberia-entity.controller.js +74 -0
- package/src/api/cyberia-entity/cyberia-entity.model.js +24 -0
- package/src/api/cyberia-entity/cyberia-entity.router.js +27 -0
- package/src/api/cyberia-entity/cyberia-entity.service.js +42 -0
- package/src/api/cyberia-instance/cyberia-fallback-world.js +368 -0
- package/src/api/cyberia-instance/cyberia-instance.controller.js +92 -0
- package/src/api/cyberia-instance/cyberia-instance.model.js +84 -0
- package/src/api/cyberia-instance/cyberia-instance.router.js +63 -0
- package/src/api/cyberia-instance/cyberia-instance.service.js +191 -0
- package/src/api/cyberia-instance/cyberia-portal-connector.js +486 -0
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.controller.js +74 -0
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.defaults.js +413 -0
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +228 -0
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +27 -0
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +42 -0
- package/src/api/cyberia-map/cyberia-map.controller.js +79 -0
- package/src/api/cyberia-map/cyberia-map.model.js +30 -0
- package/src/api/cyberia-map/cyberia-map.router.js +40 -0
- package/src/api/cyberia-map/cyberia-map.service.js +74 -0
- package/src/api/file/file.ref.json +18 -0
- package/src/api/ipfs/ipfs.controller.js +4 -25
- package/src/api/ipfs/ipfs.model.js +43 -34
- package/src/api/ipfs/ipfs.router.js +8 -13
- package/src/api/ipfs/ipfs.service.js +54 -102
- package/src/api/object-layer/README.md +347 -22
- package/src/api/object-layer/object-layer.router.js +30 -0
- package/src/api/object-layer/object-layer.service.js +114 -31
- package/src/api/user/user.service.js +8 -7
- package/src/cli/cluster.js +7 -7
- package/src/cli/db.js +710 -827
- package/src/cli/deploy.js +151 -93
- package/src/cli/env.js +29 -0
- package/src/cli/fs.js +5 -2
- package/src/cli/index.js +48 -2
- package/src/cli/kubectl.js +211 -0
- package/src/cli/release.js +284 -0
- package/src/cli/repository.js +438 -75
- package/src/cli/run.js +195 -35
- package/src/cli/secrets.js +73 -0
- package/src/cli/test.js +3 -3
- package/src/client/Cryptokoyn.index.js +3 -4
- package/src/client/CyberiaPortal.index.js +3 -4
- package/src/client/Default.index.js +3 -4
- package/src/client/Itemledger.index.js +3 -4
- package/src/client/Underpost.index.js +3 -4
- package/src/client/components/core/AppStore.js +69 -0
- package/src/client/components/core/CalendarCore.js +2 -2
- package/src/client/components/core/DropDown.js +137 -17
- package/src/client/components/core/Keyboard.js +2 -2
- package/src/client/components/core/LogIn.js +2 -2
- package/src/client/components/core/LogOut.js +2 -2
- package/src/client/components/core/Modal.js +0 -1
- package/src/client/components/core/Panel.js +0 -1
- package/src/client/components/core/PanelForm.js +19 -19
- package/src/client/components/core/SocketIo.js +82 -29
- package/src/client/components/core/SocketIoHandler.js +75 -0
- package/src/client/components/core/Stream.js +143 -95
- package/src/client/components/core/Webhook.js +40 -7
- package/src/client/components/cryptokoyn/AppStoreCryptokoyn.js +5 -0
- package/src/client/components/cryptokoyn/LogInCryptokoyn.js +3 -3
- package/src/client/components/cryptokoyn/LogOutCryptokoyn.js +2 -2
- package/src/client/components/cryptokoyn/MenuCryptokoyn.js +3 -3
- package/src/client/components/cryptokoyn/SocketIoCryptokoyn.js +3 -51
- package/src/client/components/cyberia/InstanceEngineCyberia.js +700 -0
- package/src/client/components/cyberia/MapEngineCyberia.js +1359 -2
- package/src/client/components/cyberia/ObjectLayerEngineModal.js +17 -6
- package/src/client/components/cyberia/ObjectLayerEngineViewer.js +92 -54
- package/src/client/components/cyberia-portal/AppStoreCyberiaPortal.js +5 -0
- package/src/client/components/cyberia-portal/CommonCyberiaPortal.js +216 -30
- package/src/client/components/cyberia-portal/LogInCyberiaPortal.js +3 -3
- package/src/client/components/cyberia-portal/LogOutCyberiaPortal.js +2 -2
- package/src/client/components/cyberia-portal/MenuCyberiaPortal.js +40 -7
- package/src/client/components/cyberia-portal/RoutesCyberiaPortal.js +4 -0
- package/src/client/components/cyberia-portal/SocketIoCyberiaPortal.js +3 -49
- package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +4 -0
- package/src/client/components/default/AppStoreDefault.js +5 -0
- package/src/client/components/default/LogInDefault.js +3 -3
- package/src/client/components/default/LogOutDefault.js +2 -2
- package/src/client/components/default/MenuDefault.js +5 -5
- package/src/client/components/default/SocketIoDefault.js +3 -51
- package/src/client/components/itemledger/AppStoreItemledger.js +5 -0
- package/src/client/components/itemledger/LogInItemledger.js +3 -3
- package/src/client/components/itemledger/LogOutItemledger.js +2 -2
- package/src/client/components/itemledger/MenuItemledger.js +3 -3
- package/src/client/components/itemledger/SocketIoItemledger.js +3 -51
- package/src/client/components/underpost/AppStoreUnderpost.js +5 -0
- package/src/client/components/underpost/LogInUnderpost.js +3 -3
- package/src/client/components/underpost/LogOutUnderpost.js +2 -2
- package/src/client/components/underpost/MenuUnderpost.js +5 -5
- package/src/client/components/underpost/SocketIoUnderpost.js +3 -51
- package/src/client/services/core/core.service.js +20 -8
- package/src/client/services/cyberia-dialogue/cyberia-dialogue.service.js +105 -0
- package/src/client/services/cyberia-entity/cyberia-entity.management.js +57 -0
- package/src/client/services/cyberia-entity/cyberia-entity.service.js +105 -0
- package/src/client/services/cyberia-instance/cyberia-instance.management.js +194 -0
- package/src/client/services/cyberia-instance/cyberia-instance.service.js +122 -0
- package/src/client/services/cyberia-instance-conf/cyberia-instance-conf.service.js +105 -0
- package/src/client/services/cyberia-map/cyberia-map.management.js +193 -0
- package/src/client/services/cyberia-map/cyberia-map.service.js +126 -0
- package/src/client/services/instance/instance.management.js +2 -2
- package/src/client/services/ipfs/ipfs.service.js +3 -23
- package/src/client/services/object-layer/object-layer.management.js +3 -3
- package/src/client/services/object-layer/object-layer.service.js +21 -0
- package/src/client/services/user/user.management.js +2 -2
- package/src/client/ssr/pages/CyberiaServerMetrics.js +1 -1
- package/src/grpc/cyberia/OFF_CHAIN_ECONOMY.md +305 -0
- package/src/grpc/cyberia/README.md +326 -0
- package/src/grpc/cyberia/grpc-server.js +530 -0
- package/src/index.js +24 -1
- package/src/runtime/express/Dockerfile +4 -0
- package/src/runtime/express/Express.js +18 -1
- package/src/runtime/lampp/Dockerfile +13 -2
- package/src/runtime/lampp/Lampp.js +27 -4
- package/src/runtime/wp/Dockerfile +68 -0
- package/src/runtime/wp/Wp.js +639 -0
- package/src/server/auth.js +24 -1
- package/src/server/backup.js +37 -9
- package/src/server/client-build-docs.js +9 -2
- package/src/server/client-build.js +31 -31
- package/src/server/client-formatted.js +109 -57
- package/src/server/conf.js +24 -9
- package/src/server/cron.js +25 -23
- package/src/server/dns.js +2 -1
- package/src/server/ipfs-client.js +24 -1
- package/src/server/object-layer.js +149 -108
- package/src/server/peer.js +8 -0
- package/src/server/runtime.js +25 -1
- package/src/server/semantic-layer-generator-floor.js +359 -0
- package/src/server/semantic-layer-generator-skin.js +1294 -0
- package/src/server/semantic-layer-generator.js +116 -555
- package/src/server/start.js +2 -2
- package/src/ws/IoInterface.js +1 -10
- package/src/ws/IoServer.js +14 -33
- package/src/ws/core/channels/core.ws.chat.js +65 -20
- package/src/ws/core/channels/core.ws.mailer.js +113 -32
- package/src/ws/core/channels/core.ws.stream.js +90 -31
- package/src/ws/core/core.ws.connection.js +12 -33
- package/src/ws/core/core.ws.emit.js +10 -26
- package/src/ws/core/core.ws.server.js +25 -58
- package/src/ws/default/channels/default.ws.main.js +53 -12
- package/src/ws/default/default.ws.connection.js +26 -13
- package/src/ws/default/default.ws.server.js +30 -12
- package/src/client/components/cryptokoyn/CommonCryptokoyn.js +0 -29
- package/src/client/components/cryptokoyn/ElementsCryptokoyn.js +0 -38
- package/src/client/components/cyberia-portal/ElementsCyberiaPortal.js +0 -38
- package/src/client/components/default/ElementsDefault.js +0 -38
- package/src/client/components/itemledger/CommonItemledger.js +0 -29
- package/src/client/components/itemledger/ElementsItemledger.js +0 -38
- package/src/client/components/underpost/CommonUnderpost.js +0 -29
- package/src/client/components/underpost/ElementsUnderpost.js +0 -38
- package/src/ws/core/management/core.ws.chat.js +0 -8
- package/src/ws/core/management/core.ws.mailer.js +0 -16
- package/src/ws/core/management/core.ws.stream.js +0 -8
- package/src/ws/default/management/default.ws.main.js +0 -8
|
@@ -0,0 +1,1294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skin semantic descriptor and template-based procedural generator.
|
|
3
|
+
*
|
|
4
|
+
* Instead of the shape/noise-field approach used by floor tiles, character
|
|
5
|
+
* skins are produced by painting pixel zones read from the canonical
|
|
6
|
+
* artist-authored templates in:
|
|
7
|
+
*
|
|
8
|
+
* src/client/public/cyberia/assets/templates/
|
|
9
|
+
*
|
|
10
|
+
* Templates define which pixels belong to each body part (hair, face/hands,
|
|
11
|
+
* shirt/breastplate, pants, shoes). A procedural per-seed color palette is
|
|
12
|
+
* applied so every generated skin has a distinct look while remaining
|
|
13
|
+
* technically valid for all four cardinal directions.
|
|
14
|
+
*
|
|
15
|
+
* Zone painting order (later zones override earlier ones):
|
|
16
|
+
* 1. skin – full body silhouette in skin-tone color
|
|
17
|
+
* 2. shirt – breastplate/shirt overpaints the torso
|
|
18
|
+
* 3. pants – legs overpaints the legs area
|
|
19
|
+
* 4. shoes – bottom rows of legs in shoe color
|
|
20
|
+
* 5. hair – hair overpaints the head crown
|
|
21
|
+
*
|
|
22
|
+
* UP direction derives from DOWN but colours the head area with hair instead
|
|
23
|
+
* of skin (the back of the head is visible).
|
|
24
|
+
* RIGHT direction mirrors LEFT (direction 06) template horizontally.
|
|
25
|
+
*
|
|
26
|
+
* @module src/server/semantic-layer-generator-skin.js
|
|
27
|
+
* @namespace SemanticLayerGeneratorSkin
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync } from 'fs';
|
|
31
|
+
import path from 'path';
|
|
32
|
+
import { fileURLToPath } from 'url';
|
|
33
|
+
import crypto from 'crypto';
|
|
34
|
+
|
|
35
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const TEMPLATES_DIR = path.resolve(__dirname, '../client/public/cyberia/assets/templates');
|
|
37
|
+
|
|
38
|
+
/* ─── Grid dimension (must match GRID_DIM in main generator) ────────────── */
|
|
39
|
+
const SKIN_GRID_DIM = 24;
|
|
40
|
+
|
|
41
|
+
/* ─── Y threshold: pixels at or above this row are considered "head" ─────── */
|
|
42
|
+
const HEAD_Y_MAX = 12;
|
|
43
|
+
|
|
44
|
+
/* ─── Y threshold: pixels at or above (>=) this row are "shoe" zone ──────── */
|
|
45
|
+
const SHOE_Y_MIN = 23;
|
|
46
|
+
|
|
47
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
48
|
+
* TEMPLATE LOADING
|
|
49
|
+
* Templates are loaded once at module initialisation (synchronous I/O).
|
|
50
|
+
* ═══════════════════════════════════════════════════════════════════════════ */
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Loads a JSON template file and returns the parsed value.
|
|
54
|
+
* Returns [] on any error so the generator degrades gracefully.
|
|
55
|
+
* @param {string} filename
|
|
56
|
+
* @returns {Array}
|
|
57
|
+
*/
|
|
58
|
+
function loadTemplate(filename) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(readFileSync(path.join(TEMPLATES_DIR, filename), 'utf8'));
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reads a full skin template (item-skin-08.json / item-skin-06.json) and
|
|
68
|
+
* extracts the black (#000000) outline pixels from its `color` grid.
|
|
69
|
+
* Pixels outside the 24 × 24 play area (x or y >= SKIN_GRID_DIM) are ignored.
|
|
70
|
+
* @param {string} filename Template filename (full template, not style overlay).
|
|
71
|
+
* @returns {number[][]} Array of [x, y] border pixel coordinates.
|
|
72
|
+
*/
|
|
73
|
+
function loadBorderFromTemplate(filename) {
|
|
74
|
+
try {
|
|
75
|
+
const template = JSON.parse(readFileSync(path.join(TEMPLATES_DIR, filename), 'utf8'));
|
|
76
|
+
const colorGrid = template.color;
|
|
77
|
+
if (!Array.isArray(colorGrid)) return [];
|
|
78
|
+
const border = [];
|
|
79
|
+
for (let y = 0; y < colorGrid.length; y++) {
|
|
80
|
+
const row = colorGrid[y];
|
|
81
|
+
if (!Array.isArray(row)) continue;
|
|
82
|
+
for (let x = 0; x < row.length; x++) {
|
|
83
|
+
if (row[x] === '#000000' && x < SKIN_GRID_DIM && y < SKIN_GRID_DIM) {
|
|
84
|
+
border.push([x, y]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return border;
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Raw template pixel lists keyed by direction and zone name.
|
|
96
|
+
* Coordinates are [x, y] pairs where (0,0) is the top-left cell of a
|
|
97
|
+
* SKIN_GRID_DIM × SKIN_GRID_DIM (24 × 24) grid.
|
|
98
|
+
*
|
|
99
|
+
* @type {{ down: Object, left: Object }}
|
|
100
|
+
*/
|
|
101
|
+
const RAW = {
|
|
102
|
+
down: {
|
|
103
|
+
/** Full body silhouette – face, torso, arms, legs */
|
|
104
|
+
skin: loadTemplate('item-skin-style-skin-08.json'),
|
|
105
|
+
/** Shirt / breastplate zone */
|
|
106
|
+
shirt: loadTemplate('item-skin-style-breastplate-08.json'),
|
|
107
|
+
/** Legs / pants zone (includes shoe row) */
|
|
108
|
+
legs: loadTemplate('item-skin-style-legs-08.json'),
|
|
109
|
+
/** Hair – direction-agnostic crown */
|
|
110
|
+
hair: loadTemplate('item-skin-style-hair.json'),
|
|
111
|
+
/** Black outline border from full template color grid */
|
|
112
|
+
border: loadBorderFromTemplate('item-skin-08.json'),
|
|
113
|
+
},
|
|
114
|
+
left: {
|
|
115
|
+
skin: loadTemplate('item-skin-style-skin-06.json'),
|
|
116
|
+
shirt: loadTemplate('item-skin-style-breastplate-06.json'),
|
|
117
|
+
legs: loadTemplate('item-skin-style-legs-06.json'),
|
|
118
|
+
hair: loadTemplate('item-skin-style-hair.json'),
|
|
119
|
+
border: loadBorderFromTemplate('item-skin-06.json'),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Splits the legs template into pants (y < SHOE_Y_MIN) and shoes (y >= SHOE_Y_MIN).
|
|
125
|
+
* @param {number[][]} legsCoords
|
|
126
|
+
* @returns {{ pants: number[][], shoes: number[][] }}
|
|
127
|
+
*/
|
|
128
|
+
function splitLegs(legsCoords) {
|
|
129
|
+
const pants = [];
|
|
130
|
+
const shoes = [];
|
|
131
|
+
for (const [x, y] of legsCoords) {
|
|
132
|
+
if (y >= SHOE_Y_MIN) shoes.push([x, y]);
|
|
133
|
+
else pants.push([x, y]);
|
|
134
|
+
}
|
|
135
|
+
return { pants, shoes };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Mirrors a pixel list horizontally so a left-facing sprite becomes right-facing.
|
|
140
|
+
* Uses the reference width 26 (template grid): x_new = 25 − x.
|
|
141
|
+
* All resulting coordinates remain within 0..23.
|
|
142
|
+
* @param {number[][]} coords
|
|
143
|
+
* @returns {number[][]}
|
|
144
|
+
*/
|
|
145
|
+
function mirrorH(coords) {
|
|
146
|
+
return coords
|
|
147
|
+
.map(([x, y]) => [25 - x, y])
|
|
148
|
+
.filter(([x, y]) => x >= 0 && x < SKIN_GRID_DIM && y >= 0 && y < SKIN_GRID_DIM);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Per-direction pixel zone definitions.
|
|
153
|
+
* Shoes are separated from pants; hair and skin are provided per direction.
|
|
154
|
+
*
|
|
155
|
+
* @typedef {{ skin: number[][], shirt: number[][], pants: number[][], shoes: number[][], hair: number[][] }} DirectionZones
|
|
156
|
+
* @type {{ down: DirectionZones, left: DirectionZones, right: DirectionZones, up: Object }}
|
|
157
|
+
*/
|
|
158
|
+
const ZONES = (() => {
|
|
159
|
+
const down = (() => {
|
|
160
|
+
const { pants, shoes } = splitLegs(RAW.down.legs);
|
|
161
|
+
return { skin: RAW.down.skin, shirt: RAW.down.shirt, pants, shoes, hair: RAW.down.hair, border: RAW.down.border };
|
|
162
|
+
})();
|
|
163
|
+
|
|
164
|
+
const left = (() => {
|
|
165
|
+
const { pants, shoes } = splitLegs(RAW.left.legs);
|
|
166
|
+
return { skin: RAW.left.skin, shirt: RAW.left.shirt, pants, shoes, hair: RAW.left.hair, border: RAW.left.border };
|
|
167
|
+
})();
|
|
168
|
+
|
|
169
|
+
const right = {
|
|
170
|
+
skin: mirrorH(left.skin),
|
|
171
|
+
shirt: mirrorH(left.shirt),
|
|
172
|
+
pants: mirrorH(left.pants),
|
|
173
|
+
shoes: mirrorH(left.shoes),
|
|
174
|
+
hair: mirrorH(left.hair),
|
|
175
|
+
border: mirrorH(left.border),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// UP: same body layout as DOWN but head pixels (y <= HEAD_Y_MAX) are painted
|
|
179
|
+
// with hair color (back of character's head), body/arms remain skin colored.
|
|
180
|
+
// Reuse down zones; we flag this dynamically in buildDirectionMatrix.
|
|
181
|
+
const up = { ...down, isUpDirection: true };
|
|
182
|
+
|
|
183
|
+
return { down, left, right, up };
|
|
184
|
+
})();
|
|
185
|
+
|
|
186
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
187
|
+
* COLOUR UTILITIES
|
|
188
|
+
* ═══════════════════════════════════════════════════════════════════════════ */
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Mini LCG-based RNG (32-bit seed). Avoids importing from the parent module.
|
|
192
|
+
* @param {number} seed
|
|
193
|
+
* @returns {function(): number} RNG returning floats in [0, 1).
|
|
194
|
+
*/
|
|
195
|
+
function lcgRng(seed) {
|
|
196
|
+
let s = seed >>> 0;
|
|
197
|
+
return () => {
|
|
198
|
+
s = (Math.imul(1664525, s) + 1013904223) >>> 0;
|
|
199
|
+
return s / 4294967296;
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Simple string hash → 32-bit unsigned integer.
|
|
205
|
+
* @param {string} str
|
|
206
|
+
* @returns {number}
|
|
207
|
+
*/
|
|
208
|
+
function hashStr(str) {
|
|
209
|
+
let h = 0;
|
|
210
|
+
for (let i = 0; i < str.length; i++) h = (Math.imul(31, h) + str.charCodeAt(i)) | 0;
|
|
211
|
+
return h >>> 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* HSL colour to [r, g, b] (each 0–255).
|
|
216
|
+
* @param {number} h Hue in [0, 1).
|
|
217
|
+
* @param {number} s Saturation in [0, 1].
|
|
218
|
+
* @param {number} l Lightness in [0, 1].
|
|
219
|
+
* @returns {number[]}
|
|
220
|
+
*/
|
|
221
|
+
function hslToRgb(h, s, l) {
|
|
222
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
223
|
+
const p = 2 * l - q;
|
|
224
|
+
const hue2rgb = (x) => {
|
|
225
|
+
const t = ((x % 1) + 1) % 1;
|
|
226
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
227
|
+
if (t < 1 / 2) return q;
|
|
228
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
229
|
+
return p;
|
|
230
|
+
};
|
|
231
|
+
return [Math.round(hue2rgb(h + 1 / 3) * 255), Math.round(hue2rgb(h) * 255), Math.round(hue2rgb(h - 1 / 3) * 255)];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Clamp v to [lo, hi]. */
|
|
235
|
+
function clamp(v, lo, hi) {
|
|
236
|
+
return v < lo ? lo : v > hi ? hi : v;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
240
|
+
* PALETTE DERIVATION
|
|
241
|
+
* All colours are deterministic from (seed, itemId).
|
|
242
|
+
* ═══════════════════════════════════════════════════════════════════════════ */
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @typedef {Object} SkinPalette
|
|
246
|
+
* @property {number[]} skinColor RGBA – face / hands / neck
|
|
247
|
+
* @property {number[]} hairColor RGBA – hair
|
|
248
|
+
* @property {number[]} shirtColor RGBA – shirt / breastplate
|
|
249
|
+
* @property {number[]} pantsColor RGBA – pants / legs
|
|
250
|
+
* @property {number[]} shoeColor RGBA – shoes / boots
|
|
251
|
+
* @property {number} hairDepth Max Y row (inclusive) covered by hair; controls hair length.
|
|
252
|
+
*/
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Derives a fully deterministic 5-colour palette for a skin variant.
|
|
256
|
+
*
|
|
257
|
+
* @param {string} seed
|
|
258
|
+
* @param {string} itemId
|
|
259
|
+
* @param {'random'|'dark'|'light'|'vivid'|'natural'|'shaved'} [subtype='random']
|
|
260
|
+
* @returns {SkinPalette}
|
|
261
|
+
*/
|
|
262
|
+
function deriveSkinPalette(seed, itemId, subtype = 'random') {
|
|
263
|
+
const rng = lcgRng(hashStr(`${seed}:${itemId}:skin-palette`));
|
|
264
|
+
|
|
265
|
+
/* ── Skin tones: dark brown → light peach ───────────────────────────── */
|
|
266
|
+
const allSkinTones = [
|
|
267
|
+
[38, 22, 14], // near-black
|
|
268
|
+
[72, 44, 28], // very dark
|
|
269
|
+
[110, 68, 44], // dark
|
|
270
|
+
[152, 100, 66], // medium
|
|
271
|
+
[186, 134, 94], // warm mid
|
|
272
|
+
[214, 170, 130], // light
|
|
273
|
+
[232, 196, 164], // very light
|
|
274
|
+
[248, 220, 196], // pale
|
|
275
|
+
];
|
|
276
|
+
// Constrain tone pool by subtype
|
|
277
|
+
const skinPool =
|
|
278
|
+
subtype === 'dark' ? allSkinTones.slice(0, 3) : subtype === 'light' ? allSkinTones.slice(5) : allSkinTones;
|
|
279
|
+
const skinRgb = skinPool[Math.floor(rng() * skinPool.length)];
|
|
280
|
+
|
|
281
|
+
/* ── Hair: natural shades + vivid options ───────────────────────────── */
|
|
282
|
+
const naturalHair = [
|
|
283
|
+
[18, 12, 8], // near-black
|
|
284
|
+
[55, 32, 14], // very dark brown
|
|
285
|
+
[95, 56, 22], // dark brown
|
|
286
|
+
[158, 108, 42], // medium brown
|
|
287
|
+
[194, 156, 60], // blond
|
|
288
|
+
[210, 90, 36], // auburn / ginger
|
|
289
|
+
[36, 36, 36], // dark grey
|
|
290
|
+
[140, 140, 140], // grey
|
|
291
|
+
[230, 230, 230], // white
|
|
292
|
+
];
|
|
293
|
+
const vividHair = [
|
|
294
|
+
[168, 22, 22], // vivid red
|
|
295
|
+
[22, 72, 210], // vivid blue
|
|
296
|
+
[20, 180, 76], // vivid green
|
|
297
|
+
[160, 22, 178], // vivid purple
|
|
298
|
+
[210, 168, 22], // golden
|
|
299
|
+
[22, 200, 200], // vivid cyan
|
|
300
|
+
[220, 60, 160], // hot pink
|
|
301
|
+
];
|
|
302
|
+
const allHairPresets = [...naturalHair, ...vividHair];
|
|
303
|
+
const hairPool = subtype === 'vivid' ? vividHair : subtype === 'natural' ? naturalHair : allHairPresets;
|
|
304
|
+
const hairRgb = hairPool[Math.floor(rng() * hairPool.length)];
|
|
305
|
+
|
|
306
|
+
/* ── Clothing: random hue, reasonable saturation/lightness ─────────── */
|
|
307
|
+
const shirtRgb = hslToRgb(rng(), 0.6 + rng() * 0.35, 0.32 + rng() * 0.28);
|
|
308
|
+
const pantsRgb = hslToRgb(rng(), 0.55 + rng() * 0.4, 0.2 + rng() * 0.3);
|
|
309
|
+
|
|
310
|
+
/* Shoes tend to be darker (lower lightness) */
|
|
311
|
+
const shoeH = rng();
|
|
312
|
+
const shoeRgb = hslToRgb(shoeH, 0.35 + rng() * 0.4, 0.12 + rng() * 0.2);
|
|
313
|
+
|
|
314
|
+
/* ── Hair depth: controls how far down the hair goes on the head ───── *
|
|
315
|
+
* 0 → shaved (no hair at all — bald silhouette) *
|
|
316
|
+
* 5 – 11 → short crop to long flowing hair */
|
|
317
|
+
const hairDepthOptions = subtype === 'shaved' ? [0] : [5, 6, 7, 8, 9, 10, 11];
|
|
318
|
+
const hairDepth = hairDepthOptions[Math.floor(rng() * hairDepthOptions.length)];
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
skinColor: [...skinRgb, 255],
|
|
322
|
+
hairColor: [...hairRgb, 255],
|
|
323
|
+
shirtColor: [...shirtRgb, 255],
|
|
324
|
+
pantsColor: [...pantsRgb, 255],
|
|
325
|
+
shoeColor: [...shoeRgb, 255],
|
|
326
|
+
hairDepth,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
331
|
+
* FRAME MATRIX BUILDER
|
|
332
|
+
* ═══════════════════════════════════════════════════════════════════════════ */
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Returns the index of `rgba` in `globalColors`, adding it if absent.
|
|
336
|
+
* @param {number[][]} globalColors
|
|
337
|
+
* @param {number[]} rgba
|
|
338
|
+
* @returns {number}
|
|
339
|
+
*/
|
|
340
|
+
function getOrAddColor(globalColors, rgba) {
|
|
341
|
+
const idx = globalColors.findIndex(
|
|
342
|
+
(c) => c[0] === rgba[0] && c[1] === rgba[1] && c[2] === rgba[2] && c[3] === rgba[3],
|
|
343
|
+
);
|
|
344
|
+
if (idx >= 0) return idx;
|
|
345
|
+
globalColors.push([...rgba]);
|
|
346
|
+
return globalColors.length - 1;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Paints a list of [x,y] coordinates onto a frame matrix with a color index.
|
|
351
|
+
* Skips coordinates outside SKIN_GRID_DIM.
|
|
352
|
+
* @param {number[][]} matrix
|
|
353
|
+
* @param {number[][]} coords
|
|
354
|
+
* @param {number} colorIdx
|
|
355
|
+
*/
|
|
356
|
+
function paint(matrix, coords, colorIdx) {
|
|
357
|
+
for (const [x, y] of coords) {
|
|
358
|
+
if (x >= 0 && x < SKIN_GRID_DIM && y >= 0 && y < SKIN_GRID_DIM) {
|
|
359
|
+
matrix[y][x] = colorIdx;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Builds a 24 × 24 frame matrix for DOWN, LEFT, or RIGHT directions.
|
|
366
|
+
*
|
|
367
|
+
* Hair is derived from interior skin pixels at y <= palette.hairDepth.
|
|
368
|
+
* hairDepth === 0 means shaved head — all hair steps are skipped.
|
|
369
|
+
*
|
|
370
|
+
* Non-shaved heads get:
|
|
371
|
+
* 2b. Black hairline at y = hairDepth+1 with 2–4 bang wisps punching through.
|
|
372
|
+
* 2c. Side hair extension: narrow strips hang from the crown's left/right
|
|
373
|
+
* edges for hairExtend = hairDepth − 3 rows (same formula as UP's
|
|
374
|
+
* back-extension), ensuring visual hair-length consistency across all 4
|
|
375
|
+
* directions.
|
|
376
|
+
*
|
|
377
|
+
* @param {DirectionZones} zones
|
|
378
|
+
* @param {SkinPalette} palette
|
|
379
|
+
* @param {number[][]} globalColors Mutated in place to accumulate unique colours.
|
|
380
|
+
* @param {string} seed
|
|
381
|
+
* @param {string} itemId
|
|
382
|
+
* @param {string} dirLabel 'down' | 'left' | 'right' — differentiates RNG streams.
|
|
383
|
+
* @returns {number[][]}
|
|
384
|
+
*/
|
|
385
|
+
function buildDirectionMatrix(zones, palette, globalColors, seed, itemId, dirLabel) {
|
|
386
|
+
const matrix = Array.from({ length: SKIN_GRID_DIM }, () => Array(SKIN_GRID_DIM).fill(0));
|
|
387
|
+
|
|
388
|
+
const skinIdx = getOrAddColor(globalColors, palette.skinColor);
|
|
389
|
+
const hairIdx = getOrAddColor(globalColors, palette.hairColor);
|
|
390
|
+
const shirtIdx = getOrAddColor(globalColors, palette.shirtColor);
|
|
391
|
+
const pantsIdx = getOrAddColor(globalColors, palette.pantsColor);
|
|
392
|
+
const shoeIdx = getOrAddColor(globalColors, palette.shoeColor);
|
|
393
|
+
|
|
394
|
+
// Sets for fast per-pixel lookup
|
|
395
|
+
const borderSet = new Set(zones.border.map(([x, y]) => `${x},${y}`));
|
|
396
|
+
const skinSet = new Set(zones.skin.map(([x, y]) => `${x},${y}`));
|
|
397
|
+
|
|
398
|
+
// 1. Full body silhouette → skin tone
|
|
399
|
+
paint(matrix, zones.skin, skinIdx);
|
|
400
|
+
|
|
401
|
+
// Compute hair pixels and per-row bounding boxes once — shared by steps 2b and 2c.
|
|
402
|
+
// hairDepth === 0 → shaved head, all hair steps are skipped.
|
|
403
|
+
const hairPixels =
|
|
404
|
+
palette.hairDepth > 0 ? zones.skin.filter(([x, y]) => y <= palette.hairDepth && !borderSet.has(`${x},${y}`)) : [];
|
|
405
|
+
|
|
406
|
+
const hairRowBounds = new Map();
|
|
407
|
+
for (const [x, y] of hairPixels) {
|
|
408
|
+
const b = hairRowBounds.get(y) || { min: x, max: x };
|
|
409
|
+
b.min = Math.min(b.min, x);
|
|
410
|
+
b.max = Math.max(b.max, x);
|
|
411
|
+
hairRowBounds.set(y, b);
|
|
412
|
+
}
|
|
413
|
+
const hairRows = [...hairRowBounds.keys()].sort((a, b) => a - b);
|
|
414
|
+
|
|
415
|
+
if (palette.hairDepth > 0) {
|
|
416
|
+
// 2. Hair fill
|
|
417
|
+
paint(matrix, hairPixels, hairIdx);
|
|
418
|
+
|
|
419
|
+
const blackIdx = getOrAddColor(globalColors, [0, 0, 0, 255]);
|
|
420
|
+
|
|
421
|
+
// 2b. Black hairline + bang wisps at the fringe (bottom of hair zone).
|
|
422
|
+
if (hairRows.length > 0) {
|
|
423
|
+
const rngFringe = lcgRng(hashStr(`${seed}:${itemId}:fringe-${dirLabel}`));
|
|
424
|
+
const bottomY = hairRows[hairRows.length - 1];
|
|
425
|
+
const { min: fMin, max: fMax } = hairRowBounds.get(bottomY);
|
|
426
|
+
|
|
427
|
+
// Build 2–4 random bang-wisp columns
|
|
428
|
+
const fringeCols = [];
|
|
429
|
+
for (let x = fMin; x <= fMax; x++) fringeCols.push(x);
|
|
430
|
+
const numWisps = 2 + Math.floor(rngFringe() * 3);
|
|
431
|
+
const wispCols = new Set();
|
|
432
|
+
for (let i = 0; i < numWisps; i++) {
|
|
433
|
+
const wx = fringeCols[Math.floor(rngFringe() * fringeCols.length)];
|
|
434
|
+
wispCols.add(wx);
|
|
435
|
+
const wLen = 1 + Math.floor(rngFringe() * 2);
|
|
436
|
+
for (let dy = 1; dy <= wLen; dy++) {
|
|
437
|
+
const wy = bottomY + dy;
|
|
438
|
+
if (wy < SKIN_GRID_DIM && wy <= HEAD_Y_MAX && skinSet.has(`${wx},${wy}`) && !borderSet.has(`${wx},${wy}`)) {
|
|
439
|
+
matrix[wy][wx] = hairIdx;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const tipY = bottomY + wLen + 1;
|
|
443
|
+
if (
|
|
444
|
+
tipY < SKIN_GRID_DIM &&
|
|
445
|
+
tipY <= HEAD_Y_MAX &&
|
|
446
|
+
skinSet.has(`${wx},${tipY}`) &&
|
|
447
|
+
!borderSet.has(`${wx},${tipY}`)
|
|
448
|
+
) {
|
|
449
|
+
matrix[tipY][wx] = blackIdx;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Black hairline for non-wisp fringe columns
|
|
454
|
+
const fringeY = bottomY + 1;
|
|
455
|
+
if (fringeY < SKIN_GRID_DIM) {
|
|
456
|
+
for (let x = fMin; x <= fMax; x++) {
|
|
457
|
+
if (!wispCols.has(x) && skinSet.has(`${x},${fringeY}`) && !borderSet.has(`${x},${fringeY}`)) {
|
|
458
|
+
matrix[fringeY][x] = blackIdx;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 2c. SIDE HAIR STRANDS — 1-px strands at each temple, anchored at the
|
|
465
|
+
// outermost border columns of the head at HEAD_Y_MAX (ear level).
|
|
466
|
+
// Fixed anchor means position is independent of hairDepth, giving a
|
|
467
|
+
// consistent result across all seeds and templates.
|
|
468
|
+
//
|
|
469
|
+
// Length: 1–3 rows (hairDepth 5→1, 6→2, 7+→3).
|
|
470
|
+
// Width: 1 px by default; 30 % chance of 2 px per row for subtle
|
|
471
|
+
// seed-based variation. No inward drift.
|
|
472
|
+
{
|
|
473
|
+
const rngWisp = lcgRng(hashStr(`${seed}:${itemId}:outer-wisp-${dirLabel}`));
|
|
474
|
+
const strandRows = palette.hairDepth >= 5 ? Math.min(palette.hairDepth - 4, 3) : 0;
|
|
475
|
+
|
|
476
|
+
if (strandRows > 0) {
|
|
477
|
+
const borderAtHead = zones.border.filter(([, y]) => y === HEAD_Y_MAX);
|
|
478
|
+
if (borderAtHead.length >= 2) {
|
|
479
|
+
const hbL = Math.min(...borderAtHead.map(([x]) => x));
|
|
480
|
+
const hbR = Math.max(...borderAtHead.map(([x]) => x));
|
|
481
|
+
|
|
482
|
+
let lastLx = null,
|
|
483
|
+
lastRx = null;
|
|
484
|
+
|
|
485
|
+
for (let row = 0; row < strandRows; row++) {
|
|
486
|
+
const y = HEAD_Y_MAX + 1 + row; // just below ear level
|
|
487
|
+
if (y >= SKIN_GRID_DIM) break;
|
|
488
|
+
const ext = rngWisp() < 0.3 ? 2 : 1;
|
|
489
|
+
|
|
490
|
+
const lMin = Math.max(0, hbL - ext);
|
|
491
|
+
const rMax = Math.min(SKIN_GRID_DIM - 1, hbR + ext);
|
|
492
|
+
|
|
493
|
+
// Left strand: pixels outside the body to the left of hbL
|
|
494
|
+
for (let x = lMin; x < hbL; x++) matrix[y][x] = hairIdx;
|
|
495
|
+
// Right strand: pixels outside the body to the right of hbR
|
|
496
|
+
for (let x = hbR + 1; x <= rMax; x++) matrix[y][x] = hairIdx;
|
|
497
|
+
|
|
498
|
+
if (lMin > 0 && matrix[y][lMin - 1] !== hairIdx) matrix[y][lMin - 1] = blackIdx;
|
|
499
|
+
if (rMax < SKIN_GRID_DIM - 1 && matrix[y][rMax + 1] !== hairIdx) matrix[y][rMax + 1] = blackIdx;
|
|
500
|
+
|
|
501
|
+
lastLx = lMin;
|
|
502
|
+
lastRx = rMax;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const tipY = HEAD_Y_MAX + 1 + strandRows;
|
|
506
|
+
if (tipY < SKIN_GRID_DIM) {
|
|
507
|
+
if (lastLx !== null) matrix[tipY][Math.max(0, lastLx)] = blackIdx;
|
|
508
|
+
if (lastRx !== null) matrix[tipY][Math.min(SKIN_GRID_DIM - 1, lastRx)] = blackIdx;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// 2d. UPPER SIDE HAIR ARCH — thick arch on each temple anchored at y=10.
|
|
515
|
+
// The arch is widest at its crown (y=10, 4-5 px outward from the border)
|
|
516
|
+
// and tapers toward the ear base, creating a visible sideburn arc.
|
|
517
|
+
// 3 rows max (hairDepth 5→1 row, 6→2 rows, 7+→3 rows).
|
|
518
|
+
// Together with the lower 2c strands the pair forms a broken arc shape.
|
|
519
|
+
{
|
|
520
|
+
const UPPER_ANCHOR_Y = HEAD_Y_MAX - 2; // y=10
|
|
521
|
+
const rngWisp2 = lcgRng(hashStr(`${seed}:${itemId}:outer-wisp2-${dirLabel}`));
|
|
522
|
+
const strandRows2 = palette.hairDepth >= 5 ? Math.min(palette.hairDepth - 4, 3) : 0;
|
|
523
|
+
|
|
524
|
+
if (strandRows2 > 0) {
|
|
525
|
+
const borderAtUpper = zones.border.filter(([, y]) => y === UPPER_ANCHOR_Y);
|
|
526
|
+
if (borderAtUpper.length >= 2) {
|
|
527
|
+
const hbL2 = Math.min(...borderAtUpper.map(([x]) => x));
|
|
528
|
+
const hbR2 = Math.max(...borderAtUpper.map(([x]) => x));
|
|
529
|
+
|
|
530
|
+
// Arch widths per row: narrow at crown (y=10), widest at ear base (y=12).
|
|
531
|
+
// Follows the head silhouette arc — wider where the head is wider.
|
|
532
|
+
const extBase = [2, 3, 4];
|
|
533
|
+
let lastLx2 = null,
|
|
534
|
+
lastRx2 = null;
|
|
535
|
+
|
|
536
|
+
for (let row = 0; row < strandRows2; row++) {
|
|
537
|
+
const y = UPPER_ANCHOR_Y + row; // y=10, y=11, y=12
|
|
538
|
+
if (y >= SKIN_GRID_DIM) break;
|
|
539
|
+
const ext2 = extBase[row] + (rngWisp2() < 0.4 ? 1 : 0); // 40 % +1 bonus
|
|
540
|
+
|
|
541
|
+
const innerL2 = hbL2 + 2; // 2 px toward center
|
|
542
|
+
const innerR2 = hbR2 - 2;
|
|
543
|
+
const lMin2 = Math.max(0, innerL2 - ext2);
|
|
544
|
+
const rMax2 = Math.min(SKIN_GRID_DIM - 1, innerR2 + ext2);
|
|
545
|
+
|
|
546
|
+
for (let x = lMin2; x < innerL2; x++) matrix[y][x] = hairIdx;
|
|
547
|
+
for (let x = innerR2 + 1; x <= rMax2; x++) matrix[y][x] = hairIdx;
|
|
548
|
+
|
|
549
|
+
if (lMin2 > 0 && matrix[y][lMin2 - 1] !== hairIdx) matrix[y][lMin2 - 1] = blackIdx;
|
|
550
|
+
if (rMax2 < SKIN_GRID_DIM - 1 && matrix[y][rMax2 + 1] !== hairIdx) matrix[y][rMax2 + 1] = blackIdx;
|
|
551
|
+
|
|
552
|
+
lastLx2 = lMin2;
|
|
553
|
+
lastRx2 = rMax2;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const tipY2 = UPPER_ANCHOR_Y + strandRows2; // first row after arch
|
|
557
|
+
if (tipY2 < SKIN_GRID_DIM) {
|
|
558
|
+
if (lastLx2 !== null && matrix[tipY2][Math.max(0, lastLx2)] !== hairIdx)
|
|
559
|
+
matrix[tipY2][Math.max(0, lastLx2)] = blackIdx;
|
|
560
|
+
if (lastRx2 !== null && matrix[tipY2][Math.min(SKIN_GRID_DIM - 1, lastRx2)] !== hairIdx)
|
|
561
|
+
matrix[tipY2][Math.min(SKIN_GRID_DIM - 1, lastRx2)] = blackIdx;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// 2e. HAIR EDGE DISTORTIONS — 5–9 stray hair pixels scattered along the
|
|
568
|
+
// outer border of the hair zone for an organic, hand-drawn look.
|
|
569
|
+
// Each pixel sits one step outside the body silhouette and is closed
|
|
570
|
+
// with a black tip pixel for pixel-art definition.
|
|
571
|
+
{
|
|
572
|
+
const rngDist = lcgRng(hashStr(`${seed}:${itemId}:hair-distort-${dirLabel}`));
|
|
573
|
+
// Collect the outermost border column per hair row.
|
|
574
|
+
const borderInHair = zones.border.filter(([, y]) => y <= palette.hairDepth);
|
|
575
|
+
const bhrMap = new Map();
|
|
576
|
+
for (const [x, y] of borderInHair) {
|
|
577
|
+
const b = bhrMap.get(y) || { min: x, max: x };
|
|
578
|
+
b.min = Math.min(b.min, x);
|
|
579
|
+
b.max = Math.max(b.max, x);
|
|
580
|
+
bhrMap.set(y, b);
|
|
581
|
+
}
|
|
582
|
+
const bhrRows = [...bhrMap.keys()];
|
|
583
|
+
const numDistort = 5 + Math.floor(rngDist() * 5); // 5–9
|
|
584
|
+
for (let i = 0; i < numDistort; i++) {
|
|
585
|
+
const y = bhrRows[Math.floor(rngDist() * bhrRows.length)];
|
|
586
|
+
const bnd = bhrMap.get(y);
|
|
587
|
+
if (!bnd) continue;
|
|
588
|
+
const side = rngDist() < 0.5 ? -1 : 1;
|
|
589
|
+
const px = side < 0 ? bnd.min - 1 : bnd.max + 1;
|
|
590
|
+
if (px >= 0 && px < SKIN_GRID_DIM && matrix[y][px] !== hairIdx) {
|
|
591
|
+
matrix[y][px] = hairIdx;
|
|
592
|
+
const bx = px + side;
|
|
593
|
+
if (bx >= 0 && bx < SKIN_GRID_DIM && matrix[y][bx] !== hairIdx) matrix[y][bx] = blackIdx;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// 2f. MID-CROWN SIDE HAIR ARC — 7-row arc of hair strands following the
|
|
599
|
+
// head edge, centered at y=7 (crown shoulders). Right strand anchors
|
|
600
|
+
// near x=8 (character right), left mirrors it at x≈15. Width peaks
|
|
601
|
+
// at the center row (3 px outward) and tapers to 1 px at the extremes.
|
|
602
|
+
// Per-row 40 % width bonus + 30 % extra distortion pixel per side.
|
|
603
|
+
{
|
|
604
|
+
const CROWN_Y = 7;
|
|
605
|
+
const crownW = [3, 2, 2, 1]; // base width at |dy| = 0, 1, 2, 3
|
|
606
|
+
const rngCrown = lcgRng(hashStr(`${seed}:${itemId}:crown-side-${dirLabel}`));
|
|
607
|
+
|
|
608
|
+
for (let dy = -3; dy <= 3; dy++) {
|
|
609
|
+
const y = CROWN_Y + dy;
|
|
610
|
+
if (y < 0 || y >= SKIN_GRID_DIM) continue;
|
|
611
|
+
|
|
612
|
+
const borderAtY = zones.border.filter(([, by]) => by === y);
|
|
613
|
+
if (borderAtY.length < 2) continue;
|
|
614
|
+
const hbL = Math.min(...borderAtY.map(([x]) => x));
|
|
615
|
+
const hbR = Math.max(...borderAtY.map(([x]) => x));
|
|
616
|
+
|
|
617
|
+
const ext = crownW[Math.abs(dy)] + (rngCrown() < 0.4 ? 1 : 0);
|
|
618
|
+
|
|
619
|
+
// Left strand (character right, viewer left)
|
|
620
|
+
const lMin = Math.max(0, hbL - ext);
|
|
621
|
+
for (let x = lMin; x < hbL; x++) matrix[y][x] = hairIdx;
|
|
622
|
+
if (lMin > 0 && matrix[y][lMin - 1] !== hairIdx) matrix[y][lMin - 1] = blackIdx;
|
|
623
|
+
|
|
624
|
+
// Right strand (character left, viewer right)
|
|
625
|
+
const rMax = Math.min(SKIN_GRID_DIM - 1, hbR + ext);
|
|
626
|
+
for (let x = hbR + 1; x <= rMax; x++) matrix[y][x] = hairIdx;
|
|
627
|
+
if (rMax < SKIN_GRID_DIM - 1 && matrix[y][rMax + 1] !== hairIdx) matrix[y][rMax + 1] = blackIdx;
|
|
628
|
+
|
|
629
|
+
// Distortion: 30 % chance of one extra pixel per side
|
|
630
|
+
if (rngCrown() < 0.3) {
|
|
631
|
+
const px = lMin - 1;
|
|
632
|
+
if (px >= 0 && matrix[y][px] !== hairIdx) {
|
|
633
|
+
matrix[y][px] = hairIdx;
|
|
634
|
+
if (px > 0 && matrix[y][px - 1] !== hairIdx) matrix[y][px - 1] = blackIdx;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (rngCrown() < 0.3) {
|
|
638
|
+
const px = rMax + 1;
|
|
639
|
+
if (px < SKIN_GRID_DIM && matrix[y][px] !== hairIdx) {
|
|
640
|
+
matrix[y][px] = hairIdx;
|
|
641
|
+
if (px < SKIN_GRID_DIM - 1 && matrix[y][px + 1] !== hairIdx) matrix[y][px + 1] = blackIdx;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// 3. Clothes
|
|
649
|
+
paint(matrix, zones.shirt, shirtIdx);
|
|
650
|
+
paint(matrix, zones.pants, pantsIdx);
|
|
651
|
+
paint(matrix, zones.shoes, shoeIdx);
|
|
652
|
+
|
|
653
|
+
// 4. Black border outline — always last
|
|
654
|
+
const borderIdx = getOrAddColor(globalColors, [0, 0, 0, 255]);
|
|
655
|
+
paint(matrix, zones.border, borderIdx);
|
|
656
|
+
|
|
657
|
+
// 4b. CROWN BORDER SOFTENING — for non-shaved skins, replace most outer
|
|
658
|
+
// silhouette border pixels within the hair zone with hair colour so the
|
|
659
|
+
// crown blends into the background rather than having a hard black outline.
|
|
660
|
+
// Interior features (eyes, pupils) are never touched — only outer-silhouette
|
|
661
|
+
// pixels (those with at least one empty 8-neighbour) are candidates.
|
|
662
|
+
// ~25 % of candidates are kept black for pixel-art definition.
|
|
663
|
+
if (palette.hairDepth > 0) {
|
|
664
|
+
const rngBorder = lcgRng(hashStr(`${seed}:${itemId}:soften-border-${dirLabel}`));
|
|
665
|
+
for (const [bx, by] of zones.border) {
|
|
666
|
+
if (by > palette.hairDepth) continue;
|
|
667
|
+
let isOuter = false;
|
|
668
|
+
for (let dx = -1; dx <= 1 && !isOuter; dx++) {
|
|
669
|
+
for (let dy = -1; dy <= 1 && !isOuter; dy++) {
|
|
670
|
+
if (dx === 0 && dy === 0) continue;
|
|
671
|
+
const nx = bx + dx, ny = by + dy;
|
|
672
|
+
if (nx < 0 || nx >= SKIN_GRID_DIM || ny < 0 || ny >= SKIN_GRID_DIM ||
|
|
673
|
+
(!skinSet.has(`${nx},${ny}`) && !borderSet.has(`${nx},${ny}`)))
|
|
674
|
+
isOuter = true;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (isOuter && rngBorder() < 0.75) matrix[by][bx] = hairIdx;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return matrix;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Builds a 24 × 24 frame matrix for the UP (back-facing) direction.
|
|
686
|
+
*
|
|
687
|
+
* The entire head is covered with hair, eliminating face features (eyes/mouth).
|
|
688
|
+
* Only the outer silhouette border is kept black; inner face-feature pixels
|
|
689
|
+
* (pupils, mouth line) are overwritten with hair.
|
|
690
|
+
*
|
|
691
|
+
* Hair flows below the head onto the upper back with a tapering strip.
|
|
692
|
+
* Black borders frame the sides and bottom of the flowing hair.
|
|
693
|
+
* Random wisps extend 1–2 px beyond the body silhouette for dynamism.
|
|
694
|
+
*
|
|
695
|
+
* @param {DirectionZones} zones Reuses ZONES.down pixel data.
|
|
696
|
+
* @param {SkinPalette} palette
|
|
697
|
+
* @param {number[][]} globalColors Mutated in place.
|
|
698
|
+
* @param {string} seed
|
|
699
|
+
* @param {string} itemId
|
|
700
|
+
* @returns {number[][]}
|
|
701
|
+
*/
|
|
702
|
+
function buildUpDirectionMatrix(zones, palette, globalColors, seed, itemId) {
|
|
703
|
+
const matrix = Array.from({ length: SKIN_GRID_DIM }, () => Array(SKIN_GRID_DIM).fill(0));
|
|
704
|
+
|
|
705
|
+
const skinIdx = getOrAddColor(globalColors, palette.skinColor);
|
|
706
|
+
const hairIdx = getOrAddColor(globalColors, palette.hairColor);
|
|
707
|
+
const shirtIdx = getOrAddColor(globalColors, palette.shirtColor);
|
|
708
|
+
const pantsIdx = getOrAddColor(globalColors, palette.pantsColor);
|
|
709
|
+
const shoeIdx = getOrAddColor(globalColors, palette.shoeColor);
|
|
710
|
+
const blackIdx = getOrAddColor(globalColors, [0, 0, 0, 255]);
|
|
711
|
+
|
|
712
|
+
const rng = lcgRng(hashStr(`${seed}:${itemId}:up-hair`));
|
|
713
|
+
|
|
714
|
+
// All body pixels (skin + border) used for outer-silhouette detection
|
|
715
|
+
const allBodyCoords = [...zones.skin, ...zones.border];
|
|
716
|
+
const bodySet = new Set(allBodyCoords.map(([x, y]) => `${x},${y}`));
|
|
717
|
+
|
|
718
|
+
// 1. Paint full body → skin tone, then clothes, then border
|
|
719
|
+
paint(matrix, zones.skin, skinIdx);
|
|
720
|
+
paint(matrix, zones.shirt, shirtIdx);
|
|
721
|
+
paint(matrix, zones.pants, pantsIdx);
|
|
722
|
+
paint(matrix, zones.shoes, shoeIdx);
|
|
723
|
+
paint(matrix, zones.border, blackIdx);
|
|
724
|
+
|
|
725
|
+
// 1b. FACE FEATURE WIPE — the zones come from the DOWN (front-facing) template
|
|
726
|
+
// which embeds eyes, pupils and mouth as black border pixels. In the UP
|
|
727
|
+
// (back-of-head) view those pixels must not appear.
|
|
728
|
+
// Walk every border pixel in the face+neck band (y ≤ FACE_FEATURE_Y_MAX).
|
|
729
|
+
// If a pixel has ALL 8 neighbours inside bodySet it is an *interior* feature
|
|
730
|
+
// (eye outline, pupil, mouth corners) — paint it with skinIdx so it is
|
|
731
|
+
// invisible. Outer-silhouette pixels keep their black value.
|
|
732
|
+
//
|
|
733
|
+
// The mouth is at y=15 in the 26×26 template, which is ABOVE HEAD_Y_MAX=12
|
|
734
|
+
// so the subsequent scanline fill does NOT reach it — this wipe is the only
|
|
735
|
+
// place that erases it.
|
|
736
|
+
const FACE_FEATURE_Y_MAX = 17; // below collar; safe upper bound for face features
|
|
737
|
+
for (const [bx, by] of zones.border) {
|
|
738
|
+
if (by > FACE_FEATURE_Y_MAX) continue;
|
|
739
|
+
let outer = false;
|
|
740
|
+
for (let dx = -1; dx <= 1 && !outer; dx++) {
|
|
741
|
+
for (let dy = -1; dy <= 1 && !outer; dy++) {
|
|
742
|
+
if (dx === 0 && dy === 0) continue;
|
|
743
|
+
const nx = bx + dx,
|
|
744
|
+
ny = by + dy;
|
|
745
|
+
if (nx < 0 || nx >= SKIN_GRID_DIM || ny < 0 || ny >= SKIN_GRID_DIM || !bodySet.has(`${nx},${ny}`)) outer = true;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (!outer) matrix[by][bx] = skinIdx;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// 2. HEAD HAIR — two paths: shaved (hairDepth=0) or normal.
|
|
752
|
+
//
|
|
753
|
+
// SHAVED: scanline-fill the head with *skin tone* (covers inner face-feature
|
|
754
|
+
// border pixels so no eyes/mouth bleed through on the bald back-head).
|
|
755
|
+
// Repaint only the outer silhouette black. No extensions or wisps.
|
|
756
|
+
//
|
|
757
|
+
// NORMAL: scanline-fill with hair colour, keep inner pixels as hair, repaint
|
|
758
|
+
// outer silhouette black, then extend hair strip + add wisps.
|
|
759
|
+
const headBounds = new Map();
|
|
760
|
+
for (const [x, y] of allBodyCoords) {
|
|
761
|
+
if (y > HEAD_Y_MAX) continue;
|
|
762
|
+
const b = headBounds.get(y) || { min: x, max: x };
|
|
763
|
+
b.min = Math.min(b.min, x);
|
|
764
|
+
b.max = Math.max(b.max, x);
|
|
765
|
+
headBounds.set(y, b);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Shared helper: repaint outer head silhouette black; inner pixels stay as-is.
|
|
769
|
+
// A border pixel is "outer" if any 8-connected neighbour is outside bodySet.
|
|
770
|
+
const repaintOuterHeadBorder = () => {
|
|
771
|
+
for (const [bx, by] of zones.border) {
|
|
772
|
+
if (by > HEAD_Y_MAX) continue;
|
|
773
|
+
let outer = false;
|
|
774
|
+
for (let dx = -1; dx <= 1 && !outer; dx++) {
|
|
775
|
+
for (let dy = -1; dy <= 1 && !outer; dy++) {
|
|
776
|
+
if (dx === 0 && dy === 0) continue;
|
|
777
|
+
const nx = bx + dx,
|
|
778
|
+
ny = by + dy;
|
|
779
|
+
if (nx < 0 || nx >= SKIN_GRID_DIM || ny < 0 || ny >= SKIN_GRID_DIM || !bodySet.has(`${nx},${ny}`))
|
|
780
|
+
outer = true;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
if (outer) matrix[by][bx] = blackIdx;
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
if (palette.hairDepth === 0) {
|
|
788
|
+
// ── SHAVED HEAD ─────────────────────────────────────────────────────────
|
|
789
|
+
// Fill head scanlines with skin tone (eliminates any face-feature artefacts)
|
|
790
|
+
for (const [y, { min, max }] of headBounds) {
|
|
791
|
+
for (let x = min; x <= max; x++) matrix[y][x] = skinIdx;
|
|
792
|
+
}
|
|
793
|
+
repaintOuterHeadBorder();
|
|
794
|
+
// No extended hair, no wisps.
|
|
795
|
+
} else {
|
|
796
|
+
// ── NORMAL HAIR HEAD ────────────────────────────────────────────────────
|
|
797
|
+
// Scanline-fill y <= HEAD_Y_MAX with hair colour
|
|
798
|
+
for (const [y, { min, max }] of headBounds) {
|
|
799
|
+
for (let x = min; x <= max; x++) matrix[y][x] = hairIdx;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Repaint outer silhouette black; inner face-feature pixels stay as hair
|
|
803
|
+
repaintOuterHeadBorder();
|
|
804
|
+
|
|
805
|
+
// Crown border softening (UP) — same intent as 4b in buildDirectionMatrix.
|
|
806
|
+
// repaintOuterHeadBorder() just set outer head border pixels to black;
|
|
807
|
+
// randomly convert 75 % of them back to hair colour for a softer crown edge.
|
|
808
|
+
{
|
|
809
|
+
const rngBorder = lcgRng(hashStr(`${seed}:${itemId}:soften-border-up`));
|
|
810
|
+
for (const [bx, by] of zones.border) {
|
|
811
|
+
if (by > HEAD_Y_MAX) continue;
|
|
812
|
+
if (matrix[by][bx] !== blackIdx) continue; // already hair / not yet set
|
|
813
|
+
let isOuter = false;
|
|
814
|
+
for (let dx = -1; dx <= 1 && !isOuter; dx++) {
|
|
815
|
+
for (let dy = -1; dy <= 1 && !isOuter; dy++) {
|
|
816
|
+
if (dx === 0 && dy === 0) continue;
|
|
817
|
+
const nx = bx + dx, ny = by + dy;
|
|
818
|
+
if (nx < 0 || nx >= SKIN_GRID_DIM || ny < 0 || ny >= SKIN_GRID_DIM ||
|
|
819
|
+
!bodySet.has(`${nx},${ny}`)) isOuter = true;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
if (isOuter && rngBorder() < 0.75) matrix[by][bx] = hairIdx;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// 2b side. SIDE HAIR STRANDS (UP) — identical geometry to the other three
|
|
827
|
+
// directions: 1-px strands just below HEAD_Y_MAX, anchored at the
|
|
828
|
+
// head border bounds at that row.
|
|
829
|
+
{
|
|
830
|
+
const headAtMax = headBounds.get(HEAD_Y_MAX);
|
|
831
|
+
if (headAtMax && palette.hairDepth >= 5) {
|
|
832
|
+
const rngWisp = lcgRng(hashStr(`${seed}:${itemId}:outer-wisp-up`));
|
|
833
|
+
const strandRows = Math.min(palette.hairDepth - 4, 3);
|
|
834
|
+
const hbL = headAtMax.min;
|
|
835
|
+
const hbR = headAtMax.max;
|
|
836
|
+
|
|
837
|
+
let lastLx = null,
|
|
838
|
+
lastRx = null;
|
|
839
|
+
|
|
840
|
+
for (let row = 0; row < strandRows; row++) {
|
|
841
|
+
const y = HEAD_Y_MAX + 1 + row;
|
|
842
|
+
if (y >= SKIN_GRID_DIM) break;
|
|
843
|
+
const ext = rngWisp() < 0.3 ? 2 : 1;
|
|
844
|
+
|
|
845
|
+
const lMin = Math.max(0, hbL - ext);
|
|
846
|
+
const rMax = Math.min(SKIN_GRID_DIM - 1, hbR + ext);
|
|
847
|
+
|
|
848
|
+
for (let x = lMin; x < hbL; x++) matrix[y][x] = hairIdx;
|
|
849
|
+
for (let x = hbR + 1; x <= rMax; x++) matrix[y][x] = hairIdx;
|
|
850
|
+
|
|
851
|
+
if (lMin > 0 && matrix[y][lMin - 1] !== hairIdx) matrix[y][lMin - 1] = blackIdx;
|
|
852
|
+
if (rMax < SKIN_GRID_DIM - 1 && matrix[y][rMax + 1] !== hairIdx) matrix[y][rMax + 1] = blackIdx;
|
|
853
|
+
|
|
854
|
+
lastLx = lMin;
|
|
855
|
+
lastRx = rMax;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const tipY = HEAD_Y_MAX + 1 + strandRows;
|
|
859
|
+
if (tipY < SKIN_GRID_DIM) {
|
|
860
|
+
if (lastLx !== null) matrix[tipY][Math.max(0, lastLx)] = blackIdx;
|
|
861
|
+
if (lastRx !== null) matrix[tipY][Math.min(SKIN_GRID_DIM - 1, lastRx)] = blackIdx;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// 2c side. UPPER SIDE HAIR ARCH (UP) — mirrors 2d in buildDirectionMatrix;
|
|
867
|
+
// thick arch starting at y=10, widest at the crown.
|
|
868
|
+
{
|
|
869
|
+
const UPPER_ANCHOR_Y = HEAD_Y_MAX - 2; // y=10
|
|
870
|
+
const headAtUpper = headBounds.get(UPPER_ANCHOR_Y);
|
|
871
|
+
if (headAtUpper && palette.hairDepth >= 5) {
|
|
872
|
+
const rngWisp2 = lcgRng(hashStr(`${seed}:${itemId}:outer-wisp2-up`));
|
|
873
|
+
const strandRows2 = Math.min(palette.hairDepth - 4, 3);
|
|
874
|
+
const hbL2 = headAtUpper.min;
|
|
875
|
+
const hbR2 = headAtUpper.max;
|
|
876
|
+
|
|
877
|
+
const extBase = [2, 3, 4];
|
|
878
|
+
let lastLx2 = null,
|
|
879
|
+
lastRx2 = null;
|
|
880
|
+
|
|
881
|
+
for (let row = 0; row < strandRows2; row++) {
|
|
882
|
+
const y = UPPER_ANCHOR_Y + row; // y=10, y=11, y=12
|
|
883
|
+
if (y >= SKIN_GRID_DIM) break;
|
|
884
|
+
const ext2 = extBase[row] + (rngWisp2() < 0.4 ? 1 : 0);
|
|
885
|
+
|
|
886
|
+
const innerL2 = hbL2 + 2; // 2 px toward center
|
|
887
|
+
const innerR2 = hbR2 - 2;
|
|
888
|
+
const lMin2 = Math.max(0, innerL2 - ext2);
|
|
889
|
+
const rMax2 = Math.min(SKIN_GRID_DIM - 1, innerR2 + ext2);
|
|
890
|
+
|
|
891
|
+
for (let x = lMin2; x < innerL2; x++) matrix[y][x] = hairIdx;
|
|
892
|
+
for (let x = innerR2 + 1; x <= rMax2; x++) matrix[y][x] = hairIdx;
|
|
893
|
+
|
|
894
|
+
if (lMin2 > 0 && matrix[y][lMin2 - 1] !== hairIdx) matrix[y][lMin2 - 1] = blackIdx;
|
|
895
|
+
if (rMax2 < SKIN_GRID_DIM - 1 && matrix[y][rMax2 + 1] !== hairIdx) matrix[y][rMax2 + 1] = blackIdx;
|
|
896
|
+
|
|
897
|
+
lastLx2 = lMin2;
|
|
898
|
+
lastRx2 = rMax2;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const tipY2 = UPPER_ANCHOR_Y + strandRows2;
|
|
902
|
+
if (tipY2 < SKIN_GRID_DIM) {
|
|
903
|
+
if (lastLx2 !== null && matrix[tipY2][Math.max(0, lastLx2)] !== hairIdx)
|
|
904
|
+
matrix[tipY2][Math.max(0, lastLx2)] = blackIdx;
|
|
905
|
+
if (lastRx2 !== null && matrix[tipY2][Math.min(SKIN_GRID_DIM - 1, lastRx2)] !== hairIdx)
|
|
906
|
+
matrix[tipY2][Math.min(SKIN_GRID_DIM - 1, lastRx2)] = blackIdx;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// 2d. HEAD HAIR EDGE DISTORTIONS (UP) — 5–9 stray pixels along the outer
|
|
912
|
+
// head silhouette for organic texture. Identical approach to 2e in
|
|
913
|
+
// buildDirectionMatrix, using headBounds for column references.
|
|
914
|
+
{
|
|
915
|
+
const rngDist = lcgRng(hashStr(`${seed}:${itemId}:hair-distort-up`));
|
|
916
|
+
const hbKeys = [...headBounds.keys()];
|
|
917
|
+
const numDistort = 5 + Math.floor(rngDist() * 5);
|
|
918
|
+
for (let i = 0; i < numDistort; i++) {
|
|
919
|
+
const y = hbKeys[Math.floor(rngDist() * hbKeys.length)];
|
|
920
|
+
const bnd = headBounds.get(y);
|
|
921
|
+
if (!bnd) continue;
|
|
922
|
+
const side = rngDist() < 0.5 ? -1 : 1;
|
|
923
|
+
const px = side < 0 ? bnd.min - 1 : bnd.max + 1;
|
|
924
|
+
if (px >= 0 && px < SKIN_GRID_DIM && matrix[y][px] !== hairIdx) {
|
|
925
|
+
matrix[y][px] = hairIdx;
|
|
926
|
+
const bx = px + side;
|
|
927
|
+
if (bx >= 0 && bx < SKIN_GRID_DIM && matrix[y][bx] !== hairIdx) matrix[y][bx] = blackIdx;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// 2e. MID-CROWN SIDE HAIR ARC (UP) — mirrors 2f in buildDirectionMatrix;
|
|
933
|
+
// same 7-row arc centered at y=7, using headBounds for the silhouette
|
|
934
|
+
// edge reference instead of zones.border.
|
|
935
|
+
{
|
|
936
|
+
const CROWN_Y = 7;
|
|
937
|
+
const crownW = [3, 2, 2, 1];
|
|
938
|
+
const rngCrown = lcgRng(hashStr(`${seed}:${itemId}:crown-side-up`));
|
|
939
|
+
|
|
940
|
+
for (let dy = -3; dy <= 3; dy++) {
|
|
941
|
+
const y = CROWN_Y + dy;
|
|
942
|
+
if (y < 0 || y >= SKIN_GRID_DIM) continue;
|
|
943
|
+
const bnd = headBounds.get(y);
|
|
944
|
+
if (!bnd) continue;
|
|
945
|
+
const hbL = bnd.min;
|
|
946
|
+
const hbR = bnd.max;
|
|
947
|
+
|
|
948
|
+
const ext = crownW[Math.abs(dy)] + (rngCrown() < 0.4 ? 1 : 0);
|
|
949
|
+
|
|
950
|
+
const lMin = Math.max(0, hbL - ext);
|
|
951
|
+
for (let x = lMin; x < hbL; x++) matrix[y][x] = hairIdx;
|
|
952
|
+
if (lMin > 0 && matrix[y][lMin - 1] !== hairIdx) matrix[y][lMin - 1] = blackIdx;
|
|
953
|
+
|
|
954
|
+
const rMax = Math.min(SKIN_GRID_DIM - 1, hbR + ext);
|
|
955
|
+
for (let x = hbR + 1; x <= rMax; x++) matrix[y][x] = hairIdx;
|
|
956
|
+
if (rMax < SKIN_GRID_DIM - 1 && matrix[y][rMax + 1] !== hairIdx) matrix[y][rMax + 1] = blackIdx;
|
|
957
|
+
|
|
958
|
+
if (rngCrown() < 0.3) {
|
|
959
|
+
const px = lMin - 1;
|
|
960
|
+
if (px >= 0 && matrix[y][px] !== hairIdx) {
|
|
961
|
+
matrix[y][px] = hairIdx;
|
|
962
|
+
if (px > 0 && matrix[y][px - 1] !== hairIdx) matrix[y][px - 1] = blackIdx;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (rngCrown() < 0.3) {
|
|
966
|
+
const px = rMax + 1;
|
|
967
|
+
if (px < SKIN_GRID_DIM && matrix[y][px] !== hairIdx) {
|
|
968
|
+
matrix[y][px] = hairIdx;
|
|
969
|
+
if (px < SKIN_GRID_DIM - 1 && matrix[y][px + 1] !== hairIdx) matrix[y][px + 1] = blackIdx;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// 4. EXTENDED HAIR — flows below the head onto the upper back.
|
|
976
|
+
// hairDepth (5–11) maps to hairExtend (2–8 rows below HEAD_Y_MAX).
|
|
977
|
+
const hairExtend = Math.min(palette.hairDepth - 3, 6); // 5→2 … 9→6, capped at 6
|
|
978
|
+
const extMaxY = Math.min(HEAD_Y_MAX + hairExtend, SKIN_GRID_DIM - 2);
|
|
979
|
+
|
|
980
|
+
// Compute body-bounds for each extension row (centering reference)
|
|
981
|
+
const extBounds = new Map();
|
|
982
|
+
for (const [x, y] of allBodyCoords) {
|
|
983
|
+
if (y <= HEAD_Y_MAX || y > extMaxY) continue;
|
|
984
|
+
const b = extBounds.get(y) || { min: x, max: x };
|
|
985
|
+
b.min = Math.min(b.min, x);
|
|
986
|
+
b.max = Math.max(b.max, x);
|
|
987
|
+
extBounds.set(y, b);
|
|
988
|
+
}
|
|
989
|
+
const extYs = [...extBounds.keys()].sort((a, b) => a - b);
|
|
990
|
+
|
|
991
|
+
// Track actual hair strip bounds per row (for border painting)
|
|
992
|
+
const hairStripBounds = new Map();
|
|
993
|
+
for (const y of extYs) {
|
|
994
|
+
const { min, max } = extBounds.get(y);
|
|
995
|
+
const cx = (min + max) / 2;
|
|
996
|
+
const bodyHalfW = (max - min) / 2;
|
|
997
|
+
const yDist = y - HEAD_Y_MAX;
|
|
998
|
+
// Taper: narrower further from the head
|
|
999
|
+
const taper = Math.max(0.35, 1 - yDist * 0.08);
|
|
1000
|
+
const halfW = Math.max(2, Math.round(bodyHalfW * taper));
|
|
1001
|
+
// Per-row random outside extension (0–2 px each side = wisps)
|
|
1002
|
+
const extL = Math.floor(rng() * 2);
|
|
1003
|
+
const extR = Math.floor(rng() * 2);
|
|
1004
|
+
const hMin = Math.max(0, Math.floor(cx) - halfW - extL);
|
|
1005
|
+
const hMax = Math.min(SKIN_GRID_DIM - 1, Math.ceil(cx) + halfW + extR);
|
|
1006
|
+
for (let x = hMin; x <= hMax; x++) matrix[y][x] = hairIdx;
|
|
1007
|
+
// Store core bounds (without wisp extension) for border
|
|
1008
|
+
hairStripBounds.set(y, {
|
|
1009
|
+
min: Math.max(0, Math.floor(cx) - halfW),
|
|
1010
|
+
max: Math.min(SKIN_GRID_DIM - 1, Math.ceil(cx) + halfW),
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// 5. BLACK BORDERS on sides and bottom of the hair extension.
|
|
1015
|
+
for (const y of extYs) {
|
|
1016
|
+
const { min, max } = hairStripBounds.get(y);
|
|
1017
|
+
if (min > 0 && matrix[y][min - 1] !== hairIdx) matrix[y][min - 1] = blackIdx;
|
|
1018
|
+
if (max < SKIN_GRID_DIM - 1 && matrix[y][max + 1] !== hairIdx) matrix[y][max + 1] = blackIdx;
|
|
1019
|
+
}
|
|
1020
|
+
if (extYs.length > 0) {
|
|
1021
|
+
const bottomY = extYs[extYs.length - 1];
|
|
1022
|
+
const { min, max } = hairStripBounds.get(bottomY);
|
|
1023
|
+
const nextY = bottomY + 1;
|
|
1024
|
+
if (nextY < SKIN_GRID_DIM) {
|
|
1025
|
+
for (let x = min - 1; x <= max + 1; x++) {
|
|
1026
|
+
if (x >= 0 && x < SKIN_GRID_DIM && matrix[nextY][x] !== hairIdx) matrix[nextY][x] = blackIdx;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// 6. RANDOM WISPS — 2–4 stray hair pixels at edges for visual dynamism.
|
|
1032
|
+
// Each wisp extends 1–2 px beyond the current hair boundary,
|
|
1033
|
+
// with a black tip pixel closing it off.
|
|
1034
|
+
const allHairRows = [...headBounds.keys(), ...extYs];
|
|
1035
|
+
const numWisps = 2 + Math.floor(rng() * 3);
|
|
1036
|
+
for (let i = 0; i < numWisps; i++) {
|
|
1037
|
+
const wy = allHairRows[Math.floor(rng() * allHairRows.length)];
|
|
1038
|
+
const hb = hairStripBounds.get(wy) || headBounds.get(wy);
|
|
1039
|
+
if (!hb) continue;
|
|
1040
|
+
const side = rng() < 0.5 ? -1 : 1;
|
|
1041
|
+
const baseX = side < 0 ? hb.min - 1 : hb.max + 1;
|
|
1042
|
+
if (baseX < 0 || baseX >= SKIN_GRID_DIM) continue;
|
|
1043
|
+
matrix[wy][baseX] = hairIdx;
|
|
1044
|
+
// Optional second wisp pixel
|
|
1045
|
+
if (rng() < 0.45) {
|
|
1046
|
+
const px2 = baseX + side;
|
|
1047
|
+
if (px2 >= 0 && px2 < SKIN_GRID_DIM) matrix[wy][px2] = hairIdx;
|
|
1048
|
+
}
|
|
1049
|
+
// Black closing tip
|
|
1050
|
+
const tipX =
|
|
1051
|
+
side < 0
|
|
1052
|
+
? Math.max(0, baseX - (rng() < 0.45 ? 2 : 1))
|
|
1053
|
+
: Math.min(SKIN_GRID_DIM - 1, baseX + (rng() < 0.45 ? 2 : 1));
|
|
1054
|
+
if (matrix[wy][tipX] !== hairIdx) matrix[wy][tipX] = blackIdx;
|
|
1055
|
+
}
|
|
1056
|
+
} // end else (normal hair)
|
|
1057
|
+
|
|
1058
|
+
return matrix;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
1062
|
+
* WALK FRAME BUILDER
|
|
1063
|
+
* Two-frame walk cycle: frame 0 = idle pose, frame 1 = body shifted up 1px.
|
|
1064
|
+
* The vertical bob creates a natural walking bounce effect.
|
|
1065
|
+
* ═══════════════════════════════════════════════════════════════════════════ */
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Derives a two-frame walk cycle from a pre-built idle matrix.
|
|
1069
|
+
* Frame 0: deep copy of idleMatrix.
|
|
1070
|
+
* Frame 1: all rows shifted up by 1 pixel — the walk-bounce step.
|
|
1071
|
+
* The bottom row becomes fully transparent (index 0).
|
|
1072
|
+
*
|
|
1073
|
+
* @param {number[][]} idleMatrix Pre-built idle frame (not mutated).
|
|
1074
|
+
* @returns {number[][][]} [frame0, frame1]
|
|
1075
|
+
*/
|
|
1076
|
+
function buildWalkFrames(idleMatrix) {
|
|
1077
|
+
const FOOT_TOP = SHOE_Y_MIN - 1; // y=22 — bottom of leg zone, just above shoes
|
|
1078
|
+
const midX = Math.floor(SKIN_GRID_DIM / 2); // x=12 — column boundary between left/right foot
|
|
1079
|
+
|
|
1080
|
+
// Build one walk frame: copy idle, then raise shoe row up 1 px for the chosen side,
|
|
1081
|
+
// leaving y=SHOE_Y_MIN transparent for that foot.
|
|
1082
|
+
const makeFrame = (liftLeft) => {
|
|
1083
|
+
const frame = idleMatrix.map((row) => [...row]);
|
|
1084
|
+
const xFrom = liftLeft ? 0 : midX;
|
|
1085
|
+
const xTo = liftLeft ? midX : SKIN_GRID_DIM;
|
|
1086
|
+
for (let x = xFrom; x < xTo; x++) {
|
|
1087
|
+
frame[FOOT_TOP][x] = idleMatrix[SHOE_Y_MIN][x]; // raise shoe to y=22
|
|
1088
|
+
frame[SHOE_Y_MIN][x] = 0; // clear y=23 → transparent gap
|
|
1089
|
+
}
|
|
1090
|
+
return frame;
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
// frame0: right foot raised; frame1: left foot raised → alternating step cycle
|
|
1094
|
+
return [makeFrame(false), makeFrame(true)];
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
1098
|
+
* UUID HELPER (self-contained, no import from parent)
|
|
1099
|
+
* ═══════════════════════════════════════════════════════════════════════════ */
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Derives a deterministic UUID v4 from an arbitrary seed string.
|
|
1103
|
+
* @param {string} seed
|
|
1104
|
+
* @returns {string}
|
|
1105
|
+
*/
|
|
1106
|
+
function localSeedToUUIDv4(seed) {
|
|
1107
|
+
const hash = crypto.createHash('sha256').update(seed).digest();
|
|
1108
|
+
hash[6] = (hash[6] & 0x0f) | 0x40;
|
|
1109
|
+
hash[8] = (hash[8] & 0x3f) | 0x80;
|
|
1110
|
+
const hex = hash.toString('hex');
|
|
1111
|
+
return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20, 32)].join('-');
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
1115
|
+
* CUSTOM MULTI-FRAME GENERATOR
|
|
1116
|
+
* Called by generateMultiFrame() when descriptor.customMultiFrameGenerator exists.
|
|
1117
|
+
* ═══════════════════════════════════════════════════════════════════════════ */
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Generates a complete MultiFrameResult for a skin item using template-based
|
|
1121
|
+
* pixel painting. Each of the four cardinal directions gets a properly oriented
|
|
1122
|
+
* frame matrix; all share the same deterministic colour palette.
|
|
1123
|
+
*
|
|
1124
|
+
* @param {import('./semantic-layer-generator.js').GenerateLayerOptions & { frameCount?: number, startFrame?: number, frameDuration?: number }} options
|
|
1125
|
+
* @param {import('./semantic-layer-generator.js').SemanticDescriptor} _descriptor
|
|
1126
|
+
* @returns {import('./semantic-layer-generator.js').MultiFrameResult}
|
|
1127
|
+
*/
|
|
1128
|
+
function generateSkinMultiFrame(options, _descriptor) {
|
|
1129
|
+
const { itemId, seed, frameCount = 1, startFrame = 0, frameDuration = 250 } = options;
|
|
1130
|
+
|
|
1131
|
+
// Shared mutable palette; starts with index 0 = transparent
|
|
1132
|
+
const globalColors = [[0, 0, 0, 0]];
|
|
1133
|
+
|
|
1134
|
+
// Skin subtype is injected via the descriptor (set during registration).
|
|
1135
|
+
// Falls back to 'random' for the legacy skin- prefix or any unknown descriptor.
|
|
1136
|
+
const subtype = _descriptor?.skinSubtype ?? 'random';
|
|
1137
|
+
const palette = deriveSkinPalette(seed, itemId, subtype);
|
|
1138
|
+
|
|
1139
|
+
// Build idle direction matrices
|
|
1140
|
+
const matrices = {
|
|
1141
|
+
down: buildDirectionMatrix(ZONES.down, palette, globalColors, seed, itemId, 'down'),
|
|
1142
|
+
up: buildUpDirectionMatrix(ZONES.up, palette, globalColors, seed, itemId),
|
|
1143
|
+
left: buildDirectionMatrix(ZONES.left, palette, globalColors, seed, itemId, 'left'),
|
|
1144
|
+
right: buildDirectionMatrix(ZONES.right, palette, globalColors, seed, itemId, 'right'),
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
// Build 2-frame walk cycles from idle matrices (shared color palette, no extra allocations)
|
|
1148
|
+
const walkFrames = {
|
|
1149
|
+
down: buildWalkFrames(matrices.down),
|
|
1150
|
+
up: buildWalkFrames(matrices.up),
|
|
1151
|
+
left: buildWalkFrames(matrices.left),
|
|
1152
|
+
right: buildWalkFrames(matrices.right),
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
// Idle: frameCount identical copies of the direction matrix.
|
|
1156
|
+
const makeIdleArray = (matrix) => Array.from({ length: frameCount }, () => matrix.map((row) => [...row]));
|
|
1157
|
+
|
|
1158
|
+
// Walking: always exactly 2 frames (walk cycle is independent of frameCount).
|
|
1159
|
+
const makeWalkArray = (frames2) => frames2.map((m) => m.map((row) => [...row]));
|
|
1160
|
+
|
|
1161
|
+
const objectLayerRenderFramesData = {
|
|
1162
|
+
frame_duration: frameDuration,
|
|
1163
|
+
is_stateless: false,
|
|
1164
|
+
frames: {
|
|
1165
|
+
// DOWN (08) idle
|
|
1166
|
+
down_idle: makeIdleArray(matrices.down),
|
|
1167
|
+
none_idle: makeIdleArray(matrices.down),
|
|
1168
|
+
default_idle: makeIdleArray(matrices.down),
|
|
1169
|
+
// UP (02) idle
|
|
1170
|
+
up_idle: makeIdleArray(matrices.up),
|
|
1171
|
+
// LEFT (06) idle
|
|
1172
|
+
left_idle: makeIdleArray(matrices.left),
|
|
1173
|
+
up_left_idle: makeIdleArray(matrices.left),
|
|
1174
|
+
down_left_idle: makeIdleArray(matrices.left),
|
|
1175
|
+
// RIGHT (04) idle
|
|
1176
|
+
right_idle: makeIdleArray(matrices.right),
|
|
1177
|
+
up_right_idle: makeIdleArray(matrices.right),
|
|
1178
|
+
down_right_idle: makeIdleArray(matrices.right),
|
|
1179
|
+
// Walking animations – 2-frame bounce cycle
|
|
1180
|
+
down_walking: makeWalkArray(walkFrames.down),
|
|
1181
|
+
up_walking: makeWalkArray(walkFrames.up),
|
|
1182
|
+
left_walking: makeWalkArray(walkFrames.left),
|
|
1183
|
+
right_walking: makeWalkArray(walkFrames.right),
|
|
1184
|
+
},
|
|
1185
|
+
colors: globalColors,
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
const objectLayerData = {
|
|
1189
|
+
data: {
|
|
1190
|
+
item: {
|
|
1191
|
+
id: itemId,
|
|
1192
|
+
type: 'skin',
|
|
1193
|
+
description: `Procedurally generated character skin (seed: ${seed})`,
|
|
1194
|
+
activable: true,
|
|
1195
|
+
},
|
|
1196
|
+
stats: {
|
|
1197
|
+
effect: 0,
|
|
1198
|
+
resistance: 0,
|
|
1199
|
+
agility: 0,
|
|
1200
|
+
range: 0,
|
|
1201
|
+
intelligence: 0,
|
|
1202
|
+
utility: 0,
|
|
1203
|
+
},
|
|
1204
|
+
ledger: { type: 'OFF_CHAIN' },
|
|
1205
|
+
seed: localSeedToUUIDv4(`${seed}:${itemId}`),
|
|
1206
|
+
},
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
// Build synthetic frames array for layer-summary logging in cyberia.js
|
|
1210
|
+
const layerSummary = [
|
|
1211
|
+
{ layerKey: 'skin', layerId: `${itemId}-skin`, keys: ZONES.down.skin.map(() => ({ type: 'template' })) },
|
|
1212
|
+
{ layerKey: 'hair', layerId: `${itemId}-hair`, keys: ZONES.down.hair.map(() => ({ type: 'template' })) },
|
|
1213
|
+
{ layerKey: 'shirt', layerId: `${itemId}-shirt`, keys: ZONES.down.shirt.map(() => ({ type: 'template' })) },
|
|
1214
|
+
{ layerKey: 'pants', layerId: `${itemId}-pants`, keys: ZONES.down.pants.map(() => ({ type: 'template' })) },
|
|
1215
|
+
{ layerKey: 'shoes', layerId: `${itemId}-shoes`, keys: ZONES.down.shoes.map(() => ({ type: 'template' })) },
|
|
1216
|
+
{ layerKey: 'border', layerId: `${itemId}-border`, keys: ZONES.down.border.map(() => ({ type: 'template' })) },
|
|
1217
|
+
];
|
|
1218
|
+
|
|
1219
|
+
const frames = Array.from({ length: frameCount }, (_, fi) => ({
|
|
1220
|
+
itemId,
|
|
1221
|
+
seed,
|
|
1222
|
+
frameIndex: startFrame + fi,
|
|
1223
|
+
layers: layerSummary,
|
|
1224
|
+
compositeFrameMatrix: matrices.down,
|
|
1225
|
+
compositeColors: globalColors,
|
|
1226
|
+
}));
|
|
1227
|
+
|
|
1228
|
+
return {
|
|
1229
|
+
itemId,
|
|
1230
|
+
seed,
|
|
1231
|
+
frameCount,
|
|
1232
|
+
frames,
|
|
1233
|
+
objectLayerRenderFramesData,
|
|
1234
|
+
objectLayerData,
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
1239
|
+
* REGISTRATION
|
|
1240
|
+
* ═══════════════════════════════════════════════════════════════════════════ */
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Registers all skin semantic descriptors.
|
|
1244
|
+
* Uses dependency injection — no import from the parent module.
|
|
1245
|
+
*
|
|
1246
|
+
* @param {function(string, import('./semantic-layer-generator.js').SemanticDescriptor): void} registerFn
|
|
1247
|
+
* @memberof SemanticLayerGeneratorSkin
|
|
1248
|
+
*/
|
|
1249
|
+
export function registerSkinSemantics(registerFn) {
|
|
1250
|
+
/**
|
|
1251
|
+
* Skin subtypes — each maps a prefix to palette constraints.
|
|
1252
|
+
*
|
|
1253
|
+
* | Prefix | Skin tone | Hair pool | hairDepth |
|
|
1254
|
+
* |-----------------|---------------|--------------------|----------|
|
|
1255
|
+
* | skin-random | any | any | 5–11 |
|
|
1256
|
+
* | skin-dark | dark (1–3) | any | 5–11 |
|
|
1257
|
+
* | skin-light | light (6–8) | any | 5–11 |
|
|
1258
|
+
* | skin-vivid | any | vivid only | 5–11 |
|
|
1259
|
+
* | skin-natural | any | natural only | 5–11 |
|
|
1260
|
+
* | skin-shaved | any | any (unused) | 0 only |
|
|
1261
|
+
*/
|
|
1262
|
+
const SUBTYPES = [
|
|
1263
|
+
{ prefix: 'skin-random', subtype: 'random', desc: 'Fully random skin tone and hair' },
|
|
1264
|
+
{ prefix: 'skin-dark', subtype: 'dark', desc: 'Dark skin tones' },
|
|
1265
|
+
{ prefix: 'skin-light', subtype: 'light', desc: 'Light / pale skin tones' },
|
|
1266
|
+
{ prefix: 'skin-vivid', subtype: 'vivid', desc: 'Vivid / exotic hair colours (blue, red, green…)' },
|
|
1267
|
+
{ prefix: 'skin-natural', subtype: 'natural', desc: 'Natural hair colours (brown, blond, grey…)' },
|
|
1268
|
+
{ prefix: 'skin-shaved', subtype: 'shaved', desc: 'Shaved / bald head — no hair' },
|
|
1269
|
+
];
|
|
1270
|
+
|
|
1271
|
+
const sharedLayers = {
|
|
1272
|
+
skin: { generator: 'template-zone' },
|
|
1273
|
+
hair: { generator: 'template-zone' },
|
|
1274
|
+
shirt: { generator: 'template-zone' },
|
|
1275
|
+
pants: { generator: 'template-zone' },
|
|
1276
|
+
shoes: { generator: 'template-zone' },
|
|
1277
|
+
border: { generator: 'template-zone' },
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
for (const { prefix, subtype, desc } of SUBTYPES) {
|
|
1281
|
+
registerFn(prefix, {
|
|
1282
|
+
semanticTags: ['character', 'body', 'humanoid'],
|
|
1283
|
+
paletteHints: [],
|
|
1284
|
+
preferredShapes: {},
|
|
1285
|
+
itemType: 'skin',
|
|
1286
|
+
skinSubtype: subtype,
|
|
1287
|
+
description: desc,
|
|
1288
|
+
layers: sharedLayers,
|
|
1289
|
+
// Custom generator bypasses the default shape/noise pipeline;
|
|
1290
|
+
// receives this descriptor so it can read skinSubtype.
|
|
1291
|
+
customMultiFrameGenerator: generateSkinMultiFrame,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}
|