jaml-ui 0.7.1 → 0.8.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 (38) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +135 -135
  3. package/dist/assets.js +1 -1
  4. package/dist/components/JamlIde.d.ts +11 -3
  5. package/dist/components/JamlIde.js +50 -3
  6. package/dist/components/JamlIdeToolbar.js +43 -2
  7. package/dist/data/balatro-jokers.json +1241 -0
  8. package/dist/hooks/searchWorkerCode.js +59 -59
  9. package/dist/r3f/BalatroJokerMesh3D.d.ts +8 -0
  10. package/dist/r3f/BalatroJokerMesh3D.js +98 -0
  11. package/dist/r3f/BalatroJokerPreview3D.d.ts +14 -0
  12. package/dist/r3f/BalatroJokerPreview3D.js +30 -0
  13. package/dist/r3f/BalatroPlayingCard3D.d.ts +22 -0
  14. package/dist/r3f/BalatroPlayingCard3D.js +62 -0
  15. package/dist/r3f/cardConstants.d.ts +16 -0
  16. package/dist/r3f/cardConstants.js +14 -0
  17. package/dist/r3f/compositedAtlas.d.ts +5 -0
  18. package/dist/r3f/compositedAtlas.js +56 -0
  19. package/dist/r3f/gridUV.d.ts +22 -0
  20. package/dist/r3f/gridUV.js +30 -0
  21. package/dist/r3f/index.d.ts +12 -0
  22. package/dist/r3f/index.js +13 -0
  23. package/dist/r3f/jokerRegistry.d.ts +28 -0
  24. package/dist/r3f/jokerRegistry.js +40 -0
  25. package/dist/r3f/jokerTilt.d.ts +8 -0
  26. package/dist/r3f/jokerTilt.js +41 -0
  27. package/dist/r3f/magneticTilt.d.ts +18 -0
  28. package/dist/r3f/magneticTilt.js +34 -0
  29. package/dist/r3f/playingCardTypes.d.ts +24 -0
  30. package/dist/r3f/playingCardTypes.js +32 -0
  31. package/dist/r3f/playingCardVisuals.d.ts +7 -0
  32. package/dist/r3f/playingCardVisuals.js +45 -0
  33. package/dist/r3f/usePlayingCardTexture.d.ts +7 -0
  34. package/dist/r3f/usePlayingCardTexture.js +92 -0
  35. package/dist/ui/footer.js +5 -5
  36. package/dist/utils/jamlVisualFilter.d.ts +9 -0
  37. package/dist/utils/jamlVisualFilter.js +194 -0
  38. package/package.json +1 -1
@@ -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
+ }
package/dist/ui/footer.js CHANGED
@@ -9,10 +9,10 @@ const SUITS = [
9
9
  ];
10
10
  const CYCLE = '5s';
11
11
  export function JimboBalatroFooter({ hidden = false, className = '' }) {
12
- return (_jsxs("div", { className: ['fixed right-0 bottom-0 left-0 w-screen min-w-full transition-opacity duration-200', hidden ? 'pointer-events-none opacity-0' : 'opacity-100', className].filter(Boolean).join(' '), children: [_jsx("div", { style: { width: '100%', borderTop: '1px solid rgba(255,255,255,0.1)', background: 'rgba(0,0,0,0.9)', padding: '0 1rem 3px', textAlign: 'center' }, children: _jsxs("p", { style: { fontFamily: 'm6x11plus, monospace', fontSize: 'clamp(11px, 0.8vw + 8px, 14px)', display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center', gap: '0 0.5rem', color: 'white', margin: 0 }, children: [_jsx("span", { children: "Not affiliated with LocalThunk or PlayStack" }), _jsxs("span", { style: { display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }, children: ["Made with", ' ', _jsx("span", { style: { position: 'relative', display: 'inline-block', width: '1.5em', height: '1em', verticalAlign: 'middle' }, children: SUITS.map(({ char, kf }) => (_jsx("span", { style: { position: 'absolute', inset: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', opacity: 0, animationName: kf, animationDuration: CYCLE, animationDelay: '0s', animationIterationCount: 'infinite', animationTimingFunction: 'ease-out' }, children: char }, char))) }), ' ', "for the", ' ', _jsx("a", { href: "https://playbalatro.com", target: "_blank", rel: "noopener noreferrer", style: { color: JimboColorOption.GOLD, textDecoration: 'none' }, children: "Balatro" }), ' ', "community"] })] }) }), _jsx("style", { children: `
13
- @keyframes jaml-heart { 0%{opacity:0;transform:scale(1)} 1%{opacity:1;transform:scale(1.45)} 3.5%{opacity:1;transform:scale(1)} 61.5%{opacity:1;transform:scale(1)} 62%{opacity:0} 100%{opacity:0} }
14
- @keyframes jaml-spade { 0%,61.5%{opacity:0} 62%{opacity:1;transform:scale(1.45)} 64.5%{opacity:1;transform:scale(1)} 71.5%{opacity:1} 72%{opacity:0} 100%{opacity:0} }
15
- @keyframes jaml-diamond { 0%,71.5%{opacity:0} 72%{opacity:1;transform:scale(1.45)} 74.5%{opacity:1;transform:scale(1)} 81.5%{opacity:1} 82%{opacity:0} 100%{opacity:0} }
16
- @keyframes jaml-club { 0%,81.5%{opacity:0} 82%{opacity:1;transform:scale(1.45)} 84.5%{opacity:1;transform:scale(1)} 95%{opacity:1} 96%{opacity:0} 100%{opacity:0} }
12
+ return (_jsxs("div", { className: ['fixed right-0 bottom-0 left-0 w-screen min-w-full transition-opacity duration-200', hidden ? 'pointer-events-none opacity-0' : 'opacity-100', className].filter(Boolean).join(' '), children: [_jsx("div", { style: { width: '100%', borderTop: '1px solid rgba(255,255,255,0.1)', background: 'rgba(0,0,0,0.9)', padding: '0 1rem 3px', textAlign: 'center' }, children: _jsxs("p", { style: { fontFamily: 'm6x11plus, monospace', fontSize: 'clamp(11px, 0.8vw + 8px, 14px)', display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center', gap: '0 0.5rem', color: 'white', margin: 0 }, children: [_jsx("span", { children: "Not affiliated with LocalThunk or PlayStack" }), _jsxs("span", { style: { display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }, children: ["Made with", ' ', _jsx("span", { style: { position: 'relative', display: 'inline-block', width: '1.5em', height: '1em', verticalAlign: 'middle' }, children: SUITS.map(({ char, kf }) => (_jsx("span", { style: { position: 'absolute', inset: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', opacity: 0, animationName: kf, animationDuration: CYCLE, animationDelay: '0s', animationIterationCount: 'infinite', animationTimingFunction: 'ease-out' }, children: char }, char))) }), ' ', "for the", ' ', _jsx("a", { href: "https://playbalatro.com", target: "_blank", rel: "noopener noreferrer", style: { color: JimboColorOption.GOLD, textDecoration: 'none' }, children: "Balatro" }), ' ', "community"] })] }) }), _jsx("style", { children: `
13
+ @keyframes jaml-heart { 0%{opacity:0;transform:scale(1)} 1%{opacity:1;transform:scale(1.45)} 3.5%{opacity:1;transform:scale(1)} 61.5%{opacity:1;transform:scale(1)} 62%{opacity:0} 100%{opacity:0} }
14
+ @keyframes jaml-spade { 0%,61.5%{opacity:0} 62%{opacity:1;transform:scale(1.45)} 64.5%{opacity:1;transform:scale(1)} 71.5%{opacity:1} 72%{opacity:0} 100%{opacity:0} }
15
+ @keyframes jaml-diamond { 0%,71.5%{opacity:0} 72%{opacity:1;transform:scale(1.45)} 74.5%{opacity:1;transform:scale(1)} 81.5%{opacity:1} 82%{opacity:0} 100%{opacity:0} }
16
+ @keyframes jaml-club { 0%,81.5%{opacity:0} 82%{opacity:1;transform:scale(1.45)} 84.5%{opacity:1;transform:scale(1)} 95%{opacity:1} 96%{opacity:0} 100%{opacity:0} }
17
17
  ` })] }));
18
18
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Utilities for converting between JAML text and JamlVisualFilter.
3
+ *
4
+ * Intentionally does NOT depend on a YAML library — uses the same
5
+ * line-by-line approach as jamlMapPreview.ts to stay zero-dep.
6
+ */
7
+ import type { JamlVisualFilter } from "../components/JamlIdeVisual.js";
8
+ export declare function jamlTextToVisualFilter(text: string): JamlVisualFilter;
9
+ export declare function visualFilterToJamlText(filter: JamlVisualFilter): string;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Utilities for converting between JAML text and JamlVisualFilter.
3
+ *
4
+ * Intentionally does NOT depend on a YAML library — uses the same
5
+ * line-by-line approach as jamlMapPreview.ts to stay zero-dep.
6
+ */
7
+ // ─── Text → Filter ────────────────────────────────────────────────────────────
8
+ function stripQuotes(s) {
9
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
10
+ return s.slice(1, -1).trim();
11
+ }
12
+ return s;
13
+ }
14
+ function parseScalarValue(raw) {
15
+ const v = stripQuotes(raw.trim().replace(/,$/, "").trim());
16
+ return v || null;
17
+ }
18
+ function parseInlineList(raw) {
19
+ const t = raw.trim();
20
+ if (t.startsWith("[") && t.includes("]")) {
21
+ const body = t.slice(1, t.indexOf("]"));
22
+ return body
23
+ .split(",")
24
+ .map((s) => parseScalarValue(s))
25
+ .filter((s) => s !== null);
26
+ }
27
+ const v = parseScalarValue(t);
28
+ return v ? [v] : [];
29
+ }
30
+ function topLevelScalar(lines, key) {
31
+ for (const line of lines) {
32
+ const m = new RegExp(`^${key}:\\s*(.+)$`).exec(line.trim());
33
+ if (m)
34
+ return stripQuotes(m[1].trim());
35
+ }
36
+ return undefined;
37
+ }
38
+ const CLAUSE_ZONE_KEYS = new Set([
39
+ "joker", "jokers", "commonJoker", "commonJokers", "uncommonJoker", "uncommonJokers",
40
+ "rareJoker", "rareJokers", "mixedJoker", "mixedJokers", "soulJoker", "legendaryJoker",
41
+ "voucher", "vouchers",
42
+ "tarot", "tarotCard", "spectral", "spectralCard", "planet", "planetCard",
43
+ "boss", "bosses",
44
+ "tag", "tags", "smallBlindTag", "bigBlindTag", "smallblindtag", "bigblindtag",
45
+ ]);
46
+ // JAML uses "mustnot" as zone key in some contexts; the visual filter uses "mustnot".
47
+ // The text format may use "mustnot" or "must_not" — handle both, normalise to "mustnot".
48
+ function sectionToZone(raw) {
49
+ if (raw === "must")
50
+ return "must";
51
+ if (raw === "should")
52
+ return "should";
53
+ if (raw === "mustnot" || raw === "must_not" || raw === "mustNot")
54
+ return "mustnot";
55
+ return null;
56
+ }
57
+ let _uid = 0;
58
+ function uid() {
59
+ return `clause-${++_uid}`;
60
+ }
61
+ export function jamlTextToVisualFilter(text) {
62
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
63
+ const filter = { must: [], should: [], mustnot: [] };
64
+ filter.name = topLevelScalar(lines, "name");
65
+ filter.author = topLevelScalar(lines, "author");
66
+ filter.description = topLevelScalar(lines, "description");
67
+ filter.deck = topLevelScalar(lines, "deck");
68
+ filter.stake = topLevelScalar(lines, "stake");
69
+ let zone = null;
70
+ let current = null;
71
+ const seen = new Set();
72
+ function flushClause() {
73
+ if (!current || !zone)
74
+ return;
75
+ const dedupeKey = `${zone}:${current.type}:${current.value.toLowerCase()}`;
76
+ if (!seen.has(dedupeKey)) {
77
+ seen.add(dedupeKey);
78
+ const clause = { id: uid(), type: current.type, value: current.value };
79
+ if (current.antes && current.antes.length > 0)
80
+ clause.antes = current.antes;
81
+ if (current.score !== undefined)
82
+ clause.score = current.score;
83
+ if (current.edition)
84
+ clause.edition = current.edition;
85
+ clause.label = current.value;
86
+ filter[zone].push(clause);
87
+ }
88
+ current = null;
89
+ }
90
+ for (const rawLine of lines) {
91
+ const trimmed = rawLine.trim();
92
+ if (!trimmed || trimmed.startsWith("#"))
93
+ continue;
94
+ // Top-level section header: must:/should:/mustnot:
95
+ const sectionMatch = /^(must|should|mustnot|must_not|mustNot):\s*$/.exec(trimmed);
96
+ if (sectionMatch) {
97
+ flushClause();
98
+ zone = sectionToZone(sectionMatch[1]);
99
+ continue;
100
+ }
101
+ // Top-level key (non-section) resets zone
102
+ const indent = rawLine.search(/\S|$/);
103
+ if (indent === 0 && /^[A-Za-z]/.test(trimmed) && !trimmed.startsWith("-")) {
104
+ flushClause();
105
+ if (!sectionToZone(trimmed.replace(/:.*/, "")))
106
+ zone = null;
107
+ continue;
108
+ }
109
+ if (!zone)
110
+ continue;
111
+ // New clause start: " - rareJoker: Blueprint"
112
+ const clauseStart = /^-\s*([A-Za-z][A-Za-z0-9]*):\s*(.*?)\s*$/.exec(trimmed);
113
+ if (clauseStart) {
114
+ flushClause();
115
+ const type = clauseStart[1];
116
+ const rawVal = clauseStart[2];
117
+ if (!CLAUSE_ZONE_KEYS.has(type))
118
+ continue;
119
+ const value = parseScalarValue(rawVal) ?? "Any";
120
+ current = { type, value };
121
+ continue;
122
+ }
123
+ // Continuation line inside a clause: " antes: [1,2,3]"
124
+ if (current && indent > 0 && !trimmed.startsWith("-")) {
125
+ const contMatch = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$/.exec(trimmed);
126
+ if (contMatch) {
127
+ const key = contMatch[1];
128
+ const val = contMatch[2];
129
+ if (key === "antes") {
130
+ const nums = parseInlineList(val)
131
+ .map(Number)
132
+ .filter((n) => !isNaN(n));
133
+ current.antes = nums;
134
+ }
135
+ else if (key === "score") {
136
+ const n = Number(val);
137
+ if (!isNaN(n))
138
+ current.score = n;
139
+ }
140
+ else if (key === "edition") {
141
+ current.edition = parseScalarValue(val) ?? undefined;
142
+ }
143
+ }
144
+ }
145
+ }
146
+ flushClause();
147
+ return filter;
148
+ }
149
+ // ─── Filter → Text ───────────────────────────────────────────────────────────
150
+ function q(s) {
151
+ if (!s)
152
+ return "";
153
+ return /[:#\[\]{}|>&*!,'"?]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
154
+ }
155
+ function serializeClause(clause) {
156
+ let out = ` - ${clause.type}: ${q(clause.value)}\n`;
157
+ if (clause.antes && clause.antes.length > 0) {
158
+ out += ` antes: [${clause.antes.join(", ")}]\n`;
159
+ }
160
+ if (clause.score !== undefined) {
161
+ out += ` score: ${clause.score}\n`;
162
+ }
163
+ if (clause.edition) {
164
+ out += ` edition: ${q(clause.edition)}\n`;
165
+ }
166
+ return out;
167
+ }
168
+ export function visualFilterToJamlText(filter) {
169
+ const parts = [];
170
+ if (filter.name)
171
+ parts.push(`name: ${q(filter.name)}`);
172
+ if (filter.author)
173
+ parts.push(`author: ${q(filter.author)}`);
174
+ if (filter.description)
175
+ parts.push(`description: ${q(filter.description)}`);
176
+ if (filter.deck)
177
+ parts.push(`deck: ${q(filter.deck)}`);
178
+ if (filter.stake)
179
+ parts.push(`stake: ${q(filter.stake)}`);
180
+ const zones = [
181
+ { key: "must", label: "must", clauses: filter.must },
182
+ { key: "should", label: "should", clauses: filter.should },
183
+ { key: "mustnot", label: "mustnot", clauses: filter.mustnot },
184
+ ];
185
+ for (const { label, clauses } of zones) {
186
+ if (clauses.length > 0) {
187
+ parts.push(`${label}:`);
188
+ for (const c of clauses) {
189
+ parts.push(serializeClause(c).trimEnd());
190
+ }
191
+ }
192
+ }
193
+ return parts.join("\n") + "\n";
194
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jaml-ui",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Balatro rendering components, sprite metadata, and optional Motely helpers for React apps.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",