@siegesailor/terminal-sigma 1.4.0 → 1.5.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.
@@ -1,135 +1,255 @@
1
- // The pet lives on a stage as wide as its panel and 28 pixels tall. Each
2
- // terminal cell draws 2 stacked pixels with "▀", foreground for the top and
3
- // background for the bottom, so the stage takes 14 rows.
4
- export const stageHeight = 28;
5
- export const defaultLook = { size: 1, build: "average", gender: "other" };
1
+ // The character stands on a stage as wide as its panel and 32 pixels tall.
2
+ // Each terminal cell draws 2 stacked pixels with "▀", foreground for the top
3
+ // and background for the bottom, so the stage takes 16 rows.
4
+ export const stageHeight = 32;
5
+ export const defaultLook = {
6
+ height: 23,
7
+ build: "average",
8
+ gender: "other",
9
+ style: "desk",
10
+ hair: "dark",
11
+ };
12
+ // 145 cm draws 20 pixels tall and 205 cm 26; the build follows the BMI.
6
13
  export function lookOf(profile) {
7
14
  if (!profile) {
8
15
  return defaultLook;
9
16
  }
10
17
  const bmi = profile.weight / (profile.height / 100) ** 2;
11
18
  let build = "average";
12
- if (bmi < 20) {
19
+ if (bmi < 19.5) {
13
20
  build = "slim";
14
21
  }
15
- else if (bmi >= 27) {
16
- build = "broad";
22
+ else if (bmi >= 30) {
23
+ build = "heavy";
24
+ }
25
+ else if (bmi >= 25) {
26
+ build = "stocky";
27
+ }
28
+ let hair = "dark";
29
+ if (profile.age >= 70) {
30
+ hair = "white";
31
+ }
32
+ else if (profile.age >= 55) {
33
+ hair = "grey";
17
34
  }
18
35
  return {
19
- size: Math.min(Math.max(Math.floor((profile.height - 150) / 12), 0), 3),
36
+ height: Math.min(Math.max(Math.round(20 + (profile.height - 145) / 10), 19), 27),
20
37
  build,
21
38
  gender: profile.gender,
39
+ style: profile.workStyle,
40
+ hair,
22
41
  goal: profile.goal,
23
42
  };
24
43
  }
25
- export const noBadges = { meal: false, workout: false, focus: false };
26
44
  // Each equipment slot upgrades once from level 2 and again 4 levels later,
27
45
  // staggered so every level from 2 to 9 changes exactly 1 piece.
28
46
  export const gearOf = (level) => {
29
47
  const tier = (slot) => Math.min(Math.max(Math.floor((level - 2 - slot) / 4) + 1, 0), 2);
30
- return { gadget: tier(0), wear: tier(1), headwear: tier(2), aura: tier(3) };
48
+ return { gadget: tier(0), wear: tier(1), headwear: tier(2), carry: tier(3) };
31
49
  };
32
- // The pet grows twice: a hatchling below level 4, an adult from level 7.
33
- export const growthOf = (level) => level >= 7 ? 2 : level >= 4 ? 1 : 0;
34
- const widthOf = { slim: 13, average: 15, broad: 18 };
35
- const shapeOf = (look, level, squash = 0) => {
36
- const growth = growthOf(level) - 1;
37
- const width = widthOf[look.build] + growth * 2 - squash;
38
- const height = 9 + look.size + growth * 2 + squash;
39
- return { width, height, top: height + 1 };
50
+ // The feet stand on row 1, in the middle of their shadow.
51
+ const ground = 1;
52
+ const halfWidths = {
53
+ female: [3, 2, 3],
54
+ male: [4, 3, 3],
55
+ other: [3, 3, 3],
40
56
  };
57
+ // A head of 5 rows and a neck take 6 of the height; legs take 56% of the rest.
58
+ function bodyOf(look) {
59
+ const span = look.height - 6;
60
+ const legs = Math.round(span * 0.56);
61
+ const torso = span - legs;
62
+ let [chest, waist, hips] = halfWidths[look.gender];
63
+ switch (look.build) {
64
+ case "slim": {
65
+ chest -= look.gender === "male" ? 1 : 0;
66
+ waist = Math.max(waist - 1, 2);
67
+ hips = Math.max(hips - 1, 2);
68
+ break;
69
+ }
70
+ case "stocky": {
71
+ waist += 1;
72
+ break;
73
+ }
74
+ case "heavy": {
75
+ chest += 1;
76
+ waist += 2;
77
+ hips += 1;
78
+ break;
79
+ }
80
+ case "average": {
81
+ break;
82
+ }
83
+ }
84
+ if (look.goal === "buildMuscle") {
85
+ chest += 1;
86
+ }
87
+ const hip = ground + legs;
88
+ const shoulder = hip + torso - 1;
89
+ const chin = shoulder + 2;
90
+ return {
91
+ hip,
92
+ shoulder,
93
+ chin,
94
+ top: chin + 4,
95
+ legs,
96
+ chest,
97
+ waist,
98
+ hips,
99
+ arm: look.build === "heavy" || look.goal === "buildMuscle" ? 2 : 1,
100
+ reach: torso + 1,
101
+ };
102
+ }
41
103
  const span = (put, from, to, y, key) => {
42
104
  for (let x = from; x <= to; x++) {
43
105
  put(x, y, key);
44
106
  }
45
107
  };
46
- const icon = (rows, x, y) => (put) => {
47
- for (const [offset, row] of rows.entries()) {
48
- for (const [index, key] of [...row].entries()) {
49
- put(x + index, y - offset, key);
50
- }
108
+ const pointsOf = ([x0, y0], [x1, y1]) => {
109
+ const points = [];
110
+ const steps = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1);
111
+ for (let step = 0; step <= steps; step++) {
112
+ points.push([
113
+ Math.round(x0 + ((x1 - x0) * step) / steps),
114
+ Math.round(y0 + ((y1 - y0) * step) / steps),
115
+ ]);
116
+ }
117
+ return points;
118
+ };
119
+ const hanging = (b, side, swing = 0) => [
120
+ side * (b.chest + 1),
121
+ b.shoulder - b.reach + swing,
122
+ ];
123
+ // A standing desk at the waist, with the laptop's lid facing out.
124
+ const desk = (put, _upper, b) => {
125
+ const top = b.hip + 1;
126
+ span(put, -8, 8, top, "L");
127
+ for (let y = 0; y < top; y++) {
128
+ put(-7, y, "x");
129
+ put(7, y, "x");
130
+ }
131
+ for (let y = top + 1; y <= top + 3; y++) {
132
+ span(put, -2, 2, y, "d");
133
+ }
134
+ put(0, top + 2, "l");
135
+ };
136
+ const cup = ([x, y], steam) => (_put, upper) => {
137
+ span(upper, x, x + 1, y, "Q");
138
+ span(upper, x, x + 1, y + 1, "Q");
139
+ upper(x, y + 1, "o");
140
+ if (steam !== undefined) {
141
+ upper(x + (steam % 2), y + 3, "v");
142
+ upper(x + 1 - (steam % 2), y + 4, "v");
51
143
  }
52
144
  };
53
- const sparkles = (points) => (put, shape) => {
54
- for (const [x, y] of points) {
55
- put(x < 0 ? x : shape.width - 1 + x, y + shape.top, "y");
145
+ const apple = ([x, y], isBitten) => (_put, upper) => {
146
+ span(upper, x, x + 1, y, "F");
147
+ upper(x, y + 1, "F");
148
+ upper(x + 1, y + 1, isBitten ? "." : "F");
149
+ upper(x + 1, y + 2, "G");
150
+ };
151
+ const barbell = (y) => (_put, upper, b) => {
152
+ const end = b.chest + 6;
153
+ span(upper, -end, end, y, "d");
154
+ for (const x of [-end, -end + 1, end - 1, end]) {
155
+ span(upper, x, x, y - 1, "D");
156
+ upper(x, y, "D");
157
+ span(upper, x, x, y + 1, "D");
56
158
  }
57
159
  };
58
- const laptop = (put, { width }) => {
59
- span(put, width + 3, width + 8, 0, "x");
60
- for (let y = 1; y <= 2; y++) {
61
- put(width + 7, y, "x");
160
+ const sparkles = (points) => (put) => {
161
+ for (const [x, y] of points) {
162
+ put(x, y, "y");
62
163
  }
63
- span(put, width + 3, width + 7, 3, "L");
64
- for (let y = 4; y <= 7; y++) {
65
- put(width + 6, y, "l");
66
- put(width + 7, y, "L");
164
+ };
165
+ const sleepyZ = (at) => (put, _upper, b) => {
166
+ const rows = ["ZZZZ", "..Z.", ".Z..", "ZZZZ"];
167
+ for (const [offset, row] of rows.entries()) {
168
+ for (const [index, key] of [...row].entries()) {
169
+ put(3 + (at % 2) + index, b.top + 4 + at - offset, key);
170
+ }
67
171
  }
68
172
  };
69
- const held = (rows, dy) => (put, shape) => {
70
- icon(rows, shape.width + 2, dy(shape))(put, shape, 0);
173
+ // Standing in place between walks: breathing, blinking, looking about, and
174
+ // waving when every target is met.
175
+ const resting = (t, mood) => (b) => {
176
+ const isWaving = mood === "happy" && t >= 3 && t <= 5;
177
+ let turn = 0;
178
+ if (t === 2 || t === 3) {
179
+ turn = -1;
180
+ }
181
+ else if (t === 4) {
182
+ turn = 1;
183
+ }
184
+ return {
185
+ mouth: mood === "happy" ? "smile" : undefined,
186
+ bob: Math.floor(t / 2) % 2,
187
+ eyes: t === 6 ? "closed" : "open",
188
+ turn,
189
+ lift: mood === "happy" && t === 1 ? 1 : 0,
190
+ hands: isWaving
191
+ ? [undefined, [b.chest + 3 + (t % 2), b.top - 1]]
192
+ : undefined,
193
+ };
71
194
  };
72
- // Where the pet stands and what it does on every tick of `counter`.
73
- function sceneOf(activity, counter, width, shape, mood) {
74
- const home = 3;
75
- const minimum = 3;
76
- const maximum = Math.max(width - shape.width - 3, minimum + 1);
195
+ const walking = (step, mood) => (b) => ({
196
+ mouth: mood === "happy" ? "smile" : undefined,
197
+ turn: 1,
198
+ step: [0, 1, 0, 3][step % 4],
199
+ bob: step % 2 === 0 ? 1 : 0,
200
+ hands: [
201
+ hanging(b, -1, step % 4 === 3 ? 1 : 0),
202
+ hanging(b, 1, step % 4 === 1 ? 1 : 0),
203
+ ],
204
+ });
205
+ // Where the character stands and what it does on every tick of `counter`.
206
+ function sceneOf(activity, counter, width, mood) {
207
+ const home = Math.floor(width / 2);
208
+ const minimum = 8;
209
+ const maximum = Math.max(width - 9, minimum + 1);
77
210
  const travel = maximum - minimum;
78
211
  const tick = counter % 4;
212
+ const mouth = mood === "happy" ? "smile" : undefined;
79
213
  if (activity === "idle" && mood === "sleepy") {
80
- // Napping in place: breathing, with Zs drifting up.
214
+ // Dozing on its feet: nodding, yawning, with Zs drifting up.
215
+ const at = counter % 6;
216
+ const isYawning = at === 4;
81
217
  return {
82
- x: Math.floor((width - shape.width) / 2),
218
+ x: home,
83
219
  facing: 1,
84
- pose: { squash: tick < 2 ? 0 : 1, eyes: "closed" },
85
- props: [
86
- (put, { width: body, top }) => {
87
- const rise = counter % 6;
88
- icon(["zzzz", "..z.", ".z..", "zzzz"], body - 2 + rise, top + 4 + rise)(put, shape, 0);
89
- },
90
- ],
220
+ pose: (b) => ({
221
+ eyes: "closed",
222
+ bob: at % 3 === 0 ? 1 : 0,
223
+ mouth: isYawning ? "open" : undefined,
224
+ hands: isYawning ? [undefined, [2, b.chin]] : undefined,
225
+ }),
226
+ props: [sleepyZ(at)],
91
227
  };
92
228
  }
93
229
  if (activity === "idle") {
94
230
  // Wander: pause, walk across, pause and look about, walk back.
95
- const pause = 6;
96
- const cycle = 2 * (pause + travel);
97
- const at = counter % cycle;
98
- const isHappy = mood === "happy";
99
- const walk = (step) => ({
100
- step: step % 2 === 0 ? 1 : 2,
101
- look: 1,
102
- lift: isHappy && step % 2 === 1 ? 1 : 0,
103
- eyes: isHappy ? "happy" : "open",
104
- });
105
- const rest = (moment) => ({
106
- squash: Math.floor(moment / 2) % 2,
107
- eyes: isHappy ? "happy" : moment === 4 ? "closed" : "open",
108
- arms: isHappy && moment % 2 === 0 ? ["up", "up"] : undefined,
109
- });
231
+ const pause = 8;
232
+ const at = counter % (2 * (pause + travel));
110
233
  if (at < pause) {
111
- return {
112
- x: minimum,
113
- facing: at === 2 || at === 3 ? -1 : 1,
114
- pose: rest(at),
115
- };
234
+ return { x: minimum, facing: 1, pose: resting(at, mood) };
116
235
  }
117
236
  if (at < pause + travel) {
118
- return { x: minimum + at - pause, facing: 1, pose: walk(at - pause) };
237
+ const step = at - pause;
238
+ return { x: minimum + step, facing: 1, pose: walking(step, mood) };
119
239
  }
120
240
  if (at < 2 * pause + travel) {
121
- const moment = at - pause - travel;
122
241
  return {
123
242
  x: maximum,
124
- facing: moment === 2 || moment === 3 ? 1 : -1,
125
- pose: rest(moment),
243
+ facing: -1,
244
+ pose: resting(at - pause - travel, mood),
126
245
  };
127
246
  }
128
247
  const step = at - 2 * pause - travel;
129
- return { x: maximum - step, facing: -1, pose: walk(step) };
248
+ return { x: maximum - step, facing: -1, pose: walking(step, mood) };
130
249
  }
131
250
  switch (activity) {
132
251
  case "run": {
252
+ // Laps across the stage, 2 pixels a tick, with the arms bent.
133
253
  const lap = Math.max(Math.ceil(travel / 2), 1);
134
254
  const at = counter % (2 * lap);
135
255
  const forward = at < lap;
@@ -139,125 +259,166 @@ function sceneOf(activity, counter, width, shape, mood) {
139
259
  ? Math.min(minimum + step * 2, maximum)
140
260
  : Math.max(maximum - step * 2, minimum),
141
261
  facing: forward ? 1 : -1,
142
- pose: {
143
- step: counter % 2 === 0 ? 1 : 2,
144
- lift: counter % 2,
145
- look: 1,
146
- arms: counter % 2 === 0 ? ["up", "down"] : ["down", "up"],
147
- },
148
- props: [icon(["vv.", "...", ".vv"], -6, 6)],
262
+ pose: (b) => ({
263
+ mouth,
264
+ turn: 1,
265
+ step: counter % 2 === 0 ? 1 : 3,
266
+ bob: counter % 2,
267
+ hands: [
268
+ [-(b.chest + 2), b.hip + 2 + (counter % 2)],
269
+ [b.chest + 2, b.hip + 3 - (counter % 2)],
270
+ ],
271
+ }),
272
+ props: [
273
+ (put, _upper, b) => {
274
+ put(-(b.chest + 4), b.top - 1 - (counter % 2), "v");
275
+ put(-(b.chest + 6), b.hip + (counter % 3), "v");
276
+ },
277
+ ],
149
278
  };
150
279
  }
151
280
  case "focus": {
281
+ // Typing at a standing desk, the laptop's glow on the face.
152
282
  return {
153
283
  x: home,
154
284
  facing: 1,
155
- pose: {
156
- look: 1,
157
- squash: tick < 2 ? 0 : 1,
158
- arms: ["down", tick % 2 === 0 ? "forward" : "mid"],
159
- },
285
+ pose: (b) => ({
286
+ mouth,
287
+ bob: tick === 3 ? 1 : 0,
288
+ hands: [
289
+ [-3, b.hip + 2 + (tick % 2)],
290
+ [3, b.hip + 3 - (tick % 2)],
291
+ ],
292
+ }),
160
293
  props: [
161
- laptop,
162
- (put, { width: body }) => {
163
- put(body + 5 + (tick % 2), 9 + tick, "y");
164
- put(body + 7 - (tick % 2), 9 + ((tick + 2) % 4), "y");
294
+ desk,
295
+ (put, _upper, b) => {
296
+ put(-3 + tick, b.top + 2 + (tick % 2), "y");
165
297
  },
166
298
  ],
167
299
  };
168
300
  }
169
301
  case "break": {
170
- const step = counter % 6;
171
- const sipping = step >= 4;
302
+ // A coffee, sipped every few ticks.
303
+ const at = counter % 6;
304
+ const isSipping = at >= 4;
305
+ const hand = (b) => isSipping ? [2, b.chin] : [b.chest + 2, b.hip + 2];
172
306
  return {
173
307
  x: home,
174
308
  facing: 1,
175
- pose: {
176
- look: 1,
177
- squash: step % 2,
178
- eyes: sipping ? "closed" : "open",
179
- arms: ["down", sipping ? "up" : "mid"],
180
- },
309
+ pose: (b) => ({
310
+ mouth,
311
+ bob: at % 2,
312
+ eyes: isSipping ? "closed" : "open",
313
+ hands: [undefined, hand(b)],
314
+ }),
181
315
  props: [
182
- held(["QQ", "Qc"], (shape) => sipping ? shape.top - 1 : Math.ceil(shape.height / 2) + 2),
183
- ...(sipping
184
- ? []
185
- : [
186
- held(step % 2 === 0 ? [".v", "v."] : ["v.", ".v"], (shape) => Math.ceil(shape.height / 2) + 5),
187
- ]),
316
+ (put, upper, b) => {
317
+ cup(hand(b), isSipping ? undefined : at)(put, upper, b);
318
+ },
188
319
  ],
189
320
  };
190
321
  }
191
322
  case "eat": {
192
- const atMouth = tick === 1 || tick === 2;
323
+ const isBiting = tick === 1 || tick === 2;
324
+ const hand = (b) => isBiting ? [2, b.chin] : [b.chest + 2, b.hip + 3];
193
325
  return {
194
326
  x: home,
195
327
  facing: 1,
196
- pose: {
197
- look: 1,
198
- squash: tick === 2 ? 1 : 0,
199
- eyes: tick === 2 ? "happy" : "open",
200
- arms: ["down", atMouth ? "up" : "mid"],
201
- },
328
+ pose: (b) => ({
329
+ bob: tick === 2 ? 1 : 0,
330
+ mouth: isBiting ? "open" : mouth,
331
+ hands: [undefined, hand(b)],
332
+ }),
202
333
  props: [
203
- held(tick === 3 ? [".G", "F.", "FF"] : [".G", "FF", "FF"], (shape) => atMouth ? shape.top - 2 : Math.ceil(shape.height / 2) + 2),
334
+ (put, upper, b) => {
335
+ const [x, y] = hand(b);
336
+ apple([x, y - 1], tick === 3)(put, upper, b);
337
+ },
204
338
  ],
205
339
  };
206
340
  }
207
341
  case "lift": {
208
- const up = tick === 1 || tick === 2;
342
+ // An overhead press: rack, press, lock out, lower.
343
+ const height = (b) => [b.shoulder, b.shoulder + 3, b.top + 1, b.shoulder + 3][tick] ??
344
+ b.shoulder;
209
345
  return {
210
346
  x: home,
211
347
  facing: 1,
212
- pose: {
213
- arms: up ? ["up", "up"] : ["mid", "mid"],
214
- lift: tick === 2 ? 1 : 0,
215
- squash: up ? 0 : 1,
348
+ pose(b) {
349
+ const y = height(b);
350
+ const x = tick === 2 ? b.chest + 1 : b.chest + 2;
351
+ return {
352
+ mouth,
353
+ bob: tick === 0 ? 1 : 0,
354
+ hands: [
355
+ [-x, y],
356
+ [x, y],
357
+ ],
358
+ };
216
359
  },
217
360
  props: [
218
- (put, shape) => {
219
- const y = up ? shape.top + 3 : Math.ceil(shape.height / 2) + 4;
220
- span(put, -3, shape.width + 2, y, "d");
221
- icon(["DD", "DD", "DD"], -4, y + 1)(put, shape, 0);
222
- icon(["DD", "DD", "DD"], shape.width + 2, y + 1)(put, shape, 0);
361
+ (put, upper, b) => {
362
+ barbell(height(b))(put, upper, b);
223
363
  },
224
364
  ],
225
365
  };
226
366
  }
227
367
  case "stretch": {
368
+ // Reach up, rise on the toes, open the arms wide, and come down.
228
369
  return {
229
370
  x: home,
230
371
  facing: 1,
231
- pose: {
232
- arms: tick < 2 ? ["up", "up"] : ["mid", "mid"],
233
- squash: tick < 2 ? -1 : 1,
234
- eyes: tick === 1 ? "closed" : "open",
372
+ pose(b) {
373
+ const reaches = [
374
+ [1, b.top + 2],
375
+ [1, b.top + 3],
376
+ [b.chest + 6, b.shoulder],
377
+ [b.chest + 4, b.shoulder + 4],
378
+ ];
379
+ const [x, y] = reaches[tick] ?? [1, b.top + 2];
380
+ return {
381
+ mouth,
382
+ bob: tick === 1 ? 0 : 1,
383
+ lift: tick === 1 ? 1 : 0,
384
+ eyes: tick === 1 ? "closed" : "open",
385
+ hands: [
386
+ [-x, y],
387
+ [x, y],
388
+ ],
389
+ };
235
390
  },
236
391
  };
237
392
  }
238
393
  case "levelUp": {
394
+ const lift = [0, 2, 3, 1][tick] ?? 0;
239
395
  return {
240
396
  x: home,
241
397
  facing: 1,
242
- pose: {
243
- arms: ["up", "up"],
244
- lift: [0, 2, 3, 1][tick] ?? 0,
245
- eyes: "happy",
246
- },
398
+ pose: (b) => ({
399
+ mouth: "smile",
400
+ lift,
401
+ hands: [
402
+ [-(b.chest + 3), b.top],
403
+ [b.chest + 3, b.top],
404
+ ],
405
+ }),
247
406
  props: [
248
- sparkles(tick % 2 === 0
249
- ? [
250
- [-3, 2],
251
- [3, 4],
252
- [-4, -3],
253
- [4, -1],
254
- ]
255
- : [
256
- [-4, 4],
257
- [4, 2],
258
- [-3, -1],
259
- [3, -3],
260
- ]),
407
+ (put, upper, b) => {
408
+ sparkles(tick % 2 === 0
409
+ ? [
410
+ [-8, b.top],
411
+ [8, b.top + 2],
412
+ [-9, b.shoulder],
413
+ [9, b.shoulder - 2],
414
+ ]
415
+ : [
416
+ [-9, b.top + 2],
417
+ [9, b.top],
418
+ [-8, b.shoulder - 2],
419
+ [8, b.shoulder],
420
+ ])(put, upper, b);
421
+ },
261
422
  ],
262
423
  };
263
424
  }
@@ -269,226 +430,361 @@ export const intervalOf = (activity) => ({
269
430
  break: 380,
270
431
  eat: 300,
271
432
  run: 110,
272
- lift: 400,
433
+ lift: 420,
273
434
  stretch: 480,
274
435
  levelUp: 160,
275
436
  })[activity];
276
437
  // How many ticks one loop of an activity takes on a stage of `width`.
277
438
  export function cycleOf(activity, width) {
278
- const travel = Math.max(width - 21, 4);
439
+ const travel = Math.max(width - 17, 1);
279
440
  if (activity === "idle") {
280
- return 2 * (6 + travel);
441
+ return 2 * (8 + travel);
281
442
  }
282
443
  if (activity === "run") {
283
444
  return 2 * Math.max(Math.ceil(travel / 2), 1);
284
445
  }
285
446
  return activity === "break" ? 6 : 4;
286
447
  }
287
- // Behind the body: a cape that flares and ripples, or sparkles of the aura.
288
- function paintBehind({ put, shape, gear, counter }) {
289
- if (gear.wear === 2) {
290
- for (let y = 2; y <= shape.top - 2; y++) {
291
- const flare = Math.floor((shape.top - y) / 3) + ((counter + y) % 4 < 2 ? 0 : 1);
292
- span(put, -1 - flare, -1, y, y === 2 ? "s" : "S");
448
+ // The tops by work style: a hoodie, a shirt under an apron, a tee, a tank.
449
+ const topOf = {
450
+ desk: ["C", "c"],
451
+ standing: ["Q", "U"],
452
+ active: ["C", "c"],
453
+ athlete: ["S", "z"],
454
+ };
455
+ // How much of the arm the sleeve covers, from the shoulder down.
456
+ const sleeveOf = {
457
+ desk: 1,
458
+ standing: 0.5,
459
+ active: 0.3,
460
+ athlete: 0,
461
+ };
462
+ // Jeans and sneakers, chinos and shoes, cargo pants and boots, or shorts
463
+ // and running shoes.
464
+ const bottomOf = {
465
+ desk: { pants: "P", shade: "p", shoe: "w", shoeRows: 1 },
466
+ standing: { pants: "N", shade: "n", shoe: "b", shoeRows: 1 },
467
+ active: { pants: "O", shade: "V", shoe: "B", shoeRows: 2 },
468
+ athlete: { pants: "x", shade: "S", shoe: "S", shoeRows: 1 },
469
+ };
470
+ const hairKeys = { dark: "h", grey: "H", white: "I" };
471
+ // An oval on the ground with the feet in its middle, shrinking in the air.
472
+ function paintShadow(put, b, lift) {
473
+ const half = b.hips + 4 - Math.min(lift, 2);
474
+ span(put, -half + 2, half - 2, ground - 1, "A");
475
+ span(put, -half, half, ground, "A");
476
+ span(put, -half + 2, half - 2, ground + 1, "A");
477
+ }
478
+ // Long hair falls behind the shoulders, so it goes on before the body.
479
+ function paintHairBehind({ upper, b, look, counter }) {
480
+ const hair = hairKeys[look.hair];
481
+ if (look.gender !== "female") {
482
+ return;
483
+ }
484
+ if (look.style === "athlete") {
485
+ // A ponytail that swings.
486
+ const sway = counter % 4 < 2 ? 0 : 1;
487
+ upper(3, b.chin + 3, hair);
488
+ upper(4, b.chin + 2, hair);
489
+ upper(4 + sway, b.chin + 1, hair);
490
+ upper(4 + sway, b.chin, hair);
491
+ return;
492
+ }
493
+ for (let y = b.shoulder - 2; y <= b.chin + 3; y++) {
494
+ upper(-3, y, hair);
495
+ upper(3, y, hair);
496
+ }
497
+ for (let y = b.shoulder - 1; y < b.chin; y++) {
498
+ span(upper, -2, 2, y, hair);
499
+ }
500
+ }
501
+ function paintLegs({ put, b, look, pose }) {
502
+ const { pants, shade, shoe, shoeRows } = bottomOf[look.style];
503
+ const thigh = b.hips;
504
+ const calf = Math.max(thigh - 1, 2);
505
+ const knee = ground + Math.floor(b.legs / 2);
506
+ const shorts = ground + Math.round(b.legs * 0.55);
507
+ for (const side of [-1, 1]) {
508
+ const isLifted = (side === -1 && pose.step === 1) || (side === 1 && pose.step === 3);
509
+ const sole = ground + (isLifted ? 1 : 0);
510
+ for (let y = sole; y < b.hip; y++) {
511
+ const width = y >= knee ? thigh : calf;
512
+ let key = pants;
513
+ if (y < sole + shoeRows) {
514
+ key = shoe;
515
+ }
516
+ else if (look.style === "athlete" && y < shorts) {
517
+ key = "s";
518
+ }
519
+ for (let x = 1; x <= width; x++) {
520
+ put(side * x, y, key);
521
+ }
522
+ if (key === pants) {
523
+ put(side * width, y, shade);
524
+ }
525
+ }
526
+ // Shoes stick out a little past the ankle.
527
+ put(side * (calf + 1), sole, shoe);
528
+ if (look.style === "active") {
529
+ put(side * thigh, knee + 1, "V");
530
+ put(side * thigh, knee + 2, "V");
293
531
  }
294
532
  }
295
- if (gear.aura === 2) {
296
- const sway = counter % 2;
297
- put(-3 - sway, shape.top - 1, "y");
298
- put(shape.width + 2 + sway, shape.top - 3, "y");
299
- put(-2, 3 + sway, "y");
533
+ // The thighs meet at the crotch, and all the way down on a heavy build.
534
+ let crotch = b.hip - 1;
535
+ if (look.build === "heavy") {
536
+ crotch = look.style === "athlete" ? shorts : knee;
537
+ }
538
+ for (let y = crotch; y < b.hip; y++) {
539
+ put(0, y, pants);
300
540
  }
301
541
  }
302
- // 4 short legs, like the Claude Code mascot; sneakers for an endurance goal.
303
- function paintLegs({ put, shape, look, pose }) {
304
- const columns = [1, 3, shape.width - 4, shape.width - 2];
305
- const foot = look.goal === "endurance" ? "w" : "o";
306
- for (const [index, x] of columns.entries()) {
307
- const isLifted = (pose.step === 1 && index % 2 === 0) ||
308
- (pose.step === 2 && index % 2 === 1);
309
- if (!isLifted) {
310
- put(x, 0, foot);
542
+ function paintTorso({ upper, b, look, gear }) {
543
+ const [main, shade] = topOf[look.style];
544
+ const torso = b.shoulder - b.hip + 1;
545
+ const chestRows = Math.ceil(torso * 0.45);
546
+ const halfAt = (y) => {
547
+ if (b.shoulder - y < chestRows) {
548
+ return b.chest;
549
+ }
550
+ return y - b.hip < 2 ? Math.max(b.hips, b.waist) : b.waist;
551
+ };
552
+ for (let y = b.hip; y <= b.shoulder; y++) {
553
+ const half = halfAt(y);
554
+ span(upper, -half, half, y, main);
555
+ upper(-half, y, shade);
556
+ upper(half, y, shade);
557
+ }
558
+ span(upper, -1, 1, b.shoulder + 1, "s");
559
+ switch (look.style) {
560
+ case "desk": {
561
+ // A hoodie: the hood behind the neck, drawstrings, a front pocket.
562
+ upper(-2, b.shoulder + 1, shade);
563
+ upper(2, b.shoulder + 1, shade);
564
+ upper(-1, b.shoulder - 1, "w");
565
+ upper(1, b.shoulder - 1, "w");
566
+ span(upper, -2, 2, b.hip + 1, shade);
567
+ break;
568
+ }
569
+ case "standing": {
570
+ // A collared shirt under an apron that hangs over the thighs.
571
+ upper(-1, b.shoulder, "w");
572
+ upper(1, b.shoulder, "w");
573
+ upper(0, b.shoulder, "s");
574
+ for (const x of [-2, 2]) {
575
+ upper(x, b.shoulder, "S");
576
+ upper(x, b.shoulder - 1, "S");
577
+ }
578
+ for (let y = b.hip - 2; y <= b.shoulder - 2; y++) {
579
+ span(upper, -b.waist, b.waist, y, "S");
580
+ }
581
+ span(upper, -1, 1, b.hip + 1, "z");
582
+ break;
583
+ }
584
+ case "active": {
585
+ upper(0, b.shoulder, shade);
586
+ break;
587
+ }
588
+ case "athlete": {
589
+ upper(0, b.shoulder, "s");
590
+ span(upper, -b.chest, b.chest, b.shoulder - 2, shade);
591
+ break;
311
592
  }
312
- put(x, 1, "o");
313
593
  }
314
- }
315
- function paintBody({ put, shape, look, gear }) {
316
- const { width, top } = shape;
317
- for (let y = 2; y <= top; y++) {
318
- const isEdge = y === 2 || y === top;
319
- span(put, isEdge ? 1 : 0, isEdge ? width - 2 : width - 1, y, y === 2 ? "o" : "O");
594
+ if (gear.wear === 2) {
595
+ // An open jacket over whatever is underneath.
596
+ for (let y = b.hip; y <= b.shoulder; y++) {
597
+ const half = halfAt(y);
598
+ span(upper, -half, -half + 1, y, "X");
599
+ span(upper, half - 1, half, y, "X");
600
+ }
320
601
  }
321
- span(put, 1, 2, top - 1, "h");
322
- if (look.goal === "loseFat") {
323
- span(put, 0, width - 1, top - 1, "w");
602
+ if (gear.carry >= 1) {
603
+ // Backpack straps over both shoulders.
604
+ for (let y = b.shoulder - 3; y <= b.shoulder; y++) {
605
+ upper(-(b.chest - 1), y, "B");
606
+ upper(b.chest - 1, y, "B");
607
+ }
324
608
  }
325
- else if (look.goal === "maintain") {
326
- const middle = Math.floor(width / 2);
327
- icon(["r.r", "rrr", ".r."], middle - 1, 6)(put, shape, 0);
609
+ if (gear.wear >= 1) {
610
+ // A scarf around the neck, its end hanging down the chest.
611
+ span(upper, -2, 2, b.shoulder + 1, "S");
612
+ upper(1, b.shoulder, "z");
613
+ upper(1, b.shoulder - 1, "z");
328
614
  }
329
- if (gear.wear === 1) {
330
- span(put, 0, width - 1, top - 5, "S");
331
- put(-1, top - 5, "S");
332
- put(-2, top - 6, "s");
615
+ if (gear.carry === 2) {
616
+ // A gold medal on its ribbon.
617
+ upper(-1, b.shoulder, "z");
618
+ upper(1, b.shoulder, "z");
619
+ upper(0, b.shoulder - 1, "z");
620
+ upper(0, b.shoulder - 2, "g");
333
621
  }
334
622
  }
335
- function paintFace({ put, shape, pose, counter }) {
336
- const look = pose.look ?? 0;
337
- const eyeY = shape.top - 3;
338
- const eyes = [
339
- Math.round(shape.width * 0.3) + look,
340
- Math.round(shape.width * 0.7) - 1 + look,
341
- ];
342
- for (const x of eyes) {
343
- if (pose.eyes === "closed") {
344
- span(put, x, x + 1, eyeY, "e");
345
- }
346
- else if (pose.eyes === "happy") {
347
- put(x - 1, eyeY, "e");
348
- put(x, eyeY + 1, "e");
349
- put(x + 1, eyeY, "e");
350
- put(x, eyeY - 2, "r");
623
+ function paintArms({ upper, b, look, gear, pose }) {
624
+ const isJacket = gear.wear === 2;
625
+ const [main, shade] = isJacket ? ["X", "X"] : topOf[look.style];
626
+ const sleeve = isJacket ? 1 : sleeveOf[look.style];
627
+ for (const [index, side] of [-1, 1].entries()) {
628
+ const joint = [side * (b.chest + 1), b.shoulder];
629
+ const hand = pose.hands?.[index] ?? hanging(b, side);
630
+ const points = pointsOf(joint, hand);
631
+ const covered = Math.round((points.length - 1) * sleeve);
632
+ const isLevel = Math.abs(hand[0] - joint[0]) > Math.abs(hand[1] - joint[1]);
633
+ const [thickX, thickY] = isLevel ? [0, -1] : [side, 0];
634
+ for (const [step, [x, y]] of points.entries()) {
635
+ const isHand = step === points.length - 1;
636
+ const isSleeve = !isHand && (step < covered || (step === 0 && sleeve > 0));
637
+ upper(x, y, isSleeve ? main : "s");
638
+ if (b.arm === 2 && step < points.length - 2) {
639
+ upper(x + thickX, y + thickY, isSleeve ? shade : "q");
640
+ }
351
641
  }
352
- else {
353
- put(x, eyeY, "e");
354
- put(x, eyeY + 1, counter % 23 === 0 ? "O" : "e");
642
+ const wrist = points.at(-2);
643
+ if (side === 1 && gear.gadget === 2 && wrist && points.length > 2) {
644
+ upper(wrist[0], wrist[1], "t");
355
645
  }
356
646
  }
357
647
  }
358
- // Side nubs; a muscle goal makes them bigger.
359
- function paintArms({ put, shape, look, pose }) {
360
- const size = look.goal === "buildMuscle" ? 3 : 2;
361
- const [back, front] = pose.arms ?? ["down", "down"];
362
- const rowOf = (arm) => ({
363
- down: 3 + size - 1,
364
- mid: Math.ceil(shape.height / 2) + size,
365
- up: shape.top + size - 1,
366
- forward: Math.ceil(shape.height / 2) + size,
367
- })[arm];
368
- const nub = (x, arm) => {
369
- const reach = arm === "forward" ? 1 : 0;
370
- for (let row = 0; row < size; row++) {
371
- span(put, x + reach, x + reach + size - 1, rowOf(arm) - row, row === size - 1 ? "o" : "O");
372
- }
373
- };
374
- nub(-size, back);
375
- nub(shape.width, front);
376
- }
377
- // Gender sets an ornament at the back of the head, level the gear on top.
378
- function paintHead({ put, shape, look, gear, counter }) {
379
- const { width, top } = shape;
380
- if (look.gender === "female") {
381
- icon(["r.r", "rrr", "r.r"], 0, top + 3)(put, shape, 0);
382
- }
383
- else if (look.gender === "male") {
384
- put(1, top + 1, "O");
385
- put(2, top + 2, "O");
386
- put(3, top + 1, "O");
648
+ function paintHead({ upper, b, look, gear, pose }) {
649
+ const { chin } = b;
650
+ const hair = hairKeys[look.hair];
651
+ const turn = pose.turn ?? 0;
652
+ const jaw = look.build === "heavy" ? 2 : 1;
653
+ span(upper, -jaw, jaw, chin, "s");
654
+ span(upper, -2, 2, chin + 1, "s");
655
+ span(upper, -2, 2, chin + 2, "s");
656
+ span(upper, -2, 2, chin + 3, hair);
657
+ span(upper, -1, 1, chin + 4, hair);
658
+ const eye = pose.eyes === "closed" ? "q" : "e";
659
+ upper(turn - 1, chin + 1, eye);
660
+ upper(turn + 1, chin + 1, eye);
661
+ let mouth = "q";
662
+ if (pose.mouth === "smile") {
663
+ mouth = "m";
664
+ }
665
+ else if (pose.mouth === "open") {
666
+ mouth = "e";
667
+ }
668
+ upper(turn, chin, mouth);
669
+ // Short hair with sideburns, a side-swept fringe, or a chin-length bob.
670
+ if (look.gender === "male") {
671
+ upper(-2, chin + 2, hair);
672
+ upper(2, chin + 2, hair);
673
+ }
674
+ else if (look.gender === "female") {
675
+ upper(-2, chin + 2, hair);
676
+ upper(-1, chin + 2, hair);
677
+ upper(2, chin + 2, hair);
387
678
  }
388
679
  else {
389
- put(2, top + 1, "G");
390
- put(1, top + 2, "G");
391
- put(3, top + 3, "G");
680
+ for (const x of [-3, 3]) {
681
+ for (let y = chin; y <= chin + 3; y++) {
682
+ upper(x, y, hair);
683
+ }
684
+ }
685
+ upper(-2, chin + 2, hair);
686
+ upper(2, chin + 2, hair);
392
687
  }
393
688
  if (gear.gadget >= 1) {
394
- span(put, 5, width - 2, top + 1, "k");
395
- icon(["S", "S"], -1, top - 1)(put, shape, 0);
396
- icon(["S", "S"], width, top - 1)(put, shape, 0);
397
- }
398
- if (gear.gadget === 2) {
399
- put(width - 3, top + 2, "k");
400
- put(width - 3, top + 3, counter % 2 === 0 ? "t" : "y");
689
+ // Headphones: a band over the top and a cup on each ear.
690
+ upper(-2, chin + 4, "k");
691
+ upper(2, chin + 4, "k");
692
+ span(upper, -1, 1, chin + 5, "k");
693
+ for (const x of [-3, 3]) {
694
+ upper(x, chin + 1, "k");
695
+ upper(x, chin + 2, "k");
696
+ }
401
697
  }
402
698
  if (gear.headwear === 1) {
403
- span(put, 5, width - 3, top + 1, "S");
404
- span(put, 6, width - 4, top + 2, "S");
405
- span(put, width - 2, width, top + 1, "s");
699
+ span(upper, -1, 1, chin + 4, "S");
700
+ span(upper, -2, 2, chin + 3, "S");
701
+ span(upper, -3 + turn, 3 + turn, chin + 2, "z");
406
702
  }
407
703
  else if (gear.headwear === 2) {
408
- span(put, 6, width - 3, top + 1, "g");
409
- put(6, top + 2, "g");
410
- put(Math.floor((width + 3) / 2), top + 2, "g");
411
- put(width - 3, top + 2, "g");
412
- put(Math.floor((width + 3) / 2), top + 1, "t");
413
- }
414
- if (gear.aura >= 1) {
415
- const bob = counter % 4 < 2 ? 0 : 1;
416
- put(width + 4, top + 2 + bob, "t");
417
- put(width + 4, top + 3 + bob, "y");
704
+ span(upper, -2, 2, chin + 5, "g");
705
+ for (const x of [-2, 0, 2]) {
706
+ upper(x, chin + 6, "g");
707
+ }
708
+ upper(0, chin + 5, "t");
418
709
  }
419
710
  }
420
711
  export function paletteOf(palette) {
421
- const { cloak, cloakShade, glow } = palette.sprite;
712
+ const { cloak, cloakShade, scarf, scarfShade, glow } = palette.sprite;
422
713
  return new Map([
423
- ["c", "#5A3A25"],
714
+ ["A", "#121212"],
715
+ ["B", "#3E3226"],
716
+ ["b", "#6B4A2E"],
717
+ ["C", cloak],
718
+ ["c", cloakShade],
424
719
  ["D", "#50555F"],
425
- ["d", "#8A8F99"],
426
- ["e", "#1E1E1E"],
720
+ ["d", "#9AA0AA"],
721
+ ["e", "#2B2B2B"],
427
722
  ["F", "#C8473F"],
428
723
  ["G", "#6FA35A"],
429
724
  ["g", "#D9B45A"],
430
- ["h", palette.soft],
725
+ ["H", "#9C9C9C"],
726
+ ["h", "#4A3222"],
727
+ ["I", "#E4E4E4"],
431
728
  ["k", "#2B2B2B"],
432
729
  ["L", "#4A505B"],
433
730
  ["l", glow],
434
- ["O", palette.accent],
435
- ["o", palette.deep],
731
+ ["m", "#B5524A"],
732
+ ["N", "#B59A6A"],
733
+ ["n", "#937B52"],
734
+ ["O", "#5E6B45"],
735
+ ["o", "#5A3A25"],
736
+ ["P", "#3E6870"],
737
+ ["p", "#33565D"],
436
738
  ["Q", "#E6E0D4"],
437
- ["r", "#E87A90"],
438
- ["S", cloak],
439
- ["s", cloakShade],
440
- ["T", "#E5533D"],
739
+ ["q", "#C99872"],
740
+ ["S", scarf],
741
+ ["s", "#E2B48C"],
441
742
  ["t", glow],
743
+ ["U", "#B4AE9F"],
744
+ ["V", "#4A5536"],
442
745
  ["v", "#9AA0A8"],
443
746
  ["w", "#F2F2F2"],
747
+ ["X", "#2F3540"],
444
748
  ["x", "#3D414B"],
445
749
  ["y", palette.soft],
446
- ["z", "#C8C8C8"],
750
+ ["Z", "#C8C8C8"],
751
+ ["z", scarfShade],
447
752
  ]);
448
753
  }
449
754
  // The pixel keys of 1 frame, row by row, "." where nothing is drawn.
450
- export function composeScene({ activity, counter, level, look, mood, badges, width, }) {
755
+ export function composeScene({ activity, counter, level, look, mood, width, }) {
451
756
  const canvas = Array.from({ length: stageHeight }, () => Array.from({ length: width }, () => "."));
452
- const plain = (column, y, key) => {
453
- const row = canvas[stageHeight - 1 - y];
757
+ const scene = sceneOf(activity, counter, width, mood);
758
+ const b = bodyOf(look);
759
+ const pose = scene.pose(b);
760
+ const lift = pose.lift ?? 0;
761
+ // The character's x = 0 is its center column, mirrored when facing left.
762
+ const plant = (rise) => (x, y, key) => {
763
+ const column = scene.x + scene.facing * x;
764
+ const row = canvas[stageHeight - 1 - (y + rise)];
454
765
  if (row && column >= 0 && column < width && key !== ".") {
455
766
  row[column] = key;
456
767
  }
457
768
  };
458
- const baseShape = shapeOf(look, level);
459
- const scene = sceneOf(activity, counter, width, baseShape, mood);
460
- const shape = shapeOf(look, level, scene.pose.squash ?? 0);
461
- const lift = scene.pose.lift ?? 0;
462
- // Local x runs from the body's left edge toward where the pet faces.
463
- const put = (x, y, key) => {
464
- const column = scene.facing === 1 ? scene.x + x : scene.x + baseShape.width - 1 - x;
465
- plain(column, y + lift, key);
466
- };
769
+ const put = plant(lift);
770
+ const upper = plant(lift - (pose.bob ?? 0));
467
771
  const paint = {
468
772
  put,
469
- shape,
773
+ upper,
774
+ b,
470
775
  look,
471
776
  gear: gearOf(level),
472
- pose: scene.pose,
777
+ pose,
473
778
  counter,
474
779
  };
475
- paintBehind(paint);
780
+ paintShadow(plant(0), b, lift);
781
+ paintHairBehind(paint);
476
782
  paintLegs(paint);
477
- paintBody(paint);
478
- paintFace(paint);
783
+ paintTorso(paint);
479
784
  paintArms(paint);
480
785
  paintHead(paint);
481
786
  for (const draw of scene.props ?? []) {
482
- draw(put, shape, counter);
483
- }
484
- // Today's badges float in the top right corner, whatever the pet does.
485
- const icons = [
486
- badges.focus ? ["GGG", "TTT", "TTT"] : undefined,
487
- badges.meal ? [".G.", "FFF", ".F."] : undefined,
488
- badges.workout ? ["D.D", "DdD", "D.D"] : undefined,
489
- ].filter((rows) => rows !== undefined);
490
- for (const [index, rows] of icons.entries()) {
491
- icon(rows, width - 4 - index * 4, stageHeight - 2)(plain, shape, 0);
787
+ draw(put, upper, b);
492
788
  }
493
789
  return canvas.map((row) => row.join(""));
494
790
  }