@aethervtt/shard-cards 0.0.0-stage → 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/dist/table.js ADDED
@@ -0,0 +1,537 @@
1
+ import {
2
+ defineResource,
3
+ ShardError
4
+ } from "@aethervtt/shard-core";
5
+ import {
6
+ Gpu,
7
+ GpuAssetsResource,
8
+ InstanceData,
9
+ MaterialAsset,
10
+ Materials,
11
+ Mesh3d,
12
+ Meshes,
13
+ MeshMaterial,
14
+ reportUnsupported
15
+ } from "@aethervtt/shard-render";
16
+ import { FrameDemand } from "@aethervtt/shard-runtime";
17
+ import { Texture, Textures } from "@aethervtt/shard-texture";
18
+ import { Transform } from "@aethervtt/shard-transform";
19
+ import {
20
+ Card,
21
+ CardDefinition,
22
+ CardInteraction
23
+ } from "./definition.js";
24
+ import { cardMesh } from "./mesh.js";
25
+ import { CardAutomaticMotion, sampleCardMotion } from "./motion.js";
26
+ const WIDTH = 64;
27
+ const FLOATS = WIDTH * 4;
28
+ const HALF_BYTES = FLOATS * 2;
29
+ const halfFloat = new Float32Array(1);
30
+ const halfBits = new Uint32Array(halfFloat.buffer);
31
+ const createCardHit = () => ({
32
+ card: void 0,
33
+ side: "front",
34
+ u: 0,
35
+ v: 0,
36
+ distance: Infinity
37
+ });
38
+ class CardHandle {
39
+ slot;
40
+ entity;
41
+ instanceId;
42
+ definition;
43
+ table;
44
+ pose = new Float64Array(7);
45
+ target = new Float64Array(7);
46
+ start = new Float64Array(7);
47
+ translation = [0, 0, 0];
48
+ rotation = [0, 0, 0, 1];
49
+ scale = [1, 1, 1];
50
+ transform = { translation: this.translation, rotation: this.rotation, scale: this.scale };
51
+ data;
52
+ bytes;
53
+ halfView;
54
+ active = true;
55
+ interactive = true;
56
+ tilt = false;
57
+ automaticTilt = false;
58
+ maxTilt = 8;
59
+ hoverX = 0;
60
+ hoverY = 0;
61
+ hovered = false;
62
+ tiltX = 0;
63
+ tiltY = 0;
64
+ duration = 0;
65
+ age = 0;
66
+ poseDirty = true;
67
+ dataDirty = true;
68
+ animationFps = 0;
69
+ constructor(table, slot, entity, id, definition) {
70
+ this.table = table;
71
+ this.slot = slot;
72
+ this.entity = entity;
73
+ this.instanceId = id;
74
+ this.definition = definition;
75
+ this.data = new Float32Array(FLOATS);
76
+ this.bytes = new Uint8Array(table.recordBytes.buffer, slot * HALF_BYTES, HALF_BYTES);
77
+ this.halfView = new DataView(this.bytes.buffer, this.bytes.byteOffset, this.bytes.byteLength);
78
+ this.pose[6] = this.target[6] = 1;
79
+ }
80
+ setPose(pose, seconds = 0) {
81
+ this.start.set(this.pose);
82
+ this.target[0] = pose.x;
83
+ this.target[1] = pose.y;
84
+ this.target[2] = pose.z;
85
+ this.target[3] = pose.pitch ?? 0;
86
+ this.target[4] = pose.yaw ?? 0;
87
+ this.target[5] = pose.roll ?? 0;
88
+ this.target[6] = pose.scale ?? 1;
89
+ this.duration = this.table.reducedMotion ? 0 : Math.max(0, seconds);
90
+ this.age = 0;
91
+ if (!this.duration) this.pose.set(this.target);
92
+ this.poseDirty = true;
93
+ this.table.wake();
94
+ }
95
+ flip(seconds = 0.65) {
96
+ this.start.set(this.pose);
97
+ this.target.set(this.pose);
98
+ this.target[4] = Math.round(this.target[4] / 180 + 1) * 180;
99
+ this.duration = this.table.reducedMotion ? 0 : seconds;
100
+ this.age = 0;
101
+ if (!this.duration) this.pose.set(this.target);
102
+ this.poseDirty = true;
103
+ this.table.wake();
104
+ }
105
+ setHover(u, v, active) {
106
+ const x = Math.max(-1, Math.min(1, u * 2 - 1));
107
+ const y = Math.max(-1, Math.min(1, v * 2 - 1));
108
+ if (this.hoverX === x && this.hoverY === y && this.hovered === active) return;
109
+ this.hoverX = x;
110
+ this.hoverY = y;
111
+ this.hovered = active;
112
+ this.data[8] = this.hoverX;
113
+ this.data[9] = this.hoverY;
114
+ this.data[10] = Number(active);
115
+ this.dataDirty = true;
116
+ this.table.wake();
117
+ }
118
+ /** Replaces the automatic driver settings; no per-frame instance uploads for foil-only motion. */
119
+ setAutomaticMotion(options) {
120
+ const motion = CardAutomaticMotion.deserialize({
121
+ phase: this.slot * 0.625 % 1024,
122
+ ...options
123
+ });
124
+ const enabled = Number(motion.enabled);
125
+ const seconds = Math.round(motion.seconds * 32) / 32;
126
+ const range = Math.round(motion.range * 1024) / 1024;
127
+ const phase = Math.round(motion.phase * 2) / 2;
128
+ if (this.automaticTilt === motion.tilt && this.data[12] === enabled && this.data[13] === seconds && this.data[14] === range && this.data[15] === phase)
129
+ return;
130
+ this.automaticTilt = motion.tilt;
131
+ this.data[12] = enabled;
132
+ this.data[13] = seconds;
133
+ this.data[14] = range;
134
+ this.data[15] = phase;
135
+ this.dataDirty = true;
136
+ this.table.wake();
137
+ }
138
+ setStrength(value, layer, side = "front") {
139
+ const amount = Math.max(0, Math.min(2, value));
140
+ if (layer === void 0) this.data[0] = amount;
141
+ else {
142
+ if (!Number.isInteger(layer) || layer < 0 || layer > 3)
143
+ throw new ShardError("cards/invalid-layer", "Layer must be 0\u20133");
144
+ this.data[((side === "front" ? 4 : 24) + 3 + layer * 4) * 4 + 1] = Math.min(1, amount);
145
+ }
146
+ this.dataDirty = true;
147
+ this.table.wake();
148
+ }
149
+ setFinish(layer, name, parameters = {}, side = "front") {
150
+ this.table.finish(this, layer, name, parameters, side);
151
+ }
152
+ dispose() {
153
+ this.table.remove(this);
154
+ }
155
+ }
156
+ class CardTableState {
157
+ world;
158
+ finishes;
159
+ cards = [];
160
+ recordBytes;
161
+ records;
162
+ material;
163
+ materialRef;
164
+ art;
165
+ masks;
166
+ capacity;
167
+ byId = /* @__PURE__ */ new Map();
168
+ definitionRefs = /* @__PURE__ */ new Map();
169
+ meshes = /* @__PURE__ */ new Map();
170
+ free = [];
171
+ textureRefs;
172
+ dirtyOrigin = { x: 0, y: 0, z: 0 };
173
+ writeTarget = {
174
+ texture: null,
175
+ origin: this.dirtyOrigin
176
+ };
177
+ writeLayout = { bytesPerRow: HALF_BYTES, rowsPerImage: 1 };
178
+ writeSize = { width: WIDTH, height: 1, depthOrArrayLayers: 1 };
179
+ disposed = false;
180
+ automaticTime = 0;
181
+ automaticPoint = new Float32Array(2);
182
+ reducedMotion = false;
183
+ animated = false;
184
+ uploadedBytes = 0;
185
+ constructor(world, finishes, type, art, masks, capacity = 1024, budgetMiB = 128) {
186
+ this.world = world;
187
+ this.finishes = finishes;
188
+ this.art = art;
189
+ this.masks = masks;
190
+ this.capacity = Math.min(2048, Math.max(1, Math.floor(capacity)));
191
+ this.recordBytes = new Uint8Array(this.capacity * HALF_BYTES);
192
+ this.records = Texture.create({
193
+ width: WIDTH,
194
+ height: this.capacity,
195
+ format: "rgba16float",
196
+ usage: "data",
197
+ mips: [this.recordBytes]
198
+ });
199
+ if (art.texture.byteSize + masks.texture.byteSize + this.records.byteSize > budgetMiB * 1024 * 1024) {
200
+ throw new ShardError("cards/texture-budget", `Card textures exceed ${budgetMiB} MiB`);
201
+ }
202
+ const textures = world.resource(Textures);
203
+ this.textureRefs = [
204
+ textures.add(art.texture),
205
+ textures.add(masks.texture),
206
+ textures.add(this.records)
207
+ ];
208
+ this.material = new MaterialAsset(
209
+ {
210
+ ...type.standard ? { baseColor: [1, 1, 1, 1] } : {},
211
+ art: this.textureRefs[0],
212
+ masks: this.textureRefs[1],
213
+ records: this.textureRefs[2]
214
+ },
215
+ type
216
+ );
217
+ this.materialRef = world.resource(Materials).add(this.material, "cards/batch");
218
+ }
219
+ wake() {
220
+ this.world.tryResource(FrameDemand)?.after(0);
221
+ }
222
+ spawn(definition, instanceId, pose) {
223
+ if (this.disposed) throw new ShardError("cards/disposed", "The card table has been disposed");
224
+ if (this.byId.has(instanceId)) throw new ShardError("cards/duplicate-id", instanceId);
225
+ const slot = this.free.length ? this.free.pop() : this.cards.length;
226
+ if (slot >= this.capacity)
227
+ throw new ShardError("cards/capacity", `Only ${this.capacity} cards can be exposed at once`);
228
+ const key = `${definition.size[0]}:${definition.size[1]}:${definition.cornerRadius}:${definition.thickness}`;
229
+ let mesh = this.meshes.get(key);
230
+ if (!mesh) {
231
+ const value = cardMesh(definition);
232
+ mesh = { mesh: value, ref: this.world.resource(Meshes).add(value) };
233
+ this.meshes.set(key, mesh);
234
+ }
235
+ let definitionRef = this.definitionRefs.get(definition);
236
+ if (!definitionRef) {
237
+ definitionRef = this.world.resource(CardDefinition.store).add(definition);
238
+ this.definitionRefs.set(definition, definitionRef);
239
+ }
240
+ const entity = this.world.spawn(
241
+ [Card, { instanceId, definition: definitionRef }],
242
+ CardInteraction,
243
+ [Mesh3d, { mesh: mesh.ref }],
244
+ [MeshMaterial, { material: this.materialRef }],
245
+ [InstanceData, { x: slot, y: 0 }],
246
+ Transform
247
+ );
248
+ const card = new CardHandle(this, slot, entity, instanceId, definition);
249
+ this.cards[slot] = card;
250
+ this.byId.set(instanceId, card);
251
+ card.data[0] = 1;
252
+ card.data[2] = definition.size[0] / definition.size[1];
253
+ card.data[3] = slot * 0.17;
254
+ for (let i = 0; i < 4; i++) card.data[4 + i] = definition.edgeColor[i];
255
+ this.face(card, definition.front, 4);
256
+ this.face(card, definition.back, 24);
257
+ if (pose) card.setPose(pose);
258
+ this.wake();
259
+ return card;
260
+ }
261
+ face(card, face, start) {
262
+ const d = card.data;
263
+ const artId = face.art?.path ?? face.art?.guid ?? "";
264
+ const index = this.art.indices.get(artId) ?? 0;
265
+ d[start * 4] = index;
266
+ d[start * 4 + 1] = ["contain", "cover", "stretch"].indexOf(face.fit);
267
+ d[start * 4 + 2] = this.art.aspects[index] ?? card.data[2];
268
+ d[start * 4 + 3] = face.rotation;
269
+ for (let k = 0; k < 4; k++) {
270
+ d[(start + 1) * 4 + k] = face.uvRect[k];
271
+ d[(start + 2) * 4 + k] = face.background[k];
272
+ }
273
+ for (let i = 0; i < 4; i++) {
274
+ const layer = face.finishes[i];
275
+ const offset = (start + 3 + i * 4) * 4;
276
+ d[offset + 8] = d[offset + 9] = 1;
277
+ if (!layer) continue;
278
+ d[offset + 1] = layer.strength;
279
+ const maskId = layer.mask.texture?.path ?? layer.mask.texture?.guid ?? "";
280
+ d[offset + 2] = this.masks.indices.get(maskId) ?? 0;
281
+ d[offset + 8] = layer.mask.scale[0];
282
+ d[offset + 9] = layer.mask.scale[1];
283
+ d[offset + 10] = layer.mask.offset[0];
284
+ d[offset + 11] = layer.mask.offset[1];
285
+ d[offset + 12] = ["r", "g", "b", "a"].indexOf(layer.mask.channel);
286
+ d[offset + 13] = Number(layer.mask.invert);
287
+ d[offset + 14] = Number(layer.mask.space === "art");
288
+ this.finish(
289
+ card,
290
+ i,
291
+ layer.family,
292
+ layer.parameters,
293
+ start === 4 ? "front" : "back"
294
+ );
295
+ }
296
+ }
297
+ finish(card, layer, name, parameters, side) {
298
+ if (!Number.isInteger(layer) || layer < 0 || layer > 3)
299
+ throw new ShardError("cards/invalid-layer", "Layer must be 0\u20133");
300
+ const index = this.finishes.findIndex((f) => f.name === name);
301
+ if (index < 0) throw new ShardError("cards/unknown-finish", `Unknown foil family: ${name}`);
302
+ const finish = this.finishes[index];
303
+ if (finish.options.baseline === "unsupported" && this.world.resource(Gpu).tier === "baseline") {
304
+ reportUnsupported(
305
+ this.world,
306
+ name,
307
+ `Card finish ${name} is unavailable on the baseline tier; showing the underlying print.`
308
+ );
309
+ }
310
+ const value = finish.schema.deserialize(parameters);
311
+ const offset = ((side === "front" ? 4 : 24) + 3 + layer * 4) * 4;
312
+ card.data[offset] = index + 1;
313
+ for (let i = 0; i < 4; i++)
314
+ card.data[offset + 4 + i] = i < finish.keys.length ? value[finish.keys[i]] : 0;
315
+ card.animationFps = 0;
316
+ for (let face = 0; face < 2; face++)
317
+ for (let l = 0; l < 4; l++) {
318
+ const family = card.data[((face === 0 ? 4 : 24) + 3 + l * 4) * 4];
319
+ const animated = this.finishes[family - 1]?.options.animated;
320
+ if (animated) card.animationFps = Math.max(card.animationFps, animated.fps);
321
+ }
322
+ card.dataDirty = true;
323
+ this.wake();
324
+ }
325
+ configure(values) {
326
+ this.material.set(values);
327
+ this.wake();
328
+ }
329
+ update(delta, elapsed) {
330
+ if (this.disposed) return;
331
+ let moving = false;
332
+ let dirty = false;
333
+ let animationFps = this.animated ? 60 : 0;
334
+ let automatic = false;
335
+ if (!this.reducedMotion) this.automaticTime += Math.max(0, Math.min(delta, 0.1));
336
+ this.uploadedBytes = 0;
337
+ for (let i = 0; i < this.cards.length; i++) {
338
+ const card = this.cards[i];
339
+ if (!card?.active) continue;
340
+ animationFps = Math.max(animationFps, card.animationFps);
341
+ if (!this.world.isAlive(card.entity)) {
342
+ this.remove(card);
343
+ continue;
344
+ }
345
+ if (card.duration > 0) {
346
+ card.age += delta;
347
+ const t = this.reducedMotion ? 1 : Math.min(1, card.age / card.duration);
348
+ const ease = t * t * (3 - 2 * t);
349
+ for (let k = 0; k < 7; k++)
350
+ card.pose[k] = card.start[k] + (card.target[k] - card.start[k]) * ease;
351
+ if (t === 1) card.duration = 0;
352
+ card.poseDirty = true;
353
+ moving = true;
354
+ }
355
+ const auto = card.data[12] > 0.5;
356
+ if (auto) {
357
+ automatic = true;
358
+ animationFps = Math.max(animationFps, 60);
359
+ }
360
+ const on = !this.reducedMotion && (auto ? card.automaticTilt : card.tilt && card.hovered);
361
+ let px = card.hoverX, py = card.hoverY;
362
+ if (auto && on) {
363
+ sampleCardMotion(
364
+ Math.fround(this.automaticTime),
365
+ card.data[13],
366
+ card.data[14],
367
+ card.data[15],
368
+ this.automaticPoint
369
+ );
370
+ px = this.automaticPoint[0];
371
+ py = this.automaticPoint[1];
372
+ }
373
+ const tx = on ? -py * card.maxTilt : 0, ty = on ? px * card.maxTilt : 0;
374
+ const damping = 1 - Math.exp(-Math.min(delta, 0.05) / (on ? 0.12 : 0.18));
375
+ if (Math.abs(card.tiltX - tx) + Math.abs(card.tiltY - ty) > 5e-3) {
376
+ card.tiltX += (tx - card.tiltX) * damping;
377
+ card.tiltY += (ty - card.tiltY) * damping;
378
+ moving = true;
379
+ card.poseDirty = true;
380
+ } else if (card.tiltX !== tx || card.tiltY !== ty) {
381
+ card.tiltX = tx;
382
+ card.tiltY = ty;
383
+ card.poseDirty = true;
384
+ }
385
+ if (card.poseDirty) {
386
+ card.poseDirty = false;
387
+ const x = (card.pose[3] + card.tiltX) * Math.PI / 360;
388
+ const y = (card.pose[4] + card.tiltY) * Math.PI / 360;
389
+ const z = card.pose[5] * Math.PI / 360;
390
+ const sx = Math.sin(x), cx = Math.cos(x), sy = Math.sin(y), cy = Math.cos(y), sz = Math.sin(z), cz = Math.cos(z);
391
+ card.rotation[0] = sx * cy * cz + cx * sy * sz;
392
+ card.rotation[1] = cx * sy * cz - sx * cy * sz;
393
+ card.rotation[2] = cx * cy * sz + sx * sy * cz;
394
+ card.rotation[3] = cx * cy * cz - sx * sy * sz;
395
+ for (let k = 0; k < 3; k++) {
396
+ card.translation[k] = card.pose[k];
397
+ card.scale[k] = card.pose[6];
398
+ }
399
+ this.world.set(card.entity, Transform, card.transform);
400
+ }
401
+ if (card.dataDirty) dirty = true;
402
+ }
403
+ if (dirty) this.flush();
404
+ const animating = animationFps > 0 && !this.reducedMotion && this.byId.size > 0;
405
+ if (animating) {
406
+ this.material.value.clock = elapsed;
407
+ if (automatic) this.material.value.automaticClock = this.automaticTime;
408
+ this.material.version++;
409
+ }
410
+ const demand = this.world.tryResource(FrameDemand);
411
+ demand?.set("cards/motion", moving);
412
+ if (animating) demand?.after(1e3 / animationFps);
413
+ }
414
+ flush() {
415
+ const assets = this.world.tryResource(GpuAssetsResource);
416
+ const gpu = assets?.texture(this.records);
417
+ if (!assets || !gpu) return;
418
+ this.writeTarget.texture = gpu.texture;
419
+ for (let i = 0; i < this.cards.length; i++) {
420
+ const card = this.cards[i];
421
+ if (!card?.dataDirty) continue;
422
+ const out = card.halfView;
423
+ for (let j = 0; j < FLOATS; j++) {
424
+ halfFloat[0] = card.data[j];
425
+ const bits = halfBits[0], sign = bits >>> 16 & 32768, exponent = (bits >>> 23 & 255) - 112;
426
+ const half = exponent <= 0 ? sign : exponent >= 31 ? sign | 31743 : sign | exponent << 10 | (bits & 8388607) >>> 13;
427
+ out.setUint16(j * 2, half, true);
428
+ }
429
+ this.dirtyOrigin.y = i;
430
+ assets.gpu.device.queue.writeTexture(
431
+ this.writeTarget,
432
+ card.bytes,
433
+ this.writeLayout,
434
+ this.writeSize
435
+ );
436
+ this.uploadedBytes += HALF_BYTES;
437
+ card.dataDirty = false;
438
+ }
439
+ }
440
+ remove(card) {
441
+ if (!card.active) return;
442
+ card.active = false;
443
+ if (this.world.isAlive(card.entity)) this.world.despawn(card.entity);
444
+ this.cards[card.slot] = void 0;
445
+ this.byId.delete(card.instanceId);
446
+ this.free.push(card.slot);
447
+ this.wake();
448
+ }
449
+ describe() {
450
+ return {
451
+ cards: this.byId.size,
452
+ capacity: this.capacity,
453
+ finishes: this.finishes.map((f) => f.name),
454
+ textureBytes: this.art.texture.byteSize + this.masks.texture.byteSize + this.records.byteSize,
455
+ uploadedBytes: this.uploadedBytes,
456
+ reducedMotion: this.reducedMotion
457
+ };
458
+ }
459
+ dispose() {
460
+ if (this.disposed) return;
461
+ for (let i = 0; i < this.cards.length; i++) if (this.cards[i]) this.remove(this.cards[i]);
462
+ const assets = this.world.tryResource(GpuAssetsResource);
463
+ assets?.releaseMaterial(this.material);
464
+ this.world.resource(Materials).delete(this.materialRef.guid);
465
+ assets?.releaseTexture(this.records);
466
+ for (const ref of this.textureRefs) this.world.resource(Textures).delete(ref.guid);
467
+ for (const ref of this.definitionRefs.values())
468
+ this.world.resource(CardDefinition.store).delete(ref.guid);
469
+ this.definitionRefs.clear();
470
+ for (const entry of this.meshes.values()) {
471
+ assets?.releaseMesh(entry.mesh);
472
+ this.world.resource(Meshes).delete(entry.ref.guid);
473
+ }
474
+ this.world.tryResource(FrameDemand)?.release("cards/motion");
475
+ this.disposed = true;
476
+ }
477
+ }
478
+ const Cards = defineResource("cards/Table", {
479
+ description: "Exposed cards, finish library and shared GPU pages."
480
+ });
481
+ function cardTiltCoordinates(card, origin, direction, out) {
482
+ const ax = card.pose[3] * Math.PI / 360, ay = card.pose[4] * Math.PI / 360, az = card.pose[5] * Math.PI / 360;
483
+ const sx = Math.sin(ax), cx = Math.cos(ax), sy = Math.sin(ay), cy = Math.cos(ay), sz = Math.sin(az), cz = Math.cos(az);
484
+ const x = sx * cy * cz + cx * sy * sz, y = cx * sy * cz - sx * cy * sz;
485
+ const z = cx * cy * sz + sx * sy * cz, w = cx * cy * cz - sx * sy * sz;
486
+ const m00 = 1 - 2 * (y * y + z * z), m10 = 2 * (x * y + w * z), m20 = 2 * (x * z - w * y);
487
+ const m01 = 2 * (x * y - w * z), m11 = 1 - 2 * (x * x + z * z), m21 = 2 * (y * z + w * x);
488
+ const m02 = 2 * (x * z + w * y), m12 = 2 * (y * z - w * x), m22 = 1 - 2 * (x * x + y * y);
489
+ const ox = origin[0] - card.pose[0], oy = origin[1] - card.pose[1], oz = origin[2] - card.pose[2];
490
+ const dx = direction[0], dy = direction[1], dz = direction[2];
491
+ const rayZ = dx * m02 + dy * m12 + dz * m22;
492
+ if (Math.abs(rayZ) < 1e-8) return false;
493
+ const distance = -(ox * m02 + oy * m12 + oz * m22) / rayZ;
494
+ if (distance < 0) return false;
495
+ out[0] = 0.5 + (ox * m00 + oy * m10 + oz * m20 + distance * (dx * m00 + dy * m10 + dz * m20)) / (card.pose[6] * card.definition.size[0]);
496
+ out[1] = 0.5 - (ox * m01 + oy * m11 + oz * m21 + distance * (dx * m01 + dy * m11 + dz * m21)) / (card.pose[6] * card.definition.size[1]);
497
+ return true;
498
+ }
499
+ function hitCards(table, origin, direction, out) {
500
+ out.card = void 0;
501
+ out.distance = Infinity;
502
+ for (let i = 0; i < table.cards.length; i++) {
503
+ const card = table.cards[i];
504
+ if (!card?.active || !card.interactive) continue;
505
+ const q = card.rotation, x = q[0], y = q[1], z = q[2], w = q[3];
506
+ const m00 = 1 - 2 * (y * y + z * z), m10 = 2 * (x * y + w * z), m20 = 2 * (x * z - w * y);
507
+ const m01 = 2 * (x * y - w * z), m11 = 1 - 2 * (x * x + z * z), m21 = 2 * (y * z + w * x);
508
+ const m02 = 2 * (x * z + w * y), m12 = 2 * (y * z - w * x), m22 = 1 - 2 * (x * x + y * y);
509
+ const ox = origin[0] - card.translation[0], oy = origin[1] - card.translation[1], oz = origin[2] - card.translation[2];
510
+ const dx = direction[0], dy = direction[1], dz = direction[2];
511
+ const localZ = ox * m02 + oy * m12 + oz * m22, rayZ = dx * m02 + dy * m12 + dz * m22;
512
+ if (Math.abs(rayZ) < 1e-8) continue;
513
+ const scale = card.pose[6];
514
+ const side = rayZ < 0 ? 1 : -1;
515
+ const distance = (side * card.definition.thickness * scale / 2 - localZ) / rayZ;
516
+ if (distance < 0 || distance >= out.distance) continue;
517
+ const px = (ox * m00 + oy * m10 + oz * m20 + distance * (dx * m00 + dy * m10 + dz * m20)) / scale;
518
+ const py = (ox * m01 + oy * m11 + oz * m21 + distance * (dx * m01 + dy * m11 + dz * m21)) / scale;
519
+ const hw = card.definition.size[0] / 2, hh = card.definition.size[1] / 2, r = card.definition.cornerRadius;
520
+ const ax = Math.max(Math.abs(px) - hw + r, 0), ay = Math.max(Math.abs(py) - hh + r, 0);
521
+ if (Math.abs(px) > hw || Math.abs(py) > hh || ax * ax + ay * ay > r * r) continue;
522
+ out.card = card;
523
+ out.side = side === 1 ? "front" : "back";
524
+ out.u = 0.5 + (side === 1 ? px : -px) / (2 * hw);
525
+ out.v = 0.5 - py / (2 * hh);
526
+ out.distance = distance;
527
+ }
528
+ return out.card !== void 0;
529
+ }
530
+ export {
531
+ CardHandle,
532
+ CardTableState,
533
+ Cards,
534
+ cardTiltCoordinates,
535
+ createCardHit,
536
+ hitCards
537
+ };
@@ -0,0 +1,17 @@
1
+ import { type Image, Texture } from '@aethervtt/shard-texture';
2
+ export interface CardImage {
3
+ id: string;
4
+ image: Image;
5
+ }
6
+ export interface CardTexturePage {
7
+ texture: Texture;
8
+ indices: ReadonlyMap<string, number>;
9
+ aspects: readonly number[];
10
+ }
11
+ /** Cold-path preparation; layer zero is neutral. Images never bleed across mip boundaries. */
12
+ export declare function prepareCardTextures(images: readonly CardImage[], options?: {
13
+ width?: number;
14
+ height?: number;
15
+ mask?: boolean;
16
+ budgetMiB?: number;
17
+ }): CardTexturePage;
@@ -0,0 +1,53 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ import { buildMips, resizeImage, Texture } from "@aethervtt/shard-texture";
3
+ function prepareCardTextures(images, options = {}) {
4
+ const width = options.width ?? 512, height = options.height ?? 720;
5
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || width > 16384 || height > 16384) {
6
+ throw new ShardError(
7
+ "cards/texture-size",
8
+ "Texture page dimensions must be integers between 1 and 16384"
9
+ );
10
+ }
11
+ const indices = /* @__PURE__ */ new Map();
12
+ const unique = [];
13
+ for (const image of images)
14
+ if (!indices.has(image.id)) {
15
+ indices.set(image.id, unique.length + 1);
16
+ unique.push(image);
17
+ }
18
+ const layers = unique.length + 1;
19
+ let bytes = 0;
20
+ for (let w = width, h = height; ; w = Math.max(1, w >> 1), h = Math.max(1, h >> 1)) {
21
+ bytes += w * h * 4 * layers;
22
+ if (w === 1 && h === 1) break;
23
+ }
24
+ if (bytes > (options.budgetMiB ?? 64) * 1024 * 1024 || layers > 256) {
25
+ throw new ShardError("cards/texture-budget", "Prepared card textures exceed the page budget", {
26
+ hint: "Use a visible subset or a smaller resolution; prepare an inspection page separately."
27
+ });
28
+ }
29
+ const usage = options.mask ? "data" : "color";
30
+ const mips = [];
31
+ const aspects = [width / height];
32
+ for (let layer = 0; layer < layers; layer++) {
33
+ const item = unique[layer - 1];
34
+ const image = item ? resizeImage(item.image, width, height) : { width, height, kind: "u8", data: new Uint8Array(width * height * 4).fill(255) };
35
+ if (item) aspects.push(item.image.width / item.image.height);
36
+ const chain = buildMips(image, {
37
+ usage,
38
+ mipmaps: true,
39
+ maxSize: 16384,
40
+ flipY: false,
41
+ premultiplyAlpha: false
42
+ });
43
+ for (let level = 0; level < chain.levels.length; level++) {
44
+ const data = chain.levels[level];
45
+ mips[level] ??= new Uint8Array(data.length * layers);
46
+ mips[level].set(data, layer * data.length);
47
+ }
48
+ }
49
+ return { texture: Texture.create({ width, height, layers, usage, mips }), indices, aspects };
50
+ }
51
+ export {
52
+ prepareCardTextures
53
+ };
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@aethervtt/shard-cards",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "LICENSE",
16
+ "README.md"
17
+ ],
18
+ "dependencies": {
19
+ "@aethervtt/shard-assets": "0.2.1",
20
+ "@aethervtt/shard-core": "0.2.1",
21
+ "@aethervtt/shard-gpu": "0.2.1",
22
+ "@aethervtt/shard-input": "0.2.1",
23
+ "@aethervtt/shard-mesh": "0.2.1",
24
+ "@aethervtt/shard-render": "0.2.1",
25
+ "@aethervtt/shard-runtime": "0.2.1",
26
+ "@aethervtt/shard-texture": "0.2.1",
27
+ "@aethervtt/shard-transform": "0.2.1"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/aethervtt/shard.git",
32
+ "directory": "packages/cards"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "provenance": true
37
+ },
38
+ "shard": {
39
+ "buildSha": "772eb0d2b8a5fc499105708f4ae92c58788cc9ea"
40
+ }
41
+ }