apex-ui-kit 2.2.0 → 2.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.
- package/dist/ApexUI-Kit/templates/AiChat/AiChat.jsx +171 -0
- package/dist/ApexUI-Kit/templates/AiModelPrompt/AiModelPrompt.jsx +221 -0
- package/dist/ApexUI-Kit/templates/AiModelPrompt/index.js +2 -0
- package/dist/ApexUI-Kit/templates/AiModelPrompt/models.js +15 -0
- package/dist/ApexUI-Kit/templates/AiPromptBox/AiPromptBox.jsx +173 -0
- package/dist/ApexUI-Kit/templates/AiResponse/AiResponse.jsx +172 -0
- package/dist/ApexUI-Kit/templates/AiThinking/AiThinking.jsx +106 -0
- package/dist/ApexUI-Kit/templates/AiVoiceBars/AiVoiceBars.jsx +78 -0
- package/dist/ApexUI-Kit/templates/AiVoiceBlob/AiVoiceBlob.jsx +105 -0
- package/dist/ApexUI-Kit/templates/AiVoiceOrb/AiVoiceOrb.jsx +74 -0
- package/dist/ApexUI-Kit/templates/AiVoiceRing/AiVoiceRing.jsx +120 -0
- package/dist/ApexUI-Kit/templates/AiVoiceWave/AiVoiceWave.jsx +107 -0
- package/dist/ApexUI-Kit/templates/AnimatedSwitch/AnimatedSwitch.jsx +80 -0
- package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +10 -2
- package/dist/ApexUI-Kit/templates/AuroraBackground/AuroraBackground.jsx +48 -0
- package/dist/ApexUI-Kit/templates/BounceCards/BounceCards.jsx +53 -0
- package/dist/ApexUI-Kit/templates/CardSwap3D/CardSwap3D.jsx +80 -0
- package/dist/ApexUI-Kit/templates/CircularText/CircularText.jsx +49 -0
- package/dist/ApexUI-Kit/templates/ClickSpark/ClickSpark.jsx +72 -0
- package/dist/ApexUI-Kit/templates/Coverflow3D/Coverflow3D.jsx +108 -0
- package/dist/ApexUI-Kit/templates/CubeGallery/CubeGallery.jsx +166 -0
- package/dist/ApexUI-Kit/templates/CursorTrail/CursorTrail.jsx +200 -0
- package/dist/ApexUI-Kit/templates/ElasticSlider/ElasticSlider.jsx +152 -0
- package/dist/ApexUI-Kit/templates/ExtrudedText/ExtrudedText.jsx +78 -0
- package/dist/ApexUI-Kit/templates/FlipBook/FlipBook.jsx +191 -0
- package/dist/ApexUI-Kit/templates/FloatingNavbar/FloatingNavbar.jsx +162 -0
- package/dist/ApexUI-Kit/templates/Folder/Folder.jsx +101 -0
- package/dist/ApexUI-Kit/templates/GlitchText/GlitchText.jsx +56 -0
- package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +310 -0
- package/dist/ApexUI-Kit/templates/HoldToConfirm/HoldToConfirm.jsx +169 -0
- package/dist/ApexUI-Kit/templates/HoldToConfirm/UndoNotice.jsx +58 -0
- package/dist/ApexUI-Kit/templates/HoloCard/HoloCard.jsx +92 -0
- package/dist/ApexUI-Kit/templates/ImageCompare/ImageCompare.jsx +94 -0
- package/dist/ApexUI-Kit/templates/IsoStack/IsoStack.jsx +86 -0
- package/dist/ApexUI-Kit/templates/LoadingScreen/LoadingScreen.jsx +545 -101
- package/dist/ApexUI-Kit/templates/MagnetLines/MagnetLines.jsx +67 -0
- package/dist/ApexUI-Kit/templates/MagneticCursor/MagneticCursor.jsx +2 -160
- package/dist/ApexUI-Kit/templates/PixelTransition/PixelTransition.jsx +75 -0
- package/dist/ApexUI-Kit/templates/PricingCards/PricingCards.jsx +115 -0
- package/dist/ApexUI-Kit/templates/ProximityText/ProximityText.jsx +66 -0
- package/dist/ApexUI-Kit/templates/RotatingText/RotatingText.jsx +50 -0
- package/dist/ApexUI-Kit/templates/ShinyText/ShinyText.jsx +30 -0
- package/dist/ApexUI-Kit/templates/StarBorder/StarBorder.jsx +33 -0
- package/dist/ApexUI-Kit/templates/Stepper/Stepper.jsx +129 -0
- package/dist/ApexUI-Kit/templates/SwipeCards/SwipeCards.jsx +123 -0
- package/dist/ApexUI-Kit/templates/TextRing3D/TextRing3D.jsx +73 -0
- package/dist/ApexUI-Kit/templates/TrueFocus/TrueFocus.jsx +64 -0
- package/dist/ApexUI-Kit/templates/TypewriterText/TypewriterText.jsx +70 -0
- package/dist/cli/index.js +43 -0
- package/package.json +1 -1
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
-
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
|
2
|
+
import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from "framer-motion";
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
5
|
* Loading Screen: five ways to wait.
|
|
6
6
|
* orbit – dots orbit a breathing core around the percentage
|
|
7
7
|
* progress – a big counting percentage, a sliding bar and rotating status lines
|
|
8
8
|
* blocks – a grid of tiles fills in like a wave
|
|
9
|
-
* catch – a mini game: tap the orb before
|
|
10
|
-
* runner – a mini game: jump the
|
|
9
|
+
* catch – a mini game: tap the orb before its ring runs out and build a combo
|
|
10
|
+
* runner – a mini game: jump the neon pillars (Space / ↑ / tap) while things load
|
|
11
|
+
* racer – a mini game: steer through traffic (← → / tap a side) while things load
|
|
11
12
|
* Pass `progress` (0–100) yourself, or leave it out and it runs on its own.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
const MESSAGES = ["Warming up the engines", "Fetching your components", "Polishing pixels", "Almost there"];
|
|
16
|
+
const messageFor = (v) => MESSAGES[Math.min(MESSAGES.length - 1, Math.floor(v / (100 / MESSAGES.length)))];
|
|
15
17
|
|
|
16
18
|
// Simulated progress that eases toward 100 over `duration` ms, then calls onComplete.
|
|
17
19
|
function useProgress(progress, duration, loop, onComplete) {
|
|
@@ -26,7 +28,7 @@ function useProgress(progress, duration, loop, onComplete) {
|
|
|
26
28
|
let start = performance.now();
|
|
27
29
|
let fired = false;
|
|
28
30
|
const tick = (now) => {
|
|
29
|
-
const t = Math.min(1, (now - start) / duration);
|
|
31
|
+
const t = Math.min(1, Math.max(0, (now - start) / duration));
|
|
30
32
|
// Fast start, slow middle, quick finish — the way real loads feel.
|
|
31
33
|
const eased = t < 0.85 ? 0.92 * (1 - Math.pow(1 - t / 0.85, 2.2)) : 0.92 + 0.08 * ((t - 0.85) / 0.15);
|
|
32
34
|
setAuto(Math.round(eased * 100));
|
|
@@ -55,8 +57,21 @@ function useProgress(progress, duration, loop, onComplete) {
|
|
|
55
57
|
|
|
56
58
|
function Bar({ value, color, track }) {
|
|
57
59
|
return (
|
|
58
|
-
<div className="h-1.5 w-full overflow-hidden rounded-full" style={{ background: track }}>
|
|
59
|
-
<div className="h-full rounded-full transition-[width] duration-300 ease-out" style={{ width: `${value}%`, background: color, boxShadow: `0 0
|
|
60
|
+
<div className="relative h-1.5 w-full overflow-hidden rounded-full" style={{ background: track }}>
|
|
61
|
+
<div className="h-full rounded-full transition-[width] duration-300 ease-out" style={{ width: `${value}%`, background: `linear-gradient(90deg, ${color}99, ${color})`, boxShadow: `0 0 14px ${color}` }} />
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Progress row shared by the game variants.
|
|
67
|
+
function Footer({ value, color, track }) {
|
|
68
|
+
return (
|
|
69
|
+
<div className="mt-5 w-full">
|
|
70
|
+
<div className="mb-2 flex items-center justify-between text-[0.75rem]">
|
|
71
|
+
<span className="opacity-60">{messageFor(value)}…</span>
|
|
72
|
+
<span className="font-mono font-semibold tabular-nums">{value}%</span>
|
|
73
|
+
</div>
|
|
74
|
+
<Bar value={value} color={color} track={track} />
|
|
60
75
|
</div>
|
|
61
76
|
);
|
|
62
77
|
}
|
|
@@ -76,10 +91,7 @@ function Orbit({ value, color, moving }) {
|
|
|
76
91
|
>
|
|
77
92
|
{[0, 1, 2].map((d) => (
|
|
78
93
|
<div key={d} className="absolute inset-0" style={{ transform: `rotate(${d * 120}deg)` }}>
|
|
79
|
-
<span
|
|
80
|
-
className="absolute left-1/2 top-0 -ml-[5px] -mt-[5px] h-2.5 w-2.5 rounded-full"
|
|
81
|
-
style={{ background: color, opacity: 1 - ring * 0.25, boxShadow: `0 0 10px ${color}` }}
|
|
82
|
-
/>
|
|
94
|
+
<span className="absolute left-1/2 top-0 -ml-[5px] -mt-[5px] h-2.5 w-2.5 rounded-full" style={{ background: color, opacity: 1 - ring * 0.25, boxShadow: `0 0 10px ${color}` }} />
|
|
83
95
|
</div>
|
|
84
96
|
))}
|
|
85
97
|
</motion.div>
|
|
@@ -97,12 +109,12 @@ function Orbit({ value, color, moving }) {
|
|
|
97
109
|
}
|
|
98
110
|
|
|
99
111
|
function ProgressView({ value, color, track, moving }) {
|
|
100
|
-
const message =
|
|
112
|
+
const message = messageFor(value);
|
|
101
113
|
return (
|
|
102
114
|
<div className="w-[min(22rem,80vw)]">
|
|
103
115
|
<p className="font-mono text-[clamp(3.5rem,12vw,5.5rem)] font-bold leading-none tabular-nums tracking-tight">
|
|
104
116
|
{value}
|
|
105
|
-
<span className="text-[0.4em]
|
|
117
|
+
<span className="align-top text-[0.4em] opacity-50">%</span>
|
|
106
118
|
</p>
|
|
107
119
|
<div className="mt-5">
|
|
108
120
|
<Bar value={value} color={color} track={track} />
|
|
@@ -144,56 +156,150 @@ function Blocks({ value, color, track, moving }) {
|
|
|
144
156
|
);
|
|
145
157
|
}
|
|
146
158
|
|
|
147
|
-
|
|
159
|
+
/* ---------------------------------------------------------------- Catch game */
|
|
160
|
+
|
|
161
|
+
const ORB_LIFE = 1.5; // seconds before an orb escapes
|
|
162
|
+
|
|
163
|
+
function CatchGame({ value, color, track, dark }) {
|
|
148
164
|
const [score, setScore] = useState(0);
|
|
149
|
-
const [
|
|
165
|
+
const [best, setBest] = useState(0);
|
|
166
|
+
const [combo, setCombo] = useState(0);
|
|
167
|
+
const [orb, setOrb] = useState({ id: 0, x: 50, y: 55 });
|
|
168
|
+
const [bursts, setBursts] = useState([]);
|
|
169
|
+
const arena = useAnimationControls();
|
|
170
|
+
|
|
171
|
+
const nextOrb = (id) => ({ id: id + 1, x: 12 + Math.random() * 76, y: 22 + Math.random() * 60 });
|
|
150
172
|
|
|
173
|
+
// An orb that escapes resets the combo and nudges the arena.
|
|
151
174
|
useEffect(() => {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
175
|
+
const t = setTimeout(() => {
|
|
176
|
+
setCombo(0);
|
|
177
|
+
arena.start({ x: [0, -6, 6, -3, 0], transition: { duration: 0.3 } });
|
|
178
|
+
setOrb((o) => nextOrb(o.id));
|
|
179
|
+
}, ORB_LIFE * 1000);
|
|
180
|
+
return () => clearTimeout(t);
|
|
181
|
+
}, [orb.id, arena]);
|
|
182
|
+
|
|
183
|
+
const hit = () => {
|
|
184
|
+
const nextCombo = combo + 1;
|
|
185
|
+
const gain = Math.min(5, 1 + Math.floor(nextCombo / 3));
|
|
186
|
+
setCombo(nextCombo);
|
|
187
|
+
setScore((s) => {
|
|
188
|
+
const n = s + gain;
|
|
189
|
+
setBest((b) => Math.max(b, n));
|
|
190
|
+
return n;
|
|
191
|
+
});
|
|
192
|
+
setBursts((list) => [...list.slice(-4), { id: performance.now(), x: orb.x, y: orb.y, gain }]);
|
|
193
|
+
setOrb((o) => nextOrb(o.id));
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const surface = dark ? "rgba(255,255,255,0.03)" : "rgba(0,0,0,0.025)";
|
|
158
197
|
|
|
159
198
|
return (
|
|
160
|
-
<div className="w-[min(
|
|
161
|
-
<div
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
199
|
+
<div className="w-[min(24rem,86vw)]">
|
|
200
|
+
<motion.div
|
|
201
|
+
animate={arena}
|
|
202
|
+
className="relative h-52 overflow-hidden rounded-3xl border"
|
|
203
|
+
style={{
|
|
204
|
+
borderColor: track,
|
|
205
|
+
background: `radial-gradient(120% 90% at 50% 0%, ${color}1f, transparent 60%), ${surface}`,
|
|
206
|
+
backgroundImage: `linear-gradient(${track} 1px, transparent 1px), linear-gradient(90deg, ${track} 1px, transparent 1px)`,
|
|
207
|
+
backgroundSize: "28px 28px",
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
{/* HUD */}
|
|
211
|
+
<div className="pointer-events-none absolute inset-x-3 top-3 z-10 flex items-center justify-between text-[0.7rem] font-semibold uppercase tracking-[0.14em]">
|
|
212
|
+
<span className="rounded-full px-2.5 py-1 backdrop-blur" style={{ background: dark ? "rgba(0,0,0,0.45)" : "rgba(255,255,255,0.7)" }}>
|
|
213
|
+
Score <span className="ml-1 font-mono text-[0.85rem] tabular-nums" style={{ color }}>{score}</span>
|
|
214
|
+
</span>
|
|
215
|
+
<AnimatePresence>
|
|
216
|
+
{combo >= 3 && (
|
|
217
|
+
<motion.span
|
|
218
|
+
key={Math.floor(combo / 3)}
|
|
219
|
+
initial={{ scale: 0.6, opacity: 0 }}
|
|
220
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
221
|
+
exit={{ scale: 0.6, opacity: 0 }}
|
|
222
|
+
className="rounded-full px-2.5 py-1 font-mono text-black"
|
|
223
|
+
style={{ background: color, boxShadow: `0 0 16px ${color}` }}
|
|
224
|
+
>
|
|
225
|
+
×{Math.min(5, 1 + Math.floor(combo / 3))} combo
|
|
226
|
+
</motion.span>
|
|
227
|
+
)}
|
|
228
|
+
</AnimatePresence>
|
|
229
|
+
<span className="rounded-full px-2.5 py-1 opacity-80 backdrop-blur" style={{ background: dark ? "rgba(0,0,0,0.45)" : "rgba(255,255,255,0.7)" }}>
|
|
230
|
+
Best <span className="ml-1 font-mono tabular-nums">{best}</span>
|
|
231
|
+
</span>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
{/* The orb with a ring that empties as time runs out. */}
|
|
166
235
|
<AnimatePresence>
|
|
167
236
|
<motion.button
|
|
168
237
|
key={orb.id}
|
|
169
238
|
type="button"
|
|
170
239
|
aria-label="Catch the orb"
|
|
171
|
-
onPointerDown={
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}}
|
|
175
|
-
className="absolute h-9 w-9 rounded-full outline-none"
|
|
176
|
-
style={{ left: `${orb.x}%`, top: `${orb.y}%`, marginLeft: -18, marginTop: -18, background: color, boxShadow: `0 0 24px ${color}` }}
|
|
240
|
+
onPointerDown={hit}
|
|
241
|
+
className="absolute grid h-14 w-14 place-items-center rounded-full outline-none"
|
|
242
|
+
style={{ left: `${orb.x}%`, top: `${orb.y}%`, marginLeft: "-1.75rem", marginTop: "-1.75rem" }}
|
|
177
243
|
initial={{ scale: 0, opacity: 0 }}
|
|
178
|
-
animate={{ scale:
|
|
179
|
-
exit={{ scale: 1.
|
|
180
|
-
transition={{
|
|
181
|
-
|
|
244
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
245
|
+
exit={{ scale: 1.6, opacity: 0, transition: { duration: 0.22 } }}
|
|
246
|
+
transition={{ type: "spring", stiffness: 420, damping: 18 }}
|
|
247
|
+
>
|
|
248
|
+
<svg viewBox="0 0 56 56" className="absolute inset-0 -rotate-90">
|
|
249
|
+
<circle cx="28" cy="28" r="25" fill="none" stroke={track} strokeWidth="3" />
|
|
250
|
+
<motion.circle cx="28" cy="28" r="25" fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" strokeDasharray="157" initial={{ strokeDashoffset: 0 }} animate={{ strokeDashoffset: 157 }} transition={{ duration: ORB_LIFE, ease: "linear" }} />
|
|
251
|
+
</svg>
|
|
252
|
+
<motion.span
|
|
253
|
+
className="h-7 w-7 rounded-full"
|
|
254
|
+
style={{ background: `radial-gradient(circle at 35% 30%, #fff, ${color} 60%)`, boxShadow: `0 0 22px ${color}` }}
|
|
255
|
+
animate={{ scale: [1, 1.12, 1] }}
|
|
256
|
+
transition={{ duration: 0.7, repeat: Infinity }}
|
|
257
|
+
/>
|
|
258
|
+
</motion.button>
|
|
182
259
|
</AnimatePresence>
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
260
|
+
|
|
261
|
+
{/* Hit bursts: sparks and a floating +points. */}
|
|
262
|
+
{bursts.map((b) => (
|
|
263
|
+
<div key={b.id} className="pointer-events-none absolute" style={{ left: `${b.x}%`, top: `${b.y}%` }}>
|
|
264
|
+
{Array.from({ length: 8 }, (_, i) => {
|
|
265
|
+
const a = (i / 8) * Math.PI * 2;
|
|
266
|
+
return (
|
|
267
|
+
<motion.span
|
|
268
|
+
key={i}
|
|
269
|
+
className="absolute h-1.5 w-1.5 rounded-full"
|
|
270
|
+
style={{ background: color, marginLeft: -3, marginTop: -3 }}
|
|
271
|
+
initial={{ x: 0, y: 0, opacity: 1 }}
|
|
272
|
+
animate={{ x: Math.cos(a) * 34, y: Math.sin(a) * 34, opacity: 0 }}
|
|
273
|
+
transition={{ duration: 0.5, ease: "easeOut" }}
|
|
274
|
+
/>
|
|
275
|
+
);
|
|
276
|
+
})}
|
|
277
|
+
<motion.span
|
|
278
|
+
className="absolute w-10 text-center font-mono text-sm font-bold"
|
|
279
|
+
style={{ color, marginLeft: "-1.25rem" }}
|
|
280
|
+
initial={{ y: -10, opacity: 1 }}
|
|
281
|
+
animate={{ y: -42, opacity: 0 }}
|
|
282
|
+
transition={{ duration: 0.7, ease: "easeOut" }}
|
|
283
|
+
onAnimationComplete={() => setBursts((list) => list.filter((x) => x.id !== b.id))}
|
|
284
|
+
>
|
|
285
|
+
+{b.gain}
|
|
286
|
+
</motion.span>
|
|
287
|
+
</div>
|
|
288
|
+
))}
|
|
289
|
+
</motion.div>
|
|
290
|
+
<Footer value={value} color={color} track={track} />
|
|
188
291
|
</div>
|
|
189
292
|
);
|
|
190
293
|
}
|
|
191
294
|
|
|
192
|
-
|
|
295
|
+
/* --------------------------------------------------------------- Runner game */
|
|
296
|
+
|
|
297
|
+
function RunnerGame({ value, color, track, dark }) {
|
|
193
298
|
const canvasRef = useRef(null);
|
|
299
|
+
const jumpRef = useRef(() => {});
|
|
194
300
|
const [score, setScore] = useState(0);
|
|
195
301
|
const [best, setBest] = useState(0);
|
|
196
|
-
const
|
|
302
|
+
const [started, setStarted] = useState(false);
|
|
197
303
|
|
|
198
304
|
useEffect(() => {
|
|
199
305
|
const canvas = canvasRef.current;
|
|
@@ -202,7 +308,11 @@ function RunnerGame({ value, color, track, ink }) {
|
|
|
202
308
|
let last = performance.now();
|
|
203
309
|
let w = 0;
|
|
204
310
|
let h = 0;
|
|
205
|
-
const
|
|
311
|
+
const sky = dark ? ["#05060d", "#0d1222"] : ["#eef2ff", "#f8fafc"];
|
|
312
|
+
const hill = dark ? ["rgba(255,255,255,0.05)", "rgba(255,255,255,0.09)"] : ["rgba(15,23,42,0.06)", "rgba(15,23,42,0.1)"];
|
|
313
|
+
const pillar = dark ? "#e5e7eb" : "#1f2937";
|
|
314
|
+
const stars = Array.from({ length: 40 }, () => ({ x: Math.random(), y: Math.random() * 0.6, r: Math.random() * 1.2 + 0.3, p: Math.random() * 6 }));
|
|
315
|
+
const g = { y: 0, vy: 0, rot: 0, obstacles: [], dust: [], ghosts: [], speed: 240, spawn: 1.1, score: 0, shake: 0, flash: 0, dist: 0, buffer: 0 };
|
|
206
316
|
|
|
207
317
|
const resize = () => {
|
|
208
318
|
const r = canvas.getBoundingClientRect();
|
|
@@ -217,76 +327,392 @@ function RunnerGame({ value, color, track, ink }) {
|
|
|
217
327
|
ro.observe(canvas);
|
|
218
328
|
resize();
|
|
219
329
|
|
|
330
|
+
const puff = (x, y, n) => {
|
|
331
|
+
for (let i = 0; i < n; i++) g.dust.push({ x, y, vx: -40 - Math.random() * 80, vy: -Math.random() * 60, life: 0.5, t: 0 });
|
|
332
|
+
};
|
|
220
333
|
jumpRef.current = () => {
|
|
221
|
-
|
|
334
|
+
setStarted(true);
|
|
335
|
+
g.buffer = 0.12; // a press just before landing still counts
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Rolling hills, drawn as a smooth line scrolling at `rate`.
|
|
339
|
+
const hills = (base, amp, rate, color) => {
|
|
340
|
+
ctx.fillStyle = color;
|
|
341
|
+
ctx.beginPath();
|
|
342
|
+
ctx.moveTo(0, h);
|
|
343
|
+
for (let x = 0; x <= w; x += 8) {
|
|
344
|
+
const t = (x + g.dist * rate) * 0.012;
|
|
345
|
+
ctx.lineTo(x, base - (Math.sin(t) * 0.6 + Math.sin(t * 2.3) * 0.4) * amp);
|
|
346
|
+
}
|
|
347
|
+
ctx.lineTo(w, h);
|
|
348
|
+
ctx.fill();
|
|
222
349
|
};
|
|
223
350
|
|
|
224
351
|
const tick = (now) => {
|
|
225
352
|
const dt = Math.min(0.033, (now - last) / 1000);
|
|
226
353
|
last = now;
|
|
227
|
-
const ground = h -
|
|
228
|
-
const
|
|
229
|
-
const
|
|
354
|
+
const ground = h - 26;
|
|
355
|
+
const size = 20;
|
|
356
|
+
const px = Math.max(44, w * 0.14);
|
|
230
357
|
|
|
231
358
|
// Physics.
|
|
232
|
-
g.
|
|
359
|
+
g.buffer = Math.max(0, g.buffer - dt);
|
|
360
|
+
if (g.buffer > 0 && g.y === 0) {
|
|
361
|
+
g.vy = 560;
|
|
362
|
+
g.buffer = 0;
|
|
363
|
+
puff(px + size / 2, ground, 5);
|
|
364
|
+
}
|
|
365
|
+
const wasAir = g.y > 0;
|
|
366
|
+
g.vy -= 1650 * dt;
|
|
233
367
|
g.y = Math.max(0, g.y + g.vy * dt);
|
|
234
|
-
if (g.y === 0)
|
|
235
|
-
|
|
368
|
+
if (g.y === 0) {
|
|
369
|
+
g.vy = Math.max(0, g.vy);
|
|
370
|
+
if (wasAir) puff(px + size / 2, ground, 7);
|
|
371
|
+
g.rot = Math.round(g.rot / (Math.PI / 2)) * (Math.PI / 2);
|
|
372
|
+
} else g.rot += dt * 7.5;
|
|
373
|
+
|
|
374
|
+
g.speed = Math.min(460, g.speed + 7 * dt);
|
|
375
|
+
g.dist += g.speed * dt;
|
|
236
376
|
g.spawn -= dt;
|
|
237
377
|
if (g.spawn <= 0) {
|
|
238
|
-
const tall = Math.random() < 0.
|
|
239
|
-
g.obstacles.push({ x: w +
|
|
240
|
-
|
|
378
|
+
const tall = Math.random() < 0.4;
|
|
379
|
+
g.obstacles.push({ x: w + 20, w: tall ? 16 : 22, h: tall ? 40 : 24 });
|
|
380
|
+
if (Math.random() < 0.25) g.obstacles.push({ x: w + 46, w: 16, h: 22 });
|
|
381
|
+
g.spawn = 0.8 + Math.random() * 0.8 - Math.min(0.3, g.speed / 2000);
|
|
241
382
|
}
|
|
242
|
-
g.obstacles
|
|
243
|
-
g.obstacles = g.obstacles.filter((o) => o.x + o.w > -
|
|
383
|
+
for (const o of g.obstacles) o.x -= g.speed * dt;
|
|
384
|
+
g.obstacles = g.obstacles.filter((o) => o.x + o.w > -20);
|
|
385
|
+
for (const d of g.dust) {
|
|
386
|
+
d.t += dt;
|
|
387
|
+
d.x += d.vx * dt;
|
|
388
|
+
d.y += d.vy * dt;
|
|
389
|
+
d.vy += 200 * dt;
|
|
390
|
+
}
|
|
391
|
+
g.dust = g.dust.filter((d) => d.t < d.life);
|
|
244
392
|
|
|
245
|
-
// Collision resets the run.
|
|
246
393
|
const top = ground - g.y - size;
|
|
247
|
-
|
|
248
|
-
if (
|
|
394
|
+
g.ghosts.push({ y: top, rot: g.rot });
|
|
395
|
+
if (g.ghosts.length > 5) g.ghosts.shift();
|
|
396
|
+
|
|
397
|
+
// Collision resets the run.
|
|
398
|
+
const hitIt = g.obstacles.some((o) => px + size - 3 > o.x && px + 3 < o.x + o.w && top + size > ground - o.h + 3);
|
|
399
|
+
if (hitIt) {
|
|
249
400
|
g.obstacles = [];
|
|
250
|
-
g.speed =
|
|
251
|
-
g.
|
|
401
|
+
g.speed = 240;
|
|
402
|
+
g.shake = 0.3;
|
|
403
|
+
g.flash = 0.35;
|
|
252
404
|
g.score = 0;
|
|
253
405
|
}
|
|
254
|
-
g.score += dt *
|
|
406
|
+
g.score += dt * 12;
|
|
407
|
+
g.shake = Math.max(0, g.shake - dt);
|
|
255
408
|
g.flash = Math.max(0, g.flash - dt);
|
|
256
409
|
const s = Math.floor(g.score);
|
|
257
410
|
setScore((prev) => (prev === s ? prev : s));
|
|
258
411
|
setBest((b) => Math.max(b, s));
|
|
259
412
|
|
|
260
413
|
// Draw.
|
|
261
|
-
ctx.
|
|
414
|
+
ctx.save();
|
|
415
|
+
if (g.shake) ctx.translate((Math.random() - 0.5) * 10 * g.shake, (Math.random() - 0.5) * 10 * g.shake);
|
|
416
|
+
const bg = ctx.createLinearGradient(0, 0, 0, h);
|
|
417
|
+
bg.addColorStop(0, sky[0]);
|
|
418
|
+
bg.addColorStop(1, sky[1]);
|
|
419
|
+
ctx.fillStyle = bg;
|
|
420
|
+
ctx.fillRect(-10, -10, w + 20, h + 20);
|
|
421
|
+
if (dark) {
|
|
422
|
+
for (const st of stars) {
|
|
423
|
+
ctx.globalAlpha = 0.35 + 0.35 * Math.sin(now / 600 + st.p);
|
|
424
|
+
ctx.fillStyle = "#fff";
|
|
425
|
+
ctx.beginPath();
|
|
426
|
+
ctx.arc(((st.x * w - g.dist * 0.02) % w + w) % w, st.y * h, st.r, 0, Math.PI * 2);
|
|
427
|
+
ctx.fill();
|
|
428
|
+
}
|
|
429
|
+
ctx.globalAlpha = 1;
|
|
430
|
+
}
|
|
431
|
+
hills(ground - 22, 16, 0.15, hill[0]);
|
|
432
|
+
hills(ground - 6, 10, 0.35, hill[1]);
|
|
433
|
+
|
|
434
|
+
// Ground with moving dashes.
|
|
262
435
|
ctx.fillStyle = track;
|
|
263
436
|
ctx.fillRect(0, ground, w, 2);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
437
|
+
ctx.fillStyle = dark ? "rgba(255,255,255,0.18)" : "rgba(0,0,0,0.15)";
|
|
438
|
+
for (let x = -(g.dist % 32); x < w; x += 32) ctx.fillRect(x, ground + 9, 12, 2);
|
|
439
|
+
|
|
440
|
+
// Neon pillars.
|
|
441
|
+
for (const o of g.obstacles) {
|
|
442
|
+
ctx.fillStyle = pillar;
|
|
443
|
+
ctx.globalAlpha = 0.9;
|
|
444
|
+
ctx.beginPath();
|
|
445
|
+
ctx.roundRect(o.x, ground - o.h, o.w, o.h, 4);
|
|
446
|
+
ctx.fill();
|
|
447
|
+
ctx.globalAlpha = 1;
|
|
448
|
+
ctx.fillStyle = "#f43f5e";
|
|
449
|
+
ctx.shadowColor = "#f43f5e";
|
|
450
|
+
ctx.shadowBlur = 10;
|
|
451
|
+
ctx.fillRect(o.x + 2, ground - o.h, o.w - 4, 3);
|
|
452
|
+
ctx.shadowBlur = 0;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Dust.
|
|
456
|
+
for (const d of g.dust) {
|
|
457
|
+
ctx.globalAlpha = 1 - d.t / d.life;
|
|
458
|
+
ctx.fillStyle = dark ? "#cbd5e1" : "#64748b";
|
|
459
|
+
ctx.beginPath();
|
|
460
|
+
ctx.arc(d.x, d.y, 2.2, 0, Math.PI * 2);
|
|
461
|
+
ctx.fill();
|
|
462
|
+
}
|
|
463
|
+
ctx.globalAlpha = 1;
|
|
464
|
+
|
|
465
|
+
// Runner: faded echoes, then the glowing square that spins in the air.
|
|
466
|
+
const drawBox = (y, rot, alpha) => {
|
|
467
|
+
ctx.save();
|
|
468
|
+
ctx.globalAlpha = alpha;
|
|
469
|
+
ctx.translate(px + size / 2, y + size / 2);
|
|
470
|
+
ctx.rotate(rot);
|
|
471
|
+
ctx.fillStyle = g.flash ? "#f43f5e" : color;
|
|
472
|
+
ctx.shadowColor = color;
|
|
473
|
+
ctx.shadowBlur = alpha === 1 ? 18 : 0;
|
|
474
|
+
ctx.beginPath();
|
|
475
|
+
ctx.roundRect(-size / 2, -size / 2, size, size, 5);
|
|
476
|
+
ctx.fill();
|
|
477
|
+
if (alpha === 1) {
|
|
478
|
+
ctx.shadowBlur = 0;
|
|
479
|
+
ctx.fillStyle = "rgba(0,0,0,0.75)";
|
|
480
|
+
ctx.fillRect(3, -4, 4, 4);
|
|
481
|
+
}
|
|
482
|
+
ctx.restore();
|
|
483
|
+
};
|
|
484
|
+
g.ghosts.forEach((gh, i) => g.y > 0 && drawBox(gh.y, gh.rot, (i / g.ghosts.length) * 0.25));
|
|
485
|
+
drawBox(top, g.rot, 1);
|
|
486
|
+
|
|
487
|
+
if (g.flash) {
|
|
488
|
+
ctx.fillStyle = `rgba(244,63,94,${g.flash * 0.25})`;
|
|
489
|
+
ctx.fillRect(0, 0, w, h);
|
|
490
|
+
}
|
|
491
|
+
ctx.restore();
|
|
492
|
+
raf = requestAnimationFrame(tick);
|
|
493
|
+
};
|
|
494
|
+
raf = requestAnimationFrame(tick);
|
|
495
|
+
|
|
496
|
+
const onKey = (e) => {
|
|
497
|
+
if (e.code !== "Space" && e.code !== "ArrowUp") return;
|
|
498
|
+
const area = canvas.closest("[data-loading-screen]");
|
|
499
|
+
if (area?.matches(":hover") || area?.contains(document.activeElement)) {
|
|
500
|
+
e.preventDefault();
|
|
501
|
+
jumpRef.current();
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
window.addEventListener("keydown", onKey);
|
|
505
|
+
return () => {
|
|
506
|
+
cancelAnimationFrame(raf);
|
|
507
|
+
ro.disconnect();
|
|
508
|
+
window.removeEventListener("keydown", onKey);
|
|
509
|
+
};
|
|
510
|
+
}, [color, track, dark]);
|
|
511
|
+
|
|
512
|
+
return (
|
|
513
|
+
<div className="w-[min(28rem,88vw)]">
|
|
514
|
+
<div className="relative overflow-hidden rounded-3xl border" style={{ borderColor: track }}>
|
|
515
|
+
<canvas
|
|
516
|
+
ref={canvasRef}
|
|
517
|
+
tabIndex={0}
|
|
518
|
+
aria-label="Runner game: press Space or tap to jump"
|
|
519
|
+
onPointerDown={() => jumpRef.current()}
|
|
520
|
+
className="block h-44 w-full cursor-pointer touch-manipulation outline-none"
|
|
521
|
+
/>
|
|
522
|
+
<div className="pointer-events-none absolute inset-x-3 top-3 flex items-center justify-between text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-white mix-blend-difference">
|
|
523
|
+
<span>
|
|
524
|
+
Score <span className="ml-1 font-mono text-[0.85rem] tabular-nums">{score}</span>
|
|
525
|
+
</span>
|
|
526
|
+
<span className="opacity-80">
|
|
527
|
+
Best <span className="ml-1 font-mono tabular-nums">{best}</span>
|
|
528
|
+
</span>
|
|
529
|
+
</div>
|
|
530
|
+
<AnimatePresence>
|
|
531
|
+
{!started && (
|
|
532
|
+
<motion.div exit={{ opacity: 0 }} className="pointer-events-none absolute inset-x-0 bottom-10 flex justify-center">
|
|
533
|
+
<motion.span
|
|
534
|
+
animate={{ y: [0, -4, 0] }}
|
|
535
|
+
transition={{ duration: 1.2, repeat: Infinity }}
|
|
536
|
+
className="rounded-full px-3 py-1 text-[0.72rem] font-semibold text-black"
|
|
537
|
+
style={{ background: color, boxShadow: `0 0 18px ${color}` }}
|
|
538
|
+
>
|
|
539
|
+
Tap or press Space to jump
|
|
540
|
+
</motion.span>
|
|
541
|
+
</motion.div>
|
|
542
|
+
)}
|
|
543
|
+
</AnimatePresence>
|
|
544
|
+
</div>
|
|
545
|
+
<Footer value={value} color={color} track={track} />
|
|
546
|
+
</div>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/* ---------------------------------------------------------------- Racer game */
|
|
551
|
+
|
|
552
|
+
const LANES = 3;
|
|
553
|
+
const TRAFFIC = ["#f43f5e", "#38bdf8", "#f59e0b", "#a78bfa", "#e5e7eb"];
|
|
554
|
+
|
|
555
|
+
function RacerGame({ value, color, track, dark }) {
|
|
556
|
+
const canvasRef = useRef(null);
|
|
557
|
+
const moveRef = useRef(() => {});
|
|
558
|
+
const [score, setScore] = useState(0);
|
|
559
|
+
const [best, setBest] = useState(0);
|
|
560
|
+
const [started, setStarted] = useState(false);
|
|
561
|
+
|
|
562
|
+
useEffect(() => {
|
|
563
|
+
const canvas = canvasRef.current;
|
|
564
|
+
const ctx = canvas.getContext("2d");
|
|
565
|
+
let raf = 0;
|
|
566
|
+
let last = performance.now();
|
|
567
|
+
let w = 0;
|
|
568
|
+
let h = 0;
|
|
569
|
+
const g = { lane: 1, x: 1, cars: [], speed: 260, spawn: 0.6, score: 0, road: 0, shake: 0, flash: 0, sparks: [] };
|
|
570
|
+
|
|
571
|
+
const resize = () => {
|
|
572
|
+
const r = canvas.getBoundingClientRect();
|
|
573
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
574
|
+
w = r.width;
|
|
575
|
+
h = r.height;
|
|
576
|
+
canvas.width = Math.round(w * dpr);
|
|
577
|
+
canvas.height = Math.round(h * dpr);
|
|
578
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
579
|
+
};
|
|
580
|
+
const ro = new ResizeObserver(resize);
|
|
581
|
+
ro.observe(canvas);
|
|
582
|
+
resize();
|
|
583
|
+
|
|
584
|
+
moveRef.current = (dir) => {
|
|
585
|
+
setStarted(true);
|
|
586
|
+
g.lane = Math.max(0, Math.min(LANES - 1, g.lane + dir));
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
// A top-down car: body, roof, windows and lights.
|
|
590
|
+
const car = (cx, cy, cw, ch, body, player) => {
|
|
591
|
+
ctx.save();
|
|
592
|
+
ctx.translate(cx, cy);
|
|
593
|
+
if (player) {
|
|
594
|
+
ctx.shadowColor = body;
|
|
595
|
+
ctx.shadowBlur = 18;
|
|
596
|
+
}
|
|
597
|
+
ctx.fillStyle = body;
|
|
268
598
|
ctx.beginPath();
|
|
269
|
-
ctx.roundRect(
|
|
599
|
+
ctx.roundRect(-cw / 2, -ch / 2, cw, ch, cw * 0.32);
|
|
270
600
|
ctx.fill();
|
|
271
601
|
ctx.shadowBlur = 0;
|
|
272
|
-
ctx.fillStyle =
|
|
273
|
-
ctx.
|
|
274
|
-
|
|
602
|
+
ctx.fillStyle = "rgba(0,0,0,0.55)";
|
|
603
|
+
ctx.beginPath();
|
|
604
|
+
ctx.roundRect(-cw * 0.36, -ch * (player ? 0.28 : 0.1), cw * 0.72, ch * 0.24, 4);
|
|
605
|
+
ctx.fill();
|
|
606
|
+
ctx.fillStyle = "rgba(255,255,255,0.18)";
|
|
607
|
+
ctx.fillRect(-cw * 0.3, -ch * 0.02, cw * 0.6, ch * 0.18);
|
|
608
|
+
// Headlights for the player (facing up), tail lights for traffic (facing down at us).
|
|
609
|
+
ctx.fillStyle = player ? "#fffbe6" : "#ff3b3b";
|
|
610
|
+
const ly = player ? -ch / 2 + 3 : ch / 2 - 6;
|
|
611
|
+
ctx.fillRect(-cw * 0.38, ly, cw * 0.18, 3);
|
|
612
|
+
ctx.fillRect(cw * 0.2, ly, cw * 0.18, 3);
|
|
613
|
+
if (player && dark) {
|
|
614
|
+
const beam = ctx.createLinearGradient(0, -ch / 2, 0, -ch / 2 - 70);
|
|
615
|
+
beam.addColorStop(0, "rgba(255,251,230,0.35)");
|
|
616
|
+
beam.addColorStop(1, "rgba(255,251,230,0)");
|
|
617
|
+
ctx.fillStyle = beam;
|
|
275
618
|
ctx.beginPath();
|
|
276
|
-
ctx.
|
|
619
|
+
ctx.moveTo(-cw * 0.38, -ch / 2);
|
|
620
|
+
ctx.lineTo(cw * 0.38, -ch / 2);
|
|
621
|
+
ctx.lineTo(cw * 0.75, -ch / 2 - 70);
|
|
622
|
+
ctx.lineTo(-cw * 0.75, -ch / 2 - 70);
|
|
277
623
|
ctx.fill();
|
|
278
|
-
}
|
|
624
|
+
}
|
|
625
|
+
ctx.restore();
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const tick = (now) => {
|
|
629
|
+
const dt = Math.min(0.033, (now - last) / 1000);
|
|
630
|
+
last = now;
|
|
631
|
+
const roadW = Math.min(w * 0.8, 260);
|
|
632
|
+
const left = (w - roadW) / 2;
|
|
633
|
+
const laneW = roadW / LANES;
|
|
634
|
+
const cw = laneW * 0.5;
|
|
635
|
+
const ch = cw * 1.75;
|
|
636
|
+
const py = h - ch / 2 - 14;
|
|
637
|
+
|
|
638
|
+
g.x += (g.lane - g.x) * Math.min(1, dt * 14);
|
|
639
|
+
g.speed = Math.min(620, g.speed + 9 * dt);
|
|
640
|
+
g.road = (g.road + g.speed * dt) % 40;
|
|
641
|
+
g.spawn -= dt;
|
|
642
|
+
if (g.spawn <= 0) {
|
|
643
|
+
const lane = Math.floor(Math.random() * LANES);
|
|
644
|
+
if (!g.cars.some((c) => c.lane === lane && c.y < ch * 2)) g.cars.push({ lane, y: -ch, color: TRAFFIC[Math.floor(Math.random() * TRAFFIC.length)], v: 0.45 + Math.random() * 0.25 });
|
|
645
|
+
g.spawn = Math.max(0.35, 0.9 - g.speed / 1400) + Math.random() * 0.35;
|
|
646
|
+
}
|
|
647
|
+
for (const c of g.cars) c.y += g.speed * c.v * dt;
|
|
648
|
+
g.cars = g.cars.filter((c) => c.y < h + ch);
|
|
649
|
+
|
|
650
|
+
// Crash: sparks, shake and a fresh start.
|
|
651
|
+
const pxc = left + laneW * (g.x + 0.5);
|
|
652
|
+
const crash = g.cars.find((c) => Math.abs(left + laneW * (c.lane + 0.5) - pxc) < cw * 0.85 && Math.abs(c.y - py) < ch * 0.85);
|
|
653
|
+
if (crash) {
|
|
654
|
+
for (let i = 0; i < 14; i++) g.sparks.push({ x: pxc, y: py - ch / 2, vx: (Math.random() - 0.5) * 240, vy: -Math.random() * 220, t: 0 });
|
|
655
|
+
g.cars = [];
|
|
656
|
+
g.speed = 260;
|
|
657
|
+
g.shake = 0.35;
|
|
658
|
+
g.flash = 0.4;
|
|
659
|
+
g.score = 0;
|
|
660
|
+
}
|
|
661
|
+
g.score += dt * g.speed * 0.04;
|
|
662
|
+
g.shake = Math.max(0, g.shake - dt);
|
|
663
|
+
g.flash = Math.max(0, g.flash - dt);
|
|
664
|
+
for (const sp of g.sparks) {
|
|
665
|
+
sp.t += dt;
|
|
666
|
+
sp.x += sp.vx * dt;
|
|
667
|
+
sp.y += sp.vy * dt;
|
|
668
|
+
sp.vy += 500 * dt;
|
|
669
|
+
}
|
|
670
|
+
g.sparks = g.sparks.filter((sp) => sp.t < 0.6);
|
|
671
|
+
const s = Math.floor(g.score);
|
|
672
|
+
setScore((prev) => (prev === s ? prev : s));
|
|
673
|
+
setBest((b) => Math.max(b, s));
|
|
674
|
+
|
|
675
|
+
// Draw: grass, road, moving lane dashes, edge lines, traffic, player.
|
|
676
|
+
ctx.save();
|
|
677
|
+
if (g.shake) ctx.translate((Math.random() - 0.5) * 12 * g.shake, (Math.random() - 0.5) * 12 * g.shake);
|
|
678
|
+
ctx.fillStyle = dark ? "#071209" : "#dcfce7";
|
|
679
|
+
ctx.fillRect(-10, -10, w + 20, h + 20);
|
|
680
|
+
ctx.fillStyle = dark ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.05)";
|
|
681
|
+
for (let y = -40 + g.road * 1; y < h; y += 40) {
|
|
682
|
+
ctx.fillRect(left - 22, y, 8, 18);
|
|
683
|
+
ctx.fillRect(left + roadW + 14, y, 8, 18);
|
|
684
|
+
}
|
|
685
|
+
ctx.fillStyle = dark ? "#15171d" : "#3f3f46";
|
|
686
|
+
ctx.fillRect(left, -10, roadW, h + 20);
|
|
687
|
+
ctx.fillStyle = color;
|
|
688
|
+
ctx.fillRect(left, -10, 3, h + 20);
|
|
689
|
+
ctx.fillRect(left + roadW - 3, -10, 3, h + 20);
|
|
690
|
+
ctx.fillStyle = "rgba(255,255,255,0.55)";
|
|
691
|
+
for (let i = 1; i < LANES; i++) for (let y = -40 + g.road; y < h; y += 40) ctx.fillRect(left + laneW * i - 1.5, y, 3, 20);
|
|
692
|
+
for (const c of g.cars) car(left + laneW * (c.lane + 0.5), c.y, cw, ch, c.color, false);
|
|
693
|
+
car(pxc, py, cw, ch, g.flash ? "#f43f5e" : color, true);
|
|
694
|
+
for (const sp of g.sparks) {
|
|
695
|
+
ctx.globalAlpha = 1 - sp.t / 0.6;
|
|
696
|
+
ctx.fillStyle = "#fbbf24";
|
|
697
|
+
ctx.fillRect(sp.x, sp.y, 3, 3);
|
|
698
|
+
}
|
|
279
699
|
ctx.globalAlpha = 1;
|
|
700
|
+
if (g.flash) {
|
|
701
|
+
ctx.fillStyle = `rgba(244,63,94,${g.flash * 0.25})`;
|
|
702
|
+
ctx.fillRect(0, 0, w, h);
|
|
703
|
+
}
|
|
704
|
+
ctx.restore();
|
|
280
705
|
raf = requestAnimationFrame(tick);
|
|
281
706
|
};
|
|
282
707
|
raf = requestAnimationFrame(tick);
|
|
283
708
|
|
|
284
709
|
const onKey = (e) => {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
710
|
+
const dir = { ArrowLeft: -1, KeyA: -1, ArrowRight: 1, KeyD: 1 }[e.code];
|
|
711
|
+
if (!dir) return;
|
|
712
|
+
const area = canvas.closest("[data-loading-screen]");
|
|
713
|
+
if (area?.matches(":hover") || area?.contains(document.activeElement)) {
|
|
714
|
+
e.preventDefault();
|
|
715
|
+
moveRef.current(dir);
|
|
290
716
|
}
|
|
291
717
|
};
|
|
292
718
|
window.addEventListener("keydown", onKey);
|
|
@@ -295,28 +721,38 @@ function RunnerGame({ value, color, track, ink }) {
|
|
|
295
721
|
ro.disconnect();
|
|
296
722
|
window.removeEventListener("keydown", onKey);
|
|
297
723
|
};
|
|
298
|
-
}, [color,
|
|
724
|
+
}, [color, dark]);
|
|
299
725
|
|
|
300
726
|
return (
|
|
301
|
-
<div className="w-[min(
|
|
302
|
-
<div className="
|
|
303
|
-
<
|
|
304
|
-
|
|
305
|
-
{
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
className="
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
727
|
+
<div className="w-[min(24rem,86vw)]">
|
|
728
|
+
<div className="relative overflow-hidden rounded-3xl border" style={{ borderColor: track }}>
|
|
729
|
+
<canvas
|
|
730
|
+
ref={canvasRef}
|
|
731
|
+
tabIndex={0}
|
|
732
|
+
aria-label="Racing game: use the left and right arrow keys, or tap the left or right side, to change lanes"
|
|
733
|
+
onPointerDown={(e) => {
|
|
734
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
735
|
+
moveRef.current(e.clientX - r.left < r.width / 2 ? -1 : 1);
|
|
736
|
+
}}
|
|
737
|
+
className="block h-60 w-full cursor-pointer touch-manipulation outline-none"
|
|
738
|
+
/>
|
|
739
|
+
<div className="pointer-events-none absolute inset-x-3 top-3 flex items-center justify-between text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-white">
|
|
740
|
+
<span className="rounded-full bg-black/45 px-2.5 py-1 backdrop-blur">
|
|
741
|
+
{score} <span className="opacity-60">m</span>
|
|
742
|
+
</span>
|
|
743
|
+
<span className="rounded-full bg-black/45 px-2.5 py-1 opacity-90 backdrop-blur">Best {best}</span>
|
|
744
|
+
</div>
|
|
745
|
+
<AnimatePresence>
|
|
746
|
+
{!started && (
|
|
747
|
+
<motion.div exit={{ opacity: 0 }} className="pointer-events-none absolute inset-x-0 bottom-24 flex justify-center">
|
|
748
|
+
<motion.span animate={{ y: [0, -4, 0] }} transition={{ duration: 1.2, repeat: Infinity }} className="rounded-full px-3 py-1 text-[0.72rem] font-semibold text-black" style={{ background: color, boxShadow: `0 0 18px ${color}` }}>
|
|
749
|
+
← → or tap a side to steer
|
|
750
|
+
</motion.span>
|
|
751
|
+
</motion.div>
|
|
752
|
+
)}
|
|
753
|
+
</AnimatePresence>
|
|
319
754
|
</div>
|
|
755
|
+
<Footer value={value} color={color} track={track} />
|
|
320
756
|
</div>
|
|
321
757
|
);
|
|
322
758
|
}
|
|
@@ -337,8 +773,8 @@ const LoadingScreen = ({
|
|
|
337
773
|
const value = useProgress(progress, duration, loop, onComplete);
|
|
338
774
|
const dark = theme !== "light";
|
|
339
775
|
const track = dark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)";
|
|
340
|
-
const
|
|
341
|
-
const
|
|
776
|
+
const shared = { value, color: accentColor, track, moving, dark };
|
|
777
|
+
const game = variant === "catch" || variant === "runner" || variant === "racer";
|
|
342
778
|
|
|
343
779
|
return (
|
|
344
780
|
<div
|
|
@@ -346,14 +782,22 @@ const LoadingScreen = ({
|
|
|
346
782
|
role="status"
|
|
347
783
|
aria-live="polite"
|
|
348
784
|
aria-label={`${title} ${value}%`}
|
|
349
|
-
className={`flex flex-col items-center justify-center gap-6 p-8 ${fullScreen ? "fixed inset-0 z-[100]" : "relative h-full w-full"} ${dark ? "bg-neutral-950 text-white" : "bg-white text-neutral-900"} ${className}`}
|
|
785
|
+
className={`flex flex-col items-center justify-center gap-6 p-6 sm:p-8 ${fullScreen ? "fixed inset-0 z-[100]" : "relative h-full w-full"} ${dark ? "bg-neutral-950 text-white" : "bg-white text-neutral-900"} ${className}`}
|
|
786
|
+
style={dark ? { backgroundImage: `radial-gradient(60% 50% at 50% 40%, ${accentColor}14, transparent 70%)` } : undefined}
|
|
350
787
|
>
|
|
788
|
+
{game && (
|
|
789
|
+
<p className="text-center">
|
|
790
|
+
<span className="block text-[0.7rem] font-semibold uppercase tracking-[0.3em] opacity-50">{title}</span>
|
|
791
|
+
<span className="mt-1 block text-[0.95rem] font-semibold">{{ catch: "Catch the orb while you wait", runner: "Jump the pillars while you wait", racer: "Dodge the traffic while you wait" }[variant]}</span>
|
|
792
|
+
</p>
|
|
793
|
+
)}
|
|
351
794
|
{variant === "orbit" && <Orbit {...shared} />}
|
|
352
795
|
{variant === "progress" && <ProgressView {...shared} />}
|
|
353
796
|
{variant === "blocks" && <Blocks {...shared} />}
|
|
354
797
|
{variant === "catch" && <CatchGame {...shared} />}
|
|
355
798
|
{variant === "runner" && <RunnerGame {...shared} />}
|
|
356
|
-
{variant
|
|
799
|
+
{variant === "racer" && <RacerGame {...shared} />}
|
|
800
|
+
{!game && variant !== "progress" && <p className="text-[0.7rem] font-semibold uppercase tracking-[0.3em] opacity-50">{title}</p>}
|
|
357
801
|
</div>
|
|
358
802
|
);
|
|
359
803
|
};
|