Compare commits

..

No commits in common. "15144240b73a52bc977e914f3b4cca41b4dab4b1" and "c72846b06ebd44418d23c9e16a7fddc39b42a47e" have entirely different histories.

22 changed files with 228 additions and 3202 deletions

View File

@ -14,9 +14,7 @@
"Bash(npm link:*)", "Bash(npm link:*)",
"Bash(find:*)", "Bash(find:*)",
"Bash(nul)", "Bash(nul)",
"Bash(cd:*)", "Bash(cd:*)"
"Bash(ls -la /d/Projects/WebstormProjects/raonnuri/src/app/\\\\[locale\\\\]/)",
"Bash(find \"D:\\\\Projects\\\\WebstormProjects\\\\raonnuri\\\\src\\\\app\\\\[locale]\\\\interaction\" -type f \\\\\\( -name \"*.tsx\" -o -name \"*.ts\" \\\\\\) 2>/dev/null | head -10)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

869
README.md
View File

@ -1,23 +1,20 @@
# Responsive Image Canvas # Responsive Image Canvas
GPU 가속 이미지 왜곡 효과를 제공하는 React 컴포넌트 라이브러리입니다. GPU 가속 이미지 왜곡 효과를 제공하는 React 컴포넌트 라이브러리입니다. Three.js와 GLSL 셰이더를 사용하여 실시간 이미지 왜곡 애니메이션을 구현합니다.
Three.js와 GLSL 셰이더를 사용하여 실시간 이미지 왜곡 애니메이션, 마우스/터치 인터랙션, 파티클 이펙트를 구현합니다.
## 특징 ## 특징
- GPU 가속 렌더링 (Three.js + WebGL) - 🚀 GPU 가속 렌더링 (Three.js + WebGL)
- 최대 8개의 독립적인 왜곡 영역 지원 - 🎨 최대 8개의 독립적인 왜곡 영역 지원
- 스프링 물리 기반 마우스/터치 인터랙션 - ⚡ 60fps 실시간 애니메이션
- 이모지 & 스프라이트 시트 파티클 이펙트 - 🎯 정규화된 좌표계 (0.0 - 1.0)
- 모션 프리셋 & 커스텀 이징 함수 - 🔧 TypeScript 완벽 지원
- 렌즈 왜곡 (볼록/오목) 효과 - 📦 ESM & CommonJS 모두 지원
- 영역 편집을 위한 에디터 컴포넌트
- TypeScript & ESM/CJS 지원
## 설치 ## 설치
```bash ```bash
npm install @baekryang/responsive-image-canvas npm install responsive-image-canvas
``` ```
### Peer Dependencies ### Peer Dependencies
@ -26,38 +23,25 @@ npm install @baekryang/responsive-image-canvas
npm install react react-dom three npm install react react-dom three
``` ```
| 패키지 | 버전 |
|--------|------|
| `react` | `^18.0.0 \|\| ^19.0.0` |
| `react-dom` | `^18.0.0 \|\| ^19.0.0` |
| `three` | `>=0.150.0` |
---
## 기본 사용법 ## 기본 사용법
### 이미지 왜곡 표시 (View Mode)
```tsx ```tsx
import { ImageDistortion } from '@baekryang/responsive-image-canvas'; import { ImageDistortion, DistortionArea } from 'responsive-image-canvas';
import type { DistortionArea } from '@baekryang/responsive-image-canvas';
const areas: DistortionArea[] = [ const areas: DistortionArea[] = [
{ {
id: 'area-1', id: 'area-1',
basePoints: [ basePoints: [
{ x: 0.3, y: 0.3 }, // 좌상단 { x: 0.2, y: 0.2 }, // 좌상단
{ x: 0.7, y: 0.3 }, // 우상단 { x: 0.4, y: 0.2 }, // 우상단
{ x: 0.7, y: 0.7 }, // 우하단 { x: 0.4, y: 0.4 }, // 우하단
{ x: 0.3, y: 0.7 }, // 좌하단 { x: 0.2, y: 0.4 }, // 좌하단
], ],
movement: { movement: {
preset: 'horizontal', vectorA: { x: 0.1, y: 0.1 },
vectorA: { x: 0.1, y: 0 }, vectorB: { x: -0.1, y: -0.1 },
vectorB: { x: -0.1, y: 0 },
duration: 2.0, duration: 2.0,
easing: 'easeInOut', easing: 'easeInOut',
strength: 0.15,
}, },
distortionStrength: 0.5, distortionStrength: 0.5,
progress: 0, progress: 0,
@ -67,119 +51,88 @@ const areas: DistortionArea[] = [
function App() { function App() {
return ( return (
<div style={{ width: '800px', height: '600px' }}>
<ImageDistortion <ImageDistortion
imageSrc="/image.jpg" imageSrc="/path/to/image.jpg"
areas={areas} areas={areas}
style={{ width: '100%', height: '100%' }} isPlaying={true}
/> />
</div>
); );
} }
``` ```
> **좌표계**: 모든 좌표는 **정규화 좌표(0.0 ~ 1.0)** 를 사용합니다. `(0, 0)`은 이미지 좌상단, `(1, 1)`은 우하단입니다. ## Props
--- ### `ImageDistortionProps`
## 컴포넌트 | Prop | 타입 | 필수 | 기본값 | 설명 |
|------|------|------|--------|------|
| `imageSrc` | `string` | ✓ | - | 이미지 소스 URL |
| `areas` | `DistortionArea[]` | ✓ | - | 왜곡 영역 배열 |
| `vertexShaderPath` | `string` | ✗ | `/shaders/distortion.vert.glsl` | 커스텀 버텍스 셰이더 경로 |
| `fragmentShaderPath` | `string` | ✗ | `/shaders/distortion.frag.glsl` | 커스텀 프래그먼트 셰이더 경로 |
| `isPlaying` | `boolean` | ✗ | `true` | 애니메이션 재생 여부 |
| `style` | `CSSProperties` | ✗ | - | 컨테이너 스타일 |
| `className` | `string` | ✗ | - | 컨테이너 클래스명 |
### `<ImageDistortion />` ## 타입 정의
이미지 왜곡 및 인터랙션 렌더링을 담당하는 메인 컴포넌트입니다. ### `DistortionArea`
```tsx ```typescript
<ImageDistortion interface DistortionArea {
imageSrc="/image.jpg" id: string; // 고유 식별자
areas={areas} basePoints: [Point, Point, Point, Point]; // 사각형의 네 모서리
mouseInteraction={mouseConfig} movement: DistortionMovement; // 애니메이션 설정
spriteEffectAreas={spriteEffectAreas} distortionStrength: number; // 왜곡 강도 (0.0 - 1.0)
style={{ width: '100%', height: '100%' }} progress: number; // 애니메이션 진행도 (0.0 - 1.0)
/> dragVector: Point; // 현재 드래그 벡터
}
``` ```
| Prop | 타입 | 필수 | 설명 | ### `Point`
|------|------|:----:|------|
| `imageSrc` | `string` | O | 이미지 URL |
| `areas` | `DistortionArea[]` | O | 왜곡 영역 배열 |
| `mouseInteraction` | `MouseInteractionConfig` | | 마우스/터치 인터랙션 설정 |
| `spriteEffectAreas` | `SpriteEffectArea[]` | | 파티클 이펙트 영역 |
| `isPlaying` | `boolean` | | 애니메이션 재생 여부 (기본: `true`) |
| `style` | `CSSProperties` | | 컨테이너 스타일 |
| `className` | `string` | | 컨테이너 클래스 |
### `<EditorCanvas />` ```typescript
interface Point {
영역 편집을 위한 시각적 에디터 오버레이입니다. 꼭짓점 드래그, 영역 선택 등을 제공합니다. x: number; // 0.0 - 1.0 (정규화된 좌표)
y: number; // 0.0 - 1.0 (정규화된 좌표)
```tsx }
<EditorCanvas
areas={areas}
selectedAreaId={selectedId}
imageSrc="/image.jpg"
width={imageWidth}
height={imageHeight}
onUpdatePoint={(areaId, pointIndex, point) => { /* ... */ }}
onUpdateArea={(areaId, updates) => { /* ... */ }}
draggingPointIndex={draggingIndex}
onStartDragging={(index) => { /* ... */ }}
onStopDragging={() => { /* ... */ }}
style={editorCanvasStyle}
showEditor={true}
onSelectArea={(areaId) => { /* ... */ }}
spriteEffectAreas={spriteEffectAreas}
/>
``` ```
| Prop | 타입 | 필수 | 설명 | ### `DistortionMovement`
|------|------|:----:|------|
| `areas` | `DistortionArea[]` | O | 왜곡 영역 배열 |
| `selectedAreaId` | `string \| null` | O | 선택된 영역 ID |
| `imageSrc` | `string` | O | 이미지 URL |
| `width` | `number` | O | 이미지 너비 (px) |
| `height` | `number` | O | 이미지 높이 (px) |
| `onUpdatePoint` | `(areaId, pointIndex, point) => void` | O | 꼭짓점 이동 콜백 |
| `onUpdateArea` | `(areaId, updates) => void` | O | 영역 업데이트 콜백 |
| `draggingPointIndex` | `number \| null` | O | 드래그 중인 포인트 인덱스 |
| `onStartDragging` | `(index) => void` | O | 드래그 시작 콜백 |
| `onStopDragging` | `() => void` | O | 드래그 종료 콜백 |
| `style` | `EditorCanvasStyle` | | 에디터 UI 스타일 |
| `showEditor` | `boolean` | | 에디터 표시 여부 (기본: `true`) |
| `onSelectArea` | `(areaId) => void` | | 영역 선택 콜백 |
| `spriteEffectAreas` | `SpriteEffectArea[]` | | 파티클 이펙트 영역 |
### `<AreaList />`, `<ParameterPanel />` ```typescript
interface DistortionMovement {
영역 목록 관리 및 파라미터 편집을 위한 보조 에디터 컴포넌트입니다. vectorA: Point; // 시작 벡터
vectorB: Point; // 종료 벡터
--- duration: number; // 지속 시간 (초)
easing: EasingFunction; // 이징 함수
## Hooks }
### `useDistortionEditor`
에디터 상태 관리를 위한 핵심 훅입니다.
```tsx
import { useDistortionEditor, DEFAULT_AREA } from '@baekryang/responsive-image-canvas';
const {
state, // { areas, selectedAreaId, editMode, draggingPointIndex }
selectArea, // (areaId: string | null) => void
addArea, // (area: DistortionArea) => void
removeArea, // (areaId: string) => void
updateArea, // (areaId: string, updates: Partial<DistortionArea>) => void
updatePoint, // (areaId: string, pointIndex: number, point: Point) => void
startDragging, // (pointIndex: number) => void
stopDragging, // () => void
getSelectedArea, // () => DistortionArea | null
} = useDistortionEditor(initialAreas);
``` ```
#### 사용 예시 ### `EasingFunction`
```typescript
type EasingFunction =
| 'linear'
| 'easeIn'
| 'easeOut'
| 'easeInOut'
| 'easeInQuad'
| 'easeOutQuad';
```
## 고급 사용법
### 영역 동적 추가/제거
```tsx ```tsx
// 새 영역 추가 function DynamicDistortion() {
const handleAddArea = () => { const [areas, setAreas] = useState<DistortionArea[]>([]);
addArea({
const addArea = () => {
const newArea: DistortionArea = {
id: `area-${Date.now()}`, id: `area-${Date.now()}`,
basePoints: [ basePoints: [
{ x: 0.3, y: 0.3 }, { x: 0.3, y: 0.3 },
@ -188,624 +141,114 @@ const handleAddArea = () => {
{ x: 0.3, y: 0.7 }, { x: 0.3, y: 0.7 },
], ],
movement: { movement: {
preset: 'none', vectorA: { x: 0.15, y: 0 },
vectorA: { x: 0, y: 0 }, vectorB: { x: -0.15, y: 0 },
vectorB: { x: 0, y: 0 }, duration: 3.0,
duration: DEFAULT_AREA.DURATION, easing: 'easeInOut',
easing: DEFAULT_AREA.EASING,
strength: 0.15,
}, },
distortionStrength: DEFAULT_AREA.DISTORTION_STRENGTH, distortionStrength: 0.6,
progress: 0, progress: 0,
dragVector: { x: 0, y: 0 }, dragVector: { x: 0, y: 0 },
});
}; };
// 선택된 영역 업데이트 setAreas([...areas, newArea]);
updateArea(state.selectedAreaId, {
distortionStrength: 0.8,
lensEffect: { strength: 0.3 },
});
```
### `useMouseInteraction`
마우스/터치 기반 스프링 물리 인터랙션을 제공합니다.
```tsx
import { useMouseInteraction } from '@baekryang/responsive-image-canvas';
const {
updateInteraction, // (areas, deltaTime) => DistortionArea[]
updateConfig, // (newConfig: Partial<MouseInteractionConfig>) => void
reset, // () => void
isDragging, // () => boolean
getInteractingAreaIndices, // () => Set<number>
getMouseState, // () => MouseState
} = useMouseInteraction(containerRef, mouseConfig);
```
### `useAnimationFrame`
requestAnimationFrame 기반 애니메이션 루프입니다.
```tsx
import { useAnimationFrame } from '@baekryang/responsive-image-canvas';
useAnimationFrame((deltaTime) => {
// deltaTime: 초 단위
}, isPlaying);
```
---
## 마우스 인터랙션
스프링 물리 기반의 마우스/터치 인터랙션을 설정합니다.
```tsx
import type { MouseInteractionConfig } from '@baekryang/responsive-image-canvas';
const mouseConfig: MouseInteractionConfig = {
enabled: true,
physics: {
stiffness: 80, // 탄성 계수 (높을수록 빠르게 반응)
damping: 8, // 감쇠 계수 (높을수록 빠르게 정지)
mass: 1.0, // 질량 (높을수록 무겁게 반응)
influenceRadius: 0.15, // 영향 반경 (정규화 좌표)
maxStrength: 0.5, // 최대 왜곡 강도
},
minVelocity: 0.1,
maxVelocity: 1.0,
velocityMultiplier: 0.15,
};
<ImageDistortion
imageSrc="/image.jpg"
areas={areas}
mouseInteraction={mouseConfig}
/>
```
### 물리 프리셋 예시
```tsx
// 부드러운 반응
const soft = {
stiffness: 30, damping: 20, mass: 2.0, maxStrength: 0.3, influenceRadius: 0.15
};
// 탄성 있는 반응
const bouncy = {
stiffness: 150, damping: 5, mass: 1.0, maxStrength: 0.5, influenceRadius: 0.15
};
// 무거운 반응
const heavy = {
stiffness: 80, damping: 15, mass: 3.0, maxStrength: 0.4, influenceRadius: 0.15
};
```
영역별로 개별 물리 설정도 가능합니다:
```tsx
const area: DistortionArea = {
// ...
physics: {
stiffness: 150,
damping: 5,
mass: 1.0,
influenceRadius: 0.15,
maxStrength: 0.5,
},
};
```
---
## 파티클 이펙트
이미지 위에 이모지 또는 스프라이트 이미지 기반 파티클 이펙트를 추가합니다.
### 이모지 파티클
```tsx
import type { SpriteEffectArea, SpriteEffectConfig } from '@baekryang/responsive-image-canvas';
const spriteEffectAreas: SpriteEffectArea[] = [
{
id: 'effect-area-1',
position: { x: 0.5, y: 0.5 }, // 이펙트 중심 (정규화 좌표)
radius: 0.15, // 방출 반경
effects: [
{
id: 'hearts',
emoji: '❤️', // 이모지 → Canvas 텍스처로 자동 변환
trigger: 'touch', // 'touch': 클릭 시 발사 | 'ambient': 지속 방출
blendMode: 'normal',
maxParticles: 40,
burstCount: 8, // touch 트리거 시 한번에 생성할 파티클 수
lifetime: [1, 2.5], // [최소, 최대] 수명 (초)
initialScale: [0.04, 0.08], // [최소, 최대] 크기 (정규화)
initialSpeed: [0.06, 0.12], // [최소, 최대] 초기 속도
emitAngle: [0, 360], // 방출 각도 범위 (도)
overLifetime: {
scale: [1, 0.3], // 수명에 따른 크기 변화
opacity: [1, 0], // 수명에 따른 투명도 변화
velocityDamping: 0.94, // 속도 감쇠 (0~1)
},
},
],
},
];
<ImageDistortion
imageSrc="/image.jpg"
areas={areas}
spriteEffectAreas={spriteEffectAreas}
/>
```
### 지속 방출 (Ambient) 이펙트
```tsx
const fireEffect: SpriteEffectConfig = {
id: 'fire',
emoji: '🔥',
trigger: 'ambient',
blendMode: 'normal',
maxParticles: 30,
emitRate: 3, // 초당 생성 파티클 수
lifetime: [1, 2.5],
initialScale: [0.04, 0.08],
initialSpeed: [0.02, 0.05],
emitAngle: [250, 290], // 위쪽 방향으로 제한
emitRadius: 0.15,
overLifetime: {
scale: [1, 0.4],
opacity: [1, 0],
velocityDamping: 0.97,
},
};
```
### 스프라이트 이미지 사용
이모지 대신 URL 기반 스프라이트 이미지를 사용할 수 있습니다:
```tsx
const effect: SpriteEffectConfig = {
id: 'custom-sprite',
spriteUrl: '/sprites/particle.png', // emoji 대신 spriteUrl 사용
trigger: 'ambient',
// ... 나머지 설정 동일
};
```
### 스프라이트 시트
```tsx
const effect: SpriteEffectConfig = {
id: 'animated-sprite',
spriteUrl: '/sprites/explosion-sheet.png',
spriteSheet: {
columns: 4,
rows: 4,
totalFrames: 16,
fps: 24,
loop: false,
},
// ... 나머지 설정
};
```
---
## 모션 프리셋
영역 애니메이션에 사용할 수 있는 빌트인 모션 프리셋입니다.
| 프리셋 | 동작 |
|--------|------|
| `none` | 움직임 없음 |
| `horizontal` | 좌우 왕복 |
| `vertical` | 상하 왕복 |
| `rotate-cw` | 시계 방향 회전 |
| `rotate-ccw` | 반시계 방향 회전 |
| `pulse` | 맥동 (확대/축소) |
| `diagonal-1` | 대각선 (↗↙) |
| `diagonal-2` | 대각선 (↘↖) |
```tsx
const area: DistortionArea = {
// ...
movement: {
preset: 'horizontal',
vectorA: { x: 0.1, y: 0 },
vectorB: { x: -0.1, y: 0 },
duration: 2.0,
easing: 'easeInOut',
strength: 0.15,
},
};
```
### 커스텀 모션 프리셋 등록
```tsx
import { registerMotionPresets } from '@baekryang/responsive-image-canvas';
registerMotionPresets({
'wave': (strength) => ({ x: strength * 0.5, y: strength * 0.3 }),
'spiral': (strength) => ({ x: strength, y: 0 }),
}, ['spiral']); // 두 번째 인자: 회전형 프리셋 이름 목록
```
### 이징 함수
| 이징 | 설명 |
|------|------|
| `linear` | 등속 |
| `easeIn` / `easeInQuad` / `easeInCubic` | 느리게 시작 → 빠르게 |
| `easeOut` / `easeOutQuad` / `easeOutCubic` | 빠르게 시작 → 느리게 |
| `easeInOut` | 양쪽 모두 부드럽게 |
---
## 렌즈 효과 & 스텝 이징
### 렌즈 왜곡
영역에 볼록/오목 렌즈 효과를 적용합니다.
```tsx
const area: DistortionArea = {
// ...
lensEffect: {
strength: 0.5, // 양수: 볼록, 음수: 오목 (-1.0 ~ 1.0)
},
};
```
### 스텝 이징
애니메이션을 이산적인 단계로 분할합니다.
```tsx
const area: DistortionArea = {
// ...
snapSteps: 3, // 0: 부드러운 연속 애니메이션, 1~5: 단계 수
};
```
---
## 에디터 스타일 커스터마이징
`EditorCanvas`의 시각적 요소를 커스터마이징할 수 있습니다.
```tsx
import type { EditorCanvasStyle } from '@baekryang/responsive-image-canvas';
const editorStyle: EditorCanvasStyle = {
// 가이드 원 (최대 3단계)
circleLevels: [
{ radius: 0.5, opacity: 0.4, lineWidth: 2.5, color: 'rgba(255,107,157,0.8)', dashPattern: [10, 5] },
{ radius: 0.33, opacity: 0.7, lineWidth: 3, color: 'rgba(255,107,157,0.9)', dashPattern: [8, 4] },
{ radius: 0.167, opacity: 1.0, lineWidth: 4, color: 'rgba(255,107,157,1)', dashPattern: [6, 3] },
],
circleFillColor: 'rgba(255, 107, 157, 0.08)',
// 중심점
centerPoint: {
radius: 6,
fillColor: 'rgba(255, 107, 157, 1)',
strokeColor: 'rgba(255, 255, 255, 0.9)',
strokeWidth: 2.5,
},
// 꼭짓점 핸들
pointHandle: {
size: 18,
fillColor: '#FF6B9D',
strokeColor: '#ffffff',
strokeWidth: 2.5,
labelColor: '#FF6B9D',
labelFontSize: 12,
},
// 영역 외곽선
areaOutline: {
selectedColor: '#FF6B9D',
unselectedColor: 'rgba(0, 48, 255, 0.55)',
selectedWidth: 2.5,
unselectedWidth: 4,
unselectedDashPattern: [6, 4],
selectedFillColor: 'rgba(255, 107, 157, 0.12)',
unselectedFillColor: 'rgba(156, 163, 175, 0.05)',
},
};
```
---
## 데이터 영속화
`DistortionArea`에서 런타임 필드(`progress`, `dragVector`)를 제외하고 저장합니다.
### 저장
```tsx
const areasToSave = state.areas.map(area => ({
id: area.id,
basePoints: area.basePoints,
movement: area.movement,
distortionStrength: area.distortionStrength,
physics: area.physics,
lensEffect: area.lensEffect,
snapSteps: area.snapSteps,
}));
// DB에 저장 (Firestore, REST API 등)
await saveToDatabase(areasToSave);
```
### 로드
```tsx
const loadedAreas: DistortionArea[] = savedData.map(area => ({
...area,
basePoints: area.basePoints as [Point, Point, Point, Point],
movement: {
...area.movement,
easing: area.movement.easing as EasingFunction,
},
progress: 0, // 런타임 상태 초기화
dragVector: { x: 0, y: 0 }, // 런타임 상태 초기화
}));
```
---
## 통합 예제: View + Editor
```tsx
import {
ImageDistortion,
EditorCanvas,
useDistortionEditor,
DEFAULT_AREA,
} from '@baekryang/responsive-image-canvas';
import type {
DistortionArea,
MouseInteractionConfig,
SpriteEffectArea,
EditorCanvasStyle,
} from '@baekryang/responsive-image-canvas';
function ImageEditor({ imageSrc, imageWidth, imageHeight }) {
const [isEditing, setIsEditing] = useState(true);
const {
state, selectArea, addArea, removeArea,
updateArea, updatePoint, startDragging, stopDragging,
} = useDistortionEditor([]);
const mouseConfig: MouseInteractionConfig = {
enabled: !isEditing,
physics: { stiffness: 80, damping: 8, mass: 1.0, influenceRadius: 0.15, maxStrength: 0.5 },
}; };
return ( return (
<div style={{ position: 'relative', width: imageWidth, height: imageHeight }}> <div>
{isEditing ? ( <button onClick={addArea}>영역 추가</button>
<EditorCanvas
areas={state.areas}
selectedAreaId={state.selectedAreaId}
imageSrc={imageSrc}
width={imageWidth}
height={imageHeight}
onUpdatePoint={updatePoint}
onUpdateArea={(id, updates) => updateArea(id, updates)}
draggingPointIndex={state.draggingPointIndex}
onStartDragging={startDragging}
onStopDragging={stopDragging}
onSelectArea={selectArea}
/>
) : (
<ImageDistortion <ImageDistortion
imageSrc={imageSrc} imageSrc="/image.jpg"
areas={state.areas} areas={areas}
mouseInteraction={mouseConfig}
style={{ width: '100%', height: '100%' }}
/> />
)}
</div> </div>
); );
} }
``` ```
--- ### 유틸리티 함수 사용
## 타입 레퍼런스 ```tsx
import { DEFAULT_AREA, applyEasing } from 'responsive-image-canvas';
### 핵심 타입 // 기본 설정값 사용
const newArea = {
```typescript ...DEFAULT_AREA,
interface Point { id: 'my-area',
x: number; // 0.0 ~ 1.0 basePoints: [/* ... */],
y: number; // 0.0 ~ 1.0 };
}
interface DistortionArea {
id: string;
basePoints: [Point, Point, Point, Point]; // [좌상, 우상, 우하, 좌하]
movement: DistortionMovement;
distortionStrength: number; // 0.0 ~ 1.0
progress: number; // 0.0 ~ 1.0 (런타임)
dragVector: Point; // (런타임)
physics?: SpringPhysicsConfig;
lensEffect?: { strength: number }; // -1.0 ~ 1.0
snapSteps?: number; // 0 ~ 5
}
interface DistortionMovement {
preset?: MotionPreset;
vectorA: Point;
vectorB: Point;
duration: number; // 초
easing: EasingFunction;
strength?: number;
}
```
### 인터랙션 타입
```typescript
interface SpringPhysicsConfig {
stiffness: number;
damping: number;
mass: number;
influenceRadius: number;
maxStrength: number;
}
interface MouseInteractionConfig {
enabled: boolean;
physics: SpringPhysicsConfig;
minVelocity?: number;
maxVelocity?: number;
velocityMultiplier?: number;
}
interface MouseState {
position: Point | null;
prevPosition: Point | null;
velocity: Point;
acceleration: Point;
isHovering: boolean;
isDragging: boolean;
}
```
### 파티클 이펙트 타입
```typescript
type SpriteEffectTrigger = 'ambient' | 'touch';
type SpriteBlendMode = 'normal' | 'additive';
interface SpriteEffectConfig {
id: string;
trigger: SpriteEffectTrigger;
emoji?: string; // 이모지 (spriteUrl과 택1)
spriteUrl?: string; // 스프라이트 이미지 URL
blendMode?: SpriteBlendMode;
maxParticles: number;
emitRate?: number; // ambient용 (초당 생성 수)
burstCount?: number; // touch용 (클릭당 생성 수)
lifetime: [number, number]; // [최소, 최대] 초
initialScale: [number, number];
initialSpeed: [number, number];
emitAngle?: [number, number]; // 도
emitOffset?: Point;
emitRadius?: number;
overLifetime?: SpriteParticleOverLifetime;
spriteSheet?: SpriteSheetConfig;
}
interface SpriteEffectArea {
id: string;
position: Point;
radius?: number; // 기본: 0.1
effects: SpriteEffectConfig[];
}
interface SpriteParticleOverLifetime {
scale?: number[]; // [시작, 끝] 또는 [시작, 중간, 끝]
opacity?: number[];
rotationSpeed?: number; // 라디안/초
velocityDamping?: number; // 0 ~ 1
}
interface SpriteSheetConfig {
columns: number;
rows: number;
totalFrames: number;
fps: number;
loop?: boolean;
}
```
### 이징 & 프리셋 타입
```typescript
type EasingFunction =
| 'linear'
| 'easeIn' | 'easeOut' | 'easeInOut'
| 'easeInQuad' | 'easeOutQuad'
| 'easeInCubic' | 'easeOutCubic';
type BuiltInMotionPreset =
| 'none' | 'horizontal' | 'vertical'
| 'rotate-cw' | 'rotate-ccw'
| 'pulse' | 'diagonal-1' | 'diagonal-2';
type MotionPreset = BuiltInMotionPreset | (string & {});
```
---
## 상수
```typescript
import { DEFAULT_AREA, SHADER_CONFIG, ANIMATION_CONFIG } from '@baekryang/responsive-image-canvas';
DEFAULT_AREA.DISTORTION_STRENGTH // 0.5
DEFAULT_AREA.DURATION // 2.0
DEFAULT_AREA.EASING // 'easeInOut'
DEFAULT_AREA.LENS_STRENGTH // 0
DEFAULT_AREA.SNAP_STEPS // 0
SHADER_CONFIG.MAX_AREAS // 8
ANIMATION_CONFIG.TARGET_FPS // 60
```
---
## 유틸리티
```typescript
import {
applyEasing,
registerMotionPreset,
registerMotionPresets,
unregisterMotionPreset,
getRegisteredPresets,
presetToVector,
isRotationPreset,
} from '@baekryang/responsive-image-canvas';
// 이징 함수 직접 사용 // 이징 함수 직접 사용
const easedValue = applyEasing(0.5, 'easeInOut'); // 0.5 const easedValue = applyEasing(0.5, 'easeInOut');
console.log(easedValue); // 0.5
// 커스텀 모션 프리셋
registerMotionPreset('wobble', (strength) => ({
x: strength * Math.sin(Date.now() * 0.001),
y: 0,
}));
// 프리셋 → 벡터 변환
const vector = presetToVector('horizontal', 0.15); // { x: 0.15, y: 0 }
``` ```
--- ## 셰이더 파일
패키지는 기본 셰이더 파일을 포함하고 있습니다:
- `dist/distortion.vert.glsl` - 버텍스 셰이더
- `dist/distortion.frag.glsl` - 프래그먼트 셰이더
웹 서버에서 이 파일들을 정적 파일로 제공해야 합니다.
### Vite 설정 예시
```typescript
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
publicDir: 'public',
// node_modules의 셰이더 파일을 복사
build: {
rollupOptions: {
output: {
assetFileNames: 'assets/[name].[ext]',
},
},
},
});
```
셰이더 파일을 public 폴더로 복사:
```bash
cp node_modules/responsive-image-canvas/dist/*.glsl public/shaders/
```
## 성능 최적화
### 1. 영역 수 제한
최대 8개의 영역까지 지원하지만, 성능을 위해 4개 이하를 권장합니다.
### 2. 이미지 크기 최적화
큰 이미지는 성능에 영향을 줄 수 있습니다. 적절한 크기로 리사이징하세요.
### 3. 애니메이션 일시정지
필요하지 않을 때는 `isPlaying={false}`로 설정하세요.
## 제한사항 ## 제한사항
- WebGL을 지원하지 않는 브라우저에서는 동작하지 않습니다 - WebGL을 지원하지 않는 브라우저에서는 동작하지 않습니다
- 모바일 환경에서는 성능이 제한될 수 있습니다
- 최대 8개의 왜곡 영역만 지원합니다 - 최대 8개의 왜곡 영역만 지원합니다
- `emoji``spriteUrl`은 하나만 사용 가능합니다 (둘 다 없으면 텍스처 로드 실패)
## 브라우저 지원
- Chrome 60+
- Firefox 60+
- Safari 12+
- Edge 79+
## 라이선스 ## 라이선스
MIT MIT
## 기여
이슈와 PR을 환영합니다!
## 관련 프로젝트
- [Three.js](https://threejs.org/)
- [React Three Fiber](https://github.com/pmndrs/react-three-fiber)

View File

@ -76,28 +76,13 @@ void main() {
vec2 distortion = u_dragVectors[i] * influence * u_distortionStrengths[i]; vec2 distortion = u_dragVectors[i] * influence * u_distortionStrengths[i];
texCoord += distortion; texCoord += distortion;
// 렌즈 왜곡 효과 (볼록: 중심 확대, 오목: 중심 축소) // 렌즈 왜곡 효과 (방사형 UV 왜곡)
if (abs(u_lensEffects[i]) > 0.001) { if (abs(u_lensEffects[i]) > 0.001) {
// 영역 중심의 글로벌 UV 좌표 vec2 centered = uv_local - vec2(0.5);
vec2 minP_area = min(min(p0, p1), min(p2, p3)); float dist2 = dot(centered, centered);
vec2 maxP_area = max(max(p0, p1), max(p2, p3)); float lensK = u_lensEffects[i] * 2.0; // 강도 스케일링
vec2 areaSize = maxP_area - minP_area; vec2 lensDistortion = centered * lensK * dist2;
vec2 areaCenterUV = (minP_area + maxP_area) * 0.5 / u_resolution; texCoord += lensDistortion * u_distortionStrengths[i];
// 현재 픽셀에서 영역 중심까지의 글로벌 UV 오프셋
vec2 offset = vUv - areaCenterUV;
// 픽셀 공간 거리로 원형 감쇠 (긴 변 기준으로 영역 전체 커버)
float distPx = length(offset * u_resolution);
float maxRadiusPx = max(areaSize.x, areaSize.y) * 0.5;
float normalizedDist = distPx / maxRadiusPx;
if (normalizedDist < 1.0) {
// 중심에서 최대 강도, 가장자리로 갈수록 자연스럽게 0으로 감소
float lensAmount = u_lensEffects[i] * (1.0 - normalizedDist * normalizedDist);
// 볼록(+): 텍스처 좌표를 중심으로 당김 → 확대
// offset은 글로벌 UV이므로 픽셀 공간에서 등방성(isotropic) 확대
texCoord -= offset * lensAmount * u_distortionStrengths[i];
}
} }
} }
} }

170
dist/index.d.mts vendored
View File

@ -143,120 +143,6 @@ interface AnimationTicker {
resume: () => void; resume: () => void;
} }
/** 이펙트 트리거 타입 */
type SpriteEffectTrigger = 'ambient' | 'touch';
/** 블렌드 모드 */
type SpriteBlendMode = 'normal' | 'additive';
/**
*
*/
interface SpriteParticleOverLifetime {
/** [시작, 끝] 스케일 */
scale?: [number, number];
/** [시작, 끝] 투명도 */
opacity?: [number, number];
/** 회전 속도 (라디안/초) */
rotationSpeed?: number;
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
velocityDamping?: number;
}
/**
*
*/
interface SpriteSheetConfig {
/** 가로 프레임 수 */
columns: number;
/** 세로 프레임 수 */
rows: number;
/** 총 프레임 수 (columns * rows 보다 적을 수 있음) */
totalFrames: number;
/** 재생 속도 (프레임/초) */
fps: number;
/** 반복 재생 여부 (기본: true) */
loop?: boolean;
}
/**
*
* (DistortionArea)
*/
interface SpriteEffectArea {
/** 고유 식별자 */
id: string;
/** 이펙트 중심 좌표 (정규화 0-1) */
position: Point;
/** 터치 감지 반경 (정규화, 기본: 0.1) */
radius?: number;
/** 이 영역에 연결된 이펙트 설정 배열 */
effects: SpriteEffectConfig[];
}
/**
* SpriteEffectArea
* DB
*/
interface SpriteEffectAreaData {
id: string;
position: {
x: number;
y: number;
};
radius?: number;
effects: Array<{
id: string;
trigger: SpriteEffectTrigger;
spriteUrl: string;
blendMode?: SpriteBlendMode;
maxParticles: number;
emitRate?: number;
burstCount?: number;
lifetime: [number, number];
initialScale: [number, number];
initialSpeed: [number, number];
emitAngle?: [number, number];
emitOffset?: {
x: number;
y: number;
};
emitRadius?: number;
overLifetime?: SpriteParticleOverLifetime;
spriteSheet?: SpriteSheetConfig;
}>;
}
/**
*
*/
interface SpriteEffectConfig {
/** 고유 식별자 */
id: string;
/** 트리거 타입 */
trigger: SpriteEffectTrigger;
/** 스프라이트 이미지 URL */
spriteUrl: string;
/** 블렌드 모드 (기본: 'normal') */
blendMode?: SpriteBlendMode;
/** 최대 파티클 수 */
maxParticles: number;
/** ambient: 초당 방출 수 */
emitRate?: number;
/** touch: 터치 시 방출 수 */
burstCount?: number;
/** [최소, 최대] 수명 (초) */
lifetime: [number, number];
/** [최소, 최대] 초기 스케일 */
initialScale: [number, number];
/** [최소, 최대] 초기 속도 */
initialSpeed: [number, number];
/** 방출 각도 범위 (도) */
emitAngle?: [number, number];
/** 영역 중심 대비 방출 오프셋 */
emitOffset?: Point;
/** 방출 범위 반경 */
emitRadius?: number;
/** 수명 기반 속성 보간 */
overLifetime?: SpriteParticleOverLifetime;
/** 스프라이트 시트 설정 (없으면 정적 이미지) */
spriteSheet?: SpriteSheetConfig;
}
/** /**
* *
*/ */
@ -334,8 +220,6 @@ interface ImageDistortionProps {
className?: string; className?: string;
/** 마우스 인터랙션 설정 */ /** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig; mouseInteraction?: MouseInteractionConfig;
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
spriteEffectAreas?: SpriteEffectArea[];
} }
/** /**
* GPU * GPU
@ -457,10 +341,6 @@ interface EditorCanvasProps {
showEditor?: boolean; showEditor?: boolean;
/** 영역 선택 콜백 (비선택 영역 클릭 시) */ /** 영역 선택 콜백 (비선택 영역 클릭 시) */
onSelectArea?: (areaId: string) => void; onSelectArea?: (areaId: string) => void;
/** 독립 스프라이트 이펙트 영역 */
spriteEffectAreas?: SpriteEffectArea[];
/** 스프라이트 이펙트 영역 업데이트 콜백 */
onUpdateSpriteEffectArea?: (areaId: string, updates: Partial<SpriteEffectArea>) => void;
} }
declare const EditorCanvas: React$1.FC<EditorCanvasProps>; declare const EditorCanvas: React$1.FC<EditorCanvasProps>;
@ -644,10 +524,6 @@ declare class ThreeScene {
* @param fragmentShader * @param fragmentShader
*/ */
setShaderMaterial(vertexShader: string, fragmentShader: string): void; setShaderMaterial(vertexShader: string, fragmentShader: string): void;
/**
* Three.js
*/
getScene(): THREE.Scene;
/** /**
* *
* @param updates * @param updates
@ -766,49 +642,6 @@ declare class SpringPhysics {
returnToEquilibrium(): void; returnToEquilibrium(): void;
} }
/**
* / ( )
*/
interface SpriteEffectTouchState {
/** 마우스/터치 위치 (정규화 좌표, null이면 미접촉) */
position: Point | null;
/** 드래그 중 여부 */
isDragging: boolean;
}
/**
*
* ImageDistortion
* (DistortionArea)
*/
declare class SpriteEffectManager {
/** 모든 이펙트 메쉬를 담는 그룹 */
private effectGroup;
/** 영역ID+이펙트ID → 인스턴스 맵 */
private instances;
/** 이전 프레임에서 터치 중이던 영역 ID 세트 (버스트 감지용) */
private previousTouchingAreas;
constructor();
/**
* Three.js
*/
attachToScene(scene: THREE.Scene): void;
/**
* /
*/
syncEffects(effectAreas: SpriteEffectArea[]): void;
/**
*
* @param effectAreas
* @param deltaTime
* @param touchState /
*/
update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState): void;
/**
*
*/
dispose(): void;
}
/** /**
* requestAnimationFrame을 * requestAnimationFrame을
* @param callback (deltaTime을 ) * @param callback (deltaTime을 )
@ -833,7 +666,6 @@ declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement |
reset: () => void; reset: () => void;
isDragging: () => boolean; isDragging: () => boolean;
getInteractingAreaIndices: () => Set<number>; getInteractingAreaIndices: () => Set<number>;
getMouseState: () => MouseState;
}; };
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, AreaList, type AreaListProps, type AreaOutlineStyle, type BuiltInMotionPreset, type CenterPointStyle, type CircleLevelStyle, DEFAULT_AREA, DEFAULT_EDITOR_CANVAS_STYLE, type DistortionArea, type DistortionMovement, type EasingFunction, type EditMode, EditorCanvas, type EditorCanvasProps, type EditorCanvasStyle, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MotionPresetDefinition, type MouseInteractionConfig, type MouseState, ParameterPanel, type ParameterPanelProps, type Point, type PointHandleStyle, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, type SpriteBlendMode, type SpriteEffectArea, type SpriteEffectAreaData, type SpriteEffectConfig, SpriteEffectManager, type SpriteEffectTrigger, type SpriteParticleOverLifetime, type SpriteSheetConfig, ThreeScene, applyEasing, getRegisteredPresets, hasPreset, isRotationPreset, presetToVector, registerMotionPreset, registerMotionPresets, resetToBuiltInPresets, unregisterMotionPreset, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity }; export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, AreaList, type AreaListProps, type AreaOutlineStyle, type BuiltInMotionPreset, type CenterPointStyle, type CircleLevelStyle, DEFAULT_AREA, DEFAULT_EDITOR_CANVAS_STYLE, type DistortionArea, type DistortionMovement, type EasingFunction, type EditMode, EditorCanvas, type EditorCanvasProps, type EditorCanvasStyle, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MotionPresetDefinition, type MouseInteractionConfig, type MouseState, ParameterPanel, type ParameterPanelProps, type Point, type PointHandleStyle, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, ThreeScene, applyEasing, getRegisteredPresets, hasPreset, isRotationPreset, presetToVector, registerMotionPreset, registerMotionPresets, resetToBuiltInPresets, unregisterMotionPreset, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };

170
dist/index.d.ts vendored
View File

@ -143,120 +143,6 @@ interface AnimationTicker {
resume: () => void; resume: () => void;
} }
/** 이펙트 트리거 타입 */
type SpriteEffectTrigger = 'ambient' | 'touch';
/** 블렌드 모드 */
type SpriteBlendMode = 'normal' | 'additive';
/**
*
*/
interface SpriteParticleOverLifetime {
/** [시작, 끝] 스케일 */
scale?: [number, number];
/** [시작, 끝] 투명도 */
opacity?: [number, number];
/** 회전 속도 (라디안/초) */
rotationSpeed?: number;
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
velocityDamping?: number;
}
/**
*
*/
interface SpriteSheetConfig {
/** 가로 프레임 수 */
columns: number;
/** 세로 프레임 수 */
rows: number;
/** 총 프레임 수 (columns * rows 보다 적을 수 있음) */
totalFrames: number;
/** 재생 속도 (프레임/초) */
fps: number;
/** 반복 재생 여부 (기본: true) */
loop?: boolean;
}
/**
*
* (DistortionArea)
*/
interface SpriteEffectArea {
/** 고유 식별자 */
id: string;
/** 이펙트 중심 좌표 (정규화 0-1) */
position: Point;
/** 터치 감지 반경 (정규화, 기본: 0.1) */
radius?: number;
/** 이 영역에 연결된 이펙트 설정 배열 */
effects: SpriteEffectConfig[];
}
/**
* SpriteEffectArea
* DB
*/
interface SpriteEffectAreaData {
id: string;
position: {
x: number;
y: number;
};
radius?: number;
effects: Array<{
id: string;
trigger: SpriteEffectTrigger;
spriteUrl: string;
blendMode?: SpriteBlendMode;
maxParticles: number;
emitRate?: number;
burstCount?: number;
lifetime: [number, number];
initialScale: [number, number];
initialSpeed: [number, number];
emitAngle?: [number, number];
emitOffset?: {
x: number;
y: number;
};
emitRadius?: number;
overLifetime?: SpriteParticleOverLifetime;
spriteSheet?: SpriteSheetConfig;
}>;
}
/**
*
*/
interface SpriteEffectConfig {
/** 고유 식별자 */
id: string;
/** 트리거 타입 */
trigger: SpriteEffectTrigger;
/** 스프라이트 이미지 URL */
spriteUrl: string;
/** 블렌드 모드 (기본: 'normal') */
blendMode?: SpriteBlendMode;
/** 최대 파티클 수 */
maxParticles: number;
/** ambient: 초당 방출 수 */
emitRate?: number;
/** touch: 터치 시 방출 수 */
burstCount?: number;
/** [최소, 최대] 수명 (초) */
lifetime: [number, number];
/** [최소, 최대] 초기 스케일 */
initialScale: [number, number];
/** [최소, 최대] 초기 속도 */
initialSpeed: [number, number];
/** 방출 각도 범위 (도) */
emitAngle?: [number, number];
/** 영역 중심 대비 방출 오프셋 */
emitOffset?: Point;
/** 방출 범위 반경 */
emitRadius?: number;
/** 수명 기반 속성 보간 */
overLifetime?: SpriteParticleOverLifetime;
/** 스프라이트 시트 설정 (없으면 정적 이미지) */
spriteSheet?: SpriteSheetConfig;
}
/** /**
* *
*/ */
@ -334,8 +220,6 @@ interface ImageDistortionProps {
className?: string; className?: string;
/** 마우스 인터랙션 설정 */ /** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig; mouseInteraction?: MouseInteractionConfig;
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
spriteEffectAreas?: SpriteEffectArea[];
} }
/** /**
* GPU * GPU
@ -457,10 +341,6 @@ interface EditorCanvasProps {
showEditor?: boolean; showEditor?: boolean;
/** 영역 선택 콜백 (비선택 영역 클릭 시) */ /** 영역 선택 콜백 (비선택 영역 클릭 시) */
onSelectArea?: (areaId: string) => void; onSelectArea?: (areaId: string) => void;
/** 독립 스프라이트 이펙트 영역 */
spriteEffectAreas?: SpriteEffectArea[];
/** 스프라이트 이펙트 영역 업데이트 콜백 */
onUpdateSpriteEffectArea?: (areaId: string, updates: Partial<SpriteEffectArea>) => void;
} }
declare const EditorCanvas: React$1.FC<EditorCanvasProps>; declare const EditorCanvas: React$1.FC<EditorCanvasProps>;
@ -644,10 +524,6 @@ declare class ThreeScene {
* @param fragmentShader * @param fragmentShader
*/ */
setShaderMaterial(vertexShader: string, fragmentShader: string): void; setShaderMaterial(vertexShader: string, fragmentShader: string): void;
/**
* Three.js
*/
getScene(): THREE.Scene;
/** /**
* *
* @param updates * @param updates
@ -766,49 +642,6 @@ declare class SpringPhysics {
returnToEquilibrium(): void; returnToEquilibrium(): void;
} }
/**
* / ( )
*/
interface SpriteEffectTouchState {
/** 마우스/터치 위치 (정규화 좌표, null이면 미접촉) */
position: Point | null;
/** 드래그 중 여부 */
isDragging: boolean;
}
/**
*
* ImageDistortion
* (DistortionArea)
*/
declare class SpriteEffectManager {
/** 모든 이펙트 메쉬를 담는 그룹 */
private effectGroup;
/** 영역ID+이펙트ID → 인스턴스 맵 */
private instances;
/** 이전 프레임에서 터치 중이던 영역 ID 세트 (버스트 감지용) */
private previousTouchingAreas;
constructor();
/**
* Three.js
*/
attachToScene(scene: THREE.Scene): void;
/**
* /
*/
syncEffects(effectAreas: SpriteEffectArea[]): void;
/**
*
* @param effectAreas
* @param deltaTime
* @param touchState /
*/
update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState): void;
/**
*
*/
dispose(): void;
}
/** /**
* requestAnimationFrame을 * requestAnimationFrame을
* @param callback (deltaTime을 ) * @param callback (deltaTime을 )
@ -833,7 +666,6 @@ declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement |
reset: () => void; reset: () => void;
isDragging: () => boolean; isDragging: () => boolean;
getInteractingAreaIndices: () => Set<number>; getInteractingAreaIndices: () => Set<number>;
getMouseState: () => MouseState;
}; };
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, AreaList, type AreaListProps, type AreaOutlineStyle, type BuiltInMotionPreset, type CenterPointStyle, type CircleLevelStyle, DEFAULT_AREA, DEFAULT_EDITOR_CANVAS_STYLE, type DistortionArea, type DistortionMovement, type EasingFunction, type EditMode, EditorCanvas, type EditorCanvasProps, type EditorCanvasStyle, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MotionPresetDefinition, type MouseInteractionConfig, type MouseState, ParameterPanel, type ParameterPanelProps, type Point, type PointHandleStyle, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, type SpriteBlendMode, type SpriteEffectArea, type SpriteEffectAreaData, type SpriteEffectConfig, SpriteEffectManager, type SpriteEffectTrigger, type SpriteParticleOverLifetime, type SpriteSheetConfig, ThreeScene, applyEasing, getRegisteredPresets, hasPreset, isRotationPreset, presetToVector, registerMotionPreset, registerMotionPresets, resetToBuiltInPresets, unregisterMotionPreset, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity }; export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, AreaList, type AreaListProps, type AreaOutlineStyle, type BuiltInMotionPreset, type CenterPointStyle, type CircleLevelStyle, DEFAULT_AREA, DEFAULT_EDITOR_CANVAS_STYLE, type DistortionArea, type DistortionMovement, type EasingFunction, type EditMode, EditorCanvas, type EditorCanvasProps, type EditorCanvasStyle, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MotionPresetDefinition, type MouseInteractionConfig, type MouseState, ParameterPanel, type ParameterPanelProps, type Point, type PointHandleStyle, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, ThreeScene, applyEasing, getRegisteredPresets, hasPreset, isRotationPreset, presetToVector, registerMotionPreset, registerMotionPresets, resetToBuiltInPresets, unregisterMotionPreset, useAnimationFrame, useDistortionEditor, useMouseInteraction, useMouseVelocity };

584
dist/index.js vendored
View File

@ -41,7 +41,6 @@ __export(index_exports, {
SHADER_CONFIG: () => SHADER_CONFIG, SHADER_CONFIG: () => SHADER_CONFIG,
ShaderManager: () => ShaderManager, ShaderManager: () => ShaderManager,
SpringPhysics: () => SpringPhysics, SpringPhysics: () => SpringPhysics,
SpriteEffectManager: () => SpriteEffectManager,
ThreeScene: () => ThreeScene, ThreeScene: () => ThreeScene,
applyEasing: () => applyEasing, applyEasing: () => applyEasing,
getRegisteredPresets: () => getRegisteredPresets, getRegisteredPresets: () => getRegisteredPresets,
@ -61,7 +60,7 @@ module.exports = __toCommonJS(index_exports);
// src/components/ImageDistortion.tsx // src/components/ImageDistortion.tsx
var import_react4 = require("react"); var import_react4 = require("react");
var THREE4 = __toESM(require("three")); var THREE2 = __toESM(require("three"));
// src/engine/ThreeScene.ts // src/engine/ThreeScene.ts
var THREE = __toESM(require("three")); var THREE = __toESM(require("three"));
@ -133,16 +132,9 @@ var ThreeScene = class {
this.scene.remove(this.mesh); this.scene.remove(this.mesh);
} }
this.mesh = new THREE.Mesh(geometry, material); this.mesh = new THREE.Mesh(geometry, material);
this.mesh.renderOrder = 0;
this.scene.add(this.mesh); this.scene.add(this.mesh);
console.log("[ThreeScene] mesh\uB97C \uC52C\uC5D0 \uCD94\uAC00\uD568"); console.log("[ThreeScene] mesh\uB97C \uC52C\uC5D0 \uCD94\uAC00\uD568");
} }
/**
* Three.js 객체 반환
*/
getScene() {
return this.scene;
}
/** /**
* 유니폼 업데이트 * 유니폼 업데이트
* @param updates 업데이트할 유니폼 값들 * @param updates 업데이트할 유니폼 값들
@ -415,401 +407,6 @@ var AnimationLoop = class {
} }
}; };
// src/engine/SpriteEffectManager.ts
var THREE3 = __toESM(require("three"));
// src/engine/SpriteEffectInstance.ts
var THREE2 = __toESM(require("three"));
// src/engine/SpriteParticlePool.ts
var SpriteParticlePool = class {
constructor(maxParticles) {
this.particles = Array.from({ length: maxParticles }, (_, i) => this.createParticle(i));
}
/** 비활성 파티클 생성 */
createParticle(index) {
return {
index,
active: false,
position: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
scale: 1,
rotation: 0,
opacity: 1,
age: 0,
lifetime: 1,
frameTime: 0,
frameIndex: 0
};
}
/**
* 비활성 파티클을 활성화하여 반환
* 사용 가능한 파티클이 없으면 null 반환
*/
acquire() {
for (const particle of this.particles) {
if (!particle.active) {
particle.active = true;
particle.age = 0;
return particle;
}
}
return null;
}
/**
* 파티클을 비활성화하여 풀로 반환
*/
release(particle) {
particle.active = false;
}
/**
* 활성 파티클 목록 반환
*/
getActiveParticles() {
return this.particles.filter((p) => p.active);
}
/**
* 활성 파티클
*/
getActiveCount() {
let count = 0;
for (const p of this.particles) {
if (p.active) count++;
}
return count;
}
};
// src/engine/SpriteEffectInstance.ts
var randomRange = (min, max) => min + Math.random() * (max - min);
var lerp = (a, b, t) => a + (b - a) * t;
var SpriteEffectInstance = class {
constructor(config) {
this.texture = null;
this.ready = false;
this.emitAccumulator = 0;
/**
* 프레임 업데이트
* @param deltaTime 단위 프레임 시간
* @param emitCenter 방출 중심 (정규화 좌표 0-1)
*/
this._logCounter = 0;
this.config = config;
this.pool = new SpriteParticlePool(config.maxParticles);
this.group = new THREE2.Group();
this.geometry = new THREE2.PlaneGeometry(1, 1);
const blending = config.blendMode === "additive" ? THREE2.AdditiveBlending : THREE2.NormalBlending;
this.material = new THREE2.MeshBasicMaterial({
transparent: true,
depthTest: false,
depthWrite: false,
blending,
opacity: 0
});
this.meshes = Array.from({ length: config.maxParticles }, () => {
const mesh = new THREE2.Mesh(this.geometry, this.material.clone());
mesh.visible = false;
mesh.renderOrder = 1;
this.group.add(mesh);
return mesh;
});
this.loadTexture(config.spriteUrl);
}
/** 텍스처 로드 */
loadTexture(url) {
const loader = new THREE2.TextureLoader();
loader.load(
url,
(texture) => {
this.texture = texture;
const sheet = this.config.spriteSheet;
if (sheet) {
texture.repeat.set(1 / sheet.columns, 1 / sheet.rows);
texture.offset.set(0, 1 - 1 / sheet.rows);
}
for (const mesh of this.meshes) {
const mat = mesh.material;
if (sheet) {
const cloned = texture.clone();
cloned.repeat.copy(texture.repeat);
cloned.offset.copy(texture.offset);
mat.map = cloned;
} else {
mat.map = texture;
}
mat.needsUpdate = true;
}
this.ready = true;
console.log(`[SpriteEffectInstance] \uD14D\uC2A4\uCC98 \uB85C\uB4DC \uC131\uACF5: ${url}`, texture.image.width, "x", texture.image.height);
},
void 0,
(error) => {
console.error(`[SpriteEffectInstance] \uD14D\uC2A4\uCC98 \uB85C\uB4DC \uC2E4\uD328: ${url}`, error);
}
);
}
/**
* 파티클 1 방출
* @param center 방출 중심 (정규화 좌표 0-1)
*/
emitOne(center) {
const particle = this.pool.acquire();
if (!particle) return;
const { config } = this;
let px = center.x + (config.emitOffset?.x ?? 0);
let py = center.y + (config.emitOffset?.y ?? 0);
if (config.emitRadius && config.emitRadius > 0) {
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * config.emitRadius;
px += Math.cos(angle) * radius;
py += Math.sin(angle) * radius;
}
particle.position.x = px;
particle.position.y = py;
const angleRange = config.emitAngle ?? [0, 360];
const angleDeg = randomRange(angleRange[0], angleRange[1]);
const angleRad = angleDeg * Math.PI / 180;
const speed = randomRange(config.initialSpeed[0], config.initialSpeed[1]);
particle.velocity.x = Math.cos(angleRad) * speed;
particle.velocity.y = Math.sin(angleRad) * speed;
particle.scale = randomRange(config.initialScale[0], config.initialScale[1]);
particle.rotation = 0;
particle.opacity = 1;
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
particle.age = 0;
particle.frameTime = 0;
particle.frameIndex = 0;
}
/**
* ambient 모드: 프레임 누적기 기반 방출
*/
updateAmbientEmit(deltaTime, center) {
if (!this.config.emitRate || this.config.emitRate <= 0) return;
this.emitAccumulator += deltaTime;
const interval = 1 / this.config.emitRate;
while (this.emitAccumulator >= interval) {
this.emitAccumulator -= interval;
this.emitOne(center);
}
}
/**
* touch 모드: 버스트 방출
*/
triggerBurst(center) {
if (!this.ready) return;
const count = this.config.burstCount ?? 1;
for (let i = 0; i < count; i++) {
this.emitOne(center);
}
}
update(deltaTime, emitCenter) {
if (!this.ready) return;
if (this.config.trigger === "ambient") {
this.updateAmbientEmit(deltaTime, emitCenter);
}
const overLifetime = this.config.overLifetime;
const activeParticles = this.pool.getActiveParticles();
if (this._logCounter++ % 60 === 0 && activeParticles.length > 0) {
const p = activeParticles[0];
console.log(`[SpriteEffectInstance] \uD65C\uC131 \uD30C\uD2F0\uD074: ${activeParticles.length}\uAC1C, \uCCAB \uD30C\uD2F0\uD074 pos=(${p.position.x.toFixed(3)}, ${p.position.y.toFixed(3)}), scale=${p.scale.toFixed(3)}, opacity=${p.opacity.toFixed(3)}`);
}
for (const particle of activeParticles) {
particle.age += deltaTime;
if (particle.age >= particle.lifetime) {
this.pool.release(particle);
this.syncMesh(particle);
continue;
}
const lifeRatio = particle.age / particle.lifetime;
if (overLifetime) {
if (overLifetime.scale) {
particle.scale = lerp(overLifetime.scale[0], overLifetime.scale[1], lifeRatio);
}
if (overLifetime.opacity) {
particle.opacity = lerp(overLifetime.opacity[0], overLifetime.opacity[1], lifeRatio);
}
if (overLifetime.rotationSpeed) {
particle.rotation += overLifetime.rotationSpeed * deltaTime;
}
if (overLifetime.velocityDamping !== void 0) {
const damping = Math.pow(overLifetime.velocityDamping, deltaTime);
particle.velocity.x *= damping;
particle.velocity.y *= damping;
}
}
if (this.config.spriteSheet) {
this.updateSpriteFrame(particle, deltaTime, this.config.spriteSheet);
}
particle.position.x += particle.velocity.x * deltaTime;
particle.position.y += particle.velocity.y * deltaTime;
this.syncMesh(particle);
}
}
/**
* 스프라이트 시트 프레임 진행
*/
updateSpriteFrame(particle, deltaTime, sheet) {
particle.frameTime += deltaTime;
const frameDuration = 1 / sheet.fps;
if (particle.frameTime >= frameDuration) {
particle.frameTime -= frameDuration;
const nextFrame = particle.frameIndex + 1;
if (nextFrame >= sheet.totalFrames) {
if (sheet.loop !== false) {
particle.frameIndex = 0;
}
} else {
particle.frameIndex = nextFrame;
}
}
}
/**
* 파티클 상태를 Three.js 메쉬에 동기화
* 정규화 좌표(0-1) NDC(-1~1) 변환, y축 반전
*/
syncMesh(particle) {
const mesh = this.meshes[particle.index];
if (!mesh) return;
if (!particle.active) {
mesh.visible = false;
return;
}
mesh.visible = true;
mesh.position.x = particle.position.x * 2 - 1;
mesh.position.y = -(particle.position.y * 2 - 1);
mesh.position.z = -0.01;
mesh.scale.set(particle.scale, particle.scale, 1);
mesh.rotation.z = particle.rotation;
const mat = mesh.material;
mat.opacity = particle.opacity;
const sheet = this.config.spriteSheet;
if (sheet && mat.map) {
const col = particle.frameIndex % sheet.columns;
const row = Math.floor(particle.frameIndex / sheet.columns);
mat.map.offset.set(
col / sheet.columns,
1 - (row + 1) / sheet.rows
);
}
}
/**
* 텍스처 로딩 완료 여부
*/
isReady() {
return this.ready;
}
/**
* 리소스 정리
*/
dispose() {
if (this.texture) {
this.texture.dispose();
this.texture = null;
}
this.geometry.dispose();
for (const mesh of this.meshes) {
mesh.material.dispose();
}
this.material.dispose();
while (this.group.children.length > 0) {
this.group.remove(this.group.children[0]);
}
}
};
// src/engine/SpriteEffectManager.ts
var isPointInCircle = (point, center, radius) => {
const dx = point.x - center.x;
const dy = point.y - center.y;
return dx * dx + dy * dy <= radius * radius;
};
var SpriteEffectManager = class {
constructor() {
/** 영역ID+이펙트ID → 인스턴스 맵 */
this.instances = /* @__PURE__ */ new Map();
/** 이전 프레임에서 터치 중이던 영역 ID 세트 (버스트 감지용) */
this.previousTouchingAreas = /* @__PURE__ */ new Set();
this.effectGroup = new THREE3.Group();
this.effectGroup.renderOrder = 1;
}
/**
* Three.js 씬에 이펙트 그룹 추가
*/
attachToScene(scene) {
scene.add(this.effectGroup);
}
/**
* 이펙트 영역 설정 변경을 감지하여 인스턴스 생성/제거
*/
syncEffects(effectAreas) {
console.log("[SpriteEffectManager] syncEffects \uD638\uCD9C:", effectAreas.length, "\uAC1C \uC601\uC5ED");
const activeKeys = /* @__PURE__ */ new Set();
for (const area of effectAreas) {
for (const effectConfig of area.effects) {
const key = `${area.id}::${effectConfig.id}`;
activeKeys.add(key);
if (this.instances.has(key)) continue;
console.log("[SpriteEffectManager] \uC778\uC2A4\uD134\uC2A4 \uC0DD\uC131:", key, effectConfig.spriteUrl);
const instance = new SpriteEffectInstance(effectConfig);
this.instances.set(key, instance);
this.effectGroup.add(instance.group);
}
}
for (const [key, instance] of this.instances) {
if (!activeKeys.has(key)) {
instance.dispose();
this.effectGroup.remove(instance.group);
this.instances.delete(key);
}
}
}
/**
* 프레임 업데이트
* @param effectAreas 이펙트 영역 배열
* @param deltaTime 단위 프레임 시간
* @param touchState 마우스/터치 상태
*/
update(effectAreas, deltaTime, touchState) {
const currentTouchingAreas = /* @__PURE__ */ new Set();
if (touchState.isDragging && touchState.position) {
for (const area of effectAreas) {
const radius = area.radius ?? 0.1;
if (isPointInCircle(touchState.position, area.position, radius)) {
currentTouchingAreas.add(area.id);
}
}
}
for (const area of effectAreas) {
for (const effectConfig of area.effects) {
const key = `${area.id}::${effectConfig.id}`;
const instance = this.instances.get(key);
if (!instance) continue;
if (effectConfig.trigger === "touch") {
const isNewTouch = currentTouchingAreas.has(area.id) && !this.previousTouchingAreas.has(area.id);
if (isNewTouch) {
instance.triggerBurst(touchState.position ?? area.position);
}
}
instance.update(deltaTime, area.position);
}
}
this.previousTouchingAreas = currentTouchingAreas;
}
/**
* 리소스 정리
*/
dispose() {
for (const [, instance] of this.instances) {
instance.dispose();
}
this.instances.clear();
this.previousTouchingAreas.clear();
if (this.effectGroup.parent) {
this.effectGroup.parent.remove(this.effectGroup);
}
}
};
// src/hooks/useAnimationFrame.ts // src/hooks/useAnimationFrame.ts
var import_react = require("react"); var import_react = require("react");
var useAnimationFrame = (callback, isPlaying = true) => { var useAnimationFrame = (callback, isPlaying = true) => {
@ -1219,8 +816,7 @@ var useMouseInteraction = (containerRef, config) => {
updateConfig, updateConfig,
reset, reset,
isDragging, isDragging,
getInteractingAreaIndices, getInteractingAreaIndices
getMouseState: getState
}; };
}; };
@ -1269,15 +865,12 @@ var ImageDistortion = ({
fragmentShaderPath, fragmentShaderPath,
style, style,
className, className,
mouseInteraction, mouseInteraction
spriteEffectAreas = []
}) => { }) => {
const containerRef = (0, import_react4.useRef)(null); const containerRef = (0, import_react4.useRef)(null);
const sceneRef = (0, import_react4.useRef)(null); const sceneRef = (0, import_react4.useRef)(null);
const shaderManagerRef = (0, import_react4.useRef)(new ShaderManager()); const shaderManagerRef = (0, import_react4.useRef)(new ShaderManager());
const textureRef = (0, import_react4.useRef)(null); const textureRef = (0, import_react4.useRef)(null);
const spriteManagerRef = (0, import_react4.useRef)(null);
const currentAreasRef = (0, import_react4.useRef)(areas);
const [isReady, setIsReady] = (0, import_react4.useState)(false); const [isReady, setIsReady] = (0, import_react4.useState)(false);
const [imageLoaded, setImageLoaded] = (0, import_react4.useState)(false); const [imageLoaded, setImageLoaded] = (0, import_react4.useState)(false);
const [currentAreas, setCurrentAreas] = (0, import_react4.useState)(areas); const [currentAreas, setCurrentAreas] = (0, import_react4.useState)(areas);
@ -1297,22 +890,6 @@ var ImageDistortion = ({
(0, import_react4.useEffect)(() => { (0, import_react4.useEffect)(() => {
setCurrentAreas(areas); setCurrentAreas(areas);
}, [areas]); }, [areas]);
(0, import_react4.useEffect)(() => {
currentAreasRef.current = currentAreas;
}, [currentAreas]);
(0, import_react4.useEffect)(() => {
if (!sceneRef.current || !isReady) return;
const manager = new SpriteEffectManager();
manager.attachToScene(sceneRef.current.getScene());
spriteManagerRef.current = manager;
return () => {
manager.dispose();
spriteManagerRef.current = null;
};
}, [isReady]);
(0, import_react4.useEffect)(() => {
spriteManagerRef.current?.syncEffects(spriteEffectAreas);
}, [spriteEffectAreas, isReady]);
(0, import_react4.useEffect)(() => { (0, import_react4.useEffect)(() => {
if (mouseInteraction) { if (mouseInteraction) {
mouseInteractionHook.updateConfig(mouseInteraction); mouseInteractionHook.updateConfig(mouseInteraction);
@ -1351,7 +928,7 @@ var ImageDistortion = ({
} }
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc); console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc);
setImageLoaded(false); setImageLoaded(false);
const loader = new THREE4.TextureLoader(); const loader = new THREE2.TextureLoader();
loader.load( loader.load(
imageSrc, imageSrc,
(texture) => { (texture) => {
@ -1443,19 +1020,7 @@ var ImageDistortion = ({
} }
return updatedAreas; return updatedAreas;
}); });
if (spriteManagerRef.current) { }, [isReady, mouseInteraction, mouseInteractionHook]);
const mouseState = mouseInteractionHook.getMouseState();
spriteManagerRef.current.update(
spriteEffectAreas,
deltaTime,
{
position: mouseState.position ?? null,
isDragging: mouseState.isDragging
}
);
sceneRef.current?.render();
}
}, [isReady, mouseInteraction, mouseInteractionHook, spriteEffectAreas]);
useAnimationFrame(animationCallback, true); useAnimationFrame(animationCallback, true);
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)( return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
"div", "div",
@ -1821,15 +1386,12 @@ var EditorCanvas = ({
onStopDragging, onStopDragging,
style: customStyle, style: customStyle,
showEditor = true, showEditor = true,
onSelectArea, onSelectArea
spriteEffectAreas = [],
onUpdateSpriteEffectArea
}) => { }) => {
const containerRef = (0, import_react6.useRef)(null); const containerRef = (0, import_react6.useRef)(null);
const [canvasSize, setCanvasSize] = (0, import_react6.useState)({ width: 0, height: 0 }); const [canvasSize, setCanvasSize] = (0, import_react6.useState)({ width: 0, height: 0 });
const [isDraggingArea, setIsDraggingArea] = (0, import_react6.useState)(false); const [isDraggingArea, setIsDraggingArea] = (0, import_react6.useState)(false);
const [dragStartPos, setDragStartPos] = (0, import_react6.useState)(null); const [dragStartPos, setDragStartPos] = (0, import_react6.useState)(null);
const [draggingSpriteAreaId, setDraggingSpriteAreaId] = (0, import_react6.useState)(null);
const editorStyle = (0, import_react6.useMemo)(() => ({ const editorStyle = (0, import_react6.useMemo)(() => ({
...DEFAULT_EDITOR_CANVAS_STYLE, ...DEFAULT_EDITOR_CANVAS_STYLE,
...customStyle, ...customStyle,
@ -1896,20 +1458,6 @@ var EditorCanvas = ({
const x = (clientX - rect.left) / rect.width; const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height; const y = (clientY - rect.top) / rect.height;
const clickPoint = { x, y }; const clickPoint = { x, y };
if (onUpdateSpriteEffectArea) {
for (let i = spriteEffectAreas.length - 1; i >= 0; i--) {
const sa = spriteEffectAreas[i];
const dx = clickPoint.x - sa.position.x;
const dy = clickPoint.y - sa.position.y;
const radius = sa.radius ?? 0.1;
if (dx * dx + dy * dy <= radius * radius) {
setDraggingSpriteAreaId(sa.id);
setDragStartPos(clickPoint);
e.preventDefault();
return;
}
}
}
if (selectedArea && isPointInPolygon2(clickPoint, selectedArea.basePoints)) { if (selectedArea && isPointInPolygon2(clickPoint, selectedArea.basePoints)) {
setIsDraggingArea(true); setIsDraggingArea(true);
setDragStartPos(clickPoint); setDragStartPos(clickPoint);
@ -1927,12 +1475,12 @@ var EditorCanvas = ({
} }
} }
}, },
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea, spriteEffectAreas, onUpdateSpriteEffectArea] [showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea]
); );
const handleMove = (0, import_react6.useCallback)( const handleMove = (0, import_react6.useCallback)(
(e) => { (e) => {
if (!showEditor || !containerRef.current) return; if (!showEditor || !selectedArea || !containerRef.current) return;
if ("touches" in e && (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId)) { if ("touches" in e && (draggingPointIndex !== null || isDraggingArea)) {
e.preventDefault(); e.preventDefault();
} }
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
@ -1947,22 +1495,6 @@ var EditorCanvas = ({
} }
const x = (clientX - rect.left) / rect.width; const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height; const y = (clientY - rect.top) / rect.height;
if (draggingSpriteAreaId && dragStartPos && onUpdateSpriteEffectArea) {
const sa = spriteEffectAreas.find((a) => a.id === draggingSpriteAreaId);
if (sa) {
const deltaX = x - dragStartPos.x;
const deltaY = y - dragStartPos.y;
onUpdateSpriteEffectArea(sa.id, {
position: {
x: Math.max(0, Math.min(1, sa.position.x + deltaX)),
y: Math.max(0, Math.min(1, sa.position.y + deltaY))
}
});
setDragStartPos({ x, y });
}
return;
}
if (!selectedArea) return;
if (draggingPointIndex !== null) { if (draggingPointIndex !== null) {
const clampedX = Math.max(0, Math.min(1, x)); const clampedX = Math.max(0, Math.min(1, x));
const clampedY = Math.max(0, Math.min(1, y)); const clampedY = Math.max(0, Math.min(1, y));
@ -1978,7 +1510,7 @@ var EditorCanvas = ({
setDragStartPos({ x, y }); setDragStartPos({ x, y });
} }
}, },
[showEditor, draggingPointIndex, isDraggingArea, draggingSpriteAreaId, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea, spriteEffectAreas, onUpdateSpriteEffectArea] [showEditor, draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea]
); );
const handleUp = (0, import_react6.useCallback)(() => { const handleUp = (0, import_react6.useCallback)(() => {
if (draggingPointIndex !== null) { if (draggingPointIndex !== null) {
@ -1988,13 +1520,9 @@ var EditorCanvas = ({
setIsDraggingArea(false); setIsDraggingArea(false);
setDragStartPos(null); setDragStartPos(null);
} }
if (draggingSpriteAreaId) { }, [draggingPointIndex, isDraggingArea, onStopDragging]);
setDraggingSpriteAreaId(null);
setDragStartPos(null);
}
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, onStopDragging]);
(0, import_react6.useEffect)(() => { (0, import_react6.useEffect)(() => {
if (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId) { if (draggingPointIndex !== null || isDraggingArea) {
window.addEventListener("mouseup", handleUp); window.addEventListener("mouseup", handleUp);
window.addEventListener("touchend", handleUp); window.addEventListener("touchend", handleUp);
window.addEventListener("touchcancel", handleUp); window.addEventListener("touchcancel", handleUp);
@ -2004,7 +1532,7 @@ var EditorCanvas = ({
window.removeEventListener("touchcancel", handleUp); window.removeEventListener("touchcancel", handleUp);
}; };
} }
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, handleUp]); }, [draggingPointIndex, isDraggingArea, handleUp]);
const uvToPixel = (u, v, points, canvasWidth, canvasHeight) => { const uvToPixel = (u, v, points, canvasWidth, canvasHeight) => {
const [p0, p1, p2, p3] = points; const [p0, p1, p2, p3] = points;
const leftX = p0.x * (1 - u) + p1.x * u; const leftX = p0.x * (1 - u) + p1.x * u;
@ -2072,7 +1600,6 @@ var EditorCanvas = ({
const getCursorStyle = () => { const getCursorStyle = () => {
if (draggingPointIndex !== null) return "grabbing"; if (draggingPointIndex !== null) return "grabbing";
if (isDraggingArea) return "grabbing"; if (isDraggingArea) return "grabbing";
if (draggingSpriteAreaId) return "grabbing";
return "default"; return "default";
}; };
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)( return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
@ -2094,7 +1621,7 @@ var EditorCanvas = ({
onTouchStart: showEditor ? handleCanvasDown : void 0, onTouchStart: showEditor ? handleCanvasDown : void 0,
onTouchMove: showEditor ? handleMove : void 0, onTouchMove: showEditor ? handleMove : void 0,
children: [ children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ImageDistortion, { imageSrc, areas, spriteEffectAreas }), /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ImageDistortion, { imageSrc, areas }),
showEditor && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( showEditor && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"svg", "svg",
{ {
@ -2193,88 +1720,6 @@ var EditorCanvas = ({
}, },
index index
); );
}),
showEditor && spriteEffectAreas.map((sa) => {
const cx = sa.position.x * 100;
const cy = sa.position.y * 100;
const isDragging = draggingSpriteAreaId === sa.id;
const radiusPx = (sa.radius ?? 0.1) * canvasSize.width;
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
"div",
{
style: {
position: "absolute",
left: `${cx}%`,
top: `${cy}%`,
transform: "translate(-50%, -50%)",
pointerEvents: "none"
},
children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"svg",
{
width: radiusPx * 2,
height: radiusPx * 2,
style: {
position: "absolute",
left: -radiusPx,
top: -radiusPx,
pointerEvents: "none"
},
children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"circle",
{
cx: radiusPx,
cy: radiusPx,
r: radiusPx,
fill: isDragging ? "rgba(255, 170, 0, 0.15)" : "rgba(255, 170, 0, 0.08)",
stroke: isDragging ? "#ffaa00" : "rgba(255, 170, 0, 0.6)",
strokeWidth: isDragging ? 2 : 1.5,
strokeDasharray: isDragging ? "0" : "4,3"
}
)
}
),
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"div",
{
style: {
position: "absolute",
left: -8,
top: -8,
width: 16,
height: 16,
borderRadius: "50%",
backgroundColor: isDragging ? "#ffaa00" : "rgba(255, 170, 0, 0.8)",
border: "2px solid white",
cursor: isDragging ? "grabbing" : "grab",
pointerEvents: "auto",
boxShadow: "0 2px 4px rgba(0,0,0,0.3)"
}
}
),
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"div",
{
style: {
position: "absolute",
top: -24,
left: "50%",
transform: "translateX(-50%)",
fontSize: 10,
color: "#ffaa00",
fontWeight: "bold",
textShadow: "1px 1px 2px rgba(0,0,0,0.8)",
whiteSpace: "nowrap",
pointerEvents: "none"
},
children: "\u2728"
}
)
]
},
`sprite-${sa.id}`
);
}) })
] ]
} }
@ -2293,7 +1738,6 @@ var EditorCanvas = ({
SHADER_CONFIG, SHADER_CONFIG,
ShaderManager, ShaderManager,
SpringPhysics, SpringPhysics,
SpriteEffectManager,
ThreeScene, ThreeScene,
applyEasing, applyEasing,
getRegisteredPresets, getRegisteredPresets,

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

583
dist/index.mjs vendored
View File

@ -1,6 +1,6 @@
// src/components/ImageDistortion.tsx // src/components/ImageDistortion.tsx
import { useEffect as useEffect3, useRef as useRef4, useState as useState2, useCallback as useCallback3 } from "react"; import { useEffect as useEffect3, useRef as useRef4, useState as useState2, useCallback as useCallback3 } from "react";
import * as THREE4 from "three"; import * as THREE2 from "three";
// src/engine/ThreeScene.ts // src/engine/ThreeScene.ts
import * as THREE from "three"; import * as THREE from "three";
@ -72,16 +72,9 @@ var ThreeScene = class {
this.scene.remove(this.mesh); this.scene.remove(this.mesh);
} }
this.mesh = new THREE.Mesh(geometry, material); this.mesh = new THREE.Mesh(geometry, material);
this.mesh.renderOrder = 0;
this.scene.add(this.mesh); this.scene.add(this.mesh);
console.log("[ThreeScene] mesh\uB97C \uC52C\uC5D0 \uCD94\uAC00\uD568"); console.log("[ThreeScene] mesh\uB97C \uC52C\uC5D0 \uCD94\uAC00\uD568");
} }
/**
* Three.js 객체 반환
*/
getScene() {
return this.scene;
}
/** /**
* 유니폼 업데이트 * 유니폼 업데이트
* @param updates 업데이트할 유니폼 값들 * @param updates 업데이트할 유니폼 값들
@ -354,401 +347,6 @@ var AnimationLoop = class {
} }
}; };
// src/engine/SpriteEffectManager.ts
import * as THREE3 from "three";
// src/engine/SpriteEffectInstance.ts
import * as THREE2 from "three";
// src/engine/SpriteParticlePool.ts
var SpriteParticlePool = class {
constructor(maxParticles) {
this.particles = Array.from({ length: maxParticles }, (_, i) => this.createParticle(i));
}
/** 비활성 파티클 생성 */
createParticle(index) {
return {
index,
active: false,
position: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
scale: 1,
rotation: 0,
opacity: 1,
age: 0,
lifetime: 1,
frameTime: 0,
frameIndex: 0
};
}
/**
* 비활성 파티클을 활성화하여 반환
* 사용 가능한 파티클이 없으면 null 반환
*/
acquire() {
for (const particle of this.particles) {
if (!particle.active) {
particle.active = true;
particle.age = 0;
return particle;
}
}
return null;
}
/**
* 파티클을 비활성화하여 풀로 반환
*/
release(particle) {
particle.active = false;
}
/**
* 활성 파티클 목록 반환
*/
getActiveParticles() {
return this.particles.filter((p) => p.active);
}
/**
* 활성 파티클
*/
getActiveCount() {
let count = 0;
for (const p of this.particles) {
if (p.active) count++;
}
return count;
}
};
// src/engine/SpriteEffectInstance.ts
var randomRange = (min, max) => min + Math.random() * (max - min);
var lerp = (a, b, t) => a + (b - a) * t;
var SpriteEffectInstance = class {
constructor(config) {
this.texture = null;
this.ready = false;
this.emitAccumulator = 0;
/**
* 프레임 업데이트
* @param deltaTime 단위 프레임 시간
* @param emitCenter 방출 중심 (정규화 좌표 0-1)
*/
this._logCounter = 0;
this.config = config;
this.pool = new SpriteParticlePool(config.maxParticles);
this.group = new THREE2.Group();
this.geometry = new THREE2.PlaneGeometry(1, 1);
const blending = config.blendMode === "additive" ? THREE2.AdditiveBlending : THREE2.NormalBlending;
this.material = new THREE2.MeshBasicMaterial({
transparent: true,
depthTest: false,
depthWrite: false,
blending,
opacity: 0
});
this.meshes = Array.from({ length: config.maxParticles }, () => {
const mesh = new THREE2.Mesh(this.geometry, this.material.clone());
mesh.visible = false;
mesh.renderOrder = 1;
this.group.add(mesh);
return mesh;
});
this.loadTexture(config.spriteUrl);
}
/** 텍스처 로드 */
loadTexture(url) {
const loader = new THREE2.TextureLoader();
loader.load(
url,
(texture) => {
this.texture = texture;
const sheet = this.config.spriteSheet;
if (sheet) {
texture.repeat.set(1 / sheet.columns, 1 / sheet.rows);
texture.offset.set(0, 1 - 1 / sheet.rows);
}
for (const mesh of this.meshes) {
const mat = mesh.material;
if (sheet) {
const cloned = texture.clone();
cloned.repeat.copy(texture.repeat);
cloned.offset.copy(texture.offset);
mat.map = cloned;
} else {
mat.map = texture;
}
mat.needsUpdate = true;
}
this.ready = true;
console.log(`[SpriteEffectInstance] \uD14D\uC2A4\uCC98 \uB85C\uB4DC \uC131\uACF5: ${url}`, texture.image.width, "x", texture.image.height);
},
void 0,
(error) => {
console.error(`[SpriteEffectInstance] \uD14D\uC2A4\uCC98 \uB85C\uB4DC \uC2E4\uD328: ${url}`, error);
}
);
}
/**
* 파티클 1 방출
* @param center 방출 중심 (정규화 좌표 0-1)
*/
emitOne(center) {
const particle = this.pool.acquire();
if (!particle) return;
const { config } = this;
let px = center.x + (config.emitOffset?.x ?? 0);
let py = center.y + (config.emitOffset?.y ?? 0);
if (config.emitRadius && config.emitRadius > 0) {
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * config.emitRadius;
px += Math.cos(angle) * radius;
py += Math.sin(angle) * radius;
}
particle.position.x = px;
particle.position.y = py;
const angleRange = config.emitAngle ?? [0, 360];
const angleDeg = randomRange(angleRange[0], angleRange[1]);
const angleRad = angleDeg * Math.PI / 180;
const speed = randomRange(config.initialSpeed[0], config.initialSpeed[1]);
particle.velocity.x = Math.cos(angleRad) * speed;
particle.velocity.y = Math.sin(angleRad) * speed;
particle.scale = randomRange(config.initialScale[0], config.initialScale[1]);
particle.rotation = 0;
particle.opacity = 1;
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
particle.age = 0;
particle.frameTime = 0;
particle.frameIndex = 0;
}
/**
* ambient 모드: 프레임 누적기 기반 방출
*/
updateAmbientEmit(deltaTime, center) {
if (!this.config.emitRate || this.config.emitRate <= 0) return;
this.emitAccumulator += deltaTime;
const interval = 1 / this.config.emitRate;
while (this.emitAccumulator >= interval) {
this.emitAccumulator -= interval;
this.emitOne(center);
}
}
/**
* touch 모드: 버스트 방출
*/
triggerBurst(center) {
if (!this.ready) return;
const count = this.config.burstCount ?? 1;
for (let i = 0; i < count; i++) {
this.emitOne(center);
}
}
update(deltaTime, emitCenter) {
if (!this.ready) return;
if (this.config.trigger === "ambient") {
this.updateAmbientEmit(deltaTime, emitCenter);
}
const overLifetime = this.config.overLifetime;
const activeParticles = this.pool.getActiveParticles();
if (this._logCounter++ % 60 === 0 && activeParticles.length > 0) {
const p = activeParticles[0];
console.log(`[SpriteEffectInstance] \uD65C\uC131 \uD30C\uD2F0\uD074: ${activeParticles.length}\uAC1C, \uCCAB \uD30C\uD2F0\uD074 pos=(${p.position.x.toFixed(3)}, ${p.position.y.toFixed(3)}), scale=${p.scale.toFixed(3)}, opacity=${p.opacity.toFixed(3)}`);
}
for (const particle of activeParticles) {
particle.age += deltaTime;
if (particle.age >= particle.lifetime) {
this.pool.release(particle);
this.syncMesh(particle);
continue;
}
const lifeRatio = particle.age / particle.lifetime;
if (overLifetime) {
if (overLifetime.scale) {
particle.scale = lerp(overLifetime.scale[0], overLifetime.scale[1], lifeRatio);
}
if (overLifetime.opacity) {
particle.opacity = lerp(overLifetime.opacity[0], overLifetime.opacity[1], lifeRatio);
}
if (overLifetime.rotationSpeed) {
particle.rotation += overLifetime.rotationSpeed * deltaTime;
}
if (overLifetime.velocityDamping !== void 0) {
const damping = Math.pow(overLifetime.velocityDamping, deltaTime);
particle.velocity.x *= damping;
particle.velocity.y *= damping;
}
}
if (this.config.spriteSheet) {
this.updateSpriteFrame(particle, deltaTime, this.config.spriteSheet);
}
particle.position.x += particle.velocity.x * deltaTime;
particle.position.y += particle.velocity.y * deltaTime;
this.syncMesh(particle);
}
}
/**
* 스프라이트 시트 프레임 진행
*/
updateSpriteFrame(particle, deltaTime, sheet) {
particle.frameTime += deltaTime;
const frameDuration = 1 / sheet.fps;
if (particle.frameTime >= frameDuration) {
particle.frameTime -= frameDuration;
const nextFrame = particle.frameIndex + 1;
if (nextFrame >= sheet.totalFrames) {
if (sheet.loop !== false) {
particle.frameIndex = 0;
}
} else {
particle.frameIndex = nextFrame;
}
}
}
/**
* 파티클 상태를 Three.js 메쉬에 동기화
* 정규화 좌표(0-1) NDC(-1~1) 변환, y축 반전
*/
syncMesh(particle) {
const mesh = this.meshes[particle.index];
if (!mesh) return;
if (!particle.active) {
mesh.visible = false;
return;
}
mesh.visible = true;
mesh.position.x = particle.position.x * 2 - 1;
mesh.position.y = -(particle.position.y * 2 - 1);
mesh.position.z = -0.01;
mesh.scale.set(particle.scale, particle.scale, 1);
mesh.rotation.z = particle.rotation;
const mat = mesh.material;
mat.opacity = particle.opacity;
const sheet = this.config.spriteSheet;
if (sheet && mat.map) {
const col = particle.frameIndex % sheet.columns;
const row = Math.floor(particle.frameIndex / sheet.columns);
mat.map.offset.set(
col / sheet.columns,
1 - (row + 1) / sheet.rows
);
}
}
/**
* 텍스처 로딩 완료 여부
*/
isReady() {
return this.ready;
}
/**
* 리소스 정리
*/
dispose() {
if (this.texture) {
this.texture.dispose();
this.texture = null;
}
this.geometry.dispose();
for (const mesh of this.meshes) {
mesh.material.dispose();
}
this.material.dispose();
while (this.group.children.length > 0) {
this.group.remove(this.group.children[0]);
}
}
};
// src/engine/SpriteEffectManager.ts
var isPointInCircle = (point, center, radius) => {
const dx = point.x - center.x;
const dy = point.y - center.y;
return dx * dx + dy * dy <= radius * radius;
};
var SpriteEffectManager = class {
constructor() {
/** 영역ID+이펙트ID → 인스턴스 맵 */
this.instances = /* @__PURE__ */ new Map();
/** 이전 프레임에서 터치 중이던 영역 ID 세트 (버스트 감지용) */
this.previousTouchingAreas = /* @__PURE__ */ new Set();
this.effectGroup = new THREE3.Group();
this.effectGroup.renderOrder = 1;
}
/**
* Three.js 씬에 이펙트 그룹 추가
*/
attachToScene(scene) {
scene.add(this.effectGroup);
}
/**
* 이펙트 영역 설정 변경을 감지하여 인스턴스 생성/제거
*/
syncEffects(effectAreas) {
console.log("[SpriteEffectManager] syncEffects \uD638\uCD9C:", effectAreas.length, "\uAC1C \uC601\uC5ED");
const activeKeys = /* @__PURE__ */ new Set();
for (const area of effectAreas) {
for (const effectConfig of area.effects) {
const key = `${area.id}::${effectConfig.id}`;
activeKeys.add(key);
if (this.instances.has(key)) continue;
console.log("[SpriteEffectManager] \uC778\uC2A4\uD134\uC2A4 \uC0DD\uC131:", key, effectConfig.spriteUrl);
const instance = new SpriteEffectInstance(effectConfig);
this.instances.set(key, instance);
this.effectGroup.add(instance.group);
}
}
for (const [key, instance] of this.instances) {
if (!activeKeys.has(key)) {
instance.dispose();
this.effectGroup.remove(instance.group);
this.instances.delete(key);
}
}
}
/**
* 프레임 업데이트
* @param effectAreas 이펙트 영역 배열
* @param deltaTime 단위 프레임 시간
* @param touchState 마우스/터치 상태
*/
update(effectAreas, deltaTime, touchState) {
const currentTouchingAreas = /* @__PURE__ */ new Set();
if (touchState.isDragging && touchState.position) {
for (const area of effectAreas) {
const radius = area.radius ?? 0.1;
if (isPointInCircle(touchState.position, area.position, radius)) {
currentTouchingAreas.add(area.id);
}
}
}
for (const area of effectAreas) {
for (const effectConfig of area.effects) {
const key = `${area.id}::${effectConfig.id}`;
const instance = this.instances.get(key);
if (!instance) continue;
if (effectConfig.trigger === "touch") {
const isNewTouch = currentTouchingAreas.has(area.id) && !this.previousTouchingAreas.has(area.id);
if (isNewTouch) {
instance.triggerBurst(touchState.position ?? area.position);
}
}
instance.update(deltaTime, area.position);
}
}
this.previousTouchingAreas = currentTouchingAreas;
}
/**
* 리소스 정리
*/
dispose() {
for (const [, instance] of this.instances) {
instance.dispose();
}
this.instances.clear();
this.previousTouchingAreas.clear();
if (this.effectGroup.parent) {
this.effectGroup.parent.remove(this.effectGroup);
}
}
};
// src/hooks/useAnimationFrame.ts // src/hooks/useAnimationFrame.ts
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
var useAnimationFrame = (callback, isPlaying = true) => { var useAnimationFrame = (callback, isPlaying = true) => {
@ -1158,8 +756,7 @@ var useMouseInteraction = (containerRef, config) => {
updateConfig, updateConfig,
reset, reset,
isDragging, isDragging,
getInteractingAreaIndices, getInteractingAreaIndices
getMouseState: getState
}; };
}; };
@ -1208,15 +805,12 @@ var ImageDistortion = ({
fragmentShaderPath, fragmentShaderPath,
style, style,
className, className,
mouseInteraction, mouseInteraction
spriteEffectAreas = []
}) => { }) => {
const containerRef = useRef4(null); const containerRef = useRef4(null);
const sceneRef = useRef4(null); const sceneRef = useRef4(null);
const shaderManagerRef = useRef4(new ShaderManager()); const shaderManagerRef = useRef4(new ShaderManager());
const textureRef = useRef4(null); const textureRef = useRef4(null);
const spriteManagerRef = useRef4(null);
const currentAreasRef = useRef4(areas);
const [isReady, setIsReady] = useState2(false); const [isReady, setIsReady] = useState2(false);
const [imageLoaded, setImageLoaded] = useState2(false); const [imageLoaded, setImageLoaded] = useState2(false);
const [currentAreas, setCurrentAreas] = useState2(areas); const [currentAreas, setCurrentAreas] = useState2(areas);
@ -1236,22 +830,6 @@ var ImageDistortion = ({
useEffect3(() => { useEffect3(() => {
setCurrentAreas(areas); setCurrentAreas(areas);
}, [areas]); }, [areas]);
useEffect3(() => {
currentAreasRef.current = currentAreas;
}, [currentAreas]);
useEffect3(() => {
if (!sceneRef.current || !isReady) return;
const manager = new SpriteEffectManager();
manager.attachToScene(sceneRef.current.getScene());
spriteManagerRef.current = manager;
return () => {
manager.dispose();
spriteManagerRef.current = null;
};
}, [isReady]);
useEffect3(() => {
spriteManagerRef.current?.syncEffects(spriteEffectAreas);
}, [spriteEffectAreas, isReady]);
useEffect3(() => { useEffect3(() => {
if (mouseInteraction) { if (mouseInteraction) {
mouseInteractionHook.updateConfig(mouseInteraction); mouseInteractionHook.updateConfig(mouseInteraction);
@ -1290,7 +868,7 @@ var ImageDistortion = ({
} }
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc); console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc);
setImageLoaded(false); setImageLoaded(false);
const loader = new THREE4.TextureLoader(); const loader = new THREE2.TextureLoader();
loader.load( loader.load(
imageSrc, imageSrc,
(texture) => { (texture) => {
@ -1382,19 +960,7 @@ var ImageDistortion = ({
} }
return updatedAreas; return updatedAreas;
}); });
if (spriteManagerRef.current) { }, [isReady, mouseInteraction, mouseInteractionHook]);
const mouseState = mouseInteractionHook.getMouseState();
spriteManagerRef.current.update(
spriteEffectAreas,
deltaTime,
{
position: mouseState.position ?? null,
isDragging: mouseState.isDragging
}
);
sceneRef.current?.render();
}
}, [isReady, mouseInteraction, mouseInteractionHook, spriteEffectAreas]);
useAnimationFrame(animationCallback, true); useAnimationFrame(animationCallback, true);
return /* @__PURE__ */ jsx( return /* @__PURE__ */ jsx(
"div", "div",
@ -1760,15 +1326,12 @@ var EditorCanvas = ({
onStopDragging, onStopDragging,
style: customStyle, style: customStyle,
showEditor = true, showEditor = true,
onSelectArea, onSelectArea
spriteEffectAreas = [],
onUpdateSpriteEffectArea
}) => { }) => {
const containerRef = useRef5(null); const containerRef = useRef5(null);
const [canvasSize, setCanvasSize] = useState4({ width: 0, height: 0 }); const [canvasSize, setCanvasSize] = useState4({ width: 0, height: 0 });
const [isDraggingArea, setIsDraggingArea] = useState4(false); const [isDraggingArea, setIsDraggingArea] = useState4(false);
const [dragStartPos, setDragStartPos] = useState4(null); const [dragStartPos, setDragStartPos] = useState4(null);
const [draggingSpriteAreaId, setDraggingSpriteAreaId] = useState4(null);
const editorStyle = useMemo(() => ({ const editorStyle = useMemo(() => ({
...DEFAULT_EDITOR_CANVAS_STYLE, ...DEFAULT_EDITOR_CANVAS_STYLE,
...customStyle, ...customStyle,
@ -1835,20 +1398,6 @@ var EditorCanvas = ({
const x = (clientX - rect.left) / rect.width; const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height; const y = (clientY - rect.top) / rect.height;
const clickPoint = { x, y }; const clickPoint = { x, y };
if (onUpdateSpriteEffectArea) {
for (let i = spriteEffectAreas.length - 1; i >= 0; i--) {
const sa = spriteEffectAreas[i];
const dx = clickPoint.x - sa.position.x;
const dy = clickPoint.y - sa.position.y;
const radius = sa.radius ?? 0.1;
if (dx * dx + dy * dy <= radius * radius) {
setDraggingSpriteAreaId(sa.id);
setDragStartPos(clickPoint);
e.preventDefault();
return;
}
}
}
if (selectedArea && isPointInPolygon2(clickPoint, selectedArea.basePoints)) { if (selectedArea && isPointInPolygon2(clickPoint, selectedArea.basePoints)) {
setIsDraggingArea(true); setIsDraggingArea(true);
setDragStartPos(clickPoint); setDragStartPos(clickPoint);
@ -1866,12 +1415,12 @@ var EditorCanvas = ({
} }
} }
}, },
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea, spriteEffectAreas, onUpdateSpriteEffectArea] [showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea]
); );
const handleMove = useCallback5( const handleMove = useCallback5(
(e) => { (e) => {
if (!showEditor || !containerRef.current) return; if (!showEditor || !selectedArea || !containerRef.current) return;
if ("touches" in e && (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId)) { if ("touches" in e && (draggingPointIndex !== null || isDraggingArea)) {
e.preventDefault(); e.preventDefault();
} }
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
@ -1886,22 +1435,6 @@ var EditorCanvas = ({
} }
const x = (clientX - rect.left) / rect.width; const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height; const y = (clientY - rect.top) / rect.height;
if (draggingSpriteAreaId && dragStartPos && onUpdateSpriteEffectArea) {
const sa = spriteEffectAreas.find((a) => a.id === draggingSpriteAreaId);
if (sa) {
const deltaX = x - dragStartPos.x;
const deltaY = y - dragStartPos.y;
onUpdateSpriteEffectArea(sa.id, {
position: {
x: Math.max(0, Math.min(1, sa.position.x + deltaX)),
y: Math.max(0, Math.min(1, sa.position.y + deltaY))
}
});
setDragStartPos({ x, y });
}
return;
}
if (!selectedArea) return;
if (draggingPointIndex !== null) { if (draggingPointIndex !== null) {
const clampedX = Math.max(0, Math.min(1, x)); const clampedX = Math.max(0, Math.min(1, x));
const clampedY = Math.max(0, Math.min(1, y)); const clampedY = Math.max(0, Math.min(1, y));
@ -1917,7 +1450,7 @@ var EditorCanvas = ({
setDragStartPos({ x, y }); setDragStartPos({ x, y });
} }
}, },
[showEditor, draggingPointIndex, isDraggingArea, draggingSpriteAreaId, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea, spriteEffectAreas, onUpdateSpriteEffectArea] [showEditor, draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea]
); );
const handleUp = useCallback5(() => { const handleUp = useCallback5(() => {
if (draggingPointIndex !== null) { if (draggingPointIndex !== null) {
@ -1927,13 +1460,9 @@ var EditorCanvas = ({
setIsDraggingArea(false); setIsDraggingArea(false);
setDragStartPos(null); setDragStartPos(null);
} }
if (draggingSpriteAreaId) { }, [draggingPointIndex, isDraggingArea, onStopDragging]);
setDraggingSpriteAreaId(null);
setDragStartPos(null);
}
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, onStopDragging]);
useEffect4(() => { useEffect4(() => {
if (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId) { if (draggingPointIndex !== null || isDraggingArea) {
window.addEventListener("mouseup", handleUp); window.addEventListener("mouseup", handleUp);
window.addEventListener("touchend", handleUp); window.addEventListener("touchend", handleUp);
window.addEventListener("touchcancel", handleUp); window.addEventListener("touchcancel", handleUp);
@ -1943,7 +1472,7 @@ var EditorCanvas = ({
window.removeEventListener("touchcancel", handleUp); window.removeEventListener("touchcancel", handleUp);
}; };
} }
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, handleUp]); }, [draggingPointIndex, isDraggingArea, handleUp]);
const uvToPixel = (u, v, points, canvasWidth, canvasHeight) => { const uvToPixel = (u, v, points, canvasWidth, canvasHeight) => {
const [p0, p1, p2, p3] = points; const [p0, p1, p2, p3] = points;
const leftX = p0.x * (1 - u) + p1.x * u; const leftX = p0.x * (1 - u) + p1.x * u;
@ -2011,7 +1540,6 @@ var EditorCanvas = ({
const getCursorStyle = () => { const getCursorStyle = () => {
if (draggingPointIndex !== null) return "grabbing"; if (draggingPointIndex !== null) return "grabbing";
if (isDraggingArea) return "grabbing"; if (isDraggingArea) return "grabbing";
if (draggingSpriteAreaId) return "grabbing";
return "default"; return "default";
}; };
return /* @__PURE__ */ jsxs3( return /* @__PURE__ */ jsxs3(
@ -2033,7 +1561,7 @@ var EditorCanvas = ({
onTouchStart: showEditor ? handleCanvasDown : void 0, onTouchStart: showEditor ? handleCanvasDown : void 0,
onTouchMove: showEditor ? handleMove : void 0, onTouchMove: showEditor ? handleMove : void 0,
children: [ children: [
/* @__PURE__ */ jsx4(ImageDistortion, { imageSrc, areas, spriteEffectAreas }), /* @__PURE__ */ jsx4(ImageDistortion, { imageSrc, areas }),
showEditor && /* @__PURE__ */ jsx4( showEditor && /* @__PURE__ */ jsx4(
"svg", "svg",
{ {
@ -2132,88 +1660,6 @@ var EditorCanvas = ({
}, },
index index
); );
}),
showEditor && spriteEffectAreas.map((sa) => {
const cx = sa.position.x * 100;
const cy = sa.position.y * 100;
const isDragging = draggingSpriteAreaId === sa.id;
const radiusPx = (sa.radius ?? 0.1) * canvasSize.width;
return /* @__PURE__ */ jsxs3(
"div",
{
style: {
position: "absolute",
left: `${cx}%`,
top: `${cy}%`,
transform: "translate(-50%, -50%)",
pointerEvents: "none"
},
children: [
/* @__PURE__ */ jsx4(
"svg",
{
width: radiusPx * 2,
height: radiusPx * 2,
style: {
position: "absolute",
left: -radiusPx,
top: -radiusPx,
pointerEvents: "none"
},
children: /* @__PURE__ */ jsx4(
"circle",
{
cx: radiusPx,
cy: radiusPx,
r: radiusPx,
fill: isDragging ? "rgba(255, 170, 0, 0.15)" : "rgba(255, 170, 0, 0.08)",
stroke: isDragging ? "#ffaa00" : "rgba(255, 170, 0, 0.6)",
strokeWidth: isDragging ? 2 : 1.5,
strokeDasharray: isDragging ? "0" : "4,3"
}
)
}
),
/* @__PURE__ */ jsx4(
"div",
{
style: {
position: "absolute",
left: -8,
top: -8,
width: 16,
height: 16,
borderRadius: "50%",
backgroundColor: isDragging ? "#ffaa00" : "rgba(255, 170, 0, 0.8)",
border: "2px solid white",
cursor: isDragging ? "grabbing" : "grab",
pointerEvents: "auto",
boxShadow: "0 2px 4px rgba(0,0,0,0.3)"
}
}
),
/* @__PURE__ */ jsx4(
"div",
{
style: {
position: "absolute",
top: -24,
left: "50%",
transform: "translateX(-50%)",
fontSize: 10,
color: "#ffaa00",
fontWeight: "bold",
textShadow: "1px 1px 2px rgba(0,0,0,0.8)",
whiteSpace: "nowrap",
pointerEvents: "none"
},
children: "\u2728"
}
)
]
},
`sprite-${sa.id}`
);
}) })
] ]
} }
@ -2231,7 +1677,6 @@ export {
SHADER_CONFIG, SHADER_CONFIG,
ShaderManager, ShaderManager,
SpringPhysics, SpringPhysics,
SpriteEffectManager,
ThreeScene, ThreeScene,
applyEasing, applyEasing,
getRegisteredPresets, getRegisteredPresets,

2
dist/index.mjs.map vendored

File diff suppressed because one or more lines are too long

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@baekryang/responsive-image-canvas", "name": "@baekryang/responsive-image-canvas",
"version": "1.3.0", "version": "1.0.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@baekryang/responsive-image-canvas", "name": "@baekryang/responsive-image-canvas",
"version": "1.3.0", "version": "1.0.5",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.2", "@types/react": "^19.2.2",

View File

@ -1,6 +1,6 @@
{ {
"name": "@baekryang/responsive-image-canvas", "name": "@baekryang/responsive-image-canvas",
"version": "1.5.2", "version": "1.2.10",
"publishConfig": { "publishConfig": {
"registry": "https://git.bnovalab.com/api/packages/baekryang/npm/" "registry": "https://git.bnovalab.com/api/packages/baekryang/npm/"
}, },

BIN
petal.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -1,10 +1,9 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as THREE from 'three'; import * as THREE from 'three';
import {type DistortionArea, SpriteEffectArea} from '@/types'; import { type DistortionArea } from '@/types';
import { ThreeScene } from '@/engine/ThreeScene'; import { ThreeScene } from '@/engine/ThreeScene';
import { ShaderManager } from '@/engine/ShaderManager'; import { ShaderManager } from '@/engine/ShaderManager';
import { AnimationLoop } from '@/engine/AnimationLoop'; import { AnimationLoop } from '@/engine/AnimationLoop';
import { SpriteEffectManager } from '@/engine/SpriteEffectManager';
import { useAnimationFrame } from '@/hooks/useAnimationFrame'; import { useAnimationFrame } from '@/hooks/useAnimationFrame';
import { useMouseInteraction } from '@/hooks/useMouseInteraction'; import { useMouseInteraction } from '@/hooks/useMouseInteraction';
import { SHADER_CONFIG } from '@/utils/constants'; import { SHADER_CONFIG } from '@/utils/constants';
@ -28,8 +27,6 @@ export interface ImageDistortionProps {
className?: string; className?: string;
/** 마우스 인터랙션 설정 */ /** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig; mouseInteraction?: MouseInteractionConfig;
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
spriteEffectAreas?: SpriteEffectArea[];
} }
/** /**
@ -44,14 +41,11 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
style, style,
className, className,
mouseInteraction, mouseInteraction,
spriteEffectAreas = [],
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<ThreeScene | null>(null); const sceneRef = useRef<ThreeScene | null>(null);
const shaderManagerRef = useRef<ShaderManager>(new ShaderManager()); const shaderManagerRef = useRef<ShaderManager>(new ShaderManager());
const textureRef = useRef<THREE.Texture | null>(null); const textureRef = useRef<THREE.Texture | null>(null);
const spriteManagerRef = useRef<SpriteEffectManager | null>(null);
const currentAreasRef = useRef<DistortionArea[]>(areas);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
@ -77,28 +71,6 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
setCurrentAreas(areas); setCurrentAreas(areas);
}, [areas]); }, [areas]);
// currentAreasRef 동기화
useEffect(() => {
currentAreasRef.current = currentAreas;
}, [currentAreas]);
// 스프라이트 이펙트 매니저 초기화
useEffect(() => {
if (!sceneRef.current || !isReady) return;
const manager = new SpriteEffectManager();
manager.attachToScene(sceneRef.current.getScene());
spriteManagerRef.current = manager;
return () => {
manager.dispose();
spriteManagerRef.current = null;
};
}, [isReady]);
// 이펙트 영역 변경 또는 매니저 준비 시 스프라이트 이펙트 동기화
useEffect(() => {
spriteManagerRef.current?.syncEffects(spriteEffectAreas);
}, [spriteEffectAreas, isReady]);
// 마우스 인터랙션 설정 변경 시 업데이트 // 마우스 인터랙션 설정 변경 시 업데이트
useEffect(() => { useEffect(() => {
if (mouseInteraction) { if (mouseInteraction) {
@ -115,7 +87,7 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
return; return;
} }
console.log('[ImageDistortion] v1.5.1 초기화 시작'); console.log('[ImageDistortion] 초기화 시작');
const scene = new ThreeScene(containerRef.current); const scene = new ThreeScene(containerRef.current);
sceneRef.current = scene; sceneRef.current = scene;
@ -273,25 +245,7 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
return updatedAreas; return updatedAreas;
}); });
}, [isReady, mouseInteraction, mouseInteractionHook]);
// 스프라이트 이펙트 업데이트 (왜곡 영역과 독립적)
if (spriteManagerRef.current) {
const mouseState = mouseInteractionHook.getMouseState();
const resolution = sceneRef.current?.getResolution() ?? { x: 1, y: 1 };
spriteManagerRef.current.update(
spriteEffectAreas,
deltaTime,
{
position: mouseState.position ?? null,
isDragging: mouseState.isDragging,
},
resolution,
);
// 스프라이트 메쉬 변경 후 렌더링 필요
sceneRef.current?.render();
}
}, [isReady, mouseInteraction, mouseInteractionHook, spriteEffectAreas]);
// 애니메이션 루프 실행 // 애니메이션 루프 실행
useAnimationFrame(animationCallback, true); useAnimationFrame(animationCallback, true);

View File

@ -1,6 +1,5 @@
import React, {useRef, useEffect, useState, useCallback, useMemo} from 'react'; import React, {useRef, useEffect, useState, useCallback, useMemo} from 'react';
import {DistortionArea, Point} from '@/types'; import {DistortionArea, Point} from '@/types';
import type {SpriteEffectArea} from '@/types/spriteEffect';
import {ImageDistortion} from '@/components/ImageDistortion'; import {ImageDistortion} from '@/components/ImageDistortion';
import {EditorCanvasStyle} from '../types'; import {EditorCanvasStyle} from '../types';
import {DEFAULT_EDITOR_CANVAS_STYLE} from '@/editor'; import {DEFAULT_EDITOR_CANVAS_STYLE} from '@/editor';
@ -22,10 +21,6 @@ export interface EditorCanvasProps {
showEditor?: boolean; showEditor?: boolean;
/** 영역 선택 콜백 (비선택 영역 클릭 시) */ /** 영역 선택 콜백 (비선택 영역 클릭 시) */
onSelectArea?: (areaId: string) => void; onSelectArea?: (areaId: string) => void;
/** 독립 스프라이트 이펙트 영역 */
spriteEffectAreas?: SpriteEffectArea[];
/** 스프라이트 이펙트 영역 업데이트 콜백 */
onUpdateSpriteEffectArea?: (areaId: string, updates: Partial<SpriteEffectArea>) => void;
} }
export const EditorCanvas: React.FC<EditorCanvasProps> = ({ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
@ -42,14 +37,11 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
style: customStyle, style: customStyle,
showEditor = true, showEditor = true,
onSelectArea, onSelectArea,
spriteEffectAreas = [],
onUpdateSpriteEffectArea,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({width: 0, height: 0}); const [canvasSize, setCanvasSize] = useState({width: 0, height: 0});
const [isDraggingArea, setIsDraggingArea] = useState(false); const [isDraggingArea, setIsDraggingArea] = useState(false);
const [dragStartPos, setDragStartPos] = useState<Point | null>(null); const [dragStartPos, setDragStartPos] = useState<Point | null>(null);
const [draggingSpriteAreaId, setDraggingSpriteAreaId] = useState<string | null>(null);
// 스타일 병합 (커스텀 스타일 우선) // 스타일 병합 (커스텀 스타일 우선)
const editorStyle = useMemo(() => ({ const editorStyle = useMemo(() => ({
@ -142,22 +134,6 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
const y = (clientY - rect.top) / rect.height; const y = (clientY - rect.top) / rect.height;
const clickPoint = { x, y }; const clickPoint = { x, y };
// 스프라이트 이펙트 영역 클릭 확인 (우선 처리)
if (onUpdateSpriteEffectArea) {
for (let i = spriteEffectAreas.length - 1; i >= 0; i--) {
const sa = spriteEffectAreas[i];
const dx = clickPoint.x - sa.position.x;
const dy = clickPoint.y - sa.position.y;
const radius = sa.radius ?? 0.1;
if (dx * dx + dy * dy <= radius * radius) {
setDraggingSpriteAreaId(sa.id);
setDragStartPos(clickPoint);
e.preventDefault();
return;
}
}
}
// 선택된 영역 내부를 클릭했는지 확인 (드래그 시작) // 선택된 영역 내부를 클릭했는지 확인 (드래그 시작)
if (selectedArea && isPointInPolygon(clickPoint, selectedArea.basePoints)) { if (selectedArea && isPointInPolygon(clickPoint, selectedArea.basePoints)) {
setIsDraggingArea(true); setIsDraggingArea(true);
@ -179,17 +155,17 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
} }
} }
}, },
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon, onSelectArea, spriteEffectAreas, onUpdateSpriteEffectArea] [showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon, onSelectArea]
); );
// 이동 (마우스/터치 공통) // 이동 (마우스/터치 공통)
const handleMove = useCallback( const handleMove = useCallback(
(e: React.MouseEvent | React.TouchEvent) => { (e: React.MouseEvent | React.TouchEvent) => {
// 에디터가 숨겨진 상태면 동작하지 않음 // 에디터가 숨겨진 상태면 동작하지 않음
if (!showEditor || !containerRef.current) return; if (!showEditor || !selectedArea || !containerRef.current) return;
// 터치 이벤트면 스크롤 방지 // 터치 이벤트면 스크롤 방지
if ('touches' in e && (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId)) { if ('touches' in e && (draggingPointIndex !== null || isDraggingArea)) {
e.preventDefault(); e.preventDefault();
} }
@ -209,25 +185,6 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
const x = (clientX - rect.left) / rect.width; const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height; const y = (clientY - rect.top) / rect.height;
// 스프라이트 이펙트 영역 드래그 중
if (draggingSpriteAreaId && dragStartPos && onUpdateSpriteEffectArea) {
const sa = spriteEffectAreas.find(a => a.id === draggingSpriteAreaId);
if (sa) {
const deltaX = x - dragStartPos.x;
const deltaY = y - dragStartPos.y;
onUpdateSpriteEffectArea(sa.id, {
position: {
x: Math.max(0, Math.min(1, sa.position.x + deltaX)),
y: Math.max(0, Math.min(1, sa.position.y + deltaY)),
},
});
setDragStartPos({x, y});
}
return;
}
if (!selectedArea) return;
// 포인트 드래그 중 // 포인트 드래그 중
if (draggingPointIndex !== null) { if (draggingPointIndex !== null) {
const clampedX = Math.max(0, Math.min(1, x)); const clampedX = Math.max(0, Math.min(1, x));
@ -249,7 +206,7 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
setDragStartPos({ x, y }); // 다음 프레임을 위해 시작 위치 업데이트 setDragStartPos({ x, y }); // 다음 프레임을 위해 시작 위치 업데이트
} }
}, },
[showEditor, draggingPointIndex, isDraggingArea, draggingSpriteAreaId, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea, spriteEffectAreas, onUpdateSpriteEffectArea] [showEditor, draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea]
); );
// 업 (마우스/터치 공통) // 업 (마우스/터치 공통)
@ -261,15 +218,11 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
setIsDraggingArea(false); setIsDraggingArea(false);
setDragStartPos(null); setDragStartPos(null);
} }
if (draggingSpriteAreaId) { }, [draggingPointIndex, isDraggingArea, onStopDragging]);
setDraggingSpriteAreaId(null);
setDragStartPos(null);
}
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, onStopDragging]);
// 전역 업 이벤트 (마우스/터치) // 전역 업 이벤트 (마우스/터치)
useEffect(() => { useEffect(() => {
if (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId) { if (draggingPointIndex !== null || isDraggingArea) {
window.addEventListener('mouseup', handleUp); window.addEventListener('mouseup', handleUp);
window.addEventListener('touchend', handleUp); window.addEventListener('touchend', handleUp);
window.addEventListener('touchcancel', handleUp); window.addEventListener('touchcancel', handleUp);
@ -279,7 +232,7 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
window.removeEventListener('touchcancel', handleUp); window.removeEventListener('touchcancel', handleUp);
}; };
} }
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, handleUp]); }, [draggingPointIndex, isDraggingArea, handleUp]);
// UV 좌표를 픽셀 좌표로 변환 (셰이더와 동일한 bilinear interpolation) // UV 좌표를 픽셀 좌표로 변환 (셰이더와 동일한 bilinear interpolation)
const uvToPixel = ( const uvToPixel = (
@ -384,7 +337,6 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
const getCursorStyle = () => { const getCursorStyle = () => {
if (draggingPointIndex !== null) return 'grabbing'; if (draggingPointIndex !== null) return 'grabbing';
if (isDraggingArea) return 'grabbing'; if (isDraggingArea) return 'grabbing';
if (draggingSpriteAreaId) return 'grabbing';
return 'default'; return 'default';
}; };
@ -406,7 +358,7 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
onTouchMove={showEditor ? handleMove : undefined} onTouchMove={showEditor ? handleMove : undefined}
> >
{/* ImageDistortion 컴포넌트 */} {/* ImageDistortion 컴포넌트 */}
<ImageDistortion imageSrc={imageSrc} areas={areas} spriteEffectAreas={spriteEffectAreas}/> <ImageDistortion imageSrc={imageSrc} areas={areas}/>
{/* 오버레이 SVG - 에디터 모드일 때만 표시 */} {/* 오버레이 SVG - 에디터 모드일 때만 표시 */}
{showEditor && ( {showEditor && (
@ -512,83 +464,6 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
</div> </div>
); );
})} })}
{/* 스프라이트 이펙트 영역 표시 */}
{showEditor && spriteEffectAreas.map((sa) => {
const cx = sa.position.x * 100;
const cy = sa.position.y * 100;
const isDragging = draggingSpriteAreaId === sa.id;
// 반경을 %로 변환 (가로 기준, 종횡비 보정은 SVG에서)
const radiusPx = (sa.radius ?? 0.1) * canvasSize.width;
return (
<div
key={`sprite-${sa.id}`}
style={{
position: 'absolute',
left: `${cx}%`,
top: `${cy}%`,
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
}}
>
{/* 반경 원 */}
<svg
width={radiusPx * 2}
height={radiusPx * 2}
style={{
position: 'absolute',
left: -radiusPx,
top: -radiusPx,
pointerEvents: 'none',
}}
>
<circle
cx={radiusPx}
cy={radiusPx}
r={radiusPx}
fill={isDragging ? 'rgba(255, 170, 0, 0.15)' : 'rgba(255, 170, 0, 0.08)'}
stroke={isDragging ? '#ffaa00' : 'rgba(255, 170, 0, 0.6)'}
strokeWidth={isDragging ? 2 : 1.5}
strokeDasharray={isDragging ? '0' : '4,3'}
/>
</svg>
{/* 중심 핸들 */}
<div
style={{
position: 'absolute',
left: -8,
top: -8,
width: 16,
height: 16,
borderRadius: '50%',
backgroundColor: isDragging ? '#ffaa00' : 'rgba(255, 170, 0, 0.8)',
border: '2px solid white',
cursor: isDragging ? 'grabbing' : 'grab',
pointerEvents: 'auto',
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
}}
/>
{/* 라벨 */}
<div
style={{
position: 'absolute',
top: -24,
left: '50%',
transform: 'translateX(-50%)',
fontSize: 10,
color: '#ffaa00',
fontWeight: 'bold',
textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}
>
&#x2728;
</div>
</div>
);
})}
</div> </div>
); );
}; };

View File

@ -1,392 +0,0 @@
import * as THREE from 'three';
import type { Point } from '@/types';
import type { SpriteEffectConfig, SpriteSheetConfig } from '@/types/spriteEffect';
import { SpriteParticlePool, type SpriteParticle } from './SpriteParticlePool';
/**
*
*/
const randomRange = (min: number, max: number): number =>
min + Math.random() * (max - min);
/**
*
*/
const lerp = (a: number, b: number, t: number): number =>
a + (b - a) * t;
/**
* (2개: 선형, 3개: 전반/ )
*/
const lerpKeyframes = (values: number[], t: number): number => {
if (values.length === 2) {
return lerp(values[0], values[1], t);
}
// 3개 이상: 균등 구간 분할
const segments = values.length - 1;
const segT = t * segments;
const idx = Math.min(Math.floor(segT), segments - 1);
const localT = segT - idx;
return lerp(values[idx], values[idx + 1], localT);
};
/**
* SpriteEffectConfig에
* , , /
*/
export class SpriteEffectInstance {
private config: SpriteEffectConfig;
private pool: SpriteParticlePool;
private meshes: THREE.Mesh[];
private geometry: THREE.PlaneGeometry;
private material: THREE.MeshBasicMaterial;
private texture: THREE.Texture | null = null;
private ready = false;
private emitAccumulator = 0;
/** 메쉬를 담는 그룹 (외부에서 씬에 추가) */
readonly group: THREE.Group;
constructor(config: SpriteEffectConfig) {
this.config = config;
this.pool = new SpriteParticlePool(config.maxParticles);
this.group = new THREE.Group();
// 공유 지오메트리 (1x1 평면)
this.geometry = new THREE.PlaneGeometry(1, 1);
// 블렌드 모드 결정
const blending = config.blendMode === 'additive'
? THREE.AdditiveBlending
: THREE.NormalBlending;
// 공유 머티리얼 (텍스처 로드 전까지 투명)
this.material = new THREE.MeshBasicMaterial({
transparent: true,
depthTest: false,
depthWrite: false,
blending,
opacity: 0,
});
// 메쉬 풀 사전 생성
this.meshes = Array.from({ length: config.maxParticles }, () => {
const mesh = new THREE.Mesh(this.geometry, this.material.clone());
mesh.visible = false;
mesh.renderOrder = 1;
this.group.add(mesh);
return mesh;
});
// 이모지 또는 URL 텍스처 로드
if (config.emoji) {
this.createEmojiTexture(config.emoji);
} else if (config.spriteUrl) {
this.loadTexture(config.spriteUrl);
} else {
console.error('[SpriteEffectInstance] spriteUrl 또는 emoji 중 하나는 필수입니다.');
}
}
/** 런타임 설정 업데이트 (maxParticles 제외) */
updateConfig(config: SpriteEffectConfig): void {
this.config = config;
}
/** 텍스처 로드 */
private loadTexture(url: string): void {
const loader = new THREE.TextureLoader();
loader.load(
url,
(texture) => {
this.texture = texture;
const sheet = this.config.spriteSheet;
if (sheet) {
// 스프라이트 시트: 첫 프레임만 표시하도록 UV 설정
texture.repeat.set(1 / sheet.columns, 1 / sheet.rows);
texture.offset.set(0, 1 - 1 / sheet.rows);
}
// 각 메쉬에 독립적인 텍스처 클론 적용 (프레임별 UV 독립 제어)
for (const mesh of this.meshes) {
const mat = mesh.material as THREE.MeshBasicMaterial;
if (sheet) {
const cloned = texture.clone();
cloned.repeat.copy(texture.repeat);
cloned.offset.copy(texture.offset);
mat.map = cloned;
} else {
mat.map = texture;
}
mat.needsUpdate = true;
}
this.ready = true;
console.log(`[SpriteEffectInstance] 텍스처 로드 성공: ${url}`, texture.image.width, 'x', texture.image.height);
},
undefined,
(error) => {
console.error(`[SpriteEffectInstance] 텍스처 로드 실패: ${url}`, error);
}
);
}
/** 이모지를 Canvas에 렌더링하여 텍스처 생성 */
private createEmojiTexture(emoji: string): void {
const size = 128;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('[SpriteEffectInstance] Canvas 2D 컨텍스트 생성 실패');
return;
}
ctx.font = '100px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(emoji, size / 2, size / 2);
const texture = new THREE.CanvasTexture(canvas);
this.texture = texture;
// 각 메쉬에 텍스처 적용 (이모지는 스프라이트 시트 불필요)
for (const mesh of this.meshes) {
const mat = mesh.material as THREE.MeshBasicMaterial;
mat.map = texture;
mat.needsUpdate = true;
}
this.ready = true;
console.log(`[SpriteEffectInstance] 이모지 텍스처 생성 완료: ${emoji}`);
}
/**
* 1
* @param center ( 0-1)
*/
private emitOne(center: Point): void {
const particle = this.pool.acquire();
if (!particle) return;
const { config } = this;
// 방출 위치 계산 (중심 + 오프셋 + 반경 내 랜덤)
let px = center.x + (config.emitOffset?.x ?? 0);
let py = center.y + (config.emitOffset?.y ?? 0);
if (config.emitRadius && config.emitRadius > 0) {
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * config.emitRadius;
px += Math.cos(angle) * radius;
py += Math.sin(angle) * radius;
}
particle.position.x = px;
particle.position.y = py;
// 방출 각도 및 속도
const angleRange = config.emitAngle ?? [0, 360];
const angleDeg = randomRange(angleRange[0], angleRange[1]);
const angleRad = (angleDeg * Math.PI) / 180;
const speed = randomRange(config.initialSpeed[0], config.initialSpeed[1]);
particle.velocity.x = Math.cos(angleRad) * speed;
particle.velocity.y = Math.sin(angleRad) * speed;
// 초기 속성
particle.initialScale = randomRange(config.initialScale[0], config.initialScale[1]);
particle.scale = particle.initialScale;
particle.rotation = 0;
particle.opacity = 1;
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
particle.age = 0;
particle.frameTime = 0;
particle.frameIndex = 0;
}
/**
* ambient 모드:
*/
private updateAmbientEmit(deltaTime: number, center: Point): void {
if (!this.config.emitRate || this.config.emitRate <= 0) return;
this.emitAccumulator += deltaTime;
const interval = 1 / this.config.emitRate;
while (this.emitAccumulator >= interval) {
this.emitAccumulator -= interval;
this.emitOne(center);
}
}
/**
* touch 모드: 버스트
*/
triggerBurst(center: Point): void {
if (!this.ready) return;
const count = this.config.burstCount ?? 1;
for (let i = 0; i < count; i++) {
this.emitOne(center);
}
}
/**
*
* @param deltaTime
* @param emitCenter ( 0-1)
*/
private _logCounter = 0;
update(deltaTime: number, emitCenter: Point, resolution?: { x: number; y: number }): void {
if (!this.ready) return;
// ambient 방출
if (this.config.trigger === 'ambient') {
this.updateAmbientEmit(deltaTime, emitCenter);
}
const overLifetime = this.config.overLifetime;
// 활성 파티클 업데이트
const activeParticles = this.pool.getActiveParticles();
if (this._logCounter++ % 60 === 0 && activeParticles.length > 0) {
const p = activeParticles[0];
console.log(`[SpriteEffectInstance] 활성 파티클: ${activeParticles.length}개, 첫 파티클 pos=(${p.position.x.toFixed(3)}, ${p.position.y.toFixed(3)}), scale=${p.scale.toFixed(3)}, opacity=${p.opacity.toFixed(3)}`);
}
for (const particle of activeParticles) {
particle.age += deltaTime;
// 수명 초과 시 회수
if (particle.age >= particle.lifetime) {
this.pool.release(particle);
this.syncMesh(particle);
continue;
}
const lifeRatio = particle.age / particle.lifetime;
// overLifetime 보간 적용
if (overLifetime) {
if (overLifetime.scale) {
// overLifetime.scale은 initialScale에 대한 배율로 적용
particle.scale = particle.initialScale * lerpKeyframes(overLifetime.scale, lifeRatio);
}
if (overLifetime.opacity) {
particle.opacity = lerpKeyframes(overLifetime.opacity, lifeRatio);
}
if (overLifetime.rotationSpeed) {
particle.rotation += overLifetime.rotationSpeed * deltaTime;
}
if (overLifetime.velocityDamping !== undefined) {
const damping = Math.pow(overLifetime.velocityDamping, deltaTime);
particle.velocity.x *= damping;
particle.velocity.y *= damping;
}
}
// 스프라이트 시트 프레임 진행
if (this.config.spriteSheet) {
this.updateSpriteFrame(particle, deltaTime, this.config.spriteSheet);
}
// 위치 업데이트
particle.position.x += particle.velocity.x * deltaTime;
particle.position.y += particle.velocity.y * deltaTime;
// Three.js 메쉬 동기화
this.syncMesh(particle, resolution);
}
}
/**
*
*/
private updateSpriteFrame(particle: SpriteParticle, deltaTime: number, sheet: SpriteSheetConfig): void {
particle.frameTime += deltaTime;
const frameDuration = 1 / sheet.fps;
if (particle.frameTime >= frameDuration) {
particle.frameTime -= frameDuration;
const nextFrame = particle.frameIndex + 1;
if (nextFrame >= sheet.totalFrames) {
// 루프 여부 확인 (기본: true)
if (sheet.loop !== false) {
particle.frameIndex = 0;
}
// loop=false면 마지막 프레임 유지
} else {
particle.frameIndex = nextFrame;
}
}
}
/**
* Three.js
* (0-1) NDC(-1~1) , y축
*/
private syncMesh(particle: SpriteParticle, resolution?: { x: number; y: number }): void {
const mesh = this.meshes[particle.index];
if (!mesh) return;
if (!particle.active) {
mesh.visible = false;
return;
}
mesh.visible = true;
// 좌표 변환: 정규화(0-1) → NDC(-1~1), y 반전
mesh.position.x = particle.position.x * 2 - 1;
mesh.position.y = -(particle.position.y * 2 - 1);
mesh.position.z = -0.01; // 카메라가 -z를 바라보므로 음수가 앞쪽
// 종횡비 보정: OrthographicCamera(-1,1,1,-1) 기준 정사각형 NDC에서 직사각형 뷰포트 왜곡 방지
const aspect = resolution ? resolution.x / resolution.y : 1;
mesh.scale.set(particle.scale / aspect, particle.scale, 1);
mesh.rotation.z = particle.rotation;
const mat = mesh.material as THREE.MeshBasicMaterial;
mat.opacity = particle.opacity;
// 스프라이트 시트 UV 오프셋 업데이트
const sheet = this.config.spriteSheet;
if (sheet && mat.map) {
const col = particle.frameIndex % sheet.columns;
const row = Math.floor(particle.frameIndex / sheet.columns);
mat.map.offset.set(
col / sheet.columns,
1 - (row + 1) / sheet.rows,
);
}
}
/**
*
*/
isReady(): boolean {
return this.ready;
}
/**
*
*/
dispose(): void {
if (this.texture) {
this.texture.dispose();
this.texture = null;
}
this.geometry.dispose();
for (const mesh of this.meshes) {
(mesh.material as THREE.MeshBasicMaterial).dispose();
}
this.material.dispose();
// 그룹에서 모든 메쉬 제거
while (this.group.children.length > 0) {
this.group.remove(this.group.children[0]);
}
}
}

View File

@ -1,146 +0,0 @@
import * as THREE from 'three';
import type { Point } from '@/types';
import type { SpriteEffectArea } from '@/types/spriteEffect';
import { SpriteEffectInstance } from './SpriteEffectInstance';
/**
* / ( )
*/
export interface SpriteEffectTouchState {
/** 마우스/터치 위치 (정규화 좌표, null이면 미접촉) */
position: Point | null;
/** 드래그 중 여부 */
isDragging: boolean;
}
/**
*
*/
const isPointInCircle = (point: Point, center: Point, radius: number): boolean => {
const dx = point.x - center.x;
const dy = point.y - center.y;
return dx * dx + dy * dy <= radius * radius;
};
/**
*
* ImageDistortion
* (DistortionArea)
*/
export class SpriteEffectManager {
/** 모든 이펙트 메쉬를 담는 그룹 */
private effectGroup: THREE.Group;
/** 영역ID+이펙트ID → 인스턴스 맵 */
private instances: Map<string, SpriteEffectInstance> = new Map();
/** 이전 프레임에서 터치 중이던 영역 ID 세트 (버스트 감지용) */
private previousTouchingAreas: Set<string> = new Set();
constructor() {
this.effectGroup = new THREE.Group();
this.effectGroup.renderOrder = 1;
}
/**
* Three.js
*/
attachToScene(scene: THREE.Scene): void {
scene.add(this.effectGroup);
}
/**
* /
*/
syncEffects(effectAreas: SpriteEffectArea[]): void {
console.log('[SpriteEffectManager] syncEffects 호출:', effectAreas.length, '개 영역');
const activeKeys = new Set<string>();
for (const area of effectAreas) {
for (const effectConfig of area.effects) {
const key = `${area.id}::${effectConfig.id}`;
activeKeys.add(key);
// 이미 존재하면 설정만 업데이트
const existing = this.instances.get(key);
if (existing) {
existing.updateConfig(effectConfig);
continue;
}
// 새 인스턴스 생성
console.log('[SpriteEffectManager] 인스턴스 생성:', key, effectConfig.emoji ?? effectConfig.spriteUrl);
const instance = new SpriteEffectInstance(effectConfig);
this.instances.set(key, instance);
this.effectGroup.add(instance.group);
}
}
// 더 이상 사용되지 않는 인스턴스 제거
for (const [key, instance] of this.instances) {
if (!activeKeys.has(key)) {
instance.dispose();
this.effectGroup.remove(instance.group);
this.instances.delete(key);
}
}
}
/**
*
* @param effectAreas
* @param deltaTime
* @param touchState /
*/
update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState, resolution?: { x: number; y: number }): void {
// 현재 터치 중인 영역 감지
const currentTouchingAreas = new Set<string>();
if (touchState.isDragging && touchState.position) {
for (const area of effectAreas) {
const radius = area.radius ?? 0.1;
if (isPointInCircle(touchState.position, area.position, radius)) {
currentTouchingAreas.add(area.id);
}
}
}
// 각 영역의 이펙트 업데이트
for (const area of effectAreas) {
for (const effectConfig of area.effects) {
const key = `${area.id}::${effectConfig.id}`;
const instance = this.instances.get(key);
if (!instance) continue;
// touch 이펙트: 새로 터치된 영역이면 버스트
if (effectConfig.trigger === 'touch') {
const isNewTouch = currentTouchingAreas.has(area.id)
&& !this.previousTouchingAreas.has(area.id);
if (isNewTouch) {
instance.triggerBurst(touchState.position ?? area.position);
}
}
// 매 프레임 업데이트 (ambient 방출 + 파티클 물리)
instance.update(deltaTime, area.position, resolution);
}
}
// 터치 상태 갱신
this.previousTouchingAreas = currentTouchingAreas;
}
/**
*
*/
dispose(): void {
for (const [, instance] of this.instances) {
instance.dispose();
}
this.instances.clear();
this.previousTouchingAreas.clear();
// effectGroup을 부모에서 제거
if (this.effectGroup.parent) {
this.effectGroup.parent.remove(this.effectGroup);
}
}
}

View File

@ -1,102 +0,0 @@
import type { Point } from '@/types';
/**
*
*/
export interface SpriteParticle {
/** 풀 내 인덱스 */
index: number;
/** 활성 여부 */
active: boolean;
/** 정규화 좌표 위치 (0-1) */
position: Point;
/** 속도 (정규화 좌표/초) */
velocity: Point;
/** 현재 스케일 */
scale: number;
/** 방출 시 초기 스케일 (overLifetime 배율 기준값) */
initialScale: number;
/** 현재 회전 (라디안) */
rotation: number;
/** 현재 투명도 */
opacity: number;
/** 경과 시간 (초) */
age: number;
/** 수명 (초) */
lifetime: number;
/** 스프라이트 시트 프레임 누적 시간 */
frameTime: number;
/** 현재 프레임 인덱스 */
frameIndex: number;
}
/**
* (GC )
* /
*/
export class SpriteParticlePool {
private particles: SpriteParticle[];
constructor(maxParticles: number) {
// 최대 개수만큼 미리 할당
this.particles = Array.from({ length: maxParticles }, (_, i) => this.createParticle(i));
}
/** 비활성 파티클 생성 */
private createParticle(index: number): SpriteParticle {
return {
index,
active: false,
position: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
scale: 1,
initialScale: 1,
rotation: 0,
opacity: 1,
age: 0,
lifetime: 1,
frameTime: 0,
frameIndex: 0,
};
}
/**
*
* null
*/
acquire(): SpriteParticle | null {
for (const particle of this.particles) {
if (!particle.active) {
particle.active = true;
particle.age = 0;
return particle;
}
}
return null;
}
/**
*
*/
release(particle: SpriteParticle): void {
particle.active = false;
}
/**
*
*/
getActiveParticles(): SpriteParticle[] {
return this.particles.filter(p => p.active);
}
/**
*
*/
getActiveCount(): number {
let count = 0;
for (const p of this.particles) {
if (p.active) count++;
}
return count;
}
}

View File

@ -15,7 +15,7 @@ export class ThreeScene {
// 씬 생성 // 씬 생성
this.scene = new THREE.Scene(); this.scene = new THREE.Scene();
// 2D용 직교 카메라 설정 (카메라는 -z 방향, near=0 ~ far=1) // 2D용 직교 카메라 설정
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
// 렌더러 설정 // 렌더러 설정
@ -93,18 +93,10 @@ export class ThreeScene {
} }
this.mesh = new THREE.Mesh(geometry, material); this.mesh = new THREE.Mesh(geometry, material);
this.mesh.renderOrder = 0;
this.scene.add(this.mesh); this.scene.add(this.mesh);
console.log('[ThreeScene] mesh를 씬에 추가함'); console.log('[ThreeScene] mesh를 씬에 추가함');
} }
/**
* Three.js
*/
public getScene(): THREE.Scene {
return this.scene;
}
/** /**
* *
* @param updates * @param updates

View File

@ -225,6 +225,5 @@ export const useMouseInteraction = (
reset, reset,
isDragging, isDragging,
getInteractingAreaIndices, getInteractingAreaIndices,
getMouseState: getState,
}; };
}; };

View File

@ -51,17 +51,6 @@ export type {
SpringState, SpringState,
} from './types/interaction'; } from './types/interaction';
// 스프라이트 이펙트 타입
export type {
SpriteEffectTrigger,
SpriteBlendMode,
SpriteEffectConfig,
SpriteEffectArea,
SpriteEffectAreaData,
SpriteParticleOverLifetime,
SpriteSheetConfig,
} from './types/spriteEffect';
// 유틸리티 함수 // 유틸리티 함수
export { applyEasing } from './utils/easing'; export { applyEasing } from './utils/easing';
export { SHADER_CONFIG, ANIMATION_CONFIG, DEFAULT_AREA } from './utils/constants'; export { SHADER_CONFIG, ANIMATION_CONFIG, DEFAULT_AREA } from './utils/constants';
@ -82,7 +71,6 @@ export { ThreeScene } from './engine/ThreeScene';
export { ShaderManager } from './engine/ShaderManager'; export { ShaderManager } from './engine/ShaderManager';
export { AnimationLoop } from './engine/AnimationLoop'; export { AnimationLoop } from './engine/AnimationLoop';
export { SpringPhysics } from './engine/SpringPhysics'; export { SpringPhysics } from './engine/SpringPhysics';
export { SpriteEffectManager } from './engine/SpriteEffectManager';
// 훅 // 훅
export { useAnimationFrame } from './hooks/useAnimationFrame'; export { useAnimationFrame } from './hooks/useAnimationFrame';

View File

@ -1,4 +1,3 @@
export * from './area'; export * from './area';
export * from './shader'; export * from './shader';
export * from './animation'; export * from './animation';
export * from './spriteEffect';

View File

@ -1,120 +0,0 @@
import type { Point } from './area';
/** 이펙트 트리거 타입 */
export type SpriteEffectTrigger = 'ambient' | 'touch';
/** 블렌드 모드 */
export type SpriteBlendMode = 'normal' | 'additive';
/**
*
*/
export interface SpriteParticleOverLifetime {
/** 스케일 보간: [시작, 끝] 또는 [시작, 중간, 끝] */
scale?: number[];
/** 투명도 보간: [시작, 끝] 또는 [시작, 중간, 끝] */
opacity?: number[];
/** 회전 속도 (라디안/초) */
rotationSpeed?: number;
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
velocityDamping?: number;
}
/**
*
*/
export interface SpriteSheetConfig {
/** 가로 프레임 수 */
columns: number;
/** 세로 프레임 수 */
rows: number;
/** 총 프레임 수 (columns * rows 보다 적을 수 있음) */
totalFrames: number;
/** 재생 속도 (프레임/초) */
fps: number;
/** 반복 재생 여부 (기본: true) */
loop?: boolean;
}
/**
*
* (DistortionArea)
*/
export interface SpriteEffectArea {
/** 고유 식별자 */
id: string;
/** 이펙트 중심 좌표 (정규화 0-1) */
position: Point;
/** 터치 감지 반경 (정규화, 기본: 0.1) */
radius?: number;
/** 이 영역에 연결된 이펙트 설정 배열 */
effects: SpriteEffectConfig[];
}
/**
* SpriteEffectArea
* DB
*/
export interface SpriteEffectAreaData {
id: string;
position: { x: number; y: number };
radius?: number;
effects: Array<{
id: string;
trigger: SpriteEffectTrigger;
/** spriteUrl 또는 emoji 중 하나 필수 */
spriteUrl?: string;
/** 이모지 파티클 (spriteUrl 대신 사용 가능) */
emoji?: string;
blendMode?: SpriteBlendMode;
maxParticles: number;
emitRate?: number;
burstCount?: number;
lifetime: [number, number];
initialScale: [number, number];
initialSpeed: [number, number];
emitAngle?: [number, number];
emitOffset?: { x: number; y: number };
emitRadius?: number;
overLifetime?: SpriteParticleOverLifetime;
spriteSheet?: SpriteSheetConfig;
}>;
}
/**
*
*/
export interface SpriteEffectConfig {
/** 고유 식별자 */
id: string;
/** 트리거 타입 */
trigger: SpriteEffectTrigger;
/** 스프라이트 이미지 URL (emoji와 택1) */
spriteUrl?: string;
/** 이모지 파티클 (spriteUrl 대신 사용 가능) */
emoji?: string;
/** 블렌드 모드 (기본: 'normal') */
blendMode?: SpriteBlendMode;
/** 최대 파티클 수 */
maxParticles: number;
/** ambient: 초당 방출 수 */
emitRate?: number;
/** touch: 터치 시 방출 수 */
burstCount?: number;
/** [최소, 최대] 수명 (초) */
lifetime: [number, number];
/** [최소, 최대] 초기 스케일 */
initialScale: [number, number];
/** [최소, 최대] 초기 속도 */
initialSpeed: [number, number];
/** 방출 각도 범위 (도) */
emitAngle?: [number, number];
/** 영역 중심 대비 방출 오프셋 */
emitOffset?: Point;
/** 방출 범위 반경 */
emitRadius?: number;
/** 수명 기반 속성 보간 */
overLifetime?: SpriteParticleOverLifetime;
/** 스프라이트 시트 설정 (없으면 정적 이미지) */
spriteSheet?: SpriteSheetConfig;
}