每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。
次元刀
cursor手速夠快就能斬開世界。(「・ω・)「
切割原理延伸自易碎包裝器的「DOM 轉圖片再分割」,不過這次改以 CSS clip-path 沿刀線裁切碎塊。
| 名稱 | 描述 |
|---|---|
| Vue Directive | 自定義 Vue 指令,用於封裝 DOM 操作邏輯和重複行為 |
| DOM to Image | 將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現 |
| CSS clip-path | 以幾何路徑裁切元素的可見區域,常用於遮罩、切割與造型 |
| Anime.js | 輕量級 JavaScript 動畫函式庫 |
| 向量計算 | 處理方向、加速度、速度等等數學運算 |
| Pointer 事件 | 偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊 |
| JS 動畫 | 基於 JavaScript 實現的動畫,達成更複雜、精準的動畫控制,常見套件有 GSAP、anime.js 等 |
掛上 cursor-dimension-blade 元件啟用次元刀,再用 v-dimension-blade 標記可被切割的元素。
游標揮得夠快就可以出刀,刀線掃過標記元素就會沿刀線裂開。( •̀ ω •́ )✧
<template> <div class="example-wrap flex flex-col items-center gap-6"> <div class="flex flex-wrap items-center justify-center gap-4"> <!-- 啟用後快速揮動游標才會出刀 --> <base-checkbox v-model="bladeEnabled" :label="t('enable')" class="example-ctrl" /> </div>
<!-- 標了 v-dimension-blade 的元素才切得動;啟用後鎖住觸控捲動,讓手指滑動優先用來揮刀 --> <section class="article flex flex-col gap-4" :class="{ 'article--cutting': bladeEnabled }" > <h4 v-dimension-blade class="article-title" > {{ t('articleTitle') }} </h4>
<p v-dimension-blade class="article-text" > {{ t('articleText') }} </p>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div v-for="card in cardList" :key="card.key" v-dimension-blade class="info-card flex flex-col gap-1" > <span class="info-card-title">{{ t(`${card.key}.title`) }}</span> <span class="info-card-text">{{ t(`${card.key}.text`) }}</span> </div> </div> </section>
<cursor-dimension-blade v-if="bladeEnabled" ref="bladeRef" touch-enabled /> </div></template>
<script setup lang="ts">import { ref, useTemplateRef } from 'vue'import { useI18n } from 'vue-i18n'import BaseCheckbox from '../../base-checkbox.vue'import CursorDimensionBlade from '../cursor-dimension-blade.vue'import { vDimensionBlade } from '../v-dimension-blade'
const { t } = useI18n()
const bladeEnabled = ref(false)const bladeRef = useTemplateRef('bladeRef')
const cardList = [ { key: 'cardSpeed' }, { key: 'cardTrail' },]</script>
<style scoped lang="sass">.hint font-size: 0.9rem opacity: 0.75
.article width: 100% padding: 1.5rem border: 1px solid rgba(125, 125, 125, 0.25) border-radius: 1rem background: var(--vp-c-bg-soft)
// 啟用次元刀後,滑動手勢優先給揮刀用,鎖住這區塊的觸控捲動 &--cutting touch-action: none
.article-title margin: 0 font-size: 1.15rem font-weight: 700
.article-text margin: 0 font-size: 0.9rem line-height: 1.8 opacity: 0.85
.info-card padding: 1rem border-radius: 0.75rem background: var(--vp-c-bg)
.info-card-title font-size: 0.9rem font-weight: 700
.info-card-text font-size: 0.8rem line-height: 1.6 opacity: 0.75</style>切開畫面上所有指定的水果,證明你不是機器人。
且記,揮得夠快才會出刀。(⌐■_■)✧
<template> <div class="captcha flex flex-col items-center gap-4"> <p class="captcha-hint" :class="`captcha-hint--${status}`" > {{ hintText }} </p>
<div :key="roundKey" ref="gridRef" class="fruit-grid grid w-full gap-3 sm:w-auto" > <div v-for="fruit in fruitList" :key="fruit.id" v-dimension-blade="{ invincible: status !== 'playing', hitInset: 8 }" class="fruit-cell" :data-fruit-id="fruit.id" > {{ fruit.emoji }} </div>
<div v-for="mark in slicedMarkList" :key="mark.id" class="sliced-mark" :class="mark.correct ? 'sliced-mark--correct' : 'sliced-mark--wrong'" :style="{ left: `${mark.x}px`, top: `${mark.y}px` }" > {{ mark.correct ? '✓' : '✗' }} </div> </div>
<button type="button" class="restart-btn" @click="restart" > {{ t('restart') }} </button>
<cursor-dimension-blade :speed-threshold="3" touch-enabled /> </div></template>
<script setup lang="ts">import { useMutationObserver } from '@vueuse/core'import { sample, shuffle } from 'remeda'import { computed, onMounted, ref, useTemplateRef } from 'vue'import { useI18n } from 'vue-i18n'import CursorDimensionBlade from '../cursor-dimension-blade.vue'import { vDimensionBlade } from '../v-dimension-blade'
const { t } = useI18n()
/** 水果表情符號池,隨機抽一種當本輪目標 */const FRUIT_EMOJI_LIST = ['🍉', '🍎', '🍊', '🍋', '🍇', '🍓', '🍑', '🍈', '🥝', '🍌']/** 3x3 排列,格子編號 0~8:0 1 2 / 3 4 5 / 6 7 8 */const GRID_COLUMN_COUNT = 3const GRID_SIZE = 9/** 目標水果數量,其餘格子為干擾用的其他水果 */const TARGET_COUNT = 3
function isAdjacentPosition(positionA: number, positionB: number): boolean { const columnDelta = Math.abs((positionA % GRID_COLUMN_COUNT) - (positionB % GRID_COLUMN_COUNT)) const rowDelta = Math.abs(Math.floor(positionA / GRID_COLUMN_COUNT) - Math.floor(positionB / GRID_COLUMN_COUNT)) return columnDelta + rowDelta === 1}
/** 枚舉「彼此兩兩不相鄰(不共邊)」的位置組合,供目標水果使用 */function generateNonAdjacentPositionCombinationList(size: number, count: number): number[][] { const combinationList: number[][] = [] const currentList: number[] = []
function collect(start: number) { if (currentList.length === count) { combinationList.push([...currentList]) return } for (let position = start; position < size; position++) { if (currentList.some((picked) => isAdjacentPosition(picked, position))) { continue } currentList.push(position) collect(position + 1) currentList.pop() } }
collect(0) return combinationList}
/** * 目標水果的位置組合,彼此兩兩不相鄰, * 避免同一刀掃過去就把好幾顆目標一起切開,看不出是靠「辨識」還是「運氣」過關。 */const NON_ADJACENT_POSITION_COMBINATION_LIST = generateNonAdjacentPositionCombinationList(GRID_SIZE, TARGET_COUNT)
interface FruitItem { id: number; emoji: string; isTarget: boolean; sliced: boolean;}
/** 抽目標水果與其不相鄰的位置組合,其餘格子隨機填入干擾水果 */function generateFruitList(): FruitItem[] { const [targetEmoji, ...restEmojiList] = shuffle(FRUIT_EMOJI_LIST) const targetPositionList = sample(NON_ADJACENT_POSITION_COMBINATION_LIST, 1)[0]!
return Array.from({ length: GRID_SIZE }, (_, index) => { const isTarget = targetPositionList.includes(index) return { id: index, emoji: isTarget ? targetEmoji! : sample(restEmojiList, 1)[0]!, isTarget, sliced: false, } })}
// 隨機排列只能在用戶端掛載後產生,SSR 與 hydration 兩邊各自隨機會導致排列不一致,// 進而讓 Vue 拿雙方不同的 DOM 結構做水合比對而出錯const fruitList = ref<FruitItem[]>([])const targetEmoji = computed(() => fruitList.value.find((fruit) => fruit.isTarget)?.emoji ?? '')const remainingCount = computed( () => fruitList.value.filter((fruit) => fruit.isTarget && !fruit.sliced).length,)
/** * 依水果實際切開狀態推導結果,而非切到目標就立刻鎖定 success。 * 同一揮刀可能同時掃到好幾顆水果,每顆各自非同步擷取快照才會標記 sliced, * 完成順序不保證,若提早鎖定,晚一步才回報的誤切就會被漏掉。 */const status = computed<'playing' | 'success' | 'fail'>(() => { // 掛載前水果列表尚未產生,避免空陣列的 every() 恆真誤判成功 if (fruitList.value.length === 0) { return 'playing' } if (fruitList.value.some((fruit) => fruit.sliced && !fruit.isTarget)) { return 'fail' } if (fruitList.value.every((fruit) => !fruit.isTarget || fruit.sliced)) { return 'success' } return 'playing'})/** 綁在容器上的 key,重新挑戰時整批水果元素連同次元刀登記一起重建,碎塊才會確實清除 */const roundKey = ref(0)
const hintText = computed(() => { if (status.value === 'success') { return t('success') } if (status.value === 'fail') { return t('fail') } return t('hint', { emoji: targetEmoji.value })})
const gridRef = useTemplateRef('gridRef')
interface SlicedMark { id: number; x: number; y: number; /** 切到的是不是目標水果,決定顯示打勾還是打叉 */ correct: boolean;}
/** 已切開水果的標記,整局持續顯示,重新挑戰才清空 */const slicedMarkList = ref<SlicedMark[]>([])
/** * 在水果右上角別一個打勾/打叉徽章,不管這刀削到的是一大片還是薄薄一角, * 都能明確看出「這顆已經切過了、切對還是切錯」,不用只靠碎塊裂縫的視覺判斷。 */function markFruitSliced(id: number, target: HTMLElement, correct: boolean) { if (slicedMarkList.value.some((mark) => mark.id === id)) { return } slicedMarkList.value.push({ id, correct, x: target.offsetLeft + target.offsetWidth, y: target.offsetTop, })}
// v-dimension-blade 切開元素時會把原始元素設為 visibility:hidden 換碎塊接手,// 監看這個變化即可知道「哪一顆水果」被切開,不必更動次元刀本體useMutationObserver(gridRef, (mutationList) => { for (const mutation of mutationList) { const target = mutation.target if (!(target instanceof HTMLElement) || target.style.visibility !== 'hidden') { continue } const fruitId = target.dataset.fruitId if (fruitId !== undefined) { handleFruitSliced(Number(fruitId), target) } }}, { attributes: true, attributeFilter: ['style'], subtree: true })
function handleFruitSliced(id: number, target: HTMLElement) { const fruit = fruitList.value.find((item) => item.id === id) if (fruit) { fruit.sliced = true } markFruitSliced(id, target, fruit?.isTarget ?? false)}
function restart() { roundKey.value += 1 slicedMarkList.value = [] fruitList.value = generateFruitList()}
onMounted(() => { fruitList.value = generateFruitList()})</script>
<style scoped lang="sass">.captcha width: 100%
.captcha-hint min-height: 1.5em font-size: 0.95rem font-weight: 700
&--success color: #4ade80
&--fail color: #f87171
.fruit-grid position: relative // 固定用 color-scheme 對應站台的 .dark 手動切換,不跟著系統 prefers-color-scheme 漂移 color-scheme: light grid-template-columns: repeat(3, 4.25rem) grid-template-rows: repeat(3, 4.25rem) justify-content: center align-content: center padding: clamp(1.5rem, 6vw, 2.25rem) clamp(4rem, 16vw, 6.5rem) border: 2px solid light-dark(#c5c8cd, #55585f) border-radius: 1.25rem background: light-dark(#eef0f2, #3a3d42) overflow: visible touch-action: none
:global(.dark) & color-scheme: dark
&::before // 握把 content: '' position: absolute top: 0 bottom: 0 left: 0.5rem margin: auto 0 width: 0.8rem height: 4rem background: var(--vp-c-bg) border-radius: 999px box-shadow: inset 1px 0 2px rgba(0, 0, 0, 0.15)
&::after // 防溢流凹槽 content: '' position: absolute inset: 1.5rem border: 2px solid light-dark(rgba(0, 0, 0, 0.12), rgba(255, 255, 255, 0.12)) border-radius: 0.85rem pointer-events: none
// 已切標記:別在水果右上角的打勾/打叉徽章,不蓋住水果本身,持續顯示到重新挑戰// 切開後的碎塊圖層 teleport 到 body、z-index 高達 2147483645(見 cursor-dimension-blade.vue),// 這裡要蓋過去才看得到,不能用預設的 auto.sliced-mark position: absolute top: 0 left: 0 z-index: 2147483647 display: flex align-items: center justify-content: center // 動畫結束後停在這個 transform,跟 keyframes 的 to 對齊,避免 forwards 沒生效時跳掉 transform: translate(-50%, -50%) width: 1.35rem height: 1.35rem border-radius: 50% border: 2px solid light-dark(#ffffff, #3a3d42) color: #ffffff font-size: 0.8rem line-height: 1 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) pointer-events: none animation: sliced-mark-in 0.25s ease-out forwards
&--correct background: #4ade80
&--wrong background: #f87171
@keyframes sliced-mark-in from opacity: 0 transform: translate(-50%, -50%) scale(0.4) to opacity: 1 transform: translate(-50%, -50%) scale(1)
.fruit-cell display: flex align-items: center justify-content: center // 盒子要能真的裝下放大的水果,不能只靠 overflow 讓視覺溢出—— // 切割時的快照與碎塊範圍是照這個盒子的實際大小算的,溢出的部分擷取不到, // 切開分離後會被裁掉一角 width: 4.25rem height: 4.25rem font-size: 3.4rem user-select: none
.restart-btn padding: 0.5rem 1.25rem border: 1px solid rgba(125, 125, 125, 0.35) border-radius: 999px background: var(--vp-c-bg-soft) font-size: 0.85rem font-weight: 700 cursor: pointer transition: background 0.2s ease
&:hover background: var(--vp-c-bg-mute)</style>
<i18n lang="json">{ "zh-hant": { "hint": "請切開所有的 {emoji},證明你不是機器人", "success": "驗證通過,歡迎你,人類 (⌐■_■)", "fail": "切錯了!機器人走開!⎝(・ω´・⎝)", "restart": "重新挑戰" }, "en": { "hint": "Cut open every {emoji}, prove you are not a robot", "success": "Verified. Welcome back to the human world owo", "fail": "Wrong cut! Robot, begone!", "restart": "Try Again" }}</i18n>職責切成三塊,v-dimension-blade 指令只做登記,切割機構集中在共享 controller,cursor-dimension-blade 元件掌控刀痕與觸發。
- 指令:
mounted登記元素與參數至 controller、updated更新參數、unmounted註銷,本身不碰切割與動畫。 - controller:以
Map保管所有標記元素。切割沿用易碎包裝器「DOM 轉圖片再分割」的想法,但改以多邊形裁切搭配 CSSclip-path呈現碎塊,毋須 PixiJS,載入輕量。 - 游標元件:以 VueUse 的
useMouse取得座標、useMouseVelocity算揮動速度,teleport 一層全螢幕刀痕畫布至 body,不改變游標本身。
揮刀到切割,一共四步。(ง •̀_•́)ง
1. 累積刀痕點,沿法線展開成刀光緞帶
Section titled “1. 累積刀痕點,沿法線展開成刀光緞帶”游標每幀讀取速度,超過 speedThreshold 才算「揮動中」,將座標點推入刀痕點列。刀痕以 requestAnimationFrame 逐幀重繪,整段路徑沿法線展開成「尾尖頭寬」的緞帶多邊形(quadratic curve 平滑輪廓),疊暗色襯底、柔光、亮芯三層,做出水果忍者般的刀光,無接縫、深淺背景都清晰。(๑•̀ㅂ•́)و✧
2. 取刀線,Liang–Barsky 判斷命中
Section titled “2. 取刀線,Liang–Barsky 判斷命中”揮動中,取「上一刀點到目前點」的線段當刀線,交給 controller。controller 以 Liang–Barsky 演算法判斷刀線是否掃過各元素矩形,命中就開切。( •̀ω•́ )✧
3. snapdom 擷取快照,裁成兩塊多邊形
Section titled “3. snapdom 擷取快照,裁成兩塊多邊形”首刀以 snapdom 擷取元素當下外觀為快照,碎塊圖層以文件座標絕對定位(跟著頁面捲動),再以刀線將元素矩形裁成兩塊多邊形(Sutherland–Hodgman),各嵌同一張快照並套 clip-path。原件隨即藏起,碎塊無縫接手。(・∀・)
4. 兩半沿法線錯開,露出裂縫
Section titled “4. 兩半沿法線錯開,露出裂縫”兩半藉由 Anime.js 產生動畫,沿刀線法線反向微微錯開,露出裂縫。
同一元素能再切,每刀將掃過的碎塊再裂成兩半,越切越碎。有冷卻時間與塊數上限,不會一次揮動就逐幀切成碎屑。(๑•̀ㅂ•́)
呼叫 restore() 時碎塊沿原路閉合。碎塊與原件像素一致,接合到位直接交換,沒有原件淡入的突兀感。開 auto-restore 則每刀後自動閉合。
切換深淺色主題時,碎塊快照配色會過時。controller 監看 html 的 class/data-theme 與 prefers-color-scheme 變化,立即交還原件,避免碎塊殘留舊配色。
v-dimension-blade
Section titled “v-dimension-blade”標記元素的指令參數:
/** 標記元素的次元刀參數 */export interface DimensionBladeParams { /** 暫時免疫,`true` 時次元刀切不動此元素。@default false */ invincible?: boolean; /** * 判定命中用的矩形內縮量(px),只影響刀線是否算命中,不影響快照與碎塊範圍。 * 適合元素本身留了視覺留白(例如內容比盒子小、置中顯示)時,避免揮過空白處也算命中。 * @default 0 */ hitInset?: number;}cursor-dimension-blade Props
Section titled “cursor-dimension-blade Props”interface Props { /** 揮動速度門檻(px/ms),超過才留下刀痕並切割。@default 5 */ speedThreshold?: number; /** 刀痕粗細(px)。@default 14 */ trailWidth?: number; /** 刀痕顏色。@default '#e8f6ff' */ slashColor?: string; /** 切開後自動復原,關閉時需呼叫 `restore()` 手動復原。@default false */ autoRestore?: boolean; /** 自動復原前的等待時間(ms),僅 autoRestore 開啟時有效。@default 900 */ restoreDelay?: number; /** 單一元素碎塊數上限,達上限後不再切碎。@default 24 */ maxFragmentCount?: number; /** 疊放層級。@default 2147483646 */ zIndex?: number; /** 允許觸控裝置揮動切割。觸控拖曳與頁面捲動天生衝突,開啟後請自行在揮刀範圍套用 `touch-action: none`,否則手指滑動會變成捲動頁面。@default false */ touchEnabled?: boolean;}interface Emits { /** 揮出一刀且確實切到元素時觸發,帶當刀切到的元素數量 */ slice: [count: number];}Methods
Section titled “Methods”interface Expose { /** 復原所有被切開的元素 */ restore: () => void;}