jaml-ui 0.1.0 → 0.3.0

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.
Files changed (47) hide show
  1. package/README.md +37 -0
  2. package/dist/components/GameCard.d.ts +8 -4
  3. package/dist/components/GameCard.js +8 -8
  4. package/dist/components/JamlIde.d.ts +17 -0
  5. package/dist/components/JamlIde.js +61 -0
  6. package/dist/components/JamlIdeToolbar.d.ts +8 -0
  7. package/dist/components/JamlIdeToolbar.js +36 -0
  8. package/dist/components/JamlMapPreview.d.ts +10 -0
  9. package/dist/components/JamlMapPreview.js +117 -0
  10. package/dist/data/balatro-jokers.json +1241 -0
  11. package/dist/decode/motelyItemDecoder.d.ts +49 -5
  12. package/dist/decode/motelyItemDecoder.js +227 -8
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.js +4 -0
  15. package/dist/motely.d.ts +1 -1
  16. package/dist/motely.js +1 -1
  17. package/dist/r3f/BalatroJokerMesh3D.d.ts +8 -0
  18. package/dist/r3f/BalatroJokerMesh3D.js +98 -0
  19. package/dist/r3f/BalatroJokerPreview3D.d.ts +14 -0
  20. package/dist/r3f/BalatroJokerPreview3D.js +30 -0
  21. package/dist/r3f/BalatroPlayingCard3D.d.ts +22 -0
  22. package/dist/r3f/BalatroPlayingCard3D.js +62 -0
  23. package/dist/r3f/cardConstants.d.ts +16 -0
  24. package/dist/r3f/cardConstants.js +14 -0
  25. package/dist/r3f/compositedAtlas.d.ts +5 -0
  26. package/dist/r3f/compositedAtlas.js +56 -0
  27. package/dist/r3f/gridUV.d.ts +22 -0
  28. package/dist/r3f/gridUV.js +30 -0
  29. package/dist/r3f/index.d.ts +12 -0
  30. package/dist/r3f/index.js +13 -0
  31. package/dist/r3f/jokerRegistry.d.ts +28 -0
  32. package/dist/r3f/jokerRegistry.js +40 -0
  33. package/dist/r3f/jokerTilt.d.ts +8 -0
  34. package/dist/r3f/jokerTilt.js +41 -0
  35. package/dist/r3f/magneticTilt.d.ts +18 -0
  36. package/dist/r3f/magneticTilt.js +34 -0
  37. package/dist/r3f/playingCardTypes.d.ts +24 -0
  38. package/dist/r3f/playingCardTypes.js +32 -0
  39. package/dist/r3f/playingCardVisuals.d.ts +7 -0
  40. package/dist/r3f/playingCardVisuals.js +45 -0
  41. package/dist/r3f/usePlayingCardTexture.d.ts +7 -0
  42. package/dist/r3f/usePlayingCardTexture.js +92 -0
  43. package/dist/render/CanvasRenderer.d.ts +2 -1
  44. package/dist/render/CanvasRenderer.js +31 -3
  45. package/dist/utils/jamlMapPreview.d.ts +12 -0
  46. package/dist/utils/jamlMapPreview.js +105 -0
  47. package/package.json +11 -3
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Composite transparent 8Bit deck pips onto Enhancers card fronts (per-cell).
3
+ * Output canvas matches atlas pixel size so UV math is unchanged.
4
+ */
5
+ import { resolveJamlAssetUrl } from "../assets.js";
6
+ const CELL_W = 71;
7
+ const CELL_H = 95;
8
+ const DEFAULT_BASE_POS = { x: 1, y: 0 };
9
+ const cache = new Map();
10
+ const pending = new Map();
11
+ function loadImage(src) {
12
+ return new Promise((resolve, reject) => {
13
+ const img = new Image();
14
+ img.crossOrigin = "anonymous";
15
+ img.onload = () => resolve(img);
16
+ img.onerror = reject;
17
+ img.src = src;
18
+ });
19
+ }
20
+ export function loadCompositedPlayingAtlas(atlasUrl, cols, rows, enhancersUrl) {
21
+ const enh = enhancersUrl ?? resolveJamlAssetUrl("enhancers");
22
+ const cacheKey = `${atlasUrl}|${enh}|${cols}x${rows}`;
23
+ const cached = cache.get(cacheKey);
24
+ if (cached)
25
+ return Promise.resolve(cached);
26
+ const inflight = pending.get(cacheKey);
27
+ if (inflight)
28
+ return inflight;
29
+ const promise = (async () => {
30
+ const [atlasImg, enhancersImg] = await Promise.all([
31
+ loadImage(atlasUrl),
32
+ loadImage(enh),
33
+ ]);
34
+ const canvas = document.createElement("canvas");
35
+ canvas.width = atlasImg.naturalWidth;
36
+ canvas.height = atlasImg.naturalHeight;
37
+ const ctx = canvas.getContext("2d");
38
+ if (!ctx)
39
+ throw new Error("2d context unavailable");
40
+ const baseSx = DEFAULT_BASE_POS.x * CELL_W;
41
+ const baseSy = DEFAULT_BASE_POS.y * CELL_H;
42
+ for (let row = 0; row < rows; row++) {
43
+ for (let col = 0; col < cols; col++) {
44
+ const dx = col * CELL_W;
45
+ const dy = row * CELL_H;
46
+ ctx.drawImage(enhancersImg, baseSx, baseSy, CELL_W, CELL_H, dx, dy, CELL_W, CELL_H);
47
+ ctx.drawImage(atlasImg, dx, dy, CELL_W, CELL_H, dx, dy, CELL_W, CELL_H);
48
+ }
49
+ }
50
+ cache.set(cacheKey, canvas);
51
+ pending.delete(cacheKey);
52
+ return canvas;
53
+ })();
54
+ pending.set(cacheKey, promise);
55
+ return promise;
56
+ }
@@ -0,0 +1,22 @@
1
+ import * as THREE from "three";
2
+ export type GridPos = {
3
+ x: number;
4
+ y: number;
5
+ };
6
+ type TexImage = THREE.Texture["image"];
7
+ /** Pixel size of a loaded texture (image, canvas, or ImageBitmap). */
8
+ export declare function getLoadedTexturePixelSize(image: TexImage): {
9
+ width: number;
10
+ height: number;
11
+ };
12
+ /**
13
+ * Balatro / Love2D-style atlas: top-left origin, y grows downward.
14
+ * Maps one grid cell to UV repeat/offset on the full texture.
15
+ */
16
+ export declare function applyBalatroGridUV(tex: THREE.Texture, pos: GridPos, opts: {
17
+ cellW: number;
18
+ cellH: number;
19
+ textureWidth: number;
20
+ textureHeight: number;
21
+ }): void;
22
+ export {};
@@ -0,0 +1,30 @@
1
+ import * as THREE from "three";
2
+ /** Pixel size of a loaded texture (image, canvas, or ImageBitmap). */
3
+ export function getLoadedTexturePixelSize(image) {
4
+ if (typeof HTMLImageElement !== "undefined" && image instanceof HTMLImageElement) {
5
+ return {
6
+ width: image.naturalWidth || image.width,
7
+ height: image.naturalHeight || image.height,
8
+ };
9
+ }
10
+ if (typeof HTMLCanvasElement !== "undefined" && image instanceof HTMLCanvasElement) {
11
+ return { width: image.width, height: image.height };
12
+ }
13
+ if (typeof ImageBitmap !== "undefined" && image instanceof ImageBitmap) {
14
+ return { width: image.width, height: image.height };
15
+ }
16
+ return { width: 0, height: 0 };
17
+ }
18
+ /**
19
+ * Balatro / Love2D-style atlas: top-left origin, y grows downward.
20
+ * Maps one grid cell to UV repeat/offset on the full texture.
21
+ */
22
+ export function applyBalatroGridUV(tex, pos, opts) {
23
+ const cols = opts.textureWidth / opts.cellW;
24
+ const rows = opts.textureHeight / opts.cellH;
25
+ tex.wrapS = THREE.ClampToEdgeWrapping;
26
+ tex.wrapT = THREE.ClampToEdgeWrapping;
27
+ tex.repeat.set(1 / cols, 1 / rows);
28
+ tex.offset.set(pos.x / cols, 1 - (pos.y + 1) / rows);
29
+ tex.needsUpdate = true;
30
+ }
@@ -0,0 +1,12 @@
1
+ export { applyBalatroGridUV, getLoadedTexturePixelSize, type GridPos } from "./gridUV.js";
2
+ export { loadCompositedPlayingAtlas } from "./compositedAtlas.js";
3
+ export { BALATRO_JOKER_ATLAS_META, BALATRO_JOKERS, findJokerByDisplayName, findJokerByKey, getJokerAtlasGridSize, getJokerAtlasImageUrl, type BalatroJokerCenter, } from "./jokerRegistry.js";
4
+ export { DEFAULT_CARD_BOX, DEFAULT_CARD_MAGNET, type CardBoxOptions, type CardMagnetOptions, } from "./cardConstants.js";
5
+ export { magneticTargetFromUvPlayingCard, lerpMagneticGroup, createZeroMagneticTarget, resetMagneticTarget, type MagneticTarget, } from "./magneticTilt.js";
6
+ export { PLAYING_CARD_ATLAS, RANK_COLUMN, SUIT_COLORS, SUIT_ROW, type PlayingCard3DModel, type PlayingCardEdition, type PlayingCardEnhancement, type PlayingCardRank, type PlayingCardSeal, type PlayingCardSuit, } from "./playingCardTypes.js";
7
+ export { editionMaterialProps, enhancementGlowHex, sealColorHex } from "./playingCardVisuals.js";
8
+ export { usePlayingCardFaceTexture, useCardBackTexture } from "./usePlayingCardTexture.js";
9
+ export { BalatroPlayingCard3D, type BalatroPlayingCard3DProps } from "./BalatroPlayingCard3D.js";
10
+ export { BalatroJokerMesh3D, type BalatroJokerMesh3DProps } from "./BalatroJokerMesh3D.js";
11
+ export { BalatroJokerPreview3D, BalatroJokerThreePreview, type BalatroJokerPreview3DProps, } from "./BalatroJokerPreview3D.js";
12
+ export { jokerAmbientTiltAtTime, jokerPointerTiltFromUv, JOKER_TILT_LERP_IN, JOKER_TILT_LERP_OUT, stableIdFraction, } from "./jokerTilt.js";
@@ -0,0 +1,13 @@
1
+ "use client";
2
+ export { applyBalatroGridUV, getLoadedTexturePixelSize } from "./gridUV.js";
3
+ export { loadCompositedPlayingAtlas } from "./compositedAtlas.js";
4
+ export { BALATRO_JOKER_ATLAS_META, BALATRO_JOKERS, findJokerByDisplayName, findJokerByKey, getJokerAtlasGridSize, getJokerAtlasImageUrl, } from "./jokerRegistry.js";
5
+ export { DEFAULT_CARD_BOX, DEFAULT_CARD_MAGNET, } from "./cardConstants.js";
6
+ export { magneticTargetFromUvPlayingCard, lerpMagneticGroup, createZeroMagneticTarget, resetMagneticTarget, } from "./magneticTilt.js";
7
+ export { PLAYING_CARD_ATLAS, RANK_COLUMN, SUIT_COLORS, SUIT_ROW, } from "./playingCardTypes.js";
8
+ export { editionMaterialProps, enhancementGlowHex, sealColorHex } from "./playingCardVisuals.js";
9
+ export { usePlayingCardFaceTexture, useCardBackTexture } from "./usePlayingCardTexture.js";
10
+ export { BalatroPlayingCard3D } from "./BalatroPlayingCard3D.js";
11
+ export { BalatroJokerMesh3D } from "./BalatroJokerMesh3D.js";
12
+ export { BalatroJokerPreview3D, BalatroJokerThreePreview, } from "./BalatroJokerPreview3D.js";
13
+ export { jokerAmbientTiltAtTime, jokerPointerTiltFromUv, JOKER_TILT_LERP_IN, JOKER_TILT_LERP_OUT, stableIdFraction, } from "./jokerTilt.js";
@@ -0,0 +1,28 @@
1
+ export type BalatroJokerCenter = {
2
+ key: string;
3
+ name: string;
4
+ pos: {
5
+ x: number;
6
+ y: number;
7
+ };
8
+ soulPos?: {
9
+ x: number;
10
+ y: number;
11
+ };
12
+ };
13
+ export declare const BALATRO_JOKER_ATLAS_META: {
14
+ publicPath: string;
15
+ cellPx: {
16
+ x: number;
17
+ y: number;
18
+ };
19
+ };
20
+ export declare const BALATRO_JOKERS: BalatroJokerCenter[];
21
+ /** Prefer bundled asset resolver so hosts can override base URL. */
22
+ export declare function getJokerAtlasImageUrl(): string;
23
+ export declare function getJokerAtlasGridSize(): {
24
+ cols: number;
25
+ rows: number;
26
+ };
27
+ export declare function findJokerByDisplayName(displayName: string): BalatroJokerCenter | undefined;
28
+ export declare function findJokerByKey(key: string): BalatroJokerCenter | undefined;
@@ -0,0 +1,40 @@
1
+ import jokersPayload from "../data/balatro-jokers.json";
2
+ import { resolveJamlAssetUrl } from "../assets.js";
3
+ const payload = jokersPayload;
4
+ export const BALATRO_JOKER_ATLAS_META = payload.atlas;
5
+ export const BALATRO_JOKERS = payload.jokers;
6
+ /** Prefer bundled asset resolver so hosts can override base URL. */
7
+ export function getJokerAtlasImageUrl() {
8
+ return resolveJamlAssetUrl("jokers");
9
+ }
10
+ export function getJokerAtlasGridSize() {
11
+ let mx = 0;
12
+ let my = 0;
13
+ for (const j of BALATRO_JOKERS) {
14
+ mx = Math.max(mx, j.pos.x);
15
+ my = Math.max(my, j.pos.y);
16
+ if (j.soulPos) {
17
+ mx = Math.max(mx, j.soulPos.x);
18
+ my = Math.max(my, j.soulPos.y);
19
+ }
20
+ }
21
+ return { cols: mx + 1, rows: my + 1 };
22
+ }
23
+ const NAME_ALIASES = {
24
+ canio: "Caino",
25
+ séance: "Seance",
26
+ seance: "Seance",
27
+ };
28
+ function normalizeLookup(name) {
29
+ const t = name.trim();
30
+ const alias = NAME_ALIASES[t.toLowerCase()];
31
+ return alias ?? t;
32
+ }
33
+ export function findJokerByDisplayName(displayName) {
34
+ const needle = normalizeLookup(displayName).toLowerCase();
35
+ return BALATRO_JOKERS.find((j) => j.name.toLowerCase() === needle);
36
+ }
37
+ export function findJokerByKey(key) {
38
+ const k = key.trim().toLowerCase();
39
+ return BALATRO_JOKERS.find((j) => j.key.toLowerCase() === k);
40
+ }
@@ -0,0 +1,8 @@
1
+ import * as THREE from "three";
2
+ import type { MagneticTarget } from "./magneticTilt.js";
3
+ export declare const JOKER_TILT_LERP_IN = 20;
4
+ export declare const JOKER_TILT_LERP_OUT = 11;
5
+ export declare function stableIdFraction(s: string): number;
6
+ export declare function applyJokerTiltFromNormalized(nx: number, ny: number, amtScale: number, target: MagneticTarget): void;
7
+ export declare function jokerPointerTiltFromUv(uv: THREE.Vector2, target: MagneticTarget): void;
8
+ export declare function jokerAmbientTiltAtTime(t: number, idFrac: number, target: MagneticTarget): void;
@@ -0,0 +1,41 @@
1
+ import * as THREE from "three";
2
+ const AMBIENT_TILT = 0.2;
3
+ const TILT_FACTOR = 0.3;
4
+ const MAGNET_MAX_RX = 0.34;
5
+ const MAGNET_MAX_RY = 0.4;
6
+ const MAGNET_MAX_SHIFT = 0.05;
7
+ const MAGNET_TWIST_Z = 0.1;
8
+ export const JOKER_TILT_LERP_IN = 20;
9
+ export const JOKER_TILT_LERP_OUT = 11;
10
+ export function stableIdFraction(s) {
11
+ let h = 2166136261;
12
+ for (let i = 0; i < s.length; i++) {
13
+ h ^= s.charCodeAt(i);
14
+ h = Math.imul(h, 16777619);
15
+ }
16
+ return ((h >>> 0) % 100000) / 100000;
17
+ }
18
+ export function applyJokerTiltFromNormalized(nx, ny, amtScale, target) {
19
+ const clampedX = THREE.MathUtils.clamp(nx, -1, 1);
20
+ const clampedY = THREE.MathUtils.clamp(ny, -1, 1);
21
+ target.ry = -clampedX * MAGNET_MAX_RY * amtScale;
22
+ target.rx = clampedY * MAGNET_MAX_RX * amtScale;
23
+ target.rz = -clampedX * clampedY * MAGNET_TWIST_Z * amtScale;
24
+ target.ox = clampedX * MAGNET_MAX_SHIFT * amtScale;
25
+ target.oy = -clampedY * MAGNET_MAX_SHIFT * 0.65 * amtScale;
26
+ }
27
+ export function jokerPointerTiltFromUv(uv, target) {
28
+ const nx = (uv.x - 0.5) * 2;
29
+ const ny = (uv.y - 0.5) * 2;
30
+ const amt = Math.abs(ny + nx - 1) * TILT_FACTOR;
31
+ applyJokerTiltFromNormalized(nx, ny, THREE.MathUtils.clamp(amt * 1.15, 0.35, 1.25), target);
32
+ }
33
+ export function jokerAmbientTiltAtTime(t, idFrac, target) {
34
+ const tiltAngle = t * (1.56 + ((idFrac / 1.14212) % 1)) + idFrac / 1.35122;
35
+ const nu = 0.5 + 0.5 * AMBIENT_TILT * Math.cos(tiltAngle);
36
+ const nv = 0.5 + 0.5 * AMBIENT_TILT * Math.sin(tiltAngle);
37
+ const nx = (nu - 0.5) * 2;
38
+ const ny = (nv - 0.5) * 2;
39
+ const amt = AMBIENT_TILT * (0.5 + Math.cos(tiltAngle)) * TILT_FACTOR;
40
+ applyJokerTiltFromNormalized(nx, ny, THREE.MathUtils.clamp(amt * 2.2, 0.2, 1), target);
41
+ }
@@ -0,0 +1,18 @@
1
+ import * as THREE from "three";
2
+ export type MagneticTarget = {
3
+ rx: number;
4
+ ry: number;
5
+ rz: number;
6
+ ox: number;
7
+ oy: number;
8
+ };
9
+ export declare function createZeroMagneticTarget(): MagneticTarget;
10
+ /** Map pointer UV on card face to rotation + in-plane shift (Balatro-style). */
11
+ export declare function magneticTargetFromUvPlayingCard(uv: THREE.Vector2, magnet: {
12
+ MAX_TILT_X: number;
13
+ MAX_TILT_Y: number;
14
+ MAX_SHIFT: number;
15
+ TWIST_Z: number;
16
+ }): MagneticTarget;
17
+ export declare function resetMagneticTarget(t: MagneticTarget): void;
18
+ export declare function lerpMagneticGroup(group: THREE.Group, target: MagneticTarget, dt: number, lerpIn: number, lerpOut: number, hovered: boolean, extraRz: number): void;
@@ -0,0 +1,34 @@
1
+ import * as THREE from "three";
2
+ export function createZeroMagneticTarget() {
3
+ return { rx: 0, ry: 0, rz: 0, ox: 0, oy: 0 };
4
+ }
5
+ /** Map pointer UV on card face to rotation + in-plane shift (Balatro-style). */
6
+ export function magneticTargetFromUvPlayingCard(uv, magnet) {
7
+ const nx = (uv.x - 0.5) * 2;
8
+ const ny = (uv.y - 0.5) * 2;
9
+ const clampedX = THREE.MathUtils.clamp(nx, -1, 1);
10
+ const clampedY = THREE.MathUtils.clamp(ny, -1, 1);
11
+ return {
12
+ ry: -clampedX * magnet.MAX_TILT_Y,
13
+ rx: clampedY * magnet.MAX_TILT_X,
14
+ rz: -clampedX * clampedY * magnet.TWIST_Z,
15
+ ox: clampedX * magnet.MAX_SHIFT,
16
+ oy: -clampedY * magnet.MAX_SHIFT * 0.65,
17
+ };
18
+ }
19
+ export function resetMagneticTarget(t) {
20
+ t.rx = 0;
21
+ t.ry = 0;
22
+ t.rz = 0;
23
+ t.ox = 0;
24
+ t.oy = 0;
25
+ }
26
+ export function lerpMagneticGroup(group, target, dt, lerpIn, lerpOut, hovered, extraRz) {
27
+ const rate = hovered ? lerpIn : lerpOut;
28
+ const a = 1 - Math.exp(-rate * dt);
29
+ group.rotation.x = THREE.MathUtils.lerp(group.rotation.x, target.rx, a);
30
+ group.rotation.y = THREE.MathUtils.lerp(group.rotation.y, target.ry, a);
31
+ group.rotation.z = THREE.MathUtils.lerp(group.rotation.z, target.rz + extraRz, a);
32
+ group.position.x = THREE.MathUtils.lerp(group.position.x, target.ox, a);
33
+ group.position.y = THREE.MathUtils.lerp(group.position.y, target.oy, a);
34
+ }
@@ -0,0 +1,24 @@
1
+ export type PlayingCardSuit = "hearts" | "diamonds" | "clubs" | "spades";
2
+ export type PlayingCardRank = "A" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "J" | "Q" | "K";
3
+ export type PlayingCardEnhancement = "bonus" | "mult" | "wild" | "glass" | "steel" | "stone" | "gold" | "lucky";
4
+ export type PlayingCardSeal = "gold" | "red" | "blue" | "purple";
5
+ export type PlayingCardEdition = "foil" | "holographic" | "polychrome" | "negative";
6
+ /** Minimal card model for 3D mesh (game-specific id/chips optional). */
7
+ export interface PlayingCard3DModel {
8
+ suit: PlayingCardSuit;
9
+ rank: PlayingCardRank;
10
+ enhancement?: PlayingCardEnhancement;
11
+ seal?: PlayingCardSeal;
12
+ edition?: PlayingCardEdition;
13
+ }
14
+ export declare const PLAYING_CARD_ATLAS: {
15
+ readonly columns: 13;
16
+ readonly rows: 4;
17
+ readonly cellPx: {
18
+ readonly x: 71;
19
+ readonly y: 95;
20
+ };
21
+ };
22
+ export declare const SUIT_ROW: Record<PlayingCardSuit, number>;
23
+ export declare const RANK_COLUMN: Record<PlayingCardRank, number>;
24
+ export declare const SUIT_COLORS: Record<PlayingCardSuit, string>;
@@ -0,0 +1,32 @@
1
+ export const PLAYING_CARD_ATLAS = {
2
+ columns: 13,
3
+ rows: 4,
4
+ cellPx: { x: 71, y: 95 },
5
+ };
6
+ export const SUIT_ROW = {
7
+ hearts: 0,
8
+ clubs: 1,
9
+ diamonds: 2,
10
+ spades: 3,
11
+ };
12
+ export const RANK_COLUMN = {
13
+ "2": 0,
14
+ "3": 1,
15
+ "4": 2,
16
+ "5": 3,
17
+ "6": 4,
18
+ "7": 5,
19
+ "8": 6,
20
+ "9": 7,
21
+ "10": 8,
22
+ J: 9,
23
+ Q: 10,
24
+ K: 11,
25
+ A: 12,
26
+ };
27
+ export const SUIT_COLORS = {
28
+ hearts: "#e74c3c",
29
+ diamonds: "#3498db",
30
+ clubs: "#27ae60",
31
+ spades: "#2c3e50",
32
+ };
@@ -0,0 +1,7 @@
1
+ import type { PlayingCard3DModel } from "./playingCardTypes.js";
2
+ export declare function editionMaterialProps(edition: PlayingCard3DModel["edition"]): {
3
+ metalness: number;
4
+ roughness: number;
5
+ };
6
+ export declare function enhancementGlowHex(card: PlayingCard3DModel, highlighted: boolean): string;
7
+ export declare function sealColorHex(seal: PlayingCard3DModel["seal"]): string;
@@ -0,0 +1,45 @@
1
+ import { SUIT_COLORS } from "./playingCardTypes.js";
2
+ export function editionMaterialProps(edition) {
3
+ switch (edition) {
4
+ case "foil":
5
+ return { metalness: 0.9, roughness: 0.1 };
6
+ case "holographic":
7
+ return { metalness: 0.7, roughness: 0.2 };
8
+ case "polychrome":
9
+ return { metalness: 0.8, roughness: 0.15 };
10
+ default:
11
+ return { metalness: 0.1, roughness: 0.8 };
12
+ }
13
+ }
14
+ export function enhancementGlowHex(card, highlighted) {
15
+ switch (card.enhancement) {
16
+ case "bonus":
17
+ return "#3498db";
18
+ case "mult":
19
+ return "#e74c3c";
20
+ case "wild":
21
+ return "#9b59b6";
22
+ case "glass":
23
+ return "#1abc9c";
24
+ case "steel":
25
+ return "#95a5a6";
26
+ case "gold":
27
+ return "#f1c40f";
28
+ case "lucky":
29
+ return "#2ecc71";
30
+ default:
31
+ return highlighted ? SUIT_COLORS[card.suit] : "#ffffff";
32
+ }
33
+ }
34
+ export function sealColorHex(seal) {
35
+ switch (seal) {
36
+ case "gold":
37
+ return "#f1c40f";
38
+ case "red":
39
+ return "#e74c3c";
40
+ case "blue":
41
+ return "#3498db";
42
+ default:
43
+ return "#9b59b6";
44
+ }
45
+ }
@@ -0,0 +1,7 @@
1
+ import * as THREE from "three";
2
+ import { type PlayingCardRank, type PlayingCardSuit } from "./playingCardTypes.js";
3
+ export declare function usePlayingCardFaceTexture(suit: PlayingCardSuit, rank: PlayingCardRank, options?: {
4
+ deckUrl?: string;
5
+ enhancersUrl?: string;
6
+ }): THREE.Texture | null;
7
+ export declare function useCardBackTexture(): THREE.Texture;
@@ -0,0 +1,92 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import * as THREE from "three";
4
+ import { resolveJamlAssetUrl } from "../assets.js";
5
+ import { applyBalatroGridUV } from "./gridUV.js";
6
+ import { loadCompositedPlayingAtlas } from "./compositedAtlas.js";
7
+ import { PLAYING_CARD_ATLAS, RANK_COLUMN, SUIT_ROW, } from "./playingCardTypes.js";
8
+ const textureCache = new Map();
9
+ function magnetKey(suit, rank, deckUrl, enhancersUrl) {
10
+ return `${deckUrl}|${enhancersUrl}|${suit}|${rank}`;
11
+ }
12
+ export function usePlayingCardFaceTexture(suit, rank, options) {
13
+ const [texture, setTexture] = useState(null);
14
+ const loadSerial = useRef(0);
15
+ const deckUrl = options?.deckUrl ?? resolveJamlAssetUrl("deck");
16
+ const enhancersUrl = options?.enhancersUrl ?? resolveJamlAssetUrl("enhancers");
17
+ useEffect(() => {
18
+ const serial = ++loadSerial.current;
19
+ let cancelled = false;
20
+ const key = magnetKey(suit, rank, deckUrl, enhancersUrl);
21
+ const cached = textureCache.get(key);
22
+ if (cached) {
23
+ setTexture(cached);
24
+ return;
25
+ }
26
+ loadCompositedPlayingAtlas(deckUrl, PLAYING_CARD_ATLAS.columns, PLAYING_CARD_ATLAS.rows, enhancersUrl)
27
+ .then((canvas) => {
28
+ if (cancelled || serial !== loadSerial.current)
29
+ return;
30
+ const tex = new THREE.CanvasTexture(canvas);
31
+ tex.colorSpace = THREE.SRGBColorSpace;
32
+ tex.magFilter = THREE.NearestFilter;
33
+ tex.minFilter = THREE.NearestFilter;
34
+ const tw = canvas.width;
35
+ const th = canvas.height;
36
+ const cellW = tw / PLAYING_CARD_ATLAS.columns;
37
+ const cellH = th / PLAYING_CARD_ATLAS.rows;
38
+ applyBalatroGridUV(tex, { x: RANK_COLUMN[rank], y: SUIT_ROW[suit] }, {
39
+ cellW,
40
+ cellH,
41
+ textureWidth: tw,
42
+ textureHeight: th,
43
+ });
44
+ textureCache.set(key, tex);
45
+ setTexture(tex);
46
+ })
47
+ .catch((err) => {
48
+ console.error("[jaml-ui/r3f] playing card atlas failed:", err);
49
+ });
50
+ return () => {
51
+ cancelled = true;
52
+ };
53
+ }, [suit, rank, deckUrl, enhancersUrl]);
54
+ return texture;
55
+ }
56
+ export function useCardBackTexture() {
57
+ const [tex] = useState(() => {
58
+ const canvas = document.createElement("canvas");
59
+ canvas.width = PLAYING_CARD_ATLAS.cellPx.x;
60
+ canvas.height = PLAYING_CARD_ATLAS.cellPx.y;
61
+ const ctx = canvas.getContext("2d");
62
+ if (!ctx)
63
+ throw new Error("2d context unavailable");
64
+ ctx.fillStyle = "#1a1a2e";
65
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
66
+ ctx.strokeStyle = "#c9a227";
67
+ ctx.lineWidth = 2;
68
+ ctx.strokeRect(2, 2, canvas.width - 4, canvas.height - 4);
69
+ ctx.fillStyle = "#16213e";
70
+ for (let y = 8; y < canvas.height - 8; y += 10) {
71
+ for (let x = 8; x < canvas.width - 8; x += 10) {
72
+ ctx.fillRect(x, y, 5, 5);
73
+ }
74
+ }
75
+ ctx.fillStyle = "#c9a227";
76
+ const cx = canvas.width / 2;
77
+ const cy = canvas.height / 2;
78
+ ctx.beginPath();
79
+ ctx.moveTo(cx, cy - 17);
80
+ ctx.lineTo(cx + 14, cy);
81
+ ctx.lineTo(cx, cy + 17);
82
+ ctx.lineTo(cx - 14, cy);
83
+ ctx.closePath();
84
+ ctx.fill();
85
+ const texture = new THREE.CanvasTexture(canvas);
86
+ texture.magFilter = THREE.NearestFilter;
87
+ texture.minFilter = THREE.NearestFilter;
88
+ texture.colorSpace = THREE.SRGBColorSpace;
89
+ return texture;
90
+ });
91
+ return tex;
92
+ }
@@ -3,5 +3,6 @@ export interface JamlCardRendererProps {
3
3
  layers: Layer[];
4
4
  invert?: boolean;
5
5
  className?: string;
6
+ hoverTilt?: boolean;
6
7
  }
7
- export declare function JamlCardRenderer({ layers, invert, className }: JamlCardRendererProps): import("react/jsx-runtime").JSX.Element;
8
+ export declare function JamlCardRenderer({ layers, invert, className, hoverTilt }: JamlCardRendererProps): import("react/jsx-runtime").JSX.Element;
@@ -40,13 +40,15 @@ function renderImage(canvas, context, image, layer, timestamp) {
40
40
  context.restore();
41
41
  return cardWidth / cardHeight;
42
42
  }
43
- export function JamlCardRenderer({ layers, invert = false, className = "" }) {
43
+ export function JamlCardRenderer({ layers, invert = false, className = "", hoverTilt = false }) {
44
44
  const canvasRef = useRef(null);
45
45
  const imageCacheRef = useRef(new Map());
46
46
  const [ratio, setRatio] = useState(3 / 4);
47
47
  const [, forceUpdate] = useState(0);
48
48
  const animationFrameRef = useRef(null);
49
49
  const [elapsed, setElapsed] = useState(0);
50
+ const [isHovered, setIsHovered] = useState(false);
51
+ const [transform, setTransform] = useState("none");
50
52
  const hasAnimatedLayer = layers?.some((layer) => layer.animated);
51
53
  useEffect(() => {
52
54
  let cancelled = false;
@@ -133,15 +135,41 @@ export function JamlCardRenderer({ layers, invert = false, className = "" }) {
133
135
  }
134
136
  return () => { cancelled = true; };
135
137
  }, [layers, elapsed, invert, hasAnimatedLayer]);
138
+ const handlePointerEnter = (event) => {
139
+ if (!hoverTilt || event.pointerType === "touch")
140
+ return;
141
+ setIsHovered(true);
142
+ };
143
+ const handlePointerLeave = () => {
144
+ if (!hoverTilt)
145
+ return;
146
+ setIsHovered(false);
147
+ setTransform("none");
148
+ };
149
+ const handlePointerMove = (event) => {
150
+ if (!hoverTilt || event.pointerType === "touch")
151
+ return;
152
+ const rect = event.currentTarget.getBoundingClientRect();
153
+ const x = event.clientX - rect.left;
154
+ const y = event.clientY - rect.top;
155
+ const rotateY = (x / rect.width) * 12 - 6;
156
+ const rotateX = (y / rect.height) * -16 + 8;
157
+ setTransform(`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateZ(10px)`);
158
+ };
136
159
  const containerStyle = {
137
160
  aspectRatio: String(ratio),
138
161
  width: "100%",
139
162
  display: "flex",
163
+ transition: hoverTilt && !isHovered ? "transform 0.4s ease" : undefined,
164
+ transform: hoverTilt ? (isHovered ? transform : "none") : undefined,
165
+ transformStyle: hoverTilt ? "preserve-3d" : undefined,
166
+ transformOrigin: hoverTilt ? "center center" : undefined,
140
167
  };
141
168
  const canvasStyle = {
142
169
  borderRadius: "6px",
143
- boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
170
+ boxShadow: hoverTilt && isHovered ? "0 2px 12px rgba(0,0,0,0.3)" : "0 2px 8px rgba(0,0,0,0.2)",
144
171
  imageRendering: "pixelated",
172
+ transition: hoverTilt && !isHovered ? "box-shadow 0.4s ease-out" : undefined,
145
173
  };
146
- return (_jsx("div", { className: className, style: containerStyle, children: _jsx("canvas", { ref: canvasRef, style: canvasStyle }) }));
174
+ return (_jsx("div", { className: className, style: containerStyle, onPointerEnter: hoverTilt ? handlePointerEnter : undefined, onPointerLeave: hoverTilt ? handlePointerLeave : undefined, onPointerMove: hoverTilt ? handlePointerMove : undefined, children: _jsx("canvas", { ref: canvasRef, style: canvasStyle }) }));
147
175
  }
@@ -0,0 +1,12 @@
1
+ export type JamlPreviewSection = "must" | "should" | "mustNot";
2
+ export type JamlPreviewVisualType = "joker" | "consumable" | "voucher" | "tag" | "boss";
3
+ export interface JamlPreviewItem {
4
+ id: string;
5
+ section: JamlPreviewSection;
6
+ clauseKey: string;
7
+ visualType: JamlPreviewVisualType;
8
+ value: string;
9
+ source: string;
10
+ }
11
+ export type JamlPreviewGroups = Record<JamlPreviewSection, JamlPreviewItem[]>;
12
+ export declare function extractVisualJamlItems(jaml: string): JamlPreviewGroups;