Refactor sprite effects to be independent and support sprite sheets
- 스프라이트 이펙트를 왜곡 영역에서 분리하여 독립적인 영역으로 관리 - 스프라이트 시트 애니메이션 기능 추가 및 UV 제어 로직 구현 - 에디터 내 스프라이트 이펙트 영역 시각화 및 드래그 이동 기능 추가 - 이펙트 감지 방식을 다각형에서 원형(Radius) 기반으로 변경 - 관련 타입 정의 및 매니저 클래스 리팩토링
This commit is contained in:
parent
48fdd5e17c
commit
530e6d0396
@ -14,7 +14,8 @@
|
||||
"Bash(npm link:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(nul)",
|
||||
"Bash(cd:*)"
|
||||
"Bash(cd:*)",
|
||||
"Bash(ls -la /d/Projects/WebstormProjects/raonnuri/src/app/\\\\[locale\\\\]/)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
184
dist/index.d.mts
vendored
184
dist/index.d.mts
vendored
@ -1,57 +1,6 @@
|
||||
import React$1 from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
/** 이펙트 트리거 타입 */
|
||||
type SpriteEffectTrigger = 'ambient' | 'touch';
|
||||
/** 블렌드 모드 */
|
||||
type SpriteBlendMode = 'normal' | 'additive';
|
||||
/**
|
||||
* 수명 기반 파티클 속성 보간 설정
|
||||
*/
|
||||
interface SpriteParticleOverLifetime {
|
||||
/** [시작, 끝] 스케일 */
|
||||
scale?: [number, number];
|
||||
/** [시작, 끝] 투명도 */
|
||||
opacity?: [number, number];
|
||||
/** 회전 속도 (라디안/초) */
|
||||
rotationSpeed?: number;
|
||||
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
|
||||
velocityDamping?: number;
|
||||
}
|
||||
/**
|
||||
* 스프라이트 이펙트 설정
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정규화된 좌표계의 2D 포인트 (0.0 - 1.0)
|
||||
*/
|
||||
@ -126,8 +75,6 @@ interface DistortionArea {
|
||||
};
|
||||
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
|
||||
snapSteps?: number;
|
||||
/** 스프라이트 이펙트 설정 배열 */
|
||||
spriteEffects?: SpriteEffectConfig[];
|
||||
}
|
||||
/**
|
||||
* 영역 충돌 감지를 위한 경계 상자
|
||||
@ -196,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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스프링 물리 파라미터
|
||||
*/
|
||||
@ -273,6 +334,8 @@ interface ImageDistortionProps {
|
||||
className?: string;
|
||||
/** 마우스 인터랙션 설정 */
|
||||
mouseInteraction?: MouseInteractionConfig;
|
||||
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
|
||||
spriteEffectAreas?: SpriteEffectArea[];
|
||||
}
|
||||
/**
|
||||
* GPU 가속 이미지 왜곡 컴포넌트
|
||||
@ -394,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>;
|
||||
|
||||
@ -711,6 +778,7 @@ interface SpriteEffectTouchState {
|
||||
/**
|
||||
* 스프라이트 이펙트 전체 관리자
|
||||
* ImageDistortion 컴포넌트에서 생성하여 사용하는 최상위 진입점
|
||||
* 왜곡 영역(DistortionArea)과 독립적으로 이펙트 영역을 관리
|
||||
*/
|
||||
declare class SpriteEffectManager {
|
||||
/** 모든 이펙트 메쉬를 담는 그룹 */
|
||||
@ -725,16 +793,16 @@ declare class SpriteEffectManager {
|
||||
*/
|
||||
attachToScene(scene: THREE.Scene): void;
|
||||
/**
|
||||
* 영역의 spriteEffects 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
* 이펙트 영역 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
*/
|
||||
syncEffects(areas: DistortionArea[]): void;
|
||||
syncEffects(effectAreas: SpriteEffectArea[]): void;
|
||||
/**
|
||||
* 매 프레임 업데이트
|
||||
* @param areas 현재 영역 배열
|
||||
* @param effectAreas 이펙트 영역 배열
|
||||
* @param deltaTime 초 단위 프레임 시간
|
||||
* @param touchState 마우스/터치 상태
|
||||
*/
|
||||
update(areas: DistortionArea[], deltaTime: number, touchState: SpriteEffectTouchState): void;
|
||||
update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState): void;
|
||||
/**
|
||||
* 리소스 정리
|
||||
*/
|
||||
@ -768,4 +836,4 @@ declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement |
|
||||
getMouseState: () => MouseState;
|
||||
};
|
||||
|
||||
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, AreaList, type AreaListProps, type AreaOutlineStyle, type BuiltInMotionPreset, type CenterPointStyle, type CircleLevelStyle, DEFAULT_AREA, DEFAULT_EDITOR_CANVAS_STYLE, type DistortionArea, type DistortionMovement, type EasingFunction, type EditMode, EditorCanvas, type EditorCanvasProps, type EditorCanvasStyle, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MotionPresetDefinition, type MouseInteractionConfig, type MouseState, ParameterPanel, type ParameterPanelProps, type Point, type PointHandleStyle, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, type SpriteBlendMode, type SpriteEffectConfig, SpriteEffectManager, type SpriteEffectTrigger, type SpriteParticleOverLifetime, 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 };
|
||||
|
||||
184
dist/index.d.ts
vendored
184
dist/index.d.ts
vendored
@ -1,57 +1,6 @@
|
||||
import React$1 from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
/** 이펙트 트리거 타입 */
|
||||
type SpriteEffectTrigger = 'ambient' | 'touch';
|
||||
/** 블렌드 모드 */
|
||||
type SpriteBlendMode = 'normal' | 'additive';
|
||||
/**
|
||||
* 수명 기반 파티클 속성 보간 설정
|
||||
*/
|
||||
interface SpriteParticleOverLifetime {
|
||||
/** [시작, 끝] 스케일 */
|
||||
scale?: [number, number];
|
||||
/** [시작, 끝] 투명도 */
|
||||
opacity?: [number, number];
|
||||
/** 회전 속도 (라디안/초) */
|
||||
rotationSpeed?: number;
|
||||
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
|
||||
velocityDamping?: number;
|
||||
}
|
||||
/**
|
||||
* 스프라이트 이펙트 설정
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정규화된 좌표계의 2D 포인트 (0.0 - 1.0)
|
||||
*/
|
||||
@ -126,8 +75,6 @@ interface DistortionArea {
|
||||
};
|
||||
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
|
||||
snapSteps?: number;
|
||||
/** 스프라이트 이펙트 설정 배열 */
|
||||
spriteEffects?: SpriteEffectConfig[];
|
||||
}
|
||||
/**
|
||||
* 영역 충돌 감지를 위한 경계 상자
|
||||
@ -196,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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스프링 물리 파라미터
|
||||
*/
|
||||
@ -273,6 +334,8 @@ interface ImageDistortionProps {
|
||||
className?: string;
|
||||
/** 마우스 인터랙션 설정 */
|
||||
mouseInteraction?: MouseInteractionConfig;
|
||||
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
|
||||
spriteEffectAreas?: SpriteEffectArea[];
|
||||
}
|
||||
/**
|
||||
* GPU 가속 이미지 왜곡 컴포넌트
|
||||
@ -394,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>;
|
||||
|
||||
@ -711,6 +778,7 @@ interface SpriteEffectTouchState {
|
||||
/**
|
||||
* 스프라이트 이펙트 전체 관리자
|
||||
* ImageDistortion 컴포넌트에서 생성하여 사용하는 최상위 진입점
|
||||
* 왜곡 영역(DistortionArea)과 독립적으로 이펙트 영역을 관리
|
||||
*/
|
||||
declare class SpriteEffectManager {
|
||||
/** 모든 이펙트 메쉬를 담는 그룹 */
|
||||
@ -725,16 +793,16 @@ declare class SpriteEffectManager {
|
||||
*/
|
||||
attachToScene(scene: THREE.Scene): void;
|
||||
/**
|
||||
* 영역의 spriteEffects 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
* 이펙트 영역 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
*/
|
||||
syncEffects(areas: DistortionArea[]): void;
|
||||
syncEffects(effectAreas: SpriteEffectArea[]): void;
|
||||
/**
|
||||
* 매 프레임 업데이트
|
||||
* @param areas 현재 영역 배열
|
||||
* @param effectAreas 이펙트 영역 배열
|
||||
* @param deltaTime 초 단위 프레임 시간
|
||||
* @param touchState 마우스/터치 상태
|
||||
*/
|
||||
update(areas: DistortionArea[], deltaTime: number, touchState: SpriteEffectTouchState): void;
|
||||
update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState): void;
|
||||
/**
|
||||
* 리소스 정리
|
||||
*/
|
||||
@ -768,4 +836,4 @@ declare const useMouseInteraction: (containerRef: React.RefObject<HTMLElement |
|
||||
getMouseState: () => MouseState;
|
||||
};
|
||||
|
||||
export { ANIMATION_CONFIG, AnimationLoop, type AnimationState, type AnimationTicker, type AreaBounds, AreaList, type AreaListProps, type AreaOutlineStyle, type BuiltInMotionPreset, type CenterPointStyle, type CircleLevelStyle, DEFAULT_AREA, DEFAULT_EDITOR_CANVAS_STYLE, type DistortionArea, type DistortionMovement, type EasingFunction, type EditMode, EditorCanvas, type EditorCanvasProps, type EditorCanvasStyle, type EditorState, ImageDistortion, type ImageDistortionProps, type MotionPreset, type MotionPresetDefinition, type MouseInteractionConfig, type MouseState, ParameterPanel, type ParameterPanelProps, type Point, type PointHandleStyle, SHADER_CONFIG, type ShaderConfig, ShaderManager, type ShaderUniforms, SpringPhysics, type SpringPhysicsConfig, type SpringState, type SpriteBlendMode, type SpriteEffectConfig, SpriteEffectManager, type SpriteEffectTrigger, type SpriteParticleOverLifetime, 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 };
|
||||
|
||||
281
dist/index.js
vendored
281
dist/index.js
vendored
@ -437,7 +437,9 @@ var SpriteParticlePool = class {
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
age: 0,
|
||||
lifetime: 1
|
||||
lifetime: 1,
|
||||
frameTime: 0,
|
||||
frameIndex: 0
|
||||
};
|
||||
}
|
||||
/**
|
||||
@ -486,6 +488,12 @@ var SpriteEffectInstance = class {
|
||||
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();
|
||||
@ -514,11 +522,25 @@ var SpriteEffectInstance = class {
|
||||
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) {
|
||||
mesh.material.map = texture;
|
||||
mesh.material.needsUpdate = true;
|
||||
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) => {
|
||||
@ -555,6 +577,8 @@ var SpriteEffectInstance = class {
|
||||
particle.opacity = 1;
|
||||
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
|
||||
particle.age = 0;
|
||||
particle.frameTime = 0;
|
||||
particle.frameIndex = 0;
|
||||
}
|
||||
/**
|
||||
* ambient 모드: 매 프레임 누적기 기반 방출
|
||||
@ -578,11 +602,6 @@ var SpriteEffectInstance = class {
|
||||
this.emitOne(center);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 매 프레임 업데이트
|
||||
* @param deltaTime 초 단위 프레임 시간
|
||||
* @param emitCenter 방출 중심 (정규화 좌표 0-1)
|
||||
*/
|
||||
update(deltaTime, emitCenter) {
|
||||
if (!this.ready) return;
|
||||
if (this.config.trigger === "ambient") {
|
||||
@ -590,6 +609,10 @@ var SpriteEffectInstance = class {
|
||||
}
|
||||
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) {
|
||||
@ -614,11 +637,32 @@ var SpriteEffectInstance = class {
|
||||
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축 반전
|
||||
@ -633,11 +677,20 @@ var SpriteEffectInstance = class {
|
||||
mesh.visible = true;
|
||||
mesh.position.x = particle.position.x * 2 - 1;
|
||||
mesh.position.y = -(particle.position.y * 2 - 1);
|
||||
mesh.position.z = 0.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
|
||||
);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 텍스처 로딩 완료 여부
|
||||
@ -665,22 +718,10 @@ var SpriteEffectInstance = class {
|
||||
};
|
||||
|
||||
// src/engine/SpriteEffectManager.ts
|
||||
var isPointInPolygon = (point, polygon) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x, yi = polygon[i].y;
|
||||
const xj = polygon[j].x, yj = polygon[j].y;
|
||||
const intersect = yi > point.y !== yj > point.y && point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
var getAreaCenter = (area) => {
|
||||
const pts = area.basePoints;
|
||||
return {
|
||||
x: (pts[0].x + pts[1].x + pts[2].x + pts[3].x) / 4,
|
||||
y: (pts[0].y + pts[1].y + pts[2].y + pts[3].y) / 4
|
||||
};
|
||||
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() {
|
||||
@ -698,16 +739,17 @@ var SpriteEffectManager = class {
|
||||
scene.add(this.effectGroup);
|
||||
}
|
||||
/**
|
||||
* 영역의 spriteEffects 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
* 이펙트 영역 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
*/
|
||||
syncEffects(areas) {
|
||||
syncEffects(effectAreas) {
|
||||
console.log("[SpriteEffectManager] syncEffects \uD638\uCD9C:", effectAreas.length, "\uAC1C \uC601\uC5ED");
|
||||
const activeKeys = /* @__PURE__ */ new Set();
|
||||
for (const area of areas) {
|
||||
if (!area.spriteEffects) continue;
|
||||
for (const effectConfig of area.spriteEffects) {
|
||||
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);
|
||||
@ -723,33 +765,32 @@ var SpriteEffectManager = class {
|
||||
}
|
||||
/**
|
||||
* 매 프레임 업데이트
|
||||
* @param areas 현재 영역 배열
|
||||
* @param effectAreas 이펙트 영역 배열
|
||||
* @param deltaTime 초 단위 프레임 시간
|
||||
* @param touchState 마우스/터치 상태
|
||||
*/
|
||||
update(areas, deltaTime, touchState) {
|
||||
update(effectAreas, deltaTime, touchState) {
|
||||
const currentTouchingAreas = /* @__PURE__ */ new Set();
|
||||
if (touchState.isDragging && touchState.position) {
|
||||
for (const area of areas) {
|
||||
if (isPointInPolygon(touchState.position, area.basePoints)) {
|
||||
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 areas) {
|
||||
if (!area.spriteEffects) continue;
|
||||
const center = getAreaCenter(area);
|
||||
for (const effectConfig of area.spriteEffects) {
|
||||
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 ?? center);
|
||||
instance.triggerBurst(touchState.position ?? area.position);
|
||||
}
|
||||
}
|
||||
instance.update(deltaTime, center);
|
||||
instance.update(deltaTime, area.position);
|
||||
}
|
||||
}
|
||||
this.previousTouchingAreas = currentTouchingAreas;
|
||||
@ -1039,7 +1080,7 @@ var SpringPhysics = class {
|
||||
};
|
||||
|
||||
// src/hooks/useMouseInteraction.ts
|
||||
var isPointInPolygon2 = (point, polygon) => {
|
||||
var isPointInPolygon = (point, polygon) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x, yi = polygon[i].y;
|
||||
@ -1072,7 +1113,7 @@ var useMouseInteraction = (containerRef, config) => {
|
||||
if (mouseState.isDragging && mouseState.position) {
|
||||
const currentlyInAreas = /* @__PURE__ */ new Set();
|
||||
for (let i = 0; i < areas.length; i++) {
|
||||
if (isPointInPolygon2(mouseState.position, areas[i].basePoints)) {
|
||||
if (isPointInPolygon(mouseState.position, areas[i].basePoints)) {
|
||||
currentlyInAreas.add(i);
|
||||
if (!interactingAreaIndices.has(i)) {
|
||||
getSpringPhysics(i, areas[i]).reset();
|
||||
@ -1228,7 +1269,8 @@ var ImageDistortion = ({
|
||||
fragmentShaderPath,
|
||||
style,
|
||||
className,
|
||||
mouseInteraction
|
||||
mouseInteraction,
|
||||
spriteEffectAreas = []
|
||||
}) => {
|
||||
const containerRef = (0, import_react4.useRef)(null);
|
||||
const sceneRef = (0, import_react4.useRef)(null);
|
||||
@ -1269,8 +1311,8 @@ var ImageDistortion = ({
|
||||
};
|
||||
}, [isReady]);
|
||||
(0, import_react4.useEffect)(() => {
|
||||
spriteManagerRef.current?.syncEffects(currentAreas);
|
||||
}, [currentAreas]);
|
||||
spriteManagerRef.current?.syncEffects(spriteEffectAreas);
|
||||
}, [spriteEffectAreas, isReady]);
|
||||
(0, import_react4.useEffect)(() => {
|
||||
if (mouseInteraction) {
|
||||
mouseInteractionHook.updateConfig(mouseInteraction);
|
||||
@ -1404,15 +1446,16 @@ var ImageDistortion = ({
|
||||
if (spriteManagerRef.current) {
|
||||
const mouseState = mouseInteractionHook.getMouseState();
|
||||
spriteManagerRef.current.update(
|
||||
currentAreasRef.current,
|
||||
spriteEffectAreas,
|
||||
deltaTime,
|
||||
{
|
||||
position: mouseState.position ?? null,
|
||||
isDragging: mouseState.isDragging
|
||||
}
|
||||
);
|
||||
sceneRef.current?.render();
|
||||
}
|
||||
}, [isReady, mouseInteraction, mouseInteractionHook]);
|
||||
}, [isReady, mouseInteraction, mouseInteractionHook, spriteEffectAreas]);
|
||||
useAnimationFrame(animationCallback, true);
|
||||
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
||||
"div",
|
||||
@ -1778,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,
|
||||
@ -1816,7 +1862,7 @@ var EditorCanvas = ({
|
||||
};
|
||||
}, []);
|
||||
const selectedArea = areas.find((a) => a.id === selectedAreaId);
|
||||
const isPointInPolygon3 = (0, import_react6.useCallback)((point, polygon) => {
|
||||
const isPointInPolygon2 = (0, import_react6.useCallback)((point, polygon) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x, yi = polygon[i].y;
|
||||
@ -1850,7 +1896,21 @@ var EditorCanvas = ({
|
||||
const x = (clientX - rect.left) / rect.width;
|
||||
const y = (clientY - rect.top) / rect.height;
|
||||
const clickPoint = { x, y };
|
||||
if (selectedArea && isPointInPolygon3(clickPoint, selectedArea.basePoints)) {
|
||||
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);
|
||||
e.preventDefault();
|
||||
@ -1859,7 +1919,7 @@ var EditorCanvas = ({
|
||||
if (onSelectArea) {
|
||||
for (let i = areas.length - 1; i >= 0; i--) {
|
||||
const area = areas[i];
|
||||
if (area.id !== selectedAreaId && isPointInPolygon3(clickPoint, area.basePoints)) {
|
||||
if (area.id !== selectedAreaId && isPointInPolygon2(clickPoint, area.basePoints)) {
|
||||
onSelectArea(area.id);
|
||||
e.preventDefault();
|
||||
return;
|
||||
@ -1867,12 +1927,12 @@ var EditorCanvas = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon3, 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();
|
||||
@ -1887,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));
|
||||
@ -1902,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) {
|
||||
@ -1912,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);
|
||||
@ -1924,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;
|
||||
@ -1992,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)(
|
||||
@ -2013,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",
|
||||
{
|
||||
@ -2112,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}`
|
||||
);
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
2
dist/index.js.map
vendored
2
dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
281
dist/index.mjs
vendored
281
dist/index.mjs
vendored
@ -376,7 +376,9 @@ var SpriteParticlePool = class {
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
age: 0,
|
||||
lifetime: 1
|
||||
lifetime: 1,
|
||||
frameTime: 0,
|
||||
frameIndex: 0
|
||||
};
|
||||
}
|
||||
/**
|
||||
@ -425,6 +427,12 @@ var SpriteEffectInstance = class {
|
||||
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();
|
||||
@ -453,11 +461,25 @@ var SpriteEffectInstance = class {
|
||||
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) {
|
||||
mesh.material.map = texture;
|
||||
mesh.material.needsUpdate = true;
|
||||
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) => {
|
||||
@ -494,6 +516,8 @@ var SpriteEffectInstance = class {
|
||||
particle.opacity = 1;
|
||||
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
|
||||
particle.age = 0;
|
||||
particle.frameTime = 0;
|
||||
particle.frameIndex = 0;
|
||||
}
|
||||
/**
|
||||
* ambient 모드: 매 프레임 누적기 기반 방출
|
||||
@ -517,11 +541,6 @@ var SpriteEffectInstance = class {
|
||||
this.emitOne(center);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 매 프레임 업데이트
|
||||
* @param deltaTime 초 단위 프레임 시간
|
||||
* @param emitCenter 방출 중심 (정규화 좌표 0-1)
|
||||
*/
|
||||
update(deltaTime, emitCenter) {
|
||||
if (!this.ready) return;
|
||||
if (this.config.trigger === "ambient") {
|
||||
@ -529,6 +548,10 @@ var SpriteEffectInstance = class {
|
||||
}
|
||||
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) {
|
||||
@ -553,11 +576,32 @@ var SpriteEffectInstance = class {
|
||||
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축 반전
|
||||
@ -572,11 +616,20 @@ var SpriteEffectInstance = class {
|
||||
mesh.visible = true;
|
||||
mesh.position.x = particle.position.x * 2 - 1;
|
||||
mesh.position.y = -(particle.position.y * 2 - 1);
|
||||
mesh.position.z = 0.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
|
||||
);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 텍스처 로딩 완료 여부
|
||||
@ -604,22 +657,10 @@ var SpriteEffectInstance = class {
|
||||
};
|
||||
|
||||
// src/engine/SpriteEffectManager.ts
|
||||
var isPointInPolygon = (point, polygon) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x, yi = polygon[i].y;
|
||||
const xj = polygon[j].x, yj = polygon[j].y;
|
||||
const intersect = yi > point.y !== yj > point.y && point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
var getAreaCenter = (area) => {
|
||||
const pts = area.basePoints;
|
||||
return {
|
||||
x: (pts[0].x + pts[1].x + pts[2].x + pts[3].x) / 4,
|
||||
y: (pts[0].y + pts[1].y + pts[2].y + pts[3].y) / 4
|
||||
};
|
||||
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() {
|
||||
@ -637,16 +678,17 @@ var SpriteEffectManager = class {
|
||||
scene.add(this.effectGroup);
|
||||
}
|
||||
/**
|
||||
* 영역의 spriteEffects 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
* 이펙트 영역 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
*/
|
||||
syncEffects(areas) {
|
||||
syncEffects(effectAreas) {
|
||||
console.log("[SpriteEffectManager] syncEffects \uD638\uCD9C:", effectAreas.length, "\uAC1C \uC601\uC5ED");
|
||||
const activeKeys = /* @__PURE__ */ new Set();
|
||||
for (const area of areas) {
|
||||
if (!area.spriteEffects) continue;
|
||||
for (const effectConfig of area.spriteEffects) {
|
||||
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);
|
||||
@ -662,33 +704,32 @@ var SpriteEffectManager = class {
|
||||
}
|
||||
/**
|
||||
* 매 프레임 업데이트
|
||||
* @param areas 현재 영역 배열
|
||||
* @param effectAreas 이펙트 영역 배열
|
||||
* @param deltaTime 초 단위 프레임 시간
|
||||
* @param touchState 마우스/터치 상태
|
||||
*/
|
||||
update(areas, deltaTime, touchState) {
|
||||
update(effectAreas, deltaTime, touchState) {
|
||||
const currentTouchingAreas = /* @__PURE__ */ new Set();
|
||||
if (touchState.isDragging && touchState.position) {
|
||||
for (const area of areas) {
|
||||
if (isPointInPolygon(touchState.position, area.basePoints)) {
|
||||
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 areas) {
|
||||
if (!area.spriteEffects) continue;
|
||||
const center = getAreaCenter(area);
|
||||
for (const effectConfig of area.spriteEffects) {
|
||||
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 ?? center);
|
||||
instance.triggerBurst(touchState.position ?? area.position);
|
||||
}
|
||||
}
|
||||
instance.update(deltaTime, center);
|
||||
instance.update(deltaTime, area.position);
|
||||
}
|
||||
}
|
||||
this.previousTouchingAreas = currentTouchingAreas;
|
||||
@ -978,7 +1019,7 @@ var SpringPhysics = class {
|
||||
};
|
||||
|
||||
// src/hooks/useMouseInteraction.ts
|
||||
var isPointInPolygon2 = (point, polygon) => {
|
||||
var isPointInPolygon = (point, polygon) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x, yi = polygon[i].y;
|
||||
@ -1011,7 +1052,7 @@ var useMouseInteraction = (containerRef, config) => {
|
||||
if (mouseState.isDragging && mouseState.position) {
|
||||
const currentlyInAreas = /* @__PURE__ */ new Set();
|
||||
for (let i = 0; i < areas.length; i++) {
|
||||
if (isPointInPolygon2(mouseState.position, areas[i].basePoints)) {
|
||||
if (isPointInPolygon(mouseState.position, areas[i].basePoints)) {
|
||||
currentlyInAreas.add(i);
|
||||
if (!interactingAreaIndices.has(i)) {
|
||||
getSpringPhysics(i, areas[i]).reset();
|
||||
@ -1167,7 +1208,8 @@ var ImageDistortion = ({
|
||||
fragmentShaderPath,
|
||||
style,
|
||||
className,
|
||||
mouseInteraction
|
||||
mouseInteraction,
|
||||
spriteEffectAreas = []
|
||||
}) => {
|
||||
const containerRef = useRef4(null);
|
||||
const sceneRef = useRef4(null);
|
||||
@ -1208,8 +1250,8 @@ var ImageDistortion = ({
|
||||
};
|
||||
}, [isReady]);
|
||||
useEffect3(() => {
|
||||
spriteManagerRef.current?.syncEffects(currentAreas);
|
||||
}, [currentAreas]);
|
||||
spriteManagerRef.current?.syncEffects(spriteEffectAreas);
|
||||
}, [spriteEffectAreas, isReady]);
|
||||
useEffect3(() => {
|
||||
if (mouseInteraction) {
|
||||
mouseInteractionHook.updateConfig(mouseInteraction);
|
||||
@ -1343,15 +1385,16 @@ var ImageDistortion = ({
|
||||
if (spriteManagerRef.current) {
|
||||
const mouseState = mouseInteractionHook.getMouseState();
|
||||
spriteManagerRef.current.update(
|
||||
currentAreasRef.current,
|
||||
spriteEffectAreas,
|
||||
deltaTime,
|
||||
{
|
||||
position: mouseState.position ?? null,
|
||||
isDragging: mouseState.isDragging
|
||||
}
|
||||
);
|
||||
sceneRef.current?.render();
|
||||
}
|
||||
}, [isReady, mouseInteraction, mouseInteractionHook]);
|
||||
}, [isReady, mouseInteraction, mouseInteractionHook, spriteEffectAreas]);
|
||||
useAnimationFrame(animationCallback, true);
|
||||
return /* @__PURE__ */ jsx(
|
||||
"div",
|
||||
@ -1717,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,
|
||||
@ -1755,7 +1801,7 @@ var EditorCanvas = ({
|
||||
};
|
||||
}, []);
|
||||
const selectedArea = areas.find((a) => a.id === selectedAreaId);
|
||||
const isPointInPolygon3 = useCallback5((point, polygon) => {
|
||||
const isPointInPolygon2 = useCallback5((point, polygon) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x, yi = polygon[i].y;
|
||||
@ -1789,7 +1835,21 @@ var EditorCanvas = ({
|
||||
const x = (clientX - rect.left) / rect.width;
|
||||
const y = (clientY - rect.top) / rect.height;
|
||||
const clickPoint = { x, y };
|
||||
if (selectedArea && isPointInPolygon3(clickPoint, selectedArea.basePoints)) {
|
||||
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);
|
||||
e.preventDefault();
|
||||
@ -1798,7 +1858,7 @@ var EditorCanvas = ({
|
||||
if (onSelectArea) {
|
||||
for (let i = areas.length - 1; i >= 0; i--) {
|
||||
const area = areas[i];
|
||||
if (area.id !== selectedAreaId && isPointInPolygon3(clickPoint, area.basePoints)) {
|
||||
if (area.id !== selectedAreaId && isPointInPolygon2(clickPoint, area.basePoints)) {
|
||||
onSelectArea(area.id);
|
||||
e.preventDefault();
|
||||
return;
|
||||
@ -1806,12 +1866,12 @@ var EditorCanvas = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon3, 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();
|
||||
@ -1826,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));
|
||||
@ -1841,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) {
|
||||
@ -1851,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);
|
||||
@ -1863,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;
|
||||
@ -1931,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(
|
||||
@ -1952,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",
|
||||
{
|
||||
@ -2051,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}`
|
||||
);
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
2
dist/index.mjs.map
vendored
2
dist/index.mjs.map
vendored
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
||||
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';
|
||||
@ -28,6 +28,8 @@ export interface ImageDistortionProps {
|
||||
className?: string;
|
||||
/** 마우스 인터랙션 설정 */
|
||||
mouseInteraction?: MouseInteractionConfig;
|
||||
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
|
||||
spriteEffectAreas?: SpriteEffectArea[];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -42,6 +44,7 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
||||
style,
|
||||
className,
|
||||
mouseInteraction,
|
||||
spriteEffectAreas = [],
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sceneRef = useRef<ThreeScene | null>(null);
|
||||
@ -91,10 +94,10 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
||||
};
|
||||
}, [isReady]);
|
||||
|
||||
// 영역 변경 시 스프라이트 이펙트 동기화
|
||||
// 이펙트 영역 변경 또는 매니저 준비 시 스프라이트 이펙트 동기화
|
||||
useEffect(() => {
|
||||
spriteManagerRef.current?.syncEffects(currentAreas);
|
||||
}, [currentAreas]);
|
||||
spriteManagerRef.current?.syncEffects(spriteEffectAreas);
|
||||
}, [spriteEffectAreas, isReady]);
|
||||
|
||||
// 마우스 인터랙션 설정 변경 시 업데이트
|
||||
useEffect(() => {
|
||||
@ -271,19 +274,22 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
|
||||
return updatedAreas;
|
||||
});
|
||||
|
||||
// 스프라이트 이펙트 업데이트 (디스토션과 독립적)
|
||||
// 스프라이트 이펙트 업데이트 (왜곡 영역과 독립적)
|
||||
if (spriteManagerRef.current) {
|
||||
const mouseState = mouseInteractionHook.getMouseState();
|
||||
spriteManagerRef.current.update(
|
||||
currentAreasRef.current,
|
||||
spriteEffectAreas,
|
||||
deltaTime,
|
||||
{
|
||||
position: mouseState.position ?? null,
|
||||
isDragging: mouseState.isDragging,
|
||||
}
|
||||
);
|
||||
|
||||
// 스프라이트 메쉬 변경 후 렌더링 필요
|
||||
sceneRef.current?.render();
|
||||
}
|
||||
}, [isReady, mouseInteraction, mouseInteractionHook]);
|
||||
}, [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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Point } from '@/types';
|
||||
import type { SpriteEffectConfig } from '@/types/spriteEffect';
|
||||
import type { SpriteEffectConfig, SpriteSheetConfig } from '@/types/spriteEffect';
|
||||
import { SpriteParticlePool, type SpriteParticle } from './SpriteParticlePool';
|
||||
|
||||
/**
|
||||
@ -74,12 +74,29 @@ export class SpriteEffectInstance {
|
||||
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) {
|
||||
(mesh.material as THREE.MeshBasicMaterial).map = texture;
|
||||
(mesh.material as THREE.MeshBasicMaterial).needsUpdate = true;
|
||||
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) => {
|
||||
@ -127,6 +144,8 @@ export class SpriteEffectInstance {
|
||||
particle.opacity = 1;
|
||||
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
|
||||
particle.age = 0;
|
||||
particle.frameTime = 0;
|
||||
particle.frameIndex = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -160,6 +179,8 @@ export class SpriteEffectInstance {
|
||||
* @param deltaTime 초 단위 프레임 시간
|
||||
* @param emitCenter 방출 중심 (정규화 좌표 0-1)
|
||||
*/
|
||||
private _logCounter = 0;
|
||||
|
||||
update(deltaTime: number, emitCenter: Point): void {
|
||||
if (!this.ready) return;
|
||||
|
||||
@ -172,6 +193,10 @@ export class SpriteEffectInstance {
|
||||
|
||||
// 활성 파티클 업데이트
|
||||
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;
|
||||
|
||||
@ -202,6 +227,11 @@ export class SpriteEffectInstance {
|
||||
}
|
||||
}
|
||||
|
||||
// 스프라이트 시트 프레임 진행
|
||||
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;
|
||||
@ -211,6 +241,29 @@ export class SpriteEffectInstance {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스프라이트 시트 프레임 진행
|
||||
*/
|
||||
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축 반전
|
||||
@ -229,13 +282,24 @@ export class SpriteEffectInstance {
|
||||
// 좌표 변환: 정규화(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.1; // 디스토션 메쉬(z=0) 위
|
||||
mesh.position.z = -0.01; // 카메라가 -z를 바라보므로 음수가 앞쪽
|
||||
|
||||
mesh.scale.set(particle.scale, 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import * as THREE from 'three';
|
||||
import type { DistortionArea, Point } from '@/types';
|
||||
import type { Point } from '@/types';
|
||||
import type { SpriteEffectArea } from '@/types/spriteEffect';
|
||||
import { SpriteEffectInstance } from './SpriteEffectInstance';
|
||||
|
||||
/**
|
||||
@ -13,34 +14,18 @@ export interface SpriteEffectTouchState {
|
||||
}
|
||||
|
||||
/**
|
||||
* 점이 사각형(볼록 다각형) 내부에 있는지 확인
|
||||
* 점이 원 안에 있는지 확인
|
||||
*/
|
||||
const isPointInPolygon = (point: Point, polygon: Point[]): boolean => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x, yi = polygon[i].y;
|
||||
const xj = polygon[j].x, yj = polygon[j].y;
|
||||
const intersect = ((yi > point.y) !== (yj > point.y))
|
||||
&& (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi);
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
|
||||
/**
|
||||
* 영역 중심 좌표 계산 (basePoints 4개의 평균)
|
||||
*/
|
||||
const getAreaCenter = (area: DistortionArea): Point => {
|
||||
const pts = area.basePoints;
|
||||
return {
|
||||
x: (pts[0].x + pts[1].x + pts[2].x + pts[3].x) / 4,
|
||||
y: (pts[0].y + pts[1].y + pts[2].y + pts[3].y) / 4,
|
||||
};
|
||||
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 {
|
||||
/** 모든 이펙트 메쉬를 담는 그룹 */
|
||||
@ -63,15 +48,14 @@ export class SpriteEffectManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 영역의 spriteEffects 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
* 이펙트 영역 설정 변경을 감지하여 인스턴스 생성/제거
|
||||
*/
|
||||
syncEffects(areas: DistortionArea[]): void {
|
||||
syncEffects(effectAreas: SpriteEffectArea[]): void {
|
||||
console.log('[SpriteEffectManager] syncEffects 호출:', effectAreas.length, '개 영역');
|
||||
const activeKeys = new Set<string>();
|
||||
|
||||
for (const area of areas) {
|
||||
if (!area.spriteEffects) continue;
|
||||
|
||||
for (const effectConfig of area.spriteEffects) {
|
||||
for (const area of effectAreas) {
|
||||
for (const effectConfig of area.effects) {
|
||||
const key = `${area.id}::${effectConfig.id}`;
|
||||
activeKeys.add(key);
|
||||
|
||||
@ -79,6 +63,7 @@ export class SpriteEffectManager {
|
||||
if (this.instances.has(key)) continue;
|
||||
|
||||
// 새 인스턴스 생성
|
||||
console.log('[SpriteEffectManager] 인스턴스 생성:', key, effectConfig.spriteUrl);
|
||||
const instance = new SpriteEffectInstance(effectConfig);
|
||||
this.instances.set(key, instance);
|
||||
this.effectGroup.add(instance.group);
|
||||
@ -97,29 +82,26 @@ export class SpriteEffectManager {
|
||||
|
||||
/**
|
||||
* 매 프레임 업데이트
|
||||
* @param areas 현재 영역 배열
|
||||
* @param effectAreas 이펙트 영역 배열
|
||||
* @param deltaTime 초 단위 프레임 시간
|
||||
* @param touchState 마우스/터치 상태
|
||||
*/
|
||||
update(areas: DistortionArea[], deltaTime: number, touchState: SpriteEffectTouchState): void {
|
||||
update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState): void {
|
||||
// 현재 터치 중인 영역 감지
|
||||
const currentTouchingAreas = new Set<string>();
|
||||
|
||||
if (touchState.isDragging && touchState.position) {
|
||||
for (const area of areas) {
|
||||
if (isPointInPolygon(touchState.position, area.basePoints)) {
|
||||
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 areas) {
|
||||
if (!area.spriteEffects) continue;
|
||||
|
||||
const center = getAreaCenter(area);
|
||||
|
||||
for (const effectConfig of area.spriteEffects) {
|
||||
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;
|
||||
@ -129,12 +111,12 @@ export class SpriteEffectManager {
|
||||
const isNewTouch = currentTouchingAreas.has(area.id)
|
||||
&& !this.previousTouchingAreas.has(area.id);
|
||||
if (isNewTouch) {
|
||||
instance.triggerBurst(touchState.position ?? center);
|
||||
instance.triggerBurst(touchState.position ?? area.position);
|
||||
}
|
||||
}
|
||||
|
||||
// 매 프레임 업데이트 (ambient 방출 + 파티클 물리)
|
||||
instance.update(deltaTime, center);
|
||||
instance.update(deltaTime, area.position);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -22,6 +22,10 @@ export interface SpriteParticle {
|
||||
age: number;
|
||||
/** 수명 (초) */
|
||||
lifetime: number;
|
||||
/** 스프라이트 시트 프레임 누적 시간 */
|
||||
frameTime: number;
|
||||
/** 현재 프레임 인덱스 */
|
||||
frameIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -48,6 +52,8 @@ export class SpriteParticlePool {
|
||||
opacity: 1,
|
||||
age: 0,
|
||||
lifetime: 1,
|
||||
frameTime: 0,
|
||||
frameIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
// 렌더러 설정
|
||||
|
||||
@ -56,7 +56,10 @@ export type {
|
||||
SpriteEffectTrigger,
|
||||
SpriteBlendMode,
|
||||
SpriteEffectConfig,
|
||||
SpriteEffectArea,
|
||||
SpriteEffectAreaData,
|
||||
SpriteParticleOverLifetime,
|
||||
SpriteSheetConfig,
|
||||
} from './types/spriteEffect';
|
||||
|
||||
// 유틸리티 함수
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import type { SpriteEffectConfig } from './spriteEffect';
|
||||
|
||||
/**
|
||||
* 정규화된 좌표계의 2D 포인트 (0.0 - 1.0)
|
||||
*/
|
||||
@ -101,8 +99,6 @@ export interface DistortionArea {
|
||||
};
|
||||
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
|
||||
snapSteps?: number;
|
||||
/** 스프라이트 이펙트 설정 배열 */
|
||||
spriteEffects?: SpriteEffectConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -20,6 +20,64 @@ export interface SpriteParticleOverLifetime {
|
||||
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: 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;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스프라이트 이펙트 설정
|
||||
*/
|
||||
@ -52,4 +110,6 @@ export interface SpriteEffectConfig {
|
||||
emitRadius?: number;
|
||||
/** 수명 기반 속성 보간 */
|
||||
overLifetime?: SpriteParticleOverLifetime;
|
||||
/** 스프라이트 시트 설정 (없으면 정적 이미지) */
|
||||
spriteSheet?: SpriteSheetConfig;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user