solid-drift 0.13.0 → 0.15.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.
package/README.md CHANGED
@@ -1145,6 +1145,55 @@ const confettiCount = () => (lowPower() ? 20 : 150)
1145
1145
 
1146
1146
  Options: `maxDeviceMemory` (GB, default `4`), `maxHardwareConcurrency` (default `4`): a device at or below either threshold counts as low-end. Where the device signals are unsupported they degrade to "not low-end". SSR-safe: always `false` on the server.
1147
1147
 
1148
+ ### `createSlotMachine(options)`
1149
+
1150
+ Gacha slot machine: reels launch fast, decelerate with momentum, and stop left to right. Spin-to-mint theater for reveals, loot boxes, and prize draws. Pass `landing` to `spin()` when the outcome is already decided (the minted NFT, the prize): the reels still spin with full drama and land exactly on your symbols. Omit it for a fair random spin.
1151
+
1152
+ ```tsx
1153
+ import { createSlotMachine } from "solid-drift"
1154
+
1155
+ const machine = createSlotMachine({
1156
+ symbols: ["🍒", "⭐", "💎", "🚀"],
1157
+ onTick: (reel) => navigator.vibrate?.(10), // haptic tick per symbol
1158
+ onDone: (result) => console.log("minted:", result),
1159
+ })
1160
+
1161
+ <button onClick={() => machine.spin()}>SPIN</button>
1162
+ <div>{machine.values().join(" ")}</div>
1163
+ ```
1164
+
1165
+ Options: `symbols` (required, at least 2), `reels` (default `3`), `duration` (ms for the first reel, default `1400`), `stagger` (extra ms per subsequent reel, default `500`), `minSpins` (full rotations before stopping, default `3`), `easing` (default `"easeOutQuart"`), `onTick(reel, symbol)`, `onDone(result)`. Returns `{ values, result, status, spin, stop, reset }`: `values()` is the visible symbol per reel, `result()` the final symbols of the last spin, `status()` is `"idle"`, `"spinning"`, or `"done"`. `stop()` halts at the current symbols; `reset()` returns to idle. SSR-safe and reduced-motion safe: `spin()` jumps straight to the result.
1166
+
1167
+ ### `createRedPacket(options?)`
1168
+
1169
+ Crypto red packet ceremony: tap to open, coins burst out with physics, the amount counts up. The primitive owns the ceremony state machine (`"sealed"`, `"opening"`, `"bursting"`, `"revealed"`) and the coin particle physics; you render the envelope and the coins. Each coin carries position, rotation, size, opacity, and its share of the total, split randomly like a real red packet grab.
1170
+
1171
+ ```tsx
1172
+ import { createRedPacket } from "solid-drift"
1173
+
1174
+ const packet = createRedPacket({ amount: 88, coins: 14 })
1175
+
1176
+ <button onClick={() => packet.open()}>
1177
+ {packet.status() === "sealed"
1178
+ ? "🧧 Tap to open"
1179
+ : `$${packet.revealed().toFixed(2)}`}
1180
+ </button>
1181
+ <For each={packet.coins()}>
1182
+ {(coin) => (
1183
+ <div
1184
+ class="coin"
1185
+ style={{
1186
+ transform: `translate(${coin.x}px, ${coin.y}px) rotate(${coin.rotation}deg)`,
1187
+ opacity: coin.opacity,
1188
+ width: `${coin.size}px`,
1189
+ }}
1190
+ />
1191
+ )}
1192
+ </For>
1193
+ ```
1194
+
1195
+ Options: `coins` (default `12`), `amount` (total, default `88`), `spread` (burst size in px, default `160`), `gravity` (px/s^2, default `900`), `openDuration` (ms, default `500`), `burstDuration` (ms, default `1600`), `revealDuration` (ms, default `800`), `onOpen`, `onReveal(amount)`. Returns `{ status, coins, revealed, open, reset }`. SSR-safe and reduced-motion safe: `open()` jumps straight to revealed with no burst.
1196
+
1148
1197
  ### Easings
1149
1198
 
1150
1199
  Named easings: `linear`, `easeInQuad`, `easeOutQuad`, `easeInOutQuad`, `easeInCubic`, `easeOutCubic`, `easeInOutCubic`, `easeInQuart`, `easeOutQuart`, `easeInOutQuart`, `easeOutExpo`, `easeOutBack`, plus the cartoon set: `easeInBack` (anticipation dip before movement), `easeInOutBack` (wind-up, overshoot, settle), `easeOutElastic` (decaying rubber-band oscillation), `easeOutBounce` (shrinking cartoon bounces). Also `cubicBezier(x1, y1, x2, y2)` for CSS-style curves. Pass a name or a custom `(t) => number` function anywhere an easing is accepted.
package/dist/fun.d.ts ADDED
@@ -0,0 +1,151 @@
1
+ import { type Accessor } from "solid-js";
2
+ import { type Easing, type EasingName } from "./easing.js";
3
+ export type SlotMachineStatus = "idle" | "spinning" | "done";
4
+ export interface SlotMachineOptions<T = string> {
5
+ /** Symbol strip every reel cycles through. Required, at least 2 entries. */
6
+ symbols: T[];
7
+ /** Number of reels. Default 3. */
8
+ reels?: number;
9
+ /** Spin duration in ms for the first reel. Default 1400. */
10
+ duration?: number;
11
+ /** Extra ms per subsequent reel, so reels stop left to right. Default 500. */
12
+ stagger?: number;
13
+ /** Full rotations before a reel may stop. Default 3. */
14
+ minSpins?: number;
15
+ /** Deceleration curve: fast launch, long settle. Default "easeOutQuart". */
16
+ easing?: Easing | EasingName;
17
+ /** Called each time a reel passes a symbol. Pair with a haptic tick. */
18
+ onTick?: (reel: number, symbol: T) => void;
19
+ /** Called with the final symbols when all reels stop. */
20
+ onDone?: (result: T[]) => void;
21
+ }
22
+ export interface SlotMachineControls<T = string> {
23
+ /** Currently visible symbol per reel. */
24
+ values: Accessor<T[]>;
25
+ /** Final symbols of the last completed spin. */
26
+ result: Accessor<T[]>;
27
+ status: Accessor<SlotMachineStatus>;
28
+ /**
29
+ * Spin the reels. Pass landing symbols to rig the outcome: the spin
30
+ * becomes theater for a predetermined mint result. A wrong-length
31
+ * landing falls back to a random one.
32
+ */
33
+ spin: (landing?: T[]) => void;
34
+ /** Halt immediately at the current symbols. */
35
+ stop: () => void;
36
+ /** Back to idle with the initial symbols. */
37
+ reset: () => void;
38
+ }
39
+ /**
40
+ * Gacha slot machine: reels launch fast, decelerate with momentum, and
41
+ * stop left to right. Spin-to-mint theater for reveals, loot boxes, and
42
+ * prize draws.
43
+ *
44
+ * Pass `landing` to `spin()` when the outcome is already decided (the
45
+ * minted NFT, the prize): the reels still spin with full drama and land
46
+ * exactly on your symbols. Omit it for a fair random spin.
47
+ *
48
+ * SSR-safe: on the server `spin()` jumps straight to the result.
49
+ * Under reduced motion the reels jump straight to the result with no spin.
50
+ *
51
+ * ```tsx
52
+ * import { createSlotMachine } from "solid-drift"
53
+ *
54
+ * const machine = createSlotMachine({
55
+ * symbols: ["🍒", "⭐", "💎", "🚀"],
56
+ * onTick: (reel) => navigator.vibrate?.(10), // haptic tick per symbol
57
+ * onDone: (result) => console.log("minted:", result),
58
+ * })
59
+ *
60
+ * <button onClick={() => machine.spin()}>SPIN</button>
61
+ * <div>{machine.values().join(" ")}</div>
62
+ * ```
63
+ */
64
+ export declare function createSlotMachine<T = string>(options: SlotMachineOptions<T>): SlotMachineControls<T>;
65
+ export type RedPacketStatus = "sealed" | "opening" | "bursting" | "revealed";
66
+ export interface RedPacketCoin {
67
+ id: number;
68
+ /** Pixels right from the packet center. */
69
+ x: number;
70
+ /** Pixels down from the packet center. */
71
+ y: number;
72
+ vx: number;
73
+ vy: number;
74
+ /** Degrees. */
75
+ rotation: number;
76
+ /** Degrees per second. */
77
+ vr: number;
78
+ /** Diameter in pixels. */
79
+ size: number;
80
+ /** 0..1, fades at the end of life. */
81
+ opacity: number;
82
+ /** This coin's share of the total amount. */
83
+ amount: number;
84
+ }
85
+ export interface RedPacketOptions {
86
+ /** Coins in the burst. Default 12. */
87
+ coins?: number;
88
+ /** Total amount, split randomly across coins like a real red packet. Default 88. */
89
+ amount?: number;
90
+ /** Burst size in pixels, scales launch speed. Default 160. */
91
+ spread?: number;
92
+ /** Gravity in px/s^2. Default 900. */
93
+ gravity?: number;
94
+ /** Envelope opening ceremony in ms. Default 500. */
95
+ openDuration?: number;
96
+ /** Coin burst in ms before the reveal. Default 1600. */
97
+ burstDuration?: number;
98
+ /** Amount count-up in ms once revealed. Default 800. */
99
+ revealDuration?: number;
100
+ /** Called when the packet is tapped open. */
101
+ onOpen?: () => void;
102
+ /** Called with the total amount when the reveal lands. */
103
+ onReveal?: (amount: number) => void;
104
+ }
105
+ export interface RedPacketControls {
106
+ status: Accessor<RedPacketStatus>;
107
+ /** Live coin particles, centered on the packet. Render them absolutely. */
108
+ coins: Accessor<RedPacketCoin[]>;
109
+ /** Amount counted up so far. Reaches the total when revealed. */
110
+ revealed: Accessor<number>;
111
+ /** Tap the packet: sealed -> opening -> bursting -> revealed. */
112
+ open: () => void;
113
+ /** Back to sealed. */
114
+ reset: () => void;
115
+ }
116
+ /**
117
+ * Crypto red packet ceremony: tap to open, coins burst out with physics,
118
+ * the amount counts up. The hongbao moment, as a signal-native primitive.
119
+ *
120
+ * The primitive owns the ceremony state machine and the coin particle
121
+ * physics; you render the envelope and the coins however fits your design.
122
+ * Each coin carries position, rotation, size, opacity, and its share of
123
+ * the total amount, split randomly like a real red packet grab.
124
+ *
125
+ * SSR-safe: `open()` jumps straight to revealed on the server. Under
126
+ * reduced motion the packet opens instantly with no burst: the amount
127
+ * just appears.
128
+ *
129
+ * ```tsx
130
+ * import { createRedPacket } from "solid-drift"
131
+ *
132
+ * const packet = createRedPacket({ amount: 88, coins: 14 })
133
+ *
134
+ * <button onClick={() => packet.open()}>
135
+ * {packet.status() === "sealed" ? "🧧 Tap to open" : `$${packet.revealed().toFixed(2)}`}
136
+ * </button>
137
+ * <For each={packet.coins()}>
138
+ * {(coin) => (
139
+ * <div
140
+ * class="coin"
141
+ * style={{
142
+ * transform: `translate(${coin.x}px, ${coin.y}px) rotate(${coin.rotation}deg)`,
143
+ * opacity: coin.opacity,
144
+ * width: `${coin.size}px`,
145
+ * }}
146
+ * />
147
+ * )}
148
+ * </For>
149
+ * ```
150
+ */
151
+ export declare function createRedPacket(options?: RedPacketOptions): RedPacketControls;
package/dist/fun.js ADDED
@@ -0,0 +1,276 @@
1
+ import { createSignal, onCleanup, } from "solid-js";
2
+ import { now, schedule } from "./engine.js";
3
+ import { resolveEasing } from "./easing.js";
4
+ import { createTween } from "./tween.js";
5
+ import { prefersReducedMotion } from "./reduced-motion.js";
6
+ /**
7
+ * Gacha slot machine: reels launch fast, decelerate with momentum, and
8
+ * stop left to right. Spin-to-mint theater for reveals, loot boxes, and
9
+ * prize draws.
10
+ *
11
+ * Pass `landing` to `spin()` when the outcome is already decided (the
12
+ * minted NFT, the prize): the reels still spin with full drama and land
13
+ * exactly on your symbols. Omit it for a fair random spin.
14
+ *
15
+ * SSR-safe: on the server `spin()` jumps straight to the result.
16
+ * Under reduced motion the reels jump straight to the result with no spin.
17
+ *
18
+ * ```tsx
19
+ * import { createSlotMachine } from "solid-drift"
20
+ *
21
+ * const machine = createSlotMachine({
22
+ * symbols: ["🍒", "⭐", "💎", "🚀"],
23
+ * onTick: (reel) => navigator.vibrate?.(10), // haptic tick per symbol
24
+ * onDone: (result) => console.log("minted:", result),
25
+ * })
26
+ *
27
+ * <button onClick={() => machine.spin()}>SPIN</button>
28
+ * <div>{machine.values().join(" ")}</div>
29
+ * ```
30
+ */
31
+ export function createSlotMachine(options) {
32
+ const { symbols, reels = 3, duration = 1400, stagger = 500, minSpins = 3, onTick, onDone, } = options;
33
+ const easing = resolveEasing(options.easing ?? "easeOutQuart");
34
+ if (symbols.length < 2) {
35
+ throw new Error("createSlotMachine: symbols needs at least 2 entries.");
36
+ }
37
+ const len = symbols.length;
38
+ const wrap = (index) => ((index % len) + len) % len;
39
+ const symbolAt = (offset) => symbols[wrap(Math.floor(offset))];
40
+ const targetIndex = (symbol) => {
41
+ const i = symbols.indexOf(symbol);
42
+ return i === -1 ? 0 : i;
43
+ };
44
+ const initialOffsets = () => Array.from({ length: reels }, (_, i) => i % len);
45
+ const initialValues = () => initialOffsets().map(symbolAt);
46
+ const [values, setValues] = createSignal(initialValues());
47
+ const [result, setResult] = createSignal(initialValues());
48
+ const [status, setStatus] = createSignal("idle");
49
+ let offsets = initialOffsets();
50
+ let anims = [];
51
+ let cancel = null;
52
+ const settle = (finalSymbols) => {
53
+ cancel = null;
54
+ anims = [];
55
+ setValues(finalSymbols);
56
+ setResult(finalSymbols);
57
+ setStatus("done");
58
+ onDone?.(finalSymbols);
59
+ };
60
+ const spin = (landing) => {
61
+ cancel?.();
62
+ cancel = null;
63
+ const finalSymbols = landing && landing.length === reels
64
+ ? landing.map((s) => symbols[targetIndex(s)])
65
+ : Array.from({ length: reels }, () => symbols[(Math.random() * len) | 0]);
66
+ if (typeof window === "undefined" || prefersReducedMotion()) {
67
+ // Accessibility / SSR: no theater, straight to the result.
68
+ offsets = finalSymbols.map((s) => targetIndex(s));
69
+ settle(finalSymbols);
70
+ return;
71
+ }
72
+ setStatus("spinning");
73
+ const t0 = now();
74
+ anims = finalSymbols.map((symbol, i) => {
75
+ const from = offsets[i];
76
+ const currentIdx = wrap(Math.floor(from));
77
+ const delta = wrap(targetIndex(symbol) - currentIdx);
78
+ // minSpins full turns plus the forward distance to the target index.
79
+ // len divides minSpins * len, so floor(to) lands exactly on target.
80
+ const to = from + minSpins * len + delta;
81
+ return {
82
+ from,
83
+ to,
84
+ startAt: t0,
85
+ duration: duration + i * stagger,
86
+ lastIndex: currentIdx,
87
+ };
88
+ });
89
+ cancel = schedule((t) => {
90
+ let allDone = true;
91
+ const next = offsets.slice();
92
+ for (let i = 0; i < reels; i++) {
93
+ const a = anims[i];
94
+ const p = Math.min(Math.max((t - a.startAt) / a.duration, 0), 1);
95
+ const offset = a.from + (a.to - a.from) * easing(p);
96
+ next[i] = offset;
97
+ const index = wrap(Math.floor(offset));
98
+ if (index !== a.lastIndex) {
99
+ a.lastIndex = index;
100
+ onTick?.(i, symbols[index]);
101
+ }
102
+ if (p < 1)
103
+ allDone = false;
104
+ }
105
+ offsets = next;
106
+ setValues(next.map(symbolAt));
107
+ if (allDone) {
108
+ settle(finalSymbols);
109
+ return false;
110
+ }
111
+ return true;
112
+ });
113
+ };
114
+ const stop = () => {
115
+ if (!cancel)
116
+ return;
117
+ cancel();
118
+ const current = offsets.map(symbolAt);
119
+ offsets = current.map((s) => targetIndex(s));
120
+ settle(current);
121
+ };
122
+ const reset = () => {
123
+ cancel?.();
124
+ cancel = null;
125
+ anims = [];
126
+ offsets = initialOffsets();
127
+ const initial = initialValues();
128
+ setValues(initial);
129
+ setResult(initial);
130
+ setStatus("idle");
131
+ };
132
+ onCleanup(() => cancel?.());
133
+ return { values, result, status, spin, stop, reset };
134
+ }
135
+ /**
136
+ * Crypto red packet ceremony: tap to open, coins burst out with physics,
137
+ * the amount counts up. The hongbao moment, as a signal-native primitive.
138
+ *
139
+ * The primitive owns the ceremony state machine and the coin particle
140
+ * physics; you render the envelope and the coins however fits your design.
141
+ * Each coin carries position, rotation, size, opacity, and its share of
142
+ * the total amount, split randomly like a real red packet grab.
143
+ *
144
+ * SSR-safe: `open()` jumps straight to revealed on the server. Under
145
+ * reduced motion the packet opens instantly with no burst: the amount
146
+ * just appears.
147
+ *
148
+ * ```tsx
149
+ * import { createRedPacket } from "solid-drift"
150
+ *
151
+ * const packet = createRedPacket({ amount: 88, coins: 14 })
152
+ *
153
+ * <button onClick={() => packet.open()}>
154
+ * {packet.status() === "sealed" ? "🧧 Tap to open" : `$${packet.revealed().toFixed(2)}`}
155
+ * </button>
156
+ * <For each={packet.coins()}>
157
+ * {(coin) => (
158
+ * <div
159
+ * class="coin"
160
+ * style={{
161
+ * transform: `translate(${coin.x}px, ${coin.y}px) rotate(${coin.rotation}deg)`,
162
+ * opacity: coin.opacity,
163
+ * width: `${coin.size}px`,
164
+ * }}
165
+ * />
166
+ * )}
167
+ * </For>
168
+ * ```
169
+ */
170
+ export function createRedPacket(options = {}) {
171
+ const { coins: coinCount = 12, amount = 88, spread = 160, gravity = 900, openDuration = 500, burstDuration = 1600, revealDuration = 800, onOpen, onReveal, } = options;
172
+ const [status, setStatus] = createSignal("sealed");
173
+ const [coinList, setCoinList] = createSignal([]);
174
+ const [target, setTarget] = createSignal(0);
175
+ const tweened = createTween(target, {
176
+ duration: revealDuration,
177
+ easing: "easeOutExpo",
178
+ });
179
+ // On the server the tween effect never runs, so read the target directly.
180
+ const revealed = () => typeof window === "undefined" ? target() : tweened();
181
+ let cancel = null;
182
+ let burstAt = 0;
183
+ let lastT = 0;
184
+ let nextCoinId = 1;
185
+ const spawnCoins = () => {
186
+ const weights = Array.from({ length: coinCount }, () => 0.5 + Math.random());
187
+ const totalWeight = weights.reduce((a, b) => a + b, 0);
188
+ const speedScale = spread / 160;
189
+ return weights.map((w, i) => {
190
+ const angle = ((Math.random() * 120 - 60) * Math.PI) / 180;
191
+ const speed = (200 + Math.random() * 220) * speedScale;
192
+ return {
193
+ id: nextCoinId++,
194
+ x: 0,
195
+ y: 0,
196
+ vx: Math.sin(angle) * speed,
197
+ vy: -Math.cos(angle) * speed,
198
+ rotation: Math.random() * 360,
199
+ vr: (Math.random() - 0.5) * 720,
200
+ size: 24 + Math.random() * 16,
201
+ opacity: 1,
202
+ amount: (amount * w) / totalWeight,
203
+ };
204
+ });
205
+ };
206
+ const stepCoins = (t) => {
207
+ // Every coin is born at burstAt, so they share one age and one fade.
208
+ const dt = Math.min(Math.max((t - lastT) / 1000, 0), 0.05);
209
+ lastT = t;
210
+ const age = t - burstAt;
211
+ if (age >= burstDuration) {
212
+ setCoinList([]);
213
+ return;
214
+ }
215
+ const fadeStart = burstDuration * 0.7;
216
+ const opacity = age < fadeStart
217
+ ? 1
218
+ : Math.max(1 - (age - fadeStart) / (burstDuration - fadeStart), 0);
219
+ setCoinList((prev) => prev.map((c) => {
220
+ const vy = c.vy + gravity * dt;
221
+ return {
222
+ ...c,
223
+ x: c.x + c.vx * dt,
224
+ y: c.y + vy * dt,
225
+ vy,
226
+ rotation: c.rotation + c.vr * dt,
227
+ opacity,
228
+ };
229
+ }));
230
+ };
231
+ const open = () => {
232
+ if (status() !== "sealed")
233
+ return;
234
+ onOpen?.();
235
+ if (typeof window === "undefined" || prefersReducedMotion()) {
236
+ // Accessibility / SSR: no ceremony, the amount just appears.
237
+ setTarget(amount);
238
+ setStatus("revealed");
239
+ onReveal?.(amount);
240
+ return;
241
+ }
242
+ setStatus("opening");
243
+ const t0 = now();
244
+ cancel?.();
245
+ cancel = schedule((t) => {
246
+ const s = status();
247
+ if (s === "opening" && t - t0 >= openDuration) {
248
+ burstAt = t;
249
+ lastT = t;
250
+ setCoinList(spawnCoins());
251
+ setStatus("bursting");
252
+ }
253
+ else if (s === "bursting") {
254
+ stepCoins(t);
255
+ if (t - burstAt >= burstDuration) {
256
+ setCoinList([]);
257
+ setTarget(amount);
258
+ setStatus("revealed");
259
+ onReveal?.(amount);
260
+ cancel = null;
261
+ return false;
262
+ }
263
+ }
264
+ return true;
265
+ });
266
+ };
267
+ const reset = () => {
268
+ cancel?.();
269
+ cancel = null;
270
+ setCoinList([]);
271
+ setTarget(0);
272
+ setStatus("sealed");
273
+ };
274
+ onCleanup(() => cancel?.());
275
+ return { status, coins: coinList, revealed, open, reset };
276
+ }
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export { createScrollProgress, type ScrollTarget } from "./scroll.js";
12
12
  export { createInView, type InViewOptions } from "./inview.js";
13
13
  export { usePrefersReducedMotion, prefersReducedMotion, } from "./reduced-motion.js";
14
14
  export { isLowPowerMode, useLowPowerMode, type LowPowerOptions, } from "./power.js";
15
+ export { createSlotMachine, type SlotMachineControls, type SlotMachineOptions, type SlotMachineStatus, createRedPacket, type RedPacketCoin, type RedPacketControls, type RedPacketOptions, type RedPacketStatus, } from "./fun.js";
15
16
  export { createStagger } from "./stagger.js";
16
17
  export { createHorizontalScroll, type HorizontalScrollOptions, type HorizontalScrollResult, } from "./horizontal.js";
17
18
  export { createScrub, type ScrubKeyframe, type ScrubOptions } from "./scrub.js";
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ export { createScrollProgress } from "./scroll.js";
12
12
  export { createInView } from "./inview.js";
13
13
  export { usePrefersReducedMotion, prefersReducedMotion, } from "./reduced-motion.js";
14
14
  export { isLowPowerMode, useLowPowerMode, } from "./power.js";
15
+ export { createSlotMachine, createRedPacket, } from "./fun.js";
15
16
  export { createStagger } from "./stagger.js";
16
17
  export { createHorizontalScroll, } from "./horizontal.js";
17
18
  export { createScrub } from "./scrub.js";
package/package.json CHANGED
@@ -43,5 +43,5 @@
43
43
  },
44
44
  "type": "module",
45
45
  "types": "./dist/index.d.ts",
46
- "version": "0.13.0"
46
+ "version": "0.15.0"
47
47
  }