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 +49 -0
- package/dist/fun.d.ts +151 -0
- package/dist/fun.js +276 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
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