Dimension Blade Guidelines
Everything in this block is marked with v-dimension-blade; swing fast enough and it splits. =͟͟͞͞( •̀д•́)
Swing fast enough and you cleave the world. (「・ω・)「
The cutting idea extends from Brittle Wrapper's "DOM-to-image, then split" approach, but this time fragments are clipped along the blade line with CSS clip-path.
Touch devices disabled by default
Touch dragging inherently conflicts with page scrolling, so only mouse devices are enabled by default. Turn on touch-enabled to let touch swipes cut too — but you're responsible for applying touch-action: none to the swipe area, or a finger swipe will just scroll the page.
| Name | Description |
|---|---|
| Vue Directive | Custom Vue directives that encapsulate DOM manipulation logic and repeated behaviors |
| DOM to Image | Converts DOM elements into images, based on SVG foreignObject |
| CSS clip-path | Clips an element to a geometric path, commonly used for masks, cuts, and shaping |
| Anime.js | Lightweight JavaScript animation library |
| Vector Math | Math operations for direction, acceleration, velocity, and more |
| Pointer Events | Detects pointer movement, clicks, hovers, and more, providing coordinates and target information |
| JS Animation | JavaScript-driven animation for more complex, precise control; popular libraries include GSAP and anime.js |
Mount cursor-dimension-blade to enable the blade, then mark cuttable elements with v-dimension-blade.
Swing the cursor fast enough to strike — when the blade line sweeps a marked element, it cracks apart along the line. ( •̀ ω •́ )✧
Everything in this block is marked with v-dimension-blade; swing fast enough and it splits. =͟͟͞͞( •̀д•́)
<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>Cut open every fruit of the specified kind to prove you're human.
Remember: swing fast enough, or the blade won't land. (⌐■_■)✧
Cut open every 🍑
<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>Responsibilities split three ways: the v-dimension-blade directive only registers targets, the cutting machinery lives in a shared controller, and the cursor-dimension-blade component drives the slash trail and the trigger.
mounted registers the element and params with the controller, updated refreshes params, unmounted removes it — it never touches cutting or animation itself.Map. Cutting borrows the "DOM-to-image, then split" idea from Brittle Wrapper, rendering polygon-clipped fragments with CSS clip-path — no PixiJS, so it stays lightweight.useMouse, measures swing speed with useMouseVelocity, and teleports a full-screen slash canvas to the body — the cursor itself is left untouched.The swing-and-cut runs in four steps. (ง •̀_•́)ง
Each frame the cursor reads the speed; only above speedThreshold does it count as "swinging" and push the point into the trail list. The trail is redrawn every requestAnimationFrame: the whole path expands along its normals into a single tail-tapered, head-wide ribbon polygon (quadratic curves smooth the outline), filled in three layers — dark underlay, soft glow, bright core — for a seamless Fruit-Ninja streak that reads clearly on both light and dark backgrounds. (๑•̀ㅂ•́)و✧
While swinging, the segment from the previous point to the current one becomes the blade line, handed to the controller. It uses the Liang–Barsky algorithm to test whether the line sweeps each element's rectangle, and cuts on a hit. ( •̀ω•́ )✧
The first cut captures the element's current look with snapdom. The fragment layer is absolutely positioned in document coordinates (so it scrolls with the page), and the blade line splits the element rectangle into two polygons (Sutherland–Hodgman); each embeds the same snapshot plus a clip-path. The original is hidden as the fragments seamlessly take over. (・∀・)
The two halves shift slightly apart along the blade line's normal with Anime.js, revealing a crack — no falling.
The same element can be cut again: each swing splits the swept fragments in two, splintering further (with a cooldown and a fragment cap, so one swing never shreds it frame by frame). (๑•̀ㅂ•́)
Calling restore() slides every fragment back along its path; the fragments are pixel-identical to the original, so the swap on contact is invisible — no original fading back in. With auto-restore on, cracks close automatically after each cut.
When the color theme switches, fragment snapshots hold stale colors. The controller watches the html element's class/data-theme and prefers-color-scheme, and instantly hands back the original so no fragment lingers in the old palette.
Directive params for marking elements:
/** 標記元素的次元刀參數 */
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;
}