@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,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<dg-avatar>` (§3.8), registered by `defineAvatarElement()` (never as a
|
|
3
|
+
* side effect of importing). For DOM and React games and static pages:
|
|
4
|
+
*
|
|
5
|
+
* ```html
|
|
6
|
+
* <dg-avatar look="AV1…" size="48" expression="happy" look-x="-1" animate title="Sam's avatar"></dg-avatar>
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* With `animate` it runs its own animator (blinks, idle motion) at up to
|
|
10
|
+
* 30 fps while visible; its `animator` property gives full control.
|
|
11
|
+
*/
|
|
12
|
+
import { animator, type Animator } from '../animator/animator';
|
|
13
|
+
import { ready } from '../art-store';
|
|
14
|
+
import { isExpression } from '../face';
|
|
15
|
+
import type { BodyPose, Facing, Frame, Pose, RenderOptions, StatusProp } from '../render/types';
|
|
16
|
+
import { renderElement, type AvatarElement } from './element';
|
|
17
|
+
|
|
18
|
+
const FACINGS: readonly Facing[] = ['S', 'SE', 'E', 'NE', 'N', 'NW', 'W', 'SW'];
|
|
19
|
+
const FRAMES: readonly Frame[] = ['head', 'bust', 'full'];
|
|
20
|
+
const BODIES: readonly BodyPose[] = ['idle', 'walk', 'run', 'sit', 'play', 'wave', 'cheer', 'jump', 'dance', 'away'];
|
|
21
|
+
const STATUSES: readonly StatusProp[] = ['goggles', 'headset', 'cards', 'controller', 'trophy', 'crown', 'book'];
|
|
22
|
+
const FRAME_MS = 1000 / 30;
|
|
23
|
+
|
|
24
|
+
/** The list member equal to `value`, if any (attribute values are untrusted). */
|
|
25
|
+
const oneOf = <T extends string>(list: readonly T[], value: string | null): T | undefined => list.find((item) => item === value);
|
|
26
|
+
|
|
27
|
+
const toNumber = (value: string | null): number | undefined => {
|
|
28
|
+
const parsed = value === null ? Number.NaN : Number(value);
|
|
29
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The attributes `<dg-avatar>` watches. */
|
|
33
|
+
export const AVATAR_ATTRIBUTES = ['look', 'size', 'frame', 'facing', 'expression', 'look-x', 'look-y', 'animate', 'title', 'background', 'body', 'status'] as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Registers `<dg-avatar>` (or another tag). Safe to call more than once;
|
|
37
|
+
* a no-op outside a browser.
|
|
38
|
+
*/
|
|
39
|
+
export function defineAvatarElement(tag = 'dg-avatar'): void {
|
|
40
|
+
if (typeof customElements === 'undefined' || customElements.get(tag)) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class DgAvatar extends HTMLElement {
|
|
45
|
+
static readonly observedAttributes = AVATAR_ATTRIBUTES;
|
|
46
|
+
private view: AvatarElement | null = null;
|
|
47
|
+
private anim: Animator | null = null;
|
|
48
|
+
private raf = 0;
|
|
49
|
+
private last = 0;
|
|
50
|
+
private base = 'neutral';
|
|
51
|
+
|
|
52
|
+
/** The animator driving this element (created on first use). */
|
|
53
|
+
get animator(): Animator {
|
|
54
|
+
this.anim ??= animator(this.getAttribute('look') ?? '', { seed: this.getAttribute('look') ?? 'dg' });
|
|
55
|
+
return this.anim;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private options(): RenderOptions {
|
|
59
|
+
const background = this.getAttribute('background');
|
|
60
|
+
const frame = oneOf(FRAMES, this.getAttribute('frame'));
|
|
61
|
+
const facing = oneOf(FACINGS, this.getAttribute('facing'));
|
|
62
|
+
const title = this.getAttribute('title');
|
|
63
|
+
return {
|
|
64
|
+
size: toNumber(this.getAttribute('size')) ?? 48,
|
|
65
|
+
...(frame ? { frame } : {}),
|
|
66
|
+
...(facing ? { facing } : {}),
|
|
67
|
+
...(title ? { title } : {}),
|
|
68
|
+
...(background === 'none' || background === 'badge' ? { background } : background?.startsWith('#') ? { background: `#${background.slice(1)}` } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private stillPose(): Pose {
|
|
73
|
+
const expression = this.getAttribute('expression') ?? '';
|
|
74
|
+
const body = oneOf(BODIES, this.getAttribute('body'));
|
|
75
|
+
const status = oneOf(STATUSES, this.getAttribute('status'));
|
|
76
|
+
return {
|
|
77
|
+
...(isExpression(expression) ? { expression } : {}),
|
|
78
|
+
look: { x: toNumber(this.getAttribute('look-x')) ?? 0, y: toNumber(this.getAttribute('look-y')) ?? 0 },
|
|
79
|
+
...(body ? { body } : {}),
|
|
80
|
+
...(status ? { status } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
connectedCallback(): void {
|
|
85
|
+
this.style.display ||= 'inline-block';
|
|
86
|
+
this.style.lineHeight ||= '0';
|
|
87
|
+
this.view = renderElement(this.getAttribute('look') ?? '', this.options(), this.ownerDocument);
|
|
88
|
+
this.replaceChildren(this.view.el);
|
|
89
|
+
this.view.update(this.stillPose());
|
|
90
|
+
void ready();
|
|
91
|
+
this.loop();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
disconnectedCallback(): void {
|
|
95
|
+
cancelAnimationFrame(this.raf);
|
|
96
|
+
this.raf = 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
attributeChangedCallback(name: string): void {
|
|
100
|
+
if (!this.view) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (name === 'look') {
|
|
104
|
+
const look = this.getAttribute('look') ?? '';
|
|
105
|
+
this.view.setLook(look);
|
|
106
|
+
this.anim?.setLook(look);
|
|
107
|
+
} else {
|
|
108
|
+
this.view.setOptions(this.options());
|
|
109
|
+
}
|
|
110
|
+
this.view.update(this.stillPose());
|
|
111
|
+
this.loop();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private loop(): void {
|
|
115
|
+
if (!this.hasAttribute('animate') || this.raf !== 0 || !this.isConnected) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const tick = (t: number): void => {
|
|
119
|
+
this.raf = 0;
|
|
120
|
+
if (!this.isConnected || !this.hasAttribute('animate')) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (t - this.last >= FRAME_MS) {
|
|
124
|
+
this.last = t;
|
|
125
|
+
const expression = this.getAttribute('expression') ?? 'neutral';
|
|
126
|
+
if (expression !== this.base && isExpression(expression)) {
|
|
127
|
+
this.base = expression;
|
|
128
|
+
this.animator.setBase(expression, { at: t });
|
|
129
|
+
}
|
|
130
|
+
const still = this.stillPose();
|
|
131
|
+
this.view?.update({ ...this.animator.pose(t), ...(still.body ? { body: still.body } : {}), ...(still.status ? { status: still.status } : {}) });
|
|
132
|
+
}
|
|
133
|
+
this.raf = requestAnimationFrame(tick);
|
|
134
|
+
};
|
|
135
|
+
this.raf = requestAnimationFrame(tick);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
customElements.define(tag, DgAvatar);
|
|
140
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `render.element`: a live DOM avatar. `update(pose)` re-renders only when
|
|
3
|
+
* the quantised pose changes, which is cheap enough for a dozen avatars at
|
|
4
|
+
* 30 fps in DOM games.
|
|
5
|
+
*/
|
|
6
|
+
import { onArtReady } from '../art-store';
|
|
7
|
+
import { toCode } from '../look-like';
|
|
8
|
+
import type { LookLike, Pose, RenderOptions } from '../render/types';
|
|
9
|
+
import { renderKey } from './pose-key';
|
|
10
|
+
import { renderSvg } from './svg';
|
|
11
|
+
|
|
12
|
+
/** A live avatar element. */
|
|
13
|
+
export interface AvatarElement {
|
|
14
|
+
/** The element to insert (an inline-block `<span>` holding the `<svg>`). */
|
|
15
|
+
readonly el: HTMLElement;
|
|
16
|
+
/** Shows a new pose (an animator's `pose(t)`). */
|
|
17
|
+
update(pose: Pose): void;
|
|
18
|
+
/** Swaps the look (a player changed their avatar). */
|
|
19
|
+
setLook(like: LookLike): void;
|
|
20
|
+
/** Changes size, frame, facing or other options. */
|
|
21
|
+
setOptions(opts: RenderOptions): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A live avatar element; it upgrades itself from the silhouette when the art loads. */
|
|
25
|
+
export function renderElement(like: LookLike, opts: RenderOptions = {}, doc: Document = document): AvatarElement {
|
|
26
|
+
const el = doc.createElement('span');
|
|
27
|
+
el.className = 'dg-avatar';
|
|
28
|
+
el.style.display = 'inline-block';
|
|
29
|
+
el.style.lineHeight = '0';
|
|
30
|
+
let look = like;
|
|
31
|
+
let options = opts;
|
|
32
|
+
let key = '';
|
|
33
|
+
const draw = (isForced = false): void => {
|
|
34
|
+
const next = renderKey(toCode(look), options);
|
|
35
|
+
if (!isForced && next === key) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
key = next;
|
|
39
|
+
el.innerHTML = renderSvg(look, options);
|
|
40
|
+
};
|
|
41
|
+
draw(true);
|
|
42
|
+
onArtReady(() => { draw(true); });
|
|
43
|
+
return {
|
|
44
|
+
el,
|
|
45
|
+
update(pose) {
|
|
46
|
+
options = { ...options, pose };
|
|
47
|
+
draw();
|
|
48
|
+
},
|
|
49
|
+
setLook(next) {
|
|
50
|
+
look = next;
|
|
51
|
+
draw(true);
|
|
52
|
+
},
|
|
53
|
+
setOptions(next) {
|
|
54
|
+
options = { ...next, ...(options.pose ? { pose: options.pose } : {}) };
|
|
55
|
+
draw(true);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A canvas that holds one avatar and redraws only when the quantised pose,
|
|
3
|
+
* look or options change. The Pixi and three.js adapters upload it as a
|
|
4
|
+
* texture after each redraw.
|
|
5
|
+
*/
|
|
6
|
+
import { toCode } from '../look-like';
|
|
7
|
+
import { pixelSize } from '../render/frame';
|
|
8
|
+
import type { LookLike, Pose, RenderOptions } from '../render/types';
|
|
9
|
+
import { renderCanvas } from './canvas';
|
|
10
|
+
import type { Ctx2D } from './canvas-paint';
|
|
11
|
+
import { renderKey } from './pose-key';
|
|
12
|
+
|
|
13
|
+
/** A live avatar canvas. */
|
|
14
|
+
export interface LiveCanvas {
|
|
15
|
+
readonly canvas: HTMLCanvasElement | OffscreenCanvas;
|
|
16
|
+
/** Redraws for a pose; `true` when the pixels changed (upload the texture). */
|
|
17
|
+
draw(pose: Pose | undefined): boolean;
|
|
18
|
+
setLook(like: LookLike): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeCanvas(w: number, h: number): { canvas: HTMLCanvasElement | OffscreenCanvas; ctx: Ctx2D | null } {
|
|
22
|
+
if (typeof document !== 'undefined') {
|
|
23
|
+
const canvas = document.createElement('canvas');
|
|
24
|
+
canvas.width = w;
|
|
25
|
+
canvas.height = h;
|
|
26
|
+
return { canvas, ctx: canvas.getContext('2d') };
|
|
27
|
+
}
|
|
28
|
+
const canvas = new OffscreenCanvas(w, h);
|
|
29
|
+
return { canvas, ctx: canvas.getContext('2d') };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A live canvas at `opts.size` × `resolution` device pixels. */
|
|
33
|
+
export function liveCanvas(like: LookLike, opts: RenderOptions, resolution = 2): LiveCanvas {
|
|
34
|
+
const scaled = { ...opts, size: (opts.size ?? 64) * resolution };
|
|
35
|
+
const { width, height } = pixelSize(scaled);
|
|
36
|
+
const { canvas, ctx } = makeCanvas(Math.ceil(width), Math.ceil(height));
|
|
37
|
+
let look = like;
|
|
38
|
+
let key = '';
|
|
39
|
+
return {
|
|
40
|
+
canvas,
|
|
41
|
+
draw(pose) {
|
|
42
|
+
const options = { ...scaled, ...(pose ? { pose } : {}) };
|
|
43
|
+
const next = renderKey(toCode(look), options);
|
|
44
|
+
if (next === key || !ctx) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
key = next;
|
|
48
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
49
|
+
renderCanvas(ctx, look, { ...options, x: 0, y: 0 });
|
|
50
|
+
return true;
|
|
51
|
+
},
|
|
52
|
+
setLook(next) {
|
|
53
|
+
look = next;
|
|
54
|
+
key = '';
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** An overlay as a standalone `<svg>` (for DOM surfaces) or painted on a canvas at its anchor. */
|
|
2
|
+
import { n } from '../geom';
|
|
3
|
+
import type { Overlay } from '../overlays';
|
|
4
|
+
import { paintNodes, type Ctx2D } from './canvas-paint';
|
|
5
|
+
import { sceneToSvg } from './svg-markup';
|
|
6
|
+
import { nextPrefix } from './svg';
|
|
7
|
+
|
|
8
|
+
/** The overlay as an `<svg>` of its own size (decorative). */
|
|
9
|
+
export function overlaySvg(overlay: Overlay, scale = 1): string {
|
|
10
|
+
const [left, top, w, h] = overlay.box;
|
|
11
|
+
const { body, defs } = sceneToSvg(overlay.nodes, nextPrefix({}));
|
|
12
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${n(left)} ${n(top)} ${n(w)} ${n(h)}" width="${n(w * scale)}" height="${n(h * scale)}" aria-hidden="true">${defs ? `<defs>${defs}</defs>` : ''}${body}</svg>`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Paints an overlay with its anchor at (x, y). */
|
|
16
|
+
export function paintOverlay(ctx: Ctx2D, overlay: Overlay, atX: number, atY: number, scale = 1): void {
|
|
17
|
+
ctx.save();
|
|
18
|
+
ctx.translate(atX, atY);
|
|
19
|
+
ctx.scale(scale, scale);
|
|
20
|
+
paintNodes(ctx, overlay.nodes);
|
|
21
|
+
ctx.restore();
|
|
22
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** A compact key for a pose and options, quantised so tiny changes don't re-render or re-cache. */
|
|
2
|
+
import type { FaceParams } from '../face';
|
|
3
|
+
import type { Pose, RenderOptions } from '../render/types';
|
|
4
|
+
|
|
5
|
+
const q = (v: number | undefined, step: number): string => (v === undefined ? '' : String(Math.round(v / step)));
|
|
6
|
+
|
|
7
|
+
function faceKey(face: FaceParams | undefined): string {
|
|
8
|
+
if (!face) {
|
|
9
|
+
return '';
|
|
10
|
+
}
|
|
11
|
+
const m = face.mouth;
|
|
12
|
+
return [face.lidL, face.lidR, face.lowL, face.lowR, face.browL.dy, face.browL.tilt, face.browR.dy, face.browR.tilt, m.open, m.sL, m.sR, m.w, m.round, face.cheeks, face.headTilt]
|
|
13
|
+
.map((v) => q(v, 0.05)).join(',') + face.extras.join('');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The pose part of a cache key. */
|
|
17
|
+
export function poseKey(pose: Pose | undefined): string {
|
|
18
|
+
if (!pose) {
|
|
19
|
+
return '-';
|
|
20
|
+
}
|
|
21
|
+
return [
|
|
22
|
+
pose.expression ?? '', faceKey(pose.face), q(pose.look?.x, 0.05), q(pose.look?.y, 0.05), q(pose.blink, 0.1), q(pose.talk, 0.1),
|
|
23
|
+
pose.body ?? '', q(pose.phase, 0.125), pose.status ?? '', q(pose.bob?.x, 0.25), q(pose.bob?.y, 0.25), q(pose.tilt, 0.5), q(pose.squash, 0.005),
|
|
24
|
+
].join('|');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The full render key for caches: code, pose bucket, size, frame, facing and background. */
|
|
28
|
+
export function renderKey(code: string, opts: RenderOptions): string {
|
|
29
|
+
return [code, poseKey(opts.pose), String(opts.size ?? 48), opts.frame ?? 'head', opts.facing ?? opts.view ?? 'S', opts.background ?? '', opts.rim ? 'r' : '', opts.stool ? 's' : '', opts.shadow === undefined ? '' : String(opts.shadow)].join('~');
|
|
30
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene → SVG markup. Ids for clips and the rim filter are prefixed per call,
|
|
3
|
+
* so many avatars can be inlined on one page. The output holds only numbers,
|
|
4
|
+
* derived colours and escaped overlay text, so it is safe to insert as HTML.
|
|
5
|
+
*/
|
|
6
|
+
import { n } from '../geom';
|
|
7
|
+
import type { Clip, Paint, SceneNode, TfOp } from '../scene';
|
|
8
|
+
|
|
9
|
+
/** The rim filter: a dilated, soft light edge behind the silhouette. */
|
|
10
|
+
export const RIM_COLOUR = '#aab3c7';
|
|
11
|
+
|
|
12
|
+
const FONTS = { mono: "'Space Mono', monospace", pixel: "'VT323', monospace" } as const;
|
|
13
|
+
|
|
14
|
+
/** Three decimals, for scales and opacities. */
|
|
15
|
+
const fine = (v: number): string => String(Math.round(v * 1000) / 1000);
|
|
16
|
+
|
|
17
|
+
/** Escapes text for SVG content. */
|
|
18
|
+
export function escapeXml(text: string): string {
|
|
19
|
+
return text.replace(/[&<>"']/g, (c) => `&#${String(c.charCodeAt(0))};`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A transform list as an SVG `transform` attribute (empty for none). */
|
|
23
|
+
export function tfAttr(tf: readonly TfOp[] | undefined): string {
|
|
24
|
+
if (!tf || tf.length === 0) {
|
|
25
|
+
return '';
|
|
26
|
+
}
|
|
27
|
+
const parts = tf.map((op) => {
|
|
28
|
+
switch (op.k) {
|
|
29
|
+
case 't': return `translate(${n(op.x)} ${n(op.y)})`;
|
|
30
|
+
case 's': return `scale(${fine(op.x)} ${fine(op.y)})`;
|
|
31
|
+
case 'r': return `rotate(${n(op.a)})`;
|
|
32
|
+
case 'skx': return `skewX(${n(op.a)})`;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
return ` transform="${parts.join(' ')}"`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function paintAttrs(p: Paint): string {
|
|
39
|
+
let out = ` fill="${p.fill ?? 'none'}"`;
|
|
40
|
+
if (p.evenOdd) {
|
|
41
|
+
out += ' fill-rule="evenodd"';
|
|
42
|
+
}
|
|
43
|
+
if (p.fillOpacity !== undefined) {
|
|
44
|
+
out += ` fill-opacity="${fine(p.fillOpacity)}"`;
|
|
45
|
+
}
|
|
46
|
+
if (p.stroke && p.stroke !== 'none') {
|
|
47
|
+
out += ` stroke="${p.stroke}" stroke-width="${n(p.sw ?? 1)}" stroke-linejoin="round" stroke-linecap="${p.cap ?? 'round'}"`;
|
|
48
|
+
}
|
|
49
|
+
if (p.opacity !== undefined && p.opacity < 1) {
|
|
50
|
+
out += ` opacity="${fine(p.opacity)}"`;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Collects `<defs>` while serialising. */
|
|
56
|
+
class Writer {
|
|
57
|
+
readonly defs: string[] = [];
|
|
58
|
+
private count = 0;
|
|
59
|
+
usesRim = false;
|
|
60
|
+
private readonly prefix: string;
|
|
61
|
+
|
|
62
|
+
constructor(prefix: string) {
|
|
63
|
+
this.prefix = prefix;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
id(kind: string): string {
|
|
67
|
+
this.count += 1;
|
|
68
|
+
return `${this.prefix}${kind}${this.count.toString(36)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
clip(clip: Clip): string {
|
|
72
|
+
const id = this.id('c');
|
|
73
|
+
const rule = clip.evenOdd ? ' clip-rule="evenodd"' : '';
|
|
74
|
+
this.defs.push(`<clipPath id="${id}">${clip.shapes.map((s) => this.node(s).replace('<path ', `<path${rule} `)).join('')}</clipPath>`);
|
|
75
|
+
return id;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
node(node: SceneNode): string {
|
|
79
|
+
switch (node.k) {
|
|
80
|
+
case 'g': {
|
|
81
|
+
let attrs = tfAttr(node.tf);
|
|
82
|
+
if (node.clip) {
|
|
83
|
+
attrs += ` clip-path="url(#${this.clip(node.clip)})"`;
|
|
84
|
+
}
|
|
85
|
+
if (node.opacity !== undefined && node.opacity < 1) {
|
|
86
|
+
attrs += ` opacity="${fine(node.opacity)}"`;
|
|
87
|
+
}
|
|
88
|
+
if (node.rim) {
|
|
89
|
+
this.usesRim = true;
|
|
90
|
+
attrs += ` filter="url(#${this.prefix}rim)"`;
|
|
91
|
+
}
|
|
92
|
+
const inner = node.children.map((child) => this.node(child)).join('');
|
|
93
|
+
return attrs === '' ? inner : `<g${attrs}>${inner}</g>`;
|
|
94
|
+
}
|
|
95
|
+
case 'path': return `<path d="${node.d}"${tfAttr(node.tf)}${paintAttrs(node)}/>`;
|
|
96
|
+
case 'ellipse': return node.rx === node.ry
|
|
97
|
+
? `<circle cx="${n(node.cx)}" cy="${n(node.cy)}" r="${n(node.rx)}"${tfAttr(node.tf)}${paintAttrs(node)}/>`
|
|
98
|
+
: `<ellipse cx="${n(node.cx)}" cy="${n(node.cy)}" rx="${n(node.rx)}" ry="${n(node.ry)}"${tfAttr(node.tf)}${paintAttrs(node)}/>`;
|
|
99
|
+
case 'rect': return `<rect x="${n(node.x)}" y="${n(node.y)}" width="${n(node.w)}" height="${n(node.h)}"${node.r ? ` rx="${n(node.r)}"` : ''}${tfAttr(node.tf)}${paintAttrs(node)}/>`;
|
|
100
|
+
case 'text': return `<text x="${n(node.x)}" y="${n(node.y)}" font-family="${FONTS[node.font]}" font-size="${n(node.size)}"${node.bold ? ' font-weight="700"' : ''}${node.anchor === 'middle' ? ' text-anchor="middle"' : ''} paint-order="stroke"${paintAttrs(node)}>${escapeXml(node.text)}</text>`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A scene as SVG markup plus the `<defs>` it needs. */
|
|
106
|
+
export function sceneToSvg(nodes: readonly SceneNode[], prefix: string): { readonly body: string; readonly defs: string } {
|
|
107
|
+
const writer = new Writer(prefix);
|
|
108
|
+
const body = nodes.map((node) => writer.node(node)).join('');
|
|
109
|
+
if (writer.usesRim) {
|
|
110
|
+
writer.defs.push(`<filter id="${prefix}rim" x="-20%" y="-20%" width="140%" height="140%"><feMorphology in="SourceAlpha" operator="dilate" radius="2.8" result="d"/><feFlood flood-color="${RIM_COLOUR}" flood-opacity=".48"/><feComposite in2="d" operator="in"/><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter>`);
|
|
111
|
+
}
|
|
112
|
+
return { body, defs: writer.defs.join('') };
|
|
113
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `render.svg`: a complete `<svg>` string. Decorative (`aria-hidden`) unless
|
|
3
|
+
* a `title` is given, then `role="img"` with that accessible name (§3.9).
|
|
4
|
+
* Safe to insert as HTML: only catalog paths, derived colours and escaped
|
|
5
|
+
* text.
|
|
6
|
+
*/
|
|
7
|
+
import type { AvatarLook } from '@diffenderfer-games/hub-contract/avatar';
|
|
8
|
+
import { n } from '../geom';
|
|
9
|
+
import { toLook } from '../look-like';
|
|
10
|
+
import { composeAvatar, type Composed } from '../render/compose';
|
|
11
|
+
import { frameBox, framed, pixelSize } from '../render/frame';
|
|
12
|
+
import type { LookLike, RenderOptions } from '../render/types';
|
|
13
|
+
import { escapeXml, sceneToSvg } from './svg-markup';
|
|
14
|
+
|
|
15
|
+
let calls = 0;
|
|
16
|
+
|
|
17
|
+
/** The id prefix for one render: the caller's `idPrefix` as given (deterministic), else a fresh one per call. */
|
|
18
|
+
export function nextPrefix(opts: RenderOptions): string {
|
|
19
|
+
if (opts.idPrefix !== undefined) {
|
|
20
|
+
return opts.idPrefix.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
21
|
+
}
|
|
22
|
+
calls += 1;
|
|
23
|
+
return `dga${calls.toString(36)}-`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The `<svg>` for an already composed avatar. */
|
|
27
|
+
export function svgFromComposed(composed: Composed, look: AvatarLook | null, opts: RenderOptions): string {
|
|
28
|
+
const [left, top, w, h] = frameBox(opts);
|
|
29
|
+
const { width, height } = pixelSize(opts);
|
|
30
|
+
const { body, defs } = sceneToSvg(framed(composed.nodes, look, opts), nextPrefix(opts));
|
|
31
|
+
const a11y = opts.title ? ` role="img" aria-label="${escapeXml(opts.title)}"` : ' aria-hidden="true"';
|
|
32
|
+
const title = opts.title ? `<title>${escapeXml(opts.title)}</title>` : '';
|
|
33
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${String(left)} ${String(top)} ${String(w)} ${String(h)}" width="${n(width)}" height="${n(height)}"${a11y}>`
|
|
34
|
+
+ `${title}${defs ? `<defs>${defs}</defs>` : ''}${body}</svg>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** An avatar as SVG markup. */
|
|
38
|
+
export function renderSvg(like: LookLike, opts: RenderOptions = {}): string {
|
|
39
|
+
const look = toLook(like);
|
|
40
|
+
return svgFromComposed(composeAvatar(look, opts), look, opts);
|
|
41
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlays drawn above an avatar (§3.11, §14.3), as scene nodes in screen
|
|
3
|
+
* pixels with the origin at the anchor they sit on (`nameplate`, `bubble`,
|
|
4
|
+
* `shadow`). Every surface draws them the same way. Text is a username or a
|
|
5
|
+
* catalog title, never player-typed bubble text, and is escaped on output.
|
|
6
|
+
*/
|
|
7
|
+
import { ellipse, g, line, path, rect, rot, tr, type SceneNode } from './scene';
|
|
8
|
+
|
|
9
|
+
/** An overlay: nodes around its anchor, plus its size for culling and layout. */
|
|
10
|
+
export interface Overlay {
|
|
11
|
+
readonly nodes: readonly SceneNode[];
|
|
12
|
+
readonly width: number;
|
|
13
|
+
readonly height: number;
|
|
14
|
+
/** Its bounds around the anchor: x, y, width, height. */
|
|
15
|
+
readonly box: readonly [number, number, number, number];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const INK = '#0d0f14';
|
|
19
|
+
const PAPER = '#e8e6df';
|
|
20
|
+
const MONO_ADVANCE = 0.6;
|
|
21
|
+
|
|
22
|
+
const textWidth = (text: string, size: number): number => Array.from(text).length * size * MONO_ADVANCE;
|
|
23
|
+
|
|
24
|
+
/** A name tag: the username in a pill; friends get a green dot and a ★ (never colour alone), you get an accent outline. */
|
|
25
|
+
export function nameTag(o: { readonly name: string; readonly friend?: boolean; readonly you?: boolean; readonly muted?: boolean }): Overlay {
|
|
26
|
+
const size = 12;
|
|
27
|
+
const lead = o.friend ? 22 : 0;
|
|
28
|
+
const w = textWidth(o.name, size) + 16 + lead;
|
|
29
|
+
const h = 19;
|
|
30
|
+
const nodes: SceneNode[] = [
|
|
31
|
+
rect(-w / 2, -h, w, h, { fill: INK, fillOpacity: 0.86, stroke: o.you ? '#ff5f3b' : '#2a3142', sw: o.you ? 2 : 1.2 }, h / 2),
|
|
32
|
+
];
|
|
33
|
+
if (o.friend) {
|
|
34
|
+
nodes.push(
|
|
35
|
+
ellipse(-w / 2 + 10, -h / 2, 3.6, 3.6, { fill: '#4ade80' }),
|
|
36
|
+
{ k: 'text', x: -w / 2 + 19, y: -5, text: '★', size: 11, font: 'mono', anchor: 'middle', fill: '#ffd23f' },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
nodes.push({ k: 'text', x: lead / 2, y: -5.5, text: o.name, size, font: 'mono', bold: true, anchor: 'middle', fill: o.muted ? '#6f7787' : PAPER });
|
|
40
|
+
return { nodes, width: w, height: h, box: [-w / 2, -h, w, h] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A small game glyph for the activity bubble. */
|
|
44
|
+
export type ActivityIcon = 'cards' | 'controller' | 'puzzle' | 'race';
|
|
45
|
+
|
|
46
|
+
function icon(kind: ActivityIcon): SceneNode {
|
|
47
|
+
switch (kind) {
|
|
48
|
+
case 'cards':
|
|
49
|
+
return g([
|
|
50
|
+
rect(-6, -8, 12, 16, { fill: '#ff5f3b', stroke: INK, sw: 1.6 }, 2.5),
|
|
51
|
+
g([rect(-1, -11, 12, 16, { fill: '#3d7bff', stroke: INK, sw: 1.6 }, 2.5)], { tf: [rot(18)] }),
|
|
52
|
+
], { tf: [rot(-10)] });
|
|
53
|
+
case 'controller':
|
|
54
|
+
return path('M-9 -2C-9 -7 9 -7 9 -2C11 6 6 8 3 4L-3 4C-6 8 -11 6 -9 -2Z', { fill: '#2a3142', stroke: INK, sw: 1.6 });
|
|
55
|
+
case 'puzzle':
|
|
56
|
+
return path('M-7 -7H-1C-1 -11 4 -11 4 -7H7V-1C11 -1 11 4 7 4V7H-7Z', { fill: '#4ade80', stroke: INK, sw: 1.6 });
|
|
57
|
+
case 'race':
|
|
58
|
+
return path('M-7 -8V8M-7 -8H7L4 -4L7 0H-7', { fill: '#ffd23f', stroke: INK, sw: 1.6 });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** "Playing Uno": the game's catalog title in a light bubble with a tail. */
|
|
63
|
+
export function activityBubble(o: { readonly game: string; readonly icon?: ActivityIcon; readonly verb?: string }): Overlay {
|
|
64
|
+
const text = `${o.verb ?? 'Playing'} ${o.game}`;
|
|
65
|
+
const w = textWidth(text, 12) + 40;
|
|
66
|
+
const h = 26;
|
|
67
|
+
return {
|
|
68
|
+
nodes: [
|
|
69
|
+
rect(-w / 2, -h - 7, w, h, { fill: PAPER, stroke: INK, sw: 2 }, 8),
|
|
70
|
+
path('M-6 -7.5L0 0L6 -7.5Z', { fill: PAPER, stroke: INK, sw: 2 }),
|
|
71
|
+
rect(-7, -10, 14, 4, { fill: PAPER }),
|
|
72
|
+
g([icon(o.icon ?? 'cards')], { tf: [tr(-w / 2 + 16, -h / 2 - 7)] }),
|
|
73
|
+
{ k: 'text', x: 10, y: -h / 2 - 2.5, text, size: 12, font: 'mono', bold: true, anchor: 'middle', fill: INK },
|
|
74
|
+
],
|
|
75
|
+
width: w,
|
|
76
|
+
height: h + 7,
|
|
77
|
+
box: [-w / 2, -h - 7, w, h + 7],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** An emote popping above the head (the hub's quick-chat emotes). */
|
|
82
|
+
export function emoteBubble(emote: string): Overlay {
|
|
83
|
+
const glyph = emote.startsWith('e:') ? emote.slice(2) : emote;
|
|
84
|
+
return {
|
|
85
|
+
nodes: [
|
|
86
|
+
ellipse(0, -24, 20, 17, { fill: PAPER, stroke: INK, sw: 2 }),
|
|
87
|
+
path('M-5 -9L0 0L5 -9Z', { fill: PAPER, stroke: INK, sw: 2 }),
|
|
88
|
+
ellipse(0, -10, 5, 2.4, { fill: PAPER }),
|
|
89
|
+
{ k: 'text', x: 0, y: -17, text: glyph, size: 20, font: 'mono', anchor: 'middle', fill: INK },
|
|
90
|
+
],
|
|
91
|
+
width: 40,
|
|
92
|
+
height: 42,
|
|
93
|
+
box: [-21, -42, 42, 42],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Small state glyphs: `!`, `?`, `…` (typing), `zzz`, `♥`. */
|
|
98
|
+
export type ReactionGlyph = '!' | '?' | '…' | 'zzz' | '♥';
|
|
99
|
+
|
|
100
|
+
/** A reaction glyph in a small bubble. */
|
|
101
|
+
export function reactionBubble(glyph: ReactionGlyph): Overlay {
|
|
102
|
+
const colour = glyph === '♥' ? '#ff5f8f' : glyph === '!' ? '#ff5f3b' : INK;
|
|
103
|
+
return {
|
|
104
|
+
nodes: [
|
|
105
|
+
rect(-15, -30, 30, 24, { fill: PAPER, stroke: INK, sw: 2 }, 9),
|
|
106
|
+
path('M-4 -6.5L0 0L4 -6.5Z', { fill: PAPER, stroke: INK, sw: 2 }),
|
|
107
|
+
rect(-5, -9, 10, 4, { fill: PAPER }),
|
|
108
|
+
{ k: 'text', x: 0, y: -12, text: glyph, size: glyph === 'zzz' ? 12 : 17, font: 'mono', bold: true, anchor: 'middle', fill: colour },
|
|
109
|
+
],
|
|
110
|
+
width: 30,
|
|
111
|
+
height: 30,
|
|
112
|
+
box: [-16, -31, 32, 31],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The hover or selection ring at the feet (`radius` in px). */
|
|
117
|
+
export function selectionRing(o: { readonly radius?: number; readonly selected?: boolean } = {}): Overlay {
|
|
118
|
+
const r = o.radius ?? 18;
|
|
119
|
+
return {
|
|
120
|
+
nodes: [ellipse(0, 0, r, r * 0.3, line(o.selected ? '#ff5f3b' : '#e8e6df', o.selected ? 2.4 : 1.6, 'round', 0.85))],
|
|
121
|
+
width: r * 2,
|
|
122
|
+
height: r * 0.6,
|
|
123
|
+
box: [-r - 2, -r * 0.3 - 2, r * 2 + 4, r * 0.6 + 4],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Every overlay builder, as the `overlays` namespace. */
|
|
128
|
+
export const overlays = { nameTag, activityBubble, emoteBubble, reactionBubble, selectionRing };
|