@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.
@@ -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}>&lt;</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}>&gt;</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
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @pixygon/avatar/components
3
+ *
4
+ * React components for avatar customisation.
5
+ */
6
+ export { AvatarEditor } from './AvatarEditor'
7
+ export type { AvatarEditorProps } from './AvatarEditor'
@@ -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
+ ]
@@ -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
+ }