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 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
+ }
@@ -0,0 +1,2 @@
1
+ export { Avatar } from './Avatar.js';
2
+ export type { AvatarProps } from './Avatar.js';
@@ -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,2 @@
1
+ export { Avatar } from './Avatar.js';
2
+ export type { AvatarProps } from './Avatar.js';
@@ -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>
@@ -0,0 +1 @@
1
+ export { default as Avatar } from './Avatar.vue';