Refactor sprite effects to be independent and support sprite sheets

- 스프라이트 이펙트를 왜곡 영역에서 분리하여 독립적인 영역으로 관리
- 스프라이트 시트 애니메이션 기능 추가 및 UV 제어 로직 구현
- 에디터 내 스프라이트 이펙트 영역 시각화 및 드래그 이동 기능 추가
- 이펙트 감지 방식을 다각형에서 원형(Radius) 기반으로 변경
- 관련 타입 정의 및 매니저 클래스 리팩토링
This commit is contained in:
BaekRyang 2026-03-11 08:32:36 +09:00
parent 48fdd5e17c
commit 530e6d0396
17 changed files with 1008 additions and 303 deletions

View File

@ -14,7 +14,8 @@
"Bash(npm link:*)", "Bash(npm link:*)",
"Bash(find:*)", "Bash(find:*)",
"Bash(nul)", "Bash(nul)",
"Bash(cd:*)" "Bash(cd:*)",
"Bash(ls -la /d/Projects/WebstormProjects/raonnuri/src/app/\\\\[locale\\\\]/)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

184
dist/index.d.mts vendored
View File

@ -1,57 +1,6 @@
import React$1 from 'react'; import React$1 from 'react';
import * as THREE from 'three'; 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) * 2D (0.0 - 1.0)
*/ */
@ -126,8 +75,6 @@ interface DistortionArea {
}; };
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */ /** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
snapSteps?: number; snapSteps?: number;
/** 스프라이트 이펙트 설정 배열 */
spriteEffects?: SpriteEffectConfig[];
} }
/** /**
* *
@ -196,6 +143,120 @@ interface AnimationTicker {
resume: () => void; resume: () => void;
} }
/** 이펙트 트리거 타입 */
type SpriteEffectTrigger = 'ambient' | 'touch';
/** 블렌드 모드 */
type SpriteBlendMode = 'normal' | 'additive';
/**
*
*/
interface SpriteParticleOverLifetime {
/** [시작, 끝] 스케일 */
scale?: [number, number];
/** [시작, 끝] 투명도 */
opacity?: [number, number];
/** 회전 속도 (라디안/초) */
rotationSpeed?: number;
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
velocityDamping?: number;
}
/**
*
*/
interface SpriteSheetConfig {
/** 가로 프레임 수 */
columns: number;
/** 세로 프레임 수 */
rows: number;
/** 총 프레임 수 (columns * rows 보다 적을 수 있음) */
totalFrames: number;
/** 재생 속도 (프레임/초) */
fps: number;
/** 반복 재생 여부 (기본: true) */
loop?: boolean;
}
/**
*
* (DistortionArea)
*/
interface SpriteEffectArea {
/** 고유 식별자 */
id: string;
/** 이펙트 중심 좌표 (정규화 0-1) */
position: Point;
/** 터치 감지 반경 (정규화, 기본: 0.1) */
radius?: number;
/** 이 영역에 연결된 이펙트 설정 배열 */
effects: SpriteEffectConfig[];
}
/**
* SpriteEffectArea
* DB
*/
interface SpriteEffectAreaData {
id: string;
position: {
x: number;
y: number;
};
radius?: number;
effects: Array<{
id: string;
trigger: SpriteEffectTrigger;
spriteUrl: string;
blendMode?: SpriteBlendMode;
maxParticles: number;
emitRate?: number;
burstCount?: number;
lifetime: [number, number];
initialScale: [number, number];
initialSpeed: [number, number];
emitAngle?: [number, number];
emitOffset?: {
x: number;
y: number;
};
emitRadius?: number;
overLifetime?: SpriteParticleOverLifetime;
spriteSheet?: SpriteSheetConfig;
}>;
}
/**
*
*/
interface SpriteEffectConfig {
/** 고유 식별자 */
id: string;
/** 트리거 타입 */
trigger: SpriteEffectTrigger;
/** 스프라이트 이미지 URL */
spriteUrl: string;
/** 블렌드 모드 (기본: 'normal') */
blendMode?: SpriteBlendMode;
/** 최대 파티클 수 */
maxParticles: number;
/** ambient: 초당 방출 수 */
emitRate?: number;
/** touch: 터치 시 방출 수 */
burstCount?: number;
/** [최소, 최대] 수명 (초) */
lifetime: [number, number];
/** [최소, 최대] 초기 스케일 */
initialScale: [number, number];
/** [최소, 최대] 초기 속도 */
initialSpeed: [number, number];
/** 방출 각도 범위 (도) */
emitAngle?: [number, number];
/** 영역 중심 대비 방출 오프셋 */
emitOffset?: Point;
/** 방출 범위 반경 */
emitRadius?: number;
/** 수명 기반 속성 보간 */
overLifetime?: SpriteParticleOverLifetime;
/** 스프라이트 시트 설정 (없으면 정적 이미지) */
spriteSheet?: SpriteSheetConfig;
}
/** /**
* *
*/ */
@ -273,6 +334,8 @@ interface ImageDistortionProps {
className?: string; className?: string;
/** 마우스 인터랙션 설정 */ /** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig; mouseInteraction?: MouseInteractionConfig;
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
spriteEffectAreas?: SpriteEffectArea[];
} }
/** /**
* GPU * GPU
@ -394,6 +457,10 @@ interface EditorCanvasProps {
showEditor?: boolean; showEditor?: boolean;
/** 영역 선택 콜백 (비선택 영역 클릭 시) */ /** 영역 선택 콜백 (비선택 영역 클릭 시) */
onSelectArea?: (areaId: string) => void; onSelectArea?: (areaId: string) => void;
/** 독립 스프라이트 이펙트 영역 */
spriteEffectAreas?: SpriteEffectArea[];
/** 스프라이트 이펙트 영역 업데이트 콜백 */
onUpdateSpriteEffectArea?: (areaId: string, updates: Partial<SpriteEffectArea>) => void;
} }
declare const EditorCanvas: React$1.FC<EditorCanvasProps>; declare const EditorCanvas: React$1.FC<EditorCanvasProps>;
@ -711,6 +778,7 @@ interface SpriteEffectTouchState {
/** /**
* *
* ImageDistortion * ImageDistortion
* (DistortionArea)
*/ */
declare class SpriteEffectManager { declare class SpriteEffectManager {
/** 모든 이펙트 메쉬를 담는 그룹 */ /** 모든 이펙트 메쉬를 담는 그룹 */
@ -725,16 +793,16 @@ declare class SpriteEffectManager {
*/ */
attachToScene(scene: THREE.Scene): void; attachToScene(scene: THREE.Scene): void;
/** /**
* spriteEffects / * /
*/ */
syncEffects(areas: DistortionArea[]): void; syncEffects(effectAreas: SpriteEffectArea[]): void;
/** /**
* *
* @param areas * @param effectAreas
* @param deltaTime * @param deltaTime
* @param touchState / * @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; 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
View File

@ -1,57 +1,6 @@
import React$1 from 'react'; import React$1 from 'react';
import * as THREE from 'three'; 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) * 2D (0.0 - 1.0)
*/ */
@ -126,8 +75,6 @@ interface DistortionArea {
}; };
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */ /** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
snapSteps?: number; snapSteps?: number;
/** 스프라이트 이펙트 설정 배열 */
spriteEffects?: SpriteEffectConfig[];
} }
/** /**
* *
@ -196,6 +143,120 @@ interface AnimationTicker {
resume: () => void; resume: () => void;
} }
/** 이펙트 트리거 타입 */
type SpriteEffectTrigger = 'ambient' | 'touch';
/** 블렌드 모드 */
type SpriteBlendMode = 'normal' | 'additive';
/**
*
*/
interface SpriteParticleOverLifetime {
/** [시작, 끝] 스케일 */
scale?: [number, number];
/** [시작, 끝] 투명도 */
opacity?: [number, number];
/** 회전 속도 (라디안/초) */
rotationSpeed?: number;
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
velocityDamping?: number;
}
/**
*
*/
interface SpriteSheetConfig {
/** 가로 프레임 수 */
columns: number;
/** 세로 프레임 수 */
rows: number;
/** 총 프레임 수 (columns * rows 보다 적을 수 있음) */
totalFrames: number;
/** 재생 속도 (프레임/초) */
fps: number;
/** 반복 재생 여부 (기본: true) */
loop?: boolean;
}
/**
*
* (DistortionArea)
*/
interface SpriteEffectArea {
/** 고유 식별자 */
id: string;
/** 이펙트 중심 좌표 (정규화 0-1) */
position: Point;
/** 터치 감지 반경 (정규화, 기본: 0.1) */
radius?: number;
/** 이 영역에 연결된 이펙트 설정 배열 */
effects: SpriteEffectConfig[];
}
/**
* SpriteEffectArea
* DB
*/
interface SpriteEffectAreaData {
id: string;
position: {
x: number;
y: number;
};
radius?: number;
effects: Array<{
id: string;
trigger: SpriteEffectTrigger;
spriteUrl: string;
blendMode?: SpriteBlendMode;
maxParticles: number;
emitRate?: number;
burstCount?: number;
lifetime: [number, number];
initialScale: [number, number];
initialSpeed: [number, number];
emitAngle?: [number, number];
emitOffset?: {
x: number;
y: number;
};
emitRadius?: number;
overLifetime?: SpriteParticleOverLifetime;
spriteSheet?: SpriteSheetConfig;
}>;
}
/**
*
*/
interface SpriteEffectConfig {
/** 고유 식별자 */
id: string;
/** 트리거 타입 */
trigger: SpriteEffectTrigger;
/** 스프라이트 이미지 URL */
spriteUrl: string;
/** 블렌드 모드 (기본: 'normal') */
blendMode?: SpriteBlendMode;
/** 최대 파티클 수 */
maxParticles: number;
/** ambient: 초당 방출 수 */
emitRate?: number;
/** touch: 터치 시 방출 수 */
burstCount?: number;
/** [최소, 최대] 수명 (초) */
lifetime: [number, number];
/** [최소, 최대] 초기 스케일 */
initialScale: [number, number];
/** [최소, 최대] 초기 속도 */
initialSpeed: [number, number];
/** 방출 각도 범위 (도) */
emitAngle?: [number, number];
/** 영역 중심 대비 방출 오프셋 */
emitOffset?: Point;
/** 방출 범위 반경 */
emitRadius?: number;
/** 수명 기반 속성 보간 */
overLifetime?: SpriteParticleOverLifetime;
/** 스프라이트 시트 설정 (없으면 정적 이미지) */
spriteSheet?: SpriteSheetConfig;
}
/** /**
* *
*/ */
@ -273,6 +334,8 @@ interface ImageDistortionProps {
className?: string; className?: string;
/** 마우스 인터랙션 설정 */ /** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig; mouseInteraction?: MouseInteractionConfig;
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
spriteEffectAreas?: SpriteEffectArea[];
} }
/** /**
* GPU * GPU
@ -394,6 +457,10 @@ interface EditorCanvasProps {
showEditor?: boolean; showEditor?: boolean;
/** 영역 선택 콜백 (비선택 영역 클릭 시) */ /** 영역 선택 콜백 (비선택 영역 클릭 시) */
onSelectArea?: (areaId: string) => void; onSelectArea?: (areaId: string) => void;
/** 독립 스프라이트 이펙트 영역 */
spriteEffectAreas?: SpriteEffectArea[];
/** 스프라이트 이펙트 영역 업데이트 콜백 */
onUpdateSpriteEffectArea?: (areaId: string, updates: Partial<SpriteEffectArea>) => void;
} }
declare const EditorCanvas: React$1.FC<EditorCanvasProps>; declare const EditorCanvas: React$1.FC<EditorCanvasProps>;
@ -711,6 +778,7 @@ interface SpriteEffectTouchState {
/** /**
* *
* ImageDistortion * ImageDistortion
* (DistortionArea)
*/ */
declare class SpriteEffectManager { declare class SpriteEffectManager {
/** 모든 이펙트 메쉬를 담는 그룹 */ /** 모든 이펙트 메쉬를 담는 그룹 */
@ -725,16 +793,16 @@ declare class SpriteEffectManager {
*/ */
attachToScene(scene: THREE.Scene): void; attachToScene(scene: THREE.Scene): void;
/** /**
* spriteEffects / * /
*/ */
syncEffects(areas: DistortionArea[]): void; syncEffects(effectAreas: SpriteEffectArea[]): void;
/** /**
* *
* @param areas * @param effectAreas
* @param deltaTime * @param deltaTime
* @param touchState / * @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; 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
View File

@ -437,7 +437,9 @@ var SpriteParticlePool = class {
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
age: 0, age: 0,
lifetime: 1 lifetime: 1,
frameTime: 0,
frameIndex: 0
}; };
} }
/** /**
@ -486,6 +488,12 @@ var SpriteEffectInstance = class {
this.texture = null; this.texture = null;
this.ready = false; this.ready = false;
this.emitAccumulator = 0; this.emitAccumulator = 0;
/**
* 프레임 업데이트
* @param deltaTime 단위 프레임 시간
* @param emitCenter 방출 중심 (정규화 좌표 0-1)
*/
this._logCounter = 0;
this.config = config; this.config = config;
this.pool = new SpriteParticlePool(config.maxParticles); this.pool = new SpriteParticlePool(config.maxParticles);
this.group = new THREE2.Group(); this.group = new THREE2.Group();
@ -514,11 +522,25 @@ var SpriteEffectInstance = class {
url, url,
(texture) => { (texture) => {
this.texture = 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) { for (const mesh of this.meshes) {
mesh.material.map = texture; const mat = mesh.material;
mesh.material.needsUpdate = true; 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; this.ready = true;
console.log(`[SpriteEffectInstance] \uD14D\uC2A4\uCC98 \uB85C\uB4DC \uC131\uACF5: ${url}`, texture.image.width, "x", texture.image.height);
}, },
void 0, void 0,
(error) => { (error) => {
@ -555,6 +577,8 @@ var SpriteEffectInstance = class {
particle.opacity = 1; particle.opacity = 1;
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]); particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
particle.age = 0; particle.age = 0;
particle.frameTime = 0;
particle.frameIndex = 0;
} }
/** /**
* ambient 모드: 프레임 누적기 기반 방출 * ambient 모드: 프레임 누적기 기반 방출
@ -578,11 +602,6 @@ var SpriteEffectInstance = class {
this.emitOne(center); this.emitOne(center);
} }
} }
/**
* 프레임 업데이트
* @param deltaTime 단위 프레임 시간
* @param emitCenter 방출 중심 (정규화 좌표 0-1)
*/
update(deltaTime, emitCenter) { update(deltaTime, emitCenter) {
if (!this.ready) return; if (!this.ready) return;
if (this.config.trigger === "ambient") { if (this.config.trigger === "ambient") {
@ -590,6 +609,10 @@ var SpriteEffectInstance = class {
} }
const overLifetime = this.config.overLifetime; const overLifetime = this.config.overLifetime;
const activeParticles = this.pool.getActiveParticles(); 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) { for (const particle of activeParticles) {
particle.age += deltaTime; particle.age += deltaTime;
if (particle.age >= particle.lifetime) { if (particle.age >= particle.lifetime) {
@ -614,11 +637,32 @@ var SpriteEffectInstance = class {
particle.velocity.y *= 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.x += particle.velocity.x * deltaTime;
particle.position.y += particle.velocity.y * deltaTime; particle.position.y += particle.velocity.y * deltaTime;
this.syncMesh(particle); 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 메쉬에 동기화 * 파티클 상태를 Three.js 메쉬에 동기화
* 정규화 좌표(0-1) NDC(-1~1) 변환, y축 반전 * 정규화 좌표(0-1) NDC(-1~1) 변환, y축 반전
@ -633,11 +677,20 @@ var SpriteEffectInstance = class {
mesh.visible = true; mesh.visible = true;
mesh.position.x = particle.position.x * 2 - 1; mesh.position.x = particle.position.x * 2 - 1;
mesh.position.y = -(particle.position.y * 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.scale.set(particle.scale, particle.scale, 1);
mesh.rotation.z = particle.rotation; mesh.rotation.z = particle.rotation;
const mat = mesh.material; const mat = mesh.material;
mat.opacity = particle.opacity; 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 // src/engine/SpriteEffectManager.ts
var isPointInPolygon = (point, polygon) => { var isPointInCircle = (point, center, radius) => {
let inside = false; const dx = point.x - center.x;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const dy = point.y - center.y;
const xi = polygon[i].x, yi = polygon[i].y; return dx * dx + dy * dy <= radius * radius;
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 SpriteEffectManager = class { var SpriteEffectManager = class {
constructor() { constructor() {
@ -698,16 +739,17 @@ var SpriteEffectManager = class {
scene.add(this.effectGroup); 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(); const activeKeys = /* @__PURE__ */ new Set();
for (const area of areas) { for (const area of effectAreas) {
if (!area.spriteEffects) continue; for (const effectConfig of area.effects) {
for (const effectConfig of area.spriteEffects) {
const key = `${area.id}::${effectConfig.id}`; const key = `${area.id}::${effectConfig.id}`;
activeKeys.add(key); activeKeys.add(key);
if (this.instances.has(key)) continue; if (this.instances.has(key)) continue;
console.log("[SpriteEffectManager] \uC778\uC2A4\uD134\uC2A4 \uC0DD\uC131:", key, effectConfig.spriteUrl);
const instance = new SpriteEffectInstance(effectConfig); const instance = new SpriteEffectInstance(effectConfig);
this.instances.set(key, instance); this.instances.set(key, instance);
this.effectGroup.add(instance.group); this.effectGroup.add(instance.group);
@ -723,33 +765,32 @@ var SpriteEffectManager = class {
} }
/** /**
* 프레임 업데이트 * 프레임 업데이트
* @param areas 현재 영역 배열 * @param effectAreas 이펙트 영역 배열
* @param deltaTime 단위 프레임 시간 * @param deltaTime 단위 프레임 시간
* @param touchState 마우스/터치 상태 * @param touchState 마우스/터치 상태
*/ */
update(areas, deltaTime, touchState) { update(effectAreas, deltaTime, touchState) {
const currentTouchingAreas = /* @__PURE__ */ new Set(); const currentTouchingAreas = /* @__PURE__ */ new Set();
if (touchState.isDragging && touchState.position) { if (touchState.isDragging && touchState.position) {
for (const area of areas) { for (const area of effectAreas) {
if (isPointInPolygon(touchState.position, area.basePoints)) { const radius = area.radius ?? 0.1;
if (isPointInCircle(touchState.position, area.position, radius)) {
currentTouchingAreas.add(area.id); currentTouchingAreas.add(area.id);
} }
} }
} }
for (const area of areas) { for (const area of effectAreas) {
if (!area.spriteEffects) continue; for (const effectConfig of area.effects) {
const center = getAreaCenter(area);
for (const effectConfig of area.spriteEffects) {
const key = `${area.id}::${effectConfig.id}`; const key = `${area.id}::${effectConfig.id}`;
const instance = this.instances.get(key); const instance = this.instances.get(key);
if (!instance) continue; if (!instance) continue;
if (effectConfig.trigger === "touch") { if (effectConfig.trigger === "touch") {
const isNewTouch = currentTouchingAreas.has(area.id) && !this.previousTouchingAreas.has(area.id); const isNewTouch = currentTouchingAreas.has(area.id) && !this.previousTouchingAreas.has(area.id);
if (isNewTouch) { 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; this.previousTouchingAreas = currentTouchingAreas;
@ -1039,7 +1080,7 @@ var SpringPhysics = class {
}; };
// src/hooks/useMouseInteraction.ts // src/hooks/useMouseInteraction.ts
var isPointInPolygon2 = (point, polygon) => { var isPointInPolygon = (point, polygon) => {
let inside = false; let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y; const xi = polygon[i].x, yi = polygon[i].y;
@ -1072,7 +1113,7 @@ var useMouseInteraction = (containerRef, config) => {
if (mouseState.isDragging && mouseState.position) { if (mouseState.isDragging && mouseState.position) {
const currentlyInAreas = /* @__PURE__ */ new Set(); const currentlyInAreas = /* @__PURE__ */ new Set();
for (let i = 0; i < areas.length; i++) { 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); currentlyInAreas.add(i);
if (!interactingAreaIndices.has(i)) { if (!interactingAreaIndices.has(i)) {
getSpringPhysics(i, areas[i]).reset(); getSpringPhysics(i, areas[i]).reset();
@ -1228,7 +1269,8 @@ var ImageDistortion = ({
fragmentShaderPath, fragmentShaderPath,
style, style,
className, className,
mouseInteraction mouseInteraction,
spriteEffectAreas = []
}) => { }) => {
const containerRef = (0, import_react4.useRef)(null); const containerRef = (0, import_react4.useRef)(null);
const sceneRef = (0, import_react4.useRef)(null); const sceneRef = (0, import_react4.useRef)(null);
@ -1269,8 +1311,8 @@ var ImageDistortion = ({
}; };
}, [isReady]); }, [isReady]);
(0, import_react4.useEffect)(() => { (0, import_react4.useEffect)(() => {
spriteManagerRef.current?.syncEffects(currentAreas); spriteManagerRef.current?.syncEffects(spriteEffectAreas);
}, [currentAreas]); }, [spriteEffectAreas, isReady]);
(0, import_react4.useEffect)(() => { (0, import_react4.useEffect)(() => {
if (mouseInteraction) { if (mouseInteraction) {
mouseInteractionHook.updateConfig(mouseInteraction); mouseInteractionHook.updateConfig(mouseInteraction);
@ -1404,15 +1446,16 @@ var ImageDistortion = ({
if (spriteManagerRef.current) { if (spriteManagerRef.current) {
const mouseState = mouseInteractionHook.getMouseState(); const mouseState = mouseInteractionHook.getMouseState();
spriteManagerRef.current.update( spriteManagerRef.current.update(
currentAreasRef.current, spriteEffectAreas,
deltaTime, deltaTime,
{ {
position: mouseState.position ?? null, position: mouseState.position ?? null,
isDragging: mouseState.isDragging isDragging: mouseState.isDragging
} }
); );
sceneRef.current?.render();
} }
}, [isReady, mouseInteraction, mouseInteractionHook]); }, [isReady, mouseInteraction, mouseInteractionHook, spriteEffectAreas]);
useAnimationFrame(animationCallback, true); useAnimationFrame(animationCallback, true);
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)( return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
"div", "div",
@ -1778,12 +1821,15 @@ var EditorCanvas = ({
onStopDragging, onStopDragging,
style: customStyle, style: customStyle,
showEditor = true, showEditor = true,
onSelectArea onSelectArea,
spriteEffectAreas = [],
onUpdateSpriteEffectArea
}) => { }) => {
const containerRef = (0, import_react6.useRef)(null); const containerRef = (0, import_react6.useRef)(null);
const [canvasSize, setCanvasSize] = (0, import_react6.useState)({ width: 0, height: 0 }); const [canvasSize, setCanvasSize] = (0, import_react6.useState)({ width: 0, height: 0 });
const [isDraggingArea, setIsDraggingArea] = (0, import_react6.useState)(false); const [isDraggingArea, setIsDraggingArea] = (0, import_react6.useState)(false);
const [dragStartPos, setDragStartPos] = (0, import_react6.useState)(null); const [dragStartPos, setDragStartPos] = (0, import_react6.useState)(null);
const [draggingSpriteAreaId, setDraggingSpriteAreaId] = (0, import_react6.useState)(null);
const editorStyle = (0, import_react6.useMemo)(() => ({ const editorStyle = (0, import_react6.useMemo)(() => ({
...DEFAULT_EDITOR_CANVAS_STYLE, ...DEFAULT_EDITOR_CANVAS_STYLE,
...customStyle, ...customStyle,
@ -1816,7 +1862,7 @@ var EditorCanvas = ({
}; };
}, []); }, []);
const selectedArea = areas.find((a) => a.id === selectedAreaId); 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; let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y; const xi = polygon[i].x, yi = polygon[i].y;
@ -1850,7 +1896,21 @@ var EditorCanvas = ({
const x = (clientX - rect.left) / rect.width; const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height; const y = (clientY - rect.top) / rect.height;
const clickPoint = { x, y }; const clickPoint = { x, y };
if (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); setIsDraggingArea(true);
setDragStartPos(clickPoint); setDragStartPos(clickPoint);
e.preventDefault(); e.preventDefault();
@ -1859,7 +1919,7 @@ var EditorCanvas = ({
if (onSelectArea) { if (onSelectArea) {
for (let i = areas.length - 1; i >= 0; i--) { for (let i = areas.length - 1; i >= 0; i--) {
const area = areas[i]; const area = areas[i];
if (area.id !== selectedAreaId && isPointInPolygon3(clickPoint, area.basePoints)) { if (area.id !== selectedAreaId && isPointInPolygon2(clickPoint, area.basePoints)) {
onSelectArea(area.id); onSelectArea(area.id);
e.preventDefault(); e.preventDefault();
return; 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)( const handleMove = (0, import_react6.useCallback)(
(e) => { (e) => {
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(); e.preventDefault();
} }
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
@ -1887,6 +1947,22 @@ var EditorCanvas = ({
} }
const x = (clientX - rect.left) / rect.width; const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height; const y = (clientY - rect.top) / rect.height;
if (draggingSpriteAreaId && dragStartPos && onUpdateSpriteEffectArea) {
const sa = spriteEffectAreas.find((a) => a.id === draggingSpriteAreaId);
if (sa) {
const deltaX = x - dragStartPos.x;
const deltaY = y - dragStartPos.y;
onUpdateSpriteEffectArea(sa.id, {
position: {
x: Math.max(0, Math.min(1, sa.position.x + deltaX)),
y: Math.max(0, Math.min(1, sa.position.y + deltaY))
}
});
setDragStartPos({ x, y });
}
return;
}
if (!selectedArea) return;
if (draggingPointIndex !== null) { if (draggingPointIndex !== null) {
const clampedX = Math.max(0, Math.min(1, x)); const clampedX = Math.max(0, Math.min(1, x));
const clampedY = Math.max(0, Math.min(1, y)); const clampedY = Math.max(0, Math.min(1, y));
@ -1902,7 +1978,7 @@ var EditorCanvas = ({
setDragStartPos({ x, y }); 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)(() => { const handleUp = (0, import_react6.useCallback)(() => {
if (draggingPointIndex !== null) { if (draggingPointIndex !== null) {
@ -1912,9 +1988,13 @@ var EditorCanvas = ({
setIsDraggingArea(false); setIsDraggingArea(false);
setDragStartPos(null); setDragStartPos(null);
} }
}, [draggingPointIndex, isDraggingArea, onStopDragging]); if (draggingSpriteAreaId) {
setDraggingSpriteAreaId(null);
setDragStartPos(null);
}
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, onStopDragging]);
(0, import_react6.useEffect)(() => { (0, import_react6.useEffect)(() => {
if (draggingPointIndex !== null || isDraggingArea) { if (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId) {
window.addEventListener("mouseup", handleUp); window.addEventListener("mouseup", handleUp);
window.addEventListener("touchend", handleUp); window.addEventListener("touchend", handleUp);
window.addEventListener("touchcancel", handleUp); window.addEventListener("touchcancel", handleUp);
@ -1924,7 +2004,7 @@ var EditorCanvas = ({
window.removeEventListener("touchcancel", handleUp); window.removeEventListener("touchcancel", handleUp);
}; };
} }
}, [draggingPointIndex, isDraggingArea, handleUp]); }, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, handleUp]);
const uvToPixel = (u, v, points, canvasWidth, canvasHeight) => { const uvToPixel = (u, v, points, canvasWidth, canvasHeight) => {
const [p0, p1, p2, p3] = points; const [p0, p1, p2, p3] = points;
const leftX = p0.x * (1 - u) + p1.x * u; const leftX = p0.x * (1 - u) + p1.x * u;
@ -1992,6 +2072,7 @@ var EditorCanvas = ({
const getCursorStyle = () => { const getCursorStyle = () => {
if (draggingPointIndex !== null) return "grabbing"; if (draggingPointIndex !== null) return "grabbing";
if (isDraggingArea) return "grabbing"; if (isDraggingArea) return "grabbing";
if (draggingSpriteAreaId) return "grabbing";
return "default"; return "default";
}; };
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)( return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
@ -2013,7 +2094,7 @@ var EditorCanvas = ({
onTouchStart: showEditor ? handleCanvasDown : void 0, onTouchStart: showEditor ? handleCanvasDown : void 0,
onTouchMove: showEditor ? handleMove : void 0, onTouchMove: showEditor ? handleMove : void 0,
children: [ children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ImageDistortion, { imageSrc, areas }), /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ImageDistortion, { imageSrc, areas, spriteEffectAreas }),
showEditor && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( showEditor && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"svg", "svg",
{ {
@ -2112,6 +2193,88 @@ var EditorCanvas = ({
}, },
index index
); );
}),
showEditor && spriteEffectAreas.map((sa) => {
const cx = sa.position.x * 100;
const cy = sa.position.y * 100;
const isDragging = draggingSpriteAreaId === sa.id;
const radiusPx = (sa.radius ?? 0.1) * canvasSize.width;
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
"div",
{
style: {
position: "absolute",
left: `${cx}%`,
top: `${cy}%`,
transform: "translate(-50%, -50%)",
pointerEvents: "none"
},
children: [
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"svg",
{
width: radiusPx * 2,
height: radiusPx * 2,
style: {
position: "absolute",
left: -radiusPx,
top: -radiusPx,
pointerEvents: "none"
},
children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"circle",
{
cx: radiusPx,
cy: radiusPx,
r: radiusPx,
fill: isDragging ? "rgba(255, 170, 0, 0.15)" : "rgba(255, 170, 0, 0.08)",
stroke: isDragging ? "#ffaa00" : "rgba(255, 170, 0, 0.6)",
strokeWidth: isDragging ? 2 : 1.5,
strokeDasharray: isDragging ? "0" : "4,3"
}
)
}
),
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"div",
{
style: {
position: "absolute",
left: -8,
top: -8,
width: 16,
height: 16,
borderRadius: "50%",
backgroundColor: isDragging ? "#ffaa00" : "rgba(255, 170, 0, 0.8)",
border: "2px solid white",
cursor: isDragging ? "grabbing" : "grab",
pointerEvents: "auto",
boxShadow: "0 2px 4px rgba(0,0,0,0.3)"
}
}
),
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
"div",
{
style: {
position: "absolute",
top: -24,
left: "50%",
transform: "translateX(-50%)",
fontSize: 10,
color: "#ffaa00",
fontWeight: "bold",
textShadow: "1px 1px 2px rgba(0,0,0,0.8)",
whiteSpace: "nowrap",
pointerEvents: "none"
},
children: "\u2728"
}
)
]
},
`sprite-${sa.id}`
);
}) })
] ]
} }

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

281
dist/index.mjs vendored
View File

@ -376,7 +376,9 @@ var SpriteParticlePool = class {
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
age: 0, age: 0,
lifetime: 1 lifetime: 1,
frameTime: 0,
frameIndex: 0
}; };
} }
/** /**
@ -425,6 +427,12 @@ var SpriteEffectInstance = class {
this.texture = null; this.texture = null;
this.ready = false; this.ready = false;
this.emitAccumulator = 0; this.emitAccumulator = 0;
/**
* 프레임 업데이트
* @param deltaTime 단위 프레임 시간
* @param emitCenter 방출 중심 (정규화 좌표 0-1)
*/
this._logCounter = 0;
this.config = config; this.config = config;
this.pool = new SpriteParticlePool(config.maxParticles); this.pool = new SpriteParticlePool(config.maxParticles);
this.group = new THREE2.Group(); this.group = new THREE2.Group();
@ -453,11 +461,25 @@ var SpriteEffectInstance = class {
url, url,
(texture) => { (texture) => {
this.texture = 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) { for (const mesh of this.meshes) {
mesh.material.map = texture; const mat = mesh.material;
mesh.material.needsUpdate = true; 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; this.ready = true;
console.log(`[SpriteEffectInstance] \uD14D\uC2A4\uCC98 \uB85C\uB4DC \uC131\uACF5: ${url}`, texture.image.width, "x", texture.image.height);
}, },
void 0, void 0,
(error) => { (error) => {
@ -494,6 +516,8 @@ var SpriteEffectInstance = class {
particle.opacity = 1; particle.opacity = 1;
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]); particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
particle.age = 0; particle.age = 0;
particle.frameTime = 0;
particle.frameIndex = 0;
} }
/** /**
* ambient 모드: 프레임 누적기 기반 방출 * ambient 모드: 프레임 누적기 기반 방출
@ -517,11 +541,6 @@ var SpriteEffectInstance = class {
this.emitOne(center); this.emitOne(center);
} }
} }
/**
* 프레임 업데이트
* @param deltaTime 단위 프레임 시간
* @param emitCenter 방출 중심 (정규화 좌표 0-1)
*/
update(deltaTime, emitCenter) { update(deltaTime, emitCenter) {
if (!this.ready) return; if (!this.ready) return;
if (this.config.trigger === "ambient") { if (this.config.trigger === "ambient") {
@ -529,6 +548,10 @@ var SpriteEffectInstance = class {
} }
const overLifetime = this.config.overLifetime; const overLifetime = this.config.overLifetime;
const activeParticles = this.pool.getActiveParticles(); 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) { for (const particle of activeParticles) {
particle.age += deltaTime; particle.age += deltaTime;
if (particle.age >= particle.lifetime) { if (particle.age >= particle.lifetime) {
@ -553,11 +576,32 @@ var SpriteEffectInstance = class {
particle.velocity.y *= 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.x += particle.velocity.x * deltaTime;
particle.position.y += particle.velocity.y * deltaTime; particle.position.y += particle.velocity.y * deltaTime;
this.syncMesh(particle); 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 메쉬에 동기화 * 파티클 상태를 Three.js 메쉬에 동기화
* 정규화 좌표(0-1) NDC(-1~1) 변환, y축 반전 * 정규화 좌표(0-1) NDC(-1~1) 변환, y축 반전
@ -572,11 +616,20 @@ var SpriteEffectInstance = class {
mesh.visible = true; mesh.visible = true;
mesh.position.x = particle.position.x * 2 - 1; mesh.position.x = particle.position.x * 2 - 1;
mesh.position.y = -(particle.position.y * 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.scale.set(particle.scale, particle.scale, 1);
mesh.rotation.z = particle.rotation; mesh.rotation.z = particle.rotation;
const mat = mesh.material; const mat = mesh.material;
mat.opacity = particle.opacity; 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 // src/engine/SpriteEffectManager.ts
var isPointInPolygon = (point, polygon) => { var isPointInCircle = (point, center, radius) => {
let inside = false; const dx = point.x - center.x;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const dy = point.y - center.y;
const xi = polygon[i].x, yi = polygon[i].y; return dx * dx + dy * dy <= radius * radius;
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 SpriteEffectManager = class { var SpriteEffectManager = class {
constructor() { constructor() {
@ -637,16 +678,17 @@ var SpriteEffectManager = class {
scene.add(this.effectGroup); 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(); const activeKeys = /* @__PURE__ */ new Set();
for (const area of areas) { for (const area of effectAreas) {
if (!area.spriteEffects) continue; for (const effectConfig of area.effects) {
for (const effectConfig of area.spriteEffects) {
const key = `${area.id}::${effectConfig.id}`; const key = `${area.id}::${effectConfig.id}`;
activeKeys.add(key); activeKeys.add(key);
if (this.instances.has(key)) continue; if (this.instances.has(key)) continue;
console.log("[SpriteEffectManager] \uC778\uC2A4\uD134\uC2A4 \uC0DD\uC131:", key, effectConfig.spriteUrl);
const instance = new SpriteEffectInstance(effectConfig); const instance = new SpriteEffectInstance(effectConfig);
this.instances.set(key, instance); this.instances.set(key, instance);
this.effectGroup.add(instance.group); this.effectGroup.add(instance.group);
@ -662,33 +704,32 @@ var SpriteEffectManager = class {
} }
/** /**
* 프레임 업데이트 * 프레임 업데이트
* @param areas 현재 영역 배열 * @param effectAreas 이펙트 영역 배열
* @param deltaTime 단위 프레임 시간 * @param deltaTime 단위 프레임 시간
* @param touchState 마우스/터치 상태 * @param touchState 마우스/터치 상태
*/ */
update(areas, deltaTime, touchState) { update(effectAreas, deltaTime, touchState) {
const currentTouchingAreas = /* @__PURE__ */ new Set(); const currentTouchingAreas = /* @__PURE__ */ new Set();
if (touchState.isDragging && touchState.position) { if (touchState.isDragging && touchState.position) {
for (const area of areas) { for (const area of effectAreas) {
if (isPointInPolygon(touchState.position, area.basePoints)) { const radius = area.radius ?? 0.1;
if (isPointInCircle(touchState.position, area.position, radius)) {
currentTouchingAreas.add(area.id); currentTouchingAreas.add(area.id);
} }
} }
} }
for (const area of areas) { for (const area of effectAreas) {
if (!area.spriteEffects) continue; for (const effectConfig of area.effects) {
const center = getAreaCenter(area);
for (const effectConfig of area.spriteEffects) {
const key = `${area.id}::${effectConfig.id}`; const key = `${area.id}::${effectConfig.id}`;
const instance = this.instances.get(key); const instance = this.instances.get(key);
if (!instance) continue; if (!instance) continue;
if (effectConfig.trigger === "touch") { if (effectConfig.trigger === "touch") {
const isNewTouch = currentTouchingAreas.has(area.id) && !this.previousTouchingAreas.has(area.id); const isNewTouch = currentTouchingAreas.has(area.id) && !this.previousTouchingAreas.has(area.id);
if (isNewTouch) { 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; this.previousTouchingAreas = currentTouchingAreas;
@ -978,7 +1019,7 @@ var SpringPhysics = class {
}; };
// src/hooks/useMouseInteraction.ts // src/hooks/useMouseInteraction.ts
var isPointInPolygon2 = (point, polygon) => { var isPointInPolygon = (point, polygon) => {
let inside = false; let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y; const xi = polygon[i].x, yi = polygon[i].y;
@ -1011,7 +1052,7 @@ var useMouseInteraction = (containerRef, config) => {
if (mouseState.isDragging && mouseState.position) { if (mouseState.isDragging && mouseState.position) {
const currentlyInAreas = /* @__PURE__ */ new Set(); const currentlyInAreas = /* @__PURE__ */ new Set();
for (let i = 0; i < areas.length; i++) { 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); currentlyInAreas.add(i);
if (!interactingAreaIndices.has(i)) { if (!interactingAreaIndices.has(i)) {
getSpringPhysics(i, areas[i]).reset(); getSpringPhysics(i, areas[i]).reset();
@ -1167,7 +1208,8 @@ var ImageDistortion = ({
fragmentShaderPath, fragmentShaderPath,
style, style,
className, className,
mouseInteraction mouseInteraction,
spriteEffectAreas = []
}) => { }) => {
const containerRef = useRef4(null); const containerRef = useRef4(null);
const sceneRef = useRef4(null); const sceneRef = useRef4(null);
@ -1208,8 +1250,8 @@ var ImageDistortion = ({
}; };
}, [isReady]); }, [isReady]);
useEffect3(() => { useEffect3(() => {
spriteManagerRef.current?.syncEffects(currentAreas); spriteManagerRef.current?.syncEffects(spriteEffectAreas);
}, [currentAreas]); }, [spriteEffectAreas, isReady]);
useEffect3(() => { useEffect3(() => {
if (mouseInteraction) { if (mouseInteraction) {
mouseInteractionHook.updateConfig(mouseInteraction); mouseInteractionHook.updateConfig(mouseInteraction);
@ -1343,15 +1385,16 @@ var ImageDistortion = ({
if (spriteManagerRef.current) { if (spriteManagerRef.current) {
const mouseState = mouseInteractionHook.getMouseState(); const mouseState = mouseInteractionHook.getMouseState();
spriteManagerRef.current.update( spriteManagerRef.current.update(
currentAreasRef.current, spriteEffectAreas,
deltaTime, deltaTime,
{ {
position: mouseState.position ?? null, position: mouseState.position ?? null,
isDragging: mouseState.isDragging isDragging: mouseState.isDragging
} }
); );
sceneRef.current?.render();
} }
}, [isReady, mouseInteraction, mouseInteractionHook]); }, [isReady, mouseInteraction, mouseInteractionHook, spriteEffectAreas]);
useAnimationFrame(animationCallback, true); useAnimationFrame(animationCallback, true);
return /* @__PURE__ */ jsx( return /* @__PURE__ */ jsx(
"div", "div",
@ -1717,12 +1760,15 @@ var EditorCanvas = ({
onStopDragging, onStopDragging,
style: customStyle, style: customStyle,
showEditor = true, showEditor = true,
onSelectArea onSelectArea,
spriteEffectAreas = [],
onUpdateSpriteEffectArea
}) => { }) => {
const containerRef = useRef5(null); const containerRef = useRef5(null);
const [canvasSize, setCanvasSize] = useState4({ width: 0, height: 0 }); const [canvasSize, setCanvasSize] = useState4({ width: 0, height: 0 });
const [isDraggingArea, setIsDraggingArea] = useState4(false); const [isDraggingArea, setIsDraggingArea] = useState4(false);
const [dragStartPos, setDragStartPos] = useState4(null); const [dragStartPos, setDragStartPos] = useState4(null);
const [draggingSpriteAreaId, setDraggingSpriteAreaId] = useState4(null);
const editorStyle = useMemo(() => ({ const editorStyle = useMemo(() => ({
...DEFAULT_EDITOR_CANVAS_STYLE, ...DEFAULT_EDITOR_CANVAS_STYLE,
...customStyle, ...customStyle,
@ -1755,7 +1801,7 @@ var EditorCanvas = ({
}; };
}, []); }, []);
const selectedArea = areas.find((a) => a.id === selectedAreaId); const selectedArea = areas.find((a) => a.id === selectedAreaId);
const isPointInPolygon3 = useCallback5((point, polygon) => { const isPointInPolygon2 = useCallback5((point, polygon) => {
let inside = false; let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y; const xi = polygon[i].x, yi = polygon[i].y;
@ -1789,7 +1835,21 @@ var EditorCanvas = ({
const x = (clientX - rect.left) / rect.width; const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height; const y = (clientY - rect.top) / rect.height;
const clickPoint = { x, y }; const clickPoint = { x, y };
if (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); setIsDraggingArea(true);
setDragStartPos(clickPoint); setDragStartPos(clickPoint);
e.preventDefault(); e.preventDefault();
@ -1798,7 +1858,7 @@ var EditorCanvas = ({
if (onSelectArea) { if (onSelectArea) {
for (let i = areas.length - 1; i >= 0; i--) { for (let i = areas.length - 1; i >= 0; i--) {
const area = areas[i]; const area = areas[i];
if (area.id !== selectedAreaId && isPointInPolygon3(clickPoint, area.basePoints)) { if (area.id !== selectedAreaId && isPointInPolygon2(clickPoint, area.basePoints)) {
onSelectArea(area.id); onSelectArea(area.id);
e.preventDefault(); e.preventDefault();
return; return;
@ -1806,12 +1866,12 @@ var EditorCanvas = ({
} }
} }
}, },
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon3, onSelectArea] [showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea, spriteEffectAreas, onUpdateSpriteEffectArea]
); );
const handleMove = useCallback5( const handleMove = useCallback5(
(e) => { (e) => {
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(); e.preventDefault();
} }
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
@ -1826,6 +1886,22 @@ var EditorCanvas = ({
} }
const x = (clientX - rect.left) / rect.width; const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height; const y = (clientY - rect.top) / rect.height;
if (draggingSpriteAreaId && dragStartPos && onUpdateSpriteEffectArea) {
const sa = spriteEffectAreas.find((a) => a.id === draggingSpriteAreaId);
if (sa) {
const deltaX = x - dragStartPos.x;
const deltaY = y - dragStartPos.y;
onUpdateSpriteEffectArea(sa.id, {
position: {
x: Math.max(0, Math.min(1, sa.position.x + deltaX)),
y: Math.max(0, Math.min(1, sa.position.y + deltaY))
}
});
setDragStartPos({ x, y });
}
return;
}
if (!selectedArea) return;
if (draggingPointIndex !== null) { if (draggingPointIndex !== null) {
const clampedX = Math.max(0, Math.min(1, x)); const clampedX = Math.max(0, Math.min(1, x));
const clampedY = Math.max(0, Math.min(1, y)); const clampedY = Math.max(0, Math.min(1, y));
@ -1841,7 +1917,7 @@ var EditorCanvas = ({
setDragStartPos({ x, y }); setDragStartPos({ x, y });
} }
}, },
[showEditor, draggingPointIndex, isDraggingArea, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea] [showEditor, draggingPointIndex, isDraggingArea, draggingSpriteAreaId, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea, spriteEffectAreas, onUpdateSpriteEffectArea]
); );
const handleUp = useCallback5(() => { const handleUp = useCallback5(() => {
if (draggingPointIndex !== null) { if (draggingPointIndex !== null) {
@ -1851,9 +1927,13 @@ var EditorCanvas = ({
setIsDraggingArea(false); setIsDraggingArea(false);
setDragStartPos(null); setDragStartPos(null);
} }
}, [draggingPointIndex, isDraggingArea, onStopDragging]); if (draggingSpriteAreaId) {
setDraggingSpriteAreaId(null);
setDragStartPos(null);
}
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, onStopDragging]);
useEffect4(() => { useEffect4(() => {
if (draggingPointIndex !== null || isDraggingArea) { if (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId) {
window.addEventListener("mouseup", handleUp); window.addEventListener("mouseup", handleUp);
window.addEventListener("touchend", handleUp); window.addEventListener("touchend", handleUp);
window.addEventListener("touchcancel", handleUp); window.addEventListener("touchcancel", handleUp);
@ -1863,7 +1943,7 @@ var EditorCanvas = ({
window.removeEventListener("touchcancel", handleUp); window.removeEventListener("touchcancel", handleUp);
}; };
} }
}, [draggingPointIndex, isDraggingArea, handleUp]); }, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, handleUp]);
const uvToPixel = (u, v, points, canvasWidth, canvasHeight) => { const uvToPixel = (u, v, points, canvasWidth, canvasHeight) => {
const [p0, p1, p2, p3] = points; const [p0, p1, p2, p3] = points;
const leftX = p0.x * (1 - u) + p1.x * u; const leftX = p0.x * (1 - u) + p1.x * u;
@ -1931,6 +2011,7 @@ var EditorCanvas = ({
const getCursorStyle = () => { const getCursorStyle = () => {
if (draggingPointIndex !== null) return "grabbing"; if (draggingPointIndex !== null) return "grabbing";
if (isDraggingArea) return "grabbing"; if (isDraggingArea) return "grabbing";
if (draggingSpriteAreaId) return "grabbing";
return "default"; return "default";
}; };
return /* @__PURE__ */ jsxs3( return /* @__PURE__ */ jsxs3(
@ -1952,7 +2033,7 @@ var EditorCanvas = ({
onTouchStart: showEditor ? handleCanvasDown : void 0, onTouchStart: showEditor ? handleCanvasDown : void 0,
onTouchMove: showEditor ? handleMove : void 0, onTouchMove: showEditor ? handleMove : void 0,
children: [ children: [
/* @__PURE__ */ jsx4(ImageDistortion, { imageSrc, areas }), /* @__PURE__ */ jsx4(ImageDistortion, { imageSrc, areas, spriteEffectAreas }),
showEditor && /* @__PURE__ */ jsx4( showEditor && /* @__PURE__ */ jsx4(
"svg", "svg",
{ {
@ -2051,6 +2132,88 @@ var EditorCanvas = ({
}, },
index index
); );
}),
showEditor && spriteEffectAreas.map((sa) => {
const cx = sa.position.x * 100;
const cy = sa.position.y * 100;
const isDragging = draggingSpriteAreaId === sa.id;
const radiusPx = (sa.radius ?? 0.1) * canvasSize.width;
return /* @__PURE__ */ jsxs3(
"div",
{
style: {
position: "absolute",
left: `${cx}%`,
top: `${cy}%`,
transform: "translate(-50%, -50%)",
pointerEvents: "none"
},
children: [
/* @__PURE__ */ jsx4(
"svg",
{
width: radiusPx * 2,
height: radiusPx * 2,
style: {
position: "absolute",
left: -radiusPx,
top: -radiusPx,
pointerEvents: "none"
},
children: /* @__PURE__ */ jsx4(
"circle",
{
cx: radiusPx,
cy: radiusPx,
r: radiusPx,
fill: isDragging ? "rgba(255, 170, 0, 0.15)" : "rgba(255, 170, 0, 0.08)",
stroke: isDragging ? "#ffaa00" : "rgba(255, 170, 0, 0.6)",
strokeWidth: isDragging ? 2 : 1.5,
strokeDasharray: isDragging ? "0" : "4,3"
}
)
}
),
/* @__PURE__ */ jsx4(
"div",
{
style: {
position: "absolute",
left: -8,
top: -8,
width: 16,
height: 16,
borderRadius: "50%",
backgroundColor: isDragging ? "#ffaa00" : "rgba(255, 170, 0, 0.8)",
border: "2px solid white",
cursor: isDragging ? "grabbing" : "grab",
pointerEvents: "auto",
boxShadow: "0 2px 4px rgba(0,0,0,0.3)"
}
}
),
/* @__PURE__ */ jsx4(
"div",
{
style: {
position: "absolute",
top: -24,
left: "50%",
transform: "translateX(-50%)",
fontSize: 10,
color: "#ffaa00",
fontWeight: "bold",
textShadow: "1px 1px 2px rgba(0,0,0,0.8)",
whiteSpace: "nowrap",
pointerEvents: "none"
},
children: "\u2728"
}
)
]
},
`sprite-${sa.id}`
);
}) })
] ]
} }

2
dist/index.mjs.map vendored

File diff suppressed because one or more lines are too long

BIN
petal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as THREE from 'three'; import * as THREE from 'three';
import { type DistortionArea } from '@/types'; import {type DistortionArea, SpriteEffectArea} from '@/types';
import { ThreeScene } from '@/engine/ThreeScene'; import { ThreeScene } from '@/engine/ThreeScene';
import { ShaderManager } from '@/engine/ShaderManager'; import { ShaderManager } from '@/engine/ShaderManager';
import { AnimationLoop } from '@/engine/AnimationLoop'; import { AnimationLoop } from '@/engine/AnimationLoop';
@ -28,6 +28,8 @@ export interface ImageDistortionProps {
className?: string; className?: string;
/** 마우스 인터랙션 설정 */ /** 마우스 인터랙션 설정 */
mouseInteraction?: MouseInteractionConfig; mouseInteraction?: MouseInteractionConfig;
/** 독립 스프라이트 이펙트 영역 (왜곡 영역과 분리) */
spriteEffectAreas?: SpriteEffectArea[];
} }
/** /**
@ -42,6 +44,7 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
style, style,
className, className,
mouseInteraction, mouseInteraction,
spriteEffectAreas = [],
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<ThreeScene | null>(null); const sceneRef = useRef<ThreeScene | null>(null);
@ -91,10 +94,10 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
}; };
}, [isReady]); }, [isReady]);
// 영역 변경 시 스프라이트 이펙트 동기화 // 이펙트 영역 변경 또는 매니저 준비 시 스프라이트 이펙트 동기화
useEffect(() => { useEffect(() => {
spriteManagerRef.current?.syncEffects(currentAreas); spriteManagerRef.current?.syncEffects(spriteEffectAreas);
}, [currentAreas]); }, [spriteEffectAreas, isReady]);
// 마우스 인터랙션 설정 변경 시 업데이트 // 마우스 인터랙션 설정 변경 시 업데이트
useEffect(() => { useEffect(() => {
@ -271,19 +274,22 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
return updatedAreas; return updatedAreas;
}); });
// 스프라이트 이펙트 업데이트 (디스토션과 독립적) // 스프라이트 이펙트 업데이트 (왜곡 영역과 독립적)
if (spriteManagerRef.current) { if (spriteManagerRef.current) {
const mouseState = mouseInteractionHook.getMouseState(); const mouseState = mouseInteractionHook.getMouseState();
spriteManagerRef.current.update( spriteManagerRef.current.update(
currentAreasRef.current, spriteEffectAreas,
deltaTime, deltaTime,
{ {
position: mouseState.position ?? null, position: mouseState.position ?? null,
isDragging: mouseState.isDragging, isDragging: mouseState.isDragging,
} }
); );
// 스프라이트 메쉬 변경 후 렌더링 필요
sceneRef.current?.render();
} }
}, [isReady, mouseInteraction, mouseInteractionHook]); }, [isReady, mouseInteraction, mouseInteractionHook, spriteEffectAreas]);
// 애니메이션 루프 실행 // 애니메이션 루프 실행
useAnimationFrame(animationCallback, true); useAnimationFrame(animationCallback, true);

View File

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

View File

@ -1,6 +1,6 @@
import * as THREE from 'three'; import * as THREE from 'three';
import type { Point } from '@/types'; import type { Point } from '@/types';
import type { SpriteEffectConfig } from '@/types/spriteEffect'; import type { SpriteEffectConfig, SpriteSheetConfig } from '@/types/spriteEffect';
import { SpriteParticlePool, type SpriteParticle } from './SpriteParticlePool'; import { SpriteParticlePool, type SpriteParticle } from './SpriteParticlePool';
/** /**
@ -74,12 +74,29 @@ export class SpriteEffectInstance {
url, url,
(texture) => { (texture) => {
this.texture = 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) { for (const mesh of this.meshes) {
(mesh.material as THREE.MeshBasicMaterial).map = texture; const mat = mesh.material as THREE.MeshBasicMaterial;
(mesh.material as THREE.MeshBasicMaterial).needsUpdate = true; 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; this.ready = true;
console.log(`[SpriteEffectInstance] 텍스처 로드 성공: ${url}`, texture.image.width, 'x', texture.image.height);
}, },
undefined, undefined,
(error) => { (error) => {
@ -127,6 +144,8 @@ export class SpriteEffectInstance {
particle.opacity = 1; particle.opacity = 1;
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]); particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
particle.age = 0; particle.age = 0;
particle.frameTime = 0;
particle.frameIndex = 0;
} }
/** /**
@ -160,6 +179,8 @@ export class SpriteEffectInstance {
* @param deltaTime * @param deltaTime
* @param emitCenter ( 0-1) * @param emitCenter ( 0-1)
*/ */
private _logCounter = 0;
update(deltaTime: number, emitCenter: Point): void { update(deltaTime: number, emitCenter: Point): void {
if (!this.ready) return; if (!this.ready) return;
@ -172,6 +193,10 @@ export class SpriteEffectInstance {
// 활성 파티클 업데이트 // 활성 파티클 업데이트
const activeParticles = this.pool.getActiveParticles(); 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) { for (const particle of activeParticles) {
particle.age += deltaTime; 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.x += particle.velocity.x * deltaTime;
particle.position.y += particle.velocity.y * 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 * Three.js
* (0-1) NDC(-1~1) , y축 * (0-1) NDC(-1~1) , y축
@ -229,13 +282,24 @@ export class SpriteEffectInstance {
// 좌표 변환: 정규화(0-1) → NDC(-1~1), y 반전 // 좌표 변환: 정규화(0-1) → NDC(-1~1), y 반전
mesh.position.x = particle.position.x * 2 - 1; mesh.position.x = particle.position.x * 2 - 1;
mesh.position.y = -(particle.position.y * 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.scale.set(particle.scale, particle.scale, 1);
mesh.rotation.z = particle.rotation; mesh.rotation.z = particle.rotation;
const mat = mesh.material as THREE.MeshBasicMaterial; const mat = mesh.material as THREE.MeshBasicMaterial;
mat.opacity = particle.opacity; 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,
);
}
} }
/** /**

View File

@ -1,5 +1,6 @@
import * as THREE from 'three'; 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'; import { SpriteEffectInstance } from './SpriteEffectInstance';
/** /**
@ -13,34 +14,18 @@ export interface SpriteEffectTouchState {
} }
/** /**
* ( ) *
*/ */
const isPointInPolygon = (point: Point, polygon: Point[]): boolean => { const isPointInCircle = (point: Point, center: Point, radius: number): boolean => {
let inside = false; const dx = point.x - center.x;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const dy = point.y - center.y;
const xi = polygon[i].x, yi = polygon[i].y; return dx * dx + dy * dy <= radius * radius;
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,
};
}; };
/** /**
* *
* ImageDistortion * ImageDistortion
* (DistortionArea)
*/ */
export class SpriteEffectManager { 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>(); const activeKeys = new Set<string>();
for (const area of areas) { for (const area of effectAreas) {
if (!area.spriteEffects) continue; for (const effectConfig of area.effects) {
for (const effectConfig of area.spriteEffects) {
const key = `${area.id}::${effectConfig.id}`; const key = `${area.id}::${effectConfig.id}`;
activeKeys.add(key); activeKeys.add(key);
@ -79,6 +63,7 @@ export class SpriteEffectManager {
if (this.instances.has(key)) continue; if (this.instances.has(key)) continue;
// 새 인스턴스 생성 // 새 인스턴스 생성
console.log('[SpriteEffectManager] 인스턴스 생성:', key, effectConfig.spriteUrl);
const instance = new SpriteEffectInstance(effectConfig); const instance = new SpriteEffectInstance(effectConfig);
this.instances.set(key, instance); this.instances.set(key, instance);
this.effectGroup.add(instance.group); this.effectGroup.add(instance.group);
@ -97,29 +82,26 @@ export class SpriteEffectManager {
/** /**
* *
* @param areas * @param effectAreas
* @param deltaTime * @param deltaTime
* @param touchState / * @param touchState /
*/ */
update(areas: DistortionArea[], deltaTime: number, touchState: SpriteEffectTouchState): void { update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState): void {
// 현재 터치 중인 영역 감지 // 현재 터치 중인 영역 감지
const currentTouchingAreas = new Set<string>(); const currentTouchingAreas = new Set<string>();
if (touchState.isDragging && touchState.position) { if (touchState.isDragging && touchState.position) {
for (const area of areas) { for (const area of effectAreas) {
if (isPointInPolygon(touchState.position, area.basePoints)) { const radius = area.radius ?? 0.1;
if (isPointInCircle(touchState.position, area.position, radius)) {
currentTouchingAreas.add(area.id); currentTouchingAreas.add(area.id);
} }
} }
} }
// 각 영역의 이펙트 업데이트 // 각 영역의 이펙트 업데이트
for (const area of areas) { for (const area of effectAreas) {
if (!area.spriteEffects) continue; for (const effectConfig of area.effects) {
const center = getAreaCenter(area);
for (const effectConfig of area.spriteEffects) {
const key = `${area.id}::${effectConfig.id}`; const key = `${area.id}::${effectConfig.id}`;
const instance = this.instances.get(key); const instance = this.instances.get(key);
if (!instance) continue; if (!instance) continue;
@ -129,12 +111,12 @@ export class SpriteEffectManager {
const isNewTouch = currentTouchingAreas.has(area.id) const isNewTouch = currentTouchingAreas.has(area.id)
&& !this.previousTouchingAreas.has(area.id); && !this.previousTouchingAreas.has(area.id);
if (isNewTouch) { if (isNewTouch) {
instance.triggerBurst(touchState.position ?? center); instance.triggerBurst(touchState.position ?? area.position);
} }
} }
// 매 프레임 업데이트 (ambient 방출 + 파티클 물리) // 매 프레임 업데이트 (ambient 방출 + 파티클 물리)
instance.update(deltaTime, center); instance.update(deltaTime, area.position);
} }
} }

View File

@ -22,6 +22,10 @@ export interface SpriteParticle {
age: number; age: number;
/** 수명 (초) */ /** 수명 (초) */
lifetime: number; lifetime: number;
/** 스프라이트 시트 프레임 누적 시간 */
frameTime: number;
/** 현재 프레임 인덱스 */
frameIndex: number;
} }
/** /**
@ -48,6 +52,8 @@ export class SpriteParticlePool {
opacity: 1, opacity: 1,
age: 0, age: 0,
lifetime: 1, lifetime: 1,
frameTime: 0,
frameIndex: 0,
}; };
} }

View File

@ -15,7 +15,7 @@ export class ThreeScene {
// 씬 생성 // 씬 생성
this.scene = new THREE.Scene(); this.scene = new THREE.Scene();
// 2D용 직교 카메라 설정 // 2D용 직교 카메라 설정 (카메라는 -z 방향, near=0 ~ far=1)
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
// 렌더러 설정 // 렌더러 설정

View File

@ -56,7 +56,10 @@ export type {
SpriteEffectTrigger, SpriteEffectTrigger,
SpriteBlendMode, SpriteBlendMode,
SpriteEffectConfig, SpriteEffectConfig,
SpriteEffectArea,
SpriteEffectAreaData,
SpriteParticleOverLifetime, SpriteParticleOverLifetime,
SpriteSheetConfig,
} from './types/spriteEffect'; } from './types/spriteEffect';
// 유틸리티 함수 // 유틸리티 함수

View File

@ -1,5 +1,3 @@
import type { SpriteEffectConfig } from './spriteEffect';
/** /**
* 2D (0.0 - 1.0) * 2D (0.0 - 1.0)
*/ */
@ -101,8 +99,6 @@ export interface DistortionArea {
}; };
/** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */ /** 스텝 양자화 단계 수 (0=없음, 1~5단계, 이징과 독립적으로 적용) */
snapSteps?: number; snapSteps?: number;
/** 스프라이트 이펙트 설정 배열 */
spriteEffects?: SpriteEffectConfig[];
} }
/** /**

View File

@ -20,6 +20,64 @@ export interface SpriteParticleOverLifetime {
velocityDamping?: number; 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; emitRadius?: number;
/** 수명 기반 속성 보간 */ /** 수명 기반 속성 보간 */
overLifetime?: SpriteParticleOverLifetime; overLifetime?: SpriteParticleOverLifetime;
/** 스프라이트 시트 설정 (없으면 정적 이미지) */
spriteSheet?: SpriteSheetConfig;
} }