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(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
View File

@ -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
View File

@ -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
View File

@ -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

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,
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

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 * 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);

View File

@ -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',
}}
>
&#x2728;
</div>
</div>
);
})}
</div>
);
};

View File

@ -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,
);
}
}
/**

View File

@ -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);
}
}

View File

@ -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,
};
}

View File

@ -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);
// 렌더러 설정

View File

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

View File

@ -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[];
}
/**

View File

@ -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;
}