diff --git a/.gitignore b/.gitignore index dd6e803..691ac2c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules/ dist/ *.log .DS_Store +demo/ +.idea/ diff --git a/README.md b/README.md index f6ca09f..104dc12 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,28 @@ Leave `target` off and every mount asks for a different random toy. Or pin one: unlock()} /> ``` -The 12 toy ids: duck, bear, panda, bunny, dino, penguin, fox, frog, whale, cat, puppy, unicorn. +The 12 toy ids: `duck` `bear` `panda` `bunny` `dino` `penguin` `fox` `frog` `whale` `cat` `puppy` `unicorn` -Other props: `title` (the heading), `assetBase`, `className`. That's all of them. +## props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `target` | `ToyId` | random | Which toy to ask for. Randomized on each mount when omitted. | +| `onVerify` | `() => void` | — | Fired once when the right toy lands in the tray. | +| `title` | `string` | — | Heading above the machine. Defaults to the language's built-in title. | +| `assetBase` | `string` | `'/toys/'` | URL prefix for toy PNG files. | +| `className` | `string` | — | Extra class on the root element. | +| `language` | `'en' \| 'zh'` | `'en'` | UI language. Supports English and Chinese. | +| `randomizeToyPosition` | `boolean` | `true` | Randomize the target toy's position in the pile each mount. | +| `randomizeClawPosition` | `boolean` | `true` | Randomize the claw's starting position on the rail each mount. | + +## language + +Built-in support for English and Chinese: + +```tsx + unlock()} /> +``` ## theming diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000..d9d82d7 --- /dev/null +++ b/README.zh.md @@ -0,0 +1,77 @@ +# playcaptcha + +一个抓娃娃机形式的验证码。 + +它会随机指定一个玩偶,你用摇杆(或方向键)控制爪子移动,按下红色按钮,爪子会下降并抓住正下方的玩偶。把它运到投放口上方并松手——抓对了即通过验证,抓错了玩偶会弹回堆里,重新来过。 + +底层没有物理引擎,只有阻尼弹簧和脚本化阶段,全部跑在一个 rAF 循环里。React 状态只在阶段切换时才变化,其余都是通过 ref 直接写 transform,在低配设备上也能保持流畅。 + +有一点显而易见但值得说明:这只验证用户是否在**操作**,而不验证是谁在操作。请把它放在真正的鉴权检查前面,不要用它替代真正的鉴权。 + + + +在线体验:https://feralui.vercel.app/#/captcha + +本项目是 [FeralUI](https://github.com/mortspace/feralui) 的一部分。 + +## 安装 + +```bash +npm install playcaptcha +``` + +将 `assets/` 目录复制到你的应用静态资源目录下。组件默认在 `/toys/` 路径下查找玩偶图片,在 `/playcaptcha.svg` 路径下查找 logo,可通过 `assetBase` 修改。 + +```tsx +import { ClawCaptcha } from 'playcaptcha' +import 'playcaptcha/clawcaptcha.css' + + unlock()} /> +``` + +不传 `target` 时每次挂载会随机选一个玩偶。也可以固定指定: + +```tsx + unlock()} /> +``` + +12 个玩偶 id:`duck`(小黄鸭)`bear`(泰迪熊)`panda`(熊猫)`bunny`(兔子)`dino`(恐龙)`penguin`(企鹅)`fox`(狐狸)`frog`(青蛙)`whale`(鲸鱼)`cat`(猫咪)`puppy`(小狗)`unicorn`(独角兽) + +## Props + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `target` | `ToyId` | 随机 | 指定目标玩偶,不传时每次挂载随机选取。 | +| `onVerify` | `() => void` | — | 正确玩偶投入托盘时触发一次。 | +| `title` | `string` | — | 机器上方的标题,不传时使用当前语言的默认标题。 | +| `assetBase` | `string` | `'/toys/'` | 玩偶 PNG 图片的 URL 前缀。 | +| `className` | `string` | — | 根元素的额外 class。 | +| `language` | `'en' \| 'zh'` | `'en'` | 界面语言,支持英文和中文。 | +| `randomizeToyPosition` | `boolean` | `true` | 每次挂载时是否随机目标玩偶在堆里的位置。 | +| `randomizeClawPosition` | `boolean` | `true` | 每次挂载时是否随机爪子在轨道上的起始位置。 | + +## 多语言 + +内置英文和中文支持: + +```tsx + unlock()} /> +``` + +## 主题定制 + +在任意祖先元素上设置 CSS 变量: + +```css +:root { + --clawcap-bg: #ffffff; /* 卡片背景 */ + --clawcap-ink: #1c1c1e; /* 文字颜色 */ + --clawcap-muted: #8a8a8e; /* 次要文字 */ + --clawcap-accent: #1c1c1e; /* 弹窗按钮 + 焦点环 */ + --clawcap-action: #ff5159; /* 大红按钮 */ +} +``` + +键盘可完整操作(方向键 + 空格/回车),摇杆是真正的 slider role,`prefers-reduced-motion` 会将装饰性动画(入场翻滚、彩纸、环形脉冲)替换为即时状态切换。 + +MIT 协议。玩偶渲染图存放于 assets/toys。 diff --git a/package-lock.json b/package-lock.json index 159f817..6061cd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -866,7 +866,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1002,7 +1001,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1263,7 +1261,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1432,7 +1429,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/source-map": { "version": "0.7.6", @@ -1596,7 +1594,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/ClawCaptcha.tsx b/src/ClawCaptcha.tsx index 69aa870..b299560 100644 --- a/src/ClawCaptcha.tsx +++ b/src/ClawCaptcha.tsx @@ -104,14 +104,17 @@ const shuffle = (arr: T[]): T[] => { * TARGET sits in the front row with the two gaps beside it left empty, so it * always reads clearly and is easy to grab. Each toy still tumbles in under * gravity (dropFrom + entrance sim) and settles on a soft spring. */ -function scatterPile(target: ToyId): Slot[] { +function scatterPile(target: ToyId, randomize: boolean): Slot[] { const order = shuffle(TOY_SET) // 12 unique toys const rest = order.filter((t) => t.toy !== target) const tgt = order.find((t) => t.toy === target)! const nB = 8 // floor row const nTop = order.length - nB // 4 nestled on top - const bottomIdx = 2 + Math.floor(Math.random() * 4) // 2..5 — where the target rests + const targetPos = randomize ? Math.floor(Math.random() * order.length) : 4 + const isTargetInFront = targetPos < nB + const bottomIdx = isTargetInFront ? targetPos : 2 + Math.floor(Math.random() * 4) + const backIdx = isTargetInFront ? -1 : targetPos - nB const slots: Slot[] = new Array(order.length) // --- floor row: ON the ground, with SIZE-AWARE spacing. Each neighbour pair @@ -122,7 +125,7 @@ function scatterPile(target: ToyId): Slot[] { const frontW: number[] = [] const frontToy: Array<{ toy: ToyId; w: number }> = [] for (let i = 0; i < nB; i++) { - const isTarget = i === bottomIdx + const isTarget = isTargetInFront && i === bottomIdx const t = isTarget ? tgt : rest[r++] frontToy.push(t) // supporting cast runs smaller than the target so the row fits the glass @@ -145,7 +148,7 @@ function scatterPile(target: ToyId): Slot[] { for (let i = 0; i < nB; i++) { const cx = offset + xs[i] * fit + rand(-3, 3) centers.push(cx) - const isTarget = i === bottomIdx + const isTarget = isTargetInFront && i === bottomIdx slots[i] = { toy: frontToy[i].toy, w: frontW[i], @@ -165,20 +168,21 @@ function scatterPile(target: ToyId): Slot[] { // stay empty so it always reads clearly --- const gaps: number[] = [] for (let g = 0; g < nB - 1; g++) { - if (g === bottomIdx - 1 || g === bottomIdx) continue + if (isTargetInFront && (g === bottomIdx - 1 || g === bottomIdx)) continue gaps.push(g) } const useGaps = shuffle(gaps).slice(0, nTop) let ti = 0 for (const g of useGaps) { - const t = rest[r++] + const isTarget = !isTargetInFront && ti === backIdx + const t = isTarget ? tgt : rest[r++] const cx = (centers[g] + centers[g + 1]) / 2 + rand(-3, 3) slots[nB + ti] = { toy: t.toy, - w: t.w * rand(0.7, 0.8), + w: t.w * (isTarget ? rand(0.8, 0.9) : rand(0.7, 0.8)), x: Math.min(GW - 26, Math.max(26, cx)), b: rand(6, 16), // low: peeking over shoulders, base out of sight - z: 1, // BEHIND the floor row + z: isTarget ? 3 : 1, rot: rand(-8, 8), dropFrom: -rand(360, 470), delay: 0.45 + ti * 0.08 + rand(0, 0.1), // settle in after the floor row @@ -221,17 +225,83 @@ export interface ClawCaptchaProps { /** Where the toy PNGs are served from. */ assetBase?: string className?: string + /** Whether to randomize toy positions. Default: true */ + randomizeToyPosition?: boolean + /** Whether to randomize claw starting position. Default: true */ + randomizeClawPosition?: boolean + /** Language for UI text. Default: 'en' */ + language?: 'en' | 'zh' } export function ClawCaptcha({ target: targetProp, onVerify, - title = 'Verify you’re human', + title, assetBase = '/toys/', className, + randomizeToyPosition = true, + randomizeClawPosition = true, + language = 'en', }: ClawCaptchaProps) { const reduce = useReducedMotion() + const i18n = { + en: { + title: "Verify you're human", + instruction: 'Use the claw to grab the', + wrongToy: "That's the", + findThe: 'Find the', + verified: "You're human. Nice catch.", + moveTray: 'Move the toy over the drop zone first.', + ariaLabel: 'Claw machine verification', + ariaAbout: 'About PlayCaptcha', + ariaClose: 'Close', + ariaMoveClaw: 'Move the claw', + steps: ['Move', 'Grab', 'Drop'] as const, + stepDescs: [ + <>Line the claw up right over your prize — joystick or , + <>Commit. The claw dives, bites and hauls it up — red button or Space, + <>Ferry it to the hatch and let go. Wrong toy? Straight back on the pile, + ], + gotIt: 'Got it', + trayNiceCatch: 'Nice catch!', + trayWrongToy: 'Hmm, wrong toy', + trayRelease: 'Release!', + trayDrop: 'Drop here', + ariaDropToy: 'Drop the toy', + btnGrab: 'Grab', + btnDrop: 'Drop', + }, + zh: { + title: '验证你是人类', + instruction: '使用爪子抓取', + wrongToy: '这是', + findThe: '请找到', + verified: '你是人类。抓得不错。', + moveTray: '请先将玩具移到投放区上方。', + ariaLabel: '抓娃娃机验证', + ariaAbout: '关于 PlayCaptcha', + ariaClose: '关闭', + ariaMoveClaw: '移动爪子', + steps: ['移动', '抓取', '投放'] as const, + stepDescs: [ + <>将爪子对准你的目标——摇杆或 , + <>按下按钮。爪子会下降、夹住并提起——红色按钮或 空格, + <>移到投放口并松手。抓错了?玩具会放回原处, + ], + gotIt: '知道了', + trayNiceCatch: '抓到了!', + trayWrongToy: '抓错了', + trayRelease: '松开!', + trayDrop: '投放到此', + ariaDropToy: '投放玩具', + btnGrab: '抓取', + btnDrop: '投放', + }, + } + const texts = i18n[language] + const displayTitle = title ?? texts.title + // unpinned challenges ask for a different toy every mount (stable within one) const [autoTarget] = useState(() => TOY_SET[Math.floor(Math.random() * TOY_SET.length)].toy) const target = targetProp ?? autoTarget @@ -244,7 +314,7 @@ export function ClawCaptcha({ const [trayMode, setTrayMode] = useState<'' | 'open' | 'win' | 'no'>('') // fresh scatter every mount (remount with a key for a new pile) - const pile = useMemo(() => scatterPile(target), [target]) + const pile = useMemo(() => scatterPile(target, randomizeToyPosition), [target, randomizeToyPosition]) const rigEl = useRef(null) const clawEl = useRef(null) @@ -265,7 +335,7 @@ export function ClawCaptcha({ onVerifyRef.current = onVerify const sim = useRef({ - x: GW / 2, + x: randomizeClawPosition ? CLAW_MIN + Math.random() * (CLAW_MAX - CLAW_MIN) : GW / 2, y: HOME_Y, vx: 0, drive: 0, // smoothed steering input (eases abrupt key/stick changes) @@ -709,7 +779,7 @@ export function ClawCaptcha({ a.setOverTray(false) a.setTrayMode('no') a.setMessage( - `That’s the ${TOY_META[pile[s.carried].toy].label}! Find the ${TOY_META[target].label}.`, + `${texts.wrongToy} ${language === 'zh' ? TOY_META[pile[s.carried].toy].labelZh : TOY_META[pile[s.carried].toy].label}! ${texts.findThe} ${language === 'zh' ? TOY_META[target].labelZh : TOY_META[target].label}.`, ) nextStage('beat') a.setPhaseBoth('deny') @@ -795,7 +865,7 @@ export function ClawCaptcha({ setOverTray(false) // neutral hatch until it opens (right) or rejects (wrong) setPhaseBoth('toTray') } else { - setMessage('Move the toy over the drop zone first.') + setMessage(texts.moveTray) } } } @@ -847,6 +917,7 @@ export function ClawCaptcha({ } const t = TOY_META[target] + const tLabel = language === 'zh' ? t.labelZh : t.label const busy = phase !== 'idle' && phase !== 'carry' const stepNo = verified || phase === 'carry' || phase === 'toTray' || phase === 'celebrate' ? 3 : phase === 'seq' ? 2 : 1 const carried = sim.current.carried @@ -860,7 +931,7 @@ export function ClawCaptcha({ setInfoOpen(true)} > @@ -909,7 +980,7 @@ export function ClawCaptcha({ className="clawcap-info" role="dialog" aria-modal="true" - aria-label="About PlayCaptcha" + aria-label={texts.ariaAbout} onClick={() => setInfoOpen(false)} initial={{ opacity: 0 }} animate={{ opacity: 1 }} @@ -928,7 +999,7 @@ export function ClawCaptcha({ @@ -983,7 +1048,7 @@ export function ClawCaptcha({ exit={{ opacity: 0, y: -5 }} transition={{ duration: 0.24, ease: 'easeOut' }} > - {verified ? 'Verified' : title} + {verified ? (language === 'zh' ? '已验证' : 'Verified') : displayTitle} @@ -998,15 +1063,15 @@ export function ClawCaptcha({ transition={{ duration: 0.2, ease: 'easeOut' }} > {verified ? ( - 'You’re human. Nice catch.' + texts.verified ) : message ? ( message ) : ( <> - Use the claw to pick up the{' '} + {texts.instruction}{' '} - {t.label} + {tLabel} )} @@ -1017,7 +1082,7 @@ export function ClawCaptcha({