BaekRyang 530e6d0396 Refactor sprite effects to be independent and support sprite sheets
- 스프라이트 이펙트를 왜곡 영역에서 분리하여 독립적인 영역으로 관리
- 스프라이트 시트 애니메이션 기능 추가 및 UV 제어 로직 구현
- 에디터 내 스프라이트 이펙트 영역 시각화 및 드래그 이동 기능 추가
- 이펙트 감지 방식을 다각형에서 원형(Radius) 기반으로 변경
- 관련 타입 정의 및 매니저 클래스 리팩토링
2026-03-11 08:32:36 +09:00

2250 lines
75 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/components/ImageDistortion.tsx
import { useEffect as useEffect3, useRef as useRef4, useState as useState2, useCallback as useCallback3 } from "react";
import * as THREE4 from "three";
// src/engine/ThreeScene.ts
import * as THREE from "three";
var ThreeScene = class {
constructor(container) {
this.container = container;
this.mesh = null;
/**
* 윈도우 리사이즈 핸들러
*/
this.handleResize = () => {
const width = this.container.clientWidth;
const height = this.container.clientHeight;
this.renderer.setSize(width, height);
this.uniforms.u_resolution.value.set(width, height);
if (this.mesh) {
this.render();
}
};
this.scene = new THREE.Scene();
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.container.appendChild(this.renderer.domElement);
this.uniforms = {
u_resolution: { value: new THREE.Vector2() },
u_texture: { value: null },
u_points: { value: new Float32Array(64) },
// 32포인트 × 2(x,y)
u_numAreas: { value: 0 },
u_dragVectors: { value: new Float32Array(16) },
// 8벡터 × 2(x,y)
u_distortionStrengths: { value: new Float32Array(8) },
u_lensEffects: { value: new Float32Array(8) }
};
this.handleResize();
window.addEventListener("resize", this.handleResize);
}
/**
* 셰이더 머티리얼 설정
* @param vertexShader 버텍스 셰이더 소스
* @param fragmentShader 프래그먼트 셰이더 소스
*/
setShaderMaterial(vertexShader, fragmentShader) {
console.log("[ThreeScene] setShaderMaterial \uD638\uCD9C\uB428");
console.log("[ThreeScene] vertexShader \uAE38\uC774:", vertexShader.length);
console.log("[ThreeScene] fragmentShader \uAE38\uC774:", fragmentShader.length);
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
uniforms: this.uniforms,
vertexShader,
fragmentShader
});
console.log("[ThreeScene] ShaderMaterial \uC0DD\uC131\uB428");
const renderer = this.renderer;
const testScene = new THREE.Scene();
const testMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
testScene.add(testMesh);
try {
renderer.compile(testScene, this.camera);
console.log("[ThreeScene] \uC170\uC774\uB354 \uCEF4\uD30C\uC77C \uC131\uACF5!");
} catch (e) {
console.error("[ThreeScene] \uC170\uC774\uB354 \uCEF4\uD30C\uC77C \uC5D0\uB7EC:", e);
}
if (this.mesh) {
this.scene.remove(this.mesh);
}
this.mesh = new THREE.Mesh(geometry, material);
this.mesh.renderOrder = 0;
this.scene.add(this.mesh);
console.log("[ThreeScene] mesh\uB97C \uC52C\uC5D0 \uCD94\uAC00\uD568");
}
/**
* Three.js 씬 객체 반환
*/
getScene() {
return this.scene;
}
/**
* 유니폼 값 업데이트
* @param updates 업데이트할 유니폼 값들
*/
updateUniforms(updates) {
Object.keys(updates).forEach((key) => {
const uniformKey = key;
this.uniforms[uniformKey].value = updates[uniformKey].value;
});
}
/**
* 씬 렌더링
*/
render() {
this.renderer.render(this.scene, this.camera);
}
/**
* 현재 해상도 가져오기
*/
getResolution() {
return {
x: this.uniforms.u_resolution.value.x,
y: this.uniforms.u_resolution.value.y
};
}
/**
* 리소스 정리
*/
dispose() {
window.removeEventListener("resize", this.handleResize);
this.renderer.dispose();
if (this.mesh) {
this.mesh.geometry.dispose();
this.mesh.material.dispose();
}
if (this.container.contains(this.renderer.domElement)) {
this.container.removeChild(this.renderer.domElement);
}
}
};
// src/engine/ShaderManager.ts
var ShaderManager = class {
constructor() {
this.vertexShaderSource = null;
this.fragmentShaderSource = null;
}
/**
* 셰이더 파일들을 비동기로 로드
* @param vertexPath 버텍스 셰이더 파일 경로
* @param fragmentPath 프래그먼트 셰이더 파일 경로
* @returns 로드된 셰이더 소스 코드
*/
async loadShaders(vertexPath, fragmentPath) {
console.log("[ShaderManager] loadShaders \uC2DC\uC791:", { vertexPath, fragmentPath });
try {
console.log("[ShaderManager] fetch \uC2DC\uC791...");
const [vertexResponse, fragmentResponse] = await Promise.all([
fetch(vertexPath),
fetch(fragmentPath)
]);
console.log("[ShaderManager] fetch \uC644\uB8CC:", {
vertexStatus: vertexResponse.status,
fragmentStatus: fragmentResponse.status
});
if (!vertexResponse.ok) {
throw new Error(`\uBC84\uD14D\uC2A4 \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328: ${vertexResponse.statusText}`);
}
if (!fragmentResponse.ok) {
throw new Error(`\uD504\uB798\uADF8\uBA3C\uD2B8 \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328: ${fragmentResponse.statusText}`);
}
console.log("[ShaderManager] text() \uBCC0\uD658 \uC2DC\uC791...");
this.vertexShaderSource = await vertexResponse.text();
this.fragmentShaderSource = await fragmentResponse.text();
console.log("[ShaderManager] \uC170\uC774\uB354 \uB85C\uB4DC \uC644\uB8CC!", {
vertexLength: this.vertexShaderSource.length,
fragmentLength: this.fragmentShaderSource.length
});
return {
vertex: this.vertexShaderSource,
fragment: this.fragmentShaderSource
};
} catch (error) {
console.error("[ShaderManager] \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328:", error);
throw new Error("\uC170\uC774\uB354 \uB85C\uB529\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4");
}
}
/**
* 버텍스 셰이더 소스 코드 반환
*/
getVertexShader() {
if (!this.vertexShaderSource) {
throw new Error("\uBC84\uD14D\uC2A4 \uC170\uC774\uB354\uAC00 \uB85C\uB4DC\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4");
}
return this.vertexShaderSource;
}
/**
* 프래그먼트 셰이더 소스 코드 반환
*/
getFragmentShader() {
if (!this.fragmentShaderSource) {
throw new Error("\uD504\uB798\uADF8\uBA3C\uD2B8 \uC170\uC774\uB354\uAC00 \uB85C\uB4DC\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4");
}
return this.fragmentShaderSource;
}
};
// src/utils/easing.ts
var easingFunctions = {
linear: (t) => t,
easeIn: (t) => t * t,
easeOut: (t) => t * (2 - t),
easeInOut: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t),
easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3)
};
var applyEasing = (progress, easingType) => {
const clampedProgress = Math.max(0, Math.min(1, progress));
return easingFunctions[easingType](clampedProgress);
};
// src/utils/motionPresets.ts
var presetRegistry = /* @__PURE__ */ new Map();
var rotationPresets = /* @__PURE__ */ new Set(["rotate-cw", "rotate-ccw"]);
var BUILT_IN_PRESETS = {
"none": () => ({ x: 0, y: 0 }),
"horizontal": (strength) => ({ x: strength, y: 0 }),
"vertical": (strength) => ({ x: 0, y: strength }),
"rotate-cw": (strength) => ({ x: strength, y: 0 }),
"rotate-ccw": (strength) => ({ x: -strength, y: 0 }),
"pulse": (strength) => ({ x: strength, y: strength }),
"diagonal-1": (strength) => ({ x: strength * 0.707, y: strength * 0.707 }),
"diagonal-2": (strength) => ({ x: strength * 0.707, y: -strength * 0.707 })
};
Object.entries(BUILT_IN_PRESETS).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
function registerMotionPreset(name, definition, options) {
presetRegistry.set(name, definition);
if (options?.isRotation) {
rotationPresets.add(name);
} else {
rotationPresets.delete(name);
}
}
function registerMotionPresets(presets, rotationPresetNames) {
Object.entries(presets).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
rotationPresetNames?.forEach((name) => rotationPresets.add(name));
}
function unregisterMotionPreset(name) {
rotationPresets.delete(name);
return presetRegistry.delete(name);
}
function getRegisteredPresets() {
return Array.from(presetRegistry.keys());
}
function hasPreset(name) {
return presetRegistry.has(name);
}
function resetToBuiltInPresets() {
presetRegistry.clear();
rotationPresets.clear();
Object.entries(BUILT_IN_PRESETS).forEach(([name, definition]) => {
presetRegistry.set(name, definition);
});
rotationPresets.add("rotate-cw");
rotationPresets.add("rotate-ccw");
}
function presetToVector(preset, strength = 0.1) {
const definition = presetRegistry.get(preset);
if (definition) {
return definition(strength);
}
console.warn(`Unknown motion preset: "${preset}". Falling back to "none".`);
return { x: 0, y: 0 };
}
function isRotationPreset(preset) {
if (!preset) return false;
return rotationPresets.has(preset);
}
// src/engine/AnimationLoop.ts
var AnimationLoop = class {
/**
* 영역들의 드래그 벡터를 현재 진행도에 따라 업데이트
* @param areas 왜곡 영역 배열
* @returns 업데이트된 영역 배열
*/
static updateAreaDragVectors(areas) {
return areas.map((area) => {
const { progress, movement } = area;
if (movement.duration <= 0 || movement.preset === "none") {
return {
...area,
dragVector: { x: 0, y: 0 }
};
}
let baseVector;
if (movement.preset) {
const strength = movement.strength ?? 0.1;
baseVector = presetToVector(movement.preset, strength);
} else {
baseVector = movement.vectorA;
}
const easedProgress = applyEasing(progress, movement.easing);
let dragVector;
const snapSteps = area.snapSteps ?? 0;
if (movement.preset && isRotationPreset(movement.preset)) {
const radius = Math.sqrt(baseVector.x * baseVector.x + baseVector.y * baseVector.y);
const direction = movement.preset === "rotate-cw" ? 1 : -1;
if (snapSteps > 0) {
const totalAngleSteps = snapSteps * 4;
const rawAngle = progress * Math.PI * 2;
const quantizedAngle = Math.round(rawAngle / (Math.PI * 2) * totalAngleSteps) / totalAngleSteps * Math.PI * 2;
dragVector = {
x: Math.cos(quantizedAngle * direction) * radius,
y: Math.sin(quantizedAngle * direction) * radius
};
} else {
const angle = easedProgress * Math.PI * 2;
dragVector = {
x: Math.cos(angle * direction) * radius,
y: Math.sin(angle * direction) * radius
};
}
} else {
if (snapSteps > 0) {
const oscillation = Math.sin(progress * Math.PI * 2);
const quantized = Math.round(oscillation * snapSteps) / snapSteps;
dragVector = {
x: baseVector.x * quantized,
y: baseVector.y * quantized
};
} else {
const oscillation = Math.sin(easedProgress * Math.PI * 2);
dragVector = {
x: baseVector.x * oscillation,
y: baseVector.y * oscillation
};
}
}
return {
...area,
dragVector
};
});
}
/**
* 모든 영역의 진행도를 델타 타임만큼 업데이트
* @param areas 왜곡 영역 배열
* @param deltaTime 델타 타임 (초)
* @returns 업데이트된 영역 배열
*/
static updateProgress(areas, deltaTime) {
return areas.map((area) => {
if (area.movement.duration <= 0) {
return area;
}
let newProgress = area.progress + deltaTime / area.movement.duration;
newProgress %= 1;
return {
...area,
progress: newProgress
};
});
}
};
// src/engine/SpriteEffectManager.ts
import * as THREE3 from "three";
// src/engine/SpriteEffectInstance.ts
import * as THREE2 from "three";
// src/engine/SpriteParticlePool.ts
var SpriteParticlePool = class {
constructor(maxParticles) {
this.particles = Array.from({ length: maxParticles }, (_, i) => this.createParticle(i));
}
/** 비활성 파티클 생성 */
createParticle(index) {
return {
index,
active: false,
position: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
scale: 1,
rotation: 0,
opacity: 1,
age: 0,
lifetime: 1,
frameTime: 0,
frameIndex: 0
};
}
/**
* 비활성 파티클을 활성화하여 반환
* 사용 가능한 파티클이 없으면 null 반환
*/
acquire() {
for (const particle of this.particles) {
if (!particle.active) {
particle.active = true;
particle.age = 0;
return particle;
}
}
return null;
}
/**
* 파티클을 비활성화하여 풀로 반환
*/
release(particle) {
particle.active = false;
}
/**
* 활성 파티클 목록 반환
*/
getActiveParticles() {
return this.particles.filter((p) => p.active);
}
/**
* 활성 파티클 수
*/
getActiveCount() {
let count = 0;
for (const p of this.particles) {
if (p.active) count++;
}
return count;
}
};
// src/engine/SpriteEffectInstance.ts
var randomRange = (min, max) => min + Math.random() * (max - min);
var lerp = (a, b, t) => a + (b - a) * t;
var SpriteEffectInstance = class {
constructor(config) {
this.texture = null;
this.ready = false;
this.emitAccumulator = 0;
/**
* 매 프레임 업데이트
* @param deltaTime 초 단위 프레임 시간
* @param emitCenter 방출 중심 (정규화 좌표 0-1)
*/
this._logCounter = 0;
this.config = config;
this.pool = new SpriteParticlePool(config.maxParticles);
this.group = new THREE2.Group();
this.geometry = new THREE2.PlaneGeometry(1, 1);
const blending = config.blendMode === "additive" ? THREE2.AdditiveBlending : THREE2.NormalBlending;
this.material = new THREE2.MeshBasicMaterial({
transparent: true,
depthTest: false,
depthWrite: false,
blending,
opacity: 0
});
this.meshes = Array.from({ length: config.maxParticles }, () => {
const mesh = new THREE2.Mesh(this.geometry, this.material.clone());
mesh.visible = false;
mesh.renderOrder = 1;
this.group.add(mesh);
return mesh;
});
this.loadTexture(config.spriteUrl);
}
/** 텍스처 로드 */
loadTexture(url) {
const loader = new THREE2.TextureLoader();
loader.load(
url,
(texture) => {
this.texture = texture;
const sheet = this.config.spriteSheet;
if (sheet) {
texture.repeat.set(1 / sheet.columns, 1 / sheet.rows);
texture.offset.set(0, 1 - 1 / sheet.rows);
}
for (const mesh of this.meshes) {
const mat = mesh.material;
if (sheet) {
const cloned = texture.clone();
cloned.repeat.copy(texture.repeat);
cloned.offset.copy(texture.offset);
mat.map = cloned;
} else {
mat.map = texture;
}
mat.needsUpdate = true;
}
this.ready = true;
console.log(`[SpriteEffectInstance] \uD14D\uC2A4\uCC98 \uB85C\uB4DC \uC131\uACF5: ${url}`, texture.image.width, "x", texture.image.height);
},
void 0,
(error) => {
console.error(`[SpriteEffectInstance] \uD14D\uC2A4\uCC98 \uB85C\uB4DC \uC2E4\uD328: ${url}`, error);
}
);
}
/**
* 파티클 1개 방출
* @param center 방출 중심 (정규화 좌표 0-1)
*/
emitOne(center) {
const particle = this.pool.acquire();
if (!particle) return;
const { config } = this;
let px = center.x + (config.emitOffset?.x ?? 0);
let py = center.y + (config.emitOffset?.y ?? 0);
if (config.emitRadius && config.emitRadius > 0) {
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * config.emitRadius;
px += Math.cos(angle) * radius;
py += Math.sin(angle) * radius;
}
particle.position.x = px;
particle.position.y = py;
const angleRange = config.emitAngle ?? [0, 360];
const angleDeg = randomRange(angleRange[0], angleRange[1]);
const angleRad = angleDeg * Math.PI / 180;
const speed = randomRange(config.initialSpeed[0], config.initialSpeed[1]);
particle.velocity.x = Math.cos(angleRad) * speed;
particle.velocity.y = Math.sin(angleRad) * speed;
particle.scale = randomRange(config.initialScale[0], config.initialScale[1]);
particle.rotation = 0;
particle.opacity = 1;
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
particle.age = 0;
particle.frameTime = 0;
particle.frameIndex = 0;
}
/**
* ambient 모드: 매 프레임 누적기 기반 방출
*/
updateAmbientEmit(deltaTime, center) {
if (!this.config.emitRate || this.config.emitRate <= 0) return;
this.emitAccumulator += deltaTime;
const interval = 1 / this.config.emitRate;
while (this.emitAccumulator >= interval) {
this.emitAccumulator -= interval;
this.emitOne(center);
}
}
/**
* touch 모드: 버스트 방출
*/
triggerBurst(center) {
if (!this.ready) return;
const count = this.config.burstCount ?? 1;
for (let i = 0; i < count; i++) {
this.emitOne(center);
}
}
update(deltaTime, emitCenter) {
if (!this.ready) return;
if (this.config.trigger === "ambient") {
this.updateAmbientEmit(deltaTime, emitCenter);
}
const overLifetime = this.config.overLifetime;
const activeParticles = this.pool.getActiveParticles();
if (this._logCounter++ % 60 === 0 && activeParticles.length > 0) {
const p = activeParticles[0];
console.log(`[SpriteEffectInstance] \uD65C\uC131 \uD30C\uD2F0\uD074: ${activeParticles.length}\uAC1C, \uCCAB \uD30C\uD2F0\uD074 pos=(${p.position.x.toFixed(3)}, ${p.position.y.toFixed(3)}), scale=${p.scale.toFixed(3)}, opacity=${p.opacity.toFixed(3)}`);
}
for (const particle of activeParticles) {
particle.age += deltaTime;
if (particle.age >= particle.lifetime) {
this.pool.release(particle);
this.syncMesh(particle);
continue;
}
const lifeRatio = particle.age / particle.lifetime;
if (overLifetime) {
if (overLifetime.scale) {
particle.scale = lerp(overLifetime.scale[0], overLifetime.scale[1], lifeRatio);
}
if (overLifetime.opacity) {
particle.opacity = lerp(overLifetime.opacity[0], overLifetime.opacity[1], lifeRatio);
}
if (overLifetime.rotationSpeed) {
particle.rotation += overLifetime.rotationSpeed * deltaTime;
}
if (overLifetime.velocityDamping !== void 0) {
const damping = Math.pow(overLifetime.velocityDamping, deltaTime);
particle.velocity.x *= damping;
particle.velocity.y *= damping;
}
}
if (this.config.spriteSheet) {
this.updateSpriteFrame(particle, deltaTime, this.config.spriteSheet);
}
particle.position.x += particle.velocity.x * deltaTime;
particle.position.y += particle.velocity.y * deltaTime;
this.syncMesh(particle);
}
}
/**
* 스프라이트 시트 프레임 진행
*/
updateSpriteFrame(particle, deltaTime, sheet) {
particle.frameTime += deltaTime;
const frameDuration = 1 / sheet.fps;
if (particle.frameTime >= frameDuration) {
particle.frameTime -= frameDuration;
const nextFrame = particle.frameIndex + 1;
if (nextFrame >= sheet.totalFrames) {
if (sheet.loop !== false) {
particle.frameIndex = 0;
}
} else {
particle.frameIndex = nextFrame;
}
}
}
/**
* 파티클 상태를 Three.js 메쉬에 동기화
* 정규화 좌표(0-1) → NDC(-1~1) 변환, y축 반전
*/
syncMesh(particle) {
const mesh = this.meshes[particle.index];
if (!mesh) return;
if (!particle.active) {
mesh.visible = false;
return;
}
mesh.visible = true;
mesh.position.x = particle.position.x * 2 - 1;
mesh.position.y = -(particle.position.y * 2 - 1);
mesh.position.z = -0.01;
mesh.scale.set(particle.scale, particle.scale, 1);
mesh.rotation.z = particle.rotation;
const mat = mesh.material;
mat.opacity = particle.opacity;
const sheet = this.config.spriteSheet;
if (sheet && mat.map) {
const col = particle.frameIndex % sheet.columns;
const row = Math.floor(particle.frameIndex / sheet.columns);
mat.map.offset.set(
col / sheet.columns,
1 - (row + 1) / sheet.rows
);
}
}
/**
* 텍스처 로딩 완료 여부
*/
isReady() {
return this.ready;
}
/**
* 리소스 정리
*/
dispose() {
if (this.texture) {
this.texture.dispose();
this.texture = null;
}
this.geometry.dispose();
for (const mesh of this.meshes) {
mesh.material.dispose();
}
this.material.dispose();
while (this.group.children.length > 0) {
this.group.remove(this.group.children[0]);
}
}
};
// src/engine/SpriteEffectManager.ts
var isPointInCircle = (point, center, radius) => {
const dx = point.x - center.x;
const dy = point.y - center.y;
return dx * dx + dy * dy <= radius * radius;
};
var SpriteEffectManager = class {
constructor() {
/** 영역ID+이펙트ID → 인스턴스 맵 */
this.instances = /* @__PURE__ */ new Map();
/** 이전 프레임에서 터치 중이던 영역 ID 세트 (버스트 감지용) */
this.previousTouchingAreas = /* @__PURE__ */ new Set();
this.effectGroup = new THREE3.Group();
this.effectGroup.renderOrder = 1;
}
/**
* Three.js 씬에 이펙트 그룹 추가
*/
attachToScene(scene) {
scene.add(this.effectGroup);
}
/**
* 이펙트 영역 설정 변경을 감지하여 인스턴스 생성/제거
*/
syncEffects(effectAreas) {
console.log("[SpriteEffectManager] syncEffects \uD638\uCD9C:", effectAreas.length, "\uAC1C \uC601\uC5ED");
const activeKeys = /* @__PURE__ */ new Set();
for (const area of effectAreas) {
for (const effectConfig of area.effects) {
const key = `${area.id}::${effectConfig.id}`;
activeKeys.add(key);
if (this.instances.has(key)) continue;
console.log("[SpriteEffectManager] \uC778\uC2A4\uD134\uC2A4 \uC0DD\uC131:", key, effectConfig.spriteUrl);
const instance = new SpriteEffectInstance(effectConfig);
this.instances.set(key, instance);
this.effectGroup.add(instance.group);
}
}
for (const [key, instance] of this.instances) {
if (!activeKeys.has(key)) {
instance.dispose();
this.effectGroup.remove(instance.group);
this.instances.delete(key);
}
}
}
/**
* 매 프레임 업데이트
* @param effectAreas 이펙트 영역 배열
* @param deltaTime 초 단위 프레임 시간
* @param touchState 마우스/터치 상태
*/
update(effectAreas, deltaTime, touchState) {
const currentTouchingAreas = /* @__PURE__ */ new Set();
if (touchState.isDragging && touchState.position) {
for (const area of effectAreas) {
const radius = area.radius ?? 0.1;
if (isPointInCircle(touchState.position, area.position, radius)) {
currentTouchingAreas.add(area.id);
}
}
}
for (const area of effectAreas) {
for (const effectConfig of area.effects) {
const key = `${area.id}::${effectConfig.id}`;
const instance = this.instances.get(key);
if (!instance) continue;
if (effectConfig.trigger === "touch") {
const isNewTouch = currentTouchingAreas.has(area.id) && !this.previousTouchingAreas.has(area.id);
if (isNewTouch) {
instance.triggerBurst(touchState.position ?? area.position);
}
}
instance.update(deltaTime, area.position);
}
}
this.previousTouchingAreas = currentTouchingAreas;
}
/**
* 리소스 정리
*/
dispose() {
for (const [, instance] of this.instances) {
instance.dispose();
}
this.instances.clear();
this.previousTouchingAreas.clear();
if (this.effectGroup.parent) {
this.effectGroup.parent.remove(this.effectGroup);
}
}
};
// src/hooks/useAnimationFrame.ts
import { useEffect, useRef } from "react";
var useAnimationFrame = (callback, isPlaying = true) => {
const requestRef = useRef(void 0);
const previousTimeRef = useRef(void 0);
useEffect(() => {
if (!isPlaying) return;
const animate = (time) => {
if (previousTimeRef.current !== void 0) {
const deltaTime = (time - previousTimeRef.current) / 1e3;
callback(deltaTime);
}
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(animate);
};
requestRef.current = requestAnimationFrame(animate);
return () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
}
};
}, [callback, isPlaying]);
};
// src/hooks/useMouseInteraction.ts
import { useRef as useRef3, useCallback as useCallback2, useState } from "react";
// src/hooks/useMouseVelocity.ts
import { useRef as useRef2, useCallback, useEffect as useEffect2 } from "react";
var useMouseVelocity = (containerRef) => {
const mouseStateRef = useRef2({
position: null,
prevPosition: null,
velocity: { x: 0, y: 0 },
acceleration: { x: 0, y: 0 },
isHovering: false,
isDragging: false
});
const lastUpdateTimeRef = useRef2(Date.now());
const prevVelocityRef = useRef2({ x: 0, y: 0 });
const toNormalized = useCallback((clientX, clientY) => {
if (!containerRef.current) return null;
const rect = containerRef.current.getBoundingClientRect();
return {
x: (clientX - rect.left) / rect.width,
y: (clientY - rect.top) / rect.height
};
}, [containerRef]);
const updatePosition = useCallback((clientX, clientY) => {
const now = Date.now();
const deltaTime = (now - lastUpdateTimeRef.current) / 1e3;
lastUpdateTimeRef.current = now;
const normalizedPos = toNormalized(clientX, clientY);
if (!normalizedPos) return;
const state = mouseStateRef.current;
const prevPos = state.position;
let velocity = { x: 0, y: 0 };
if (prevPos && deltaTime > 0) {
velocity = {
x: (normalizedPos.x - prevPos.x) / deltaTime,
y: (normalizedPos.y - prevPos.y) / deltaTime
};
}
const prevVel = prevVelocityRef.current;
let acceleration = { x: 0, y: 0 };
if (deltaTime > 0) {
acceleration = {
x: (velocity.x - prevVel.x) / deltaTime,
y: (velocity.y - prevVel.y) / deltaTime
};
}
mouseStateRef.current = {
position: normalizedPos,
prevPosition: prevPos,
velocity,
acceleration,
isHovering: true,
isDragging: state.isDragging
};
prevVelocityRef.current = velocity;
}, [toNormalized]);
const handleMouseMove = useCallback((e) => {
updatePosition(e.clientX, e.clientY);
}, [updatePosition]);
const handleMouseEnter = useCallback(() => {
mouseStateRef.current.isHovering = true;
}, []);
const handleMouseLeave = useCallback(() => {
mouseStateRef.current = {
position: null,
prevPosition: null,
velocity: { x: 0, y: 0 },
acceleration: { x: 0, y: 0 },
isHovering: false,
isDragging: false
};
prevVelocityRef.current = { x: 0, y: 0 };
}, []);
const handleMouseDown = useCallback(() => {
mouseStateRef.current.isDragging = true;
}, []);
const handleMouseUp = useCallback(() => {
mouseStateRef.current.isDragging = false;
}, []);
const handleTouchMove = useCallback((e) => {
e.preventDefault();
if (e.touches.length > 0) {
const touch = e.touches[0];
updatePosition(touch.clientX, touch.clientY);
}
}, [updatePosition]);
const handleTouchStart = useCallback((e) => {
e.preventDefault();
mouseStateRef.current.isDragging = true;
mouseStateRef.current.isHovering = true;
if (e.touches.length > 0) {
const touch = e.touches[0];
updatePosition(touch.clientX, touch.clientY);
}
}, [updatePosition]);
const handleTouchEnd = useCallback(() => {
mouseStateRef.current.isDragging = false;
mouseStateRef.current.isHovering = false;
mouseStateRef.current.position = null;
mouseStateRef.current.prevPosition = null;
mouseStateRef.current.velocity = { x: 0, y: 0 };
mouseStateRef.current.acceleration = { x: 0, y: 0 };
prevVelocityRef.current = { x: 0, y: 0 };
}, []);
useEffect2(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener("mousemove", handleMouseMove);
container.addEventListener("mouseenter", handleMouseEnter);
container.addEventListener("mouseleave", handleMouseLeave);
container.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mouseup", handleMouseUp);
container.addEventListener("touchmove", handleTouchMove, { passive: false });
container.addEventListener("touchstart", handleTouchStart, { passive: false });
container.addEventListener("touchend", handleTouchEnd);
container.addEventListener("touchcancel", handleTouchEnd);
return () => {
container.removeEventListener("mousemove", handleMouseMove);
container.removeEventListener("mouseenter", handleMouseEnter);
container.removeEventListener("mouseleave", handleMouseLeave);
container.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mouseup", handleMouseUp);
container.removeEventListener("touchmove", handleTouchMove);
container.removeEventListener("touchstart", handleTouchStart);
container.removeEventListener("touchend", handleTouchEnd);
container.removeEventListener("touchcancel", handleTouchEnd);
};
}, [containerRef, handleMouseMove, handleMouseEnter, handleMouseLeave, handleMouseDown, handleMouseUp, handleTouchMove, handleTouchStart, handleTouchEnd]);
const getState = useCallback(() => {
return { ...mouseStateRef.current };
}, []);
return {
getState
};
};
// src/engine/SpringPhysics.ts
var SpringPhysics = class {
constructor(config) {
this.config = config;
this.state = {
displacement: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
target: { x: 0, y: 0 }
};
}
/**
* 물리 파라미터 업데이트
*/
setConfig(config) {
this.config = { ...this.config, ...config };
}
/**
* 목표 위치 설정 (마우스 속도 기반)
*/
setTarget(velocity, velocityMultiplier = 1) {
this.state.target = {
x: velocity.x * velocityMultiplier,
y: velocity.y * velocityMultiplier
};
}
/**
* 초기 속도 설정 (드래그 방향과 속도를 즉시 반영)
* 드래그 방향으로 즉시 튕기는 효과
*/
setInitialVelocity(velocity, multiplier = 1) {
this.state.velocity = {
x: velocity.x * multiplier,
y: velocity.y * multiplier
};
this.state.target = { x: 0, y: 0 };
}
/**
* 스프링 물리 업데이트 (Hooke's Law + Damping)
* F = -k * x - c * v
* a = F / m
* v += a * dt
* x += v * dt
*/
update(deltaTime) {
const { stiffness, damping, mass } = this.config;
const { displacement, velocity, target } = this.state;
const dx = displacement.x - target.x;
const dy = displacement.y - target.y;
const springForceX = -stiffness * dx;
const springForceY = -stiffness * dy;
const dampingForceX = -damping * velocity.x;
const dampingForceY = -damping * velocity.y;
const totalForceX = springForceX + dampingForceX;
const totalForceY = springForceY + dampingForceY;
const accelerationX = totalForceX / mass;
const accelerationY = totalForceY / mass;
const newVelocityX = velocity.x + accelerationX * deltaTime;
const newVelocityY = velocity.y + accelerationY * deltaTime;
const newDisplacementX = displacement.x + newVelocityX * deltaTime;
const newDisplacementY = displacement.y + newVelocityY * deltaTime;
this.state = {
displacement: { x: newDisplacementX, y: newDisplacementY },
velocity: { x: newVelocityX, y: newVelocityY },
target
};
const isNearlyZero = (val) => Math.abs(val) < 1e-4;
if (isNearlyZero(this.state.displacement.x) && isNearlyZero(this.state.displacement.y) && isNearlyZero(this.state.velocity.x) && isNearlyZero(this.state.velocity.y)) {
this.reset();
}
return this.state.displacement;
}
/**
* 즉시 충격 적용 (마우스 가속도 기반)
*/
applyImpulse(acceleration, multiplier = 1) {
this.state.velocity.x += acceleration.x * multiplier;
this.state.velocity.y += acceleration.y * multiplier;
}
/**
* 현재 변위 가져오기
*/
getDisplacement() {
return { ...this.state.displacement };
}
/**
* 현재 속도 가져오기
*/
getVelocity() {
return { ...this.state.velocity };
}
/**
* 상태 리셋
*/
reset() {
this.state = {
displacement: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
target: { x: 0, y: 0 }
};
}
/**
* 마우스가 멈췄을 때 목표를 0으로 설정 (평형 상태로 복귀)
*/
returnToEquilibrium() {
this.state.target = { x: 0, y: 0 };
}
};
// src/hooks/useMouseInteraction.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 useMouseInteraction = (containerRef, config) => {
const { getState } = useMouseVelocity(containerRef);
const [interactingAreaIndices, setInteractingAreaIndices] = useState(/* @__PURE__ */ new Set());
const springPhysicsMapRef = useRef3(/* @__PURE__ */ new Map());
const getSpringPhysics = useCallback2((areaIndex, area) => {
if (!springPhysicsMapRef.current.has(areaIndex)) {
const physicsConfig = area?.physics || config.physics;
springPhysicsMapRef.current.set(areaIndex, new SpringPhysics(physicsConfig));
}
return springPhysicsMapRef.current.get(areaIndex);
}, [config.physics]);
const updateInteraction = useCallback2((areas, deltaTime) => {
if (!config.enabled) return areas;
areas.forEach((area, index) => {
if (area.physics && springPhysicsMapRef.current.has(index)) {
const spring = springPhysicsMapRef.current.get(index);
spring.setConfig(area.physics);
}
});
const mouseState = getState();
if (mouseState.isDragging && mouseState.position) {
const currentlyInAreas = /* @__PURE__ */ new Set();
for (let i = 0; i < areas.length; i++) {
if (isPointInPolygon(mouseState.position, areas[i].basePoints)) {
currentlyInAreas.add(i);
if (!interactingAreaIndices.has(i)) {
getSpringPhysics(i, areas[i]).reset();
}
}
}
interactingAreaIndices.forEach((areaIndex) => {
if (!currentlyInAreas.has(areaIndex)) {
getSpringPhysics(areaIndex, areas[areaIndex]).returnToEquilibrium();
}
});
setInteractingAreaIndices(currentlyInAreas);
const velocityMult = config.velocityMultiplier || 1;
const velocityMag = Math.sqrt(
mouseState.velocity.x ** 2 + mouseState.velocity.y ** 2
);
const minVel = config.minVelocity || 0.05;
const maxVel = config.maxVelocity || 5;
let clampedVelocity = mouseState.velocity;
if (velocityMag > maxVel) {
const scale = maxVel / velocityMag;
clampedVelocity = {
x: mouseState.velocity.x * scale,
y: mouseState.velocity.y * scale
};
}
currentlyInAreas.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
if (velocityMag >= minVel) {
spring.setTarget(clampedVelocity, velocityMult);
} else {
spring.returnToEquilibrium();
}
});
} else {
if (interactingAreaIndices.size > 0) {
const velocityMult = config.velocityMultiplier || 1;
const maxVel = config.maxVelocity || 5;
const velocityMag = Math.sqrt(
mouseState.velocity.x ** 2 + mouseState.velocity.y ** 2
);
let clampedVelocity = mouseState.velocity;
if (velocityMag > maxVel) {
const scale = maxVel / velocityMag;
clampedVelocity = {
x: mouseState.velocity.x * scale,
y: mouseState.velocity.y * scale
};
}
interactingAreaIndices.forEach((areaIndex) => {
const spring = getSpringPhysics(areaIndex, areas[areaIndex]);
spring.setInitialVelocity(clampedVelocity, velocityMult);
});
setInteractingAreaIndices(/* @__PURE__ */ new Set());
}
}
return areas.map((area, index) => {
const spring = springPhysicsMapRef.current.get(index);
if (!spring) return area;
const springVelocity = spring.getVelocity();
const springDisplacement = spring.getDisplacement();
const isSpringActive = Math.sqrt(springVelocity.x ** 2 + springVelocity.y ** 2) > 1e-3 || Math.sqrt(springDisplacement.x ** 2 + springDisplacement.y ** 2) > 1e-3;
if (!interactingAreaIndices.has(index) && !isSpringActive) {
return area;
}
const displacement = spring.update(deltaTime);
const displacementMag = Math.sqrt(displacement.x ** 2 + displacement.y ** 2);
if (displacementMag < 1e-3) {
return area;
}
return {
...area,
dragVector: {
x: area.dragVector.x - displacement.x,
y: area.dragVector.y - displacement.y
}
};
});
}, [config, getState, interactingAreaIndices, getSpringPhysics]);
const updateConfig = useCallback2((newConfig) => {
const physicsConfig = newConfig.physics;
if (physicsConfig) {
springPhysicsMapRef.current.forEach((spring) => {
spring.setConfig(physicsConfig);
});
}
}, []);
const reset = useCallback2(() => {
springPhysicsMapRef.current.forEach((spring) => {
spring.reset();
});
setInteractingAreaIndices(/* @__PURE__ */ new Set());
}, []);
const isDragging = useCallback2(() => {
const mouseState = getState();
return mouseState.isDragging;
}, [getState]);
const getInteractingAreaIndices = useCallback2(() => {
return interactingAreaIndices;
}, [interactingAreaIndices]);
return {
updateInteraction,
updateConfig,
reset,
isDragging,
getInteractingAreaIndices,
getMouseState: getState
};
};
// src/utils/constants.ts
var SHADER_CONFIG = {
/** 최대 영역 개수 */
MAX_AREAS: 8,
/** 최대 포인트 개수 (8영역 × 4포인트) */
MAX_POINTS: 32,
/** 최대 드래그 벡터 개수 */
MAX_DRAG_VECTORS: 8,
/** 최대 강도 배열 크기 */
MAX_STRENGTHS: 8,
/** 최대 렌즈 효과 배열 크기 */
MAX_LENS_EFFECTS: 8
};
var ANIMATION_CONFIG = {
/** 목표 FPS */
TARGET_FPS: 60,
/** 델타 타임 (약 16.67ms) */
DELTA_TIME: 1 / 60
};
var DEFAULT_AREA = {
/** 기본 왜곡 강도 */
DISTORTION_STRENGTH: 0.5,
/** 기본 애니메이션 지속 시간 (초) */
DURATION: 2,
/** 기본 이징 함수 */
EASING: "easeInOut",
/** 기본 벡터 A */
VECTOR_A: { x: 0.1, y: 0.1 },
/** 기본 벡터 B */
VECTOR_B: { x: -0.1, y: -0.1 },
/** 기본 렌즈 효과 강도 */
LENS_STRENGTH: 0,
/** 기본 스텝 양자화 단계 수 (0=없음) */
SNAP_STEPS: 0
};
// src/components/ImageDistortion.tsx
import { jsx } from "react/jsx-runtime";
var ImageDistortion = ({
imageSrc,
areas,
vertexShaderPath,
fragmentShaderPath,
style,
className,
mouseInteraction,
spriteEffectAreas = []
}) => {
const containerRef = useRef4(null);
const sceneRef = useRef4(null);
const shaderManagerRef = useRef4(new ShaderManager());
const textureRef = useRef4(null);
const spriteManagerRef = useRef4(null);
const currentAreasRef = useRef4(areas);
const [isReady, setIsReady] = useState2(false);
const [imageLoaded, setImageLoaded] = useState2(false);
const [currentAreas, setCurrentAreas] = useState2(areas);
const mouseInteractionHook = useMouseInteraction(
containerRef,
mouseInteraction || {
enabled: false,
physics: {
stiffness: 100,
damping: 10,
mass: 1,
influenceRadius: 0.2,
maxStrength: 1
}
}
);
useEffect3(() => {
setCurrentAreas(areas);
}, [areas]);
useEffect3(() => {
currentAreasRef.current = currentAreas;
}, [currentAreas]);
useEffect3(() => {
if (!sceneRef.current || !isReady) return;
const manager = new SpriteEffectManager();
manager.attachToScene(sceneRef.current.getScene());
spriteManagerRef.current = manager;
return () => {
manager.dispose();
spriteManagerRef.current = null;
};
}, [isReady]);
useEffect3(() => {
spriteManagerRef.current?.syncEffects(spriteEffectAreas);
}, [spriteEffectAreas, isReady]);
useEffect3(() => {
if (mouseInteraction) {
mouseInteractionHook.updateConfig(mouseInteraction);
}
}, [mouseInteraction, mouseInteractionHook]);
useEffect3(() => {
console.log("[ImageDistortion] useEffect \uC2E4\uD589, containerRef.current:", containerRef.current);
if (!containerRef.current) {
console.warn("[ImageDistortion] containerRef.current\uAC00 null\uC785\uB2C8\uB2E4. \uCEF4\uD3EC\uB10C\uD2B8\uAC00 \uC81C\uB300\uB85C \uB9C8\uC6B4\uD2B8\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.");
return;
}
console.log("[ImageDistortion] \uCD08\uAE30\uD654 \uC2DC\uC791");
const scene = new ThreeScene(containerRef.current);
sceneRef.current = scene;
const vertPath = vertexShaderPath || "/shaders/distortion.vert.glsl";
const fragPath = fragmentShaderPath || "/shaders/distortion.frag.glsl";
console.log("[ImageDistortion] \uC170\uC774\uB354 \uB85C\uB4DC \uC2DC\uB3C4:", { vertPath, fragPath });
shaderManagerRef.current.loadShaders(vertPath, fragPath).then(({ vertex, fragment }) => {
console.log("[ImageDistortion] \uC170\uC774\uB354 \uB85C\uB4DC \uC131\uACF5");
scene.setShaderMaterial(vertex, fragment);
setIsReady(true);
}).catch((error) => {
console.error("[ImageDistortion] \uC170\uC774\uB354 \uB85C\uB4DC \uC2E4\uD328:", error);
});
return () => {
scene.dispose();
if (textureRef.current) {
textureRef.current.dispose();
}
};
}, [vertexShaderPath, fragmentShaderPath]);
useEffect3(() => {
if (!imageSrc || !isReady) {
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2A4\uD0B5:", { imageSrc, isReady });
return;
}
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc);
setImageLoaded(false);
const loader = new THREE4.TextureLoader();
loader.load(
imageSrc,
(texture) => {
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC131\uACF5!", {
width: texture.image.width,
height: texture.image.height
});
textureRef.current = texture;
setImageLoaded(true);
if (sceneRef.current) {
sceneRef.current.updateUniforms({
u_texture: { value: texture }
});
sceneRef.current.render();
console.log("[ImageDistortion] \uD14D\uC2A4\uCC98 \uC5C5\uB370\uC774\uD2B8 \uBC0F \uB80C\uB354\uB9C1 \uC644\uB8CC");
}
},
(progress) => {
console.log(
"[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB529 \uC911...",
Math.round(progress.loaded / progress.total * 100) + "%"
);
},
(error) => {
console.error("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2E4\uD328:", error);
setImageLoaded(false);
}
);
return () => {
if (textureRef.current) {
textureRef.current.dispose();
textureRef.current = null;
}
};
}, [imageSrc, isReady]);
useEffect3(() => {
if (!sceneRef.current || !isReady) return;
const resolution = sceneRef.current.getResolution();
const points = new Float32Array(SHADER_CONFIG.MAX_POINTS * 2);
currentAreas.forEach((area, areaIndex) => {
area.basePoints.forEach((point, pointIndex) => {
const index = (areaIndex * 4 + pointIndex) * 2;
points[index] = point.x;
points[index + 1] = 1 - point.y;
});
});
const dragVectors = new Float32Array(SHADER_CONFIG.MAX_DRAG_VECTORS * 2);
currentAreas.forEach((area, index) => {
const baseIndex = index * 2;
dragVectors[baseIndex] = area.dragVector.x;
dragVectors[baseIndex + 1] = -area.dragVector.y;
});
const strengths = new Float32Array(SHADER_CONFIG.MAX_STRENGTHS);
currentAreas.forEach((area, index) => {
strengths[index] = area.distortionStrength;
});
const lensEffects = new Float32Array(SHADER_CONFIG.MAX_LENS_EFFECTS);
currentAreas.forEach((area, index) => {
lensEffects[index] = area.lensEffect?.strength ?? 0;
});
sceneRef.current.updateUniforms({
u_numAreas: { value: currentAreas.length },
u_points: { value: points },
u_dragVectors: { value: dragVectors },
u_distortionStrengths: { value: strengths },
u_lensEffects: { value: lensEffects }
});
sceneRef.current.render();
}, [currentAreas, isReady]);
const animationCallback = useCallback3((deltaTime) => {
if (!isReady) return;
setCurrentAreas((prevAreas) => {
const interactingIndices = mouseInteractionHook.getInteractingAreaIndices?.() || /* @__PURE__ */ new Set();
let updatedAreas = AnimationLoop.updateProgress(prevAreas, deltaTime);
updatedAreas = AnimationLoop.updateAreaDragVectors(updatedAreas);
if (interactingIndices.size > 0) {
updatedAreas = updatedAreas.map((area, index) => {
if (interactingIndices.has(index)) {
return {
...area,
dragVector: { x: 0, y: 0 }
};
}
return area;
});
}
if (mouseInteraction?.enabled) {
updatedAreas = mouseInteractionHook.updateInteraction(updatedAreas, deltaTime);
}
return updatedAreas;
});
if (spriteManagerRef.current) {
const mouseState = mouseInteractionHook.getMouseState();
spriteManagerRef.current.update(
spriteEffectAreas,
deltaTime,
{
position: mouseState.position ?? null,
isDragging: mouseState.isDragging
}
);
sceneRef.current?.render();
}
}, [isReady, mouseInteraction, mouseInteractionHook, spriteEffectAreas]);
useAnimationFrame(animationCallback, true);
return /* @__PURE__ */ jsx(
"div",
{
ref: containerRef,
style: {
width: "100%",
height: "100%",
position: "relative",
...style
},
className,
children: !imageLoaded && /* @__PURE__ */ jsx(
"div",
{
style: {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(0, 0, 0, 0.7)",
color: "white",
padding: "20px",
borderRadius: "8px",
zIndex: 999
},
children: "\uC774\uBBF8\uC9C0 \uB85C\uB529 \uC911..."
}
)
}
);
};
// src/editor/components/EditorCanvas.tsx
import { useRef as useRef5, useEffect as useEffect4, useState as useState4, useCallback as useCallback5, useMemo } from "react";
// src/editor/components/AreaList.tsx
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
var AreaList = ({
areas,
selectedAreaId,
onSelectArea,
onRemoveArea,
onAddArea
}) => {
return /* @__PURE__ */ jsxs("div", { className: "area-list", children: [
/* @__PURE__ */ jsxs("div", { className: "area-list-header", children: [
/* @__PURE__ */ jsx2("h3", { children: "\uC65C\uACE1 \uC601\uC5ED" }),
/* @__PURE__ */ jsx2(
"button",
{
onClick: onAddArea,
disabled: areas.length >= 8,
className: "btn-add",
title: areas.length >= 8 ? "\uCD5C\uB300 8\uAC1C \uC601\uC5ED\uAE4C\uC9C0 \uC9C0\uC6D0" : "\uC0C8 \uC601\uC5ED \uCD94\uAC00",
children: "+ \uCD94\uAC00"
}
)
] }),
/* @__PURE__ */ jsx2("div", { className: "area-list-items", children: areas.length === 0 ? /* @__PURE__ */ jsx2("div", { className: "area-list-empty", children: "\uC601\uC5ED\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. + \uCD94\uAC00 \uBC84\uD2BC\uC744 \uB20C\uB7EC\uC8FC\uC138\uC694." }) : areas.map((area, index) => /* @__PURE__ */ jsxs(
"div",
{
className: `area-item ${selectedAreaId === area.id ? "selected" : ""}`,
onClick: () => onSelectArea(area.id),
children: [
/* @__PURE__ */ jsxs("div", { className: "area-item-info", children: [
/* @__PURE__ */ jsxs("span", { className: "area-item-name", children: [
"\uC601\uC5ED ",
index + 1
] }),
/* @__PURE__ */ jsxs("span", { className: "area-item-strength", children: [
"\uAC15\uB3C4: ",
(area.distortionStrength * 100).toFixed(0),
"%"
] })
] }),
/* @__PURE__ */ jsx2(
"button",
{
onClick: (e) => {
e.stopPropagation();
onRemoveArea(area.id);
},
className: "btn-remove",
title: "\uC601\uC5ED \uC0AD\uC81C",
children: "\xD7"
}
)
]
},
area.id
)) })
] });
};
// src/editor/components/ParameterPanel.tsx
import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
var EASING_OPTIONS = [
{ value: "linear", label: "\uC120\uD615 (Linear)" },
{ value: "easeIn", label: "\uAC00\uC18D (Ease In)" },
{ value: "easeOut", label: "\uAC10\uC18D (Ease Out)" },
{ value: "easeInOut", label: "\uAC00\uAC10\uC18D (Ease In Out)" },
{ value: "easeInQuad", label: "\uAC00\uC18D\xB2 (Ease In Quad)" },
{ value: "easeOutQuad", label: "\uAC10\uC18D\xB2 (Ease Out Quad)" }
];
var ParameterPanel = ({ area, onUpdateArea }) => {
if (!area) {
return /* @__PURE__ */ jsx3("div", { className: "parameter-panel", children: /* @__PURE__ */ jsx3("div", { className: "parameter-panel-empty", children: "\uC601\uC5ED\uC744 \uC120\uD0DD\uD574\uC8FC\uC138\uC694" }) });
}
return /* @__PURE__ */ jsxs2("div", { className: "parameter-panel", children: [
/* @__PURE__ */ jsx3("h3", { children: "\uD30C\uB77C\uBBF8\uD130 \uD3B8\uC9D1" }),
/* @__PURE__ */ jsxs2("div", { className: "parameter-group", children: [
/* @__PURE__ */ jsxs2("label", { children: [
"\uC65C\uACE1 \uAC15\uB3C4: ",
(area.distortionStrength * 100).toFixed(0),
"%"
] }),
/* @__PURE__ */ jsx3(
"input",
{
type: "range",
min: "0",
max: "1",
step: "0.01",
value: area.distortionStrength,
onChange: (e) => onUpdateArea({ distortionStrength: parseFloat(e.target.value) }),
className: "slider"
}
)
] }),
/* @__PURE__ */ jsxs2("div", { className: "parameter-group", children: [
/* @__PURE__ */ jsxs2("label", { children: [
"\uC9C0\uC18D \uC2DC\uAC04: ",
area.movement.duration.toFixed(1),
"\uCD08"
] }),
/* @__PURE__ */ jsx3(
"input",
{
type: "number",
min: "0.1",
max: "10",
step: "0.1",
value: area.movement.duration,
onChange: (e) => onUpdateArea({
movement: { ...area.movement, duration: parseFloat(e.target.value) }
}),
className: "input-number"
}
)
] }),
/* @__PURE__ */ jsxs2("div", { className: "parameter-group", children: [
/* @__PURE__ */ jsx3("label", { children: "\uC774\uC9D5 \uD568\uC218" }),
/* @__PURE__ */ jsx3(
"select",
{
value: area.movement.easing,
onChange: (e) => onUpdateArea({
movement: { ...area.movement, easing: e.target.value }
}),
className: "select",
children: EASING_OPTIONS.map((option) => /* @__PURE__ */ jsx3("option", { value: option.value, children: option.label }, option.value))
}
)
] }),
/* @__PURE__ */ jsxs2("div", { className: "parameter-group", children: [
/* @__PURE__ */ jsxs2("label", { children: [
"\uB80C\uC988 \uD6A8\uACFC: ",
(area.lensEffect?.strength ?? 0) > 0 ? "\uBCFC\uB85D " : (area.lensEffect?.strength ?? 0) < 0 ? "\uC624\uBAA9 " : "",
((area.lensEffect?.strength ?? 0) * 100).toFixed(0),
"%"
] }),
/* @__PURE__ */ jsx3(
"input",
{
type: "range",
min: "-1",
max: "1",
step: "0.01",
value: area.lensEffect?.strength ?? 0,
onChange: (e) => onUpdateArea({ lensEffect: { strength: parseFloat(e.target.value) } }),
className: "slider"
}
)
] }),
/* @__PURE__ */ jsxs2("div", { className: "parameter-group", children: [
/* @__PURE__ */ jsxs2("label", { children: [
"\uC6C0\uC9C1\uC784 \uB2E8\uACC4: ",
(area.snapSteps ?? 0) === 0 ? "\uC5C6\uC74C" : `${area.snapSteps}\uB2E8\uACC4`
] }),
/* @__PURE__ */ jsx3(
"input",
{
type: "range",
min: "0",
max: "5",
step: "1",
value: area.snapSteps ?? 0,
onChange: (e) => onUpdateArea({ snapSteps: parseInt(e.target.value) }),
className: "slider"
}
)
] }),
/* @__PURE__ */ jsxs2("div", { className: "parameter-group", children: [
/* @__PURE__ */ jsx3("label", { children: "\uD3EC\uC778\uD2B8 \uC88C\uD45C (\uCE94\uBC84\uC2A4\uC5D0\uC11C \uB4DC\uB798\uADF8)" }),
/* @__PURE__ */ jsx3("div", { className: "points-display", children: area.basePoints.map((point, idx) => /* @__PURE__ */ jsxs2("div", { className: "point-coord", children: [
"P",
idx + 1,
": (",
point.x.toFixed(3),
", ",
point.y.toFixed(3),
")"
] }, idx)) })
] })
] });
};
// src/editor/hooks/useDistortionEditor.ts
import { useState as useState3, useCallback as useCallback4 } from "react";
var useDistortionEditor = (initialAreas = []) => {
const [state, setState] = useState3({
selectedAreaId: initialAreas[0]?.id || null,
areas: initialAreas,
editMode: "normal",
draggingPointIndex: null
});
const selectArea = useCallback4((areaId) => {
setState((prev) => ({ ...prev, selectedAreaId: areaId }));
}, []);
const addArea = useCallback4((area) => {
setState((prev) => ({
...prev,
areas: [...prev.areas, area],
selectedAreaId: area.id
}));
}, []);
const removeArea = useCallback4((areaId) => {
setState((prev) => {
const newAreas = prev.areas.filter((a) => a.id !== areaId);
return {
...prev,
areas: newAreas,
selectedAreaId: prev.selectedAreaId === areaId ? newAreas[0]?.id || null : prev.selectedAreaId
};
});
}, []);
const updateArea = useCallback4((areaId, updates) => {
setState((prev) => ({
...prev,
areas: prev.areas.map((area) => area.id === areaId ? { ...area, ...updates } : area)
}));
}, []);
const updatePoint = useCallback4((areaId, pointIndex, point) => {
setState((prev) => ({
...prev,
areas: prev.areas.map((area) => {
if (area.id === areaId) {
const newPoints = [...area.basePoints];
newPoints[pointIndex] = point;
return { ...area, basePoints: newPoints };
}
return area;
})
}));
}, []);
const startDragging = useCallback4((pointIndex) => {
setState((prev) => ({ ...prev, draggingPointIndex: pointIndex }));
}, []);
const stopDragging = useCallback4(() => {
setState((prev) => ({ ...prev, draggingPointIndex: null }));
}, []);
const setEditMode = useCallback4((mode) => {
setState((prev) => ({ ...prev, editMode: mode }));
}, []);
const getSelectedArea = useCallback4(() => {
return state.areas.find((a) => a.id === state.selectedAreaId) || null;
}, [state.areas, state.selectedAreaId]);
return {
state,
selectArea,
addArea,
removeArea,
updateArea,
updatePoint,
startDragging,
stopDragging,
setEditMode,
getSelectedArea
};
};
// src/editor/constants.ts
var DEFAULT_EDITOR_CANVAS_STYLE = {
// 3단계 원 스타일 (외부 -> 내부)
circleLevels: [
{
radius: 0.5,
opacity: 0.3,
lineWidth: 2,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
},
{
radius: 0.33,
opacity: 0.6,
lineWidth: 2.5,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
},
{
radius: 0.167,
opacity: 0.9,
lineWidth: 3,
color: "rgba(255, 200, 0, 1)",
dashPattern: [8, 4]
}
],
// 원 내부 채우기
circleFillColor: "rgba(255, 200, 0, 0.08)",
// 중심점
centerPoint: {
radius: 5,
fillColor: "rgba(255, 200, 0, 1)",
strokeColor: "rgba(255, 255, 255, 0.8)",
strokeWidth: 2
},
// 포인트 핸들
pointHandle: {
size: 16,
fillColor: "#00aaff",
strokeColor: "white",
strokeWidth: 2,
labelColor: "#00aaff",
labelFontSize: 11
},
// 영역 외곽선
areaOutline: {
selectedColor: "#00aaff",
unselectedColor: "#888",
selectedWidth: 2,
unselectedWidth: 1,
unselectedDashPattern: [5, 5],
selectedFillColor: "rgba(0, 170, 255, 0.08)",
// 선택된 영역 배경 (연한 파란색)
unselectedFillColor: "rgba(136, 136, 136, 0.03)"
// 선택 안된 영역 배경 (연한 회색)
}
};
// src/editor/components/EditorCanvas.tsx
import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
var EditorCanvas = ({
areas,
selectedAreaId,
imageSrc,
width,
height,
onUpdatePoint,
onUpdateArea,
draggingPointIndex,
onStartDragging,
onStopDragging,
style: customStyle,
showEditor = true,
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,
circleLevels: customStyle?.circleLevels || DEFAULT_EDITOR_CANVAS_STYLE.circleLevels,
centerPoint: {
...DEFAULT_EDITOR_CANVAS_STYLE.centerPoint,
...customStyle?.centerPoint
},
pointHandle: {
...DEFAULT_EDITOR_CANVAS_STYLE.pointHandle,
...customStyle?.pointHandle
},
areaOutline: {
...DEFAULT_EDITOR_CANVAS_STYLE.areaOutline,
...customStyle?.areaOutline
}
}), [customStyle]);
useEffect4(() => {
if (!containerRef.current) return;
const updateSize = () => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setCanvasSize({ width: rect.width, height: rect.height });
};
updateSize();
const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}, []);
const selectedArea = areas.find((a) => a.id === selectedAreaId);
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;
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;
}, []);
const handlePointDown = useCallback5(
(pointIndex) => (e) => {
e.preventDefault();
e.stopPropagation();
onStartDragging(pointIndex);
},
[onStartDragging]
);
const handleCanvasDown = useCallback5(
(e) => {
if (!showEditor || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
let clientX, clientY;
if ("touches" in e) {
if (e.touches.length === 0) return;
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height;
const clickPoint = { x, y };
if (onUpdateSpriteEffectArea) {
for (let i = spriteEffectAreas.length - 1; i >= 0; i--) {
const sa = spriteEffectAreas[i];
const dx = clickPoint.x - sa.position.x;
const dy = clickPoint.y - sa.position.y;
const radius = sa.radius ?? 0.1;
if (dx * dx + dy * dy <= radius * radius) {
setDraggingSpriteAreaId(sa.id);
setDragStartPos(clickPoint);
e.preventDefault();
return;
}
}
}
if (selectedArea && isPointInPolygon2(clickPoint, selectedArea.basePoints)) {
setIsDraggingArea(true);
setDragStartPos(clickPoint);
e.preventDefault();
return;
}
if (onSelectArea) {
for (let i = areas.length - 1; i >= 0; i--) {
const area = areas[i];
if (area.id !== selectedAreaId && isPointInPolygon2(clickPoint, area.basePoints)) {
onSelectArea(area.id);
e.preventDefault();
return;
}
}
}
},
[showEditor, selectedArea, selectedAreaId, areas, isPointInPolygon2, onSelectArea, spriteEffectAreas, onUpdateSpriteEffectArea]
);
const handleMove = useCallback5(
(e) => {
if (!showEditor || !containerRef.current) return;
if ("touches" in e && (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId)) {
e.preventDefault();
}
const rect = containerRef.current.getBoundingClientRect();
let clientX, clientY;
if ("touches" in e) {
if (e.touches.length === 0) return;
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
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));
onUpdatePoint(selectedArea.id, draggingPointIndex, { x: clampedX, y: clampedY });
} else if (isDraggingArea && dragStartPos) {
const deltaX = x - dragStartPos.x;
const deltaY = y - dragStartPos.y;
const newPoints = selectedArea.basePoints.map((point) => ({
x: Math.max(0, Math.min(1, point.x + deltaX)),
y: Math.max(0, Math.min(1, point.y + deltaY))
}));
onUpdateArea(selectedArea.id, { basePoints: newPoints });
setDragStartPos({ x, y });
}
},
[showEditor, draggingPointIndex, isDraggingArea, draggingSpriteAreaId, dragStartPos, selectedArea, onUpdatePoint, onUpdateArea, spriteEffectAreas, onUpdateSpriteEffectArea]
);
const handleUp = useCallback5(() => {
if (draggingPointIndex !== null) {
onStopDragging();
}
if (isDraggingArea) {
setIsDraggingArea(false);
setDragStartPos(null);
}
if (draggingSpriteAreaId) {
setDraggingSpriteAreaId(null);
setDragStartPos(null);
}
}, [draggingPointIndex, isDraggingArea, draggingSpriteAreaId, onStopDragging]);
useEffect4(() => {
if (draggingPointIndex !== null || isDraggingArea || draggingSpriteAreaId) {
window.addEventListener("mouseup", handleUp);
window.addEventListener("touchend", handleUp);
window.addEventListener("touchcancel", handleUp);
return () => {
window.removeEventListener("mouseup", handleUp);
window.removeEventListener("touchend", handleUp);
window.removeEventListener("touchcancel", 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;
const leftY = p0.y * (1 - u) + p1.y * u;
const rightX = p3.x * (1 - u) + p2.x * u;
const rightY = p3.y * (1 - u) + p2.y * u;
const posX = leftX * (1 - v) + rightX * v;
const posY = leftY * (1 - v) + rightY * v;
return {
x: posX * canvasWidth,
y: posY * canvasHeight
};
};
const drawDistortionCircle = useCallback5((ctx, points, canvasWidth, canvasHeight) => {
const segments = 128;
const centerU = 0.5;
const centerV = 0.5;
const circleLevels = editorStyle.circleLevels || [];
circleLevels.forEach((level, index) => {
const levelPoints = [];
for (let i = 0; i <= segments; i++) {
const theta = i / segments * 2 * Math.PI;
const u = centerU - level.radius * Math.sin(theta);
const v = centerV + level.radius * Math.cos(theta);
const pixelPos = uvToPixel(u, v, points, canvasWidth, canvasHeight);
levelPoints.push(pixelPos);
}
ctx.beginPath();
ctx.moveTo(levelPoints[0].x, levelPoints[0].y);
for (let i = 1; i < levelPoints.length; i++) {
ctx.lineTo(levelPoints[i].x, levelPoints[i].y);
}
ctx.closePath();
const baseColor = level.color || "rgba(255, 200, 0, 1)";
const colorWithOpacity = baseColor.replace(/rgba?\(([^)]+)\)/, (_, rgb) => {
const parts = rgb.split(",").map((p) => p.trim());
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${level.opacity})`;
});
ctx.strokeStyle = colorWithOpacity;
ctx.lineWidth = level.lineWidth;
if (level.dashPattern) {
ctx.setLineDash(level.dashPattern);
}
ctx.stroke();
ctx.setLineDash([]);
if (index === 0 && editorStyle.circleFillColor) {
ctx.fillStyle = editorStyle.circleFillColor;
ctx.fill();
}
});
const centerPointStyle = editorStyle.centerPoint || {};
const centerPixel = uvToPixel(centerU, centerV, points, canvasWidth, canvasHeight);
ctx.beginPath();
ctx.arc(centerPixel.x, centerPixel.y, centerPointStyle.radius || 5, 0, 2 * Math.PI);
if (centerPointStyle.fillColor) {
ctx.fillStyle = centerPointStyle.fillColor;
ctx.fill();
}
if (centerPointStyle.strokeColor) {
ctx.strokeStyle = centerPointStyle.strokeColor;
ctx.lineWidth = centerPointStyle.strokeWidth || 2;
ctx.stroke();
}
}, [editorStyle]);
const getCursorStyle = () => {
if (draggingPointIndex !== null) return "grabbing";
if (isDraggingArea) return "grabbing";
if (draggingSpriteAreaId) return "grabbing";
return "default";
};
return /* @__PURE__ */ jsxs3(
"div",
{
ref: containerRef,
className: "editor-canvas",
style: {
width: "100%",
height: "100%",
position: "relative",
cursor: showEditor ? getCursorStyle() : "default",
pointerEvents: showEditor ? "auto" : "none",
touchAction: "none"
// 터치 시 모든 브라우저 동작 비활성화 (스크롤, 줌 등)
},
onMouseDown: showEditor ? handleCanvasDown : void 0,
onMouseMove: showEditor ? handleMove : void 0,
onTouchStart: showEditor ? handleCanvasDown : void 0,
onTouchMove: showEditor ? handleMove : void 0,
children: [
/* @__PURE__ */ jsx4(ImageDistortion, { imageSrc, areas, spriteEffectAreas }),
showEditor && /* @__PURE__ */ jsx4(
"svg",
{
style: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none"
},
children: areas.map((area) => {
const isSelected = area.id === selectedAreaId;
const points = area.basePoints;
const outlineStyle = editorStyle.areaOutline || {};
return /* @__PURE__ */ jsx4("g", { children: /* @__PURE__ */ jsx4(
"polygon",
{
points: points.map((p) => `${p.x * canvasSize.width},${p.y * canvasSize.height}`).join(" "),
fill: isSelected ? outlineStyle.selectedFillColor || "rgba(0, 170, 255, 0.08)" : outlineStyle.unselectedFillColor || "rgba(136, 136, 136, 0.03)",
stroke: isSelected ? outlineStyle.selectedColor || "#00aaff" : outlineStyle.unselectedColor || "#888",
strokeWidth: isSelected ? outlineStyle.selectedWidth || 2 : outlineStyle.unselectedWidth || 1,
strokeDasharray: isSelected ? "0" : outlineStyle.unselectedDashPattern?.join(",") || "5,5",
opacity: isSelected ? 1 : 0.5
}
) }, area.id);
})
}
),
showEditor && selectedArea && canvasSize.width > 0 && /* @__PURE__ */ jsx4(
"canvas",
{
style: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none"
},
width: canvasSize.width,
height: canvasSize.height,
ref: (canvas) => {
if (canvas) {
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.clearRect(0, 0, canvasSize.width, canvasSize.height);
drawDistortionCircle(ctx, selectedArea.basePoints, canvasSize.width, canvasSize.height);
}
}
}
}
),
showEditor && selectedArea && selectedArea.basePoints.map((point, index) => {
const handleStyle = editorStyle.pointHandle || {};
return /* @__PURE__ */ jsx4(
"div",
{
className: `point-handle ${draggingPointIndex === index ? "dragging" : ""}`,
style: {
position: "absolute",
left: `${point.x * 100}%`,
top: `${point.y * 100}%`,
transform: "translate(-50%, -50%)",
width: handleStyle.size || 16,
height: handleStyle.size || 16,
borderRadius: "50%",
backgroundColor: handleStyle.fillColor || "#00aaff",
border: `${handleStyle.strokeWidth || 2}px solid ${handleStyle.strokeColor || "white"}`,
cursor: "grab",
pointerEvents: "auto",
boxShadow: "0 2px 4px rgba(0,0,0,0.3)"
},
onMouseDown: handlePointDown(index),
onTouchStart: handlePointDown(index),
children: /* @__PURE__ */ jsxs3(
"div",
{
style: {
position: "absolute",
top: -24,
left: "50%",
transform: "translateX(-50%)",
fontSize: handleStyle.labelFontSize || 11,
color: handleStyle.labelColor || "#00aaff",
fontWeight: "bold",
textShadow: "1px 1px 2px rgba(0,0,0,0.8)",
whiteSpace: "nowrap"
},
children: [
"P",
index + 1
]
}
)
},
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}`
);
})
]
}
);
};
export {
ANIMATION_CONFIG,
AnimationLoop,
AreaList,
DEFAULT_AREA,
DEFAULT_EDITOR_CANVAS_STYLE,
EditorCanvas,
ImageDistortion,
ParameterPanel,
SHADER_CONFIG,
ShaderManager,
SpringPhysics,
SpriteEffectManager,
ThreeScene,
applyEasing,
getRegisteredPresets,
hasPreset,
isRotationPreset,
presetToVector,
registerMotionPreset,
registerMotionPresets,
resetToBuiltInPresets,
unregisterMotionPreset,
useAnimationFrame,
useDistortionEditor,
useMouseInteraction,
useMouseVelocity
};
//# sourceMappingURL=index.mjs.map