@pixygon/avatar 1.0.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/dist/chunk-5QZCUXJW.mjs +187 -0
- package/dist/components/index.d.mts +18 -0
- package/dist/components/index.d.ts +18 -0
- package/dist/components/index.js +476 -0
- package/dist/components/index.mjs +277 -0
- package/dist/index-DwPxw0AI.d.mts +77 -0
- package/dist/index-DwPxw0AI.d.ts +77 -0
- package/dist/index.d.mts +52 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +274 -0
- package/dist/index.mjs +72 -0
- package/package.json +49 -0
- package/src/components/AvatarEditor.tsx +371 -0
- package/src/components/index.ts +7 -0
- package/src/defaults.ts +89 -0
- package/src/hooks/useAvatarEditor.ts +41 -0
- package/src/index.ts +36 -0
- package/src/presets.ts +44 -0
- package/src/skeleton.ts +82 -0
- package/src/types/index.ts +96 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { AvatarAppearance, Colour3 } from '../types'
|
|
3
|
+
import { STYLE_COUNTS } from '../types'
|
|
4
|
+
import { SKIN_PRESETS, EYE_COLOR_PRESETS, HAIR_COLOR_PRESETS } from '../presets'
|
|
5
|
+
import { useAvatarEditor, type AvatarEditorTab } from '../hooks/useAvatarEditor'
|
|
6
|
+
|
|
7
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface AvatarEditorProps {
|
|
10
|
+
/** Initial appearance (uses default if omitted). */
|
|
11
|
+
initial?: AvatarAppearance
|
|
12
|
+
/** Called whenever the appearance changes. */
|
|
13
|
+
onChange?: (appearance: AvatarAppearance) => void
|
|
14
|
+
/** Called when the user clicks "Done". */
|
|
15
|
+
onDone?: (appearance: AvatarAppearance) => void
|
|
16
|
+
/** Called when the user clicks "Cancel". */
|
|
17
|
+
onCancel?: () => void
|
|
18
|
+
/** Optional class name for the root container. */
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TABS: { key: AvatarEditorTab; label: string }[] = [
|
|
23
|
+
{ key: 'body', label: 'Body' },
|
|
24
|
+
{ key: 'head', label: 'Head' },
|
|
25
|
+
{ key: 'eyes', label: 'Eyes' },
|
|
26
|
+
{ key: 'brows', label: 'Brows' },
|
|
27
|
+
{ key: 'nose', label: 'Nose' },
|
|
28
|
+
{ key: 'mouth', label: 'Mouth' },
|
|
29
|
+
{ key: 'hair', label: 'Hair' },
|
|
30
|
+
{ key: 'extras', label: 'Extras' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
export function AvatarEditor({
|
|
34
|
+
initial,
|
|
35
|
+
onChange,
|
|
36
|
+
onDone,
|
|
37
|
+
onCancel,
|
|
38
|
+
className,
|
|
39
|
+
}: AvatarEditorProps) {
|
|
40
|
+
const { appearance, tab, setTab, update, randomize, reset } = useAvatarEditor(initial)
|
|
41
|
+
|
|
42
|
+
// Notify parent on every change
|
|
43
|
+
const handleUpdate = (path: string, value: number | Colour3) => {
|
|
44
|
+
update(path, value)
|
|
45
|
+
// We can't access the *new* state synchronously after update,
|
|
46
|
+
// so we schedule the callback on next tick.
|
|
47
|
+
setTimeout(() => onChange?.(appearance), 0)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className={className} style={rootStyle}>
|
|
52
|
+
{/* Header */}
|
|
53
|
+
<div style={headerStyle}>
|
|
54
|
+
<span style={{ fontWeight: 700, fontSize: 18 }}>Avatar Editor</span>
|
|
55
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
56
|
+
<button style={btnStyle} onClick={randomize}>Randomize</button>
|
|
57
|
+
<button style={btnStyle} onClick={reset}>Reset</button>
|
|
58
|
+
{onCancel && <button style={btnStyle} onClick={onCancel}>Cancel</button>}
|
|
59
|
+
{onDone && (
|
|
60
|
+
<button style={{ ...btnStyle, background: '#3b6' }} onClick={() => onDone(appearance)}>
|
|
61
|
+
Done
|
|
62
|
+
</button>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{/* Tab bar */}
|
|
68
|
+
<div style={tabBarStyle}>
|
|
69
|
+
{TABS.map(t => (
|
|
70
|
+
<button
|
|
71
|
+
key={t.key}
|
|
72
|
+
onClick={() => setTab(t.key)}
|
|
73
|
+
style={{
|
|
74
|
+
...tabStyle,
|
|
75
|
+
...(tab === t.key ? tabActiveStyle : {}),
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{t.label}
|
|
79
|
+
</button>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Tab content */}
|
|
84
|
+
<div style={contentStyle}>
|
|
85
|
+
{tab === 'body' && <BodyTab appearance={appearance} onUpdate={handleUpdate} />}
|
|
86
|
+
{tab === 'head' && <HeadTab appearance={appearance} onUpdate={handleUpdate} />}
|
|
87
|
+
{tab === 'eyes' && <EyesTab appearance={appearance} onUpdate={handleUpdate} />}
|
|
88
|
+
{tab === 'brows' && <BrowsTab appearance={appearance} onUpdate={handleUpdate} />}
|
|
89
|
+
{tab === 'nose' && <NoseTab appearance={appearance} onUpdate={handleUpdate} />}
|
|
90
|
+
{tab === 'mouth' && <MouthTab appearance={appearance} onUpdate={handleUpdate} />}
|
|
91
|
+
{tab === 'hair' && <HairTab appearance={appearance} onUpdate={handleUpdate} />}
|
|
92
|
+
{tab === 'extras' && <ExtrasTab appearance={appearance} onUpdate={handleUpdate} />}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Tab panels ──────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
type UpdateFn = (path: string, value: number | Colour3) => void
|
|
101
|
+
|
|
102
|
+
function BodyTab({ appearance, onUpdate }: { appearance: AvatarAppearance; onUpdate: UpdateFn }) {
|
|
103
|
+
return (
|
|
104
|
+
<>
|
|
105
|
+
<SectionLabel>Body</SectionLabel>
|
|
106
|
+
<Slider label="Height" value={appearance.body.height} onChange={v => onUpdate('body.height', v)} />
|
|
107
|
+
<Slider label="Build" value={appearance.body.build} onChange={v => onUpdate('body.build', v)} />
|
|
108
|
+
<SectionLabel>Skin Colour</SectionLabel>
|
|
109
|
+
<ColourPresets current={appearance.skin_color} presets={SKIN_PRESETS} onPick={c => onUpdate('skin_color', c)} />
|
|
110
|
+
</>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function HeadTab({ appearance, onUpdate }: { appearance: AvatarAppearance; onUpdate: UpdateFn }) {
|
|
115
|
+
return (
|
|
116
|
+
<>
|
|
117
|
+
<SectionLabel>Head Shape</SectionLabel>
|
|
118
|
+
<Slider label="Width" value={appearance.head.width} min={0.5} max={1.5} onChange={v => onUpdate('head.width', v)} />
|
|
119
|
+
<Slider label="Height" value={appearance.head.height} min={0.5} max={1.5} onChange={v => onUpdate('head.height', v)} />
|
|
120
|
+
</>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function EyesTab({ appearance, onUpdate }: { appearance: AvatarAppearance; onUpdate: UpdateFn }) {
|
|
125
|
+
return (
|
|
126
|
+
<>
|
|
127
|
+
<SectionLabel>Eyes</SectionLabel>
|
|
128
|
+
<StyleSelector label="Style" value={appearance.head.eye_style} count={STYLE_COUNTS.eye} onChange={v => onUpdate('head.eye_style', v)} />
|
|
129
|
+
<ColourPresets current={appearance.head.eye_color} presets={EYE_COLOR_PRESETS} onPick={c => onUpdate('head.eye_color', c)} />
|
|
130
|
+
<Slider label="Vertical Pos" value={appearance.head.eye_y} onChange={v => onUpdate('head.eye_y', v)} />
|
|
131
|
+
<Slider label="Spacing" value={appearance.head.eye_spacing} onChange={v => onUpdate('head.eye_spacing', v)} />
|
|
132
|
+
<Slider label="Size" value={appearance.head.eye_size} onChange={v => onUpdate('head.eye_size', v)} />
|
|
133
|
+
<Slider label="Rotation" value={appearance.head.eye_rotation} onChange={v => onUpdate('head.eye_rotation', v)} />
|
|
134
|
+
</>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function BrowsTab({ appearance, onUpdate }: { appearance: AvatarAppearance; onUpdate: UpdateFn }) {
|
|
139
|
+
return (
|
|
140
|
+
<>
|
|
141
|
+
<SectionLabel>Eyebrows</SectionLabel>
|
|
142
|
+
<StyleSelector label="Style" value={appearance.head.brow_style} count={STYLE_COUNTS.brow} onChange={v => onUpdate('head.brow_style', v)} />
|
|
143
|
+
<ColourPresets current={appearance.head.brow_color} presets={HAIR_COLOR_PRESETS} onPick={c => onUpdate('head.brow_color', c)} />
|
|
144
|
+
<Slider label="Vertical Pos" value={appearance.head.brow_y} onChange={v => onUpdate('head.brow_y', v)} />
|
|
145
|
+
<Slider label="Spacing" value={appearance.head.brow_spacing} onChange={v => onUpdate('head.brow_spacing', v)} />
|
|
146
|
+
<Slider label="Size" value={appearance.head.brow_size} onChange={v => onUpdate('head.brow_size', v)} />
|
|
147
|
+
<Slider label="Rotation" value={appearance.head.brow_rotation} onChange={v => onUpdate('head.brow_rotation', v)} />
|
|
148
|
+
</>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function NoseTab({ appearance, onUpdate }: { appearance: AvatarAppearance; onUpdate: UpdateFn }) {
|
|
153
|
+
return (
|
|
154
|
+
<>
|
|
155
|
+
<SectionLabel>Nose</SectionLabel>
|
|
156
|
+
<StyleSelector label="Style" value={appearance.head.nose_style} count={STYLE_COUNTS.nose} onChange={v => onUpdate('head.nose_style', v)} />
|
|
157
|
+
<Slider label="Vertical Pos" value={appearance.head.nose_y} onChange={v => onUpdate('head.nose_y', v)} />
|
|
158
|
+
<Slider label="Size" value={appearance.head.nose_size} onChange={v => onUpdate('head.nose_size', v)} />
|
|
159
|
+
</>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function MouthTab({ appearance, onUpdate }: { appearance: AvatarAppearance; onUpdate: UpdateFn }) {
|
|
164
|
+
return (
|
|
165
|
+
<>
|
|
166
|
+
<SectionLabel>Mouth</SectionLabel>
|
|
167
|
+
<StyleSelector label="Style" value={appearance.head.mouth_style} count={STYLE_COUNTS.mouth} onChange={v => onUpdate('head.mouth_style', v)} />
|
|
168
|
+
<Slider label="Vertical Pos" value={appearance.head.mouth_y} onChange={v => onUpdate('head.mouth_y', v)} />
|
|
169
|
+
<Slider label="Size" value={appearance.head.mouth_size} onChange={v => onUpdate('head.mouth_size', v)} />
|
|
170
|
+
</>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function HairTab({ appearance, onUpdate }: { appearance: AvatarAppearance; onUpdate: UpdateFn }) {
|
|
175
|
+
return (
|
|
176
|
+
<>
|
|
177
|
+
<SectionLabel>Hair</SectionLabel>
|
|
178
|
+
<StyleSelector label="Style" value={appearance.head.hair_style} count={STYLE_COUNTS.hair} onChange={v => onUpdate('head.hair_style', v)} />
|
|
179
|
+
<SectionLabel>Colour</SectionLabel>
|
|
180
|
+
<ColourPresets current={appearance.head.hair_color} presets={HAIR_COLOR_PRESETS} onPick={c => onUpdate('head.hair_color', c)} />
|
|
181
|
+
</>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function ExtrasTab({ appearance, onUpdate }: { appearance: AvatarAppearance; onUpdate: UpdateFn }) {
|
|
186
|
+
return (
|
|
187
|
+
<>
|
|
188
|
+
<SectionLabel>Facial Hair</SectionLabel>
|
|
189
|
+
<StyleSelector label="Style" value={appearance.head.facial_hair_style} count={STYLE_COUNTS.facial_hair} onChange={v => onUpdate('head.facial_hair_style', v)} />
|
|
190
|
+
{appearance.head.facial_hair_style > 0 && (
|
|
191
|
+
<ColourPresets current={appearance.head.facial_hair_color} presets={HAIR_COLOR_PRESETS} onPick={c => onUpdate('head.facial_hair_color', c)} />
|
|
192
|
+
)}
|
|
193
|
+
<SectionLabel>Glasses</SectionLabel>
|
|
194
|
+
<StyleSelector label="Style" value={appearance.head.glasses_style} count={STYLE_COUNTS.glasses} onChange={v => onUpdate('head.glasses_style', v)} />
|
|
195
|
+
</>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Reusable controls ───────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
202
|
+
return <div style={{ color: '#ccc', fontWeight: 600, fontSize: 13, margin: '8px 0 4px' }}>{children}</div>
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function Slider({
|
|
206
|
+
label,
|
|
207
|
+
value,
|
|
208
|
+
min = 0,
|
|
209
|
+
max = 1,
|
|
210
|
+
onChange,
|
|
211
|
+
}: {
|
|
212
|
+
label: string
|
|
213
|
+
value: number
|
|
214
|
+
min?: number
|
|
215
|
+
max?: number
|
|
216
|
+
onChange: (v: number) => void
|
|
217
|
+
}) {
|
|
218
|
+
return (
|
|
219
|
+
<label style={sliderRowStyle}>
|
|
220
|
+
<span style={{ minWidth: 90, color: '#aab' }}>{label}</span>
|
|
221
|
+
<input
|
|
222
|
+
type="range"
|
|
223
|
+
min={min}
|
|
224
|
+
max={max}
|
|
225
|
+
step={0.01}
|
|
226
|
+
value={value}
|
|
227
|
+
onChange={e => onChange(parseFloat(e.target.value))}
|
|
228
|
+
style={{ flex: 1 }}
|
|
229
|
+
/>
|
|
230
|
+
<span style={{ minWidth: 40, textAlign: 'right', color: '#889', fontSize: 12 }}>
|
|
231
|
+
{value.toFixed(2)}
|
|
232
|
+
</span>
|
|
233
|
+
</label>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function StyleSelector({
|
|
238
|
+
label,
|
|
239
|
+
value,
|
|
240
|
+
count,
|
|
241
|
+
onChange,
|
|
242
|
+
}: {
|
|
243
|
+
label: string
|
|
244
|
+
value: number
|
|
245
|
+
count: number
|
|
246
|
+
onChange: (v: number) => void
|
|
247
|
+
}) {
|
|
248
|
+
return (
|
|
249
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0' }}>
|
|
250
|
+
<span style={{ color: '#aab', minWidth: 50 }}>{label}</span>
|
|
251
|
+
<button style={smallBtnStyle} onClick={() => onChange(Math.max(0, value - 1))} disabled={value <= 0}><</button>
|
|
252
|
+
<span style={{ color: '#fff', minWidth: 40, textAlign: 'center' }}>{value + 1}/{count}</span>
|
|
253
|
+
<button style={smallBtnStyle} onClick={() => onChange(Math.min(count - 1, value + 1))} disabled={value >= count - 1}>></button>
|
|
254
|
+
</div>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function ColourPresets({
|
|
259
|
+
current,
|
|
260
|
+
presets,
|
|
261
|
+
onPick,
|
|
262
|
+
}: {
|
|
263
|
+
current: Colour3
|
|
264
|
+
presets: readonly Colour3[]
|
|
265
|
+
onPick: (c: Colour3) => void
|
|
266
|
+
}) {
|
|
267
|
+
return (
|
|
268
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, margin: '4px 0' }}>
|
|
269
|
+
{presets.map((p, i) => {
|
|
270
|
+
const selected =
|
|
271
|
+
Math.abs(current[0] - p[0]) < 0.01 &&
|
|
272
|
+
Math.abs(current[1] - p[1]) < 0.01 &&
|
|
273
|
+
Math.abs(current[2] - p[2]) < 0.01
|
|
274
|
+
return (
|
|
275
|
+
<button
|
|
276
|
+
key={i}
|
|
277
|
+
onClick={() => onPick([...p])}
|
|
278
|
+
style={{
|
|
279
|
+
width: selected ? 26 : 22,
|
|
280
|
+
height: selected ? 26 : 22,
|
|
281
|
+
borderRadius: 4,
|
|
282
|
+
border: selected ? '2px solid #fff' : '1px solid #555',
|
|
283
|
+
background: `rgb(${p[0] * 255}, ${p[1] * 255}, ${p[2] * 255})`,
|
|
284
|
+
cursor: 'pointer',
|
|
285
|
+
padding: 0,
|
|
286
|
+
}}
|
|
287
|
+
/>
|
|
288
|
+
)
|
|
289
|
+
})}
|
|
290
|
+
</div>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Inline styles ───────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
const rootStyle: React.CSSProperties = {
|
|
297
|
+
display: 'flex',
|
|
298
|
+
flexDirection: 'column',
|
|
299
|
+
background: '#1e1e2a',
|
|
300
|
+
color: '#ddd',
|
|
301
|
+
borderRadius: 8,
|
|
302
|
+
overflow: 'hidden',
|
|
303
|
+
fontFamily: 'system-ui, sans-serif',
|
|
304
|
+
fontSize: 14,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const headerStyle: React.CSSProperties = {
|
|
308
|
+
display: 'flex',
|
|
309
|
+
justifyContent: 'space-between',
|
|
310
|
+
alignItems: 'center',
|
|
311
|
+
padding: '10px 14px',
|
|
312
|
+
background: '#16161f',
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const tabBarStyle: React.CSSProperties = {
|
|
316
|
+
display: 'flex',
|
|
317
|
+
gap: 2,
|
|
318
|
+
padding: '0 8px',
|
|
319
|
+
background: '#1a1a26',
|
|
320
|
+
overflowX: 'auto',
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const tabStyle: React.CSSProperties = {
|
|
324
|
+
padding: '8px 12px',
|
|
325
|
+
background: 'transparent',
|
|
326
|
+
border: 'none',
|
|
327
|
+
color: '#99a',
|
|
328
|
+
cursor: 'pointer',
|
|
329
|
+
fontSize: 13,
|
|
330
|
+
whiteSpace: 'nowrap',
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const tabActiveStyle: React.CSSProperties = {
|
|
334
|
+
color: '#fff',
|
|
335
|
+
fontWeight: 700,
|
|
336
|
+
borderBottom: '2px solid #6af',
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const contentStyle: React.CSSProperties = {
|
|
340
|
+
padding: '8px 14px 14px',
|
|
341
|
+
overflowY: 'auto',
|
|
342
|
+
flex: 1,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const btnStyle: React.CSSProperties = {
|
|
346
|
+
padding: '6px 14px',
|
|
347
|
+
border: 'none',
|
|
348
|
+
borderRadius: 4,
|
|
349
|
+
background: '#333',
|
|
350
|
+
color: '#ddd',
|
|
351
|
+
cursor: 'pointer',
|
|
352
|
+
fontSize: 13,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const smallBtnStyle: React.CSSProperties = {
|
|
356
|
+
width: 28,
|
|
357
|
+
height: 28,
|
|
358
|
+
border: '1px solid #444',
|
|
359
|
+
borderRadius: 4,
|
|
360
|
+
background: '#2a2a36',
|
|
361
|
+
color: '#ccc',
|
|
362
|
+
cursor: 'pointer',
|
|
363
|
+
fontSize: 14,
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const sliderRowStyle: React.CSSProperties = {
|
|
367
|
+
display: 'flex',
|
|
368
|
+
alignItems: 'center',
|
|
369
|
+
gap: 8,
|
|
370
|
+
margin: '4px 0',
|
|
371
|
+
}
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default avatar appearance + randomiser.
|
|
3
|
+
*/
|
|
4
|
+
import type { AvatarAppearance } from './types'
|
|
5
|
+
import { SKIN_PRESETS, EYE_COLOR_PRESETS, HAIR_COLOR_PRESETS } from './presets'
|
|
6
|
+
import { STYLE_COUNTS } from './types'
|
|
7
|
+
|
|
8
|
+
/** Default avatar (matches Rust Default impl). */
|
|
9
|
+
export function defaultAppearance(): AvatarAppearance {
|
|
10
|
+
return {
|
|
11
|
+
body: { height: 0.5, build: 0.5 },
|
|
12
|
+
head: {
|
|
13
|
+
width: 1.0,
|
|
14
|
+
height: 1.0,
|
|
15
|
+
eye_style: 0,
|
|
16
|
+
eye_color: [...EYE_COLOR_PRESETS[1]],
|
|
17
|
+
eye_y: 0.5,
|
|
18
|
+
eye_spacing: 0.5,
|
|
19
|
+
eye_size: 0.5,
|
|
20
|
+
eye_rotation: 0.5,
|
|
21
|
+
brow_style: 0,
|
|
22
|
+
brow_color: [...HAIR_COLOR_PRESETS[0]],
|
|
23
|
+
brow_y: 0.5,
|
|
24
|
+
brow_spacing: 0.5,
|
|
25
|
+
brow_size: 0.5,
|
|
26
|
+
brow_rotation: 0.5,
|
|
27
|
+
nose_style: 0,
|
|
28
|
+
nose_y: 0.5,
|
|
29
|
+
nose_size: 0.5,
|
|
30
|
+
mouth_style: 0,
|
|
31
|
+
mouth_y: 0.5,
|
|
32
|
+
mouth_size: 0.5,
|
|
33
|
+
mouth_color: [0.75, 0.35, 0.35],
|
|
34
|
+
hair_style: 0,
|
|
35
|
+
hair_color: [...HAIR_COLOR_PRESETS[2]],
|
|
36
|
+
facial_hair_style: 0,
|
|
37
|
+
facial_hair_color: [...HAIR_COLOR_PRESETS[0]],
|
|
38
|
+
glasses_style: 0,
|
|
39
|
+
glasses_color: [0.1, 0.1, 0.1],
|
|
40
|
+
},
|
|
41
|
+
skin_color: [...SKIN_PRESETS[2]],
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Generate a random avatar appearance. */
|
|
46
|
+
export function randomAppearance(): AvatarAppearance {
|
|
47
|
+
const rng = (min: number, max: number) => min + Math.random() * (max - min)
|
|
48
|
+
const rngInt = (max: number) => Math.floor(Math.random() * max)
|
|
49
|
+
const pick = <T>(arr: readonly T[]): T => arr[rngInt(arr.length)]
|
|
50
|
+
|
|
51
|
+
const hairColor = pick(HAIR_COLOR_PRESETS)
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
body: {
|
|
55
|
+
height: rng(0.2, 0.8),
|
|
56
|
+
build: rng(0.2, 0.8),
|
|
57
|
+
},
|
|
58
|
+
head: {
|
|
59
|
+
width: rng(0.7, 1.3),
|
|
60
|
+
height: rng(0.7, 1.3),
|
|
61
|
+
eye_style: rngInt(STYLE_COUNTS.eye),
|
|
62
|
+
eye_color: [...pick(EYE_COLOR_PRESETS)],
|
|
63
|
+
eye_y: rng(0.3, 0.7),
|
|
64
|
+
eye_spacing: rng(0.3, 0.7),
|
|
65
|
+
eye_size: rng(0.3, 0.7),
|
|
66
|
+
eye_rotation: 0.5,
|
|
67
|
+
brow_style: rngInt(STYLE_COUNTS.brow),
|
|
68
|
+
brow_color: [...hairColor],
|
|
69
|
+
brow_y: rng(0.3, 0.7),
|
|
70
|
+
brow_spacing: rng(0.3, 0.7),
|
|
71
|
+
brow_size: rng(0.3, 0.7),
|
|
72
|
+
brow_rotation: 0.5,
|
|
73
|
+
nose_style: rngInt(STYLE_COUNTS.nose),
|
|
74
|
+
nose_y: rng(0.3, 0.7),
|
|
75
|
+
nose_size: rng(0.3, 0.7),
|
|
76
|
+
mouth_style: rngInt(STYLE_COUNTS.mouth),
|
|
77
|
+
mouth_y: rng(0.3, 0.7),
|
|
78
|
+
mouth_size: rng(0.3, 0.7),
|
|
79
|
+
mouth_color: [0.75, 0.35, 0.35],
|
|
80
|
+
hair_style: rngInt(STYLE_COUNTS.hair),
|
|
81
|
+
hair_color: [...hairColor],
|
|
82
|
+
facial_hair_style: Math.random() < 0.3 ? 1 + rngInt(STYLE_COUNTS.facial_hair - 1) : 0,
|
|
83
|
+
facial_hair_color: [...hairColor],
|
|
84
|
+
glasses_style: Math.random() < 0.2 ? 1 + rngInt(STYLE_COUNTS.glasses - 1) : 0,
|
|
85
|
+
glasses_color: [0.1, 0.1, 0.1],
|
|
86
|
+
},
|
|
87
|
+
skin_color: [...pick(SKIN_PRESETS)],
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
import type { AvatarAppearance, Colour3 } from '../types'
|
|
3
|
+
import { defaultAppearance, randomAppearance } from '../defaults'
|
|
4
|
+
|
|
5
|
+
export type AvatarEditorTab = 'body' | 'head' | 'eyes' | 'brows' | 'nose' | 'mouth' | 'hair' | 'extras'
|
|
6
|
+
|
|
7
|
+
export interface UseAvatarEditorReturn {
|
|
8
|
+
appearance: AvatarAppearance
|
|
9
|
+
tab: AvatarEditorTab
|
|
10
|
+
setTab: (tab: AvatarEditorTab) => void
|
|
11
|
+
/** Update a nested field. path examples: 'body.height', 'head.eye_y', 'skin_color' */
|
|
12
|
+
update: (path: string, value: number | Colour3) => void
|
|
13
|
+
randomize: () => void
|
|
14
|
+
reset: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useAvatarEditor(initial?: AvatarAppearance): UseAvatarEditorReturn {
|
|
18
|
+
const [appearance, setAppearance] = useState<AvatarAppearance>(
|
|
19
|
+
() => initial ?? defaultAppearance(),
|
|
20
|
+
)
|
|
21
|
+
const [tab, setTab] = useState<AvatarEditorTab>('body')
|
|
22
|
+
|
|
23
|
+
const update = useCallback((path: string, value: number | Colour3) => {
|
|
24
|
+
setAppearance(prev => {
|
|
25
|
+
const next = structuredClone(prev)
|
|
26
|
+
const parts = path.split('.')
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
+
let obj: any = next
|
|
29
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
30
|
+
obj = obj[parts[i]]
|
|
31
|
+
}
|
|
32
|
+
obj[parts[parts.length - 1]] = value
|
|
33
|
+
return next
|
|
34
|
+
})
|
|
35
|
+
}, [])
|
|
36
|
+
|
|
37
|
+
const randomize = useCallback(() => setAppearance(randomAppearance()), [])
|
|
38
|
+
const reset = useCallback(() => setAppearance(initial ?? defaultAppearance()), [initial])
|
|
39
|
+
|
|
40
|
+
return { appearance, tab, setTab, update, randomize, reset }
|
|
41
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pixygon/avatar
|
|
3
|
+
*
|
|
4
|
+
* Universal avatar system for all Pixygon applications.
|
|
5
|
+
*
|
|
6
|
+
* Usage (types + utilities):
|
|
7
|
+
* import { defaultAppearance, randomAppearance, buildSkeleton } from '@pixygon/avatar'
|
|
8
|
+
* import type { AvatarAppearance } from '@pixygon/avatar'
|
|
9
|
+
*
|
|
10
|
+
* Usage (React editor):
|
|
11
|
+
* import { AvatarEditor } from '@pixygon/avatar/components'
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Types
|
|
15
|
+
export type {
|
|
16
|
+
AvatarAppearance,
|
|
17
|
+
AvatarBody,
|
|
18
|
+
AvatarHead,
|
|
19
|
+
Colour3,
|
|
20
|
+
BoneSegment,
|
|
21
|
+
HeadInfo,
|
|
22
|
+
} from './types'
|
|
23
|
+
export { STYLE_COUNTS } from './types'
|
|
24
|
+
|
|
25
|
+
// Presets
|
|
26
|
+
export { SKIN_PRESETS, EYE_COLOR_PRESETS, HAIR_COLOR_PRESETS } from './presets'
|
|
27
|
+
|
|
28
|
+
// Defaults & randomisation
|
|
29
|
+
export { defaultAppearance, randomAppearance } from './defaults'
|
|
30
|
+
|
|
31
|
+
// Skeleton builder
|
|
32
|
+
export { buildSkeleton } from './skeleton'
|
|
33
|
+
|
|
34
|
+
// Editor hook (usable without the component)
|
|
35
|
+
export { useAvatarEditor } from './hooks/useAvatarEditor'
|
|
36
|
+
export type { AvatarEditorTab, UseAvatarEditorReturn } from './hooks/useAvatarEditor'
|
package/src/presets.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour presets — mirrors the Rust constants from infinite-game/src/avatar/mod.rs.
|
|
3
|
+
*/
|
|
4
|
+
import type { Colour3 } from './types'
|
|
5
|
+
|
|
6
|
+
/** 10 skin colour presets (light to dark). */
|
|
7
|
+
export const SKIN_PRESETS: readonly Colour3[] = [
|
|
8
|
+
[0.98, 0.89, 0.78], // Porcelain
|
|
9
|
+
[0.96, 0.84, 0.71], // Fair
|
|
10
|
+
[0.92, 0.78, 0.63], // Light
|
|
11
|
+
[0.87, 0.72, 0.55], // Medium-light
|
|
12
|
+
[0.78, 0.62, 0.46], // Medium
|
|
13
|
+
[0.68, 0.51, 0.36], // Olive
|
|
14
|
+
[0.58, 0.42, 0.30], // Tan
|
|
15
|
+
[0.47, 0.33, 0.22], // Brown
|
|
16
|
+
[0.36, 0.24, 0.16], // Dark
|
|
17
|
+
[0.26, 0.17, 0.11], // Deep
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
/** 8 eye colour presets. */
|
|
21
|
+
export const EYE_COLOR_PRESETS: readonly Colour3[] = [
|
|
22
|
+
[0.10, 0.10, 0.10], // Black
|
|
23
|
+
[0.35, 0.22, 0.10], // Brown
|
|
24
|
+
[0.55, 0.35, 0.15], // Hazel
|
|
25
|
+
[0.25, 0.50, 0.25], // Green
|
|
26
|
+
[0.20, 0.40, 0.70], // Blue
|
|
27
|
+
[0.45, 0.45, 0.50], // Gray
|
|
28
|
+
[0.55, 0.25, 0.25], // Red
|
|
29
|
+
[0.50, 0.30, 0.60], // Violet
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
/** 10 hair colour presets. */
|
|
33
|
+
export const HAIR_COLOR_PRESETS: readonly Colour3[] = [
|
|
34
|
+
[0.08, 0.06, 0.05], // Black
|
|
35
|
+
[0.22, 0.14, 0.08], // Dark brown
|
|
36
|
+
[0.40, 0.26, 0.14], // Brown
|
|
37
|
+
[0.58, 0.42, 0.24], // Light brown
|
|
38
|
+
[0.78, 0.62, 0.34], // Dirty blonde
|
|
39
|
+
[0.90, 0.78, 0.48], // Blonde
|
|
40
|
+
[0.72, 0.26, 0.12], // Red
|
|
41
|
+
[0.88, 0.52, 0.22], // Ginger
|
|
42
|
+
[0.60, 0.60, 0.62], // Gray
|
|
43
|
+
[0.92, 0.92, 0.94], // White
|
|
44
|
+
]
|
package/src/skeleton.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Humanoid skeleton builder — TypeScript port of
|
|
3
|
+
* infinite-game/src/avatar/mod.rs `build_skeleton()`.
|
|
4
|
+
*/
|
|
5
|
+
import type { AvatarBody, BoneSegment, HeadInfo } from './types'
|
|
6
|
+
|
|
7
|
+
function mirrorX(p: [number, number, number]): [number, number, number] {
|
|
8
|
+
return [-p[0], p[1], p[2]]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a humanoid capsule skeleton from body parameters.
|
|
13
|
+
*
|
|
14
|
+
* Returns 16 bone segments (4 torso + 6 arm + 6 leg) and a head ellipsoid.
|
|
15
|
+
* The character stands at the origin with feet on y = 0.
|
|
16
|
+
*/
|
|
17
|
+
export function buildSkeleton(body: AvatarBody): {
|
|
18
|
+
bones: BoneSegment[]
|
|
19
|
+
head: HeadInfo
|
|
20
|
+
} {
|
|
21
|
+
const h = 0.85 + body.height * 0.35 // 0.85 – 1.20
|
|
22
|
+
const b = 0.75 + body.build * 0.50 // 0.75 – 1.25
|
|
23
|
+
|
|
24
|
+
const footY = 0.02 * h
|
|
25
|
+
const ankleY = 0.08 * h
|
|
26
|
+
const kneeY = 0.45 * h
|
|
27
|
+
const hipY = 0.88 * h
|
|
28
|
+
const waistY = 0.95 * h
|
|
29
|
+
const chestY = 1.18 * h
|
|
30
|
+
const shoulderY = 1.30 * h
|
|
31
|
+
const neckY = 1.38 * h
|
|
32
|
+
const headBaseY = 1.42 * h
|
|
33
|
+
const headCenterY = 1.55 * h
|
|
34
|
+
|
|
35
|
+
const hipSpread = 0.09 * b
|
|
36
|
+
const shoulderSpread = 0.16 * b
|
|
37
|
+
|
|
38
|
+
const bones: BoneSegment[] = []
|
|
39
|
+
|
|
40
|
+
// Torso
|
|
41
|
+
bones.push({ start: [0, hipY, 0], end: [0, waistY, 0], radius: 0.10 * b })
|
|
42
|
+
bones.push({ start: [0, waistY, 0], end: [0, chestY, 0], radius: 0.11 * b })
|
|
43
|
+
bones.push({ start: [0, chestY, 0], end: [0, shoulderY, 0], radius: 0.12 * b })
|
|
44
|
+
bones.push({ start: [0, neckY, 0], end: [0, headBaseY, 0], radius: 0.04 * b })
|
|
45
|
+
|
|
46
|
+
// Left arm
|
|
47
|
+
const lShoulder: [number, number, number] = [shoulderSpread, shoulderY, 0]
|
|
48
|
+
const lElbow: [number, number, number] = [shoulderSpread + 0.22 * h, shoulderY - 0.08 * h, 0]
|
|
49
|
+
const lWrist: [number, number, number] = [shoulderSpread + 0.42 * h, shoulderY - 0.18 * h, 0]
|
|
50
|
+
const lHand: [number, number, number] = [shoulderSpread + 0.50 * h, shoulderY - 0.22 * h, 0]
|
|
51
|
+
bones.push({ start: lShoulder, end: lElbow, radius: 0.040 * b })
|
|
52
|
+
bones.push({ start: lElbow, end: lWrist, radius: 0.035 * b })
|
|
53
|
+
bones.push({ start: lWrist, end: lHand, radius: 0.030 * b })
|
|
54
|
+
|
|
55
|
+
// Right arm (mirror)
|
|
56
|
+
bones.push({ start: mirrorX(lShoulder), end: mirrorX(lElbow), radius: 0.040 * b })
|
|
57
|
+
bones.push({ start: mirrorX(lElbow), end: mirrorX(lWrist), radius: 0.035 * b })
|
|
58
|
+
bones.push({ start: mirrorX(lWrist), end: mirrorX(lHand), radius: 0.030 * b })
|
|
59
|
+
|
|
60
|
+
// Left leg
|
|
61
|
+
const lHip: [number, number, number] = [hipSpread, hipY, 0]
|
|
62
|
+
const lKnee: [number, number, number] = [hipSpread + 0.01, kneeY, 0]
|
|
63
|
+
const lAnkle: [number, number, number] = [hipSpread, ankleY, 0]
|
|
64
|
+
const lToe: [number, number, number] = [hipSpread, footY, 0.06]
|
|
65
|
+
bones.push({ start: lHip, end: lKnee, radius: 0.055 * b })
|
|
66
|
+
bones.push({ start: lKnee, end: lAnkle, radius: 0.045 * b })
|
|
67
|
+
bones.push({ start: lAnkle, end: lToe, radius: 0.035 * b })
|
|
68
|
+
|
|
69
|
+
// Right leg (mirror)
|
|
70
|
+
bones.push({ start: mirrorX(lHip), end: mirrorX(lKnee), radius: 0.055 * b })
|
|
71
|
+
bones.push({ start: mirrorX(lKnee), end: mirrorX(lAnkle), radius: 0.045 * b })
|
|
72
|
+
bones.push({ start: mirrorX(lAnkle), end: mirrorX(lToe), radius: 0.035 * b })
|
|
73
|
+
|
|
74
|
+
const head: HeadInfo = {
|
|
75
|
+
center: [0, headCenterY, 0],
|
|
76
|
+
radius_x: 0.11,
|
|
77
|
+
radius_y: 0.12,
|
|
78
|
+
radius_z: 0.10,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { bones, head }
|
|
82
|
+
}
|