Compare commits
6 Commits
c72846b06e
...
15144240b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15144240b7 | ||
|
|
77f44141a1 | ||
|
|
672dd80b9d | ||
|
|
f3c5ae3669 | ||
|
|
530e6d0396 | ||
|
|
48fdd5e17c |
@ -14,7 +14,9 @@
|
||||
"Bash(npm link:*)",
|
||||
"Bash(find:*)",
|
||||
"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": [],
|
||||
"ask": []
|
||||
|
||||
869
README.md
869
README.md
@ -1,20 +1,23 @@
|
||||
# Responsive Image Canvas
|
||||
|
||||
GPU 가속 이미지 왜곡 효과를 제공하는 React 컴포넌트 라이브러리입니다. Three.js와 GLSL 셰이더를 사용하여 실시간 이미지 왜곡 애니메이션을 구현합니다.
|
||||
GPU 가속 이미지 왜곡 효과를 제공하는 React 컴포넌트 라이브러리입니다.
|
||||
Three.js와 GLSL 셰이더를 사용하여 실시간 이미지 왜곡 애니메이션, 마우스/터치 인터랙션, 파티클 이펙트를 구현합니다.
|
||||
|
||||
## 특징
|
||||
|
||||
- 🚀 GPU 가속 렌더링 (Three.js + WebGL)
|
||||
- 🎨 최대 8개의 독립적인 왜곡 영역 지원
|
||||
- ⚡ 60fps 실시간 애니메이션
|
||||
- 🎯 정규화된 좌표계 (0.0 - 1.0)
|
||||
- 🔧 TypeScript 완벽 지원
|
||||
- 📦 ESM & CommonJS 모두 지원
|
||||
- GPU 가속 렌더링 (Three.js + WebGL)
|
||||
- 최대 8개의 독립적인 왜곡 영역 지원
|
||||
- 스프링 물리 기반 마우스/터치 인터랙션
|
||||
- 이모지 & 스프라이트 시트 파티클 이펙트
|
||||
- 모션 프리셋 & 커스텀 이징 함수
|
||||
- 렌즈 왜곡 (볼록/오목) 효과
|
||||
- 영역 편집을 위한 에디터 컴포넌트
|
||||
- TypeScript & ESM/CJS 지원
|
||||
|
||||
## 설치
|
||||
|
||||
```bash
|
||||
npm install responsive-image-canvas
|
||||
npm install @baekryang/responsive-image-canvas
|
||||
```
|
||||
|
||||
### Peer Dependencies
|
||||
@ -23,25 +26,38 @@ npm install responsive-image-canvas
|
||||
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
|
||||
import { ImageDistortion, DistortionArea } from 'responsive-image-canvas';
|
||||
import { ImageDistortion } from '@baekryang/responsive-image-canvas';
|
||||
import type { DistortionArea } from '@baekryang/responsive-image-canvas';
|
||||
|
||||
const areas: DistortionArea[] = [
|
||||
{
|
||||
id: 'area-1',
|
||||
basePoints: [
|
||||
{ x: 0.2, y: 0.2 }, // 좌상단
|
||||
{ x: 0.4, y: 0.2 }, // 우상단
|
||||
{ x: 0.4, y: 0.4 }, // 우하단
|
||||
{ x: 0.2, y: 0.4 }, // 좌하단
|
||||
{ x: 0.3, y: 0.3 }, // 좌상단
|
||||
{ x: 0.7, y: 0.3 }, // 우상단
|
||||
{ x: 0.7, y: 0.7 }, // 우하단
|
||||
{ x: 0.3, y: 0.7 }, // 좌하단
|
||||
],
|
||||
movement: {
|
||||
vectorA: { x: 0.1, y: 0.1 },
|
||||
vectorB: { x: -0.1, y: -0.1 },
|
||||
preset: 'horizontal',
|
||||
vectorA: { x: 0.1, y: 0 },
|
||||
vectorB: { x: -0.1, y: 0 },
|
||||
duration: 2.0,
|
||||
easing: 'easeInOut',
|
||||
strength: 0.15,
|
||||
},
|
||||
distortionStrength: 0.5,
|
||||
progress: 0,
|
||||
@ -51,88 +67,119 @@ const areas: DistortionArea[] = [
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div style={{ width: '800px', height: '600px' }}>
|
||||
<ImageDistortion
|
||||
imageSrc="/path/to/image.jpg"
|
||||
imageSrc="/image.jpg"
|
||||
areas={areas}
|
||||
isPlaying={true}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Props
|
||||
> **좌표계**: 모든 좌표는 **정규화 좌표(0.0 ~ 1.0)** 를 사용합니다. `(0, 0)`은 이미지 좌상단, `(1, 1)`은 우하단입니다.
|
||||
|
||||
### `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`
|
||||
|
||||
```typescript
|
||||
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; // 현재 드래그 벡터
|
||||
}
|
||||
```
|
||||
|
||||
### `Point`
|
||||
|
||||
```typescript
|
||||
interface Point {
|
||||
x: number; // 0.0 - 1.0 (정규화된 좌표)
|
||||
y: number; // 0.0 - 1.0 (정규화된 좌표)
|
||||
}
|
||||
```
|
||||
|
||||
### `DistortionMovement`
|
||||
|
||||
```typescript
|
||||
interface DistortionMovement {
|
||||
vectorA: Point; // 시작 벡터
|
||||
vectorB: Point; // 종료 벡터
|
||||
duration: number; // 지속 시간 (초)
|
||||
easing: EasingFunction; // 이징 함수
|
||||
}
|
||||
```
|
||||
|
||||
### `EasingFunction`
|
||||
|
||||
```typescript
|
||||
type EasingFunction =
|
||||
| 'linear'
|
||||
| 'easeIn'
|
||||
| 'easeOut'
|
||||
| 'easeInOut'
|
||||
| 'easeInQuad'
|
||||
| 'easeOutQuad';
|
||||
```
|
||||
|
||||
## 고급 사용법
|
||||
|
||||
### 영역 동적 추가/제거
|
||||
이미지 왜곡 및 인터랙션 렌더링을 담당하는 메인 컴포넌트입니다.
|
||||
|
||||
```tsx
|
||||
function DynamicDistortion() {
|
||||
const [areas, setAreas] = useState<DistortionArea[]>([]);
|
||||
<ImageDistortion
|
||||
imageSrc="/image.jpg"
|
||||
areas={areas}
|
||||
mouseInteraction={mouseConfig}
|
||||
spriteEffectAreas={spriteEffectAreas}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
```
|
||||
|
||||
const addArea = () => {
|
||||
const newArea: DistortionArea = {
|
||||
| Prop | 타입 | 필수 | 설명 |
|
||||
|------|------|:----:|------|
|
||||
| `imageSrc` | `string` | O | 이미지 URL |
|
||||
| `areas` | `DistortionArea[]` | O | 왜곡 영역 배열 |
|
||||
| `mouseInteraction` | `MouseInteractionConfig` | | 마우스/터치 인터랙션 설정 |
|
||||
| `spriteEffectAreas` | `SpriteEffectArea[]` | | 파티클 이펙트 영역 |
|
||||
| `isPlaying` | `boolean` | | 애니메이션 재생 여부 (기본: `true`) |
|
||||
| `style` | `CSSProperties` | | 컨테이너 스타일 |
|
||||
| `className` | `string` | | 컨테이너 클래스 |
|
||||
|
||||
### `<EditorCanvas />`
|
||||
|
||||
영역 편집을 위한 시각적 에디터 오버레이입니다. 꼭짓점 드래그, 영역 선택 등을 제공합니다.
|
||||
|
||||
```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 | 타입 | 필수 | 설명 |
|
||||
|------|------|:----:|------|
|
||||
| `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 />`
|
||||
|
||||
영역 목록 관리 및 파라미터 편집을 위한 보조 에디터 컴포넌트입니다.
|
||||
|
||||
---
|
||||
|
||||
## 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);
|
||||
```
|
||||
|
||||
#### 사용 예시
|
||||
|
||||
```tsx
|
||||
// 새 영역 추가
|
||||
const handleAddArea = () => {
|
||||
addArea({
|
||||
id: `area-${Date.now()}`,
|
||||
basePoints: [
|
||||
{ x: 0.3, y: 0.3 },
|
||||
@ -141,114 +188,624 @@ function DynamicDistortion() {
|
||||
{ x: 0.3, y: 0.7 },
|
||||
],
|
||||
movement: {
|
||||
vectorA: { x: 0.15, y: 0 },
|
||||
vectorB: { x: -0.15, y: 0 },
|
||||
duration: 3.0,
|
||||
easing: 'easeInOut',
|
||||
preset: 'none',
|
||||
vectorA: { x: 0, y: 0 },
|
||||
vectorB: { x: 0, y: 0 },
|
||||
duration: DEFAULT_AREA.DURATION,
|
||||
easing: DEFAULT_AREA.EASING,
|
||||
strength: 0.15,
|
||||
},
|
||||
distortionStrength: 0.6,
|
||||
distortionStrength: DEFAULT_AREA.DISTORTION_STRENGTH,
|
||||
progress: 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,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={addArea}>영역 추가</button>
|
||||
<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 (
|
||||
<div style={{ position: 'relative', width: imageWidth, height: imageHeight }}>
|
||||
{isEditing ? (
|
||||
<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
|
||||
imageSrc={imageSrc}
|
||||
areas={state.areas}
|
||||
mouseInteraction={mouseConfig}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 유틸리티 함수 사용
|
||||
---
|
||||
|
||||
```tsx
|
||||
import { DEFAULT_AREA, applyEasing } from 'responsive-image-canvas';
|
||||
## 타입 레퍼런스
|
||||
|
||||
// 기본 설정값 사용
|
||||
const newArea = {
|
||||
...DEFAULT_AREA,
|
||||
id: 'my-area',
|
||||
basePoints: [/* ... */],
|
||||
};
|
||||
|
||||
// 이징 함수 직접 사용
|
||||
const easedValue = applyEasing(0.5, 'easeInOut');
|
||||
console.log(easedValue); // 0.5
|
||||
```
|
||||
|
||||
## 셰이더 파일
|
||||
|
||||
패키지는 기본 셰이더 파일을 포함하고 있습니다:
|
||||
- `dist/distortion.vert.glsl` - 버텍스 셰이더
|
||||
- `dist/distortion.frag.glsl` - 프래그먼트 셰이더
|
||||
|
||||
웹 서버에서 이 파일들을 정적 파일로 제공해야 합니다.
|
||||
|
||||
### Vite 설정 예시
|
||||
### 핵심 타입
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
import { defineConfig } from 'vite';
|
||||
interface Point {
|
||||
x: number; // 0.0 ~ 1.0
|
||||
y: number; // 0.0 ~ 1.0
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
publicDir: 'public',
|
||||
// node_modules의 셰이더 파일을 복사
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: 'assets/[name].[ext]',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
셰이더 파일을 public 폴더로 복사:
|
||||
### 인터랙션 타입
|
||||
|
||||
```bash
|
||||
cp node_modules/responsive-image-canvas/dist/*.glsl public/shaders/
|
||||
```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;
|
||||
}
|
||||
```
|
||||
|
||||
## 성능 최적화
|
||||
### 파티클 이펙트 타입
|
||||
|
||||
### 1. 영역 수 제한
|
||||
최대 8개의 영역까지 지원하지만, 성능을 위해 4개 이하를 권장합니다.
|
||||
```typescript
|
||||
type SpriteEffectTrigger = 'ambient' | 'touch';
|
||||
type SpriteBlendMode = 'normal' | 'additive';
|
||||
|
||||
### 2. 이미지 크기 최적화
|
||||
큰 이미지는 성능에 영향을 줄 수 있습니다. 적절한 크기로 리사이징하세요.
|
||||
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;
|
||||
}
|
||||
|
||||
### 3. 애니메이션 일시정지
|
||||
필요하지 않을 때는 `isPlaying={false}`로 설정하세요.
|
||||
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
|
||||
|
||||
// 커스텀 모션 프리셋
|
||||
registerMotionPreset('wobble', (strength) => ({
|
||||
x: strength * Math.sin(Date.now() * 0.001),
|
||||
y: 0,
|
||||
}));
|
||||
|
||||
// 프리셋 → 벡터 변환
|
||||
const vector = presetToVector('horizontal', 0.15); // { x: 0.15, y: 0 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 제한사항
|
||||
|
||||
- WebGL을 지원하지 않는 브라우저에서는 동작하지 않습니다
|
||||
- 모바일 환경에서는 성능이 제한될 수 있습니다
|
||||
- 최대 8개의 왜곡 영역만 지원합니다
|
||||
|
||||
## 브라우저 지원
|
||||
|
||||
- Chrome 60+
|
||||
- Firefox 60+
|
||||
- Safari 12+
|
||||
- Edge 79+
|
||||
- `emoji`와 `spriteUrl`은 하나만 사용 가능합니다 (둘 다 없으면 텍스처 로드 실패)
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
|
||||
## 기여
|
||||
|
||||
이슈와 PR을 환영합니다!
|
||||
|
||||
## 관련 프로젝트
|
||||
|
||||
- [Three.js](https://threejs.org/)
|
||||
- [React Three Fiber](https://github.com/pmndrs/react-three-fiber)
|
||||
|
||||
27
dist/distortion.frag.glsl
vendored
27
dist/distortion.frag.glsl
vendored
@ -76,13 +76,28 @@ void main() {
|
||||
vec2 distortion = u_dragVectors[i] * influence * u_distortionStrengths[i];
|
||||
texCoord += distortion;
|
||||
|
||||
// 렌즈 왜곡 효과 (방사형 UV 왜곡)
|
||||
// 렌즈 왜곡 효과 (볼록: 중심 확대, 오목: 중심 축소)
|
||||
if (abs(u_lensEffects[i]) > 0.001) {
|
||||
vec2 centered = uv_local - vec2(0.5);
|
||||
float dist2 = dot(centered, centered);
|
||||
float lensK = u_lensEffects[i] * 2.0; // 강도 스케일링
|
||||
vec2 lensDistortion = centered * lensK * dist2;
|
||||
texCoord += lensDistortion * u_distortionStrengths[i];
|
||||
// 영역 중심의 글로벌 UV 좌표
|
||||
vec2 minP_area = min(min(p0, p1), min(p2, p3));
|
||||
vec2 maxP_area = max(max(p0, p1), max(p2, p3));
|
||||
vec2 areaSize = maxP_area - minP_area;
|
||||
vec2 areaCenterUV = (minP_area + maxP_area) * 0.5 / u_resolution;
|
||||
|
||||
// 현재 픽셀에서 영역 중심까지의 글로벌 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
170
dist/index.d.mts
vendored
@ -143,6 +143,120 @@ interface AnimationTicker {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스프링 물리 파라미터
|
||||
*/
|
||||
@ -220,6 +334,8 @@ interface ImageDistortionProps {
|
||||
className?: string;
|
||||
/** 마우스 인터랙션 설정 */
|
||||
mouseInteraction?: MouseInteractionConfig;
|
||||
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
|
||||
spriteEffectAreas?: SpriteEffectArea[];
|
||||
}
|
||||
/**
|
||||
* GPU 가속 이미지 왜곡 컴포넌트
|
||||
@ -341,6 +457,10 @@ interface EditorCanvasProps {
|
||||
showEditor?: boolean;
|
||||
/** 영역 선택 콜백 (비선택 영역 클릭 시) */
|
||||
onSelectArea?: (areaId: string) => void;
|
||||
/** 독립 스프라이트 이펙트 영역 */
|
||||
spriteEffectAreas?: SpriteEffectArea[];
|
||||
/** 스프라이트 이펙트 영역 업데이트 콜백 */
|
||||
onUpdateSpriteEffectArea?: (areaId: string, updates: Partial<SpriteEffectArea>) => void;
|
||||
}
|
||||
declare const EditorCanvas: React$1.FC<EditorCanvasProps>;
|
||||
|
||||
@ -524,6 +644,10 @@ declare class ThreeScene {
|
||||
* @param fragmentShader 프래그먼트 셰이더 소스
|
||||
*/
|
||||
setShaderMaterial(vertexShader: string, fragmentShader: string): void;
|
||||
/**
|
||||
* Three.js 씬 객체 반환
|
||||
*/
|
||||
getScene(): THREE.Scene;
|
||||
/**
|
||||
* 유니폼 값 업데이트
|
||||
* @param updates 업데이트할 유니폼 값들
|
||||
@ -642,6 +766,49 @@ declare class SpringPhysics {
|
||||
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을 사용한 애니메이션 루프 훅
|
||||
* @param callback 매 프레임마다 호출될 콜백 (deltaTime을 인자로 받음)
|
||||
@ -666,6 +833,7 @@ declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement |
|
||||
reset: () => void;
|
||||
isDragging: () => boolean;
|
||||
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, 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, 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 };
|
||||
|
||||
170
dist/index.d.ts
vendored
170
dist/index.d.ts
vendored
@ -143,6 +143,120 @@ interface AnimationTicker {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스프링 물리 파라미터
|
||||
*/
|
||||
@ -220,6 +334,8 @@ interface ImageDistortionProps {
|
||||
className?: string;
|
||||
/** 마우스 인터랙션 설정 */
|
||||
mouseInteraction?: MouseInteractionConfig;
|
||||
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
|
||||
spriteEffectAreas?: SpriteEffectArea[];
|
||||
}
|
||||
/**
|
||||
* GPU 가속 이미지 왜곡 컴포넌트
|
||||
@ -341,6 +457,10 @@ interface EditorCanvasProps {
|
||||
showEditor?: boolean;
|
||||
/** 영역 선택 콜백 (비선택 영역 클릭 시) */
|
||||
onSelectArea?: (areaId: string) => void;
|
||||
/** 독립 스프라이트 이펙트 영역 */
|
||||
spriteEffectAreas?: SpriteEffectArea[];
|
||||
/** 스프라이트 이펙트 영역 업데이트 콜백 */
|
||||
onUpdateSpriteEffectArea?: (areaId: string, updates: Partial<SpriteEffectArea>) => void;
|
||||
}
|
||||
declare const EditorCanvas: React$1.FC<EditorCanvasProps>;
|
||||
|
||||
@ -524,6 +644,10 @@ declare class ThreeScene {
|
||||
* @param fragmentShader 프래그먼트 셰이더 소스
|
||||
*/
|
||||
setShaderMaterial(vertexShader: string, fragmentShader: string): void;
|
||||
/**
|
||||
* Three.js 씬 객체 반환
|
||||
*/
|
||||
getScene(): THREE.Scene;
|
||||
/**
|
||||
* 유니폼 값 업데이트
|
||||
* @param updates 업데이트할 유니폼 값들
|
||||
@ -642,6 +766,49 @@ declare class SpringPhysics {
|
||||
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을 사용한 애니메이션 루프 훅
|
||||
* @param callback 매 프레임마다 호출될 콜백 (deltaTime을 인자로 받음)
|
||||
@ -666,6 +833,7 @@ declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement |
|
||||
reset: () => void;
|
||||
isDragging: () => boolean;
|
||||
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, 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, 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 };
|
||||
|
||||
584
dist/index.js
vendored
584
dist/index.js
vendored
@ -41,6 +41,7 @@ __export(index_exports, {
|
||||
SHADER_CONFIG: () => SHADER_CONFIG,
|
||||
ShaderManager: () => ShaderManager,
|
||||
SpringPhysics: () => SpringPhysics,
|
||||
SpriteEffectManager: () => SpriteEffectManager,
|
||||
ThreeScene: () => ThreeScene,
|
||||
applyEasing: () => applyEasing,
|
||||
getRegisteredPresets: () => getRegisteredPresets,
|
||||
@ -60,7 +61,7 @@ module.exports = __toCommonJS(index_exports);
|
||||
|
||||
// src/components/ImageDistortion.tsx
|
||||
var import_react4 = require("react");
|
||||
var THREE2 = __toESM(require("three"));
|
||||
var THREE4 = __toESM(require("three"));
|
||||
|
||||
// src/engine/ThreeScene.ts
|
||||
var THREE = __toESM(require("three"));
|
||||
@ -132,9 +133,16 @@ var ThreeScene = class {
|
||||
this.scene.remove(this.mesh);
|
||||
}
|
||||
this.mesh = new THREE.Mesh(geometry, material);
|
||||
this.mesh.renderOrder = 0;
|
||||
this.scene.add(this.mesh);
|
||||
console.log("[ThreeScene] mesh\uB97C \uC52C\uC5D0 \uCD94\uAC00\uD568");
|
||||
}
|
||||
/**
|
||||
* Three.js 씬 객체 반환
|
||||
*/
|
||||
getScene() {
|
||||
return this.scene;
|
||||
}
|
||||
/**
|
||||
* 유니폼 값 업데이트
|
||||
* @param updates 업데이트할 유니폼 값들
|
||||
@ -407,6 +415,401 @@ 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
|
||||
var import_react = require("react");
|
||||
var useAnimationFrame = (callback, isPlaying = true) => {
|
||||
@ -816,7 +1219,8 @@ var useMouseInteraction = (containerRef, config) => {
|
||||
updateConfig,
|
||||
reset,
|
||||
isDragging,
|
||||
getInteractingAreaIndices
|
||||
getInteractingAreaIndices,
|
||||
getMouseState: getState
|
||||
};
|
||||
};
|
||||
|
||||
@ -865,12 +1269,15 @@ var ImageDistortion = ({
|
||||
fragmentShaderPath,
|
||||
style,
|
||||
className,
|
||||
mouseInteraction
|
||||
mouseInteraction,
|
||||
spriteEffectAreas = []
|
||||
}) => {
|
||||
const containerRef = (0, import_react4.useRef)(null);
|
||||
const sceneRef = (0, import_react4.useRef)(null);
|
||||
const shaderManagerRef = (0, import_react4.useRef)(new ShaderManager());
|
||||
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 [imageLoaded, setImageLoaded] = (0, import_react4.useState)(false);
|
||||
const [currentAreas, setCurrentAreas] = (0, import_react4.useState)(areas);
|
||||
@ -890,6 +1297,22 @@ var ImageDistortion = ({
|
||||
(0, import_react4.useEffect)(() => {
|
||||
setCurrentAreas(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)(() => {
|
||||
if (mouseInteraction) {
|
||||
mouseInteractionHook.updateConfig(mouseInteraction);
|
||||
@ -928,7 +1351,7 @@ var ImageDistortion = ({
|
||||
}
|
||||
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc);
|
||||
setImageLoaded(false);
|
||||
const loader = new THREE2.TextureLoader();
|
||||
const loader = new THREE4.TextureLoader();
|
||||
loader.load(
|
||||
imageSrc,
|
||||
(texture) => {
|
||||
@ -1020,7 +1443,19 @@ var ImageDistortion = ({
|
||||
}
|
||||
return updatedAreas;
|
||||
});
|
||||
}, [isReady, mouseInteraction, mouseInteractionHook]);
|
||||
if (spriteManagerRef.current) {
|
||||
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);
|
||||
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
||||
"div",
|
||||
@ -1386,12 +1821,15 @@ var EditorCanvas = ({
|
||||
onStopDragging,
|
||||
style: customStyle,
|
||||
showEditor = true,
|
||||
onSelectArea
|
||||
onSelectArea,
|
||||
spriteEffectAreas = [],
|
||||
onUpdateSpriteEffectArea
|
||||
}) => {
|
||||
const containerRef = (0, import_react6.useRef)(null);
|
||||
const [canvasSize, setCanvasSize] = (0, import_react6.useState)({ width: 0, height: 0 });
|
||||
const [isDraggingArea, setIsDraggingArea] = (0, import_react6.useState)(false);
|
||||
const [dragStartPos, setDragStartPos] = (0, import_react6.useState)(null);
|
||||
const [draggingSpriteAreaId, setDraggingSpriteAreaId] = (0, import_react6.useState)(null);
|
||||
const editorStyle = (0, import_react6.useMemo)(() => ({
|
||||
...DEFAULT_EDITOR_CANVAS_STYLE,
|
||||
...customStyle,
|
||||
@ -1458,6 +1896,20 @@ var EditorCanvas = ({
|
||||
const x = (clientX - rect.left) / rect.width;
|
||||
const y = (clientY - rect.top) / rect.height;
|
||||
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)) {
|
||||
setIsDraggingArea(true);
|
||||
setDragStartPos(clickPoint);
|
||||
@ -1475,12 +1927,12 @@ var EditorCanvas = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea]
|
||||
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea, spriteEffectAreas, onUpdateSpriteEffectArea]
|
||||
);
|
||||
const handleMove = (0, import_react6.useCallback)(
|
||||
(e) => {
|
||||
if (!showEditor || !selectedArea || !containerRef.current) return;
|
||||
if ("touches" in e && (draggingPointIndex !== null || isDraggingArea)) {
|
||||
if (!showEditor || !containerRef.current) return;
|
||||
if ("touches" in e && (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
@ -1495,6 +1947,22 @@ var EditorCanvas = ({
|
||||
}
|
||||
const x = (clientX - rect.left) / rect.width;
|
||||
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) {
|
||||
const clampedX = Math.max(0, Math.min(1, x));
|
||||
const clampedY = Math.max(0, Math.min(1, y));
|
||||
@ -1510,7 +1978,7 @@ var EditorCanvas = ({
|
||||
setDragStartPos({ x, y });
|
||||
}
|
||||
},
|
||||
[showEditor, draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea]
|
||||
[showEditor, draggingPointIndex, isDraggingArea, draggingSpriteAreaId, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea, spriteEffectAreas, onUpdateSpriteEffectArea]
|
||||
);
|
||||
const handleUp = (0, import_react6.useCallback)(() => {
|
||||
if (draggingPointIndex !== null) {
|
||||
@ -1520,9 +1988,13 @@ var EditorCanvas = ({
|
||||
setIsDraggingArea(false);
|
||||
setDragStartPos(null);
|
||||
}
|
||||
}, [draggingPointIndex, isDraggingArea, onStopDragging]);
|
||||
if (draggingSpriteAreaId) {
|
||||
setDraggingSpriteAreaId(null);
|
||||
setDragStartPos(null);
|
||||
}
|
||||
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, onStopDragging]);
|
||||
(0, import_react6.useEffect)(() => {
|
||||
if (draggingPointIndex !== null || isDraggingArea) {
|
||||
if (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId) {
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
window.addEventListener("touchend", handleUp);
|
||||
window.addEventListener("touchcancel", handleUp);
|
||||
@ -1532,7 +2004,7 @@ var EditorCanvas = ({
|
||||
window.removeEventListener("touchcancel", handleUp);
|
||||
};
|
||||
}
|
||||
}, [draggingPointIndex, isDraggingArea, handleUp]);
|
||||
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, handleUp]);
|
||||
const uvToPixel = (u, v, points, canvasWidth, canvasHeight) => {
|
||||
const [p0, p1, p2, p3] = points;
|
||||
const leftX = p0.x * (1 - u) + p1.x * u;
|
||||
@ -1600,6 +2072,7 @@ var EditorCanvas = ({
|
||||
const getCursorStyle = () => {
|
||||
if (draggingPointIndex !== null) return "grabbing";
|
||||
if (isDraggingArea) return "grabbing";
|
||||
if (draggingSpriteAreaId) return "grabbing";
|
||||
return "default";
|
||||
};
|
||||
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
|
||||
@ -1621,7 +2094,7 @@ var EditorCanvas = ({
|
||||
onTouchStart: showEditor ? handleCanvasDown : void 0,
|
||||
onTouchMove: showEditor ? handleMove : void 0,
|
||||
children: [
|
||||
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ImageDistortion, { imageSrc, areas }),
|
||||
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ImageDistortion, { imageSrc, areas, spriteEffectAreas }),
|
||||
showEditor && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
||||
"svg",
|
||||
{
|
||||
@ -1720,6 +2193,88 @@ var EditorCanvas = ({
|
||||
},
|
||||
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}`
|
||||
);
|
||||
})
|
||||
]
|
||||
}
|
||||
@ -1738,6 +2293,7 @@ var EditorCanvas = ({
|
||||
SHADER_CONFIG,
|
||||
ShaderManager,
|
||||
SpringPhysics,
|
||||
SpriteEffectManager,
|
||||
ThreeScene,
|
||||
applyEasing,
|
||||
getRegisteredPresets,
|
||||
|
||||
2
dist/index.js.map
vendored
2
dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
583
dist/index.mjs
vendored
583
dist/index.mjs
vendored
@ -1,6 +1,6 @@
|
||||
// src/components/ImageDistortion.tsx
|
||||
import { useEffect as useEffect3, useRef as useRef4, useState as useState2, useCallback as useCallback3 } from "react";
|
||||
import * as THREE2 from "three";
|
||||
import * as THREE4 from "three";
|
||||
|
||||
// src/engine/ThreeScene.ts
|
||||
import * as THREE from "three";
|
||||
@ -72,9 +72,16 @@ var ThreeScene = class {
|
||||
this.scene.remove(this.mesh);
|
||||
}
|
||||
this.mesh = new THREE.Mesh(geometry, material);
|
||||
this.mesh.renderOrder = 0;
|
||||
this.scene.add(this.mesh);
|
||||
console.log("[ThreeScene] mesh\uB97C \uC52C\uC5D0 \uCD94\uAC00\uD568");
|
||||
}
|
||||
/**
|
||||
* Three.js 씬 객체 반환
|
||||
*/
|
||||
getScene() {
|
||||
return this.scene;
|
||||
}
|
||||
/**
|
||||
* 유니폼 값 업데이트
|
||||
* @param updates 업데이트할 유니폼 값들
|
||||
@ -347,6 +354,401 @@ 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
|
||||
import { useEffect, useRef } from "react";
|
||||
var useAnimationFrame = (callback, isPlaying = true) => {
|
||||
@ -756,7 +1158,8 @@ var useMouseInteraction = (containerRef, config) => {
|
||||
updateConfig,
|
||||
reset,
|
||||
isDragging,
|
||||
getInteractingAreaIndices
|
||||
getInteractingAreaIndices,
|
||||
getMouseState: getState
|
||||
};
|
||||
};
|
||||
|
||||
@ -805,12 +1208,15 @@ var ImageDistortion = ({
|
||||
fragmentShaderPath,
|
||||
style,
|
||||
className,
|
||||
mouseInteraction
|
||||
mouseInteraction,
|
||||
spriteEffectAreas = []
|
||||
}) => {
|
||||
const containerRef = useRef4(null);
|
||||
const sceneRef = useRef4(null);
|
||||
const shaderManagerRef = useRef4(new ShaderManager());
|
||||
const textureRef = useRef4(null);
|
||||
const spriteManagerRef = useRef4(null);
|
||||
const currentAreasRef = useRef4(areas);
|
||||
const [isReady, setIsReady] = useState2(false);
|
||||
const [imageLoaded, setImageLoaded] = useState2(false);
|
||||
const [currentAreas, setCurrentAreas] = useState2(areas);
|
||||
@ -830,6 +1236,22 @@ var ImageDistortion = ({
|
||||
useEffect3(() => {
|
||||
setCurrentAreas(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(() => {
|
||||
if (mouseInteraction) {
|
||||
mouseInteractionHook.updateConfig(mouseInteraction);
|
||||
@ -868,7 +1290,7 @@ var ImageDistortion = ({
|
||||
}
|
||||
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc);
|
||||
setImageLoaded(false);
|
||||
const loader = new THREE2.TextureLoader();
|
||||
const loader = new THREE4.TextureLoader();
|
||||
loader.load(
|
||||
imageSrc,
|
||||
(texture) => {
|
||||
@ -960,7 +1382,19 @@ var ImageDistortion = ({
|
||||
}
|
||||
return updatedAreas;
|
||||
});
|
||||
}, [isReady, mouseInteraction, mouseInteractionHook]);
|
||||
if (spriteManagerRef.current) {
|
||||
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);
|
||||
return /* @__PURE__ */ jsx(
|
||||
"div",
|
||||
@ -1326,12 +1760,15 @@ var EditorCanvas = ({
|
||||
onStopDragging,
|
||||
style: customStyle,
|
||||
showEditor = true,
|
||||
onSelectArea
|
||||
onSelectArea,
|
||||
spriteEffectAreas = [],
|
||||
onUpdateSpriteEffectArea
|
||||
}) => {
|
||||
const containerRef = useRef5(null);
|
||||
const [canvasSize, setCanvasSize] = useState4({ width: 0, height: 0 });
|
||||
const [isDraggingArea, setIsDraggingArea] = useState4(false);
|
||||
const [dragStartPos, setDragStartPos] = useState4(null);
|
||||
const [draggingSpriteAreaId, setDraggingSpriteAreaId] = useState4(null);
|
||||
const editorStyle = useMemo(() => ({
|
||||
...DEFAULT_EDITOR_CANVAS_STYLE,
|
||||
...customStyle,
|
||||
@ -1398,6 +1835,20 @@ var EditorCanvas = ({
|
||||
const x = (clientX - rect.left) / rect.width;
|
||||
const y = (clientY - rect.top) / rect.height;
|
||||
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)) {
|
||||
setIsDraggingArea(true);
|
||||
setDragStartPos(clickPoint);
|
||||
@ -1415,12 +1866,12 @@ var EditorCanvas = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea]
|
||||
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea, spriteEffectAreas, onUpdateSpriteEffectArea]
|
||||
);
|
||||
const handleMove = useCallback5(
|
||||
(e) => {
|
||||
if (!showEditor || !selectedArea || !containerRef.current) return;
|
||||
if ("touches" in e && (draggingPointIndex !== null || isDraggingArea)) {
|
||||
if (!showEditor || !containerRef.current) return;
|
||||
if ("touches" in e && (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
@ -1435,6 +1886,22 @@ var EditorCanvas = ({
|
||||
}
|
||||
const x = (clientX - rect.left) / rect.width;
|
||||
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) {
|
||||
const clampedX = Math.max(0, Math.min(1, x));
|
||||
const clampedY = Math.max(0, Math.min(1, y));
|
||||
@ -1450,7 +1917,7 @@ var EditorCanvas = ({
|
||||
setDragStartPos({ x, y });
|
||||
}
|
||||
},
|
||||
[showEditor, draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea]
|
||||
[showEditor, draggingPointIndex, isDraggingArea, draggingSpriteAreaId, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea, spriteEffectAreas, onUpdateSpriteEffectArea]
|
||||
);
|
||||
const handleUp = useCallback5(() => {
|
||||
if (draggingPointIndex !== null) {
|
||||
@ -1460,9 +1927,13 @@ var EditorCanvas = ({
|
||||
setIsDraggingArea(false);
|
||||
setDragStartPos(null);
|
||||
}
|
||||
}, [draggingPointIndex, isDraggingArea, onStopDragging]);
|
||||
if (draggingSpriteAreaId) {
|
||||
setDraggingSpriteAreaId(null);
|
||||
setDragStartPos(null);
|
||||
}
|
||||
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, onStopDragging]);
|
||||
useEffect4(() => {
|
||||
if (draggingPointIndex !== null || isDraggingArea) {
|
||||
if (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId) {
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
window.addEventListener("touchend", handleUp);
|
||||
window.addEventListener("touchcancel", handleUp);
|
||||
@ -1472,7 +1943,7 @@ var EditorCanvas = ({
|
||||
window.removeEventListener("touchcancel", handleUp);
|
||||
};
|
||||
}
|
||||
}, [draggingPointIndex, isDraggingArea, handleUp]);
|
||||
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, handleUp]);
|
||||
const uvToPixel = (u, v, points, canvasWidth, canvasHeight) => {
|
||||
const [p0, p1, p2, p3] = points;
|
||||
const leftX = p0.x * (1 - u) + p1.x * u;
|
||||
@ -1540,6 +2011,7 @@ var EditorCanvas = ({
|
||||
const getCursorStyle = () => {
|
||||
if (draggingPointIndex !== null) return "grabbing";
|
||||
if (isDraggingArea) return "grabbing";
|
||||
if (draggingSpriteAreaId) return "grabbing";
|
||||
return "default";
|
||||
};
|
||||
return /* @__PURE__ */ jsxs3(
|
||||
@ -1561,7 +2033,7 @@ var EditorCanvas = ({
|
||||
onTouchStart: showEditor ? handleCanvasDown : void 0,
|
||||
onTouchMove: showEditor ? handleMove : void 0,
|
||||
children: [
|
||||
/* @__PURE__ */ jsx4(ImageDistortion, { imageSrc, areas }),
|
||||
/* @__PURE__ */ jsx4(ImageDistortion, { imageSrc, areas, spriteEffectAreas }),
|
||||
showEditor && /* @__PURE__ */ jsx4(
|
||||
"svg",
|
||||
{
|
||||
@ -1660,6 +2132,88 @@ var EditorCanvas = ({
|
||||
},
|
||||
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}`
|
||||
);
|
||||
})
|
||||
]
|
||||
}
|
||||
@ -1677,6 +2231,7 @@ export {
|
||||
SHADER_CONFIG,
|
||||
ShaderManager,
|
||||
SpringPhysics,
|
||||
SpriteEffectManager,
|
||||
ThreeScene,
|
||||
applyEasing,
|
||||
getRegisteredPresets,
|
||||
|
||||
2
dist/index.mjs.map
vendored
2
dist/index.mjs.map
vendored
File diff suppressed because one or more lines are too long
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@baekryang/responsive-image-canvas",
|
||||
"version": "1.0.5",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@baekryang/responsive-image-canvas",
|
||||
"version": "1.0.5",
|
||||
"version": "1.3.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.2",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@baekryang/responsive-image-canvas",
|
||||
"version": "1.2.10",
|
||||
"version": "1.5.2",
|
||||
"publishConfig": {
|
||||
"registry": "https://git.bnovalab.com/api/packages/baekryang/npm/"
|
||||
},
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { type DistortionArea } from '@/types';
|
||||
import {type DistortionArea, SpriteEffectArea} from '@/types';
|
||||
import { ThreeScene } from '@/engine/ThreeScene';
|
||||
import { ShaderManager } from '@/engine/ShaderManager';
|
||||
import { AnimationLoop } from '@/engine/AnimationLoop';
|
||||
import { SpriteEffectManager } from '@/engine/SpriteEffectManager';
|
||||
import { useAnimationFrame } from '@/hooks/useAnimationFrame';
|
||||
import { useMouseInteraction } from '@/hooks/useMouseInteraction';
|
||||
import { SHADER_CONFIG } from '@/utils/constants';
|
||||
@ -27,6 +28,8 @@ export interface ImageDistortionProps {
|
||||
className?: string;
|
||||
/** 마우스 인터랙션 설정 */
|
||||
mouseInteraction?: MouseInteractionConfig;
|
||||
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
|
||||
spriteEffectAreas?: SpriteEffectArea[];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -41,11 +44,14 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
||||
style,
|
||||
className,
|
||||
mouseInteraction,
|
||||
spriteEffectAreas = [],
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sceneRef = useRef<ThreeScene | null>(null);
|
||||
const shaderManagerRef = useRef<ShaderManager>(new ShaderManager());
|
||||
const textureRef = useRef<THREE.Texture | null>(null);
|
||||
const spriteManagerRef = useRef<SpriteEffectManager | null>(null);
|
||||
const currentAreasRef = useRef<DistortionArea[]>(areas);
|
||||
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
@ -71,6 +77,28 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
||||
setCurrentAreas(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(() => {
|
||||
if (mouseInteraction) {
|
||||
@ -87,7 +115,7 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ImageDistortion] 초기화 시작');
|
||||
console.log('[ImageDistortion] v1.5.1 초기화 시작');
|
||||
const scene = new ThreeScene(containerRef.current);
|
||||
sceneRef.current = scene;
|
||||
|
||||
@ -245,7 +273,25 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
||||
|
||||
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);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, {useRef, useEffect, useState, useCallback, useMemo} from 'react';
|
||||
import {DistortionArea, Point} from '@/types';
|
||||
import type {SpriteEffectArea} from '@/types/spriteEffect';
|
||||
import {ImageDistortion} from '@/components/ImageDistortion';
|
||||
import {EditorCanvasStyle} from '../types';
|
||||
import {DEFAULT_EDITOR_CANVAS_STYLE} from '@/editor';
|
||||
@ -21,6 +22,10 @@ export interface EditorCanvasProps {
|
||||
showEditor?: boolean;
|
||||
/** 영역 선택 콜백 (비선택 영역 클릭 시) */
|
||||
onSelectArea?: (areaId: string) => void;
|
||||
/** 독립 스프라이트 이펙트 영역 */
|
||||
spriteEffectAreas?: SpriteEffectArea[];
|
||||
/** 스프라이트 이펙트 영역 업데이트 콜백 */
|
||||
onUpdateSpriteEffectArea?: (areaId: string, updates: Partial<SpriteEffectArea>) => void;
|
||||
}
|
||||
|
||||
export const EditorCanvas: React.FC<EditorCanvasProps> = ({
|
||||
@ -37,11 +42,14 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
|
||||
style: customStyle,
|
||||
showEditor = true,
|
||||
onSelectArea,
|
||||
spriteEffectAreas = [],
|
||||
onUpdateSpriteEffectArea,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [canvasSize, setCanvasSize] = useState({width: 0, height: 0});
|
||||
const [isDraggingArea, setIsDraggingArea] = useState(false);
|
||||
const [dragStartPos, setDragStartPos] = useState<Point | null>(null);
|
||||
const [draggingSpriteAreaId, setDraggingSpriteAreaId] = useState<string | null>(null);
|
||||
|
||||
// 스타일 병합 (커스텀 스타일 우선)
|
||||
const editorStyle = useMemo(() => ({
|
||||
@ -134,6 +142,22 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
|
||||
const y = (clientY - rect.top) / rect.height;
|
||||
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)) {
|
||||
setIsDraggingArea(true);
|
||||
@ -155,17 +179,17 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon, onSelectArea]
|
||||
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon, onSelectArea, spriteEffectAreas, onUpdateSpriteEffectArea]
|
||||
);
|
||||
|
||||
// 이동 (마우스/터치 공통)
|
||||
const handleMove = useCallback(
|
||||
(e: React.MouseEvent | React.TouchEvent) => {
|
||||
// 에디터가 숨겨진 상태면 동작하지 않음
|
||||
if (!showEditor || !selectedArea || !containerRef.current) return;
|
||||
if (!showEditor || !containerRef.current) return;
|
||||
|
||||
// 터치 이벤트면 스크롤 방지
|
||||
if ('touches' in e && (draggingPointIndex !== null || isDraggingArea)) {
|
||||
if ('touches' in e && (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
@ -185,6 +209,25 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
|
||||
const x = (clientX - rect.left) / rect.width;
|
||||
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) {
|
||||
const clampedX = Math.max(0, Math.min(1, x));
|
||||
@ -206,7 +249,7 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
|
||||
setDragStartPos({ x, y }); // 다음 프레임을 위해 시작 위치 업데이트
|
||||
}
|
||||
},
|
||||
[showEditor, draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea]
|
||||
[showEditor, draggingPointIndex, isDraggingArea, draggingSpriteAreaId, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea, spriteEffectAreas, onUpdateSpriteEffectArea]
|
||||
);
|
||||
|
||||
// 업 (마우스/터치 공통)
|
||||
@ -218,11 +261,15 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
|
||||
setIsDraggingArea(false);
|
||||
setDragStartPos(null);
|
||||
}
|
||||
}, [draggingPointIndex, isDraggingArea, onStopDragging]);
|
||||
if (draggingSpriteAreaId) {
|
||||
setDraggingSpriteAreaId(null);
|
||||
setDragStartPos(null);
|
||||
}
|
||||
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, onStopDragging]);
|
||||
|
||||
// 전역 업 이벤트 (마우스/터치)
|
||||
useEffect(() => {
|
||||
if (draggingPointIndex !== null || isDraggingArea) {
|
||||
if (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId) {
|
||||
window.addEventListener('mouseup', handleUp);
|
||||
window.addEventListener('touchend', handleUp);
|
||||
window.addEventListener('touchcancel', handleUp);
|
||||
@ -232,7 +279,7 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
|
||||
window.removeEventListener('touchcancel', handleUp);
|
||||
};
|
||||
}
|
||||
}, [draggingPointIndex, isDraggingArea, handleUp]);
|
||||
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, handleUp]);
|
||||
|
||||
// UV 좌표를 픽셀 좌표로 변환 (셰이더와 동일한 bilinear interpolation)
|
||||
const uvToPixel = (
|
||||
@ -337,6 +384,7 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
|
||||
const getCursorStyle = () => {
|
||||
if (draggingPointIndex !== null) return 'grabbing';
|
||||
if (isDraggingArea) return 'grabbing';
|
||||
if (draggingSpriteAreaId) return 'grabbing';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
@ -358,7 +406,7 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
|
||||
onTouchMove={showEditor ? handleMove : undefined}
|
||||
>
|
||||
{/* ImageDistortion 컴포넌트 */}
|
||||
<ImageDistortion imageSrc={imageSrc} areas={areas}/>
|
||||
<ImageDistortion imageSrc={imageSrc} areas={areas} spriteEffectAreas={spriteEffectAreas}/>
|
||||
|
||||
{/* 오버레이 SVG - 에디터 모드일 때만 표시 */}
|
||||
{showEditor && (
|
||||
@ -464,6 +512,83 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
|
||||
</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',
|
||||
}}
|
||||
>
|
||||
✨
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
392
src/engine/SpriteEffectInstance.ts
Normal file
392
src/engine/SpriteEffectInstance.ts
Normal file
@ -0,0 +1,392 @@
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
146
src/engine/SpriteEffectManager.ts
Normal file
146
src/engine/SpriteEffectManager.ts
Normal file
@ -0,0 +1,146 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/engine/SpriteParticlePool.ts
Normal file
102
src/engine/SpriteParticlePool.ts
Normal file
@ -0,0 +1,102 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,7 @@ export class ThreeScene {
|
||||
// 씬 생성
|
||||
this.scene = new THREE.Scene();
|
||||
|
||||
// 2D용 직교 카메라 설정
|
||||
// 2D용 직교 카메라 설정 (카메라는 -z 방향, near=0 ~ far=1)
|
||||
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
// 렌더러 설정
|
||||
@ -93,10 +93,18 @@ export class ThreeScene {
|
||||
}
|
||||
|
||||
this.mesh = new THREE.Mesh(geometry, material);
|
||||
this.mesh.renderOrder = 0;
|
||||
this.scene.add(this.mesh);
|
||||
console.log('[ThreeScene] mesh를 씬에 추가함');
|
||||
}
|
||||
|
||||
/**
|
||||
* Three.js 씬 객체 반환
|
||||
*/
|
||||
public getScene(): THREE.Scene {
|
||||
return this.scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유니폼 값 업데이트
|
||||
* @param updates 업데이트할 유니폼 값들
|
||||
|
||||
@ -225,5 +225,6 @@ export const useMouseInteraction = (
|
||||
reset,
|
||||
isDragging,
|
||||
getInteractingAreaIndices,
|
||||
getMouseState: getState,
|
||||
};
|
||||
};
|
||||
|
||||
12
src/index.ts
12
src/index.ts
@ -51,6 +51,17 @@ export type {
|
||||
SpringState,
|
||||
} from './types/interaction';
|
||||
|
||||
// 스프라이트 이펙트 타입
|
||||
export type {
|
||||
SpriteEffectTrigger,
|
||||
SpriteBlendMode,
|
||||
SpriteEffectConfig,
|
||||
SpriteEffectArea,
|
||||
SpriteEffectAreaData,
|
||||
SpriteParticleOverLifetime,
|
||||
SpriteSheetConfig,
|
||||
} from './types/spriteEffect';
|
||||
|
||||
// 유틸리티 함수
|
||||
export { applyEasing } from './utils/easing';
|
||||
export { SHADER_CONFIG, ANIMATION_CONFIG, DEFAULT_AREA } from './utils/constants';
|
||||
@ -71,6 +82,7 @@ export { ThreeScene } from './engine/ThreeScene';
|
||||
export { ShaderManager } from './engine/ShaderManager';
|
||||
export { AnimationLoop } from './engine/AnimationLoop';
|
||||
export { SpringPhysics } from './engine/SpringPhysics';
|
||||
export { SpriteEffectManager } from './engine/SpriteEffectManager';
|
||||
|
||||
// 훅
|
||||
export { useAnimationFrame } from './hooks/useAnimationFrame';
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './area';
|
||||
export * from './shader';
|
||||
export * from './animation';
|
||||
export * from './spriteEffect';
|
||||
120
src/types/spriteEffect.ts
Normal file
120
src/types/spriteEffect.ts
Normal file
@ -0,0 +1,120 @@
|
||||
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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user