From b59c328c1e2447391b8e28061a47a16288a83fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E9=91=AB?= Date: Sun, 5 Jul 2026 00:28:36 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(ClawCaptcha):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E5=8C=96=E9=85=8D=E7=BD=AE=E5=92=8C=E4=B8=AD?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E5=9B=BD=E9=99=85=E5=8C=96=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1、新增randomizeToyPosition参数,支持目标玩偶位置随机(含前排和后排) 2、新增randomizeClawPosition参数,支持爪子起始位置随机 3、新增language参数,支持中英文切换,覆盖全部UI文本 4、toys.ts新增labelZh字段,支持玩偶中文名显示 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + package-lock.json | 7 +-- src/ClawCaptcha.tsx | 139 ++++++++++++++++++++++++++++++++------------ src/toys.ts | 26 ++++----- 4 files changed, 118 insertions(+), 55 deletions(-) diff --git a/.gitignore b/.gitignore index dd6e803..a0c559b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ *.log .DS_Store +demo/ 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({