次元刀使用須知
此區塊內容皆已標記 v-dimension-blade,游標揮得夠快就能切開。=͟͟͞͞( •̀д•́)
手速夠快就能斬開世界。(「・ω・)「
切割原理延伸自易碎包裝器的「DOM 轉圖片再分割」,不過這次改以 CSS clip-path 沿刀線裁切碎塊。
觸控裝置預設停用
觸控拖曳與頁面捲動天生衝突,預設只有滑鼠裝置可用。開啟 touch-enabled 可讓觸控也能滑動切割,但揮刀範圍要自行套用 touch-action: none,否則手指滑動只會變成捲動頁面。
| 名稱 | 描述 |
|---|---|
| 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 標記可被切割的元素。
游標揮得夠快就可以出刀,刀線掃過標記元素就會沿刀線裂開。( •̀ ω •́ )✧
此區塊內容皆已標記 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, 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 = 3
const 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,
}
})
}
const fruitList = ref<FruitItem[]>(generateFruitList())
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'>(() => {
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()
}
</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}",
"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 註銷,本身不碰切割與動畫。Map 保管所有標記元素。切割沿用易碎包裝器「DOM 轉圖片再分割」的想法,但改以多邊形裁切搭配 CSS clip-path 呈現碎塊,毋須 PixiJS,載入輕量。useMouse 取得座標、useMouseVelocity 算揮動速度,teleport 一層全螢幕刀痕畫布至 body,不改變游標本身。揮刀到切割,一共四步。(ง •̀_•́)ง
游標每幀讀取速度,超過 speedThreshold 才算「揮動中」,將座標點推入刀痕點列。刀痕以 requestAnimationFrame 逐幀重繪,整段路徑沿法線展開成「尾尖頭寬」的緞帶多邊形(quadratic curve 平滑輪廓),疊暗色襯底、柔光、亮芯三層,做出水果忍者般的刀光,無接縫、深淺背景都清晰。(๑•̀ㅂ•́)و✧
揮動中,取「上一刀點到目前點」的線段當刀線,交給 controller。controller 以 Liang–Barsky 演算法判斷刀線是否掃過各元素矩形,命中就開切。( •̀ω•́ )✧
首刀以 snapdom 擷取元素當下外觀為快照,碎塊圖層以文件座標絕對定位(跟著頁面捲動),再以刀線將元素矩形裁成兩塊多邊形(Sutherland–Hodgman),各嵌同一張快照並套 clip-path。原件隨即藏起,碎塊無縫接手。(・∀・)
兩半藉由 Anime.js 產生動畫,沿刀線法線反向微微錯開,露出裂縫。
同一元素能再切,每刀將掃過的碎塊再裂成兩半,越切越碎。有冷卻時間與塊數上限,不會一次揮動就逐幀切成碎屑。(๑•̀ㅂ•́)
呼叫 restore() 時碎塊沿原路閉合。碎塊與原件像素一致,接合到位直接交換,沒有原件淡入的突兀感。開 auto-restore 則每刀後自動閉合。
切換深淺色主題時,碎塊快照配色會過時。controller 監看 html 的 class/data-theme 與 prefers-color-scheme 變化,立即交還原件,避免碎塊殘留舊配色。
標記元素的指令參數:
/** 標記元素的次元刀參數 */
export interface DimensionBladeParams {
/** 暫時免疫,`true` 時次元刀切不動此元素。@default false */
invincible?: boolean;
/**
* 判定命中用的矩形內縮量(px),只影響刀線是否算命中,不影響快照與碎塊範圍。
* 適合元素本身留了視覺留白(例如內容比盒子小、置中顯示)時,避免揮過空白處也算命中。
* @default 0
*/
hitInset?: number;
}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];
}interface Expose {
/** 復原所有被切開的元素 */
restore: () => void;
}