@hadoku/prefs-client 1.1.11 → 1.1.12
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/README.md +40 -0
- package/dist/avatar.d.ts +19 -1
- package/dist/avatar.js +7 -1
- package/dist/cosmetics-contract-Cqim3RuG.d.ts +158 -0
- package/dist/cosmetics.d.ts +63 -0
- package/dist/cosmetics.js +499 -0
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -169,3 +169,43 @@ Rules for an app that renders avatars:
|
|
|
169
169
|
- Resolve on join and on reconnect; avatars change rarely. `resolveAvatars` batches past 100 ids.
|
|
170
170
|
- Source choice, character choice, linking (`linkInPopup`: no page navigation), uploads and
|
|
171
171
|
removal all happen in the settings popout. Don't build your own.
|
|
172
|
+
|
|
173
|
+
## Avatar cosmetics (`@wolffm/prefs-client/cosmetics`)
|
|
174
|
+
|
|
175
|
+
What a person wears with their avatar: a name **effect**, a name **gradient**, a name **font**, an
|
|
176
|
+
avatar **overlay** (an image the person can move, turn and resize, or a drawn ring or glow), and a
|
|
177
|
+
**message** reveal for their chat lines. Items are earned or gifted, never bought; one per slot.
|
|
178
|
+
People equip them in the settings popout's avatar dialog. Every resolved avatar carries what is
|
|
179
|
+
worn as `cosmetics` (`ResolvedAvatar.cosmetics`, only items the person owns).
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import {
|
|
183
|
+
ensureCosmeticsStyles,
|
|
184
|
+
colourStyle,
|
|
185
|
+
nameHtml,
|
|
186
|
+
avatarHtml,
|
|
187
|
+
messageHtml,
|
|
188
|
+
} from '@wolffm/prefs-client/cosmetics';
|
|
189
|
+
|
|
190
|
+
ensureCosmeticsStyles(); // once: the stylesheet, and the clean-up that ends a message reveal
|
|
191
|
+
const worn = person.cosmetics; // from resolveAvatars() or the owner's settings
|
|
192
|
+
row.style.cssText = colourStyle(character.color); // every text colour derives from this
|
|
193
|
+
row.innerHTML =
|
|
194
|
+
avatarHtml({ src: pictureUrl, size: 40, worn }) +
|
|
195
|
+
nameHtml(person.name, worn) +
|
|
196
|
+
messageHtml(text, live ? worn : undefined); // reveal only messages arriving live
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Rules for an app that renders them:
|
|
200
|
+
|
|
201
|
+
- **Colour comes from the character.** Put the character's colour in `--hc-base` on the row
|
|
202
|
+
(`colourStyle`). Never give a name effect or gradient its own colour.
|
|
203
|
+
- **Reveal live messages only**, never history or a scroll-back load. The reveal ends as plain text.
|
|
204
|
+
- **Pause what is off screen**: toggle `hc-idle` on rows an IntersectionObserver reports hidden.
|
|
205
|
+
- **Leave room for overlays.** An overlay may reach about a third of the avatar's size above it
|
|
206
|
+
and a quarter to each side (`PLACEMENT_ZONE`); don't clip the avatar's box.
|
|
207
|
+
- Images and fonts load same-origin from `/cosmetics/` (hadoku_site `public/cosmetics`); a font
|
|
208
|
+
downloads only when someone wears it.
|
|
209
|
+
- Adding an item: `COSMETICS` in `cosmetics-contract.ts`, its CSS in `cosmetics.ts`, an image in
|
|
210
|
+
`public/cosmetics/art/`. `test/cosmetics.test.ts` fails if any of the three is missing, and if a
|
|
211
|
+
loop animates anything but transform or opacity.
|
package/dist/avatar.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { A as AvatarCosmetics, C as CosmeticsView, L as LoadoutUpdate } from './cosmetics-contract-Cqim3RuG.js';
|
|
2
|
+
export { a as CosmeticSlot, b as Loadout, P as Placement } from './cosmetics-contract-Cqim3RuG.js';
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Avatars — the wire contract, shared by prefs-api and the client.
|
|
3
6
|
*
|
|
@@ -7,6 +10,7 @@
|
|
|
7
10
|
*
|
|
8
11
|
* Design: hadoku_site docs/planning/avatars-2026-10-30.md
|
|
9
12
|
*/
|
|
13
|
+
|
|
10
14
|
declare const AVATAR_SOURCES: readonly ["sprite", "discord", "steam", "upload"];
|
|
11
15
|
type AvatarSource = (typeof AVATAR_SOURCES)[number];
|
|
12
16
|
declare const LINK_PROVIDERS: readonly ["discord", "steam"];
|
|
@@ -32,6 +36,12 @@ interface ResolvedAvatar {
|
|
|
32
36
|
source: AvatarSource;
|
|
33
37
|
url?: string;
|
|
34
38
|
sprite?: string;
|
|
39
|
+
/**
|
|
40
|
+
* What the person wears (effect, gradient, font, overlay, message reveal,
|
|
41
|
+
* and the overlay's placement): only items they own. Absent when nothing is
|
|
42
|
+
* worn. Render it with `@wolffm/prefs-client/cosmetics`.
|
|
43
|
+
*/
|
|
44
|
+
cosmetics?: AvatarCosmetics;
|
|
35
45
|
}
|
|
36
46
|
/** PUT /api/v1/_avatar — either or both. */
|
|
37
47
|
interface AvatarUpdate {
|
|
@@ -74,6 +84,8 @@ interface AvatarSettings {
|
|
|
74
84
|
/** Whether each provider is configured on the server. A false one cannot be linked yet. */
|
|
75
85
|
providers: Record<LinkProvider, boolean>;
|
|
76
86
|
limits: AvatarUploadLimits;
|
|
87
|
+
/** What the owner has earned and what they wear. */
|
|
88
|
+
cosmetics: CosmeticsView;
|
|
77
89
|
}
|
|
78
90
|
interface AvatarUploadLimits {
|
|
79
91
|
maxBytes: number;
|
|
@@ -158,6 +170,12 @@ declare function createAvatarClient(opts?: AvatarClientOptions): {
|
|
|
158
170
|
/** Upload an image (PNG, JPEG, WebP or GIF). It is re-encoded and becomes the chosen source. */
|
|
159
171
|
upload: (image: Blob) => Promise<AvatarSettings>;
|
|
160
172
|
removeUpload: () => Promise<AvatarSettings>;
|
|
173
|
+
/**
|
|
174
|
+
* Equip or take off items and place overlays. 409 for an item the person
|
|
175
|
+
* does not own; a placement is fitted to the allowed area before it is
|
|
176
|
+
* stored, so the returned settings show where it really landed.
|
|
177
|
+
*/
|
|
178
|
+
setCosmetics: (u: LoadoutUpdate) => Promise<AvatarSettings>;
|
|
161
179
|
};
|
|
162
180
|
type AvatarClient = ReturnType<typeof createAvatarClient>;
|
|
163
181
|
interface ResolveAvatarsOptions extends AvatarClientOptions {
|
|
@@ -172,4 +190,4 @@ interface ResolveAvatarsOptions extends AvatarClientOptions {
|
|
|
172
190
|
*/
|
|
173
191
|
declare function resolveAvatars(opts: ResolveAvatarsOptions, userIds: readonly string[]): Promise<Record<string, ResolvedPerson>>;
|
|
174
192
|
|
|
175
|
-
export { AVATAR_SOURCES, type AvatarClient, type AvatarClientOptions, AvatarError, type AvatarSettings, type AvatarSource, type AvatarUpdate, type AvatarUploadLimits, LINK_CALLBACK_PATH, LINK_MESSAGE_TYPE, LINK_POPUP_MARK, LINK_PROVIDERS, type LinkMessage, type LinkProvider, type LinkStart, type LinkedAccountView, RESOLVE_MAX_IDS, type ResolveAvatarsOptions, type ResolveRequest, type ResolveResponse, type ResolvedAvatar, type ResolvedPerson, SPRITE_ID_PATTERN, type UploadView, createAvatarClient, resolveAvatars };
|
|
193
|
+
export { AVATAR_SOURCES, type AvatarClient, type AvatarClientOptions, AvatarCosmetics, AvatarError, type AvatarSettings, type AvatarSource, type AvatarUpdate, type AvatarUploadLimits, CosmeticsView, LINK_CALLBACK_PATH, LINK_MESSAGE_TYPE, LINK_POPUP_MARK, LINK_PROVIDERS, type LinkMessage, type LinkProvider, type LinkStart, type LinkedAccountView, LoadoutUpdate, RESOLVE_MAX_IDS, type ResolveAvatarsOptions, type ResolveRequest, type ResolveResponse, type ResolvedAvatar, type ResolvedPerson, SPRITE_ID_PATTERN, type UploadView, createAvatarClient, resolveAvatars };
|
package/dist/avatar.js
CHANGED
|
@@ -110,7 +110,13 @@ function createAvatarClient(opts = {}) {
|
|
|
110
110
|
unlink: (provider) => send(`/link/${provider}`, "DELETE"),
|
|
111
111
|
/** Upload an image (PNG, JPEG, WebP or GIF). It is re-encoded and becomes the chosen source. */
|
|
112
112
|
upload: (image) => call("/upload", { method: "POST", body: image }),
|
|
113
|
-
removeUpload: () => send("/upload", "DELETE")
|
|
113
|
+
removeUpload: () => send("/upload", "DELETE"),
|
|
114
|
+
/**
|
|
115
|
+
* Equip or take off items and place overlays. 409 for an item the person
|
|
116
|
+
* does not own; a placement is fitted to the allowed area before it is
|
|
117
|
+
* stored, so the returned settings show where it really landed.
|
|
118
|
+
*/
|
|
119
|
+
setCosmetics: (u) => send("/cosmetics", "PUT", u)
|
|
114
120
|
};
|
|
115
121
|
}
|
|
116
122
|
async function resolveAvatars(opts, userIds) {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Avatar cosmetics — the wire contract, shared by prefs-api and the client.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no fetch, no DOM. prefs-api imports this file directly (as it does
|
|
5
|
+
* avatar-contract.ts), so the server validates with the very catalogue and
|
|
6
|
+
* placement rules the client renders with. The renderer lives in ./cosmetics.ts.
|
|
7
|
+
*
|
|
8
|
+
* Rules the catalogue keeps (operator, 2026-11-01; design:
|
|
9
|
+
* docs/planning/avatar-cosmetics-2026-11-02.md):
|
|
10
|
+
* - Earned by achievement or gifted by the operator, never bought; no levels.
|
|
11
|
+
* - One item per slot.
|
|
12
|
+
* - Name effects, gradients and message reveals derive every colour from the
|
|
13
|
+
* person's character colour. Overlays may bring their own (the crown is gold).
|
|
14
|
+
* - Every loop animates only transform and opacity (cosmetics.test.ts).
|
|
15
|
+
*/
|
|
16
|
+
declare const COSMETIC_SLOTS: readonly ["effect", "gradient", "font", "overlay", "message"];
|
|
17
|
+
type CosmeticSlot = (typeof COSMETIC_SLOTS)[number];
|
|
18
|
+
/** `<slot>.<name>`, e.g. `overlay.crown`. */
|
|
19
|
+
declare const COSMETIC_ID_PATTERN: RegExp;
|
|
20
|
+
/** An overlay drawn from an image, which the person may move, turn and resize. */
|
|
21
|
+
interface OverlayArt {
|
|
22
|
+
/** Image file under the cosmetics asset base, `art/<file>`. */
|
|
23
|
+
file: string;
|
|
24
|
+
/** Default box, in % of the avatar's size: top edge and width. The image is square. */
|
|
25
|
+
top: number;
|
|
26
|
+
width: number;
|
|
27
|
+
/** The image's own tilt, degrees. */
|
|
28
|
+
tilt: number;
|
|
29
|
+
/** Pixel art: scaled without smoothing. */
|
|
30
|
+
pixel: boolean;
|
|
31
|
+
/** The visible (opaque) part of the image, as fractions: left, top, right, bottom. */
|
|
32
|
+
visible: readonly [number, number, number, number];
|
|
33
|
+
}
|
|
34
|
+
interface CosmeticItem {
|
|
35
|
+
id: string;
|
|
36
|
+
slot: CosmeticSlot;
|
|
37
|
+
label: string;
|
|
38
|
+
/** How it is earned, as the inventory shows it to someone who has not. */
|
|
39
|
+
earn: string;
|
|
40
|
+
art?: OverlayArt;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Every item that exists. Adding one: add it here, give it CSS in
|
|
44
|
+
* cosmetics.ts, and (for an overlay image) put the file in
|
|
45
|
+
* public/cosmetics/art/. An id never changes once granted: it is stored in
|
|
46
|
+
* people's inventories.
|
|
47
|
+
*/
|
|
48
|
+
declare const COSMETICS: readonly CosmeticItem[];
|
|
49
|
+
declare const COSMETICS_BY_ID: ReadonlyMap<string, CosmeticItem>;
|
|
50
|
+
/**
|
|
51
|
+
* A person's adjustment of an overlay image, relative to its default spot:
|
|
52
|
+
* x/y in % of the avatar's size, r in degrees, s in % of the default size.
|
|
53
|
+
*/
|
|
54
|
+
interface Placement {
|
|
55
|
+
x: number;
|
|
56
|
+
y: number;
|
|
57
|
+
r: number;
|
|
58
|
+
s: number;
|
|
59
|
+
flip: boolean;
|
|
60
|
+
/** Drawn behind the avatar picture instead of over it. */
|
|
61
|
+
back: boolean;
|
|
62
|
+
}
|
|
63
|
+
declare const PLACEMENT_DEFAULT: Placement;
|
|
64
|
+
/** Hard ranges, before the zone. */
|
|
65
|
+
declare const PLACEMENT_LIMITS: {
|
|
66
|
+
readonly x: readonly [-60, 60];
|
|
67
|
+
readonly y: readonly [-60, 100];
|
|
68
|
+
readonly r: readonly [-45, 45];
|
|
69
|
+
readonly s: readonly [70, 140];
|
|
70
|
+
};
|
|
71
|
+
interface Box {
|
|
72
|
+
left: number;
|
|
73
|
+
top: number;
|
|
74
|
+
right: number;
|
|
75
|
+
bottom: number;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Where the visible part of an overlay image lands, in % of the avatar
|
|
79
|
+
* (0..100 is the avatar's own box), after its tilt and the person's
|
|
80
|
+
* placement. The renderer applies the same transforms in the same order:
|
|
81
|
+
* the image tilts about its centre; the placement box then scales and turns
|
|
82
|
+
* about 50% / 60% of the image and moves.
|
|
83
|
+
*/
|
|
84
|
+
declare function overlayBounds(art: OverlayArt, p: Placement): Box;
|
|
85
|
+
/**
|
|
86
|
+
* Where an overlay's visible edges may go, in % of the avatar. The ceiling is
|
|
87
|
+
* the highest any image sits by default (the crown): higher bleeds into the
|
|
88
|
+
* chat message above. Down to just below the character is fine. Sideways a
|
|
89
|
+
* quarter of the avatar, which keeps it off the name and the message text.
|
|
90
|
+
*/
|
|
91
|
+
declare const PLACEMENT_ZONE: Box;
|
|
92
|
+
/**
|
|
93
|
+
* Bring any placement inside the limits and the zone: clamp each value, shrink
|
|
94
|
+
* the image if it cannot fit at all, then slide it back in. Integers out.
|
|
95
|
+
* The editor calls this on every move and the server on every write, so what
|
|
96
|
+
* one accepts the other renders.
|
|
97
|
+
*/
|
|
98
|
+
declare function fitPlacement(art: OverlayArt, raw: Partial<Placement>): Placement;
|
|
99
|
+
/** What is equipped in each slot (null = nothing), and where each overlay image sits. */
|
|
100
|
+
interface Loadout {
|
|
101
|
+
equipped: Record<CosmeticSlot, string | null>;
|
|
102
|
+
/** By overlay item id. Kept for items not currently equipped, so swapping back keeps the spot. */
|
|
103
|
+
placement: Record<string, Placement>;
|
|
104
|
+
}
|
|
105
|
+
declare function emptyLoadout(): Loadout;
|
|
106
|
+
/**
|
|
107
|
+
* What a person wears, as every app renders it (ResolvedAvatar.cosmetics):
|
|
108
|
+
* the equipped item ids by slot, and the placement of the equipped overlay
|
|
109
|
+
* when it is an image. Only items the person owns; absent when nothing is worn.
|
|
110
|
+
*/
|
|
111
|
+
interface AvatarCosmetics {
|
|
112
|
+
effect?: string;
|
|
113
|
+
gradient?: string;
|
|
114
|
+
font?: string;
|
|
115
|
+
overlay?: string;
|
|
116
|
+
message?: string;
|
|
117
|
+
placement?: Placement;
|
|
118
|
+
}
|
|
119
|
+
/** In AvatarSettings: what the owner has earned and what they wear. */
|
|
120
|
+
interface CosmeticsView {
|
|
121
|
+
/** Item ids, in catalogue order. */
|
|
122
|
+
owned: string[];
|
|
123
|
+
loadout: Loadout;
|
|
124
|
+
}
|
|
125
|
+
/** PUT /api/v1/_avatar/cosmetics. Either or both. */
|
|
126
|
+
interface LoadoutUpdate {
|
|
127
|
+
/** Per slot: an owned item id of that slot, or null to take it off. */
|
|
128
|
+
equip?: Partial<Record<CosmeticSlot, string | null>>;
|
|
129
|
+
/** Per owned overlay image id. Fitted to the zone before it is stored. */
|
|
130
|
+
placement?: Record<string, Partial<Placement>>;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* A stored loadout made safe to serve: unknown or unowned items dropped (an
|
|
134
|
+
* item can be revoked), every placement refitted. Run on every read, so a
|
|
135
|
+
* catalogue or zone change applies to rows already stored.
|
|
136
|
+
*/
|
|
137
|
+
declare function cleanLoadout(raw: unknown, owned: ReadonlySet<string>): Loadout;
|
|
138
|
+
/** The public half of a clean loadout. Undefined when nothing is worn. */
|
|
139
|
+
declare function wornCosmetics(loadout: Loadout): AvatarCosmetics | undefined;
|
|
140
|
+
/** Admin: POST /api/v1/admin/cosmetics/grant and /revoke. */
|
|
141
|
+
interface CosmeticGrantRequest {
|
|
142
|
+
/** The person's registry name (resolved by the registry; a raw userId is never accepted, R5). */
|
|
143
|
+
name: string;
|
|
144
|
+
items: string[];
|
|
145
|
+
/** Why, for the record (e.g. "won the October Major"). Grant only. */
|
|
146
|
+
note?: string;
|
|
147
|
+
}
|
|
148
|
+
/** Admin: GET /api/v1/admin/cosmetics?name= */
|
|
149
|
+
interface CosmeticGrantList {
|
|
150
|
+
grants: {
|
|
151
|
+
item: string;
|
|
152
|
+
source: 'gift' | 'achievement';
|
|
153
|
+
note: string | null;
|
|
154
|
+
grantedAt: number;
|
|
155
|
+
}[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export { type AvatarCosmetics as A, type Box as B, type CosmeticsView as C, type LoadoutUpdate as L, type OverlayArt as O, type Placement as P, type CosmeticSlot as a, type Loadout as b, COSMETICS as c, COSMETICS_BY_ID as d, COSMETIC_ID_PATTERN as e, COSMETIC_SLOTS as f, type CosmeticGrantList as g, type CosmeticGrantRequest as h, type CosmeticItem as i, PLACEMENT_DEFAULT as j, PLACEMENT_LIMITS as k, PLACEMENT_ZONE as l, cleanLoadout as m, emptyLoadout as n, fitPlacement as o, overlayBounds as p, wornCosmetics as w };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { A as AvatarCosmetics, P as Placement } from './cosmetics-contract-Cqim3RuG.js';
|
|
2
|
+
export { B as Box, c as COSMETICS, d as COSMETICS_BY_ID, e as COSMETIC_ID_PATTERN, f as COSMETIC_SLOTS, g as CosmeticGrantList, h as CosmeticGrantRequest, i as CosmeticItem, a as CosmeticSlot, C as CosmeticsView, b as Loadout, L as LoadoutUpdate, O as OverlayArt, j as PLACEMENT_DEFAULT, k as PLACEMENT_LIMITS, l as PLACEMENT_ZONE, m as cleanLoadout, n as emptyLoadout, o as fitPlacement, p as overlayBounds, w as wornCosmetics } from './cosmetics-contract-Cqim3RuG.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Avatar cosmetics — the renderer every app uses, so a name, an avatar and a
|
|
6
|
+
* chat line look the same in watchparty, the games and the settings dialog.
|
|
7
|
+
*
|
|
8
|
+
* Framework-free: builders return HTML strings, and one stylesheet does the
|
|
9
|
+
* rest. Call `ensureCosmeticsStyles()` once, then put the strings in the page
|
|
10
|
+
* (React: `dangerouslySetInnerHTML`; every user-supplied string is escaped).
|
|
11
|
+
*
|
|
12
|
+
* The person's character colour goes in `--hc-base` on the element or an
|
|
13
|
+
* ancestor (`colourStyle(hex)`); every text colour is derived from it.
|
|
14
|
+
*
|
|
15
|
+
* Performance rules (cosmetics.test.ts enforces the first two):
|
|
16
|
+
* - Loops animate only transform and opacity, so they run on the compositor.
|
|
17
|
+
* - A message reveal fills `backwards` only, and the page swaps its letter
|
|
18
|
+
* spans back to plain text when it ends: a chat history holds no animations.
|
|
19
|
+
* - Pause what is off screen: add `hc-idle` to an ancestor (an
|
|
20
|
+
* IntersectionObserver per row is enough) and every loop under it stops.
|
|
21
|
+
*
|
|
22
|
+
* Assets (images, fonts) are served same-origin from `/cosmetics/`
|
|
23
|
+
* (hadoku_site public/cosmetics). A font downloads only when an item uses it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
declare const COSMETICS_ASSET_BASE = "/cosmetics";
|
|
27
|
+
/** `--hc-base:<hex>` for a style attribute, or '' when the colour is not a 6-digit hex. */
|
|
28
|
+
declare function colourStyle(hex: string | null | undefined): string;
|
|
29
|
+
/** The CSS transform for a person's placement of an overlay image. */
|
|
30
|
+
declare function placementTransform(p: Placement): string;
|
|
31
|
+
/**
|
|
32
|
+
* A display name with its effect, gradient and font. `large` is the profile
|
|
33
|
+
* card size; the default fits a chat line.
|
|
34
|
+
*/
|
|
35
|
+
declare function nameHtml(text: string, worn?: AvatarCosmetics, opts?: {
|
|
36
|
+
large?: boolean;
|
|
37
|
+
}): string;
|
|
38
|
+
/**
|
|
39
|
+
* The decoration layers for an avatar: put them inside the avatar's box
|
|
40
|
+
* (`avatarHtml` does), which must be `position: relative` and carry `--hc-sz`.
|
|
41
|
+
*/
|
|
42
|
+
declare function decorHtml(worn?: AvatarCosmetics, assetBase?: string): string;
|
|
43
|
+
/** A round avatar picture with its decorations. `size` in px. */
|
|
44
|
+
declare function avatarHtml(o: {
|
|
45
|
+
src: string | null;
|
|
46
|
+
size: number;
|
|
47
|
+
worn?: AvatarCosmetics;
|
|
48
|
+
assetBase?: string;
|
|
49
|
+
}): string;
|
|
50
|
+
/**
|
|
51
|
+
* A chat line with the sender's message reveal. Play it only for a message
|
|
52
|
+
* arriving live, never for history. Screen readers get the whole line at once.
|
|
53
|
+
* Pass `keep` for a preview that should keep its spans (to replay it).
|
|
54
|
+
*/
|
|
55
|
+
declare function messageHtml(text: string, worn?: AvatarCosmetics, opts?: {
|
|
56
|
+
keep?: boolean;
|
|
57
|
+
}): string;
|
|
58
|
+
/** The whole stylesheet, with asset URLs under `assetBase`. */
|
|
59
|
+
declare function cosmeticsCss(assetBase?: string): string;
|
|
60
|
+
/** Add the stylesheet and the reveal clean-up to a document, once. */
|
|
61
|
+
declare function ensureCosmeticsStyles(doc?: Document, assetBase?: string): void;
|
|
62
|
+
|
|
63
|
+
export { AvatarCosmetics, COSMETICS_ASSET_BASE, Placement, avatarHtml, colourStyle, cosmeticsCss, decorHtml, ensureCosmeticsStyles, messageHtml, nameHtml, placementTransform };
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
// src/cosmetics-contract.ts
|
|
2
|
+
var COSMETIC_SLOTS = ["effect", "gradient", "font", "overlay", "message"];
|
|
3
|
+
var COSMETIC_ID_PATTERN = /^(effect|gradient|font|overlay|message)\.[a-z0-9-]{1,32}$/;
|
|
4
|
+
var item = (slot, name, label, earn, art) => ({ id: `${slot}.${name}`, slot, label, earn, ...art ? { art } : {} });
|
|
5
|
+
var COSMETICS = [
|
|
6
|
+
item("effect", "glint", "Glint", "Win a cardtable match"),
|
|
7
|
+
item("effect", "aura", "Aura", "Attend 10 watchparties"),
|
|
8
|
+
item("effect", "hop", "Hop", "Win 25 bomberman rounds"),
|
|
9
|
+
item("effect", "outline", "Outline", "Finish 3 tournaments"),
|
|
10
|
+
item("effect", "twinkle", "Twinkle", "Top four in a Major"),
|
|
11
|
+
item("gradient", "gild", "Gilded", "Your first win in any game"),
|
|
12
|
+
item("gradient", "gloss", "Gloss", "Win 10 games"),
|
|
13
|
+
item("gradient", "split", "Split", "Finish 5 tournaments"),
|
|
14
|
+
item("gradient", "brushed", "Brushed", "Play 100 games"),
|
|
15
|
+
item("gradient", "lumen", "Lumen", "Host 5 watchparties"),
|
|
16
|
+
item("font", "caps", "Small caps", "Finish a tournament"),
|
|
17
|
+
item("font", "wide", "Wide", "Finish 5 tournaments"),
|
|
18
|
+
item("font", "script", "Script", "Attend 25 watchparties"),
|
|
19
|
+
item("font", "surf", "Surf", "Attend a summer watchparty"),
|
|
20
|
+
item("font", "gothic", "Gothic", "Win an arcane-set game"),
|
|
21
|
+
item("font", "runic", "Runic", "Win a fantasy-set game"),
|
|
22
|
+
item("font", "corsair", "Corsair", "Win 3 matches in one night"),
|
|
23
|
+
item("font", "metal", "Metal", "Win a Major final"),
|
|
24
|
+
item("font", "comic", "Comic", "Win from behind"),
|
|
25
|
+
item("font", "circuit", "Circuit", "Play 25 cyber-set games"),
|
|
26
|
+
item("font", "hover", "Hover", "Win a race"),
|
|
27
|
+
item("font", "retro", "Retro", "Play for a year"),
|
|
28
|
+
item("font", "arcade", "Arcade", "Play 50 bomberman games"),
|
|
29
|
+
item("font", "pixel", "Pixel", "Win 10 bomberman rounds"),
|
|
30
|
+
item("overlay", "crown", "Crown", "Win a Major", {
|
|
31
|
+
file: "crown.webp",
|
|
32
|
+
top: -40,
|
|
33
|
+
width: 70,
|
|
34
|
+
tilt: -8,
|
|
35
|
+
pixel: false,
|
|
36
|
+
visible: [0.027, 0.137, 0.973, 0.863]
|
|
37
|
+
}),
|
|
38
|
+
item("overlay", "halo", "Halo", "Help a new player (gift)", {
|
|
39
|
+
file: "halo.webp",
|
|
40
|
+
top: -34,
|
|
41
|
+
width: 76,
|
|
42
|
+
tilt: 0,
|
|
43
|
+
pixel: true,
|
|
44
|
+
visible: [0.023, 0.316, 0.969, 0.684]
|
|
45
|
+
}),
|
|
46
|
+
item("overlay", "horns", "Horns", "Win 10 games as the villain", {
|
|
47
|
+
file: "horns.webp",
|
|
48
|
+
top: -26,
|
|
49
|
+
width: 100,
|
|
50
|
+
tilt: 0,
|
|
51
|
+
pixel: true,
|
|
52
|
+
visible: [0.023, 0.129, 0.969, 0.871]
|
|
53
|
+
}),
|
|
54
|
+
item("overlay", "flowers", "Flower crown", "The anniversary of joining", {
|
|
55
|
+
file: "flowers.webp",
|
|
56
|
+
top: -22,
|
|
57
|
+
width: 96,
|
|
58
|
+
tilt: 0,
|
|
59
|
+
pixel: false,
|
|
60
|
+
visible: [0.027, 0.258, 0.969, 0.746]
|
|
61
|
+
}),
|
|
62
|
+
item("overlay", "runes", "Runes", "Finish a Major"),
|
|
63
|
+
item("overlay", "comet", "Comet", "A 10-game win streak"),
|
|
64
|
+
item("overlay", "shine", "Shine", "Win a cardtable match"),
|
|
65
|
+
item("overlay", "glow", "Glow", "Attend 25 watchparties"),
|
|
66
|
+
item("overlay", "petals", "Petals", "The anniversary of joining"),
|
|
67
|
+
item("message", "type", "Typewriter", "Send 1000 chat messages"),
|
|
68
|
+
item("message", "decode", "Decode", "Win 25 cyber-set games"),
|
|
69
|
+
item("message", "cascade", "Cascade", "Win 3 games in a row"),
|
|
70
|
+
item("message", "words", "Word by word", "Send 100 chat messages"),
|
|
71
|
+
item("message", "bubble", "Bubble", "Host 3 watchparties"),
|
|
72
|
+
item("message", "drift", "Drift", "Attend 20 watchparties")
|
|
73
|
+
];
|
|
74
|
+
var COSMETICS_BY_ID = new Map(
|
|
75
|
+
COSMETICS.map((i) => [i.id, i])
|
|
76
|
+
);
|
|
77
|
+
var PLACEMENT_DEFAULT = { x: 0, y: 0, r: 0, s: 100, flip: false, back: false };
|
|
78
|
+
var PLACEMENT_LIMITS = {
|
|
79
|
+
x: [-60, 60],
|
|
80
|
+
y: [-60, 100],
|
|
81
|
+
r: [-45, 45],
|
|
82
|
+
s: [70, 140]
|
|
83
|
+
};
|
|
84
|
+
function overlayBounds(art, p) {
|
|
85
|
+
const [a, b, c, d] = art.visible;
|
|
86
|
+
const x0 = p.flip ? 1 - c : a;
|
|
87
|
+
const x1 = p.flip ? 1 - a : c;
|
|
88
|
+
const L = 50 - art.width / 2;
|
|
89
|
+
const T = art.top;
|
|
90
|
+
const W = art.width;
|
|
91
|
+
const ox = L + W * 0.5;
|
|
92
|
+
const oy = T + W * 0.6;
|
|
93
|
+
const cx = L + W / 2;
|
|
94
|
+
const cy = T + W / 2;
|
|
95
|
+
const t = art.tilt * Math.PI / 180;
|
|
96
|
+
const r = p.r * Math.PI / 180;
|
|
97
|
+
const sc = p.s / 100;
|
|
98
|
+
const xs = [];
|
|
99
|
+
const ys = [];
|
|
100
|
+
for (const [fx, fy] of [
|
|
101
|
+
[x0, b],
|
|
102
|
+
[x1, b],
|
|
103
|
+
[x0, d],
|
|
104
|
+
[x1, d]
|
|
105
|
+
]) {
|
|
106
|
+
const px = L + fx * W;
|
|
107
|
+
const py = T + fy * W;
|
|
108
|
+
const tx = cx + (px - cx) * Math.cos(t) - (py - cy) * Math.sin(t);
|
|
109
|
+
const ty = cy + (px - cx) * Math.sin(t) + (py - cy) * Math.cos(t);
|
|
110
|
+
const dx = (tx - ox) * sc;
|
|
111
|
+
const dy = (ty - oy) * sc;
|
|
112
|
+
xs.push(ox + p.x + dx * Math.cos(r) - dy * Math.sin(r));
|
|
113
|
+
ys.push(oy + p.y + dx * Math.sin(r) + dy * Math.cos(r));
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
left: Math.min(...xs),
|
|
117
|
+
top: Math.min(...ys),
|
|
118
|
+
right: Math.max(...xs),
|
|
119
|
+
bottom: Math.max(...ys)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
var ART_ITEMS = COSMETICS.filter((i) => !!i.art);
|
|
123
|
+
var PLACEMENT_ZONE = {
|
|
124
|
+
left: -25,
|
|
125
|
+
top: Math.floor(Math.min(...ART_ITEMS.map((i) => overlayBounds(i.art, PLACEMENT_DEFAULT).top))),
|
|
126
|
+
right: 125,
|
|
127
|
+
bottom: 110
|
|
128
|
+
};
|
|
129
|
+
var clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v));
|
|
130
|
+
function fitPlacement(art, raw) {
|
|
131
|
+
const num = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
132
|
+
const p = {
|
|
133
|
+
x: Math.round(clamp(num(raw.x, 0), PLACEMENT_LIMITS.x)),
|
|
134
|
+
y: Math.round(clamp(num(raw.y, 0), PLACEMENT_LIMITS.y)),
|
|
135
|
+
r: Math.round(clamp(num(raw.r, 0), PLACEMENT_LIMITS.r)),
|
|
136
|
+
s: Math.round(clamp(num(raw.s, 100), PLACEMENT_LIMITS.s)),
|
|
137
|
+
flip: raw.flip === true,
|
|
138
|
+
back: raw.back === true
|
|
139
|
+
};
|
|
140
|
+
const z = PLACEMENT_ZONE;
|
|
141
|
+
let box = overlayBounds(art, p);
|
|
142
|
+
const room = Math.min(
|
|
143
|
+
(z.right - z.left - 2) / (box.right - box.left),
|
|
144
|
+
(z.bottom - z.top - 2) / (box.bottom - box.top)
|
|
145
|
+
);
|
|
146
|
+
if (room < 1) {
|
|
147
|
+
p.s = Math.max(PLACEMENT_LIMITS.s[0], Math.floor(p.s * room));
|
|
148
|
+
box = overlayBounds(art, p);
|
|
149
|
+
}
|
|
150
|
+
p.x += Math.ceil(Math.max(0, z.left - box.left)) - Math.ceil(Math.max(0, box.right - z.right));
|
|
151
|
+
p.y += Math.ceil(Math.max(0, z.top - box.top)) - Math.ceil(Math.max(0, box.bottom - z.bottom));
|
|
152
|
+
return p;
|
|
153
|
+
}
|
|
154
|
+
function emptyLoadout() {
|
|
155
|
+
return {
|
|
156
|
+
equipped: { effect: null, gradient: null, font: null, overlay: null, message: null },
|
|
157
|
+
placement: {}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function cleanLoadout(raw, owned) {
|
|
161
|
+
const out = emptyLoadout();
|
|
162
|
+
const src = raw && typeof raw === "object" ? raw : {};
|
|
163
|
+
for (const slot of COSMETIC_SLOTS) {
|
|
164
|
+
const id = src.equipped?.[slot];
|
|
165
|
+
if (typeof id === "string" && owned.has(id) && COSMETICS_BY_ID.get(id)?.slot === slot) {
|
|
166
|
+
out.equipped[slot] = id;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const [id, p] of Object.entries(src.placement ?? {})) {
|
|
170
|
+
const art = COSMETICS_BY_ID.get(id)?.art;
|
|
171
|
+
if (art && owned.has(id) && p && typeof p === "object") {
|
|
172
|
+
out.placement[id] = fitPlacement(art, p);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
function wornCosmetics(loadout) {
|
|
178
|
+
const worn = {};
|
|
179
|
+
for (const slot of COSMETIC_SLOTS) {
|
|
180
|
+
const id = loadout.equipped[slot];
|
|
181
|
+
if (id) worn[slot] = id;
|
|
182
|
+
}
|
|
183
|
+
const overlay = loadout.equipped.overlay;
|
|
184
|
+
if (overlay && COSMETICS_BY_ID.get(overlay)?.art && loadout.placement[overlay]) {
|
|
185
|
+
worn.placement = loadout.placement[overlay];
|
|
186
|
+
}
|
|
187
|
+
return Object.keys(worn).length ? worn : void 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/cosmetics.ts
|
|
191
|
+
var COSMETICS_ASSET_BASE = "/cosmetics";
|
|
192
|
+
var STYLE_ID = "hadoku-cosmetics";
|
|
193
|
+
var esc = (s) => s.replace(
|
|
194
|
+
/[&<>"']/g,
|
|
195
|
+
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]
|
|
196
|
+
);
|
|
197
|
+
var nameOf = (id) => id ? id.slice(id.indexOf(".") + 1) : "none";
|
|
198
|
+
function colourStyle(hex) {
|
|
199
|
+
return hex && /^#[0-9a-f]{6}$/i.test(hex) ? `--hc-base:${hex}` : "";
|
|
200
|
+
}
|
|
201
|
+
function placementTransform(p) {
|
|
202
|
+
const sx = p.s / 100 * (p.flip ? -1 : 1);
|
|
203
|
+
return `translate(calc(var(--hc-sz) * ${(p.x / 100).toFixed(3)}),calc(var(--hc-sz) * ${(p.y / 100).toFixed(3)})) rotate(${p.r}deg) scale(${sx.toFixed(3)},${(p.s / 100).toFixed(3)})`;
|
|
204
|
+
}
|
|
205
|
+
var SPLIT_EFFECTS = /* @__PURE__ */ new Set(["hop"]);
|
|
206
|
+
function nameHtml(text, worn, opts = {}) {
|
|
207
|
+
const fx = nameOf(worn?.effect);
|
|
208
|
+
const gr = nameOf(worn?.gradient);
|
|
209
|
+
const font = nameOf(worn?.font);
|
|
210
|
+
const t = esc(text);
|
|
211
|
+
const split = SPLIT_EFFECTS.has(fx);
|
|
212
|
+
const tx = split ? `<span class="hc-tx">${[...text].map(
|
|
213
|
+
(c, i) => `<span class="hc-ch" style="--hc-i:${i}"><span class="hc-c">${c === " " ? " " : esc(c)}</span></span>`
|
|
214
|
+
).join("")}</span>` : `<span class="hc-tx">${t}</span>`;
|
|
215
|
+
const layer = (cls2) => `<span class="hc-lyr${cls2}" aria-hidden="true">${t}</span>`;
|
|
216
|
+
let back = "";
|
|
217
|
+
let front = "";
|
|
218
|
+
if (fx === "aura" || fx === "outline") back += layer(" hc-g");
|
|
219
|
+
if (fx === "glint") front += `<span class="hc-win" aria-hidden="true">${layer("")}</span>`;
|
|
220
|
+
if (fx === "twinkle") {
|
|
221
|
+
front += [
|
|
222
|
+
[-6, -25, 0],
|
|
223
|
+
[52, 55, 0.95],
|
|
224
|
+
[94, -15, 1.9]
|
|
225
|
+
].map(
|
|
226
|
+
([x, y, d]) => `<i class="hc-tw" aria-hidden="true" style="left:${x}%;top:${y}%;animation-delay:${d}s"></i>`
|
|
227
|
+
).join("");
|
|
228
|
+
}
|
|
229
|
+
const cls = `hc-name hc-fx-${fx} hc-gr-${gr} hc-font-${font}${split ? " hc-split" : ""}${opts.large ? " hc-large" : ""}`;
|
|
230
|
+
return `<span class="${cls}">${back}${tx}${front}</span>`;
|
|
231
|
+
}
|
|
232
|
+
var RUNES_SVG = (() => {
|
|
233
|
+
let marks = "";
|
|
234
|
+
for (let i = 0; i < 12; i++) {
|
|
235
|
+
marks += `<g transform="rotate(${i * 30} 50 50)"><path d="M50 3 l2.5 4 -2.5 4 -2.5 -4z" fill="currentColor" opacity="${i % 3 ? 0.55 : 1}"/></g>`;
|
|
236
|
+
}
|
|
237
|
+
return `<svg viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="44" fill="none" stroke="currentColor" stroke-width="1" stroke-dasharray="3 5" opacity=".7"/>${marks}</svg>`;
|
|
238
|
+
})();
|
|
239
|
+
function decorHtml(worn, assetBase = COSMETICS_ASSET_BASE) {
|
|
240
|
+
const id = worn?.overlay;
|
|
241
|
+
if (!id) return "";
|
|
242
|
+
const art = COSMETICS_BY_ID.get(id)?.art;
|
|
243
|
+
if (art) {
|
|
244
|
+
const p = worn?.placement ?? PLACEMENT_DEFAULT;
|
|
245
|
+
return `<span class="hc-ol hc-art${art.pixel ? " hc-px" : ""}" data-hc-art="${esc(id)}" style="left:${50 - art.width / 2}%;top:${art.top}%;width:${art.width}%;z-index:${p.back ? 0 : 3}"><span class="hc-adj" style="transform:${placementTransform(p)}"><img alt="" draggable="false" style="--hc-tilt:${art.tilt}deg" src="${esc(`${assetBase}/art/${art.file}`)}"></span></span>`;
|
|
246
|
+
}
|
|
247
|
+
switch (nameOf(id)) {
|
|
248
|
+
case "runes":
|
|
249
|
+
return `<span class="hc-ol hc-runes">${RUNES_SVG}</span>`;
|
|
250
|
+
case "comet":
|
|
251
|
+
return `<span class="hc-ol hc-comet"></span>`;
|
|
252
|
+
case "shine":
|
|
253
|
+
return `<span class="hc-ol hc-clip"><span class="hc-band"></span></span>`;
|
|
254
|
+
case "glow":
|
|
255
|
+
return `<span class="hc-ol hc-glow"></span>`;
|
|
256
|
+
case "petals":
|
|
257
|
+
return [15, 40, 62, 85].map(
|
|
258
|
+
(l, i) => `<i class="hc-ol hc-petal" style="left:${l}%;animation-delay:${(i * 0.85).toFixed(2)}s"></i>`
|
|
259
|
+
).join("");
|
|
260
|
+
default:
|
|
261
|
+
return "";
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function avatarHtml(o) {
|
|
265
|
+
const pic = o.src ? `<img alt="" src="${esc(o.src)}">` : "";
|
|
266
|
+
return `<span class="hc-av" style="--hc-sz:${o.size}px"><span class="hc-pic">${pic}</span>${decorHtml(o.worn, o.assetBase)}</span>`;
|
|
267
|
+
}
|
|
268
|
+
var GLYPHS = "!<>-_/[]{}=+*^?#%&01";
|
|
269
|
+
var LETTER_MODES = /* @__PURE__ */ new Set(["type", "decode", "cascade"]);
|
|
270
|
+
var REVEAL_MS = 1400;
|
|
271
|
+
var LETTER_LIMIT = 160;
|
|
272
|
+
function messageHtml(text, worn, opts = {}) {
|
|
273
|
+
let mode = nameOf(worn?.message);
|
|
274
|
+
if (mode === "none") return `<span class="hc-msg">${esc(text)}</span>`;
|
|
275
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
276
|
+
if (LETTER_MODES.has(mode) && text.length > LETTER_LIMIT) mode = "words";
|
|
277
|
+
const keep = opts.keep ? " data-hc-keep" : "";
|
|
278
|
+
let body;
|
|
279
|
+
let step;
|
|
280
|
+
if (LETTER_MODES.has(mode)) {
|
|
281
|
+
step = Math.min(38, REVEAL_MS / Math.max(1, text.length));
|
|
282
|
+
let n = 0;
|
|
283
|
+
const rnd = () => GLYPHS[Math.floor(Math.random() * GLYPHS.length)];
|
|
284
|
+
body = words.map(
|
|
285
|
+
(w) => `<span class="hc-w">${[...w].map((c) => {
|
|
286
|
+
const i = n++;
|
|
287
|
+
return mode === "decode" ? `<span class="hc-ch" style="--hc-i:${i}" data-g="${esc(rnd())}" data-h="${esc(rnd())}"><span class="hc-r">${esc(c)}</span></span>` : `<span class="hc-ch" style="--hc-i:${i}">${esc(c)}</span>`;
|
|
288
|
+
}).join("")}</span>`
|
|
289
|
+
).join(" ");
|
|
290
|
+
if (mode === "type") body += `<span class="hc-caret" style="--hc-i:${n}"></span>`;
|
|
291
|
+
const lastClass = mode === "type" ? "hc-caret" : mode === "decode" ? "hc-r" : "hc-ch";
|
|
292
|
+
const at = body.lastIndexOf(`<span class="${lastClass}"`);
|
|
293
|
+
body = `${body.slice(0, at)}<span data-hc-last class="${lastClass}"${body.slice(at + `<span class="${lastClass}"`.length)}`;
|
|
294
|
+
} else {
|
|
295
|
+
step = Math.min(90, REVEAL_MS / Math.max(1, words.length));
|
|
296
|
+
body = words.map(
|
|
297
|
+
(w, i) => `<span class="hc-w" style="--hc-i:${i}"${i === words.length - 1 ? " data-hc-last" : ""}>${esc(w)}</span>`
|
|
298
|
+
).join(" ");
|
|
299
|
+
}
|
|
300
|
+
return `<span class="hc-msg hc-mt-${mode}" style="--hc-step:${step.toFixed(1)}ms" data-text="${esc(text)}"${keep}><span class="hc-sr">${esc(text)}</span><span aria-hidden="true">${body}</span></span>`;
|
|
301
|
+
}
|
|
302
|
+
function finishReveal(e) {
|
|
303
|
+
const t = e.target;
|
|
304
|
+
if (e.pseudoElement || !t?.hasAttribute?.("data-hc-last")) return;
|
|
305
|
+
const msg = t.closest(".hc-msg");
|
|
306
|
+
if (!msg || msg.hasAttribute("data-hc-keep")) return;
|
|
307
|
+
msg.className = "hc-msg";
|
|
308
|
+
msg.removeAttribute("style");
|
|
309
|
+
msg.textContent = msg.getAttribute("data-text") ?? "";
|
|
310
|
+
}
|
|
311
|
+
var FONTS = {
|
|
312
|
+
script: {
|
|
313
|
+
file: "caveat",
|
|
314
|
+
family: "Caveat",
|
|
315
|
+
generic: "cursive",
|
|
316
|
+
css: "font-weight:700;font-size:1.3em;line-height:1"
|
|
317
|
+
},
|
|
318
|
+
surf: {
|
|
319
|
+
file: "pacifico",
|
|
320
|
+
family: "Pacifico",
|
|
321
|
+
generic: "cursive",
|
|
322
|
+
css: "font-weight:400;font-size:1.02em;line-height:1.1"
|
|
323
|
+
},
|
|
324
|
+
gothic: {
|
|
325
|
+
file: "gothic",
|
|
326
|
+
family: "Grenze Gotisch",
|
|
327
|
+
generic: "serif",
|
|
328
|
+
css: "font-weight:600;font-size:1.15em"
|
|
329
|
+
},
|
|
330
|
+
runic: { file: "uncial", family: "Uncial Antiqua", generic: "serif", css: "font-weight:400" },
|
|
331
|
+
corsair: {
|
|
332
|
+
file: "pirata",
|
|
333
|
+
family: "Pirata One",
|
|
334
|
+
generic: "serif",
|
|
335
|
+
css: "font-weight:400;font-size:1.12em;letter-spacing:.02em"
|
|
336
|
+
},
|
|
337
|
+
metal: {
|
|
338
|
+
file: "metal",
|
|
339
|
+
family: "Metal Mania",
|
|
340
|
+
generic: "cursive",
|
|
341
|
+
css: "font-weight:400;font-size:1.12em;letter-spacing:.02em"
|
|
342
|
+
},
|
|
343
|
+
comic: {
|
|
344
|
+
file: "bangers",
|
|
345
|
+
family: "Bangers",
|
|
346
|
+
generic: "cursive",
|
|
347
|
+
css: "font-weight:400;font-size:1.15em;letter-spacing:.05em"
|
|
348
|
+
},
|
|
349
|
+
circuit: {
|
|
350
|
+
file: "orbitron",
|
|
351
|
+
family: "Orbitron",
|
|
352
|
+
generic: "sans-serif",
|
|
353
|
+
css: "font-weight:700;letter-spacing:.03em"
|
|
354
|
+
},
|
|
355
|
+
hover: {
|
|
356
|
+
file: "audiowide",
|
|
357
|
+
family: "Audiowide",
|
|
358
|
+
generic: "sans-serif",
|
|
359
|
+
css: "font-weight:400;font-size:.92em"
|
|
360
|
+
},
|
|
361
|
+
retro: { file: "righteous", family: "Righteous", generic: "sans-serif", css: "font-weight:400" },
|
|
362
|
+
arcade: {
|
|
363
|
+
file: "bungee",
|
|
364
|
+
family: "Bungee",
|
|
365
|
+
generic: "sans-serif",
|
|
366
|
+
css: "font-weight:400;font-size:.9em"
|
|
367
|
+
},
|
|
368
|
+
pixel: {
|
|
369
|
+
file: "pixel",
|
|
370
|
+
family: "Press Start 2P",
|
|
371
|
+
generic: "monospace",
|
|
372
|
+
css: "font-weight:400;font-size:.62em;line-height:1.6"
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
function cosmeticsCss(assetBase = COSMETICS_ASSET_BASE) {
|
|
376
|
+
const fontFaces = Object.entries(FONTS).map(
|
|
377
|
+
([k, f]) => `@font-face{font-family:"hc-${k}";src:url("${assetBase}/fonts/${f.file}.woff2") format("woff2");font-display:swap}`
|
|
378
|
+
).join("\n");
|
|
379
|
+
const fontRules = Object.entries(FONTS).map(([k, f]) => `.hc-font-${k}{font-family:"hc-${k}","${f.family}",${f.generic};${f.css}}`).join("\n");
|
|
380
|
+
return `${fontFaces}
|
|
381
|
+
/* Colours: everything derives from the character colour. */
|
|
382
|
+
.hc-name,.hc-av,.hc-msg{--hc-b:var(--hc-base,currentColor);--hc-hi:color-mix(in oklch,var(--hc-b),white 50%);--hc-lo:color-mix(in oklch,var(--hc-b),black 50%);--hc-glow:color-mix(in oklch,var(--hc-b) 75%,transparent)}
|
|
383
|
+
|
|
384
|
+
/* --- name ---
|
|
385
|
+
Bleed: a gradient is a background clipped to the letters, and a background covers only the box; script
|
|
386
|
+
swashes reach outside it. So the text box and every layer over it get room on all sides, cancelled by a
|
|
387
|
+
negative margin so nothing moves. */
|
|
388
|
+
.hc-name{--hc-bx:.4em;--hc-by:.3em;--hc-grad:none;position:relative;display:inline-block;white-space:nowrap;color:var(--hc-b);font-weight:700;line-height:1.25}
|
|
389
|
+
.hc-name.hc-large{font-size:1.6em}
|
|
390
|
+
.hc-name .hc-tx{position:relative;z-index:1;display:inline-block;padding:var(--hc-by) var(--hc-bx);margin:calc(-1 * var(--hc-by)) calc(-1 * var(--hc-bx))}
|
|
391
|
+
.hc-name .hc-lyr{position:absolute;left:calc(-1 * var(--hc-bx));top:calc(-1 * var(--hc-by));padding:var(--hc-by) var(--hc-bx);pointer-events:none;white-space:nowrap}
|
|
392
|
+
.hc-name .hc-win{position:absolute;inset:calc(-1 * var(--hc-by)) calc(-1 * var(--hc-bx));overflow:hidden}
|
|
393
|
+
.hc-name .hc-win .hc-lyr{left:0;top:0}
|
|
394
|
+
.hc-name .hc-ch,.hc-name .hc-c{display:inline-block}
|
|
395
|
+
.hc-gr-gild{--hc-grad:linear-gradient(180deg,var(--hc-hi) 0%,var(--hc-b) 55%,var(--hc-lo) 100%)}
|
|
396
|
+
.hc-gr-gloss{--hc-grad:linear-gradient(180deg,color-mix(in oklch,var(--hc-hi),white 35%) 0%,var(--hc-hi) 36%,color-mix(in oklch,var(--hc-hi),var(--hc-b)) 48%,var(--hc-b) 60%,var(--hc-lo) 100%)}
|
|
397
|
+
.hc-gr-split{--hc-grad:linear-gradient(180deg,var(--hc-hi) 0 52%,var(--hc-b) 52% 100%)}
|
|
398
|
+
.hc-gr-brushed{--hc-grad:repeating-linear-gradient(90deg,transparent 0 1px,color-mix(in oklch,var(--hc-hi) 40%,transparent) 1px 2px),linear-gradient(180deg,var(--hc-hi),var(--hc-b) 55%,var(--hc-lo))}
|
|
399
|
+
.hc-gr-lumen{--hc-grad:radial-gradient(ellipse 60% 90% at 50% 35%,var(--hc-hi),var(--hc-b) 55%,var(--hc-lo) 100%)}
|
|
400
|
+
.hc-name:not(.hc-gr-none) .hc-tx{background:var(--hc-grad);-webkit-background-clip:text;background-clip:text;color:transparent}
|
|
401
|
+
.hc-name.hc-split:not(.hc-gr-none) .hc-tx{background:none}
|
|
402
|
+
.hc-name.hc-split:not(.hc-gr-none) .hc-tx .hc-c{padding:var(--hc-by) var(--hc-bx);margin:calc(-1 * var(--hc-by)) calc(-1 * var(--hc-bx));background:var(--hc-grad);-webkit-background-clip:text;background-clip:text;color:transparent}
|
|
403
|
+
.hc-fx-glint .hc-win{z-index:3;animation:hc-win-go 4.5s cubic-bezier(.4,0,.2,1) infinite;-webkit-mask-image:linear-gradient(100deg,transparent 35%,#000 50%,transparent 65%);mask-image:linear-gradient(100deg,transparent 35%,#000 50%,transparent 65%)}
|
|
404
|
+
.hc-fx-glint .hc-win .hc-lyr{color:var(--hc-hi);animation:hc-win-back 4.5s cubic-bezier(.4,0,.2,1) infinite}
|
|
405
|
+
@keyframes hc-win-go{0%{transform:translateX(-100%)}55%,100%{transform:translateX(100%)}}
|
|
406
|
+
@keyframes hc-win-back{0%{transform:translateX(100%)}55%,100%{transform:translateX(-100%)}}
|
|
407
|
+
.hc-fx-aura .hc-lyr.hc-g{color:transparent;text-shadow:0 0 6px var(--hc-glow),0 0 14px var(--hc-glow);animation:hc-breathe 3s ease-in-out infinite}
|
|
408
|
+
.hc-fx-outline .hc-lyr.hc-g{color:transparent;-webkit-text-stroke:3px var(--hc-hi);animation:hc-breathe 3.6s ease-in-out infinite}
|
|
409
|
+
@keyframes hc-breathe{0%,100%{opacity:.25}50%{opacity:1}}
|
|
410
|
+
.hc-fx-hop .hc-c{animation:hc-hop 3.6s ease-in-out calc(var(--hc-i) * 45ms) infinite}
|
|
411
|
+
@keyframes hc-hop{0%,14%,100%{transform:none}5%{transform:translateY(-4px)}10%{transform:translateY(.5px)}}
|
|
412
|
+
.hc-fx-twinkle .hc-tw{position:absolute;width:7px;height:7px;z-index:3;pointer-events:none;background:linear-gradient(var(--hc-hi),var(--hc-hi)) center/1.4px 100% no-repeat,linear-gradient(90deg,var(--hc-hi),var(--hc-hi)) center/100% 1.4px no-repeat;animation:hc-star 2.8s ease-in-out infinite both}
|
|
413
|
+
@keyframes hc-star{0%,100%{opacity:0;transform:scale(.3) rotate(0deg)}25%{opacity:1;transform:scale(1) rotate(45deg)}50%{opacity:0;transform:scale(.3) rotate(90deg)}}
|
|
414
|
+
.hc-font-caps{font-variant:small-caps;letter-spacing:.08em}
|
|
415
|
+
.hc-font-wide{letter-spacing:.22em;font-weight:600;text-transform:uppercase;font-size:.82em}
|
|
416
|
+
${fontRules}
|
|
417
|
+
|
|
418
|
+
/* --- avatar --- */
|
|
419
|
+
.hc-av{--hc-sz:40px;position:relative;display:inline-block;width:var(--hc-sz);height:var(--hc-sz);flex:none;isolation:isolate;vertical-align:middle}
|
|
420
|
+
.hc-av .hc-pic{position:absolute;inset:0;border-radius:50%;overflow:hidden;z-index:1;background:color-mix(in oklch,var(--hc-b) 30%,transparent)}
|
|
421
|
+
.hc-av .hc-pic img{width:100%;height:100%;display:block;object-fit:cover;image-rendering:pixelated}
|
|
422
|
+
.hc-av .hc-ol{position:absolute;pointer-events:none}
|
|
423
|
+
.hc-ol.hc-art{display:block}
|
|
424
|
+
.hc-ol.hc-art .hc-adj{display:block;position:relative;transform-origin:50% 60%}
|
|
425
|
+
.hc-ol.hc-art img{display:block;width:100%;height:auto;filter:drop-shadow(0 1px 1px rgba(0,0,0,.45));animation:hc-art-bob 3.4s ease-in-out infinite;-webkit-user-drag:none;user-select:none}
|
|
426
|
+
.hc-ol.hc-art.hc-px img{image-rendering:pixelated}
|
|
427
|
+
@keyframes hc-art-bob{0%,100%{transform:rotate(var(--hc-tilt,0deg)) translateY(0)}50%{transform:rotate(var(--hc-tilt,0deg)) translateY(-2px)}}
|
|
428
|
+
.hc-ol.hc-runes{inset:-9px;z-index:2;color:var(--hc-hi);animation:hc-turn 14s linear infinite}
|
|
429
|
+
.hc-ol.hc-runes svg{width:100%;height:100%;display:block}
|
|
430
|
+
@keyframes hc-turn{to{transform:rotate(360deg)}}
|
|
431
|
+
.hc-ol.hc-comet{inset:-6px;z-index:2;border-radius:50%;background:conic-gradient(from 0deg,transparent 0 270deg,color-mix(in oklch,var(--hc-hi) 10%,transparent) 300deg,var(--hc-hi) 358deg,transparent 360deg);-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 3px),#000 calc(100% - 3px));mask:radial-gradient(farthest-side,transparent calc(100% - 3px),#000 calc(100% - 3px));animation:hc-turn 2.4s linear infinite}
|
|
432
|
+
.hc-ol.hc-clip{inset:0;border-radius:50%;overflow:hidden;z-index:2}
|
|
433
|
+
.hc-ol .hc-band{position:absolute;top:-30%;left:0;width:45%;height:160%;background:linear-gradient(90deg,transparent,color-mix(in oklch,var(--hc-hi) 70%,transparent),transparent);transform:translateX(-130%) rotate(20deg);animation:hc-band 5s ease-in-out infinite}
|
|
434
|
+
@keyframes hc-band{0%{transform:translateX(-130%) rotate(20deg)}45%,100%{transform:translateX(260%) rotate(20deg)}}
|
|
435
|
+
.hc-ol.hc-glow{inset:-30%;border-radius:50%;z-index:0;background:radial-gradient(circle,var(--hc-glow) 0,transparent 65%);animation:hc-glowp 3.2s ease-in-out infinite}
|
|
436
|
+
@keyframes hc-glowp{0%,100%{opacity:.35;transform:scale(.92)}50%{opacity:.9;transform:scale(1.04)}}
|
|
437
|
+
.hc-ol.hc-petal{top:-8%;height:100%;width:5px;z-index:2;animation:hc-fall 3.4s linear infinite both}
|
|
438
|
+
.hc-ol.hc-petal::after{content:"";position:absolute;left:0;top:0;width:5px;height:7px;border-radius:5px 0;background:#f7b6c8;transform:rotate(35deg)}
|
|
439
|
+
@keyframes hc-fall{0%{transform:translate(0,0);opacity:0}12%{opacity:.9}100%{transform:translate(-6px,100%);opacity:0}}
|
|
440
|
+
|
|
441
|
+
/* --- message reveal: fill backwards only; the page swaps back to plain text when it ends --- */
|
|
442
|
+
.hc-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
|
443
|
+
.hc-msg .hc-w{display:inline-block;white-space:nowrap}
|
|
444
|
+
.hc-msg .hc-ch{display:inline-block}
|
|
445
|
+
.hc-mt-type .hc-ch{animation:hc-in .01s linear calc(var(--hc-i) * var(--hc-step)) backwards}
|
|
446
|
+
.hc-mt-type .hc-caret{display:inline-block;width:2px;height:1em;margin-left:2px;vertical-align:-2px;background:var(--hc-b);opacity:0;animation:hc-caret 1.5s steps(1) calc(var(--hc-i) * var(--hc-step)) backwards}
|
|
447
|
+
@keyframes hc-caret{0%{opacity:0}1%{opacity:1}20%{opacity:0}40%{opacity:1}60%{opacity:0}80%{opacity:1}100%{opacity:0}}
|
|
448
|
+
.hc-mt-cascade .hc-ch{animation:hc-drop .35s cubic-bezier(.3,1.5,.5,1) calc(var(--hc-i) * var(--hc-step)) backwards}
|
|
449
|
+
.hc-mt-decode .hc-ch{position:relative}
|
|
450
|
+
.hc-mt-decode .hc-ch .hc-r{display:inline-block;animation:hc-in .01s linear calc(var(--hc-i) * var(--hc-step) + 260ms) backwards}
|
|
451
|
+
.hc-mt-decode .hc-ch::before,.hc-mt-decode .hc-ch::after{position:absolute;left:0;top:0;color:var(--hc-b);opacity:0;animation:hc-dec-a .26s steps(1) calc(var(--hc-i) * var(--hc-step)) backwards}
|
|
452
|
+
.hc-mt-decode .hc-ch::before{content:attr(data-g)}
|
|
453
|
+
.hc-mt-decode .hc-ch::after{content:attr(data-h);animation-name:hc-dec-b}
|
|
454
|
+
@keyframes hc-dec-a{0%{opacity:.85}50%,100%{opacity:0}}
|
|
455
|
+
@keyframes hc-dec-b{0%{opacity:0}50%{opacity:.85}100%{opacity:0}}
|
|
456
|
+
.hc-mt-words .hc-w{animation:hc-rise .4s ease-out calc(var(--hc-i) * var(--hc-step)) backwards}
|
|
457
|
+
.hc-mt-bubble .hc-w{animation:hc-pop .35s cubic-bezier(.3,1.6,.5,1) calc(var(--hc-i) * var(--hc-step)) backwards}
|
|
458
|
+
.hc-mt-drift .hc-w{animation:hc-slide .45s cubic-bezier(.2,.8,.2,1) calc(var(--hc-i) * var(--hc-step)) backwards}
|
|
459
|
+
@keyframes hc-in{from{opacity:0}to{opacity:1}}
|
|
460
|
+
@keyframes hc-drop{0%{transform:translateY(-10px);opacity:0}100%{transform:none;opacity:1}}
|
|
461
|
+
@keyframes hc-rise{0%{transform:translateY(10px);opacity:0}100%{transform:none;opacity:1}}
|
|
462
|
+
@keyframes hc-pop{0%{transform:scale(.6);opacity:0}100%{transform:scale(1);opacity:1}}
|
|
463
|
+
@keyframes hc-slide{0%{transform:translateX(-18px);opacity:0}100%{transform:none;opacity:1}}
|
|
464
|
+
|
|
465
|
+
.hc-idle .hc-name,.hc-idle .hc-name *,.hc-idle .hc-av *,.hc-idle .hc-av *::after{animation-play-state:paused!important}
|
|
466
|
+
@media (prefers-reduced-motion:reduce){.hc-name *,.hc-av *,.hc-av *::after,.hc-msg *,.hc-msg *::before,.hc-msg *::after{animation:none!important}}
|
|
467
|
+
`;
|
|
468
|
+
}
|
|
469
|
+
function ensureCosmeticsStyles(doc = document, assetBase = COSMETICS_ASSET_BASE) {
|
|
470
|
+
if (doc.getElementById(STYLE_ID)) return;
|
|
471
|
+
const style = doc.createElement("style");
|
|
472
|
+
style.id = STYLE_ID;
|
|
473
|
+
style.textContent = cosmeticsCss(assetBase);
|
|
474
|
+
doc.head.appendChild(style);
|
|
475
|
+
doc.addEventListener("animationend", finishReveal);
|
|
476
|
+
}
|
|
477
|
+
export {
|
|
478
|
+
COSMETICS,
|
|
479
|
+
COSMETICS_ASSET_BASE,
|
|
480
|
+
COSMETICS_BY_ID,
|
|
481
|
+
COSMETIC_ID_PATTERN,
|
|
482
|
+
COSMETIC_SLOTS,
|
|
483
|
+
PLACEMENT_DEFAULT,
|
|
484
|
+
PLACEMENT_LIMITS,
|
|
485
|
+
PLACEMENT_ZONE,
|
|
486
|
+
avatarHtml,
|
|
487
|
+
cleanLoadout,
|
|
488
|
+
colourStyle,
|
|
489
|
+
cosmeticsCss,
|
|
490
|
+
decorHtml,
|
|
491
|
+
emptyLoadout,
|
|
492
|
+
ensureCosmeticsStyles,
|
|
493
|
+
fitPlacement,
|
|
494
|
+
messageHtml,
|
|
495
|
+
nameHtml,
|
|
496
|
+
overlayBounds,
|
|
497
|
+
placementTransform,
|
|
498
|
+
wornCosmetics
|
|
499
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hadoku/prefs-client",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"
|
|
3
|
+
"version": "1.1.12",
|
|
4
|
+
"gitHead": "c159fe6d0b90efa7e3cc65c00bde50b3f17529fe",
|
|
5
|
+
"description": "Client SDK for the hadoku unified preferences store. Wraps /prefs/api/v1/* with localStorage caching, debounced writes, Zod-validated reads, and a usePrefs() React hook, plus match history, ratings and leaderboards for every game (./games), the one resolved avatar per user (./avatar), and the avatar cosmetics renderer (./cosmetics).",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"main": "./dist/index.js",
|
|
7
8
|
"types": "./dist/index.d.ts",
|
|
@@ -33,6 +34,10 @@
|
|
|
33
34
|
"./items": {
|
|
34
35
|
"types": "./dist/items.d.ts",
|
|
35
36
|
"default": "./dist/items.js"
|
|
37
|
+
},
|
|
38
|
+
"./cosmetics": {
|
|
39
|
+
"types": "./dist/cosmetics.d.ts",
|
|
40
|
+
"default": "./dist/cosmetics.js"
|
|
36
41
|
}
|
|
37
42
|
},
|
|
38
43
|
"files": [
|
|
@@ -62,7 +67,8 @@
|
|
|
62
67
|
"prefs",
|
|
63
68
|
"sdk",
|
|
64
69
|
"game-stats",
|
|
65
|
-
"avatar"
|
|
70
|
+
"avatar",
|
|
71
|
+
"cosmetics"
|
|
66
72
|
],
|
|
67
73
|
"license": "ISC"
|
|
68
74
|
}
|