termlings 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +179 -0
- package/package.json +21 -0
- package/src/index.ts +784 -0
- package/src/ink/Avatar.tsx +129 -0
- package/src/ink/index.ts +2 -0
- package/src/react/Avatar.tsx +57 -0
- package/src/react/index.ts +2 -0
- package/src/svelte/Avatar.svelte +52 -0
- package/src/svelte/index.ts +1 -0
- package/src/vue/Avatar.vue +74 -0
- package/src/vue/index.ts +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# @touchgrass/avatar
|
|
2
|
+
|
|
3
|
+
Pixel-art avatar system for [touchgrass.sh](https://touchgrass.sh). Each avatar is encoded as a **7-character hex DNA string** (~32M combinations) that deterministically renders a unique character with hat, eyes, mouth, body, legs, and two independent color hues.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @touchgrass/avatar
|
|
9
|
+
# or
|
|
10
|
+
bun add @touchgrass/avatar
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Core (framework-agnostic)
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import {
|
|
19
|
+
generateRandomDNA,
|
|
20
|
+
decodeDNA,
|
|
21
|
+
encodeDNA,
|
|
22
|
+
generateGrid,
|
|
23
|
+
traitsFromName,
|
|
24
|
+
renderSVG,
|
|
25
|
+
renderTerminal,
|
|
26
|
+
renderTerminalSmall,
|
|
27
|
+
hslToRgb,
|
|
28
|
+
SLOTS,
|
|
29
|
+
EYES, MOUTHS, HATS, BODIES, LEGS,
|
|
30
|
+
} from '@touchgrass/avatar';
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
#### Generate a random avatar
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
const dna = generateRandomDNA(); // e.g. "0a3f201"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
#### Decode / encode DNA
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const traits = decodeDNA('0a3f201');
|
|
43
|
+
// { eyes: 0, mouth: 2, hat: 5, body: 1, legs: 3, faceHue: 8, hatHue: 2 }
|
|
44
|
+
|
|
45
|
+
const dna = encodeDNA(traits); // "0a3f201"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
#### Derive avatar from a name (no DNA needed)
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
const traits = traitsFromName('my-agent');
|
|
52
|
+
// Deterministic — same name always produces same traits
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
#### Render to SVG string
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const svg = renderSVG('0a3f201'); // default 10px per pixel
|
|
59
|
+
const svg = renderSVG('0a3f201', 20); // 20px per pixel
|
|
60
|
+
const svg = renderSVG('0a3f201', 10, 1); // walking frame 1
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Returns a complete `<svg>` string with transparent background. Use it as `innerHTML`, write to a `.svg` file, or embed in an `<img>` via data URI.
|
|
64
|
+
|
|
65
|
+
#### Render to terminal (ANSI)
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const ansi = renderTerminal('0a3f201'); // full size (██ per pixel)
|
|
69
|
+
const ansi = renderTerminalSmall('0a3f201'); // compact (half-block ▀▄)
|
|
70
|
+
|
|
71
|
+
console.log(ansi);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
#### Generate the pixel grid directly
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const grid = generateGrid(traits, walkFrame, talkFrame, waveFrame);
|
|
78
|
+
// Pixel[][] — 9 columns wide, variable rows tall
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Svelte
|
|
82
|
+
|
|
83
|
+
```svelte
|
|
84
|
+
<script>
|
|
85
|
+
import { Avatar } from '@touchgrass/avatar/svelte';
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<!-- From DNA string -->
|
|
89
|
+
<Avatar dna="0a3f201" />
|
|
90
|
+
|
|
91
|
+
<!-- From name (deterministic hash) -->
|
|
92
|
+
<Avatar name="my-agent" />
|
|
93
|
+
|
|
94
|
+
<!-- Sizes: sm (3px), lg (8px, default), xl (14px) -->
|
|
95
|
+
<Avatar dna="0a3f201" size="xl" />
|
|
96
|
+
|
|
97
|
+
<!-- Animations -->
|
|
98
|
+
<Avatar dna="0a3f201" walking />
|
|
99
|
+
<Avatar dna="0a3f201" talking />
|
|
100
|
+
<Avatar dna="0a3f201" waving />
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Props:
|
|
104
|
+
|
|
105
|
+
| Prop | Type | Default | Description |
|
|
106
|
+
|------|------|---------|-------------|
|
|
107
|
+
| `dna` | `string` | — | 7-char hex DNA string |
|
|
108
|
+
| `name` | `string` | — | Fallback: derive traits from name hash |
|
|
109
|
+
| `size` | `'sm' \| 'lg' \| 'xl'` | `'lg'` | Pixel size (3/8/14px per cell) |
|
|
110
|
+
| `walking` | `boolean` | `false` | Animate legs |
|
|
111
|
+
| `talking` | `boolean` | `false` | Animate mouth |
|
|
112
|
+
| `waving` | `boolean` | `false` | Animate arms |
|
|
113
|
+
|
|
114
|
+
Either `dna` or `name` should be provided. If both are set, `dna` takes priority.
|
|
115
|
+
|
|
116
|
+
## DNA Encoding
|
|
117
|
+
|
|
118
|
+
7 traits packed into a single integer using mixed-radix encoding with **fixed slot sizes** for forward compatibility:
|
|
119
|
+
|
|
120
|
+
| Trait | Variants | Slot size | Description |
|
|
121
|
+
|-------|----------|-----------|-------------|
|
|
122
|
+
| eyes | 11 | 12 | normal, wide, close, big, squint, narrow, etc. |
|
|
123
|
+
| mouths | 7 | 12 | smile, smirk, narrow, wide variants |
|
|
124
|
+
| hats | 24 | 24 | none, tophat, beanie, crown, cap, horns, mohawk, etc. |
|
|
125
|
+
| bodies | 6 | 8 | normal, narrow, tapered (each with/without arms) |
|
|
126
|
+
| legs | 6 | 8 | biped, quad, tentacles, thin, wide stance |
|
|
127
|
+
| faceHue | 12 | 12 | 0-330 degrees in 30-degree steps |
|
|
128
|
+
| hatHue | 12 | 12 | independent from face hue |
|
|
129
|
+
|
|
130
|
+
Total slot space: `12 x 12 x 24 x 8 x 8 x 12 x 12 = 31,850,496` (~32M, 7 hex chars).
|
|
131
|
+
|
|
132
|
+
New variants can be added within slot limits without breaking existing DNA strings. Legacy 6-char DNAs decode identically (leading zero is implicit).
|
|
133
|
+
|
|
134
|
+
## Pixel Grid
|
|
135
|
+
|
|
136
|
+
The avatar is a 9-column grid with variable height (depends on hat). Each cell is a `Pixel` type:
|
|
137
|
+
|
|
138
|
+
| Pixel | Meaning | Rendering |
|
|
139
|
+
|-------|---------|-----------|
|
|
140
|
+
| `f` | Face/body | Solid face color |
|
|
141
|
+
| `e` | Eye | Solid dark color |
|
|
142
|
+
| `s` | Squint eye | Face bg + dark bottom half |
|
|
143
|
+
| `n` | Narrow eye | Face bg + dark center strip |
|
|
144
|
+
| `m` | Mouth | Face bg + dark top half |
|
|
145
|
+
| `q` | Smile corner left | Face bg + dark bottom-right quarter |
|
|
146
|
+
| `r` | Smile corner right | Face bg + dark bottom-left quarter |
|
|
147
|
+
| `d` | Dark accent | Solid dark (hat bands, etc.) |
|
|
148
|
+
| `h` | Hat | Solid hat color |
|
|
149
|
+
| `l` | Thin leg | Half-width face color |
|
|
150
|
+
| `k` | Thin hat detail | Half-width hat color |
|
|
151
|
+
| `a` | Arm | Half-height face color |
|
|
152
|
+
| `_` | Transparent | Empty |
|
|
153
|
+
|
|
154
|
+
## Colors
|
|
155
|
+
|
|
156
|
+
Each avatar has 3 colors derived from two hue indices (0-11, mapped to 0-330 degrees):
|
|
157
|
+
|
|
158
|
+
- **Face color**: `hsl(faceHue * 30, 50%, 50%)`
|
|
159
|
+
- **Dark color**: `hsl(faceHue * 30, 50%, 28%)` — eyes, mouth
|
|
160
|
+
- **Hat color**: `hsl(hatHue * 30, 50%, 50%)`
|
|
161
|
+
|
|
162
|
+
## Animations
|
|
163
|
+
|
|
164
|
+
The grid generator supports three animation types via frame parameters:
|
|
165
|
+
|
|
166
|
+
- **Walking** (`frame`): Cycles through leg variant frames
|
|
167
|
+
- **Talking** (`talkFrame`): 0 = normal mouth, 1+ = open mouth animation
|
|
168
|
+
- **Waving** (`waveFrame`): 0 = normal body, 1+ = alternating arm positions
|
|
169
|
+
|
|
170
|
+
## Exports
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
@touchgrass/avatar — Core TypeScript (DNA, grid, SVG, terminal, colors)
|
|
174
|
+
@touchgrass/avatar/svelte — Svelte 5 component (Avatar)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "termlings",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Pixel art avatar system. 32M+ unique characters from a 7-char hex DNA string.",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/tomtev/touchgrass",
|
|
10
|
+
"directory": "packages/avatar"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["avatar", "pixel-art", "svelte", "react", "vue", "terminal", "ink"],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./svelte": "./src/svelte/index.ts",
|
|
16
|
+
"./react": "./src/react/index.ts",
|
|
17
|
+
"./vue": "./src/vue/index.ts",
|
|
18
|
+
"./ink": "./src/ink/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"files": ["src"]
|
|
21
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
// Agent DNA avatar system
|
|
2
|
+
// Encodes visual identity as a 6-hex-char string (~16M combinations)
|
|
3
|
+
|
|
4
|
+
export type Pixel = "f" | "e" | "s" | "n" | "m" | "d" | "h" | "l" | "k" | "q" | "r" | "a" | "_";
|
|
5
|
+
// f = face/body, e = eye (dark, full block), s = squint eye (dark, thin horizontal ▄▄),
|
|
6
|
+
// n = narrow eye (dark, thin vertical ▐▌),
|
|
7
|
+
// m = mouth (dark, thin ▀▀ in terminal),
|
|
8
|
+
// d = dark accent (full-block dark, for hat bands etc.),
|
|
9
|
+
// h = hat (secondary hue), l = thin leg (face color, ▌ in terminal),
|
|
10
|
+
// k = thin hat (hat color, ▐▌ in terminal),
|
|
11
|
+
// q = smile corner left (dark ▗ on face bg), r = smile corner right (dark ▖ on face bg),
|
|
12
|
+
// a = arm (face color, thin horizontal ▄▄ in terminal),
|
|
13
|
+
// _ = transparent
|
|
14
|
+
|
|
15
|
+
// Face row template (7px wide head centered in 9-col grid)
|
|
16
|
+
const F: Pixel[] = ["_", "f", "f", "f", "f", "f", "f", "f", "_"];
|
|
17
|
+
|
|
18
|
+
// --- Eye variants (1 row each) ---
|
|
19
|
+
export const EYES: Pixel[][] = [
|
|
20
|
+
["_", "f", "e", "f", "f", "f", "e", "f", "_"], // normal
|
|
21
|
+
["_", "e", "f", "f", "f", "f", "f", "e", "_"], // wide
|
|
22
|
+
["_", "f", "f", "e", "f", "e", "f", "f", "_"], // close
|
|
23
|
+
["_", "f", "e", "f", "f", "f", "e", "f", "_"], // normal-alt
|
|
24
|
+
["_", "e", "e", "f", "f", "f", "e", "e", "_"], // big
|
|
25
|
+
["_", "f", "e", "e", "f", "e", "e", "f", "_"], // big close
|
|
26
|
+
["_", "f", "s", "f", "f", "f", "s", "f", "_"], // squint
|
|
27
|
+
["_", "s", "f", "f", "f", "f", "f", "s", "_"], // squint wide
|
|
28
|
+
["_", "f", "n", "f", "f", "f", "n", "f", "_"], // narrow
|
|
29
|
+
["_", "n", "f", "f", "f", "f", "f", "n", "_"], // narrow wide
|
|
30
|
+
["_", "f", "f", "n", "f", "n", "f", "f", "_"], // narrow close
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// --- Mouth variants (2 rows: gap + mouth) ---
|
|
34
|
+
// Uses thin half-block rendering: m=▀▀, q=▗ corner left, r=▖ corner right
|
|
35
|
+
export const MOUTHS: Pixel[][][] = [
|
|
36
|
+
[
|
|
37
|
+
// smile (default)
|
|
38
|
+
["_", "f", "q", "f", "f", "f", "r", "f", "_"],
|
|
39
|
+
["_", "f", "f", "m", "m", "m", "f", "f", "_"],
|
|
40
|
+
],
|
|
41
|
+
[
|
|
42
|
+
// smirk left
|
|
43
|
+
["_", "f", "q", "f", "f", "f", "f", "f", "_"],
|
|
44
|
+
["_", "f", "f", "m", "m", "m", "f", "f", "_"],
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
// smirk right
|
|
48
|
+
["_", "f", "f", "f", "f", "f", "r", "f", "_"],
|
|
49
|
+
["_", "f", "f", "m", "m", "m", "f", "f", "_"],
|
|
50
|
+
],
|
|
51
|
+
[
|
|
52
|
+
// narrow
|
|
53
|
+
["_", "f", "f", "q", "f", "r", "f", "f", "_"],
|
|
54
|
+
["_", "f", "f", "f", "m", "f", "f", "f", "_"],
|
|
55
|
+
],
|
|
56
|
+
[
|
|
57
|
+
// wide smile
|
|
58
|
+
["_", "q", "f", "f", "f", "f", "f", "r", "_"],
|
|
59
|
+
["_", "f", "m", "m", "m", "m", "m", "f", "_"],
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
// wide smirk left
|
|
63
|
+
["_", "q", "f", "f", "f", "f", "f", "f", "_"],
|
|
64
|
+
["_", "f", "m", "m", "m", "m", "m", "f", "_"],
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
// wide smirk right
|
|
68
|
+
["_", "f", "f", "f", "f", "f", "f", "r", "_"],
|
|
69
|
+
["_", "f", "m", "m", "m", "m", "m", "f", "_"],
|
|
70
|
+
],
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// --- Hat variants (0-3 rows) ---
|
|
74
|
+
export const HATS: Pixel[][][] = [
|
|
75
|
+
[], // none
|
|
76
|
+
[
|
|
77
|
+
// tophat
|
|
78
|
+
["_", "_", "_", "h", "h", "h", "_", "_", "_"],
|
|
79
|
+
["_", "_", "_", "h", "h", "h", "_", "_", "_"],
|
|
80
|
+
["_", "h", "h", "h", "h", "h", "h", "h", "_"],
|
|
81
|
+
],
|
|
82
|
+
[
|
|
83
|
+
// beanie
|
|
84
|
+
["_", "_", "_", "h", "h", "h", "_", "_", "_"],
|
|
85
|
+
["_", "_", "h", "h", "h", "h", "h", "_", "_"],
|
|
86
|
+
],
|
|
87
|
+
[
|
|
88
|
+
// crown
|
|
89
|
+
["_", "_", "h", "_", "h", "_", "h", "_", "_"],
|
|
90
|
+
["_", "_", "h", "h", "h", "h", "h", "_", "_"],
|
|
91
|
+
],
|
|
92
|
+
[
|
|
93
|
+
// cap
|
|
94
|
+
["_", "_", "h", "h", "h", "h", "h", "_", "_"],
|
|
95
|
+
["h", "h", "h", "h", "h", "h", "h", "_", "_"],
|
|
96
|
+
],
|
|
97
|
+
[
|
|
98
|
+
// horns
|
|
99
|
+
["_", "h", "_", "_", "_", "_", "_", "h", "_"],
|
|
100
|
+
["_", "h", "h", "_", "_", "_", "h", "h", "_"],
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
// mohawk
|
|
104
|
+
["_", "_", "_", "_", "h", "_", "_", "_", "_"],
|
|
105
|
+
["_", "_", "_", "h", "h", "h", "_", "_", "_"],
|
|
106
|
+
],
|
|
107
|
+
[
|
|
108
|
+
// antenna
|
|
109
|
+
["_", "_", "_", "_", "h", "_", "_", "_", "_"],
|
|
110
|
+
["_", "_", "_", "_", "h", "_", "_", "_", "_"],
|
|
111
|
+
],
|
|
112
|
+
[
|
|
113
|
+
// halo
|
|
114
|
+
["_", "_", "_", "h", "h", "h", "_", "_", "_"],
|
|
115
|
+
],
|
|
116
|
+
[
|
|
117
|
+
// bandage
|
|
118
|
+
["_", "f", "f", "f", "f", "f", "f", "f", "_"],
|
|
119
|
+
["_", "h", "h", "h", "h", "h", "h", "h", "_"],
|
|
120
|
+
],
|
|
121
|
+
[
|
|
122
|
+
// wide brim
|
|
123
|
+
["_", "_", "h", "h", "h", "h", "h", "_", "_"],
|
|
124
|
+
["h", "h", "h", "h", "h", "h", "h", "h", "h"],
|
|
125
|
+
],
|
|
126
|
+
[
|
|
127
|
+
// unicorn horn
|
|
128
|
+
["_", "_", "_", "_", "k", "_", "_", "_", "_"],
|
|
129
|
+
["_", "_", "_", "_", "h", "_", "_", "_", "_"],
|
|
130
|
+
["_", "_", "_", "h", "h", "h", "_", "_", "_"],
|
|
131
|
+
],
|
|
132
|
+
[
|
|
133
|
+
// ears
|
|
134
|
+
["_", "h", "h", "_", "_", "_", "h", "h", "_"],
|
|
135
|
+
],
|
|
136
|
+
[
|
|
137
|
+
// spikes
|
|
138
|
+
["_", "h", "_", "h", "_", "h", "_", "h", "_"],
|
|
139
|
+
["_", "h", "h", "h", "h", "h", "h", "h", "_"],
|
|
140
|
+
],
|
|
141
|
+
[
|
|
142
|
+
// party hat
|
|
143
|
+
["_", "_", "_", "_", "h", "_", "_", "_", "_"],
|
|
144
|
+
["_", "_", "_", "h", "h", "h", "_", "_", "_"],
|
|
145
|
+
["_", "_", "h", "h", "h", "h", "h", "_", "_"],
|
|
146
|
+
],
|
|
147
|
+
[
|
|
148
|
+
// flat top
|
|
149
|
+
["_", "h", "h", "h", "h", "h", "h", "h", "_"],
|
|
150
|
+
["_", "h", "h", "h", "h", "h", "h", "h", "_"],
|
|
151
|
+
],
|
|
152
|
+
[
|
|
153
|
+
// afro
|
|
154
|
+
["_", "_", "k", "h", "h", "h", "k", "_", "_"],
|
|
155
|
+
["_", "k", "h", "h", "h", "h", "h", "k", "_"],
|
|
156
|
+
],
|
|
157
|
+
[
|
|
158
|
+
// side sweep
|
|
159
|
+
["_", "_", "_", "_", "h", "h", "h", "k", "_"],
|
|
160
|
+
["_", "h", "h", "h", "h", "h", "h", "h", "_"],
|
|
161
|
+
],
|
|
162
|
+
[
|
|
163
|
+
// cowboy hat
|
|
164
|
+
["_", "_", "h", "h", "h", "h", "h", "_", "_"],
|
|
165
|
+
["_", "_", "h", "h", "h", "h", "h", "_", "_"],
|
|
166
|
+
["h", "h", "h", "h", "h", "h", "h", "h", "h"],
|
|
167
|
+
],
|
|
168
|
+
[
|
|
169
|
+
// knitted hat
|
|
170
|
+
["_", "_", "_", "h", "h", "h", "_", "_", "_"],
|
|
171
|
+
["_", "_", "h", "h", "h", "h", "h", "_", "_"],
|
|
172
|
+
["_", "h", "f", "h", "f", "h", "f", "h", "_"],
|
|
173
|
+
],
|
|
174
|
+
[
|
|
175
|
+
// clown hair
|
|
176
|
+
["h", "h", "_", "_", "_", "_", "_", "h", "h"],
|
|
177
|
+
["h", "h", "h", "_", "_", "_", "h", "h", "h"],
|
|
178
|
+
],
|
|
179
|
+
[
|
|
180
|
+
// stovepipe
|
|
181
|
+
["_", "h", "h", "h", "h", "h", "h", "h", "_"],
|
|
182
|
+
["_", "h", "h", "h", "h", "h", "h", "h", "_"],
|
|
183
|
+
["_", "h", "h", "h", "h", "h", "h", "h", "_"],
|
|
184
|
+
["_", "d", "d", "d", "d", "d", "d", "d", "_"],
|
|
185
|
+
["h", "h", "h", "h", "h", "h", "h", "h", "h"],
|
|
186
|
+
],
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
// --- Body variants (flat, no animation frames) ---
|
|
190
|
+
export const BODIES: Pixel[][][] = [
|
|
191
|
+
[
|
|
192
|
+
// normal
|
|
193
|
+
["_", "f", "f", "f", "f", "f", "f", "f", "_"],
|
|
194
|
+
["_", "f", "f", "f", "f", "f", "f", "f", "_"],
|
|
195
|
+
],
|
|
196
|
+
[
|
|
197
|
+
// normal-arms
|
|
198
|
+
["a", "f", "f", "f", "f", "f", "f", "f", "a"],
|
|
199
|
+
["_", "f", "f", "f", "f", "f", "f", "f", "_"],
|
|
200
|
+
],
|
|
201
|
+
[
|
|
202
|
+
// narrow
|
|
203
|
+
["_", "_", "f", "f", "f", "f", "f", "_", "_"],
|
|
204
|
+
["_", "_", "f", "f", "f", "f", "f", "_", "_"],
|
|
205
|
+
],
|
|
206
|
+
[
|
|
207
|
+
// narrow-arms
|
|
208
|
+
["_", "a", "f", "f", "f", "f", "f", "a", "_"],
|
|
209
|
+
["_", "_", "f", "f", "f", "f", "f", "_", "_"],
|
|
210
|
+
],
|
|
211
|
+
[
|
|
212
|
+
// tapered
|
|
213
|
+
["_", "f", "f", "f", "f", "f", "f", "f", "_"],
|
|
214
|
+
["_", "_", "f", "f", "f", "f", "f", "_", "_"],
|
|
215
|
+
],
|
|
216
|
+
[
|
|
217
|
+
// tapered-arms
|
|
218
|
+
["a", "f", "f", "f", "f", "f", "f", "f", "a"],
|
|
219
|
+
["_", "_", "f", "f", "f", "f", "f", "_", "_"],
|
|
220
|
+
],
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
// --- Leg variants (multi-frame for walking animation) ---
|
|
224
|
+
// Each variant is an array of frames. Frame 0 = standing pose.
|
|
225
|
+
export const LEGS: Pixel[][][] = [
|
|
226
|
+
[ // biped
|
|
227
|
+
["_", "_", "f", "_", "_", "f", "_", "_", "_"],
|
|
228
|
+
["_", "f", "_", "_", "_", "_", "f", "_", "_"],
|
|
229
|
+
],
|
|
230
|
+
[ // outer (thin, alternating pairs)
|
|
231
|
+
["_", "_", "l", "_", "_", "_", "_", "l", "_"],
|
|
232
|
+
["_", "_", "_", "l", "_", "l", "_", "_", "_"],
|
|
233
|
+
],
|
|
234
|
+
[ // tentacles (outer thick, inner stagger)
|
|
235
|
+
["_", "f", "_", "l", "_", "l", "_", "f", "_"],
|
|
236
|
+
["_", "f", "_", "f", "_", "l", "_", "f", "_"],
|
|
237
|
+
["_", "f", "_", "l", "_", "f", "_", "f", "_"],
|
|
238
|
+
],
|
|
239
|
+
[ // thin biped
|
|
240
|
+
["_", "_", "l", "_", "_", "_", "l", "_", "_"],
|
|
241
|
+
["_", "_", "_", "l", "_", "l", "_", "_", "_"],
|
|
242
|
+
],
|
|
243
|
+
[ // wide stance
|
|
244
|
+
["_", "f", "_", "_", "_", "_", "_", "f", "_"],
|
|
245
|
+
["_", "_", "f", "_", "_", "_", "f", "_", "_"],
|
|
246
|
+
],
|
|
247
|
+
[ // thin narrow
|
|
248
|
+
["_", "_", "_", "l", "_", "l", "_", "_", "_"],
|
|
249
|
+
["_", "_", "l", "_", "_", "_", "l", "_", "_"],
|
|
250
|
+
],
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
// Fixed slot sizes for stable DNA encoding.
|
|
254
|
+
// Adding new variants within these limits won't break existing DNA strings.
|
|
255
|
+
// 12 * 12 * 24 * 8 * 8 * 12 * 12 = 15,925,248 (~16M, fits in 6 hex chars)
|
|
256
|
+
export const SLOTS = {
|
|
257
|
+
eyes: 12,
|
|
258
|
+
mouths: 12,
|
|
259
|
+
hats: 24,
|
|
260
|
+
bodies: 8,
|
|
261
|
+
legs: 8,
|
|
262
|
+
hues: 12,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
export interface DecodedDNA {
|
|
266
|
+
eyes: number;
|
|
267
|
+
mouth: number;
|
|
268
|
+
hat: number;
|
|
269
|
+
body: number;
|
|
270
|
+
legs: number;
|
|
271
|
+
faceHue: number; // 0-11 index -> multiply by 30 for degrees
|
|
272
|
+
hatHue: number; // 0-11 index -> multiply by 30 for degrees
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Encode trait indices into a 7-character hex DNA string.
|
|
277
|
+
* Uses fixed slot sizes so adding traits doesn't break existing DNAs.
|
|
278
|
+
* Existing 6-char DNAs are decoded identically (leading zero is implicit).
|
|
279
|
+
*/
|
|
280
|
+
export function encodeDNA(traits: DecodedDNA): string {
|
|
281
|
+
let n = traits.hatHue;
|
|
282
|
+
n += traits.faceHue * SLOTS.hues;
|
|
283
|
+
n += traits.legs * SLOTS.hues * SLOTS.hues;
|
|
284
|
+
n += traits.body * SLOTS.legs * SLOTS.hues * SLOTS.hues;
|
|
285
|
+
n += traits.hat * SLOTS.bodies * SLOTS.legs * SLOTS.hues * SLOTS.hues;
|
|
286
|
+
n += traits.mouth * SLOTS.hats * SLOTS.bodies * SLOTS.legs * SLOTS.hues * SLOTS.hues;
|
|
287
|
+
n += traits.eyes * SLOTS.mouths * SLOTS.hats * SLOTS.bodies * SLOTS.legs * SLOTS.hues * SLOTS.hues;
|
|
288
|
+
return n.toString(16).padStart(7, "0");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Decode a hex DNA string (6-7 chars) into trait indices.
|
|
293
|
+
* Clamps to actual array lengths for forward compatibility.
|
|
294
|
+
*/
|
|
295
|
+
export function decodeDNA(hex: string): DecodedDNA {
|
|
296
|
+
let n = parseInt(hex, 16);
|
|
297
|
+
const hatHue = n % SLOTS.hues;
|
|
298
|
+
n = Math.floor(n / SLOTS.hues);
|
|
299
|
+
const faceHue = n % SLOTS.hues;
|
|
300
|
+
n = Math.floor(n / SLOTS.hues);
|
|
301
|
+
const legs = n % SLOTS.legs;
|
|
302
|
+
n = Math.floor(n / SLOTS.legs);
|
|
303
|
+
const body = n % SLOTS.bodies;
|
|
304
|
+
n = Math.floor(n / SLOTS.bodies);
|
|
305
|
+
const hat = n % SLOTS.hats;
|
|
306
|
+
n = Math.floor(n / SLOTS.hats);
|
|
307
|
+
const mouth = n % SLOTS.mouths;
|
|
308
|
+
n = Math.floor(n / SLOTS.mouths);
|
|
309
|
+
const eyes = n % SLOTS.eyes;
|
|
310
|
+
return {
|
|
311
|
+
eyes: eyes % EYES.length,
|
|
312
|
+
mouth: mouth % MOUTHS.length,
|
|
313
|
+
hat: hat % HATS.length,
|
|
314
|
+
body: body % BODIES.length,
|
|
315
|
+
legs: legs % LEGS.length,
|
|
316
|
+
faceHue: faceHue % SLOTS.hues,
|
|
317
|
+
hatHue: hatHue % SLOTS.hues,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Derive deterministic traits from a name string (hash-based fallback when no DNA is set).
|
|
323
|
+
*/
|
|
324
|
+
export function traitsFromName(name: string): DecodedDNA {
|
|
325
|
+
let hash = 0;
|
|
326
|
+
for (let i = 0; i < name.length; i++) {
|
|
327
|
+
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
328
|
+
}
|
|
329
|
+
// Bit-mixing finalizer: spreads entropy across all 32 bits
|
|
330
|
+
// so even short strings produce varied upper-bit values (hues).
|
|
331
|
+
hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
|
|
332
|
+
hash = Math.imul(hash ^ (hash >>> 13), 0x45d9f3b);
|
|
333
|
+
hash = hash ^ (hash >>> 16);
|
|
334
|
+
const h = Math.abs(hash);
|
|
335
|
+
return {
|
|
336
|
+
eyes: h % EYES.length,
|
|
337
|
+
mouth: (h >>> 4) % MOUTHS.length,
|
|
338
|
+
hat: (h >>> 8) % HATS.length,
|
|
339
|
+
body: (h >>> 14) % BODIES.length,
|
|
340
|
+
legs: (h >>> 18) % LEGS.length,
|
|
341
|
+
faceHue: (h >>> 22) % 12,
|
|
342
|
+
hatHue: (h >>> 26) % 12,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Generate a random valid DNA string.
|
|
348
|
+
*/
|
|
349
|
+
export function generateRandomDNA(): string {
|
|
350
|
+
return encodeDNA({
|
|
351
|
+
eyes: Math.floor(Math.random() * EYES.length),
|
|
352
|
+
mouth: Math.floor(Math.random() * MOUTHS.length),
|
|
353
|
+
hat: Math.floor(Math.random() * HATS.length),
|
|
354
|
+
body: Math.floor(Math.random() * BODIES.length),
|
|
355
|
+
legs: Math.floor(Math.random() * LEGS.length),
|
|
356
|
+
faceHue: Math.floor(Math.random() * SLOTS.hues),
|
|
357
|
+
hatHue: Math.floor(Math.random() * SLOTS.hues),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// --- Wave animation frames (override body when waving) ---
|
|
362
|
+
export const WAVE_FRAMES: Pixel[][][] = [
|
|
363
|
+
[
|
|
364
|
+
// left up, right down
|
|
365
|
+
["a", "f", "f", "f", "f", "f", "f", "f", "_"],
|
|
366
|
+
["_", "f", "f", "f", "f", "f", "f", "f", "a"],
|
|
367
|
+
],
|
|
368
|
+
[
|
|
369
|
+
// left down, right up
|
|
370
|
+
["_", "f", "f", "f", "f", "f", "f", "f", "a"],
|
|
371
|
+
["a", "f", "f", "f", "f", "f", "f", "f", "_"],
|
|
372
|
+
],
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
// --- Talk animation frames (universal, override mouth when talking) ---
|
|
376
|
+
// Cycle: agent's normal mouth (talkFrame=0) → open → repeat
|
|
377
|
+
export const TALK_FRAMES: Pixel[][][] = [
|
|
378
|
+
[
|
|
379
|
+
// open mouth (full dark, no corners)
|
|
380
|
+
["_", "f", "f", "f", "f", "f", "f", "f", "_"],
|
|
381
|
+
["_", "f", "f", "d", "d", "d", "f", "f", "_"],
|
|
382
|
+
],
|
|
383
|
+
];
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Generate the pixel grid from decoded DNA traits.
|
|
387
|
+
* @param frame Walking animation frame index (0 = standing). Wraps automatically.
|
|
388
|
+
* @param talkFrame Talk animation frame (0 = normal mouth, 1+ = talk frames). Wraps automatically.
|
|
389
|
+
* @param waveFrame Wave animation frame (0 = normal body, 1+ = wave frames). Wraps automatically.
|
|
390
|
+
*/
|
|
391
|
+
export function generateGrid(traits: DecodedDNA, frame = 0, talkFrame = 0, waveFrame = 0): Pixel[][] {
|
|
392
|
+
const legFrames = LEGS[traits.legs];
|
|
393
|
+
const legRow = legFrames[frame % legFrames.length];
|
|
394
|
+
const mouthRows = talkFrame === 0
|
|
395
|
+
? MOUTHS[traits.mouth]
|
|
396
|
+
: TALK_FRAMES[(talkFrame - 1) % TALK_FRAMES.length];
|
|
397
|
+
const bodyRows = waveFrame === 0
|
|
398
|
+
? BODIES[traits.body]
|
|
399
|
+
: WAVE_FRAMES[(waveFrame - 1) % WAVE_FRAMES.length];
|
|
400
|
+
return [
|
|
401
|
+
...HATS[traits.hat],
|
|
402
|
+
F,
|
|
403
|
+
EYES[traits.eyes],
|
|
404
|
+
...mouthRows,
|
|
405
|
+
...bodyRows,
|
|
406
|
+
legRow,
|
|
407
|
+
];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Convert HSL to RGB. h in [0,360], s and l in [0,1]. Returns [r,g,b] each in [0,255].
|
|
412
|
+
*/
|
|
413
|
+
export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
|
414
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
415
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
416
|
+
const m = l - c / 2;
|
|
417
|
+
let r1: number, g1: number, b1: number;
|
|
418
|
+
if (h < 60) {
|
|
419
|
+
[r1, g1, b1] = [c, x, 0];
|
|
420
|
+
} else if (h < 120) {
|
|
421
|
+
[r1, g1, b1] = [x, c, 0];
|
|
422
|
+
} else if (h < 180) {
|
|
423
|
+
[r1, g1, b1] = [0, c, x];
|
|
424
|
+
} else if (h < 240) {
|
|
425
|
+
[r1, g1, b1] = [0, x, c];
|
|
426
|
+
} else if (h < 300) {
|
|
427
|
+
[r1, g1, b1] = [x, 0, c];
|
|
428
|
+
} else {
|
|
429
|
+
[r1, g1, b1] = [c, 0, x];
|
|
430
|
+
}
|
|
431
|
+
return [
|
|
432
|
+
Math.round((r1 + m) * 255),
|
|
433
|
+
Math.round((g1 + m) * 255),
|
|
434
|
+
Math.round((b1 + m) * 255),
|
|
435
|
+
];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Render a DNA string as an SVG string with transparent background.
|
|
440
|
+
* Each pixel is rendered as a square rect. 1-cell padding around the grid.
|
|
441
|
+
*/
|
|
442
|
+
export function renderSVG(dna: string, pixelSize = 10, frame = 0, background: string | null = '#000'): string {
|
|
443
|
+
const traits = decodeDNA(dna);
|
|
444
|
+
const grid = generateGrid(traits, frame);
|
|
445
|
+
|
|
446
|
+
const faceHueDeg = traits.faceHue * 30;
|
|
447
|
+
const hatHueDeg = traits.hatHue * 30;
|
|
448
|
+
|
|
449
|
+
const faceRgb = hslToRgb(faceHueDeg, 0.5, 0.5);
|
|
450
|
+
const darkRgb = hslToRgb(faceHueDeg, 0.5, 0.28);
|
|
451
|
+
const hatRgb = hslToRgb(hatHueDeg, 0.5, 0.5);
|
|
452
|
+
|
|
453
|
+
const toHex = (r: number, g: number, b: number) =>
|
|
454
|
+
`#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
|
455
|
+
|
|
456
|
+
const faceHex = toHex(...faceRgb);
|
|
457
|
+
const darkHex = toHex(...darkRgb);
|
|
458
|
+
const hatHex = toHex(...hatRgb);
|
|
459
|
+
|
|
460
|
+
const cols = 9;
|
|
461
|
+
const rows = grid.length;
|
|
462
|
+
const pad = 1; // 1-cell padding
|
|
463
|
+
const w = (cols + pad * 2) * pixelSize;
|
|
464
|
+
const h = (rows + pad * 2) * pixelSize;
|
|
465
|
+
|
|
466
|
+
const half = Math.round(pixelSize / 2);
|
|
467
|
+
const quarter = Math.round(pixelSize / 4);
|
|
468
|
+
const rects: string[] = [];
|
|
469
|
+
for (let y = 0; y < rows; y++) {
|
|
470
|
+
for (let x = 0; x < cols; x++) {
|
|
471
|
+
const cell = grid[y][x];
|
|
472
|
+
const rx = (x + pad) * pixelSize;
|
|
473
|
+
const ry = (y + pad) * pixelSize;
|
|
474
|
+
if (cell === "f") {
|
|
475
|
+
rects.push(`<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${faceHex}"/>`);
|
|
476
|
+
} else if (cell === "l") {
|
|
477
|
+
rects.push(`<rect x="${rx}" y="${ry}" width="${half}" height="${pixelSize}" fill="${faceHex}"/>`);
|
|
478
|
+
} else if (cell === "a") {
|
|
479
|
+
rects.push(`<rect x="${rx}" y="${ry + half}" width="${pixelSize}" height="${half}" fill="${faceHex}"/>`);
|
|
480
|
+
} else if (cell === "e" || cell === "d") {
|
|
481
|
+
rects.push(`<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${darkHex}"/>`);
|
|
482
|
+
} else if (cell === "s") {
|
|
483
|
+
// Squint eye: face bg + dark bottom half
|
|
484
|
+
rects.push(`<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${faceHex}"/>`);
|
|
485
|
+
rects.push(`<rect x="${rx}" y="${ry + half}" width="${pixelSize}" height="${half}" fill="${darkHex}"/>`);
|
|
486
|
+
} else if (cell === "n") {
|
|
487
|
+
// Narrow eye: face bg + dark center half-width
|
|
488
|
+
rects.push(`<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${faceHex}"/>`);
|
|
489
|
+
rects.push(`<rect x="${rx + quarter}" y="${ry}" width="${half}" height="${pixelSize}" fill="${darkHex}"/>`);
|
|
490
|
+
} else if (cell === "m") {
|
|
491
|
+
// Thin mouth: face bg + dark top half
|
|
492
|
+
rects.push(`<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${faceHex}"/>`);
|
|
493
|
+
rects.push(`<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${half}" fill="${darkHex}"/>`);
|
|
494
|
+
} else if (cell === "q") {
|
|
495
|
+
// Corner left: face bg + dark bottom-right quarter
|
|
496
|
+
rects.push(`<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${faceHex}"/>`);
|
|
497
|
+
rects.push(`<rect x="${rx + half}" y="${ry + half}" width="${half}" height="${half}" fill="${darkHex}"/>`);
|
|
498
|
+
} else if (cell === "r") {
|
|
499
|
+
// Corner right: face bg + dark bottom-left quarter
|
|
500
|
+
rects.push(`<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${faceHex}"/>`);
|
|
501
|
+
rects.push(`<rect x="${rx}" y="${ry + half}" width="${half}" height="${half}" fill="${darkHex}"/>`);
|
|
502
|
+
} else if (cell === "h") {
|
|
503
|
+
rects.push(`<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${hatHex}"/>`);
|
|
504
|
+
} else if (cell === "k") {
|
|
505
|
+
rects.push(`<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${hatHex}"/>`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const bg = background ? `<rect width="${w}" height="${h}" fill="${background}"/>\n` : '';
|
|
511
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" shape-rendering="crispEdges">\n${bg}${rects.join("\n")}\n</svg>`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Render a DNA string as ANSI colored pixel art for the terminal.
|
|
516
|
+
* Uses `██` per pixel (2 chars wide for square proportions).
|
|
517
|
+
*/
|
|
518
|
+
export function renderTerminal(dna: string, frame = 0): string {
|
|
519
|
+
const traits = decodeDNA(dna);
|
|
520
|
+
const grid = generateGrid(traits, frame);
|
|
521
|
+
|
|
522
|
+
const faceHueDeg = traits.faceHue * 30;
|
|
523
|
+
const hatHueDeg = traits.hatHue * 30;
|
|
524
|
+
|
|
525
|
+
const faceRgb = hslToRgb(faceHueDeg, 0.5, 0.5);
|
|
526
|
+
const darkRgb = hslToRgb(faceHueDeg, 0.5, 0.28);
|
|
527
|
+
const hatRgb = hslToRgb(hatHueDeg, 0.5, 0.5);
|
|
528
|
+
|
|
529
|
+
const faceAnsi = `\x1b[38;2;${faceRgb[0]};${faceRgb[1]};${faceRgb[2]}m`;
|
|
530
|
+
const darkAnsi = `\x1b[38;2;${darkRgb[0]};${darkRgb[1]};${darkRgb[2]}m`;
|
|
531
|
+
const hatAnsi = `\x1b[38;2;${hatRgb[0]};${hatRgb[1]};${hatRgb[2]}m`;
|
|
532
|
+
const reset = "\x1b[0m";
|
|
533
|
+
|
|
534
|
+
const faceBg = `\x1b[48;2;${faceRgb[0]};${faceRgb[1]};${faceRgb[2]}m`;
|
|
535
|
+
|
|
536
|
+
const lines: string[] = [];
|
|
537
|
+
for (const row of grid) {
|
|
538
|
+
let line = "";
|
|
539
|
+
for (const cell of row) {
|
|
540
|
+
if (cell === "_") {
|
|
541
|
+
line += " ";
|
|
542
|
+
} else if (cell === "f") {
|
|
543
|
+
line += `${faceAnsi}██${reset}`;
|
|
544
|
+
} else if (cell === "l") {
|
|
545
|
+
line += `${faceAnsi}▌${reset} `;
|
|
546
|
+
} else if (cell === "e" || cell === "d") {
|
|
547
|
+
line += `${darkAnsi}██${reset}`;
|
|
548
|
+
} else if (cell === "s") {
|
|
549
|
+
line += `${darkAnsi}${faceBg}▄▄${reset}`;
|
|
550
|
+
} else if (cell === "n") {
|
|
551
|
+
line += `${darkAnsi}${faceBg}▐▌${reset}`;
|
|
552
|
+
} else if (cell === "m") {
|
|
553
|
+
line += `${darkAnsi}${faceBg}▀▀${reset}`;
|
|
554
|
+
} else if (cell === "q") {
|
|
555
|
+
line += `${darkAnsi}${faceBg} ▗${reset}`;
|
|
556
|
+
} else if (cell === "r") {
|
|
557
|
+
line += `${darkAnsi}${faceBg}▖ ${reset}`;
|
|
558
|
+
} else if (cell === "a") {
|
|
559
|
+
line += `${faceAnsi}▄▄${reset}`;
|
|
560
|
+
} else if (cell === "h") {
|
|
561
|
+
line += `${hatAnsi}██${reset}`;
|
|
562
|
+
} else if (cell === "k") {
|
|
563
|
+
line += `${hatAnsi}▐▌${reset}`;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
lines.push(line);
|
|
567
|
+
}
|
|
568
|
+
return lines.join("\n");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Compact terminal renderer using half-block characters.
|
|
573
|
+
* Packs two pixel rows into one terminal line using ▀/▄ with fg/bg colors.
|
|
574
|
+
* Roughly half the height and width of renderTerminal.
|
|
575
|
+
*/
|
|
576
|
+
/**
|
|
577
|
+
* Render a DNA string as a layered SVG with separate groups for animated parts.
|
|
578
|
+
* Returns the SVG string, number of leg frames, and total row count.
|
|
579
|
+
* Used by framework components for CSS-only animation (no JS timers).
|
|
580
|
+
*/
|
|
581
|
+
export function renderLayeredSVG(dna: string, pixelSize = 10): {
|
|
582
|
+
svg: string;
|
|
583
|
+
legFrames: number;
|
|
584
|
+
rows: number;
|
|
585
|
+
} {
|
|
586
|
+
const traits = decodeDNA(dna);
|
|
587
|
+
|
|
588
|
+
const faceHueDeg = traits.faceHue * 30;
|
|
589
|
+
const hatHueDeg = traits.hatHue * 30;
|
|
590
|
+
|
|
591
|
+
const faceRgb = hslToRgb(faceHueDeg, 0.5, 0.5);
|
|
592
|
+
const darkRgb = hslToRgb(faceHueDeg, 0.5, 0.28);
|
|
593
|
+
const hatRgb = hslToRgb(hatHueDeg, 0.5, 0.5);
|
|
594
|
+
|
|
595
|
+
const toHex = (r: number, g: number, b: number) =>
|
|
596
|
+
`#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
|
597
|
+
|
|
598
|
+
const faceHex = toHex(...faceRgb);
|
|
599
|
+
const darkHex = toHex(...darkRgb);
|
|
600
|
+
const hatHex = toHex(...hatRgb);
|
|
601
|
+
|
|
602
|
+
const half = Math.round(pixelSize / 2);
|
|
603
|
+
const quarter = Math.round(pixelSize / 4);
|
|
604
|
+
const cols = 9;
|
|
605
|
+
|
|
606
|
+
const hatRows = HATS[traits.hat];
|
|
607
|
+
const mouthNormal = MOUTHS[traits.mouth];
|
|
608
|
+
const mouthTalk = TALK_FRAMES[0];
|
|
609
|
+
const bodyNormal = BODIES[traits.body];
|
|
610
|
+
const waveF1 = WAVE_FRAMES[0];
|
|
611
|
+
const waveF2 = WAVE_FRAMES[1];
|
|
612
|
+
const legVariant = LEGS[traits.legs];
|
|
613
|
+
const legFrameCount = legVariant.length;
|
|
614
|
+
|
|
615
|
+
// hat + face + eyes + 2 mouth + 2 body + 1 legs
|
|
616
|
+
const totalRows = hatRows.length + 1 + 1 + 2 + 2 + 1;
|
|
617
|
+
const w = cols * pixelSize;
|
|
618
|
+
const h = totalRows * pixelSize;
|
|
619
|
+
|
|
620
|
+
function px(cell: Pixel, rx: number, ry: number): string {
|
|
621
|
+
if (cell === "_") return "";
|
|
622
|
+
if (cell === "f") return `<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${faceHex}"/>`;
|
|
623
|
+
if (cell === "l") return `<rect x="${rx}" y="${ry}" width="${half}" height="${pixelSize}" fill="${faceHex}"/>`;
|
|
624
|
+
if (cell === "a") return `<rect x="${rx}" y="${ry + half}" width="${pixelSize}" height="${half}" fill="${faceHex}"/>`;
|
|
625
|
+
if (cell === "e" || cell === "d") return `<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${darkHex}"/>`;
|
|
626
|
+
if (cell === "s") return `<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${faceHex}"/><rect x="${rx}" y="${ry + half}" width="${pixelSize}" height="${half}" fill="${darkHex}"/>`;
|
|
627
|
+
if (cell === "n") return `<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${faceHex}"/><rect x="${rx + quarter}" y="${ry}" width="${half}" height="${pixelSize}" fill="${darkHex}"/>`;
|
|
628
|
+
if (cell === "m") return `<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${faceHex}"/><rect x="${rx}" y="${ry}" width="${pixelSize}" height="${half}" fill="${darkHex}"/>`;
|
|
629
|
+
if (cell === "q") return `<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${faceHex}"/><rect x="${rx + half}" y="${ry + half}" width="${half}" height="${half}" fill="${darkHex}"/>`;
|
|
630
|
+
if (cell === "r") return `<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${faceHex}"/><rect x="${rx}" y="${ry + half}" width="${half}" height="${half}" fill="${darkHex}"/>`;
|
|
631
|
+
if (cell === "h" || cell === "k") return `<rect x="${rx}" y="${ry}" width="${pixelSize}" height="${pixelSize}" fill="${hatHex}"/>`;
|
|
632
|
+
return "";
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function renderRows(rows: Pixel[][], startY: number): string {
|
|
636
|
+
let out = "";
|
|
637
|
+
for (let y = 0; y < rows.length; y++) {
|
|
638
|
+
for (let x = 0; x < cols; x++) {
|
|
639
|
+
out += px(rows[y][x], x * pixelSize, (startY + y) * pixelSize);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return out;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Static rows: hat + face + eyes
|
|
646
|
+
const staticRects = renderRows([...hatRows, F, EYES[traits.eyes]], 0);
|
|
647
|
+
|
|
648
|
+
// Mouth starts after static rows
|
|
649
|
+
const mY = hatRows.length + 2;
|
|
650
|
+
const mouth0 = renderRows(mouthNormal, mY);
|
|
651
|
+
const mouth1 = renderRows(mouthTalk, mY);
|
|
652
|
+
|
|
653
|
+
// Body starts after mouth
|
|
654
|
+
const bY = mY + 2;
|
|
655
|
+
const body0 = renderRows(bodyNormal, bY);
|
|
656
|
+
const body1 = renderRows(waveF1, bY);
|
|
657
|
+
const body2 = renderRows(waveF2, bY);
|
|
658
|
+
|
|
659
|
+
// Legs start after body
|
|
660
|
+
const lY = bY + 2;
|
|
661
|
+
const legs0 = renderRows([legVariant[0]], lY);
|
|
662
|
+
const legs1 = legFrameCount > 1 ? renderRows([legVariant[1]], lY) : "";
|
|
663
|
+
const legs2 = legFrameCount > 2 ? renderRows([legVariant[2]], lY) : "";
|
|
664
|
+
const legs3 = legFrameCount > 3 ? renderRows([legVariant[3]], lY) : "";
|
|
665
|
+
|
|
666
|
+
const svg =
|
|
667
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" shape-rendering="crispEdges">` +
|
|
668
|
+
`<g class="tg-bob">${staticRects}` +
|
|
669
|
+
`<g class="tg-mouth-0">${mouth0}</g>` +
|
|
670
|
+
`<g class="tg-mouth-1">${mouth1}</g>` +
|
|
671
|
+
`<g class="tg-body-0">${body0}</g>` +
|
|
672
|
+
`<g class="tg-body-1">${body1}</g>` +
|
|
673
|
+
`<g class="tg-body-2">${body2}</g>` +
|
|
674
|
+
`</g>` +
|
|
675
|
+
`<g class="tg-legs-0">${legs0}</g>` +
|
|
676
|
+
(legs1 ? `<g class="tg-legs-1">${legs1}</g>` : "") +
|
|
677
|
+
(legs2 ? `<g class="tg-legs-2">${legs2}</g>` : "") +
|
|
678
|
+
(legs3 ? `<g class="tg-legs-3">${legs3}</g>` : "") +
|
|
679
|
+
`</svg>`;
|
|
680
|
+
|
|
681
|
+
return { svg, legFrames: legFrameCount, rows: totalRows };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Shared CSS for layered SVG avatar animations.
|
|
686
|
+
* Inject once into the document head. Components use CSS classes
|
|
687
|
+
* (.idle, .walking, .talking, .waving, .walk-3f) on the wrapper div.
|
|
688
|
+
*/
|
|
689
|
+
export function getAvatarCSS(): string {
|
|
690
|
+
return `
|
|
691
|
+
.tg-avatar .tg-mouth-1,
|
|
692
|
+
.tg-avatar .tg-body-1,
|
|
693
|
+
.tg-avatar .tg-body-2,
|
|
694
|
+
.tg-avatar .tg-legs-1,
|
|
695
|
+
.tg-avatar .tg-legs-2,
|
|
696
|
+
.tg-avatar .tg-legs-3 { opacity: 0 }
|
|
697
|
+
|
|
698
|
+
@keyframes tg-toggle {
|
|
699
|
+
0%, 49.99% { opacity: 1 }
|
|
700
|
+
50%, 100% { opacity: 0 }
|
|
701
|
+
}
|
|
702
|
+
@keyframes tg-toggle-3 {
|
|
703
|
+
0%, 33.32% { opacity: 1 }
|
|
704
|
+
33.33%, 100% { opacity: 0 }
|
|
705
|
+
}
|
|
706
|
+
@keyframes tg-toggle-4 {
|
|
707
|
+
0%, 24.99% { opacity: 1 }
|
|
708
|
+
25%, 100% { opacity: 0 }
|
|
709
|
+
}
|
|
710
|
+
@keyframes tg-idle-bob {
|
|
711
|
+
0%, 30%, 100% { transform: translateY(0) }
|
|
712
|
+
35%, 65% { transform: translateY(1px) }
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.tg-avatar.idle .tg-bob {
|
|
716
|
+
animation: tg-idle-bob 3s steps(1) infinite;
|
|
717
|
+
animation-delay: var(--tg-idle-delay, 0s);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
.tg-avatar.walking .tg-legs-0 { animation: tg-toggle 800ms steps(1) infinite }
|
|
721
|
+
.tg-avatar.walking .tg-legs-1 { animation: tg-toggle 800ms steps(1) infinite; animation-delay: -400ms }
|
|
722
|
+
.tg-avatar.walking.walk-3f .tg-legs-0 { animation: tg-toggle-3 1200ms steps(1) infinite }
|
|
723
|
+
.tg-avatar.walking.walk-3f .tg-legs-1 { animation: tg-toggle-3 1200ms steps(1) infinite; animation-delay: -800ms }
|
|
724
|
+
.tg-avatar.walking.walk-3f .tg-legs-2 { animation: tg-toggle-3 1200ms steps(1) infinite; animation-delay: -400ms }
|
|
725
|
+
.tg-avatar.walking.walk-4f .tg-legs-0 { animation: tg-toggle-4 1200ms steps(1) infinite }
|
|
726
|
+
.tg-avatar.walking.walk-4f .tg-legs-1 { animation: tg-toggle-4 1200ms steps(1) infinite; animation-delay: -900ms }
|
|
727
|
+
.tg-avatar.walking.walk-4f .tg-legs-2 { animation: tg-toggle-4 1200ms steps(1) infinite; animation-delay: -600ms }
|
|
728
|
+
.tg-avatar.walking.walk-4f .tg-legs-3 { animation: tg-toggle-4 1200ms steps(1) infinite; animation-delay: -300ms }
|
|
729
|
+
|
|
730
|
+
.tg-avatar.talking .tg-mouth-0 { animation: tg-toggle 400ms steps(1) infinite }
|
|
731
|
+
.tg-avatar.talking .tg-mouth-1 { animation: tg-toggle 400ms steps(1) infinite; animation-delay: -200ms }
|
|
732
|
+
|
|
733
|
+
.tg-avatar.waving .tg-body-0 { opacity: 0 }
|
|
734
|
+
.tg-avatar.waving .tg-body-1 { animation: tg-toggle 1200ms steps(1) infinite }
|
|
735
|
+
.tg-avatar.waving .tg-body-2 { animation: tg-toggle 1200ms steps(1) infinite; animation-delay: -600ms }
|
|
736
|
+
`;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export function renderTerminalSmall(dna: string, frame = 0): string {
|
|
740
|
+
const traits = decodeDNA(dna);
|
|
741
|
+
const grid = generateGrid(traits, frame);
|
|
742
|
+
|
|
743
|
+
const faceHueDeg = traits.faceHue * 30;
|
|
744
|
+
const hatHueDeg = traits.hatHue * 30;
|
|
745
|
+
|
|
746
|
+
const faceRgb = hslToRgb(faceHueDeg, 0.5, 0.5);
|
|
747
|
+
const darkRgb = hslToRgb(faceHueDeg, 0.5, 0.28);
|
|
748
|
+
const hatRgb = hslToRgb(hatHueDeg, 0.5, 0.5);
|
|
749
|
+
|
|
750
|
+
function cellRgb(cell: Pixel): [number, number, number] | null {
|
|
751
|
+
if (cell === "f" || cell === "l" || cell === "a" || cell === "q" || cell === "r") return faceRgb;
|
|
752
|
+
if (cell === "e" || cell === "s" || cell === "n" || cell === "d" || cell === "m") return darkRgb;
|
|
753
|
+
if (cell === "h" || cell === "k") return hatRgb;
|
|
754
|
+
return null; // transparent
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const reset = "\x1b[0m";
|
|
758
|
+
const lines: string[] = [];
|
|
759
|
+
|
|
760
|
+
// Process two rows at a time using ▀ (upper half block)
|
|
761
|
+
for (let r = 0; r < grid.length; r += 2) {
|
|
762
|
+
const topRow = grid[r];
|
|
763
|
+
const botRow = r + 1 < grid.length ? grid[r + 1] : null;
|
|
764
|
+
let line = "";
|
|
765
|
+
for (let c = 0; c < topRow.length; c++) {
|
|
766
|
+
const top = cellRgb(topRow[c]);
|
|
767
|
+
const bot = botRow ? cellRgb(botRow[c]) : null;
|
|
768
|
+
if (top && bot) {
|
|
769
|
+
// ▀ with fg=top, bg=bot
|
|
770
|
+
line += `\x1b[38;2;${top[0]};${top[1]};${top[2]}m\x1b[48;2;${bot[0]};${bot[1]};${bot[2]}m▀${reset}`;
|
|
771
|
+
} else if (top) {
|
|
772
|
+
// ▀ with fg=top, no bg
|
|
773
|
+
line += `\x1b[38;2;${top[0]};${top[1]};${top[2]}m▀${reset}`;
|
|
774
|
+
} else if (bot) {
|
|
775
|
+
// ▄ with fg=bot, no bg
|
|
776
|
+
line += `\x1b[38;2;${bot[0]};${bot[1]};${bot[2]}m▄${reset}`;
|
|
777
|
+
} else {
|
|
778
|
+
line += " ";
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
lines.push(line);
|
|
782
|
+
}
|
|
783
|
+
return lines.join("\n");
|
|
784
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import { Text, Box } from 'ink';
|
|
3
|
+
import { decodeDNA, generateGrid, hslToRgb, traitsFromName } from '../index.js';
|
|
4
|
+
import type { Pixel } from '../index.js';
|
|
5
|
+
|
|
6
|
+
export interface AvatarProps {
|
|
7
|
+
dna?: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
compact?: boolean;
|
|
10
|
+
walking?: boolean;
|
|
11
|
+
talking?: boolean;
|
|
12
|
+
waving?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Avatar({ dna, name, compact = false, walking = false, talking = false, waving = false }: AvatarProps) {
|
|
16
|
+
const [walkFrame, setWalkFrame] = useState(0);
|
|
17
|
+
const [talkFrame, setTalkFrame] = useState(0);
|
|
18
|
+
const [waveFrame, setWaveFrame] = useState(0);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!walking) { setWalkFrame(0); return; }
|
|
22
|
+
const id = setInterval(() => setWalkFrame(f => (f + 1) % 6), 400);
|
|
23
|
+
return () => clearInterval(id);
|
|
24
|
+
}, [walking]);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!talking) { setTalkFrame(0); return; }
|
|
28
|
+
let timeout: ReturnType<typeof setTimeout>;
|
|
29
|
+
function tick() {
|
|
30
|
+
setTalkFrame(f => (f + 1) % 2);
|
|
31
|
+
timeout = setTimeout(tick, 100 + Math.random() * 200);
|
|
32
|
+
}
|
|
33
|
+
timeout = setTimeout(tick, 100 + Math.random() * 200);
|
|
34
|
+
return () => clearTimeout(timeout);
|
|
35
|
+
}, [talking]);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!waving) { setWaveFrame(0); return; }
|
|
39
|
+
setWaveFrame(1);
|
|
40
|
+
const id = setInterval(() => setWaveFrame(f => f === 1 ? 2 : 1), 600);
|
|
41
|
+
return () => clearInterval(id);
|
|
42
|
+
}, [waving]);
|
|
43
|
+
|
|
44
|
+
const traits = useMemo(
|
|
45
|
+
() => dna ? decodeDNA(dna) : traitsFromName(name ?? 'agent'),
|
|
46
|
+
[dna, name]
|
|
47
|
+
);
|
|
48
|
+
const grid = useMemo(
|
|
49
|
+
() => generateGrid(traits, walkFrame, talkFrame, waveFrame),
|
|
50
|
+
[traits, walkFrame, talkFrame, waveFrame]
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const faceHueDeg = traits.faceHue * 30;
|
|
54
|
+
const hatHueDeg = traits.hatHue * 30;
|
|
55
|
+
const faceRgb = hslToRgb(faceHueDeg, 0.5, 0.5);
|
|
56
|
+
const darkRgb = hslToRgb(faceHueDeg, 0.5, 0.28);
|
|
57
|
+
const hatRgb = hslToRgb(hatHueDeg, 0.5, 0.5);
|
|
58
|
+
|
|
59
|
+
const faceHex = rgbHex(faceRgb);
|
|
60
|
+
const darkHex = rgbHex(darkRgb);
|
|
61
|
+
const hatHex = rgbHex(hatRgb);
|
|
62
|
+
|
|
63
|
+
if (compact) {
|
|
64
|
+
// Half-block rendering: two rows per line using ▀/▄
|
|
65
|
+
const lines: React.ReactNode[] = [];
|
|
66
|
+
for (let r = 0; r < grid.length; r += 2) {
|
|
67
|
+
const topRow = grid[r];
|
|
68
|
+
const botRow = r + 1 < grid.length ? grid[r + 1] : null;
|
|
69
|
+
const spans: React.ReactNode[] = [];
|
|
70
|
+
for (let c = 0; c < topRow.length; c++) {
|
|
71
|
+
const top = cellHex(topRow[c], faceHex, darkHex, hatHex);
|
|
72
|
+
const bot = botRow ? cellHex(botRow[c], faceHex, darkHex, hatHex) : null;
|
|
73
|
+
if (top && bot) {
|
|
74
|
+
spans.push(<Text key={c} color={top} backgroundColor={bot}>▀</Text>);
|
|
75
|
+
} else if (top) {
|
|
76
|
+
spans.push(<Text key={c} color={top}>▀</Text>);
|
|
77
|
+
} else if (bot) {
|
|
78
|
+
spans.push(<Text key={c} color={bot}>▄</Text>);
|
|
79
|
+
} else {
|
|
80
|
+
spans.push(<Text key={c}> </Text>);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
lines.push(<Box key={r}>{spans}</Box>);
|
|
84
|
+
}
|
|
85
|
+
return <Box flexDirection="column">{lines}</Box>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Full-size rendering: ██ per pixel
|
|
89
|
+
return (
|
|
90
|
+
<Box flexDirection="column">
|
|
91
|
+
{grid.map((row, y) => (
|
|
92
|
+
<Box key={y}>
|
|
93
|
+
{row.map((cell, x) => {
|
|
94
|
+
const ch = cellChar(cell, faceHex, darkHex, hatHex);
|
|
95
|
+
return ch;
|
|
96
|
+
}).map((node, x) => <React.Fragment key={x}>{node}</React.Fragment>)}
|
|
97
|
+
</Box>
|
|
98
|
+
))}
|
|
99
|
+
</Box>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function rgbHex(rgb: [number, number, number]): string {
|
|
104
|
+
return `#${rgb[0].toString(16).padStart(2, '0')}${rgb[1].toString(16).padStart(2, '0')}${rgb[2].toString(16).padStart(2, '0')}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function cellHex(cell: Pixel, face: string, dark: string, hat: string): string | null {
|
|
108
|
+
if (cell === 'f' || cell === 'l' || cell === 'a' || cell === 'q' || cell === 'r' || cell === 'm') return face;
|
|
109
|
+
if (cell === 'e' || cell === 's' || cell === 'n' || cell === 'd') return dark;
|
|
110
|
+
if (cell === 'h' || cell === 'k') return hat;
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cellChar(cell: Pixel, face: string, dark: string, hat: string): React.ReactNode {
|
|
115
|
+
switch (cell) {
|
|
116
|
+
case 'f': return <Text color={face}>██</Text>;
|
|
117
|
+
case 'e': case 'd': return <Text color={dark}>██</Text>;
|
|
118
|
+
case 'h': return <Text color={hat}>██</Text>;
|
|
119
|
+
case 'k': return <Text color={hat}>▐▌</Text>;
|
|
120
|
+
case 'l': return <Text color={face}>▌ </Text>;
|
|
121
|
+
case 'a': return <Text color={face}>▄▄</Text>;
|
|
122
|
+
case 's': return <Text color={dark} backgroundColor={face}>▄▄</Text>;
|
|
123
|
+
case 'n': return <Text color={dark} backgroundColor={face}>▐▌</Text>;
|
|
124
|
+
case 'm': return <Text color={dark} backgroundColor={face}>▀▀</Text>;
|
|
125
|
+
case 'q': return <Text color={dark} backgroundColor={face}> ▗</Text>;
|
|
126
|
+
case 'r': return <Text color={dark} backgroundColor={face}>▖ </Text>;
|
|
127
|
+
default: return <Text> </Text>;
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/ink/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useMemo, useRef } from 'react';
|
|
2
|
+
import { encodeDNA, traitsFromName, renderLayeredSVG, getAvatarCSS } from '../index.js';
|
|
3
|
+
|
|
4
|
+
export interface AvatarProps {
|
|
5
|
+
dna?: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
size?: 'sm' | 'lg' | 'xl';
|
|
8
|
+
walking?: boolean;
|
|
9
|
+
talking?: boolean;
|
|
10
|
+
waving?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let cssInjected = false;
|
|
14
|
+
|
|
15
|
+
function injectCSS() {
|
|
16
|
+
if (cssInjected || typeof document === 'undefined') return;
|
|
17
|
+
cssInjected = true;
|
|
18
|
+
const style = document.createElement('style');
|
|
19
|
+
style.id = 'tg-avatar-css';
|
|
20
|
+
style.textContent = getAvatarCSS();
|
|
21
|
+
document.head.appendChild(style);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PX_SIZES = { sm: 3, lg: 8, xl: 14 } as const;
|
|
25
|
+
|
|
26
|
+
export function Avatar({ dna, name, size = 'lg', walking = false, talking = false, waving = false }: AvatarProps) {
|
|
27
|
+
injectCSS();
|
|
28
|
+
const idleDelay = useRef(Math.random() * 3);
|
|
29
|
+
const resolvedDna = useMemo(() => dna ?? encodeDNA(traitsFromName(name ?? 'agent')), [dna, name]);
|
|
30
|
+
const { svg, legFrames } = useMemo(() => renderLayeredSVG(resolvedDna, PX_SIZES[size]), [resolvedDna, size]);
|
|
31
|
+
const idle = !walking && !talking && !waving;
|
|
32
|
+
|
|
33
|
+
const cls = [
|
|
34
|
+
'tg-avatar',
|
|
35
|
+
idle && 'idle',
|
|
36
|
+
walking && 'walking',
|
|
37
|
+
talking && 'talking',
|
|
38
|
+
waving && 'waving',
|
|
39
|
+
legFrames === 3 && 'walk-3f',
|
|
40
|
+
legFrames === 4 && 'walk-4f',
|
|
41
|
+
size === 'sm' && 'tg-avatar-sm',
|
|
42
|
+
].filter(Boolean).join(' ');
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
className={cls}
|
|
47
|
+
style={{
|
|
48
|
+
display: 'inline-flex',
|
|
49
|
+
alignItems: 'center',
|
|
50
|
+
justifyContent: 'center',
|
|
51
|
+
'--tg-idle-delay': `${idleDelay.current}s`,
|
|
52
|
+
...(size === 'sm' ? { width: 27, height: 27, overflow: 'hidden' } : {}),
|
|
53
|
+
} as React.CSSProperties}
|
|
54
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { encodeDNA, traitsFromName, renderLayeredSVG, getAvatarCSS } from '../index.js';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
dna?: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
size?: 'sm' | 'lg' | 'xl';
|
|
8
|
+
walking?: boolean;
|
|
9
|
+
talking?: boolean;
|
|
10
|
+
waving?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let { dna, name, size = 'lg', walking = false, talking = false, waving = false }: Props = $props();
|
|
14
|
+
|
|
15
|
+
const resolvedDna = $derived(dna ?? encodeDNA(traitsFromName(name ?? 'agent')));
|
|
16
|
+
const rendered = $derived(renderLayeredSVG(resolvedDna, size === 'xl' ? 14 : size === 'sm' ? 3 : 8));
|
|
17
|
+
const idle = $derived(!walking && !talking && !waving);
|
|
18
|
+
const idleDelay = Math.random() * 3;
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<svelte:head>{@html `<style>${getAvatarCSS()}</style>`}</svelte:head>
|
|
22
|
+
|
|
23
|
+
<div
|
|
24
|
+
class="tg-avatar"
|
|
25
|
+
class:idle
|
|
26
|
+
class:walking
|
|
27
|
+
class:talking
|
|
28
|
+
class:waving
|
|
29
|
+
class:walk-3f={rendered.legFrames === 3}
|
|
30
|
+
class:walk-4f={rendered.legFrames === 4}
|
|
31
|
+
class:sm={size === 'sm'}
|
|
32
|
+
style:--tg-idle-delay="{idleDelay}s"
|
|
33
|
+
>
|
|
34
|
+
{@html rendered.svg}
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<style>
|
|
38
|
+
.tg-avatar {
|
|
39
|
+
display: inline-flex;
|
|
40
|
+
align-items: center;
|
|
41
|
+
justify-content: center;
|
|
42
|
+
}
|
|
43
|
+
.tg-avatar.sm {
|
|
44
|
+
width: 27px;
|
|
45
|
+
height: 27px;
|
|
46
|
+
overflow: hidden;
|
|
47
|
+
}
|
|
48
|
+
.tg-avatar.sm :global(svg) {
|
|
49
|
+
width: 27px;
|
|
50
|
+
height: 27px;
|
|
51
|
+
}
|
|
52
|
+
</style>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Avatar } from './Avatar.svelte';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted } from 'vue';
|
|
3
|
+
import { encodeDNA, traitsFromName, renderLayeredSVG, getAvatarCSS } from '../index.js';
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(defineProps<{
|
|
6
|
+
dna?: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
size?: 'sm' | 'lg' | 'xl';
|
|
9
|
+
walking?: boolean;
|
|
10
|
+
talking?: boolean;
|
|
11
|
+
waving?: boolean;
|
|
12
|
+
}>(), {
|
|
13
|
+
size: 'lg',
|
|
14
|
+
walking: false,
|
|
15
|
+
talking: false,
|
|
16
|
+
waving: false,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const PX_SIZES = { sm: 3, lg: 8, xl: 14 } as const;
|
|
20
|
+
|
|
21
|
+
let cssInjected = false;
|
|
22
|
+
onMounted(() => {
|
|
23
|
+
if (cssInjected || typeof document === 'undefined') return;
|
|
24
|
+
cssInjected = true;
|
|
25
|
+
const style = document.createElement('style');
|
|
26
|
+
style.id = 'tg-avatar-css';
|
|
27
|
+
style.textContent = getAvatarCSS();
|
|
28
|
+
document.head.appendChild(style);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const idleDelay = Math.random() * 3;
|
|
32
|
+
|
|
33
|
+
const resolvedDna = computed(() =>
|
|
34
|
+
props.dna ?? encodeDNA(traitsFromName(props.name ?? 'agent'))
|
|
35
|
+
);
|
|
36
|
+
const rendered = computed(() =>
|
|
37
|
+
renderLayeredSVG(resolvedDna.value, PX_SIZES[props.size])
|
|
38
|
+
);
|
|
39
|
+
const idle = computed(() => !props.walking && !props.talking && !props.waving);
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<div
|
|
44
|
+
class="tg-avatar"
|
|
45
|
+
:class="{
|
|
46
|
+
idle: idle,
|
|
47
|
+
walking: props.walking,
|
|
48
|
+
talking: props.talking,
|
|
49
|
+
waving: props.waving,
|
|
50
|
+
'walk-3f': rendered.legFrames === 3,
|
|
51
|
+
'walk-4f': rendered.legFrames === 4,
|
|
52
|
+
'tg-avatar-sm': props.size === 'sm',
|
|
53
|
+
}"
|
|
54
|
+
:style="{ '--tg-idle-delay': `${idleDelay}s` }"
|
|
55
|
+
v-html="rendered.svg"
|
|
56
|
+
/>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<style scoped>
|
|
60
|
+
.tg-avatar {
|
|
61
|
+
display: inline-flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: center;
|
|
64
|
+
}
|
|
65
|
+
.tg-avatar-sm {
|
|
66
|
+
width: 27px;
|
|
67
|
+
height: 27px;
|
|
68
|
+
overflow: hidden;
|
|
69
|
+
}
|
|
70
|
+
.tg-avatar-sm :deep(svg) {
|
|
71
|
+
width: 27px;
|
|
72
|
+
height: 27px;
|
|
73
|
+
}
|
|
74
|
+
</style>
|
package/src/vue/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Avatar } from './Avatar.vue';
|