@siegesailor/terminal-sigma 1.5.0 → 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,30 +1,50 @@
1
- // The character stands on a stage as wide as its panel and 32 pixels tall.
1
+ // The character stands on a stage as wide as its panel and 40 pixels tall.
2
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;
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;
5
7
  export const defaultLook = {
6
- height: 23,
8
+ height: 29,
7
9
  build: "average",
8
10
  gender: "other",
9
11
  style: "desk",
10
12
  hair: "dark",
13
+ isMuscular: false,
11
14
  };
12
- // 145 cm draws 20 pixels tall and 205 cm 26; the build follows the BMI.
13
- export function lookOf(profile) {
14
- if (!profile) {
15
- return defaultLook;
16
- }
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) {
17
23
  const bmi = profile.weight / (profile.height / 100) ** 2;
18
- let build = "average";
19
- if (bmi < 19.5) {
20
- build = "slim";
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";
21
32
  }
22
- else if (bmi >= 30) {
23
- build = "heavy";
33
+ const [lean, soft, heavy] = fatBands[profile.gender];
34
+ if (profile.bodyFat < lean) {
35
+ return bmi >= 23 ? "athletic" : "slim";
24
36
  }
25
- else if (bmi >= 25) {
26
- build = "stocky";
37
+ if (profile.bodyFat >= heavy) {
38
+ return "heavy";
27
39
  }
40
+ return profile.bodyFat >= soft ? "stocky" : "average";
41
+ }
42
+ // 145 cm draws 26 pixels tall and 205 cm 34.
43
+ export function lookOf(profile) {
44
+ if (!profile) {
45
+ return defaultLook;
46
+ }
47
+ const build = buildOf(profile);
28
48
  let hair = "dark";
29
49
  if (profile.age >= 70) {
30
50
  hair = "white";
@@ -33,12 +53,12 @@ export function lookOf(profile) {
33
53
  hair = "grey";
34
54
  }
35
55
  return {
36
- height: Math.min(Math.max(Math.round(20 + (profile.height - 145) / 10), 19), 27),
56
+ height: Math.min(Math.max(Math.round(26 + (profile.height - 145) / 7.5), 24), 35),
37
57
  build,
38
58
  gender: profile.gender,
39
59
  style: profile.workStyle,
40
60
  hair,
41
- goal: profile.goal,
61
+ isMuscular: build === "athletic" || profile.goal === "buildMuscle",
42
62
  };
43
63
  }
44
64
  // Each equipment slot upgrades once from level 2 and again 4 levels later,
@@ -47,43 +67,55 @@ export const gearOf = (level) => {
47
67
  const tier = (slot) => Math.min(Math.max(Math.floor((level - 2 - slot) / 4) + 1, 0), 2);
48
68
  return { gadget: tier(0), wear: tier(1), headwear: tier(2), carry: tier(3) };
49
69
  };
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],
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],
56
76
  };
57
- // A head of 5 rows and a neck take 6 of the height; legs take 56% of the rest.
77
+ // A 7-row head and a neck take 8 of the height; legs take 55% of the rest.
58
78
  function bodyOf(look) {
59
- const span = look.height - 6;
60
- const legs = Math.round(span * 0.56);
79
+ const span = look.height - 8;
80
+ const legs = Math.round(span * 0.55);
61
81
  const torso = span - legs;
62
- let [chest, waist, hips] = halfWidths[look.gender];
82
+ let [chest, waist, hips] = frames[look.gender];
63
83
  switch (look.build) {
64
84
  case "slim": {
65
85
  chest -= look.gender === "male" ? 1 : 0;
66
86
  waist = Math.max(waist - 1, 2);
67
- hips = Math.max(hips - 1, 2);
87
+ hips = Math.max(hips - 1, 3);
88
+ break;
89
+ }
90
+ case "athletic": {
91
+ chest += 1;
68
92
  break;
69
93
  }
70
94
  case "stocky": {
71
95
  waist += 1;
96
+ hips += 1;
72
97
  break;
73
98
  }
74
99
  case "heavy": {
75
100
  chest += 1;
76
- waist += 2;
77
- hips += 1;
101
+ waist += 3;
102
+ hips += 2;
78
103
  break;
79
104
  }
80
105
  case "average": {
81
106
  break;
82
107
  }
83
108
  }
84
- if (look.goal === "buildMuscle") {
109
+ if (look.isMuscular && look.build !== "athletic") {
85
110
  chest += 1;
86
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
+ }
87
119
  const hip = ground + legs;
88
120
  const shoulder = hip + torso - 1;
89
121
  const chin = shoulder + 2;
@@ -91,17 +123,22 @@ function bodyOf(look) {
91
123
  hip,
92
124
  shoulder,
93
125
  chin,
94
- top: chin + 4,
126
+ top: chin + 6,
95
127
  legs,
128
+ knee: ground + Math.floor(legs / 2),
129
+ shin: ground + Math.floor(legs / 4),
96
130
  chest,
97
131
  waist,
98
132
  hips,
99
- arm: look.build === "heavy" || look.goal === "buildMuscle" ? 2 : 1,
100
- reach: torso + 1,
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,
101
138
  };
102
139
  }
103
140
  const span = (put, from, to, y, key) => {
104
- for (let x = from; x <= to; x++) {
141
+ for (let x = Math.min(from, to); x <= Math.max(from, to); x++) {
105
142
  put(x, y, key);
106
143
  }
107
144
  };
@@ -116,431 +153,561 @@ const pointsOf = ([x0, y0], [x1, y1]) => {
116
153
  }
117
154
  return points;
118
155
  };
156
+ // Where a hand rests with the arm hanging, nudged by `swing`.
119
157
  const hanging = (b, side, swing = 0) => [
120
158
  side * (b.chest + 1),
121
- b.shoulder - b.reach + swing,
159
+ b.shoulder - b.reach + 1 + swing,
122
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 };
208
+ }
209
+ remaining -= Math.abs(to - from);
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];
216
+ };
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);
233
+ }
234
+ return cycles[activity];
235
+ }
236
+ const smileOf = (mood) => (mood === "happy" ? "smile" : undefined);
123
237
  // A standing desk at the waist, with the laptop's lid facing out.
124
238
  const desk = (put, _upper, b) => {
125
239
  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");
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");
130
245
  }
131
- for (let y = top + 1; y <= top + 3; y++) {
132
- span(put, -2, 2, y, "d");
246
+ for (let y = top + 1; y <= top + 4; y++) {
247
+ span(put, -3, 3, y, "d");
133
248
  }
134
- put(0, top + 2, "l");
249
+ span(put, -3, 3, top + 1, "D");
250
+ put(0, top + 3, "l");
135
251
  };
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");
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");
140
258
  if (steam !== undefined) {
141
- upper(x + (steam % 2), y + 3, "v");
142
- upper(x + 1 - (steam % 2), y + 4, "v");
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");
143
263
  }
144
264
  };
145
265
  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");
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");
150
273
  };
151
274
  const barbell = (y) => (_put, upper, b) => {
152
- const end = b.chest + 6;
275
+ const end = b.chest + 7;
153
276
  span(upper, -end, end, y, "d");
154
277
  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");
278
+ for (let dy = -2; dy <= 2; dy++) {
279
+ upper(x, y + dy, Math.abs(x) === end ? "D" : "d");
280
+ }
158
281
  }
159
282
  };
160
- const sparkles = (points) => (put) => {
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
+ ];
161
299
  for (const [x, y] of points) {
162
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");
163
305
  }
164
306
  };
165
- const sleepyZ = (at) => (put, _upper, b) => {
307
+ const sleepyZ = (t) => (put, _upper, b) => {
308
+ const rise = Math.floor((t % 24) / 3);
166
309
  const rows = ["ZZZZ", "..Z.", ".Z..", "ZZZZ"];
167
310
  for (const [offset, row] of rows.entries()) {
168
311
  for (const [index, key] of [...row].entries()) {
169
- put(3 + (at % 2) + index, b.top + 4 + at - offset, key);
312
+ put(4 + (rise % 2) + index, b.top + 1 + rise - offset, key);
170
313
  }
171
314
  }
172
315
  };
173
- // Standing in place between walks: breathing, blinking, looking about, and
174
- // waving when every target is met.
316
+ // Standing still between walks: breathing, blinking, looking about, shaking
317
+ // out the hands, and waving when every target is met.
175
318
  const resting = (t, mood) => (b) => {
176
- const isWaving = mood === "happy" && t >= 3 && t <= 5;
177
319
  let turn = 0;
178
- if (t === 2 || t === 3) {
320
+ if (t >= 8 && t < 12) {
179
321
  turn = -1;
180
322
  }
181
- else if (t === 4) {
323
+ else if (t >= 12 && t < 16) {
182
324
  turn = 1;
183
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
+ }
184
339
  return {
185
- mouth: mood === "happy" ? "smile" : undefined,
186
- bob: Math.floor(t / 2) % 2,
187
- eyes: t === 6 ? "closed" : "open",
340
+ view: "front",
341
+ mouth: smileOf(mood),
342
+ bob: breathing(t),
343
+ eyes: t === 5 || t === 6 ? "closed" : "open",
188
344
  turn,
189
- lift: mood === "happy" && t === 1 ? 1 : 0,
190
- hands: isWaving
191
- ? [undefined, [b.chest + 3 + (t % 2), b.top - 1]]
192
- : undefined,
345
+ hands,
193
346
  };
194
347
  };
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);
210
- const travel = maximum - minimum;
211
- const tick = counter % 4;
212
- const mouth = mood === "happy" ? "smile" : undefined;
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
+ });
213
361
  if (activity === "idle" && mood === "sleepy") {
214
- // Dozing on its feet: nodding, yawning, with Zs drifting up.
215
- const at = counter % 6;
216
- const isYawning = at === 4;
217
- return {
218
- x: home,
219
- facing: 1,
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)],
227
- };
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)]);
228
372
  }
229
- if (activity === "idle") {
230
- // Wander: pause, walk across, pause and look about, walk back.
231
- const pause = 8;
232
- const at = counter % (2 * (pause + travel));
233
- if (at < pause) {
234
- return { x: minimum, facing: 1, pose: resting(at, mood) };
235
- }
236
- if (at < pause + travel) {
237
- const step = at - pause;
238
- return { x: minimum + step, facing: 1, pose: walking(step, mood) };
239
- }
240
- if (at < 2 * 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
+ }
241
389
  return {
242
- x: maximum,
243
- facing: -1,
244
- pose: resting(at - pause - travel, mood),
390
+ x: place.x,
391
+ facing: 1,
392
+ lift: mood === "happy" && place.resting === 1 ? 1 : 0,
393
+ pose: resting(place.resting, mood),
245
394
  };
246
395
  }
247
- const step = at - 2 * pause - travel;
248
- return { x: maximum - step, facing: -1, pose: walking(step, mood) };
249
- }
250
- switch (activity) {
251
396
  case "run": {
252
- // Laps across the stage, 2 pixels a tick, with the arms bent.
253
- const lap = Math.max(Math.ceil(travel / 2), 1);
254
- const at = counter % (2 * lap);
255
- const forward = at < lap;
256
- const step = forward ? at : at - lap;
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,
401
+ };
257
402
  return {
258
- x: forward
259
- ? Math.min(minimum + step * 2, maximum)
260
- : Math.max(maximum - step * 2, minimum),
261
- facing: forward ? 1 : -1,
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
- }),
403
+ x: place.x,
404
+ facing: place.facing,
405
+ lift: 0,
406
+ pose: () => ({ view: "side", frame: at, gait: "run", mouth }),
272
407
  props: [
273
408
  (put, _upper, b) => {
274
- put(-(b.chest + 4), b.top - 1 - (counter % 2), "v");
275
- put(-(b.chest + 6), b.hip + (counter % 3), "v");
409
+ put(-(b.chest + 3), b.top - 1 - (at % 2), "v");
410
+ put(-(b.chest + 5), b.hip + (at % 3), "v");
276
411
  },
277
412
  ],
278
413
  };
279
414
  }
280
415
  case "focus": {
281
- // Typing at a standing desk, the laptop's glow on the face.
282
- return {
283
- x: home,
284
- facing: 1,
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
- }),
293
- props: [
294
- desk,
295
- (put, _upper, b) => {
296
- put(-3 + tick, b.top + 2 + (tick % 2), "y");
297
- },
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],
298
425
  ],
299
- };
426
+ }), [
427
+ desk,
428
+ (put, _upper, b) => {
429
+ put(-2 + (at % 5), b.top + 2 + key, "y");
430
+ },
431
+ ]);
300
432
  }
301
433
  case "break": {
302
- // A coffee, sipped every few ticks.
303
- const at = counter % 6;
304
- const isSipping = at >= 4;
434
+ // A coffee, sipped every few seconds.
435
+ const isSipping = at >= 28;
305
436
  const hand = (b) => isSipping ? [2, b.chin] : [b.chest + 2, b.hip + 2];
306
- return {
307
- x: home,
308
- facing: 1,
309
- pose: (b) => ({
310
- mouth,
311
- bob: at % 2,
312
- eyes: isSipping ? "closed" : "open",
313
- hands: [undefined, hand(b)],
314
- }),
315
- props: [
316
- (put, upper, b) => {
317
- cup(hand(b), isSipping ? undefined : at)(put, upper, b);
318
- },
319
- ],
320
- };
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);
447
+ },
448
+ ]);
321
449
  }
322
450
  case "eat": {
323
- const isBiting = tick === 1 || tick === 2;
324
- const hand = (b) => isBiting ? [2, b.chin] : [b.chest + 2, b.hip + 3];
325
- return {
326
- x: home,
327
- facing: 1,
328
- pose: (b) => ({
329
- bob: tick === 2 ? 1 : 0,
330
- mouth: isBiting ? "open" : mouth,
331
- hands: [undefined, hand(b)],
332
- }),
333
- props: [
334
- (put, upper, b) => {
335
- const [x, y] = hand(b);
336
- apple([x, y - 1], tick === 3)(put, upper, b);
337
- },
338
- ],
339
- };
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);
464
+ },
465
+ ]);
340
466
  }
341
467
  case "lift": {
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;
345
- return {
346
- x: home,
347
- facing: 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
- };
359
- },
360
- props: [
361
- (put, upper, b) => {
362
- barbell(height(b))(put, upper, b);
363
- },
364
- ],
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);
365
482
  };
366
- }
367
- case "stretch": {
368
- // Reach up, rise on the toes, open the arms wide, and come down.
369
- return {
370
- x: home,
371
- facing: 1,
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
- };
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);
390
499
  },
391
- };
500
+ ]);
392
501
  }
393
- case "levelUp": {
394
- const lift = [0, 2, 3, 1][tick] ?? 0;
395
- return {
396
- x: home,
397
- facing: 1,
398
- pose: (b) => ({
399
- mouth: "smile",
400
- lift,
502
+ case "stretch": {
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",
401
518
  hands: [
402
- [-(b.chest + 3), b.top],
403
- [b.chest + 3, b.top],
519
+ [-x, phase === 3 ? y + 1 - (at % 2) : y],
520
+ [x, y],
404
521
  ],
405
- }),
406
- props: [
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
- },
522
+ };
523
+ }, undefined, phase === 1 ? 1 : 0);
524
+ }
525
+ case "levelUp": {
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],
422
535
  ],
423
- };
536
+ }), [sparkles(at)], lift);
424
537
  }
425
538
  }
426
539
  }
427
- export const intervalOf = (activity) => ({
428
- idle: 220,
429
- focus: 180,
430
- break: 380,
431
- eat: 300,
432
- run: 110,
433
- lift: 420,
434
- stretch: 480,
435
- levelUp: 160,
436
- })[activity];
437
- // How many ticks one loop of an activity takes on a stage of `width`.
438
- export function cycleOf(activity, width) {
439
- const travel = Math.max(width - 17, 1);
440
- if (activity === "idle") {
441
- return 2 * (8 + travel);
442
- }
443
- if (activity === "run") {
444
- 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
+ }
445
557
  }
446
- return activity === "break" ? 6 : 4;
558
+ return { ...motion, tick: motion.tick + 1 };
447
559
  }
448
560
  // The tops by work style: a hoodie, a shirt under an apron, a tee, a tank.
449
561
  const topOf = {
450
- desk: ["C", "c"],
451
- standing: ["Q", "U"],
452
- active: ["C", "c"],
453
- athlete: ["S", "z"],
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" },
454
566
  };
455
567
  // How much of the arm the sleeve covers, from the shoulder down.
456
568
  const sleeveOf = {
457
569
  desk: 1,
458
570
  standing: 0.5,
459
- active: 0.3,
571
+ active: 0.35,
460
572
  athlete: 0,
461
573
  };
462
574
  // Jeans and sneakers, chinos and shoes, cargo pants and boots, or shorts
463
575
  // and running shoes.
464
576
  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 },
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"],
469
614
  };
470
- const hairKeys = { dark: "h", grey: "H", white: "I" };
471
615
  // An oval on the ground with the feet in its middle, shrinking in the air.
472
616
  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");
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");
475
620
  span(put, -half, half, ground, "A");
476
621
  span(put, -half + 2, half - 2, ground + 1, "A");
477
622
  }
478
623
  // 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];
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);
633
+ }
634
+ return;
635
+ }
481
636
  if (look.gender !== "female") {
482
637
  return;
483
638
  }
484
639
  if (look.style === "athlete") {
485
640
  // 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);
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);
491
646
  return;
492
647
  }
493
- for (let y = b.shoulder - 2; y <= b.chin + 3; y++) {
494
- upper(-3, y, hair);
495
- upper(3, y, hair);
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);
496
651
  }
497
- for (let y = b.shoulder - 1; y < b.chin; y++) {
498
- span(upper, -2, 2, y, hair);
652
+ for (let y = b.shoulder - 2; y < b.chin; y++) {
653
+ span(upper, -3, 3, y, hair);
499
654
  }
500
655
  }
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);
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;
661
+ }
662
+ if (look.style === "athlete" && at.y < shorts) {
663
+ return at.y === b.knee - 1 && at.x === 2 ? "q" : "s";
664
+ }
665
+ if (at.x === at.width) {
666
+ return side < 0 ? bottom.light : bottom.shade;
667
+ }
668
+ return at.y === at.sole + bottom.shoeRows ? bottom.shade : bottom.main;
669
+ }
670
+ function paintLegsFront({ put, b, look }, pose) {
671
+ const bottom = bottomOf[look.style];
506
672
  const shorts = ground + Math.round(b.legs * 0.55);
507
673
  for (const side of [-1, 1]) {
508
674
  const isLifted = (side === -1 && pose.step === 1) || (side === 1 && pose.step === 3);
509
675
  const sole = ground + (isLifted ? 1 : 0);
510
676
  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;
677
+ let width = b.ankle;
678
+ if (y < sole + bottom.shoeRows) {
679
+ width = b.ankle + 1;
515
680
  }
516
- else if (look.style === "athlete" && y < shorts) {
517
- key = "s";
681
+ else if (y >= b.knee) {
682
+ width = b.thigh;
518
683
  }
519
- for (let x = 1; x <= width; x++) {
520
- put(side * x, y, key);
684
+ else if (y >= b.shin) {
685
+ width = b.calf;
521
686
  }
522
- if (key === pants) {
523
- put(side * width, y, shade);
687
+ for (let x = 1; x <= width; x++) {
688
+ put(side * x, y, legKeyOf(look, b, { x, y, sole, width }, side));
524
689
  }
525
690
  }
526
- // Shoes stick out a little past the ankle.
527
- put(side * (calf + 1), sole, shoe);
528
691
  if (look.style === "active") {
529
- put(side * thigh, knee + 1, "V");
530
- put(side * thigh, knee + 2, "V");
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");
695
+ }
696
+ if (look.style === "athlete") {
697
+ put(side * b.thigh, shorts + 1, "S");
531
698
  }
532
699
  }
533
700
  // The thighs meet at the crotch, and all the way down on a heavy build.
534
- let crotch = b.hip - 1;
701
+ let crotch = b.hip - 2;
535
702
  if (look.build === "heavy") {
536
- crotch = look.style === "athlete" ? shorts : knee;
703
+ crotch = look.style === "athlete" ? shorts : b.knee;
537
704
  }
538
705
  for (let y = crotch; y < b.hip; y++) {
539
- put(0, y, pants);
706
+ put(0, y, bottom.main);
540
707
  }
541
708
  }
542
- function paintTorso({ upper, b, look, gear }) {
543
- const [main, shade] = topOf[look.style];
709
+ function paintTorsoFront({ upper, b, look, gear }) {
710
+ const top = topOf[look.style];
544
711
  const torso = b.shoulder - b.hip + 1;
545
712
  const chestRows = Math.ceil(torso * 0.45);
546
713
  const halfAt = (y) => {
@@ -549,175 +716,457 @@ function paintTorso({ upper, b, look, gear }) {
549
716
  }
550
717
  return y - b.hip < 2 ? Math.max(b.hips, b.waist) : b.waist;
551
718
  };
552
- for (let y = b.hip; y <= b.shoulder; y++) {
719
+ for (let y = b.hip; y < b.shoulder; y++) {
553
720
  const half = halfAt(y);
554
- span(upper, -half, half, y, main);
555
- upper(-half, y, shade);
556
- upper(half, y, shade);
721
+ span(upper, -half, half, y, top.main);
722
+ upper(-half, y, top.light);
723
+ upper(half, y, top.shade);
557
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.
558
734
  span(upper, -1, 1, b.shoulder + 1, "s");
735
+ upper(1, b.shoulder + 1, "q");
559
736
  switch (look.style) {
560
737
  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);
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);
567
751
  break;
568
752
  }
569
753
  case "standing": {
570
754
  // A collared shirt under an apron that hangs over the thighs.
571
- upper(-1, b.shoulder, "w");
572
- upper(1, b.shoulder, "w");
755
+ span(upper, -2, 2, b.shoulder, "w");
573
756
  upper(0, b.shoulder, "s");
574
757
  for (const x of [-2, 2]) {
575
- upper(x, b.shoulder, "S");
576
758
  upper(x, b.shoulder - 1, "S");
759
+ upper(x, b.shoulder - 2, "S");
577
760
  }
578
- for (let y = b.hip - 2; y <= b.shoulder - 2; y++) {
761
+ for (let y = b.hip - 3; y <= b.shoulder - 3; y++) {
579
762
  span(upper, -b.waist, b.waist, y, "S");
763
+ upper(b.waist, y, "z");
764
+ upper(-b.waist, y, "T");
580
765
  }
581
- span(upper, -1, 1, b.hip + 1, "z");
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");
582
770
  break;
583
771
  }
584
772
  case "active": {
585
- upper(0, b.shoulder, shade);
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);
586
777
  break;
587
778
  }
588
779
  case "athlete": {
589
- upper(0, b.shoulder, "s");
590
- span(upper, -b.chest, b.chest, b.shoulder - 2, shade);
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
+ }
591
789
  break;
592
790
  }
593
791
  }
594
792
  if (gear.wear === 2) {
595
793
  // An open jacket over whatever is underneath.
596
794
  for (let y = b.hip; y <= b.shoulder; y++) {
597
- const half = halfAt(y);
598
- span(upper, -half, -half + 1, y, "X");
795
+ const half = y === b.shoulder ? b.chest + 1 : halfAt(y);
796
+ span(upper, -half, -half + 1, y, "M");
599
797
  span(upper, half - 1, half, y, "X");
600
798
  }
799
+ upper(-2, b.shoulder, "M");
800
+ upper(2, b.shoulder, "X");
601
801
  }
602
802
  if (gear.carry >= 1) {
603
803
  // Backpack straps over both shoulders.
604
- for (let y = b.shoulder - 3; y <= b.shoulder; y++) {
804
+ for (let y = b.shoulder - 4; y <= b.shoulder; y++) {
605
805
  upper(-(b.chest - 1), y, "B");
606
806
  upper(b.chest - 1, y, "B");
607
807
  }
808
+ upper(-(b.chest - 1), b.shoulder - 3, "d");
809
+ upper(b.chest - 1, b.shoulder - 3, "d");
608
810
  }
609
811
  if (gear.wear >= 1) {
610
812
  // A scarf around the neck, its end hanging down the chest.
611
813
  span(upper, -2, 2, b.shoulder + 1, "S");
612
- upper(1, b.shoulder, "z");
613
- upper(1, b.shoulder - 1, "z");
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
+ }
614
819
  }
615
820
  if (gear.carry === 2) {
616
821
  // 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");
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");
621
827
  }
622
828
  }
623
- function paintArms({ upper, b, look, gear, pose }) {
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") {
624
831
  const isJacket = gear.wear === 2;
625
- const [main, shade] = isJacket ? ["X", "X"] : topOf[look.style];
832
+ const tones = isJacket
833
+ ? { main: "X", shade: "X", light: "M" }
834
+ : topOf[look.style];
626
835
  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
- }
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;
641
862
  }
642
- const wrist = points.at(-2);
643
- if (side === 1 && gear.gadget === 2 && wrist && points.length > 2) {
644
- upper(wrist[0], wrist[1], "t");
863
+ for (let index = 0; index < width; index++) {
864
+ upper(x + dx * index, y + dy * index, index === width - 1 ? edge : key);
645
865
  }
646
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
+ }
647
872
  }
648
- function paintHead({ upper, b, look, gear, pose }) {
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
+ };
649
878
  const { chin } = b;
650
- const hair = hairKeys[look.hair];
879
+ const [hair, light] = hairOf[look.hair];
651
880
  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);
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.
658
895
  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";
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");
662
901
  if (pose.mouth === "smile") {
663
- mouth = "m";
902
+ span(head, turn - 1, turn + 1, chin + 1, "m");
903
+ head(turn - 2, chin + 2, "r");
904
+ head(turn + 2, chin + 2, "r");
664
905
  }
665
906
  else if (pose.mouth === "open") {
666
- mouth = "e";
907
+ span(head, turn - 1, turn, chin + 1, "e");
667
908
  }
668
- upper(turn, chin, mouth);
669
- // Short hair with sideburns, a side-swept fringe, or a chin-length bob.
909
+ else {
910
+ span(head, turn - 1, turn, chin + 1, "q");
911
+ }
912
+ // Short hair with sideburns and ears, a side-swept fringe, or a bob.
670
913
  if (look.gender === "male") {
671
- upper(-2, chin + 2, hair);
672
- upper(2, chin + 2, hair);
914
+ head(-3, chin + 4, hair);
915
+ head(3, chin + 4, hair);
673
916
  }
674
917
  else if (look.gender === "female") {
675
- upper(-2, chin + 2, hair);
676
- upper(-1, chin + 2, hair);
677
- upper(2, chin + 2, hair);
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);
678
922
  }
679
923
  else {
680
- for (const x of [-3, 3]) {
681
- for (let y = chin; y <= chin + 3; y++) {
682
- upper(x, y, hair);
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");
932
+ }
933
+ if (gear.gadget >= 1) {
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");
683
941
  }
684
942
  }
685
- upper(-2, chin + 2, hair);
686
- upper(2, chin + 2, hair);
943
+ }
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
+ }
687
1040
  }
688
1041
  if (gear.gadget >= 1) {
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");
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");
696
1045
  }
697
1046
  }
698
1047
  if (gear.headwear === 1) {
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");
1048
+ span(upper, -3, 2, chin + 6, "S");
1049
+ span(upper, -2, 1, chin + 7, "S");
1050
+ span(upper, 2, 5, chin + 5, "z");
702
1051
  }
703
1052
  else if (gear.headwear === 2) {
704
- span(upper, -2, 2, chin + 5, "g");
705
- for (const x of [-2, 0, 2]) {
706
- upper(x, chin + 6, "g");
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");
707
1108
  }
708
- upper(0, chin + 5, "t");
709
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);
1113
+ }
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");
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);
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);
710
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
+ };
711
1159
  export function paletteOf(palette) {
712
1160
  const { cloak, cloakShade, scarf, scarfShade, glow } = palette.sprite;
713
1161
  return new Map([
714
- ["A", "#121212"],
1162
+ ["A", "#141414"],
715
1163
  ["B", "#3E3226"],
716
1164
  ["b", "#6B4A2E"],
717
1165
  ["C", cloak],
718
1166
  ["c", cloakShade],
719
1167
  ["D", "#50555F"],
720
1168
  ["d", "#9AA0AA"],
1169
+ ["E", mix(cloak, "#FFFFFF", 0.16)],
721
1170
  ["e", "#2B2B2B"],
722
1171
  ["F", "#C8473F"],
723
1172
  ["G", "#6FA35A"],
@@ -725,9 +1174,12 @@ export function paletteOf(palette) {
725
1174
  ["H", "#9C9C9C"],
726
1175
  ["h", "#4A3222"],
727
1176
  ["I", "#E4E4E4"],
728
- ["k", "#2B2B2B"],
1177
+ ["i", "#6B4A33"],
1178
+ ["J", "#BDBDBD"],
1179
+ ["K", "#C4C4C4"],
729
1180
  ["L", "#4A505B"],
730
1181
  ["l", glow],
1182
+ ["M", "#424A58"],
731
1183
  ["m", "#B5524A"],
732
1184
  ["N", "#B59A6A"],
733
1185
  ["n", "#937B52"],
@@ -737,8 +1189,11 @@ export function paletteOf(palette) {
737
1189
  ["p", "#33565D"],
738
1190
  ["Q", "#E6E0D4"],
739
1191
  ["q", "#C99872"],
1192
+ ["R", "#4F8189"],
1193
+ ["r", "#E8909A"],
740
1194
  ["S", scarf],
741
1195
  ["s", "#E2B48C"],
1196
+ ["T", mix(scarf, "#FFFFFF", 0.22)],
742
1197
  ["t", glow],
743
1198
  ["U", "#B4AE9F"],
744
1199
  ["V", "#4A5536"],
@@ -746,18 +1201,18 @@ export function paletteOf(palette) {
746
1201
  ["w", "#F2F2F2"],
747
1202
  ["X", "#2F3540"],
748
1203
  ["x", "#3D414B"],
1204
+ ["Y", "#C8C8C8"],
749
1205
  ["y", palette.soft],
750
1206
  ["Z", "#C8C8C8"],
751
1207
  ["z", scarfShade],
752
1208
  ]);
753
1209
  }
754
1210
  // The pixel keys of 1 frame, row by row, "." where nothing is drawn.
755
- export function composeScene({ activity, counter, level, look, mood, width, }) {
1211
+ export function composeScene({ activity, counter, level, look, mood, width, anchor = homeOf(width), }) {
756
1212
  const canvas = Array.from({ length: stageHeight }, () => Array.from({ length: width }, () => "."));
757
- const scene = sceneOf(activity, counter, width, mood);
1213
+ const scene = sceneOf(activity, counter, width, mood, anchor);
758
1214
  const b = bodyOf(look);
759
1215
  const pose = scene.pose(b);
760
- const lift = pose.lift ?? 0;
761
1216
  // The character's x = 0 is its center column, mirrored when facing left.
762
1217
  const plant = (rise) => (x, y, key) => {
763
1218
  const column = scene.x + scene.facing * x;
@@ -766,25 +1221,25 @@ export function composeScene({ activity, counter, level, look, mood, width, }) {
766
1221
  row[column] = key;
767
1222
  }
768
1223
  };
769
- const put = plant(lift);
770
- const upper = plant(lift - (pose.bob ?? 0));
1224
+ const put = plant(scene.lift);
1225
+ const bob = pose.view === "front" ? (pose.bob ?? 0) : 0;
771
1226
  const paint = {
772
1227
  put,
773
- upper,
1228
+ upper: plant(scene.lift - bob),
774
1229
  b,
775
1230
  look,
776
1231
  gear: gearOf(level),
777
- pose,
778
1232
  counter,
779
1233
  };
780
- paintShadow(plant(0), b, lift);
781
- paintHairBehind(paint);
782
- paintLegs(paint);
783
- paintTorso(paint);
784
- paintArms(paint);
785
- 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
+ }
786
1241
  for (const draw of scene.props ?? []) {
787
- draw(put, upper, b);
1242
+ draw(put, paint.upper, b);
788
1243
  }
789
1244
  return canvas.map((row) => row.join(""));
790
1245
  }