@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +163 -0
- package/LICENSE +21 -21
- package/README.md +203 -139
- package/dist/auto.d.ts +8 -8
- package/dist/auto.js +3 -3
- package/dist/avatar-pixi.d.ts +165 -0
- package/dist/avatar-pixi.d.ts.map +1 -0
- package/dist/avatar-pixi.js +299 -0
- package/dist/avatar-pixi.js.map +1 -0
- package/dist/avatar-svg.d.ts +4 -0
- package/dist/avatar-svg.js +5 -0
- package/dist/avatar-three.d.ts +69 -0
- package/dist/avatar-three.d.ts.map +1 -0
- package/dist/avatar-three.js +49 -0
- package/dist/avatar-three.js.map +1 -0
- package/dist/avatar.d.ts +7 -0
- package/dist/avatar.js +500 -0
- package/dist/avatar.js.map +1 -0
- package/dist/daily.d.ts +2 -2
- package/dist/doctor/cli.mjs +119 -28
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.js +3 -3
- package/dist/input.d.ts +2 -2
- package/dist/input.js +2 -2
- package/dist/mp.d.ts +3 -3
- package/dist/race.d.ts +1 -1
- package/dist/rooms.d.ts +3 -3
- package/dist/rooms.js +1 -1
- package/dist/shared/animator.d.ts +104 -0
- package/dist/shared/animator.d.ts.map +1 -0
- package/dist/shared/art.js +1946 -0
- package/dist/shared/art.js.map +1 -0
- package/dist/shared/atlas.d.ts +138 -0
- package/dist/shared/atlas.d.ts.map +1 -0
- package/dist/shared/avatar.d.ts +123 -0
- package/dist/shared/avatar.d.ts.map +1 -0
- package/dist/shared/bridge.js +18 -4
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +145 -19
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +138 -25
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/defineProperty.js +39 -0
- package/dist/shared/describe.d.ts +32 -0
- package/dist/shared/describe.d.ts.map +1 -0
- package/dist/shared/describe.js +109 -0
- package/dist/shared/describe.js.map +1 -0
- package/dist/shared/globals.js +9 -42
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +1122 -46
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +74 -14
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +179 -25
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/json-guards.js +48 -0
- package/dist/shared/json-guards.js.map +1 -0
- package/dist/shared/latest-state.js +1 -1
- package/dist/shared/live-canvas.js +62 -0
- package/dist/shared/live-canvas.js.map +1 -0
- package/dist/shared/mp.js +10 -2
- package/dist/shared/mp.js.map +1 -1
- package/dist/shared/multiplayer-types.d.ts +15 -2
- package/dist/shared/multiplayer-types.d.ts.map +1 -1
- package/dist/shared/overlays.js +800 -0
- package/dist/shared/overlays.js.map +1 -0
- package/dist/shared/page-stack.js +2 -1
- package/dist/shared/page-stack.js.map +1 -1
- package/dist/shared/pose-key.js +249 -0
- package/dist/shared/pose-key.js.map +1 -0
- package/dist/shared/race-types.d.ts +1 -1
- package/dist/shared/race.js +1 -1
- package/dist/shared/room-types.d.ts +28 -3
- package/dist/shared/room-types.d.ts.map +1 -1
- package/dist/shared/rooms.d.ts.map +1 -1
- package/dist/shared/rooms.js +23 -4
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.js +1 -1
- package/dist/shared/scene.d.ts +90 -0
- package/dist/shared/scene.d.ts.map +1 -0
- package/dist/shared/social.d.ts +90 -9
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/social.js +6 -1
- package/dist/shared/social.js.map +1 -1
- package/dist/shared/src.js +4563 -667
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +2354 -0
- package/dist/shared/svg-markup.js.map +1 -0
- package/dist/shared/svg.js +27 -0
- package/dist/shared/svg.js.map +1 -0
- package/dist/shared/types.d.ts +174 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/wire-types.d.ts +6 -2
- package/dist/shared/wire-types.d.ts.map +1 -1
- package/dist/social.d.ts +5 -5
- package/dist/social.js +2 -2
- package/dist/turns-rules.d.ts +2 -0
- package/dist/turns-rules.js +1 -0
- package/dist/turns.d.ts +61 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +183 -0
- package/dist/turns.js.map +1 -0
- package/package.json +125 -89
- package/src/auto.ts +39 -39
- package/src/avatar/adapters/pixi-avatar.ts +73 -0
- package/src/avatar/adapters/pixi-crowd.ts +329 -0
- package/src/avatar/adapters/pixi-types.ts +84 -0
- package/src/avatar/adapters/three.ts +126 -0
- package/src/avatar/animator/animator.ts +285 -0
- package/src/avatar/animator/reactions.ts +44 -0
- package/src/avatar/animator/schedule.ts +73 -0
- package/src/avatar/art/beard.ts +49 -0
- package/src/avatar/art/glasses.ts +67 -0
- package/src/avatar/art/hair.ts +254 -0
- package/src/avatar/art/headwear.ts +75 -0
- package/src/avatar/art/index.ts +21 -0
- package/src/avatar/art/shape.ts +87 -0
- package/src/avatar/art-store.ts +36 -0
- package/src/avatar/atlas.ts +246 -0
- package/src/avatar/catalog-view.ts +14 -0
- package/src/avatar/colour.ts +109 -0
- package/src/avatar/describe.ts +79 -0
- package/src/avatar/face.ts +187 -0
- package/src/avatar/geom.ts +173 -0
- package/src/avatar/layout.ts +47 -0
- package/src/avatar/look-like.ts +52 -0
- package/src/avatar/outputs/bitmap.ts +75 -0
- package/src/avatar/outputs/canvas-paint.ts +177 -0
- package/src/avatar/outputs/canvas.ts +62 -0
- package/src/avatar/outputs/custom-element.ts +140 -0
- package/src/avatar/outputs/element.ts +58 -0
- package/src/avatar/outputs/live-canvas.ts +57 -0
- package/src/avatar/outputs/overlay-svg.ts +22 -0
- package/src/avatar/outputs/pose-key.ts +30 -0
- package/src/avatar/outputs/svg-markup.ts +113 -0
- package/src/avatar/outputs/svg.ts +41 -0
- package/src/avatar/overlays.ts +128 -0
- package/src/avatar/render/body.ts +116 -0
- package/src/avatar/render/colours.ts +81 -0
- package/src/avatar/render/compose.ts +320 -0
- package/src/avatar/render/context.ts +117 -0
- package/src/avatar/render/eyes.ts +78 -0
- package/src/avatar/render/eyewear.ts +118 -0
- package/src/avatar/render/frame.ts +73 -0
- package/src/avatar/render/head.ts +81 -0
- package/src/avatar/render/mouth.ts +86 -0
- package/src/avatar/render/props.ts +91 -0
- package/src/avatar/render/types.ts +93 -0
- package/src/avatar/render/walker-parts.ts +121 -0
- package/src/avatar/render/walker.ts +199 -0
- package/src/avatar/scene.ts +119 -0
- package/src/avatar-pixi.ts +20 -0
- package/src/avatar-svg.ts +21 -0
- package/src/avatar-three.ts +18 -0
- package/src/avatar.ts +63 -0
- package/src/core/avatar/avatar-cache.ts +95 -0
- package/src/core/avatar/avatar-ref.ts +34 -0
- package/src/core/avatar/avatar-types.ts +43 -0
- package/src/core/avatar/hub-avatar.ts +308 -0
- package/src/core/connection.ts +408 -299
- package/src/core/errors.ts +160 -147
- package/src/core/game-data.ts +40 -38
- package/src/core/hub-economy.ts +216 -179
- package/src/core/hub-progress.ts +269 -205
- package/src/core/hub.ts +352 -131
- package/src/core/menu-control.ts +61 -61
- package/src/core/offline/cloud-saves.ts +62 -0
- package/src/core/offline/events.ts +50 -50
- package/src/core/offline/favorites.ts +21 -21
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
- package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
- package/src/core/offline/hub-kinds/game-fields.ts +2 -0
- package/src/core/offline/hub-kinds/index.ts +52 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +165 -0
- package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- package/src/core/offline/kind-adapters.ts +48 -0
- package/src/core/offline/local-inventory.ts +64 -0
- package/src/core/offline/local-store.ts +304 -261
- package/src/core/offline/offline-api.ts +65 -0
- package/src/core/offline/outbox-entry.ts +59 -0
- package/src/core/offline/outbox-kinds.ts +78 -0
- package/src/core/offline/outbox-merge.ts +39 -0
- package/src/core/offline/outbox-replayer.ts +141 -0
- package/src/core/offline/outbox-store.ts +85 -0
- package/src/core/offline/outbox.ts +232 -0
- package/src/core/offline/queue.ts +126 -171
- package/src/core/offline/replay-lease.ts +87 -0
- package/src/core/offline/replay-policy.ts +52 -0
- package/src/core/page-account.ts +121 -0
- package/src/core/page.ts +87 -74
- package/src/core/rest/endpoint-types.ts +22 -22
- package/src/core/rest/rest-client.ts +163 -152
- package/src/core/rest/routes.gen.ts +144 -88
- package/src/core/resume/hub-resume.ts +286 -0
- package/src/core/resume/local-resume.ts +130 -0
- package/src/core/resume/resume-list.ts +65 -0
- package/src/core/resume/resume-request.ts +52 -0
- package/src/core/resume/resume-types.ts +64 -0
- package/src/core/saved/daily-saves.ts +196 -0
- package/src/core/saved/daily-seed.ts +16 -0
- package/src/core/saved/hub-saved.ts +221 -0
- package/src/core/saved/local-saved.ts +122 -0
- package/src/core/saved/saved-list.ts +129 -0
- package/src/core/saved/saved-open.ts +104 -0
- package/src/core/saved/saved-order.ts +49 -0
- package/src/core/saved/saved-types.ts +24 -0
- package/src/core/storage/json-guards.ts +80 -74
- package/src/core/storage/key-value.ts +66 -66
- package/src/core/ticket.ts +43 -43
- package/src/core/turns/hub-turns.ts +161 -0
- package/src/core/turns/turn-game.ts +510 -0
- package/src/core/turns/your-turn-buzz.ts +69 -0
- package/src/core/types.ts +134 -134
- package/src/core/version/client-version.gen.ts +2 -2
- package/src/core/version/hub-version.ts +62 -62
- package/src/core/version/semver.ts +97 -97
- package/src/daily/daily-system.ts +276 -161
- package/src/daily/page-daily.ts +36 -36
- package/src/daily/types.ts +105 -67
- package/src/daily.ts +10 -10
- package/src/doctor/checks.ts +86 -76
- package/src/doctor/cli.ts +101 -106
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -12
- package/src/doctor/world-check.ts +61 -0
- package/src/globals.ts +115 -110
- package/src/index.ts +68 -50
- package/src/input/device-state.ts +146 -146
- package/src/input/edges.ts +55 -55
- package/src/input/focus-ring.ts +13 -0
- package/src/input/gamepad.ts +61 -61
- package/src/input/input-events.ts +32 -32
- package/src/input/input-registry.ts +171 -164
- package/src/input/input-system.ts +406 -393
- package/src/input/legacy-controls.ts +64 -64
- package/src/input/modality.ts +122 -0
- package/src/input/nav-state.ts +200 -191
- package/src/input/navigator.ts +182 -171
- package/src/input/on-screen-controls.ts +101 -101
- package/src/input/page-control.ts +43 -43
- package/src/input/page-input.ts +193 -193
- package/src/input/player-remaps.ts +77 -77
- package/src/input/source-values.ts +116 -116
- package/src/input/spatial.ts +47 -47
- package/src/input/types.ts +331 -331
- package/src/input/zero-config.ts +164 -177
- package/src/input.ts +26 -22
- package/src/mp.ts +10 -10
- package/src/overlay/controls-panel.ts +163 -163
- package/src/overlay/dom.ts +46 -46
- package/src/overlay/layout-clusters.ts +162 -162
- package/src/overlay/layout-geometry.ts +136 -136
- package/src/overlay/layout.ts +203 -203
- package/src/overlay/listener-scope.ts +23 -23
- package/src/overlay/on-screen-keyboard.ts +137 -137
- package/src/overlay/overlay.ts +234 -208
- package/src/overlay/styles.ts +13 -12
- package/src/overlay/virtual-controls.ts +262 -262
- package/src/race.ts +10 -10
- package/src/realtime/bridge.ts +126 -121
- package/src/realtime/cross-bundle.ts +29 -29
- package/src/realtime/fallback-notice.ts +48 -48
- package/src/realtime/hub-link.ts +65 -65
- package/src/realtime/latest-state.ts +33 -33
- package/src/realtime/multiplayer-forwarder.ts +40 -28
- package/src/realtime/multiplayer-types.ts +66 -52
- package/src/realtime/page-runtime.ts +60 -60
- package/src/realtime/race-forwarder.ts +59 -59
- package/src/realtime/race-types.ts +132 -132
- package/src/realtime/room-types.ts +151 -125
- package/src/realtime/rooms-forwarder.ts +104 -79
- package/src/realtime/runtime-api.ts +74 -49
- package/src/realtime/runtime-opener.ts +61 -61
- package/src/realtime/social-forwarder.ts +77 -72
- package/src/realtime/social-types.ts +86 -78
- package/src/realtime/types.ts +54 -53
- package/src/realtime/wire-types.ts +79 -72
- package/src/realtime/world-types.ts +20 -0
- package/src/rooms.ts +10 -10
- package/src/social.ts +19 -18
- package/src/turns-kit/check-rules.ts +281 -0
- package/src/turns-rules.ts +12 -0
- package/src/turns.ts +14 -0
- package/src/types.ts +13 -13
- package/src/uistack/page-stack.ts +45 -45
- package/src/uistack/play-suspension.ts +82 -82
- package/src/uistack/types.ts +76 -76
- package/src/uistack/ui-stack.ts +144 -144
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eyes and brows in style C (§14.1): whites 7% larger than style A's with a
|
|
3
|
+
* 1.6-unit rim in a darker shade of the skin, an iris with a darker ring, a
|
|
4
|
+
* pupil, two glints, and a soft dark-brown lid line. No dark outline.
|
|
5
|
+
*/
|
|
6
|
+
import { n } from '../geom';
|
|
7
|
+
import { ellipse, g, line, path, sc, tr, type SceneNode } from '../scene';
|
|
8
|
+
import { FIXED, SOFT_INK } from './colours';
|
|
9
|
+
import { feat, LINE, type Ctx } from './context';
|
|
10
|
+
|
|
11
|
+
/** Eye-white radii: style A's 9.6 × 11.8, 7% larger. */
|
|
12
|
+
const RX = 9.6 * 1.07;
|
|
13
|
+
const RY = 11.8 * 1.07;
|
|
14
|
+
|
|
15
|
+
/** One eye; `side` −1 is the viewer's left. */
|
|
16
|
+
export function eye(ctx: Ctx, side: -1 | 1): SceneNode[] {
|
|
17
|
+
const f = feat(ctx, 17 * side);
|
|
18
|
+
if (!f.vis) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const fp = ctx.face;
|
|
22
|
+
const lidBase = side < 0 ? fp.lidL : fp.lidR;
|
|
23
|
+
const lid = lidBase + (1 - lidBase) * ctx.blink;
|
|
24
|
+
const low = side < 0 ? fp.lowL : fp.lowR;
|
|
25
|
+
const curve = side < 0 ? fp.closedCurveL : fp.closedCurveR;
|
|
26
|
+
const place = [tr(f.x, 4 + ctx.dy), sc(f.sx, 1)];
|
|
27
|
+
if (lid >= 0.9) {
|
|
28
|
+
return [g([path(`M-8 1.5Q0 ${n(1.5 - 9 * curve)} 8 1.5`, line(SOFT_INK, LINE * 1.05))], { tf: place })];
|
|
29
|
+
}
|
|
30
|
+
const top = -RY + lid * 2 * RY;
|
|
31
|
+
const edgeY = (px: number): number => top + (fp.lidTilt * (px * -side)) / 9;
|
|
32
|
+
const bot = RY - low * 2 * RY;
|
|
33
|
+
const gx = ctx.gaze.x * 2.8;
|
|
34
|
+
const gy = ctx.gaze.y * 3.4;
|
|
35
|
+
const clip = { shapes: [ellipse(0, 0, RX, RY, {})] };
|
|
36
|
+
const inner: SceneNode[] = [
|
|
37
|
+
g([
|
|
38
|
+
ellipse(0, 0, 7, 7, { fill: ctx.c.eye }),
|
|
39
|
+
ellipse(0, 0, 6.4, 6.4, line(ctx.c.eyeDark, 1.2)),
|
|
40
|
+
ellipse(0, 0, 3.6 * fp.pupil, 3.6 * fp.pupil, { fill: FIXED.pupil }),
|
|
41
|
+
ellipse(-2.4, -2.8, 2.2, 2.2, { fill: '#ffffff' }),
|
|
42
|
+
ellipse(2.1, 2, 0.9, 0.9, { fill: '#ffffff' }),
|
|
43
|
+
], { tf: [tr(gx, gy)] }),
|
|
44
|
+
];
|
|
45
|
+
if (lid > 0.02) {
|
|
46
|
+
inner.push(path(`M-12 -14L12 -14L12 ${n(edgeY(12))}Q0 ${n(top + 1.5)} -12 ${n(edgeY(-12))}Z`, { fill: ctx.c.skin }));
|
|
47
|
+
}
|
|
48
|
+
if (low > 0.02) {
|
|
49
|
+
inner.push(path(`M-12 14L12 14L12 ${n(bot)}Q0 ${n(bot - 3)} -12 ${n(bot)}Z`, { fill: ctx.c.skin }));
|
|
50
|
+
}
|
|
51
|
+
const out: SceneNode[] = [ellipse(0, 0, RX, RY, { fill: FIXED.white }), g(inner, { clip })];
|
|
52
|
+
if (lid > 0.06) {
|
|
53
|
+
out.push(g([path(`M${n(-RX + 0.5)} ${n(edgeY(-RX + 0.5))}Q0 ${n(top + 1.5)} ${n(RX - 0.5)} ${n(edgeY(RX - 0.5))}`, line(SOFT_INK, 1.9))], { clip }));
|
|
54
|
+
}
|
|
55
|
+
out.push(ellipse(0, 0, RX, RY, line(ctx.c.eyeRim, 1.6)));
|
|
56
|
+
return [g(out, { tf: place })];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const BROW_W: Readonly<Record<string, number>> = { soft: 3.8, thin: 2.4, thick: 5.6, arched: 3.6, straight: 4.2 };
|
|
60
|
+
const BROW_ARCH: Readonly<Record<string, number>> = { soft: 1, thin: 1, thick: 0.8, arched: 1.9, straight: 0.2 };
|
|
61
|
+
|
|
62
|
+
/** One brow in a style (`soft`, `thin`, …). */
|
|
63
|
+
export function brow(ctx: Ctx, side: -1 | 1, style: string): SceneNode[] {
|
|
64
|
+
const f = feat(ctx, 17 * side);
|
|
65
|
+
if (!f.vis) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
const p = side < 0 ? ctx.face.browL : ctx.face.browR;
|
|
69
|
+
const by = -14 + p.dy + ctx.dy * 1.1;
|
|
70
|
+
const xi = -7.5 * side;
|
|
71
|
+
const xo = 7.5 * side;
|
|
72
|
+
const yi = by + p.tilt * 0.8;
|
|
73
|
+
const yo = by - p.tilt * 0.35;
|
|
74
|
+
const arch = p.arch * (BROW_ARCH[style] ?? 1);
|
|
75
|
+
const width = (BROW_W[style] ?? 3.8) * 1.15;
|
|
76
|
+
const d = `M${n(xi)} ${n(yi)}Q0 ${n(Math.min(yi, yo) - arch * 2)} ${n(xo)} ${n(yo)}`;
|
|
77
|
+
return [g([path(d, line(ctx.c.brow, width))], { tf: [tr(f.x, 0), sc(f.sx, 1)] })];
|
|
78
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** Glasses and the `goggles` status prop, both following the head turn. */
|
|
2
|
+
import type { GlassesPart } from '../art/shape';
|
|
3
|
+
import { deltaE, ink, lightness, outline } from '../colour';
|
|
4
|
+
import { blob, Path } from '../geom';
|
|
5
|
+
import { g, line, path, rect, sc, tr, type SceneNode } from '../scene';
|
|
6
|
+
import { feat, LINE, turnWarp, type Ctx } from './context';
|
|
7
|
+
|
|
8
|
+
/** What fills a lens: clear glass, a tint, or a mirror finish. */
|
|
9
|
+
export interface LensGlass {
|
|
10
|
+
/** The tint colour; absent for clear glass. */
|
|
11
|
+
readonly tint?: string;
|
|
12
|
+
readonly isMirror?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The default frame colour. */
|
|
16
|
+
export const FRAME_BLACK = '#1d1a1e';
|
|
17
|
+
|
|
18
|
+
const SHINE = 'M-6 -4L-2 -7';
|
|
19
|
+
const MIRROR_STREAKS = 'M-9 7L3 -11M-1 10L9 -4';
|
|
20
|
+
|
|
21
|
+
/** The frame's colour and line width, and the dark edge a frame gets when it is light or close to the skin. */
|
|
22
|
+
interface FrameInk {
|
|
23
|
+
readonly colour: string;
|
|
24
|
+
readonly sw: number;
|
|
25
|
+
readonly edge: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A frame stroke along a path, over its edge when it has one. */
|
|
29
|
+
function frameLine(d: string, frame: FrameInk, sw = frame.sw): SceneNode[] {
|
|
30
|
+
return [...(frame.edge === undefined ? [] : [path(d, line(frame.edge, sw + 1.6))]), path(d, line(frame.colour, sw))];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One lens (or visor) in its own coordinates: the glass, then the frame. */
|
|
34
|
+
function lensNodes(d: string, glass: LensGlass, frame: FrameInk, shines: readonly (readonly [number, number])[]): SceneNode[] {
|
|
35
|
+
const out: SceneNode[] = [];
|
|
36
|
+
const clip = { shapes: [path(d, {})] };
|
|
37
|
+
if (glass.tint === undefined) {
|
|
38
|
+
out.push(path(d, { fill: '#ffffff', fillOpacity: 0.16 }));
|
|
39
|
+
} else if (glass.isMirror === true) {
|
|
40
|
+
out.push(path(d, { fill: glass.tint }), g([
|
|
41
|
+
rect(-60, 2, 120, 40, { fill: outline(glass.tint), opacity: 0.75 }),
|
|
42
|
+
...shines.map(([px, py]) => path(MIRROR_STREAKS, line('#ffffff', 2.6, 'round', 0.85), [tr(px, py)])),
|
|
43
|
+
], { clip }));
|
|
44
|
+
} else {
|
|
45
|
+
// Dark enough to read as sunglasses, light enough that winks show through.
|
|
46
|
+
out.push(path(d, { fill: glass.tint, fillOpacity: 0.86 }), g([rect(-60, 3, 120, 40, { fill: '#ffffff', opacity: 0.12 })], { clip }));
|
|
47
|
+
}
|
|
48
|
+
out.push(...frameLine(d, frame));
|
|
49
|
+
out.push(...shines.map(([px, py]) => path(SHINE, line('#ffffff', 1.8, 'round', glass.tint === undefined ? 0.7 : 0.9), [tr(px, py)])));
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Glasses: arms toward the ears, a lens at each eye (or one visor), and the bridge. */
|
|
54
|
+
export function glasses(ctx: Ctx, part: GlassesPart, colour: string, glass: LensGlass): SceneNode[] {
|
|
55
|
+
const out: SceneNode[] = [];
|
|
56
|
+
const warp = turnWarp(ctx.phi, ctx.dy);
|
|
57
|
+
const isEdged = lightness(colour) > 0.7 || deltaE(colour, ctx.c.skin) < 24;
|
|
58
|
+
const frame: FrameInk = { colour, sw: part.frame ?? 3.2, edge: isEdged ? ink(colour) : undefined };
|
|
59
|
+
const tinted: LensGlass = part.tint === undefined ? {} : { tint: glass.tint ?? part.tint, ...(glass.isMirror === true ? { isMirror: true } : {}) };
|
|
60
|
+
if (part.isSingle !== true) {
|
|
61
|
+
for (const side of [-1, 1] as const) {
|
|
62
|
+
if (feat(ctx, 30 * side).vis) {
|
|
63
|
+
out.push(...frameLine(new Path().m(30 * side, 2).l(45 * side, 0).d(warp), frame, 2.6));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (part.visor) {
|
|
68
|
+
const shines = [feat(ctx, -17), feat(ctx, 17)].filter((f) => f.vis).map((f) => [f.x, 4 + ctx.dy] as const);
|
|
69
|
+
out.push(...lensNodes(part.visor.d(warp), tinted, frame, shines));
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
const lens = part.lens;
|
|
73
|
+
for (const side of [-1, 1] as const) {
|
|
74
|
+
const f = feat(ctx, 17 * side);
|
|
75
|
+
if (!lens || !f.vis || (part.isSingle === true && side < 0)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const flip = part.isMirrored === true && side < 0 ? -1 : 1;
|
|
79
|
+
out.push(g([
|
|
80
|
+
...lensNodes(lens.d(), tinted, frame, [[0, 0]]),
|
|
81
|
+
...(part.bar ? frameLine(part.bar.d(), frame, 4.4) : []),
|
|
82
|
+
], { tf: [tr(f.x, 4 + ctx.dy), sc(f.sx * flip, 1)] }));
|
|
83
|
+
}
|
|
84
|
+
if (part.chain && feat(ctx, 30).vis) {
|
|
85
|
+
out.push(path(part.chain.d(warp), line(colour, 1.6, 'round', 0.9)));
|
|
86
|
+
}
|
|
87
|
+
if (part.bridge) {
|
|
88
|
+
const fb = feat(ctx, 0);
|
|
89
|
+
out.push(g(frameLine(part.bridge.d(), frame, 2.8), { tf: [tr(fb.x, 4 + ctx.dy), sc(fb.sx, 1)] }));
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const STRAP_FRONT = new Path().m(-47, -2).c(-30, -6, 30, -6, 47, -2);
|
|
95
|
+
const STRAP_BACK = new Path().m(-47, -2).c(-30, 4, 30, 4, 47, -2);
|
|
96
|
+
const SHELL = blob([[-31, -9], [-4, -10], [0, -6], [4, -10], [31, -9], [34, 4], [26, 16], [7, 16], [0, 11], [-7, 16], [-26, 16], [-34, 4]]);
|
|
97
|
+
const GLASS_L = blob([[-27, -5], [-7, -6], [-5, 8], [-9, 12], [-24, 12], [-29, 4]]);
|
|
98
|
+
const GLASS_R = blob([[27, -5], [7, -6], [5, 8], [9, 12], [24, 12], [29, 4]]);
|
|
99
|
+
|
|
100
|
+
/** Play goggles over the eyes (they replace the eyes and any glasses); the strap is the body colour. */
|
|
101
|
+
export function goggles(ctx: Ctx): SceneNode[] {
|
|
102
|
+
const w = turnWarp(ctx.phi, ctx.dy);
|
|
103
|
+
const band = ctx.c.body;
|
|
104
|
+
const strap = STRAP_FRONT.d(w);
|
|
105
|
+
return [
|
|
106
|
+
path(strap, line(ink(band), 9)), path(strap, line(band, 6)),
|
|
107
|
+
path(SHELL.d(w), { fill: '#2a3142', stroke: '#0d0f14', sw: LINE }),
|
|
108
|
+
path(GLASS_L.d(w), { fill: '#5fe3f2' }), path(GLASS_R.d(w), { fill: '#5fe3f2' }),
|
|
109
|
+
path(new Path().m(-22, 6).l(-14, -2).d(w), line('#e8fbff', 3)),
|
|
110
|
+
path(new Path().m(12, 6).l(20, -2).d(w), line('#e8fbff', 3)),
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The goggles strap seen from behind. */
|
|
115
|
+
export function gogglesStrapBack(ctx: Ctx, phi: number): SceneNode[] {
|
|
116
|
+
const d = STRAP_BACK.d(turnWarp(phi));
|
|
117
|
+
return [path(d, line(ink(ctx.c.body), 9)), path(d, line(ctx.c.body, 6))];
|
|
118
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frames (§3.4): which part of rig space an output shows, and the badge
|
|
3
|
+
* background (the site's rounded panel with a ring that lightens on dark
|
|
4
|
+
* skin, §3.9).
|
|
5
|
+
*/
|
|
6
|
+
import { normaliseHex, resolveColour, type AvatarLook } from '@diffenderfer-games/hub-contract/avatar';
|
|
7
|
+
import { lightness } from '../colour';
|
|
8
|
+
import { g, rect, type SceneNode } from '../scene';
|
|
9
|
+
import type { Frame, RenderOptions } from './types';
|
|
10
|
+
|
|
11
|
+
/** A frame's box in rig units: x, y, width, height. */
|
|
12
|
+
export type Box = readonly [number, number, number, number];
|
|
13
|
+
|
|
14
|
+
/** The three frames' boxes. */
|
|
15
|
+
export const FRAMES: Readonly<Record<Frame, Box>> = {
|
|
16
|
+
head: [-66, -164, 132, 132],
|
|
17
|
+
bust: [-84, -188, 168, 168],
|
|
18
|
+
full: [-86, -196, 172, 206],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The walker's full frame: a smaller head, and room for feet hanging below a seat. */
|
|
22
|
+
export const WALKER_FULL: Box = [-60, -176, 120, 214];
|
|
23
|
+
|
|
24
|
+
/** True when options draw the walker figure (full frames only). */
|
|
25
|
+
export function isWalker(opts: RenderOptions): boolean {
|
|
26
|
+
return opts.figure === 'walker' && frameOf(opts) === 'full';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The box options draw, in rig units. */
|
|
30
|
+
export function frameBox(opts: RenderOptions): Box {
|
|
31
|
+
return isWalker(opts) ? WALKER_FULL : FRAMES[frameOf(opts)];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The site panel colour behind badges. */
|
|
35
|
+
export const PANEL = '#11141c';
|
|
36
|
+
|
|
37
|
+
/** The frame an options object asks for (default `head`). */
|
|
38
|
+
export function frameOf(opts: RenderOptions): Frame {
|
|
39
|
+
return opts.frame ?? 'head';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Output pixel size for a frame (the longer edge is `size`). */
|
|
43
|
+
export function pixelSize(opts: RenderOptions): { readonly width: number; readonly height: number; readonly scale: number } {
|
|
44
|
+
const [, , w, h] = frameBox(opts);
|
|
45
|
+
const size = opts.size ?? 48;
|
|
46
|
+
const scale = size / Math.max(w, h);
|
|
47
|
+
return { width: w * scale, height: h * scale, scale };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The badge ring colour: lighter when the skin is darker than the panel, so every skin has an edge. */
|
|
51
|
+
export function ringColour(look: AvatarLook | null): string {
|
|
52
|
+
return look && lightness(resolveColour(look.skin)) < 0.42 ? '#6f7787' : '#2a3142';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Wraps the avatar's nodes in the frame's background (a badge clips to the rounded panel). */
|
|
56
|
+
export function framed(nodes: readonly SceneNode[], look: AvatarLook | null, opts: RenderOptions): SceneNode[] {
|
|
57
|
+
const [left, top, w, h] = frameBox(opts);
|
|
58
|
+
const background = opts.background ?? (frameOf(opts) === 'head' ? 'badge' : 'none');
|
|
59
|
+
if (background === 'badge') {
|
|
60
|
+
const r = w * 0.24;
|
|
61
|
+
return [
|
|
62
|
+
rect(left, top, w, h, { fill: PANEL }, r),
|
|
63
|
+
g(nodes, { clip: { shapes: [rect(left, top, w, h, {}, r)] } }),
|
|
64
|
+
rect(left + 1.5, top + 1.5, w - 3, h - 3, { fill: 'none', stroke: ringColour(look), sw: 3 }, r - 1.5),
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
if (background === 'none') {
|
|
68
|
+
return [...nodes];
|
|
69
|
+
}
|
|
70
|
+
// A caller-supplied colour is normalised, so it can never become markup.
|
|
71
|
+
const fill = normaliseHex(background);
|
|
72
|
+
return fill ? [rect(left, top, w, h, { fill }), ...nodes] : [...nodes];
|
|
73
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** The head, ears, cheeks and the small face and ear extras. */
|
|
2
|
+
import { clamp } from '../geom';
|
|
3
|
+
import { ellipse, g, line, path, sc, tr, type SceneNode } from '../scene';
|
|
4
|
+
import { FIXED } from './colours';
|
|
5
|
+
import { feat, HEAD_PATH, LINE, SKULL_R, type Ctx } from './context';
|
|
6
|
+
|
|
7
|
+
const EAR = 'M-2 -10C6 -12 11 -6 10 1C9 8 5 11 -1 10Z';
|
|
8
|
+
const EAR_FOLD = 'M1.5 -5C5.5 -5 6.5 0 5 3.5';
|
|
9
|
+
|
|
10
|
+
/** Ear extras: studs or hoops, in a colour. */
|
|
11
|
+
export interface EarExtra {
|
|
12
|
+
readonly kind: 'studs' | 'hoops';
|
|
13
|
+
readonly colour: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Ears behind the head (`behind`) or in front of it (`front`), hidden when turned away. */
|
|
17
|
+
export function ears(ctx: Ctx, which: 'behind' | 'front', extra?: EarExtra): SceneNode[] {
|
|
18
|
+
const out: SceneNode[] = [];
|
|
19
|
+
for (const side of [-1, 1] as const) {
|
|
20
|
+
const t = (Math.PI / 2) * side + ctx.phi;
|
|
21
|
+
const cos = Math.cos(t);
|
|
22
|
+
if (cos < -0.15) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const isInFront = cos > 0.3;
|
|
26
|
+
if ((which === 'front') !== isInFront) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const earX = clamp(SKULL_R * Math.sin(t), -SKULL_R + 2, SKULL_R - 2);
|
|
30
|
+
const w = isInFront ? 0.75 + 0.25 * cos : 1;
|
|
31
|
+
const flip = isInFront ? -Math.sign(earX || side) : side;
|
|
32
|
+
const parts: SceneNode[] = [
|
|
33
|
+
path(EAR, { fill: ctx.c.skin, stroke: ctx.c.skinInk, sw: LINE }),
|
|
34
|
+
path(EAR_FOLD, line(ctx.c.skinShade, 2.2)),
|
|
35
|
+
];
|
|
36
|
+
if (extra?.kind === 'studs') {
|
|
37
|
+
parts.push(ellipse(3, 7, 2.2, 2.2, { fill: extra.colour, stroke: '#3a2a2a', sw: 0.8 }));
|
|
38
|
+
}
|
|
39
|
+
if (extra?.kind === 'hoops') {
|
|
40
|
+
parts.push(ellipse(3, 12, 3.6, 4.4, line(extra.colour, 1.8)));
|
|
41
|
+
}
|
|
42
|
+
out.push(g(parts, { tf: [tr(earX, 6 + ctx.dy * 0.3), sc(flip * w, 1)] }));
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The head: a shade base, the lit face, and the tonal outline. */
|
|
48
|
+
export function head(ctx: Ctx): SceneNode[] {
|
|
49
|
+
const d = HEAD_PATH.d();
|
|
50
|
+
const lx = -2.5 + Math.sin(ctx.phi) * 3;
|
|
51
|
+
return [
|
|
52
|
+
path(d, { fill: ctx.c.skinShade }),
|
|
53
|
+
path(d, { fill: ctx.c.skin }, [tr(lx, -3), sc(0.95)]),
|
|
54
|
+
path(d, { fill: 'none', stroke: ctx.c.skinInk, sw: LINE }),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const FRECKLES: readonly (readonly [number, number])[] = [[-3.5, -1.5], [0, 0.8], [3.5, -1.2], [1.5, -3.5]];
|
|
59
|
+
|
|
60
|
+
/** Cheek blush (from the expression, plus `rosy`) and freckles. */
|
|
61
|
+
export function cheeks(ctx: Ctx, hasFreckles: boolean, isRosy: boolean): SceneNode[] {
|
|
62
|
+
const out: SceneNode[] = [];
|
|
63
|
+
const blush = Math.min(1, ctx.face.cheeks + (isRosy ? 0.45 : 0));
|
|
64
|
+
for (const side of [-1, 1] as const) {
|
|
65
|
+
const f = feat(ctx, 25 * side);
|
|
66
|
+
if (!f.vis) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const parts: SceneNode[] = [];
|
|
70
|
+
if (blush > 0.02) {
|
|
71
|
+
parts.push(ellipse(0, 0, 6.5, 3.8, { fill: FIXED.blush, opacity: Math.round(blush * 5) / 10 }));
|
|
72
|
+
}
|
|
73
|
+
if (hasFreckles) {
|
|
74
|
+
parts.push(...FRECKLES.map(([fx, fy]) => ellipse(fx, fy, 1.05, 1.05, { fill: ctx.c.skinInk, opacity: 0.45 })));
|
|
75
|
+
}
|
|
76
|
+
if (parts.length > 0) {
|
|
77
|
+
out.push(g(parts, { tf: [tr(f.x, 18 + ctx.dy), sc(f.sx, 1)] }));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nose, the mouth morph (one cubic structure for every shape, so any two
|
|
3
|
+
* expressions tween, §3.5) and the small FX glyphs.
|
|
4
|
+
*/
|
|
5
|
+
import { n } from '../geom';
|
|
6
|
+
import { ellipse, g, line, path, rect, sc, tr, type SceneNode } from '../scene';
|
|
7
|
+
import { FIXED, SOFT_INK } from './colours';
|
|
8
|
+
import { feat, LINE, type Ctx } from './context';
|
|
9
|
+
|
|
10
|
+
/** The nose: a soft shade with a highlight. */
|
|
11
|
+
export function nose(ctx: Ctx): SceneNode[] {
|
|
12
|
+
const f = feat(ctx, 0);
|
|
13
|
+
return [g([
|
|
14
|
+
ellipse(0, 0, 4.4, 3, { fill: ctx.c.skinShade }),
|
|
15
|
+
ellipse(-1.5, -1, 1.2, 1.2, { fill: ctx.c.skinLight, opacity: 0.9 }),
|
|
16
|
+
], { tf: [tr(f.x, 16 + ctx.dy), sc(Math.max(0.6, f.sx), 1)] })];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The mouth: a line when closed, a filled shape with teeth and tongue when open. Talking adds opening. */
|
|
20
|
+
export function mouth(ctx: Ctx): SceneNode[] {
|
|
21
|
+
const base = ctx.face.mouth;
|
|
22
|
+
const m = { ...base, open: base.open + ctx.talk * 9 * (1 - base.round * 0.4) };
|
|
23
|
+
const f = feat(ctx, m.dx);
|
|
24
|
+
const baseY = 27;
|
|
25
|
+
const lx = -m.w / 2;
|
|
26
|
+
const ly = baseY - m.sL;
|
|
27
|
+
const rx = m.w / 2;
|
|
28
|
+
const ry = baseY - m.sR;
|
|
29
|
+
const s = (m.sL + m.sR) / 2;
|
|
30
|
+
const corner = baseY - s;
|
|
31
|
+
const ctrl = (target: number): number => (target - 0.25 * corner) / 0.75;
|
|
32
|
+
const place = [tr(f.x, ctx.dy * 0.8), sc(Math.max(0.35, f.sx), 1)];
|
|
33
|
+
if (m.open < 1) {
|
|
34
|
+
const c = ctrl(baseY + s * 0.55);
|
|
35
|
+
return [g([path(`M${n(lx)} ${n(ly)}C${n(-m.w * 0.18)} ${n(c)} ${n(m.w * 0.18)} ${n(c)} ${n(rx)} ${n(ry)}`, line(SOFT_INK, LINE * 0.95))], { tf: place })];
|
|
36
|
+
}
|
|
37
|
+
const up = baseY + s * 0.3 - m.round * m.open * 0.45;
|
|
38
|
+
const lo = baseY + s * 0.55 + m.open * (1 - m.round * 0.45);
|
|
39
|
+
const kx = (m.w / 2) * (0.55 + m.round * 0.8);
|
|
40
|
+
const d = `M${n(lx)} ${n(ly)}C${n(-kx)} ${n(ctrl(up))} ${n(kx)} ${n(ctrl(up))} ${n(rx)} ${n(ry)}C${n(kx)} ${n(ctrl(lo))} ${n(-kx)} ${n(ctrl(lo))} ${n(lx)} ${n(ly)}Z`;
|
|
41
|
+
const inside: SceneNode[] = [];
|
|
42
|
+
if (m.tongue > 0.05) {
|
|
43
|
+
inside.push(ellipse(0, lo + 1, m.w * 0.32, m.open * 0.42 * m.tongue + 1, { fill: FIXED.tongue }));
|
|
44
|
+
}
|
|
45
|
+
if (m.teeth > 0.05) {
|
|
46
|
+
inside.push(rect(-m.w / 2, up - 6, m.w, 6 + m.open * 0.22 * m.teeth, { fill: FIXED.white }));
|
|
47
|
+
}
|
|
48
|
+
return [g([
|
|
49
|
+
path(d, { fill: FIXED.mouth }),
|
|
50
|
+
g(inside, { clip: { shapes: [path(d, {})] } }),
|
|
51
|
+
path(d, line(SOFT_INK, LINE * 0.9)),
|
|
52
|
+
], { tf: place })];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** FX glyphs beside the head (sparkle, tear, sweat, anger, question, zzz). */
|
|
56
|
+
export function extras(ctx: Ctx): SceneNode[] {
|
|
57
|
+
const out: SceneNode[] = [];
|
|
58
|
+
const f = feat(ctx, 0);
|
|
59
|
+
for (const e of ctx.face.extras) {
|
|
60
|
+
if (e === 'sparkle') {
|
|
61
|
+
out.push(path('M0 -9Q1.4 -1.4 9 0Q1.4 1.4 0 9Q-1.4 1.4 -9 0Q-1.4 -1.4 0 -9Z', { fill: '#ffd23f', stroke: '#0d0f14', sw: 1.6 }, [tr(48 + f.x * 0.2, -44)]));
|
|
62
|
+
}
|
|
63
|
+
if (e === 'tear') {
|
|
64
|
+
const t = feat(ctx, -16);
|
|
65
|
+
if (t.vis) {
|
|
66
|
+
out.push(path('M0 -5Q4.5 2 0 5Q-4.5 2 0 -5Z', { fill: '#7cc7ff', stroke: '#1f3b5c', sw: 1.4 }, [tr(t.x - 5, 16 + ctx.dy)]));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (e === 'sweat') {
|
|
70
|
+
out.push(path('M0 -6Q5 2 0 6Q-5 2 0 -6Z', { fill: '#9fdcff', stroke: '#1f3b5c', sw: 1.4 }, [tr(40 + f.x * 0.2, -26)]));
|
|
71
|
+
}
|
|
72
|
+
if (e === 'anger') {
|
|
73
|
+
out.push(g([
|
|
74
|
+
path('M-6 -2Q-2 -2 -2 -6', line('#ff4d4d', 2.6)), path('M2 -6Q2 -2 6 -2', line('#ff4d4d', 2.6)),
|
|
75
|
+
path('M6 2Q2 2 2 6', line('#ff4d4d', 2.6)), path('M-2 6Q-2 2 -6 2', line('#ff4d4d', 2.6)),
|
|
76
|
+
], { tf: [tr(30 + f.x * 0.3, -36)] }));
|
|
77
|
+
}
|
|
78
|
+
if (e === 'question') {
|
|
79
|
+
out.push({ k: 'text', x: 50 + f.x * 0.2, y: -40, text: '?', size: 26, font: 'pixel', fill: '#ffd23f', stroke: '#0d0f14', sw: 1.2 });
|
|
80
|
+
}
|
|
81
|
+
if (e === 'zzz') {
|
|
82
|
+
out.push({ k: 'text', x: 44 + f.x * 0.2, y: -42, text: 'z z', size: 20, font: 'pixel', fill: '#8ab4ff', stroke: '#0d0f14', sw: 1.2 });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status props (§3.11, §14.3): runtime-only parts the world (or a preview)
|
|
3
|
+
* puts on a player. `goggles` lives in eyewear.ts; the head props here sit on
|
|
4
|
+
* the head, the hand props at the `prop` anchor in front of the body.
|
|
5
|
+
*/
|
|
6
|
+
import { arc, blob, Path, roundedRect } from '../geom';
|
|
7
|
+
import { ellipse, g, line, path, rect, rot, sc, tr, type SceneNode } from '../scene';
|
|
8
|
+
import { LINE, SKULL_R, turnWarp, type Ctx } from './context';
|
|
9
|
+
import type { StatusProp } from './types';
|
|
10
|
+
|
|
11
|
+
const DARK = '#0d0f14';
|
|
12
|
+
/** Hand props are drawn at this scale so they read at small sizes. */
|
|
13
|
+
const PROP_SCALE = 1.45;
|
|
14
|
+
|
|
15
|
+
/** A headset: a band over the head, ear cups and a mic (head space). */
|
|
16
|
+
export function headset(ctx: Ctx, isBack: boolean): SceneNode[] {
|
|
17
|
+
const w = turnWarp(isBack ? 0 : ctx.phi);
|
|
18
|
+
const band = blob(arc(0, -4, 51, 51, 196, 344, 8), false);
|
|
19
|
+
const nodes: SceneNode[] = [path(band.d(w), line(DARK, 8)), path(band.d(w), line('#4a5470', 5))];
|
|
20
|
+
const phi = isBack ? 0 : ctx.phi;
|
|
21
|
+
for (const side of [-1, 1] as const) {
|
|
22
|
+
// The cups sit over the ears, so they follow the ears through a turn.
|
|
23
|
+
const t = (Math.PI / 2) * side + phi;
|
|
24
|
+
if (Math.cos(t) < -0.15) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const cupX = Math.max(-SKULL_R - 1, Math.min(SKULL_R + 1, SKULL_R * Math.sin(t)));
|
|
28
|
+
const wide = 6 + 3 * Math.max(0, Math.cos(t));
|
|
29
|
+
nodes.push(path(roundedRect(cupX - wide, -6, wide * 2, 20, 5).d(), { fill: '#2a3142', stroke: DARK, sw: LINE }));
|
|
30
|
+
}
|
|
31
|
+
if (!isBack && Math.abs(phi) < 0.7) {
|
|
32
|
+
nodes.push(path(new Path().m(-46, 12).q(-40, 30, -18, 30).d(w), line('#4a5470', 3)), ellipse(-17, 30, 3, 3, { fill: '#ff5f3b', stroke: DARK, sw: 1.2 }));
|
|
33
|
+
}
|
|
34
|
+
return nodes;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A small crown on top of the head (head space; `top` is the hair or hat top). */
|
|
38
|
+
export function crown(top: number): SceneNode[] {
|
|
39
|
+
const d = 'M-16 0L-18 -16L-8 -8L0 -20L8 -8L18 -16L16 0Z';
|
|
40
|
+
return [g([
|
|
41
|
+
path(d, { fill: '#ffd23f', stroke: '#8a5a00', sw: LINE }),
|
|
42
|
+
ellipse(0, -5, 2.4, 2.4, { fill: '#ff5f3b' }),
|
|
43
|
+
], { tf: [tr(0, -50 - top + 4)] })];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const card = (colour: string): SceneNode =>
|
|
47
|
+
rect(-6, -9, 12, 17, { fill: colour, stroke: DARK, sw: 1.6 }, 2.5);
|
|
48
|
+
|
|
49
|
+
/** Nub hands holding something at the prop anchor (body space). */
|
|
50
|
+
function nubs(body: string, ink: string, spread: number, atY: number): SceneNode[] {
|
|
51
|
+
return [-1, 1].map((s) => ellipse(spread * s, atY, 7, 6, { fill: body, stroke: ink, sw: LINE }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A hand prop drawn at the `prop` anchor (x, y) in front of the body. */
|
|
55
|
+
export function handProp(prop: StatusProp, atX: number, atY: number, body: string, bodyInk: string): SceneNode[] {
|
|
56
|
+
switch (prop) {
|
|
57
|
+
case 'cards':
|
|
58
|
+
return [g([
|
|
59
|
+
g([card('#ff5f3b')], { tf: [tr(-8, 0), rot(-18)] }),
|
|
60
|
+
g([card('#ffd23f')], { tf: [tr(0, -3)] }),
|
|
61
|
+
g([card('#3d7bff')], { tf: [tr(8, 0), rot(18)] }),
|
|
62
|
+
...nubs(body, bodyInk, 13, 8),
|
|
63
|
+
], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
|
|
64
|
+
case 'controller':
|
|
65
|
+
return [g([
|
|
66
|
+
path(new Path().m(-20, -4).c(-20, -12, 20, -12, 20, -4).c(24, 10, 14, 14, 8, 6).l(-8, 6).c(-14, 14, -24, 10, -20, -4).z().d(), { fill: '#2a3142', stroke: DARK, sw: LINE }),
|
|
67
|
+
ellipse(-9, -2, 2.6, 2.6, { fill: '#8ab4ff' }), ellipse(10, -4, 2, 2, { fill: '#ff5f3b' }), ellipse(13, 0, 2, 2, { fill: '#4ade80' }),
|
|
68
|
+
...nubs(body, bodyInk, 20, 4),
|
|
69
|
+
], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
|
|
70
|
+
case 'book':
|
|
71
|
+
return [g([
|
|
72
|
+
path('M0 -2C-8 -8 -18 -8 -24 -5L-24 12C-18 9 -8 9 0 14C8 9 18 9 24 12L24 -5C18 -8 8 -8 0 -2Z', { fill: '#f4f1ea', stroke: DARK, sw: LINE }),
|
|
73
|
+
path('M0 -2L0 14', line('#c9c2b4', 1.6)),
|
|
74
|
+
path('M-19 0L-5 3M-19 5L-5 8M5 3L19 0M5 8L19 5', line('#9a9384', 1.2)),
|
|
75
|
+
path('M-24 12C-18 9 -8 9 0 14C8 9 18 9 24 12L24 15C18 12 8 12 0 17C-8 12 -18 12 -24 15Z', { fill: '#ff5f3b', stroke: DARK, sw: 1.4 }),
|
|
76
|
+
...nubs(body, bodyInk, 25, 6),
|
|
77
|
+
], { tf: [tr(atX, atY), sc(PROP_SCALE)] })];
|
|
78
|
+
case 'trophy':
|
|
79
|
+
return [g([
|
|
80
|
+
path('M-11 -14L11 -14L9 0C8 6 -8 6 -9 0Z', { fill: '#ffd23f', stroke: '#8a5a00', sw: LINE }),
|
|
81
|
+
path('M-11 -11C-18 -11 -18 -2 -9 -1M11 -11C18 -11 18 -2 9 -1', line('#8a5a00', 2.2)),
|
|
82
|
+
rect(-3, 4, 6, 6, { fill: '#e0a800' }), rect(-9, 10, 18, 5, { fill: '#6b4f12', stroke: DARK, sw: 1.2 }, 1.5),
|
|
83
|
+
ellipse(13, 6, 7, 6, { fill: body, stroke: bodyInk, sw: LINE }),
|
|
84
|
+
], { tf: [tr(atX + 24, atY - 10), sc(PROP_SCALE)] })];
|
|
85
|
+
default:
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Props worn on the head (drawn in head space). */
|
|
91
|
+
export const HEAD_PROPS: ReadonlySet<StatusProp> = new Set(['goggles', 'headset', 'crown']);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** The renderer's public option and pose types (avatars.md §3.3, §3.4, §3.11). */
|
|
2
|
+
import type { AvatarLook } from '@diffenderfer-games/hub-contract/avatar';
|
|
3
|
+
import type { Expression, FaceParams } from '../face';
|
|
4
|
+
|
|
5
|
+
/** Eight facings: `S` faces the viewer; `W`, `SW` and `NW` mirror `E`, `SE` and `NE`. */
|
|
6
|
+
export type Facing = 'S' | 'SE' | 'E' | 'NE' | 'N' | 'NW' | 'W' | 'SW';
|
|
7
|
+
|
|
8
|
+
/** The 4-direction aliases. */
|
|
9
|
+
export type View = 'front' | 'back' | 'left' | 'right';
|
|
10
|
+
|
|
11
|
+
/** Body poses on the blob; a future limbed body re-implements the same names. */
|
|
12
|
+
export type BodyPose = 'idle' | 'walk' | 'run' | 'sit' | 'play' | 'wave' | 'cheer' | 'jump' | 'dance' | 'away';
|
|
13
|
+
|
|
14
|
+
/** Runtime status props (never stored in the look). */
|
|
15
|
+
export type StatusProp = 'goggles' | 'headset' | 'cards' | 'controller' | 'trophy' | 'crown' | 'book';
|
|
16
|
+
|
|
17
|
+
/** How much of the character a frame shows. */
|
|
18
|
+
export type Frame = 'head' | 'bust' | 'full';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The full-body figure: `blob` (the default, a big head on a gumdrop) or
|
|
22
|
+
* `walker` (a small full-body character with legs and arms, for worlds and
|
|
23
|
+
* crowds). Head and bust frames always draw the blob.
|
|
24
|
+
*/
|
|
25
|
+
export type Figure = 'blob' | 'walker';
|
|
26
|
+
|
|
27
|
+
/** A point in rig units. */
|
|
28
|
+
export interface Point {
|
|
29
|
+
readonly x: number;
|
|
30
|
+
readonly y: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Where the eyes look: x right, y down, each in [−1, 1]. */
|
|
34
|
+
export type LookDir = Point;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Everything that moves: the face (an expression or raw parameters), gaze,
|
|
38
|
+
* blink, talk, the body pose and the status prop. The animator produces one
|
|
39
|
+
* per frame; a still render takes a partial one.
|
|
40
|
+
*/
|
|
41
|
+
export interface Pose {
|
|
42
|
+
readonly expression?: Expression;
|
|
43
|
+
/** Raw face parameters (wins over `expression`). */
|
|
44
|
+
readonly face?: FaceParams;
|
|
45
|
+
readonly look?: LookDir;
|
|
46
|
+
/** Extra lid closure, 0 … 1 (a blink). */
|
|
47
|
+
readonly blink?: number;
|
|
48
|
+
/** Extra mouth opening, 0 … 1 (talking). */
|
|
49
|
+
readonly talk?: number;
|
|
50
|
+
readonly body?: BodyPose;
|
|
51
|
+
/** Walk, dance or jump cycle phase, 0 … 1. */
|
|
52
|
+
readonly phase?: number;
|
|
53
|
+
readonly status?: StatusProp | null;
|
|
54
|
+
/** Reaction offset of the head, rig units. */
|
|
55
|
+
readonly bob?: Point;
|
|
56
|
+
/** Reaction tilt added to the head, degrees. */
|
|
57
|
+
readonly tilt?: number;
|
|
58
|
+
/** Reaction squash added to the head. */
|
|
59
|
+
readonly squash?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Render options shared by every output. */
|
|
63
|
+
export interface RenderOptions {
|
|
64
|
+
/** Output px of the frame's longer edge (default 48). */
|
|
65
|
+
readonly size?: number;
|
|
66
|
+
/** `head` badge (default), `bust` or `full` character. */
|
|
67
|
+
readonly frame?: Frame;
|
|
68
|
+
/** `badge` = the site's rounded panel and ring (the default for `head`); `none`; or a `#rrggbb`. */
|
|
69
|
+
readonly background?: 'badge' | 'none' | `#${string}`;
|
|
70
|
+
readonly facing?: Facing;
|
|
71
|
+
readonly view?: View;
|
|
72
|
+
readonly pose?: Pose;
|
|
73
|
+
/** The accessible name ("Sam's avatar"); omitted, the avatar is decorative. */
|
|
74
|
+
readonly title?: string;
|
|
75
|
+
/** A prefix for SVG ids when inlining many. */
|
|
76
|
+
readonly idPrefix?: string;
|
|
77
|
+
/** A soft light rim for dark scenes (the world; on by default in the atlas). */
|
|
78
|
+
readonly rim?: boolean;
|
|
79
|
+
/** The ground shadow (default: on for `full`). */
|
|
80
|
+
readonly shadow?: boolean;
|
|
81
|
+
/** Draws a stool under `sit` and `play` (pose sheets and previews). */
|
|
82
|
+
readonly stool?: boolean;
|
|
83
|
+
/** The full frame's figure (default `blob`). */
|
|
84
|
+
readonly figure?: Figure;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Anything a render accepts: a look, an `AV1…` code, or an object carrying a code. */
|
|
88
|
+
export type LookLike = AvatarLook | string | { readonly code: string } | { readonly look: AvatarLook } | null | undefined;
|
|
89
|
+
|
|
90
|
+
/** Named rig anchors (feet origin, y down), for name plates, bubbles, props and the world. */
|
|
91
|
+
export type Anchor =
|
|
92
|
+
| 'feet' | 'body' | 'neck' | 'head' | 'eyeLine' | 'mouth' | 'hatTop' | 'nameplate' | 'bubble'
|
|
93
|
+
| 'seat' | 'prop' | 'shadow' | 'handL' | 'handR' | 'pod';
|