@siegesailor/terminal-sigma 1.4.1 → 1.6.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,494 +1,1245 @@
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 40 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 20 rows.
4
+ export const stageHeight = 40;
5
+ // 10 frames a second; slower motions hold a pose for several frames.
6
+ export const frameMilliseconds = 100;
7
+ export const defaultLook = {
8
+ height: 29,
9
+ build: "average",
10
+ gender: "other",
11
+ style: "desk",
12
+ hair: "dark",
13
+ isMuscular: false,
14
+ };
15
+ // Body fat that counts as lean, soft, and heavy, by gender.
16
+ const fatBands = {
17
+ female: [21, 32, 38],
18
+ male: [13, 22, 28],
19
+ other: [17, 27, 33],
20
+ };
21
+ // Body fat tells muscle from fat at the same weight; without it, BMI decides.
22
+ function buildOf(profile) {
23
+ const bmi = profile.weight / (profile.height / 100) ** 2;
24
+ if (profile.bodyFat === undefined) {
25
+ if (bmi < 19.5) {
26
+ return "slim";
27
+ }
28
+ if (bmi >= 30) {
29
+ return "heavy";
30
+ }
31
+ return bmi >= 25 ? "stocky" : "average";
32
+ }
33
+ const [lean, soft, heavy] = fatBands[profile.gender];
34
+ if (profile.bodyFat < lean) {
35
+ return bmi >= 23 ? "athletic" : "slim";
36
+ }
37
+ if (profile.bodyFat >= heavy) {
38
+ return "heavy";
39
+ }
40
+ return profile.bodyFat >= soft ? "stocky" : "average";
41
+ }
42
+ // 145 cm draws 26 pixels tall and 205 cm 34.
6
43
  export function lookOf(profile) {
7
44
  if (!profile) {
8
45
  return defaultLook;
9
46
  }
10
- const bmi = profile.weight / (profile.height / 100) ** 2;
11
- let build = "average";
12
- if (bmi < 20) {
13
- build = "slim";
47
+ const build = buildOf(profile);
48
+ let hair = "dark";
49
+ if (profile.age >= 70) {
50
+ hair = "white";
14
51
  }
15
- else if (bmi >= 27) {
16
- build = "broad";
52
+ else if (profile.age >= 55) {
53
+ hair = "grey";
17
54
  }
18
55
  return {
19
- size: Math.min(Math.max(Math.floor((profile.height - 150) / 12), 0), 3),
56
+ height: Math.min(Math.max(Math.round(26 + (profile.height - 145) / 7.5), 24), 35),
20
57
  build,
21
58
  gender: profile.gender,
22
- goal: profile.goal,
59
+ style: profile.workStyle,
60
+ hair,
61
+ isMuscular: build === "athletic" || profile.goal === "buildMuscle",
23
62
  };
24
63
  }
25
- export const noBadges = { meal: false, workout: false, focus: false };
26
64
  // Each equipment slot upgrades once from level 2 and again 4 levels later,
27
65
  // staggered so every level from 2 to 9 changes exactly 1 piece.
28
66
  export const gearOf = (level) => {
29
67
  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) };
68
+ return { gadget: tier(0), wear: tier(1), headwear: tier(2), carry: tier(3) };
31
69
  };
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 };
70
+ // The feet stand on row 2, in the middle of their shadow.
71
+ const ground = 2;
72
+ const frames = {
73
+ female: [4, 3, 5],
74
+ male: [5, 4, 4],
75
+ other: [4, 4, 4],
40
76
  };
77
+ // A 7-row head and a neck take 8 of the height; legs take 55% of the rest.
78
+ function bodyOf(look) {
79
+ const span = look.height - 8;
80
+ const legs = Math.round(span * 0.55);
81
+ const torso = span - legs;
82
+ let [chest, waist, hips] = frames[look.gender];
83
+ switch (look.build) {
84
+ case "slim": {
85
+ chest -= look.gender === "male" ? 1 : 0;
86
+ waist = Math.max(waist - 1, 2);
87
+ hips = Math.max(hips - 1, 3);
88
+ break;
89
+ }
90
+ case "athletic": {
91
+ chest += 1;
92
+ break;
93
+ }
94
+ case "stocky": {
95
+ waist += 1;
96
+ hips += 1;
97
+ break;
98
+ }
99
+ case "heavy": {
100
+ chest += 1;
101
+ waist += 3;
102
+ hips += 2;
103
+ break;
104
+ }
105
+ case "average": {
106
+ break;
107
+ }
108
+ }
109
+ if (look.isMuscular && look.build !== "athletic") {
110
+ chest += 1;
111
+ }
112
+ let thigh = Math.max(hips - 1, 3);
113
+ if (look.build === "heavy") {
114
+ thigh = hips;
115
+ }
116
+ else if (look.build === "slim") {
117
+ thigh = Math.max(hips - 1, 2);
118
+ }
119
+ const hip = ground + legs;
120
+ const shoulder = hip + torso - 1;
121
+ const chin = shoulder + 2;
122
+ return {
123
+ hip,
124
+ shoulder,
125
+ chin,
126
+ top: chin + 6,
127
+ legs,
128
+ knee: ground + Math.floor(legs / 2),
129
+ shin: ground + Math.floor(legs / 4),
130
+ chest,
131
+ waist,
132
+ hips,
133
+ thigh,
134
+ calf: Math.max(thigh - 1, 2),
135
+ ankle: Math.max(thigh - 2, 2),
136
+ arm: look.isMuscular || look.build === "heavy" ? 3 : 2,
137
+ reach: torso + 3,
138
+ };
139
+ }
41
140
  const span = (put, from, to, y, key) => {
42
- for (let x = from; x <= to; x++) {
141
+ for (let x = Math.min(from, to); x <= Math.max(from, to); x++) {
43
142
  put(x, y, key);
44
143
  }
45
144
  };
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);
145
+ const pointsOf = ([x0, y0], [x1, y1]) => {
146
+ const points = [];
147
+ const steps = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1);
148
+ for (let step = 0; step <= steps; step++) {
149
+ points.push([
150
+ Math.round(x0 + ((x1 - x0) * step) / steps),
151
+ Math.round(y0 + ((y1 - y0) * step) / steps),
152
+ ]);
153
+ }
154
+ return points;
155
+ };
156
+ // Where a hand rests with the arm hanging, nudged by `swing`.
157
+ const hanging = (b, side, swing = 0) => [
158
+ side * (b.chest + 1),
159
+ b.shoulder - b.reach + 1 + swing,
160
+ ];
161
+ const breathing = (t, every = 6) => Math.floor(t / every) % 2;
162
+ // The stage edges the character walks between.
163
+ const marginOf = (width) => {
164
+ const minimum = 9;
165
+ return { minimum, maximum: Math.max(width - 10, minimum + 1) };
166
+ };
167
+ export const homeOf = (width) => Math.floor(width / 2);
168
+ // Walks a path of stops at `speed` pixels a tick, returning the position
169
+ // and heading at tick `t`, or undefined once the path is done.
170
+ function along(stops, speed, t) {
171
+ let remaining = t;
172
+ for (let index = 1; index < stops.length; index++) {
173
+ const from = stops[index - 1] ?? 0;
174
+ const to = stops[index] ?? 0;
175
+ const ticks = Math.ceil(Math.abs(to - from) / speed);
176
+ if (remaining < ticks) {
177
+ const facing = to >= from ? 1 : -1;
178
+ return {
179
+ x: from + facing * Math.min(remaining * speed, Math.abs(to - from)),
180
+ facing,
181
+ };
182
+ }
183
+ remaining -= ticks;
184
+ }
185
+ return undefined;
186
+ }
187
+ const pathTicks = (stops, speed) => stops
188
+ .slice(1)
189
+ .reduce((total, stop, index) => total + Math.ceil(Math.abs(stop - (stops[index] ?? 0)) / speed), 0);
190
+ const pause = 24;
191
+ // Idle: pause, walk to 1 edge, pause, walk to the other, pause, walk home.
192
+ function wanderAt(t, width, anchor) {
193
+ const { minimum, maximum } = marginOf(width);
194
+ const legs = [
195
+ [anchor, maximum],
196
+ [maximum, minimum],
197
+ [minimum, anchor],
198
+ ];
199
+ let remaining = t;
200
+ for (const [from, to] of legs) {
201
+ if (remaining < pause) {
202
+ return { x: from, facing: 1, resting: remaining };
203
+ }
204
+ remaining -= pause;
205
+ const walked = along([from, to], 1, remaining);
206
+ if (walked) {
207
+ return { ...walked, walking: remaining };
50
208
  }
209
+ remaining -= Math.abs(to - from);
51
210
  }
211
+ return { x: anchor, facing: 1, resting: 0 };
212
+ }
213
+ const runStops = (width, anchor) => {
214
+ const { minimum, maximum } = marginOf(width);
215
+ return [anchor, maximum, minimum, anchor];
52
216
  };
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");
217
+ const cycles = {
218
+ focus: 16,
219
+ break: 40,
220
+ eat: 32,
221
+ lift: 32,
222
+ stretch: 40,
223
+ levelUp: 16,
224
+ };
225
+ // How many ticks one loop of an activity takes.
226
+ export function cycleOf(activity, width, anchor = homeOf(width), mood = "content") {
227
+ if (activity === "idle") {
228
+ const { minimum, maximum } = marginOf(width);
229
+ return mood === "sleepy" ? 48 : 3 * pause + 2 * (maximum - minimum);
230
+ }
231
+ if (activity === "run") {
232
+ return pathTicks(runStops(width, anchor), 2);
56
233
  }
234
+ return cycles[activity];
235
+ }
236
+ const smileOf = (mood) => (mood === "happy" ? "smile" : undefined);
237
+ // A standing desk at the waist, with the laptop's lid facing out.
238
+ const desk = (put, _upper, b) => {
239
+ const top = b.hip + 1;
240
+ span(put, -9, 9, top, "L");
241
+ span(put, -9, 9, top - 1, "x");
242
+ for (let y = 0; y < top - 1; y++) {
243
+ put(-8, y, "x");
244
+ put(8, y, "x");
245
+ }
246
+ for (let y = top + 1; y <= top + 4; y++) {
247
+ span(put, -3, 3, y, "d");
248
+ }
249
+ span(put, -3, 3, top + 1, "D");
250
+ put(0, top + 3, "l");
57
251
  };
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");
252
+ const mug = ([x, y], steam) => (_put, upper) => {
253
+ span(upper, x, x + 2, y, "Q");
254
+ span(upper, x, x + 2, y + 1, "Q");
255
+ span(upper, x, x + 2, y + 2, "U");
256
+ upper(x + 1, y + 2, "o");
257
+ upper(x + 3, y + 1, "Q");
258
+ if (steam !== undefined) {
259
+ const sway = Math.floor(steam / 3) % 2;
260
+ upper(x + sway, y + 4, "v");
261
+ upper(x + 1 - sway, y + 5, "v");
262
+ upper(x + 2 - sway, y + 6, "v");
263
+ }
264
+ };
265
+ const apple = ([x, y], isBitten) => (_put, upper) => {
266
+ span(upper, x, x + 2, y, "F");
267
+ span(upper, x, x + 2, y + 1, "F");
268
+ span(upper, x, x + 1, y + 2, "F");
269
+ upper(x + 2, y + 2, isBitten ? "w" : "F");
270
+ upper(x, y + 1, "T");
271
+ upper(x + 1, y + 3, "G");
272
+ upper(x + 2, y + 4, "G");
273
+ };
274
+ const barbell = (y) => (_put, upper, b) => {
275
+ const end = b.chest + 7;
276
+ span(upper, -end, end, y, "d");
277
+ for (const x of [-end, -end + 1, end - 1, end]) {
278
+ for (let dy = -2; dy <= 2; dy++) {
279
+ upper(x, y + dy, Math.abs(x) === end ? "D" : "d");
280
+ }
62
281
  }
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");
282
+ };
283
+ const sparkles = (t) => (put, _upper, b) => {
284
+ const points = t % 4 < 2
285
+ ? [
286
+ [-9, b.top],
287
+ [9, b.top + 3],
288
+ [-11, b.shoulder],
289
+ [11, b.shoulder - 3],
290
+ [0, b.top + 5],
291
+ ]
292
+ : [
293
+ [-10, b.top + 3],
294
+ [10, b.top],
295
+ [-9, b.shoulder - 3],
296
+ [9, b.shoulder],
297
+ [-3, b.top + 4],
298
+ ];
299
+ for (const [x, y] of points) {
300
+ put(x, y, "y");
301
+ put(x - 1, y, "y");
302
+ put(x + 1, y, "y");
303
+ put(x, y - 1, "y");
304
+ put(x, y + 1, "y");
305
+ }
306
+ };
307
+ const sleepyZ = (t) => (put, _upper, b) => {
308
+ const rise = Math.floor((t % 24) / 3);
309
+ const rows = ["ZZZZ", "..Z.", ".Z..", "ZZZZ"];
310
+ for (const [offset, row] of rows.entries()) {
311
+ for (const [index, key] of [...row].entries()) {
312
+ put(4 + (rise % 2) + index, b.top + 1 + rise - offset, key);
313
+ }
67
314
  }
68
315
  };
69
- const held = (rows, dy) => (put, shape) => {
70
- icon(rows, shape.width + 2, dy(shape))(put, shape, 0);
316
+ // Standing still between walks: breathing, blinking, looking about, shaking
317
+ // out the hands, and waving when every target is met.
318
+ const resting = (t, mood) => (b) => {
319
+ let turn = 0;
320
+ if (t >= 8 && t < 12) {
321
+ turn = -1;
322
+ }
323
+ else if (t >= 12 && t < 16) {
324
+ turn = 1;
325
+ }
326
+ const isWaving = mood === "happy" && t >= 2 && t < 14;
327
+ const isShaking = t >= 16 && t < 22;
328
+ const shake = isShaking ? t % 2 : 0;
329
+ let hands = [
330
+ hanging(b, -1, shake),
331
+ hanging(b, 1, isShaking ? 1 - shake : 0),
332
+ ];
333
+ if (isWaving) {
334
+ hands = [
335
+ hanging(b, -1),
336
+ [b.chest + 3 + (Math.floor(t / 2) % 2), b.top - 1],
337
+ ];
338
+ }
339
+ return {
340
+ view: "front",
341
+ mouth: smileOf(mood),
342
+ bob: breathing(t),
343
+ eyes: t === 5 || t === 6 ? "closed" : "open",
344
+ turn,
345
+ hands,
346
+ };
71
347
  };
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);
77
- const travel = maximum - minimum;
78
- const tick = counter % 4;
348
+ // Where the character stands and what it does on tick `t` of an activity
349
+ // that began at `anchor`. Every loop starts and ends at the anchor.
350
+ function sceneOf(activity, t, width, mood, anchor) {
351
+ const cycle = cycleOf(activity, width, anchor, mood);
352
+ const at = t % cycle;
353
+ const mouth = smileOf(mood);
354
+ const still = (pose, props, lift = 0) => ({
355
+ x: anchor,
356
+ facing: 1,
357
+ lift,
358
+ pose,
359
+ props,
360
+ });
79
361
  if (activity === "idle" && mood === "sleepy") {
80
- // Napping in place: breathing, with Zs drifting up.
81
- return {
82
- x: Math.floor((width - shape.width) / 2),
83
- 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
- ],
91
- };
362
+ // Dozing on its feet: nodding off, yawning, with Zs drifting up.
363
+ const isYawning = at >= 36 && at < 44;
364
+ return still((b) => ({
365
+ view: "front",
366
+ eyes: "closed",
367
+ nod: at % 16 >= 8 ? 1 : 0,
368
+ bob: breathing(at, 8),
369
+ mouth: isYawning ? "open" : undefined,
370
+ hands: isYawning ? [undefined, [2, b.chin]] : undefined,
371
+ }), [sleepyZ(at)]);
92
372
  }
93
- if (activity === "idle") {
94
- // 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
- });
110
- if (at < pause) {
111
- return {
112
- x: minimum,
113
- facing: at === 2 || at === 3 ? -1 : 1,
114
- pose: rest(at),
115
- };
116
- }
117
- if (at < pause + travel) {
118
- return { x: minimum + at - pause, facing: 1, pose: walk(at - pause) };
119
- }
120
- if (at < 2 * pause + travel) {
121
- const moment = at - pause - travel;
373
+ switch (activity) {
374
+ case "idle": {
375
+ const place = wanderAt(at, width, anchor);
376
+ if ("walking" in place) {
377
+ return {
378
+ x: place.x,
379
+ facing: place.facing,
380
+ lift: 0,
381
+ pose: () => ({
382
+ view: "side",
383
+ frame: place.walking,
384
+ gait: "walk",
385
+ mouth,
386
+ }),
387
+ };
388
+ }
122
389
  return {
123
- x: maximum,
124
- facing: moment === 2 || moment === 3 ? 1 : -1,
125
- pose: rest(moment),
390
+ x: place.x,
391
+ facing: 1,
392
+ lift: mood === "happy" && place.resting === 1 ? 1 : 0,
393
+ pose: resting(place.resting, mood),
126
394
  };
127
395
  }
128
- const step = at - 2 * pause - travel;
129
- return { x: maximum - step, facing: -1, pose: walk(step) };
130
- }
131
- switch (activity) {
132
396
  case "run": {
133
- const lap = Math.max(Math.ceil(travel / 2), 1);
134
- const at = counter % (2 * lap);
135
- const forward = at < lap;
136
- const step = forward ? at : at - lap;
137
- return {
138
- x: forward
139
- ? Math.min(minimum + step * 2, maximum)
140
- : Math.max(maximum - step * 2, minimum),
141
- 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)],
397
+ // A lap out to 1 edge, across to the other, and back home.
398
+ const place = along(runStops(width, anchor), 2, at) ?? {
399
+ x: anchor,
400
+ facing: 1,
149
401
  };
150
- }
151
- case "focus": {
152
402
  return {
153
- x: home,
154
- facing: 1,
155
- pose: {
156
- look: 1,
157
- squash: tick < 2 ? 0 : 1,
158
- arms: ["down", tick % 2 === 0 ? "forward" : "mid"],
159
- },
403
+ x: place.x,
404
+ facing: place.facing,
405
+ lift: 0,
406
+ pose: () => ({ view: "side", frame: at, gait: "run", mouth }),
160
407
  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");
408
+ (put, _upper, b) => {
409
+ put(-(b.chest + 3), b.top - 1 - (at % 2), "v");
410
+ put(-(b.chest + 5), b.hip + (at % 3), "v");
165
411
  },
166
412
  ],
167
413
  };
168
414
  }
415
+ case "focus": {
416
+ // Typing at a standing desk, the screen glowing.
417
+ const key = Math.floor(at / 2) % 2;
418
+ return still((b) => ({
419
+ view: "front",
420
+ mouth,
421
+ bob: breathing(at, 8),
422
+ hands: [
423
+ [-3, b.hip + 3 + key],
424
+ [3, b.hip + 4 - key],
425
+ ],
426
+ }), [
427
+ desk,
428
+ (put, _upper, b) => {
429
+ put(-2 + (at % 5), b.top + 2 + key, "y");
430
+ },
431
+ ]);
432
+ }
169
433
  case "break": {
170
- const step = counter % 6;
171
- const sipping = step >= 4;
172
- return {
173
- x: home,
174
- facing: 1,
175
- pose: {
176
- look: 1,
177
- squash: step % 2,
178
- eyes: sipping ? "closed" : "open",
179
- arms: ["down", sipping ? "up" : "mid"],
434
+ // A coffee, sipped every few seconds.
435
+ const isSipping = at >= 28;
436
+ const hand = (b) => isSipping ? [2, b.chin] : [b.chest + 2, b.hip + 2];
437
+ return still((b) => ({
438
+ view: "front",
439
+ mouth,
440
+ bob: breathing(at, isSipping ? 2 : 6),
441
+ eyes: isSipping ? "closed" : "open",
442
+ hands: [undefined, hand(b)],
443
+ }), [
444
+ (put, upper, b) => {
445
+ const [x, y] = hand(b);
446
+ mug([x, y + 1], isSipping ? undefined : at)(put, upper, b);
180
447
  },
181
- 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
- ]),
188
- ],
189
- };
448
+ ]);
190
449
  }
191
450
  case "eat": {
192
- const atMouth = tick === 1 || tick === 2;
193
- return {
194
- x: home,
195
- 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"],
451
+ // Raise the apple, take a bite, chew, and lower it.
452
+ const isRaised = at >= 12 && at < 24;
453
+ const isChewing = at >= 16 && at < 24;
454
+ const hand = (b) => isRaised ? [2, b.chin - 1] : [b.chest + 2, b.hip + 3];
455
+ return still((b) => ({
456
+ view: "front",
457
+ bob: breathing(at, 4),
458
+ mouth: isChewing && at % 2 === 0 ? "open" : mouth,
459
+ hands: [undefined, hand(b)],
460
+ }), [
461
+ (put, upper, b) => {
462
+ const [x, y] = hand(b);
463
+ apple([x, y], at >= 18)(put, upper, b);
201
464
  },
202
- props: [
203
- held(tick === 3 ? [".G", "F.", "FF"] : [".G", "FF", "FF"], (shape) => atMouth ? shape.top - 2 : Math.ceil(shape.height / 2) + 2),
204
- ],
205
- };
465
+ ]);
206
466
  }
207
467
  case "lift": {
208
- const up = tick === 1 || tick === 2;
209
- return {
210
- x: home,
211
- facing: 1,
212
- pose: {
213
- arms: up ? ["up", "up"] : ["mid", "mid"],
214
- lift: tick === 2 ? 1 : 0,
215
- squash: up ? 0 : 1,
216
- },
217
- 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);
223
- },
224
- ],
468
+ // An overhead press: rack, press, lock out, lower, rest.
469
+ const height = (b) => {
470
+ const rack = b.shoulder;
471
+ const lockout = b.top + 2;
472
+ if (at < 8 || at >= 28) {
473
+ return rack;
474
+ }
475
+ if (at < 12) {
476
+ return rack + Math.round(((lockout - rack) * (at - 7)) / 4);
477
+ }
478
+ if (at < 20) {
479
+ return lockout;
480
+ }
481
+ return lockout - Math.round(((lockout - rack) * (at - 19)) / 8);
225
482
  };
483
+ return still((b) => {
484
+ const y = height(b);
485
+ const isLocked = at >= 12 && at < 20;
486
+ const x = isLocked ? b.chest + 1 : b.chest + 3;
487
+ return {
488
+ view: "front",
489
+ mouth,
490
+ bob: breathing(at, isLocked ? 2 : 4),
491
+ hands: [
492
+ [-x, y],
493
+ [x, y],
494
+ ],
495
+ };
496
+ }, [
497
+ (put, upper, b) => {
498
+ barbell(height(b) + 1)(put, upper, b);
499
+ },
500
+ ]);
226
501
  }
227
502
  case "stretch": {
228
- return {
229
- x: home,
230
- facing: 1,
231
- pose: {
232
- arms: tick < 2 ? ["up", "up"] : ["mid", "mid"],
233
- squash: tick < 2 ? -1 : 1,
234
- eyes: tick === 1 ? "closed" : "open",
235
- },
236
- };
503
+ // Reach up, rise on the toes, open the arms wide, shake them out.
504
+ const phase = Math.floor(at / 10);
505
+ return still((b) => {
506
+ const reaches = [
507
+ [1, b.top + 3],
508
+ [1, b.top + 4],
509
+ [b.chest + 8, b.shoulder + 1],
510
+ [b.chest + 1, b.shoulder - b.reach + 1 + (at % 2)],
511
+ ];
512
+ const [x, y] = reaches[phase] ?? [1, b.top + 3];
513
+ return {
514
+ view: "front",
515
+ mouth,
516
+ bob: breathing(at, 5),
517
+ eyes: phase === 1 ? "closed" : "open",
518
+ hands: [
519
+ [-x, phase === 3 ? y + 1 - (at % 2) : y],
520
+ [x, y],
521
+ ],
522
+ };
523
+ }, undefined, phase === 1 ? 1 : 0);
237
524
  }
238
525
  case "levelUp": {
239
- return {
240
- x: home,
241
- facing: 1,
242
- pose: {
243
- arms: ["up", "up"],
244
- lift: [0, 2, 3, 1][tick] ?? 0,
245
- eyes: "happy",
246
- },
247
- 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
- ]),
526
+ // Crouch, jump with both arms up, land, with sparkles.
527
+ const lift = [0, 0, 0, 1, 2, 3, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0][at] ?? 0;
528
+ return still((b) => ({
529
+ view: "front",
530
+ mouth: "smile",
531
+ bob: at < 3 || at > 11 ? 1 : 0,
532
+ hands: [
533
+ [-(b.chest + 4), b.top + 1],
534
+ [b.chest + 4, b.top + 1],
261
535
  ],
262
- };
536
+ }), [sparkles(at)], lift);
263
537
  }
264
538
  }
265
539
  }
266
- export const intervalOf = (activity) => ({
267
- idle: 220,
268
- focus: 180,
269
- break: 380,
270
- eat: 300,
271
- run: 110,
272
- lift: 400,
273
- stretch: 480,
274
- levelUp: 160,
275
- })[activity];
276
- // How many ticks one loop of an activity takes on a stage of `width`.
277
- export function cycleOf(activity, width) {
278
- const travel = Math.max(width - 21, 4);
279
- if (activity === "idle") {
280
- return 2 * (6 + travel);
281
- }
282
- if (activity === "run") {
283
- return 2 * Math.max(Math.ceil(travel / 2), 1);
540
+ // Where the character is on tick `t` of an activity, to keep moving from
541
+ // there when the next one begins.
542
+ export function placeOf(activity, t, width, mood, anchor) {
543
+ const scene = sceneOf(activity, t, width, mood, anchor);
544
+ return { x: scene.x, lift: scene.lift };
545
+ }
546
+ export const startMotion = (activity, mood, width) => ({ activity, mood, anchor: homeOf(width), tick: 0 });
547
+ // 1 frame later. A new activity starts where the character stands: idling
548
+ // gives way at once, and anything else first finishes its loop back home, so
549
+ // the character never jumps.
550
+ export function advance(motion, wanted, width) {
551
+ const isSame = wanted.activity === motion.activity && wanted.mood === motion.mood;
552
+ if (!isSame) {
553
+ const { x, lift } = placeOf(motion.activity, motion.tick, width, motion.mood, motion.anchor);
554
+ if (motion.activity === "idle" || (x === motion.anchor && lift === 0)) {
555
+ return { ...wanted, anchor: x, tick: 0 };
556
+ }
284
557
  }
285
- return activity === "break" ? 6 : 4;
558
+ return { ...motion, tick: motion.tick + 1 };
286
559
  }
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");
560
+ // The tops by work style: a hoodie, a shirt under an apron, a tee, a tank.
561
+ const topOf = {
562
+ desk: { main: "C", shade: "c", light: "E" },
563
+ standing: { main: "Q", shade: "U", light: "w" },
564
+ active: { main: "C", shade: "c", light: "E" },
565
+ athlete: { main: "S", shade: "z", light: "T" },
566
+ };
567
+ // How much of the arm the sleeve covers, from the shoulder down.
568
+ const sleeveOf = {
569
+ desk: 1,
570
+ standing: 0.5,
571
+ active: 0.35,
572
+ athlete: 0,
573
+ };
574
+ // Jeans and sneakers, chinos and shoes, cargo pants and boots, or shorts
575
+ // and running shoes.
576
+ const bottomOf = {
577
+ desk: {
578
+ main: "P",
579
+ shade: "p",
580
+ light: "R",
581
+ shoe: "w",
582
+ sole: "Y",
583
+ shoeRows: 2,
584
+ },
585
+ standing: {
586
+ main: "N",
587
+ shade: "n",
588
+ light: "N",
589
+ shoe: "b",
590
+ sole: "o",
591
+ shoeRows: 2,
592
+ },
593
+ active: {
594
+ main: "O",
595
+ shade: "V",
596
+ light: "O",
597
+ shoe: "B",
598
+ sole: "e",
599
+ shoeRows: 3,
600
+ },
601
+ athlete: {
602
+ main: "x",
603
+ shade: "x",
604
+ light: "x",
605
+ shoe: "S",
606
+ sole: "w",
607
+ shoeRows: 2,
608
+ },
609
+ };
610
+ const hairOf = {
611
+ dark: ["h", "i"],
612
+ grey: ["H", "J"],
613
+ white: ["I", "K"],
614
+ };
615
+ // An oval on the ground with the feet in its middle, shrinking in the air.
616
+ function paintShadow(put, b, lift) {
617
+ const half = b.hips + 5 - Math.min(lift, 3);
618
+ span(put, -half + 3, half - 3, ground - 2, "A");
619
+ span(put, -half + 1, half - 1, ground - 1, "A");
620
+ span(put, -half, half, ground, "A");
621
+ span(put, -half + 2, half - 2, ground + 1, "A");
622
+ }
623
+ // Long hair falls behind the shoulders, so it goes on before the body.
624
+ function paintHairBehind({ upper, b, look, counter }, nod) {
625
+ const [hair, light] = hairOf[look.hair];
626
+ const head = (x, y, key) => {
627
+ upper(x, y - nod, key);
628
+ };
629
+ if (look.gender === "other") {
630
+ for (let y = b.chin + 1; y <= b.chin + 4; y++) {
631
+ head(-4, y, hair);
632
+ head(4, y, hair);
293
633
  }
634
+ return;
294
635
  }
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");
636
+ if (look.gender !== "female") {
637
+ return;
300
638
  }
301
- }
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);
311
- }
312
- put(x, 1, "o");
639
+ if (look.style === "athlete") {
640
+ // A ponytail that swings.
641
+ const sway = Math.floor(counter / 3) % 2;
642
+ head(4, b.chin + 4, hair);
643
+ head(5, b.chin + 3, hair);
644
+ head(5 + sway, b.chin + 2, light);
645
+ head(5 + sway, b.chin + 1, hair);
646
+ return;
313
647
  }
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");
648
+ for (let y = b.shoulder - 3; y <= b.chin + 4; y++) {
649
+ head(-4, y, hair);
650
+ head(4, y, y % 3 === 0 ? light : hair);
651
+ }
652
+ for (let y = b.shoulder - 2; y < b.chin; y++) {
653
+ span(upper, -3, 3, y, hair);
320
654
  }
321
- span(put, 1, 2, top - 1, "h");
322
- if (look.goal === "loseFat") {
323
- span(put, 0, width - 1, top - 1, "w");
655
+ }
656
+ function legKeyOf(look, b, at, side) {
657
+ const bottom = bottomOf[look.style];
658
+ const shorts = ground + Math.round(b.legs * 0.55);
659
+ if (at.y < at.sole + bottom.shoeRows) {
660
+ return at.y === at.sole ? bottom.sole : bottom.shoe;
324
661
  }
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);
662
+ if (look.style === "athlete" && at.y < shorts) {
663
+ return at.y === b.knee - 1 && at.x === 2 ? "q" : "s";
328
664
  }
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");
665
+ if (at.x === at.width) {
666
+ return side < 0 ? bottom.light : bottom.shade;
333
667
  }
668
+ return at.y === at.sole + bottom.shoeRows ? bottom.shade : bottom.main;
334
669
  }
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");
670
+ function paintLegsFront({ put, b, look }, pose) {
671
+ const bottom = bottomOf[look.style];
672
+ const shorts = ground + Math.round(b.legs * 0.55);
673
+ for (const side of [-1, 1]) {
674
+ const isLifted = (side === -1 && pose.step === 1) || (side === 1 && pose.step === 3);
675
+ const sole = ground + (isLifted ? 1 : 0);
676
+ for (let y = sole; y < b.hip; y++) {
677
+ let width = b.ankle;
678
+ if (y < sole + bottom.shoeRows) {
679
+ width = b.ankle + 1;
680
+ }
681
+ else if (y >= b.knee) {
682
+ width = b.thigh;
683
+ }
684
+ else if (y >= b.shin) {
685
+ width = b.calf;
686
+ }
687
+ for (let x = 1; x <= width; x++) {
688
+ put(side * x, y, legKeyOf(look, b, { x, y, sole, width }, side));
689
+ }
345
690
  }
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");
691
+ if (look.style === "active") {
692
+ put(side * b.thigh, b.knee + 1, "V");
693
+ put(side * b.thigh, b.knee + 2, "V");
694
+ put(side * (b.thigh - 1), b.knee + 2, "V");
351
695
  }
352
- else {
353
- put(x, eyeY, "e");
354
- put(x, eyeY + 1, counter % 23 === 0 ? "O" : "e");
696
+ if (look.style === "athlete") {
697
+ put(side * b.thigh, shorts + 1, "S");
355
698
  }
356
699
  }
700
+ // The thighs meet at the crotch, and all the way down on a heavy build.
701
+ let crotch = b.hip - 2;
702
+ if (look.build === "heavy") {
703
+ crotch = look.style === "athlete" ? shorts : b.knee;
704
+ }
705
+ for (let y = crotch; y < b.hip; y++) {
706
+ put(0, y, bottom.main);
707
+ }
357
708
  }
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");
709
+ function paintTorsoFront({ upper, b, look, gear }) {
710
+ const top = topOf[look.style];
711
+ const torso = b.shoulder - b.hip + 1;
712
+ const chestRows = Math.ceil(torso * 0.45);
713
+ const halfAt = (y) => {
714
+ if (b.shoulder - y < chestRows) {
715
+ return b.chest;
372
716
  }
717
+ return y - b.hip < 2 ? Math.max(b.hips, b.waist) : b.waist;
373
718
  };
374
- nub(-size, back);
375
- nub(shape.width, front);
719
+ for (let y = b.hip; y < b.shoulder; y++) {
720
+ const half = halfAt(y);
721
+ span(upper, -half, half, y, top.main);
722
+ upper(-half, y, top.light);
723
+ upper(half, y, top.shade);
724
+ }
725
+ // The shoulder line runs out over the tops of the arms.
726
+ const shoulderKey = look.style === "athlete" ? "s" : top.main;
727
+ span(upper, -(b.chest + 1), b.chest + 1, b.shoulder, shoulderKey);
728
+ span(upper, -(b.chest - 2), b.chest - 2, b.shoulder, top.main);
729
+ if (look.build === "heavy") {
730
+ // The belly's curve.
731
+ span(upper, -(b.waist - 1), b.waist - 1, b.hip + 1, top.shade);
732
+ }
733
+ // The neck, shadowed under the chin.
734
+ span(upper, -1, 1, b.shoulder + 1, "s");
735
+ upper(1, b.shoulder + 1, "q");
736
+ switch (look.style) {
737
+ case "desk": {
738
+ // A hoodie: the hood around the neck, drawstrings, a front pocket.
739
+ span(upper, -3, -2, b.shoulder + 1, top.shade);
740
+ span(upper, 2, 3, b.shoulder + 1, top.shade);
741
+ span(upper, -2, 2, b.shoulder, top.shade);
742
+ for (const x of [-1, 1]) {
743
+ upper(x, b.shoulder - 1, "w");
744
+ upper(x, b.shoulder - 2, "w");
745
+ }
746
+ span(upper, -3, 3, b.hip + 2, top.shade);
747
+ span(upper, -3, 3, b.hip + 1, top.main);
748
+ upper(-3, b.hip + 1, top.shade);
749
+ upper(3, b.hip + 1, top.shade);
750
+ span(upper, -(b.waist - 1), b.waist - 1, b.hip, top.shade);
751
+ break;
752
+ }
753
+ case "standing": {
754
+ // A collared shirt under an apron that hangs over the thighs.
755
+ span(upper, -2, 2, b.shoulder, "w");
756
+ upper(0, b.shoulder, "s");
757
+ for (const x of [-2, 2]) {
758
+ upper(x, b.shoulder - 1, "S");
759
+ upper(x, b.shoulder - 2, "S");
760
+ }
761
+ for (let y = b.hip - 3; y <= b.shoulder - 3; y++) {
762
+ span(upper, -b.waist, b.waist, y, "S");
763
+ upper(b.waist, y, "z");
764
+ upper(-b.waist, y, "T");
765
+ }
766
+ span(upper, -2, 2, b.hip + 1, "z");
767
+ span(upper, -1, 1, b.hip + 2, "S");
768
+ upper(-(b.waist + 1), b.hip + 2, "S");
769
+ upper(b.waist + 1, b.hip + 2, "S");
770
+ break;
771
+ }
772
+ case "active": {
773
+ // A crew-neck tee with a chest pocket.
774
+ span(upper, -1, 1, b.shoulder, top.shade);
775
+ upper(-2, b.shoulder - 2, top.shade);
776
+ upper(-3, b.shoulder - 2, top.shade);
777
+ break;
778
+ }
779
+ case "athlete": {
780
+ // A racerback tank with a stripe; abs show on a lean build.
781
+ span(upper, -1, 1, b.shoulder, "s");
782
+ span(upper, -b.chest, b.chest, b.shoulder - 3, top.shade);
783
+ if (look.build === "athletic") {
784
+ for (let y = b.hip + 2; y < b.shoulder - 4; y += 2) {
785
+ upper(-1, y, top.shade);
786
+ upper(1, y, top.shade);
787
+ }
788
+ }
789
+ break;
790
+ }
791
+ }
792
+ if (gear.wear === 2) {
793
+ // An open jacket over whatever is underneath.
794
+ for (let y = b.hip; y <= b.shoulder; y++) {
795
+ const half = y === b.shoulder ? b.chest + 1 : halfAt(y);
796
+ span(upper, -half, -half + 1, y, "M");
797
+ span(upper, half - 1, half, y, "X");
798
+ }
799
+ upper(-2, b.shoulder, "M");
800
+ upper(2, b.shoulder, "X");
801
+ }
802
+ if (gear.carry >= 1) {
803
+ // Backpack straps over both shoulders.
804
+ for (let y = b.shoulder - 4; y <= b.shoulder; y++) {
805
+ upper(-(b.chest - 1), y, "B");
806
+ upper(b.chest - 1, y, "B");
807
+ }
808
+ upper(-(b.chest - 1), b.shoulder - 3, "d");
809
+ upper(b.chest - 1, b.shoulder - 3, "d");
810
+ }
811
+ if (gear.wear >= 1) {
812
+ // A scarf around the neck, its end hanging down the chest.
813
+ span(upper, -2, 2, b.shoulder + 1, "S");
814
+ span(upper, -2, 2, b.shoulder, "z");
815
+ for (let y = b.shoulder - 3; y < b.shoulder; y++) {
816
+ upper(1, y, "S");
817
+ upper(2, y, "z");
818
+ }
819
+ }
820
+ if (gear.carry === 2) {
821
+ // A gold medal on its ribbon.
822
+ upper(-1, b.shoulder - 1, "z");
823
+ upper(1, b.shoulder - 1, "z");
824
+ upper(0, b.shoulder - 2, "z");
825
+ span(upper, -1, 0, b.shoulder - 3, "g");
826
+ span(upper, -1, 0, b.shoulder - 4, "g");
827
+ }
828
+ }
829
+ // An arm from the shoulder to the hand, sleeved by the work style.
830
+ function paintArm({ upper, b, look, gear }, side, hand, depth = "level") {
831
+ const isJacket = gear.wear === 2;
832
+ const tones = isJacket
833
+ ? { main: "X", shade: "X", light: "M" }
834
+ : topOf[look.style];
835
+ const sleeve = isJacket ? 1 : sleeveOf[look.style];
836
+ const joint = [side * (b.chest + 1), b.shoulder - 1];
837
+ const points = pointsOf(joint, hand);
838
+ const covered = Math.round((points.length - 1) * sleeve);
839
+ const isLevel = Math.abs(hand[0] - joint[0]) > Math.abs(hand[1] - joint[1]);
840
+ const [dx, dy] = isLevel ? [0, -1] : [side, 0];
841
+ const edgeTone = side < 0 ? tones.light : tones.shade;
842
+ for (const [step, [x, y]] of points.entries()) {
843
+ const isHand = step >= points.length - 2;
844
+ const isSleeve = !isHand && (step < covered || (step === 0 && sleeve > 0));
845
+ let width = Math.max(b.arm - 1, 2);
846
+ if (isHand) {
847
+ width = 2;
848
+ }
849
+ else if (step < points.length / 2) {
850
+ width = b.arm;
851
+ }
852
+ let key = isSleeve ? tones.main : "s";
853
+ let edge = isSleeve ? edgeTone : "q";
854
+ // In profile, the far arm sinks into shadow and the near one catches
855
+ // the light, so both read against the torso.
856
+ if (depth === "far") {
857
+ key = isSleeve ? tones.shade : "q";
858
+ edge = key;
859
+ }
860
+ else if (depth === "near" && isSleeve) {
861
+ key = tones.light;
862
+ }
863
+ for (let index = 0; index < width; index++) {
864
+ upper(x + dx * index, y + dy * index, index === width - 1 ? edge : key);
865
+ }
866
+ }
867
+ const wrist = points.at(-3);
868
+ if (side === 1 && depth !== "far" && gear.gadget === 2 && wrist) {
869
+ upper(wrist[0], wrist[1], "t");
870
+ upper(wrist[0] + dx, wrist[1] + dy, "e");
871
+ }
376
872
  }
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);
873
+ function paintHeadFront({ upper, b, look, gear }, pose) {
874
+ const nod = pose.nod ?? 0;
875
+ const head = (x, y, key) => {
876
+ upper(x, y - nod, key);
877
+ };
878
+ const { chin } = b;
879
+ const [hair, light] = hairOf[look.hair];
880
+ const turn = pose.turn ?? 0;
881
+ const jaw = look.build === "heavy" ? 3 : 2;
882
+ span(head, -jaw, jaw, chin, "s");
883
+ head(-jaw, chin, "q");
884
+ head(jaw, chin, "q");
885
+ for (let y = chin + 1; y <= chin + 4; y++) {
886
+ span(head, -3, 3, y, "s");
887
+ }
888
+ head(3, chin + 1, "q");
889
+ head(3, chin + 2, "q");
890
+ span(head, -3, 3, chin + 5, hair);
891
+ span(head, -2, 1, chin + 5, light);
892
+ span(head, -2, 2, chin + 6, hair);
893
+ head(-1, chin + 6, light);
894
+ // Eyes, brows, the shadow under the nose, and the mouth.
895
+ const eye = pose.eyes === "closed" ? "q" : "e";
896
+ head(turn - 2, chin + 3, eye);
897
+ head(turn + 2, chin + 3, eye);
898
+ head(turn - 2, chin + 4, hair);
899
+ head(turn + 2, chin + 4, hair);
900
+ head(turn, chin + 2, "q");
901
+ if (pose.mouth === "smile") {
902
+ span(head, turn - 1, turn + 1, chin + 1, "m");
903
+ head(turn - 2, chin + 2, "r");
904
+ head(turn + 2, chin + 2, "r");
905
+ }
906
+ else if (pose.mouth === "open") {
907
+ span(head, turn - 1, turn, chin + 1, "e");
908
+ }
909
+ else {
910
+ span(head, turn - 1, turn, chin + 1, "q");
382
911
  }
383
- else if (look.gender === "male") {
384
- put(1, top + 1, "O");
385
- put(2, top + 2, "O");
386
- put(3, top + 1, "O");
912
+ // Short hair with sideburns and ears, a side-swept fringe, or a bob.
913
+ if (look.gender === "male") {
914
+ head(-3, chin + 4, hair);
915
+ head(3, chin + 4, hair);
916
+ }
917
+ else if (look.gender === "female") {
918
+ span(head, -3, -1, chin + 4, hair);
919
+ head(3, chin + 4, hair);
920
+ head(-3, chin + 3, hair);
921
+ head(3, chin + 3, hair);
387
922
  }
388
923
  else {
389
- put(2, top + 1, "G");
390
- put(1, top + 2, "G");
391
- put(3, top + 3, "G");
924
+ span(head, -3, -2, chin + 4, hair);
925
+ head(3, chin + 4, hair);
926
+ }
927
+ if (look.gender === "male" && gear.gadget === 0) {
928
+ head(-4, chin + 2, "q");
929
+ head(-4, chin + 3, "s");
930
+ head(4, chin + 2, "q");
931
+ head(4, chin + 3, "q");
392
932
  }
393
933
  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);
934
+ // Headphones: a band over the top and a cup on each ear.
935
+ head(-3, chin + 6, "e");
936
+ head(3, chin + 6, "e");
937
+ span(head, -2, 2, chin + 7, "e");
938
+ for (const x of [-4, 4]) {
939
+ for (let y = chin + 2; y <= chin + 4; y++) {
940
+ head(x, y, y === chin + 3 ? "S" : "e");
941
+ }
942
+ }
397
943
  }
398
- if (gear.gadget === 2) {
399
- put(width - 3, top + 2, "k");
400
- put(width - 3, top + 3, counter % 2 === 0 ? "t" : "y");
944
+ if (gear.headwear === 1) {
945
+ // A cap with its brim over the forehead.
946
+ span(head, -2, 2, chin + 7, "S");
947
+ span(head, -3, 3, chin + 6, "S");
948
+ head(-2, chin + 6, "T");
949
+ span(head, -4 + turn, 4 + turn, chin + 5, "z");
950
+ }
951
+ else if (gear.headwear === 2) {
952
+ // Sunglasses with a glint.
953
+ span(head, -3, -1, chin + 3, "e");
954
+ span(head, 1, 3, chin + 3, "e");
955
+ head(0, chin + 3, "e");
956
+ head(-2, chin + 3, "w");
957
+ }
958
+ }
959
+ // Where each foot is on every frame of a stride, toward +x.
960
+ const gaits = {
961
+ walk: {
962
+ feet: [4, 3, 1, -1, -3, -4, -2, 2],
963
+ lift: [0, 0, 0, 0, 0, 1, 2, 1],
964
+ bob: [1, 0, 0, 0, 1, 0, 0, 0],
965
+ },
966
+ run: {
967
+ feet: [5, 2, -2, -5, -2, 3],
968
+ lift: [0, 0, 1, 3, 4, 2],
969
+ bob: [1, 0, 0, 1, 0, 0],
970
+ },
971
+ };
972
+ function paintSideLeg({ put, b, look }, gait, index, isFar) {
973
+ const bottom = bottomOf[look.style];
974
+ const footX = gait.feet[index] ?? 0;
975
+ const lift = gait.lift[index] ?? 0;
976
+ const sole = ground + lift;
977
+ const hip = [0, b.hip - 1];
978
+ const knee = [
979
+ Math.round(footX / 2) + 1 + Math.min(lift, 2),
980
+ Math.round((b.hip + sole) / 2) + Math.floor(lift / 2),
981
+ ];
982
+ const ankle = [footX, sole + bottom.shoeRows];
983
+ const pants = isFar ? bottom.shade : bottom.main;
984
+ const skin = isFar ? "q" : "s";
985
+ const shorts = ground + Math.round(b.legs * 0.55);
986
+ for (const [from, to, width] of [
987
+ [hip, knee, b.thigh - 1],
988
+ [knee, ankle, b.calf - 1],
989
+ ]) {
990
+ for (const [x, y] of pointsOf(from, to)) {
991
+ const key = look.style === "athlete" && y < shorts ? skin : pants;
992
+ span(put, x - Math.floor(width / 2), x + Math.ceil(width / 2), y, key);
993
+ }
994
+ }
995
+ // The shoe points the way it walks, toe first.
996
+ for (let row = 0; row < bottom.shoeRows; row++) {
997
+ const key = row === 0 ? bottom.sole : bottom.shoe;
998
+ span(put, footX - 1, footX + (row === 0 ? 3 : 2), sole + row, key);
999
+ }
1000
+ }
1001
+ function paintSideHead({ upper, b, look, gear }, pose) {
1002
+ const { chin } = b;
1003
+ const [hair, light] = hairOf[look.hair];
1004
+ // The neck and the head in profile, nose forward and hair behind.
1005
+ span(upper, -1, 1, b.shoulder + 1, "s");
1006
+ upper(-1, b.shoulder + 1, "q");
1007
+ span(upper, -1, 2, chin, "s");
1008
+ upper(-1, chin, "q");
1009
+ span(upper, -2, 3, chin + 1, "s");
1010
+ for (let y = chin + 2; y <= chin + 4; y++) {
1011
+ span(upper, -3, 3, y, "s");
1012
+ }
1013
+ upper(4, chin + 2, "s");
1014
+ span(upper, -3, 3, chin + 5, hair);
1015
+ span(upper, -2, 2, chin + 6, hair);
1016
+ upper(0, chin + 5, light);
1017
+ span(upper, -3, -1, chin + 4, hair);
1018
+ span(upper, -3, -2, chin + 3, hair);
1019
+ upper(-3, chin + 2, hair);
1020
+ upper(-1, chin + 3, "q");
1021
+ upper(2, chin + 3, "e");
1022
+ upper(2, chin + 4, hair);
1023
+ upper(3, chin + 1, pose.mouth === "smile" ? "m" : "q");
1024
+ if (look.gender === "female" && look.style !== "athlete") {
1025
+ for (let y = b.shoulder - 3; y <= chin + 3; y++) {
1026
+ span(upper, -4, -3, y, hair);
1027
+ }
1028
+ }
1029
+ else if (look.gender === "female") {
1030
+ const sway = Math.floor(pose.frame / 2) % 2;
1031
+ upper(-4, chin + 4, hair);
1032
+ upper(-5, chin + 3 - sway, hair);
1033
+ upper(-5, chin + 2 - sway, light);
1034
+ }
1035
+ else if (look.gender === "other") {
1036
+ for (let y = chin + 1; y <= chin + 3; y++) {
1037
+ upper(-3, y, hair);
1038
+ upper(-4, y, hair);
1039
+ }
1040
+ }
1041
+ if (gear.gadget >= 1) {
1042
+ span(upper, -1, 1, chin + 7, "e");
1043
+ for (let y = chin + 2; y <= chin + 4; y++) {
1044
+ upper(-1, y, y === chin + 3 ? "S" : "e");
1045
+ }
401
1046
  }
402
1047
  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");
1048
+ span(upper, -3, 2, chin + 6, "S");
1049
+ span(upper, -2, 1, chin + 7, "S");
1050
+ span(upper, 2, 5, chin + 5, "z");
406
1051
  }
407
1052
  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");
1053
+ span(upper, 1, 3, chin + 3, "e");
1054
+ upper(0, chin + 4, "e");
1055
+ }
1056
+ }
1057
+ // The body in profile, facing +x, on frame `frame` of the gait.
1058
+ function paintSide(paint, pose) {
1059
+ const { put, b, look, gear } = paint;
1060
+ const gait = gaits[pose.gait];
1061
+ const count = gait.feet.length;
1062
+ const frame = pose.frame % count;
1063
+ const other = (frame + count / 2) % count;
1064
+ const bob = gait.bob[frame] ?? 0;
1065
+ const upper = (x, y, key) => {
1066
+ put(x, y - bob, key);
1067
+ };
1068
+ const sideways = { ...paint, upper };
1069
+ const top = topOf[look.style];
1070
+ const back = Math.max(b.chest - 2, 2);
1071
+ const front = b.chest - 1;
1072
+ // Arms swing against the legs; running bends them at the elbow.
1073
+ const arm = (index, isFar) => {
1074
+ const swing = -(gait.feet[index] ?? 0);
1075
+ const hand = pose.gait === "run"
1076
+ ? [Math.round(swing * 0.7) + 2, b.shoulder - Math.round(b.reach / 2)]
1077
+ : [Math.round(swing * 0.75), b.shoulder - b.reach + 1];
1078
+ paintArm({ ...sideways, b: { ...b, chest: -1 } }, 1, hand, isFar ? "far" : "near");
1079
+ };
1080
+ // The far arm and leg first, in shadow.
1081
+ arm(other, true);
1082
+ paintSideLeg(paint, gait, other, true);
1083
+ paintSideLeg(paint, gait, frame, false);
1084
+ // The torso, deeper at the chest, with a belly or hips where they are.
1085
+ const torso = b.shoulder - b.hip + 1;
1086
+ const chestRows = Math.ceil(torso * 0.45);
1087
+ for (let y = b.hip; y <= b.shoulder; y++) {
1088
+ const isChest = b.shoulder - y < chestRows;
1089
+ let forward = front;
1090
+ let behind = back;
1091
+ if (!isChest && look.build === "heavy") {
1092
+ forward += 2;
1093
+ }
1094
+ else if (!isChest && look.build === "stocky") {
1095
+ forward += 1;
1096
+ }
1097
+ if (y - b.hip < 2 && look.gender === "female") {
1098
+ behind += 1;
1099
+ }
1100
+ span(upper, -behind, forward, y, top.main);
1101
+ upper(-behind, y, top.shade);
1102
+ upper(forward, y, top.light);
1103
+ }
1104
+ if (look.style === "standing") {
1105
+ for (let y = b.hip - 3; y <= b.shoulder - 2; y++) {
1106
+ upper(front, y, "S");
1107
+ upper(front - 1, y, "S");
1108
+ }
1109
+ }
1110
+ else if (look.style === "desk") {
1111
+ span(upper, -2, -1, b.shoulder + 1, top.shade);
1112
+ span(upper, 0, front, b.hip + 2, top.shade);
413
1113
  }
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");
1114
+ if (gear.wear === 2) {
1115
+ for (let y = b.hip; y <= b.shoulder; y++) {
1116
+ span(upper, -back, front - 2, y, "X");
1117
+ }
1118
+ }
1119
+ if (gear.carry >= 1) {
1120
+ // The backpack rides behind.
1121
+ for (let y = b.shoulder - 6; y <= b.shoulder - 1; y++) {
1122
+ span(upper, -back - 3, -back - 1, y, y === b.shoulder - 1 ? "b" : "B");
1123
+ }
1124
+ upper(-back - 2, b.shoulder - 3, "d");
418
1125
  }
1126
+ if (gear.carry === 2) {
1127
+ upper(front, b.shoulder - 3, "g");
1128
+ upper(front - 1, b.shoulder - 1, "z");
1129
+ }
1130
+ paintSideHead(sideways, pose);
1131
+ if (gear.wear >= 1) {
1132
+ span(upper, -2, 2, b.shoulder + 1, "S");
1133
+ const flutter = Math.floor(pose.frame / 2) % 2;
1134
+ upper(-3, b.shoulder + 1 - flutter, "z");
1135
+ upper(-4, b.shoulder - flutter, "z");
1136
+ }
1137
+ // The near arm swings in front of the body.
1138
+ arm(frame, false);
419
1139
  }
1140
+ function paintFront(paint, pose) {
1141
+ const { b } = paint;
1142
+ paintHairBehind(paint, pose.nod ?? 0);
1143
+ paintLegsFront(paint, pose);
1144
+ paintTorsoFront(paint);
1145
+ for (const [index, side] of [-1, 1].entries()) {
1146
+ paintArm(paint, side, pose.hands?.[index] ?? hanging(b, side));
1147
+ }
1148
+ paintHeadFront(paint, pose);
1149
+ }
1150
+ const mix = (from, to, amount) => {
1151
+ const parse = (hex) => [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16));
1152
+ const [start, end] = [parse(from), parse(to)];
1153
+ return `#${start
1154
+ .map((value, index) => Math.round(value + ((end[index] ?? value) - value) * amount)
1155
+ .toString(16)
1156
+ .padStart(2, "0"))
1157
+ .join("")}`;
1158
+ };
420
1159
  export function paletteOf(palette) {
421
- const { cloak, cloakShade, glow } = palette.sprite;
1160
+ const { cloak, cloakShade, scarf, scarfShade, glow } = palette.sprite;
422
1161
  return new Map([
423
- ["c", "#5A3A25"],
1162
+ ["A", "#141414"],
1163
+ ["B", "#3E3226"],
1164
+ ["b", "#6B4A2E"],
1165
+ ["C", cloak],
1166
+ ["c", cloakShade],
424
1167
  ["D", "#50555F"],
425
- ["d", "#8A8F99"],
426
- ["e", "#1E1E1E"],
1168
+ ["d", "#9AA0AA"],
1169
+ ["E", mix(cloak, "#FFFFFF", 0.16)],
1170
+ ["e", "#2B2B2B"],
427
1171
  ["F", "#C8473F"],
428
1172
  ["G", "#6FA35A"],
429
1173
  ["g", "#D9B45A"],
430
- ["h", palette.soft],
431
- ["k", "#2B2B2B"],
1174
+ ["H", "#9C9C9C"],
1175
+ ["h", "#4A3222"],
1176
+ ["I", "#E4E4E4"],
1177
+ ["i", "#6B4A33"],
1178
+ ["J", "#BDBDBD"],
1179
+ ["K", "#C4C4C4"],
432
1180
  ["L", "#4A505B"],
433
1181
  ["l", glow],
434
- ["O", palette.accent],
435
- ["o", palette.deep],
1182
+ ["M", "#424A58"],
1183
+ ["m", "#B5524A"],
1184
+ ["N", "#B59A6A"],
1185
+ ["n", "#937B52"],
1186
+ ["O", "#5E6B45"],
1187
+ ["o", "#5A3A25"],
1188
+ ["P", "#3E6870"],
1189
+ ["p", "#33565D"],
436
1190
  ["Q", "#E6E0D4"],
437
- ["r", "#E87A90"],
438
- ["S", cloak],
439
- ["s", cloakShade],
440
- ["T", "#E5533D"],
1191
+ ["q", "#C99872"],
1192
+ ["R", "#4F8189"],
1193
+ ["r", "#E8909A"],
1194
+ ["S", scarf],
1195
+ ["s", "#E2B48C"],
1196
+ ["T", mix(scarf, "#FFFFFF", 0.22)],
441
1197
  ["t", glow],
1198
+ ["U", "#B4AE9F"],
1199
+ ["V", "#4A5536"],
442
1200
  ["v", "#9AA0A8"],
443
1201
  ["w", "#F2F2F2"],
1202
+ ["X", "#2F3540"],
444
1203
  ["x", "#3D414B"],
1204
+ ["Y", "#C8C8C8"],
445
1205
  ["y", palette.soft],
446
- ["z", "#C8C8C8"],
1206
+ ["Z", "#C8C8C8"],
1207
+ ["z", scarfShade],
447
1208
  ]);
448
1209
  }
449
1210
  // The pixel keys of 1 frame, row by row, "." where nothing is drawn.
450
- export function composeScene({ activity, counter, level, look, mood, badges, width, }) {
1211
+ export function composeScene({ activity, counter, level, look, mood, width, anchor = homeOf(width), }) {
451
1212
  const canvas = Array.from({ length: stageHeight }, () => Array.from({ length: width }, () => "."));
452
- const plain = (column, y, key) => {
453
- const row = canvas[stageHeight - 1 - y];
1213
+ const scene = sceneOf(activity, counter, width, mood, anchor);
1214
+ const b = bodyOf(look);
1215
+ const pose = scene.pose(b);
1216
+ // The character's x = 0 is its center column, mirrored when facing left.
1217
+ const plant = (rise) => (x, y, key) => {
1218
+ const column = scene.x + scene.facing * x;
1219
+ const row = canvas[stageHeight - 1 - (y + rise)];
454
1220
  if (row && column >= 0 && column < width && key !== ".") {
455
1221
  row[column] = key;
456
1222
  }
457
1223
  };
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
- };
1224
+ const put = plant(scene.lift);
1225
+ const bob = pose.view === "front" ? (pose.bob ?? 0) : 0;
467
1226
  const paint = {
468
1227
  put,
469
- shape,
1228
+ upper: plant(scene.lift - bob),
1229
+ b,
470
1230
  look,
471
1231
  gear: gearOf(level),
472
- pose: scene.pose,
473
1232
  counter,
474
1233
  };
475
- paintBehind(paint);
476
- paintLegs(paint);
477
- paintBody(paint);
478
- paintFace(paint);
479
- paintArms(paint);
480
- paintHead(paint);
1234
+ paintShadow(plant(0), b, scene.lift);
1235
+ if (pose.view === "side") {
1236
+ paintSide(paint, pose);
1237
+ }
1238
+ else {
1239
+ paintFront(paint, pose);
1240
+ }
481
1241
  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);
1242
+ draw(put, paint.upper, b);
492
1243
  }
493
1244
  return canvas.map((row) => row.join(""));
494
1245
  }