@skinhub/viewer 0.1.1 → 0.2.1
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/EMBED.md +105 -7
- package/README.md +47 -26
- package/dist/SkinViewer.js +1 -1
- package/dist/SkinViewer.js.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/item.d.ts +26 -1
- package/dist/item.d.ts.map +1 -1
- package/dist/item.js +37 -8
- package/dist/item.js.map +1 -1
- package/dist/protocol.d.ts +38 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/state.d.ts +25 -4
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +139 -12
- package/dist/state.js.map +1 -1
- package/dist/types.d.ts +118 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/SkinViewer.tsx +1 -1
- package/src/index.ts +13 -0
- package/src/item.ts +72 -1
- package/src/protocol.ts +32 -1
- package/src/state.ts +166 -15
- package/src/types.ts +144 -1
package/src/SkinViewer.tsx
CHANGED
|
@@ -201,7 +201,7 @@ export const SkinViewer = (props: SkinViewerProps) => {
|
|
|
201
201
|
* showing, or the next real item would be diffed against a hole and sent in full for no reason.
|
|
202
202
|
*/
|
|
203
203
|
sent.current = { ...next, item: next.item ?? sent.current.item }
|
|
204
|
-
if (coversCanvas(patch, next
|
|
204
|
+
if (coversCanvas(patch, next)) {
|
|
205
205
|
setStatus('loading')
|
|
206
206
|
// A NEW ITEM IS A NEW CHANCE. A lost GL context or a 404 on one model says nothing about the
|
|
207
207
|
// next one, so the fallback comes down and the frame is allowed to try. A protocol mismatch is
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
* <SkinViewer item={{ weapon: 'weapon_ak47', paintIndex: 1449, float: 0.27 }} style={{ height: 420 }} />
|
|
7
7
|
* <SkinViewer inspectLink={tradeOffer.inspectLink} style={{ height: 420 }} />
|
|
8
8
|
*
|
|
9
|
+
* A weapon (or a glove) is one of SIX ways to name a subject, and each is its own prop:
|
|
10
|
+
*
|
|
11
|
+
* <SkinViewer sticker={{ id: 37, wear: 0.2 }} /> one sticker, the real holo/foil shader
|
|
12
|
+
* <SkinViewer charm={{ id: 5 }} /> one charm, off the gun
|
|
13
|
+
* <SkinViewer collectible={{ id: 874 }} /> one pin, coin, medal or trophy
|
|
14
|
+
* <SkinViewer operator={{ id: 5036 }} /> one agent, alone
|
|
15
|
+
*
|
|
16
|
+
* Exactly one of the six, enforced in the types - see `ViewerSubject`.
|
|
17
|
+
*
|
|
9
18
|
* The 3D is not in here. It is a page on our origin that this component embeds and drives over
|
|
10
19
|
* `postMessage`, which is why installing this pulls in no `three`, no `@react-three/fiber` and no
|
|
11
20
|
* asset bundle - the only peer dependency is React. `EMBED.md` documents the same contract for stacks
|
|
@@ -62,14 +71,18 @@ export type {
|
|
|
62
71
|
ViewerAgent,
|
|
63
72
|
ViewerBackground,
|
|
64
73
|
ViewerCameraSettings,
|
|
74
|
+
ViewerCharmSubject,
|
|
75
|
+
ViewerCollectibleSubject,
|
|
65
76
|
ViewerEnvironmentSettings,
|
|
66
77
|
ViewerGloves,
|
|
67
78
|
ViewerInteractions,
|
|
79
|
+
ViewerOperatorSubject,
|
|
68
80
|
ViewerOverlaySettings,
|
|
69
81
|
ViewerQualitySettings,
|
|
70
82
|
ViewerResize,
|
|
71
83
|
ViewerSettings,
|
|
72
84
|
ViewerStatus,
|
|
85
|
+
ViewerStickerSubject,
|
|
73
86
|
ViewerSubject,
|
|
74
87
|
ViewerView,
|
|
75
88
|
} from './types.js'
|
package/src/item.ts
CHANGED
|
@@ -33,7 +33,14 @@
|
|
|
33
33
|
import { buildInspectUrl, readInspectUrl } from '@skinhub/cdn/inspect'
|
|
34
34
|
import { emptyKeychain, emptySticker, makeSkinPlacement, type SkinPlacement } from '@skinhub/cdn/placement'
|
|
35
35
|
|
|
36
|
-
import type {
|
|
36
|
+
import type {
|
|
37
|
+
FrameCharm,
|
|
38
|
+
FrameCollectible,
|
|
39
|
+
FrameItem,
|
|
40
|
+
FrameSticker,
|
|
41
|
+
FrameSubjectKind,
|
|
42
|
+
PlacementSlots,
|
|
43
|
+
} from './protocol.js'
|
|
37
44
|
import type { SkinViewerCharm, SkinViewerError, SkinViewerItem, SkinViewerSticker, ViewerSubject } from './types.js'
|
|
38
45
|
import { defindexForWeaponId, normalizeWeaponId, weaponIdForDefindex } from './weapons.js'
|
|
39
46
|
|
|
@@ -182,6 +189,70 @@ const fromPlacement = (placement: SkinPlacement, weaponType: string): FrameItem
|
|
|
182
189
|
* build the frame's URL in a `useState` initialiser and have the FIRST PAINT be the integrator's item
|
|
183
190
|
* rather than ours swapped a tick later.
|
|
184
191
|
*/
|
|
192
|
+
/**
|
|
193
|
+
* *** THE OTHER FOUR SUBJECTS, RESOLVED BEFORE THE WEAPON IS EVEN LOOKED FOR. ***
|
|
194
|
+
*
|
|
195
|
+
* Returns null when the props name none of them, which is what sends {@link resolveSubject} on to the
|
|
196
|
+
* `inspectLink` / `item` pair below. The ORDER is the arm order in `types.ts` and it only matters for
|
|
197
|
+
* props that bypassed the types (plain JavaScript, an `any`), where naming two subjects has to mean
|
|
198
|
+
* SOMETHING - it means the first one on this list.
|
|
199
|
+
*
|
|
200
|
+
* *** AN ID THAT IS NOT A POSITIVE INTEGER IS `no-item`, NOT A ZERO. *** `sticker_id: 0` IS the empty
|
|
201
|
+
* slot throughout the renderer, so passing it through would ask the frame to draw an item that by
|
|
202
|
+
* definition does not exist; the instruction card plus an `onError` is a far better answer than an
|
|
203
|
+
* empty canvas. This is the same "data had not arrived yet" case the `no-item` code exists for.
|
|
204
|
+
*/
|
|
205
|
+
export type ResolvedStandalone = {
|
|
206
|
+
subject: Exclude<FrameSubjectKind, 'weapon'>
|
|
207
|
+
sticker?: FrameSticker
|
|
208
|
+
charm?: FrameCharm
|
|
209
|
+
collectible?: FrameCollectible
|
|
210
|
+
agent?: { id: number; pose?: string | null }
|
|
211
|
+
error: SkinViewerError | null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export const resolveStandalone = (props: Partial<ViewerSubject>): ResolvedStandalone | null => {
|
|
215
|
+
const bad = (subject: ResolvedStandalone['subject'], prop: string, id: unknown): ResolvedStandalone => ({
|
|
216
|
+
subject,
|
|
217
|
+
error: {
|
|
218
|
+
code: 'no-item',
|
|
219
|
+
message: `\`${prop}.id\` must be a positive integer, got ${JSON.stringify(id)}. In TypeScript that is a compile error, so this is JavaScript, an \`any\`, or data that had not arrived yet.`,
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
const ok = (id: unknown) => typeof id === 'number' && Number.isSafeInteger(id) && id > 0
|
|
223
|
+
|
|
224
|
+
if (props.sticker)
|
|
225
|
+
return ok(props.sticker.id)
|
|
226
|
+
? { subject: 'sticker', sticker: dropUndefined({ id: props.sticker.id, wear: props.sticker.wear }), error: null }
|
|
227
|
+
: bad('sticker', 'sticker', props.sticker.id)
|
|
228
|
+
|
|
229
|
+
if (props.charm)
|
|
230
|
+
return ok(props.charm.id)
|
|
231
|
+
? { subject: 'charm', charm: dropUndefined({ id: props.charm.id, pattern: props.charm.pattern }), error: null }
|
|
232
|
+
: bad('charm', 'charm', props.charm.id)
|
|
233
|
+
|
|
234
|
+
if (props.collectible)
|
|
235
|
+
return ok(props.collectible.id)
|
|
236
|
+
? { subject: 'collectible', collectible: { id: props.collectible.id }, error: null }
|
|
237
|
+
: bad('collectible', 'collectible', props.collectible.id)
|
|
238
|
+
|
|
239
|
+
/* `operator` BECOMES `agent` ON THE WIRE - see `FrameSubjectKind`. The pose rides in the same group
|
|
240
|
+
the weapon views use, because it is the same question (which performance) asked of the same row. */
|
|
241
|
+
if (props.operator)
|
|
242
|
+
return ok(props.operator.id)
|
|
243
|
+
? { subject: 'agent', agent: dropUndefined({ id: props.operator.id, pose: props.operator.pose }), error: null }
|
|
244
|
+
: bad('agent', 'operator', props.operator.id)
|
|
245
|
+
|
|
246
|
+
return null
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Drops keys the caller did not set, so "say nothing" survives into the URL and into the diff. */
|
|
250
|
+
const dropUndefined = <T extends object>(source: T): T => {
|
|
251
|
+
const out = {} as T
|
|
252
|
+
for (const key of Object.keys(source) as (keyof T)[]) if (source[key] !== undefined) out[key] = source[key]
|
|
253
|
+
return out
|
|
254
|
+
}
|
|
255
|
+
|
|
185
256
|
export const resolveSubject = (subject: Partial<ViewerSubject>): ResolvedSubject => {
|
|
186
257
|
const fail = (code: SkinViewerError['code'], message: string): ResolvedSubject => ({
|
|
187
258
|
item: null,
|
package/src/protocol.ts
CHANGED
|
@@ -121,9 +121,35 @@ export type FrameInteractions = {
|
|
|
121
121
|
dragCharm?: boolean
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* *** THE FRAME'S FIVE SUBJECT KINDS. *** `weapon` covers gloves too - a glove is a `weaponType`.
|
|
126
|
+
*
|
|
127
|
+
* NOTE THE WORD: the wire says `agent` where `types.ts` says `operator`. That is the translation this
|
|
128
|
+
* package exists to do (see `item.ts`'s header): the frame's own subject vocabulary has always called
|
|
129
|
+
* the person an agent, and an integrator holding a weapon-modifier prop already named `agent` needs a
|
|
130
|
+
* different word for the standalone picture. One rename, in one file.
|
|
131
|
+
*/
|
|
132
|
+
export type FrameSubjectKind = 'weapon' | 'sticker' | 'charm' | 'collectible' | 'agent'
|
|
133
|
+
|
|
134
|
+
/** The three standalone item groups, in the frame's words. An id, and at most one number. */
|
|
135
|
+
export type FrameSticker = { id: number; wear?: number }
|
|
136
|
+
export type FrameCharm = { id: number; pattern?: number }
|
|
137
|
+
export type FrameCollectible = { id: number }
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Everything `/frame` holds. One field per prop of the renderer that a host can set.
|
|
141
|
+
*
|
|
142
|
+
* *** THE FIVE SUBJECTS ARE HELD AT ONCE AND `subject` PICKS ONE. *** A patch naming `sticker` does
|
|
143
|
+
* NOT make the sticker the subject - only `subject` does. That matters here in particular because this
|
|
144
|
+
* package restates its whole prop set on every render: if a group write switched the picture, a host
|
|
145
|
+
* holding both a weapon and a pin would flip between them on any render mentioning both.
|
|
146
|
+
*/
|
|
125
147
|
export type FrameState = {
|
|
148
|
+
subject: FrameSubjectKind
|
|
126
149
|
item: FrameItem
|
|
150
|
+
sticker: FrameSticker
|
|
151
|
+
charm: FrameCharm
|
|
152
|
+
collectible: FrameCollectible
|
|
127
153
|
view: 'gun' | 'hands' | 'agent'
|
|
128
154
|
agent: { id: number; pose?: string | null }
|
|
129
155
|
gloves: { type: string; paintIndex: number; float?: number; seed?: number } | null
|
|
@@ -141,7 +167,12 @@ export type FrameState = {
|
|
|
141
167
|
* rather than merged would blank the weapon on every float tick.
|
|
142
168
|
*/
|
|
143
169
|
export type FramePatch = {
|
|
170
|
+
/** The subject switch, and the only one - see {@link FrameState}. An IDENTITY change in every direction. */
|
|
171
|
+
subject?: FrameSubjectKind
|
|
144
172
|
item?: Partial<FrameItem>
|
|
173
|
+
sticker?: Partial<FrameSticker>
|
|
174
|
+
charm?: Partial<FrameCharm>
|
|
175
|
+
collectible?: Partial<FrameCollectible>
|
|
145
176
|
view?: FrameState['view']
|
|
146
177
|
agent?: Partial<FrameState['agent']>
|
|
147
178
|
gloves?: FrameState['gloves']
|
package/src/state.ts
CHANGED
|
@@ -31,8 +31,18 @@
|
|
|
31
31
|
* So the diff is structural: a field is in the patch only if its VALUE moved.
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
import type {
|
|
35
|
-
|
|
34
|
+
import type {
|
|
35
|
+
FrameCharm,
|
|
36
|
+
FrameCollectible,
|
|
37
|
+
FrameInteractions,
|
|
38
|
+
FrameItem,
|
|
39
|
+
FramePatch,
|
|
40
|
+
FrameSettings,
|
|
41
|
+
FrameSticker,
|
|
42
|
+
FrameSubjectKind,
|
|
43
|
+
PlacementSlots,
|
|
44
|
+
} from './protocol.js'
|
|
45
|
+
import { resolveStandalone, resolveSubject } from './item.js'
|
|
36
46
|
import type { SkinViewerError, SkinViewerProps, ViewerGloves, ViewerView } from './types.js'
|
|
37
47
|
|
|
38
48
|
/**
|
|
@@ -51,8 +61,23 @@ export type HelpReason = 'no-item' | 'bad-link' | 'unknown-weapon'
|
|
|
51
61
|
* caller did not mention.
|
|
52
62
|
*/
|
|
53
63
|
export type DesiredState = {
|
|
54
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* WHICH OF THE FIVE IS ON SCREEN.
|
|
66
|
+
*
|
|
67
|
+
* *** THE ONE FIELD HERE THAT IS ALWAYS SET, AND THE EXCEPTION IS EARNED. *** Everything else in
|
|
68
|
+
* this type obeys "absent means say nothing", because our defaults must not be frozen into somebody
|
|
69
|
+
* else's URL. The subject cannot: going BACK to a weapon from a sticker has to be expressible, and a
|
|
70
|
+
* diff can only express it if there is a value to compare against. So it is always resolved - and
|
|
71
|
+
* {@link frameUrl} then declines to write `?subject=weapon`, which keeps the same promise in the one
|
|
72
|
+
* place it can be kept.
|
|
73
|
+
*/
|
|
74
|
+
subject: FrameSubjectKind
|
|
75
|
+
/** `null` when the props named no renderable item, or named a subject that is not a weapon. */
|
|
55
76
|
item: FrameItem | null
|
|
77
|
+
/** Set only when {@link subject} names it. Same rule as `item`, one per standalone subject. */
|
|
78
|
+
sticker?: FrameSticker
|
|
79
|
+
charm?: FrameCharm
|
|
80
|
+
collectible?: FrameCollectible
|
|
56
81
|
/** The integrator's own inspect link, forwarded verbatim as `?i=`. See `item.ts`. */
|
|
57
82
|
inspectPayload: string | null
|
|
58
83
|
help: HelpReason | null
|
|
@@ -88,14 +113,9 @@ const HELP_FOR: Record<SkinViewerError['code'], HelpReason | null> = {
|
|
|
88
113
|
* rather than a `TypeError`.
|
|
89
114
|
*/
|
|
90
115
|
export const resolveState = (props: Partial<SkinViewerProps>): DesiredState => {
|
|
91
|
-
const subject = resolveSubject(props)
|
|
92
116
|
const settings = toFrameSettings(props.settings)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
item: subject.item,
|
|
96
|
-
inspectPayload: subject.inspectPayload,
|
|
97
|
-
help: subject.error ? HELP_FOR[subject.error.code] : null,
|
|
98
|
-
subjectError: subject.error,
|
|
117
|
+
/* Everything true of every subject. Hoisted so the two returns below cannot drift apart. */
|
|
118
|
+
const common = {
|
|
99
119
|
...(props.view !== undefined && { view: props.view }),
|
|
100
120
|
...(props.agent !== undefined && { agent: props.agent }),
|
|
101
121
|
...(props.gloves !== undefined && { gloves: props.gloves }),
|
|
@@ -103,6 +123,40 @@ export const resolveState = (props: Partial<SkinViewerProps>): DesiredState => {
|
|
|
103
123
|
...(props.interactions !== undefined && { interactions: { ...props.interactions } }),
|
|
104
124
|
...(props.editingSlot !== undefined && { editingSlot: props.editingSlot }),
|
|
105
125
|
}
|
|
126
|
+
|
|
127
|
+
/*
|
|
128
|
+
* THE OTHER FOUR SUBJECTS FIRST, because each is named by its own prop and the weapon is the
|
|
129
|
+
* fall-through. `item` stays null for them: there IS no weapon, and inventing one would put a rifle
|
|
130
|
+
* into this package's URL that the frame would then hold behind a subject nobody is looking at.
|
|
131
|
+
*
|
|
132
|
+
* `agent` IS WRITTEN LAST for the operator, so `operator={{ pose }}` beats an `agent={{ pose }}` a
|
|
133
|
+
* host also happened to pass. Two props answering one question is only reachable through the types'
|
|
134
|
+
* back door, and the subject arm is the more specific statement.
|
|
135
|
+
*/
|
|
136
|
+
const standalone = resolveStandalone(props)
|
|
137
|
+
if (standalone)
|
|
138
|
+
return {
|
|
139
|
+
subject: standalone.subject,
|
|
140
|
+
item: null,
|
|
141
|
+
...(standalone.sticker && { sticker: standalone.sticker }),
|
|
142
|
+
...(standalone.charm && { charm: standalone.charm }),
|
|
143
|
+
...(standalone.collectible && { collectible: standalone.collectible }),
|
|
144
|
+
inspectPayload: null,
|
|
145
|
+
help: standalone.error ? HELP_FOR[standalone.error.code] : null,
|
|
146
|
+
subjectError: standalone.error,
|
|
147
|
+
...common,
|
|
148
|
+
...(standalone.agent && { agent: standalone.agent }),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const subject = resolveSubject(props)
|
|
152
|
+
return {
|
|
153
|
+
subject: 'weapon',
|
|
154
|
+
item: subject.item,
|
|
155
|
+
inspectPayload: subject.inspectPayload,
|
|
156
|
+
help: subject.error ? HELP_FOR[subject.error.code] : null,
|
|
157
|
+
subjectError: subject.error,
|
|
158
|
+
...common,
|
|
159
|
+
}
|
|
106
160
|
}
|
|
107
161
|
|
|
108
162
|
/**
|
|
@@ -160,6 +214,31 @@ export const frameUrl = (origin: string, desired: DesiredState): { src: string;
|
|
|
160
214
|
|
|
161
215
|
if (desired.help) params.set('help', desired.help)
|
|
162
216
|
|
|
217
|
+
/*
|
|
218
|
+
* ── THE SUBJECT ──────────────────────────────────────────────────────────────────────────
|
|
219
|
+
*
|
|
220
|
+
* `?subject=` IS WRITTEN FOR EVERY NON-WEAPON AND NEVER FOR A WEAPON. Not writing it for the weapon
|
|
221
|
+
* keeps this file's promise about defaults: `?subject=weapon` in a customer's `<iframe src>` would
|
|
222
|
+
* be OUR default frozen into THEIR embed. Writing it for the other four is not the same thing - it
|
|
223
|
+
* is the caller's own statement, and for the operator it is the only way to say it, because
|
|
224
|
+
* `?agent=` on its own has always meant "who holds the weapon".
|
|
225
|
+
*
|
|
226
|
+
* The id params are what the frame reads back; `?sticker=`/`?charm=`/`?collectible=` each imply the
|
|
227
|
+
* subject on their own, so the pair is redundant by design rather than by accident - a URL that says
|
|
228
|
+
* the same thing twice cannot be half-copied into meaning something else.
|
|
229
|
+
*/
|
|
230
|
+
if (desired.subject !== 'weapon') params.set('subject', desired.subject)
|
|
231
|
+
if (desired.sticker) {
|
|
232
|
+
params.set('sticker', String(desired.sticker.id))
|
|
233
|
+
num(params, 'wear', desired.sticker.wear)
|
|
234
|
+
}
|
|
235
|
+
if (desired.charm) {
|
|
236
|
+
params.set('charm', String(desired.charm.id))
|
|
237
|
+
// `?pattern=` and not `?seed=` - on this URL `seed` is already the WEAPON's paint seed.
|
|
238
|
+
num(params, 'pattern', desired.charm.pattern)
|
|
239
|
+
}
|
|
240
|
+
if (desired.collectible) params.set('collectible', String(desired.collectible.id))
|
|
241
|
+
|
|
163
242
|
if (item) {
|
|
164
243
|
params.set('weapon', item.weaponType)
|
|
165
244
|
params.set('paint', String(item.paintIndex))
|
|
@@ -291,6 +370,46 @@ export const diffState = (previous: DesiredState, next: DesiredState): FramePatc
|
|
|
291
370
|
if (Object.keys(item).length > 0) patch.item = item
|
|
292
371
|
}
|
|
293
372
|
|
|
373
|
+
/*
|
|
374
|
+
* ── THE OTHER FOUR SUBJECTS ───────────────────────────────────────────────────────────────
|
|
375
|
+
*
|
|
376
|
+
* A GROUP IS DIFFED EVEN WHEN THE SUBJECT DID NOT MOVE, so a `wear` drag on a sticker is one small
|
|
377
|
+
* patch per tick and nothing else - the identical shape a `float` drag on a rifle has, and cheap for
|
|
378
|
+
* the identical reason: the id is not in the patch, so nothing keyed on the id can move.
|
|
379
|
+
*
|
|
380
|
+
* THE SUBJECT IS DIFFED SEPARATELY AND CARRIES NO GROUP WITH IT. Switching to a sticker whose id has
|
|
381
|
+
* not changed sends `{ subject: 'sticker' }` alone, because the frame still holds the id it was given
|
|
382
|
+
* before - which is what makes flipping between two subjects cost one field.
|
|
383
|
+
*/
|
|
384
|
+
if (next.subject !== previous.subject) {
|
|
385
|
+
patch.subject = next.subject
|
|
386
|
+
changed = true
|
|
387
|
+
}
|
|
388
|
+
/*
|
|
389
|
+
* *** FIELD BY FIELD, NOT WHOLE-GROUP, AND THE TEST BELOW IS WHY. *** Sending the whole group would
|
|
390
|
+
* be correct on the wire - the frame merges by field either way - and would still break the
|
|
391
|
+
* contract, because `coversCanvas` reads `sticker.id !== undefined` to decide whether to raise the
|
|
392
|
+
* caller's `loading` slot. A `wear` drag that restated the id would raise it sixty times a second
|
|
393
|
+
* over a picture the frame never covered, which from outside is indistinguishable from a reload.
|
|
394
|
+
* The item above is diffed per field for the same reason; gloves are the one group sent wholesale,
|
|
395
|
+
* and that is because a partial pair is a state the renderer cannot resolve.
|
|
396
|
+
*/
|
|
397
|
+
const sticker = diffGroup(previous.sticker, next.sticker)
|
|
398
|
+
if (sticker) {
|
|
399
|
+
patch.sticker = sticker
|
|
400
|
+
changed = true
|
|
401
|
+
}
|
|
402
|
+
const charm = diffGroup(previous.charm, next.charm)
|
|
403
|
+
if (charm) {
|
|
404
|
+
patch.charm = charm
|
|
405
|
+
changed = true
|
|
406
|
+
}
|
|
407
|
+
const collectible = diffGroup(previous.collectible, next.collectible)
|
|
408
|
+
if (collectible) {
|
|
409
|
+
patch.collectible = collectible
|
|
410
|
+
changed = true
|
|
411
|
+
}
|
|
412
|
+
|
|
294
413
|
if (next.view !== undefined && next.view !== previous.view) {
|
|
295
414
|
patch.view = next.view
|
|
296
415
|
changed = true
|
|
@@ -330,6 +449,26 @@ export const diffState = (previous: DesiredState, next: DesiredState): FramePatc
|
|
|
330
449
|
return changed ? patch : undefined
|
|
331
450
|
}
|
|
332
451
|
|
|
452
|
+
/**
|
|
453
|
+
* One flat group, per field. `undefined` when nothing moved, so the caller can skip the key entirely.
|
|
454
|
+
*
|
|
455
|
+
* A KEY THE NEXT RENDER DID NOT MENTION IS NOT DIFFED AWAY - there is no way to say "unset" over the
|
|
456
|
+
* wire, and the alternative reading would make a conditional prop destructive. Same rule as
|
|
457
|
+
* {@link diffSettings} one level in.
|
|
458
|
+
*/
|
|
459
|
+
const diffGroup = <T extends object>(previous: T | undefined, next: T | undefined): Partial<T> | undefined => {
|
|
460
|
+
if (!next) return undefined
|
|
461
|
+
if (!previous) return { ...next }
|
|
462
|
+
const out: Partial<T> = {}
|
|
463
|
+
let changed = false
|
|
464
|
+
for (const key of Object.keys(next) as (keyof T)[])
|
|
465
|
+
if (!Object.is(previous[key], next[key])) {
|
|
466
|
+
out[key] = next[key]
|
|
467
|
+
changed = true
|
|
468
|
+
}
|
|
469
|
+
return changed ? out : undefined
|
|
470
|
+
}
|
|
471
|
+
|
|
333
472
|
const glovesEqual = (a: ViewerGloves | null | undefined, b: ViewerGloves | null | undefined) => {
|
|
334
473
|
if (a === b) return true
|
|
335
474
|
if (!a || !b) return false
|
|
@@ -378,12 +517,24 @@ const overlaysEqual = (a: FrameSettings['overlays'], b: FrameSettings['overlays'
|
|
|
378
517
|
return a.stickerGizmo === b.stickerGizmo && a.charmGizmo === b.charmGizmo && shallowEqual(a.gizmoStyle, b.gizmoStyle)
|
|
379
518
|
}
|
|
380
519
|
|
|
381
|
-
/**
|
|
382
|
-
|
|
520
|
+
/**
|
|
521
|
+
* True when a patch would cover the canvas - see {@link IDENTITY_FIELDS} and `CHEAP_FIELDS`.
|
|
522
|
+
*
|
|
523
|
+
* IT TAKES THE WHOLE NEXT STATE rather than just the view, because one of the answers depends on which
|
|
524
|
+
* SUBJECT the patch lands on: an operator's id covers under `subject: 'agent'` for the same reason it
|
|
525
|
+
* covers under `view: 'agent'`, and does not under `hands`.
|
|
526
|
+
*/
|
|
527
|
+
export const coversCanvas = (patch: FramePatch, next: Pick<DesiredState, 'subject' | 'view'>): boolean => {
|
|
528
|
+
// A different KIND of subject is a different renderer. Always a reload, in every direction.
|
|
529
|
+
if (patch.subject !== undefined) return true
|
|
383
530
|
if (patch.view !== undefined) return true
|
|
384
531
|
if (patch.item && IDENTITY_FIELDS.some(field => patch.item?.[field] !== undefined)) return true
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
532
|
+
// An id is identity for all four standalone subjects; their second field (`wear`, `pattern`) is not.
|
|
533
|
+
if (patch.sticker?.id !== undefined || patch.charm?.id !== undefined || patch.collectible?.id !== undefined)
|
|
534
|
+
return true
|
|
535
|
+
// The operator is identity when they ARE the subject, and in the `agent` view, where their
|
|
536
|
+
// `<Suspense>` tears the subtree down; cheap in `hands`, where the arms are already mounted. The
|
|
537
|
+
// asymmetry is the renderer's, not ours.
|
|
538
|
+
if (patch.agent?.id !== undefined && (next.subject === 'agent' || next.view === 'agent')) return true
|
|
388
539
|
return false
|
|
389
540
|
}
|
package/src/types.ts
CHANGED
|
@@ -145,6 +145,17 @@ type ItemConfiguration = {
|
|
|
145
145
|
* This list is the FIRST half. It is here, as a value, because {@link SkinViewerProps.loading} is
|
|
146
146
|
* raised off its complement - see `SkinViewer.tsx`'s `isIdentityChange` - and because a contract an
|
|
147
147
|
* integrator relies on should be readable without opening the renderer.
|
|
148
|
+
*
|
|
149
|
+
* *** AND THE SAME SPLIT FOR THE OTHER FOUR SUBJECTS, which is a shorter table because they are
|
|
150
|
+
* smaller items: ***
|
|
151
|
+
*
|
|
152
|
+
* `sticker` identity `id`, cheap `wear`
|
|
153
|
+
* `charm` identity `id`, cheap `pattern`
|
|
154
|
+
* `collectible` identity `id`, and NOTHING is cheap - there is no other field to change
|
|
155
|
+
* `operator` identity `id`, cheap `pose`
|
|
156
|
+
*
|
|
157
|
+
* *** CHANGING WHICH SUBJECT YOU PASS IS ALWAYS AN IDENTITY CHANGE, *** in every direction: a weapon
|
|
158
|
+
* and a pin are drawn by different renderers, so the picture is rebuilt from nothing.
|
|
148
159
|
*/
|
|
149
160
|
export const CHEAP_FIELDS = ['float', 'seed', 'statTrak', 'nameTag', 'stickers', 'charm'] as const
|
|
150
161
|
|
|
@@ -180,6 +191,109 @@ export type SkinViewerItem = ItemConfiguration &
|
|
|
180
191
|
}
|
|
181
192
|
)
|
|
182
193
|
|
|
194
|
+
/* ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
195
|
+
* THE OTHER FOUR SUBJECTS
|
|
196
|
+
*
|
|
197
|
+
* *** A WEAPON IS ONE OF FIVE THINGS THE VIEWER CAN SHOW, NOT THE ONLY ONE. *** Owner: *"Weapons and
|
|
198
|
+
* gloves only... why not? it's already working in our system, I want them all to be inspectable
|
|
199
|
+
* through the viewer."* A GLOVE IS NOT ON THIS LIST because a glove is a `weapon` id like any other
|
|
200
|
+
* (`'sporty_gloves'`) - it always was one of the arms above.
|
|
201
|
+
*
|
|
202
|
+
* *** YOU NAME ONE BY PASSING ITS PROP, AND EXACTLY ONE. *** See {@link ViewerSubject}: the same
|
|
203
|
+
* `?: never` enforcement the `inspectLink` / `item` pair already had, widened to six arms. There is no
|
|
204
|
+
* `kind` or `type` discriminator to get wrong, because the prop you passed IS the discriminator.
|
|
205
|
+
* ═══════════════════════════════════════════════════════════════════════════════════════════ */
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* ONE STICKER, ON NOTHING - what `/sticker/:id` shows on our own site.
|
|
209
|
+
*
|
|
210
|
+
* *** NOT {@link SkinViewerSticker}, AND THE MISSING FIELDS ARE THE DIFFERENCE. *** That type is a
|
|
211
|
+
* sticker APPLIED to a weapon and carries a slot, a rotation and an offset; all three are statements
|
|
212
|
+
* about the sticker being ON something. Here the quad is the whole surface, so what is left is the id
|
|
213
|
+
* and how scratched it is.
|
|
214
|
+
*
|
|
215
|
+
* IT IS THE REAL SHADER AND NOT AN IMAGE ON A PLANE: holo, foil and glitter are functions of the view
|
|
216
|
+
* direction and of screen-space derivatives, so they exist only in 3D and only under lighting. That is
|
|
217
|
+
* the entire reason to embed a sticker rather than show your catalogue's icon.
|
|
218
|
+
*/
|
|
219
|
+
export type ViewerStickerSubject = {
|
|
220
|
+
/** `sticker_id` - the id in `@skinhub/cdn`'s `stickers.json`. */
|
|
221
|
+
id: number
|
|
222
|
+
/** Scratch, `0` (mint) to `1` (scraped off). Default `0`. Updates IN PLACE - see {@link CHEAP_FIELDS}. */
|
|
223
|
+
wear?: number
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* ONE CHARM, OFF THE GUN.
|
|
228
|
+
*
|
|
229
|
+
* It hangs in the pose its own model authors and does not swing: the physics exists to answer to how a
|
|
230
|
+
* rifle is being turned, and there is no rifle here. Everything that is the charm's own - its model,
|
|
231
|
+
* its per-id material, its template - is unchanged.
|
|
232
|
+
*/
|
|
233
|
+
export type ViewerCharmSubject = {
|
|
234
|
+
/** The id in `@skinhub/cdn`'s `keychains.json`. */
|
|
235
|
+
id: number
|
|
236
|
+
/**
|
|
237
|
+
* The charm's template. NOT a variant: it drives a hue/saturation/brightness adjust on the charm's
|
|
238
|
+
* own albedo, so two charms with the same `id` and a different value are the same model in different
|
|
239
|
+
* colours. Default `0`. Updates IN PLACE.
|
|
240
|
+
*
|
|
241
|
+
* *** SPELLED `pattern` HERE AND `seed` ON {@link SkinViewerCharm}, WHICH IS DELIBERATE. *** On a
|
|
242
|
+
* weapon the charm sits beside a paint `seed` and calling it a seed reads naturally; as a SUBJECT it
|
|
243
|
+
* sits beside nothing, and `pattern` is what the wire, the game and the URL (`?pattern=`) all call
|
|
244
|
+
* it. The one word that could not be reused in both places is `seed`, because on the URL that is
|
|
245
|
+
* already the weapon's paint seed.
|
|
246
|
+
*/
|
|
247
|
+
pattern?: number
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* ONE PIN, COIN, MEDAL OR TROPHY.
|
|
252
|
+
*
|
|
253
|
+
* *** IT HAS NOTHING ELSE, AND THAT IS THE WHOLE TYPE. *** No float, no seed, no wear, no template, no
|
|
254
|
+
* pose: two copies of the same medal ARE the same object. If you are drawing controls for an embedded
|
|
255
|
+
* collectible, there is nothing to draw except the camera.
|
|
256
|
+
*/
|
|
257
|
+
export type ViewerCollectibleSubject = {
|
|
258
|
+
/** The item definition index in `@skinhub/cdn`'s `collectibles.json`. */
|
|
259
|
+
id: number
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* ONE OPERATOR, ALONE - the agent as the SUBJECT rather than as the person holding a weapon.
|
|
264
|
+
*
|
|
265
|
+
* ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
266
|
+
* *** WHY THIS IS NOT {@link ViewerAgent}, WHEN IT NAMES THE SAME CATALOGUE ROW. ***
|
|
267
|
+
*
|
|
268
|
+
* They are two pictures, and the prop you pass is which one you get:
|
|
269
|
+
*
|
|
270
|
+
* `agent={{ id }}` WHO IS HOLDING IT. A modifier on the weapon subject, visible in the `hands`
|
|
271
|
+
* and `agent` views, ignored in `gun`. The weapon is what is being shown.
|
|
272
|
+
* `operator={{ id }}` THE PERSON IS WHAT IS BEING SHOWN. No weapon is drawn at all.
|
|
273
|
+
*
|
|
274
|
+
* A marketplace with an agent row in an inventory wants the second: they asked for Sir Bloody Darryl,
|
|
275
|
+
* and rendering him holding an AK-47 nobody named would be a picture of two items, one of them
|
|
276
|
+
* invented. (Our own site made the opposite call for its own routes, for a reason that does not carry:
|
|
277
|
+
* in the app there is always a weapon being configured, so `/agent/:id` folds into `?view=agent`
|
|
278
|
+
* rather than duplicating a surface. An embed has no such weapon.)
|
|
279
|
+
*
|
|
280
|
+
* PASSING BOTH IS NOT POSSIBLE for the subject - `?: never` - and passing `agent` ALONGSIDE `operator`
|
|
281
|
+
* simply does nothing, because there is no weapon for anyone to hold.
|
|
282
|
+
*/
|
|
283
|
+
export type ViewerOperatorSubject = {
|
|
284
|
+
/** An agent's item definition index; `5036` is the default Terrorist. */
|
|
285
|
+
id: number
|
|
286
|
+
/**
|
|
287
|
+
* Which main-menu performance they play, by clip leaf name, or `null` (the default) for their
|
|
288
|
+
* team's own knife idle - what CS2's main menu opens on.
|
|
289
|
+
*
|
|
290
|
+
* 285 leaves across 44 weapon families are reachable. THE WEAPON EACH CLIP HOLDS IS NOT DRAWN, so
|
|
291
|
+
* `Look at gun` is an operator studying an empty hand; that is a property of the performance rather
|
|
292
|
+
* than a defect. Updates IN PLACE - the clip is swapped on a rig that keeps running.
|
|
293
|
+
*/
|
|
294
|
+
pose?: string | null
|
|
295
|
+
}
|
|
296
|
+
|
|
183
297
|
/* ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
184
298
|
* PRESENTATION
|
|
185
299
|
* ═══════════════════════════════════════════════════════════════════════════════════════════ */
|
|
@@ -188,6 +302,9 @@ export type SkinViewerItem = ItemConfiguration &
|
|
|
188
302
|
* What the item is being shown ON. All three are the same item under the same lighting, finish,
|
|
189
303
|
* stickers and charm - the difference is the camera and what is holding the weapon.
|
|
190
304
|
*
|
|
305
|
+
* *** IT IS A PROPERTY OF THE WEAPON SUBJECT AND IS IGNORED BY THE OTHER FOUR. *** A sticker is not a
|
|
306
|
+
* way of showing a weapon, so there is nothing for it to choose between.
|
|
307
|
+
*
|
|
191
308
|
* `gun` the item alone, orbitable, framed to the viewport. The default.
|
|
192
309
|
* `hands` CS2's first-person viewmodel, driven by the game's own clips. Orbit is off here because
|
|
193
310
|
* there is nothing to orbit: the weapon is welded to the eye.
|
|
@@ -494,8 +611,34 @@ export type ViewerResize = { width: number; height: number; dpr: number }
|
|
|
494
611
|
* was still `undefined` when the component mounted: the frame renders a short instruction card rather
|
|
495
612
|
* than a blank box or - worse - our default AK-47, which would look like a successful render of the
|
|
496
613
|
* wrong item. `onError` fires with `no-item` at the same time. See `SkinViewer.tsx`.
|
|
614
|
+
*
|
|
615
|
+
* *** AND IT IS SIX ARMS NOW RATHER THAN TWO, on exactly the same rule. *** A weapon, an inspect link
|
|
616
|
+
* and the four subjects above are six ways of naming ONE thing to render, and the same `?: never`
|
|
617
|
+
* enforcement covers all of them: passing two is an excess-property error, passing none is a
|
|
618
|
+
* missing-property error, and there is no `kind` discriminator anywhere because the prop you passed is
|
|
619
|
+
* the discriminator.
|
|
497
620
|
*/
|
|
498
|
-
|
|
621
|
+
type SubjectArms = {
|
|
622
|
+
inspectLink: string
|
|
623
|
+
item: SkinViewerItem
|
|
624
|
+
sticker: ViewerStickerSubject
|
|
625
|
+
charm: ViewerCharmSubject
|
|
626
|
+
collectible: ViewerCollectibleSubject
|
|
627
|
+
operator: ViewerOperatorSubject
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** One arm present, the other five forbidden. Written once so six arms cannot disagree about five. */
|
|
631
|
+
type OnlySubject<K extends keyof SubjectArms> = { [P in K]: SubjectArms[P] } & {
|
|
632
|
+
[P in Exclude<keyof SubjectArms, K>]?: never
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export type ViewerSubject =
|
|
636
|
+
| OnlySubject<'inspectLink'>
|
|
637
|
+
| OnlySubject<'item'>
|
|
638
|
+
| OnlySubject<'sticker'>
|
|
639
|
+
| OnlySubject<'charm'>
|
|
640
|
+
| OnlySubject<'collectible'>
|
|
641
|
+
| OnlySubject<'operator'>
|
|
499
642
|
|
|
500
643
|
export type SkinViewerProps = ViewerSubject & {
|
|
501
644
|
/* ── Presentation ──────────────────────────────────────────────────────────────────────── */
|