Compare commits

...

2 Commits

Author SHA1 Message Date
09cb2f09af Truncate oversized image sources in logs and bump version to 1.5.5
- data: URL을 그대로 console.log에 넘기면 DevTools가 그 한 줄을 저장·포맷·
  렌더하느라 메인 스레드가 멈춘다. 실측으로 0.41MB짜리 한 줄에 10초 넘게 굳었다
- 화면이 통째로 얼어붙는데 원인이 로그 한 줄이라 추적이 매우 어렵다
- describeSrc가 1,000자를 넘는 소스를 길이 표시로 대체한다
- 거르는 기준을 스킴이 아니라 길이로 잡았다: 위험한 것은 data:라는 형식이 아니라
  크기이고, 스킴으로 거르면 상대 경로처럼 짧고 멀쩡한 주소까지 가려진다
2026-08-13 15:54:59 +09:00
BaekRyang
769934adff Fix aspect ratio in circle hit detection logic
- 캔버스 종횡비에 따른 원형 영역 클릭 감지 로직 수정
- SpriteEffectManager의 원형 판정 함수에 aspect ratio 계산 추가
- 패키지 버전을 1.5.4로 업데이트
2026-03-16 16:53:13 +09:00
12 changed files with 252 additions and 67 deletions

24
dist/index.d.mts vendored
View File

@ -151,10 +151,10 @@ type SpriteBlendMode = 'normal' | 'additive';
* *
*/ */
interface SpriteParticleOverLifetime { interface SpriteParticleOverLifetime {
/** [시작, 끝] 스케일 */ /** 스케일 보간: [시작, 끝] 또는 [시작, 중간, 끝] */
scale?: [number, number]; scale?: number[];
/** [시작, 끝] 투명도 */ /** 투명도 보간: [시작, 끝] 또는 [시작, 중간, 끝] */
opacity?: [number, number]; opacity?: number[];
/** 회전 속도 (라디안/초) */ /** 회전 속도 (라디안/초) */
rotationSpeed?: number; rotationSpeed?: number;
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */ /** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
@ -203,7 +203,10 @@ interface SpriteEffectAreaData {
effects: Array<{ effects: Array<{
id: string; id: string;
trigger: SpriteEffectTrigger; trigger: SpriteEffectTrigger;
spriteUrl: string; /** spriteUrl 또는 emoji 중 하나 필수 */
spriteUrl?: string;
/** 이모지 파티클 (spriteUrl 대신 사용 가능) */
emoji?: string;
blendMode?: SpriteBlendMode; blendMode?: SpriteBlendMode;
maxParticles: number; maxParticles: number;
emitRate?: number; emitRate?: number;
@ -229,8 +232,10 @@ interface SpriteEffectConfig {
id: string; id: string;
/** 트리거 타입 */ /** 트리거 타입 */
trigger: SpriteEffectTrigger; trigger: SpriteEffectTrigger;
/** 스프라이트 이미지 URL */ /** 스프라이트 이미지 URL (emoji와 택1) */
spriteUrl: string; spriteUrl?: string;
/** 이모지 파티클 (spriteUrl 대신 사용 가능) */
emoji?: string;
/** 블렌드 모드 (기본: 'normal') */ /** 블렌드 모드 (기본: 'normal') */
blendMode?: SpriteBlendMode; blendMode?: SpriteBlendMode;
/** 최대 파티클 수 */ /** 최대 파티클 수 */
@ -802,7 +807,10 @@ declare class SpriteEffectManager {
* @param deltaTime * @param deltaTime
* @param touchState / * @param touchState /
*/ */
update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState): void; update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState, resolution?: {
x: number;
y: number;
}): void;
/** /**
* *
*/ */

24
dist/index.d.ts vendored
View File

@ -151,10 +151,10 @@ type SpriteBlendMode = 'normal' | 'additive';
* *
*/ */
interface SpriteParticleOverLifetime { interface SpriteParticleOverLifetime {
/** [시작, 끝] 스케일 */ /** 스케일 보간: [시작, 끝] 또는 [시작, 중간, 끝] */
scale?: [number, number]; scale?: number[];
/** [시작, 끝] 투명도 */ /** 투명도 보간: [시작, 끝] 또는 [시작, 중간, 끝] */
opacity?: [number, number]; opacity?: number[];
/** 회전 속도 (라디안/초) */ /** 회전 속도 (라디안/초) */
rotationSpeed?: number; rotationSpeed?: number;
/** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */ /** 속도 감쇠 (0-1, 매 프레임 속도에 곱해짐) */
@ -203,7 +203,10 @@ interface SpriteEffectAreaData {
effects: Array<{ effects: Array<{
id: string; id: string;
trigger: SpriteEffectTrigger; trigger: SpriteEffectTrigger;
spriteUrl: string; /** spriteUrl 또는 emoji 중 하나 필수 */
spriteUrl?: string;
/** 이모지 파티클 (spriteUrl 대신 사용 가능) */
emoji?: string;
blendMode?: SpriteBlendMode; blendMode?: SpriteBlendMode;
maxParticles: number; maxParticles: number;
emitRate?: number; emitRate?: number;
@ -229,8 +232,10 @@ interface SpriteEffectConfig {
id: string; id: string;
/** 트리거 타입 */ /** 트리거 타입 */
trigger: SpriteEffectTrigger; trigger: SpriteEffectTrigger;
/** 스프라이트 이미지 URL */ /** 스프라이트 이미지 URL (emoji와 택1) */
spriteUrl: string; spriteUrl?: string;
/** 이모지 파티클 (spriteUrl 대신 사용 가능) */
emoji?: string;
/** 블렌드 모드 (기본: 'normal') */ /** 블렌드 모드 (기본: 'normal') */
blendMode?: SpriteBlendMode; blendMode?: SpriteBlendMode;
/** 최대 파티클 수 */ /** 최대 파티클 수 */
@ -802,7 +807,10 @@ declare class SpriteEffectManager {
* @param deltaTime * @param deltaTime
* @param touchState / * @param touchState /
*/ */
update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState): void; update(effectAreas: SpriteEffectArea[], deltaTime: number, touchState: SpriteEffectTouchState, resolution?: {
x: number;
y: number;
}): void;
/** /**
* *
*/ */

107
dist/index.js vendored
View File

@ -434,6 +434,7 @@ var SpriteParticlePool = class {
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
velocity: { x: 0, y: 0 }, velocity: { x: 0, y: 0 },
scale: 1, scale: 1,
initialScale: 1,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
age: 0, age: 0,
@ -483,6 +484,16 @@ var SpriteParticlePool = class {
// src/engine/SpriteEffectInstance.ts // src/engine/SpriteEffectInstance.ts
var randomRange = (min, max) => min + Math.random() * (max - min); var randomRange = (min, max) => min + Math.random() * (max - min);
var lerp = (a, b, t) => a + (b - a) * t; var lerp = (a, b, t) => a + (b - a) * t;
var lerpKeyframes = (values, t) => {
if (values.length === 2) {
return lerp(values[0], values[1], t);
}
const segments = values.length - 1;
const segT = t * segments;
const idx = Math.min(Math.floor(segT), segments - 1);
const localT = segT - idx;
return lerp(values[idx], values[idx + 1], localT);
};
var SpriteEffectInstance = class { var SpriteEffectInstance = class {
constructor(config) { constructor(config) {
this.texture = null; this.texture = null;
@ -513,7 +524,17 @@ var SpriteEffectInstance = class {
this.group.add(mesh); this.group.add(mesh);
return mesh; return mesh;
}); });
this.loadTexture(config.spriteUrl); if (config.emoji) {
this.createEmojiTexture(config.emoji);
} else if (config.spriteUrl) {
this.loadTexture(config.spriteUrl);
} else {
console.error("[SpriteEffectInstance] spriteUrl \uB610\uB294 emoji \uC911 \uD558\uB098\uB294 \uD544\uC218\uC785\uB2C8\uB2E4.");
}
}
/** 런타임 설정 업데이트 (maxParticles 제외) */
updateConfig(config) {
this.config = config;
} }
/** 텍스처 로드 */ /** 텍스처 로드 */
loadTexture(url) { loadTexture(url) {
@ -548,6 +569,31 @@ var SpriteEffectInstance = class {
} }
); );
} }
/** 이모지를 Canvas에 렌더링하여 텍스처 생성 */
createEmojiTexture(emoji) {
const size = 128;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) {
console.error("[SpriteEffectInstance] Canvas 2D \uCEE8\uD14D\uC2A4\uD2B8 \uC0DD\uC131 \uC2E4\uD328");
return;
}
ctx.font = "100px serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(emoji, size / 2, size / 2);
const texture = new THREE2.CanvasTexture(canvas);
this.texture = texture;
for (const mesh of this.meshes) {
const mat = mesh.material;
mat.map = texture;
mat.needsUpdate = true;
}
this.ready = true;
console.log(`[SpriteEffectInstance] \uC774\uBAA8\uC9C0 \uD14D\uC2A4\uCC98 \uC0DD\uC131 \uC644\uB8CC: ${emoji}`);
}
/** /**
* 파티클 1 방출 * 파티클 1 방출
* @param center 방출 중심 (정규화 좌표 0-1) * @param center 방출 중심 (정규화 좌표 0-1)
@ -572,7 +618,8 @@ var SpriteEffectInstance = class {
const speed = randomRange(config.initialSpeed[0], config.initialSpeed[1]); const speed = randomRange(config.initialSpeed[0], config.initialSpeed[1]);
particle.velocity.x = Math.cos(angleRad) * speed; particle.velocity.x = Math.cos(angleRad) * speed;
particle.velocity.y = Math.sin(angleRad) * speed; particle.velocity.y = Math.sin(angleRad) * speed;
particle.scale = randomRange(config.initialScale[0], config.initialScale[1]); particle.initialScale = randomRange(config.initialScale[0], config.initialScale[1]);
particle.scale = particle.initialScale;
particle.rotation = 0; particle.rotation = 0;
particle.opacity = 1; particle.opacity = 1;
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]); particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
@ -602,7 +649,7 @@ var SpriteEffectInstance = class {
this.emitOne(center); this.emitOne(center);
} }
} }
update(deltaTime, emitCenter) { update(deltaTime, emitCenter, resolution) {
if (!this.ready) return; if (!this.ready) return;
if (this.config.trigger === "ambient") { if (this.config.trigger === "ambient") {
this.updateAmbientEmit(deltaTime, emitCenter); this.updateAmbientEmit(deltaTime, emitCenter);
@ -623,10 +670,10 @@ var SpriteEffectInstance = class {
const lifeRatio = particle.age / particle.lifetime; const lifeRatio = particle.age / particle.lifetime;
if (overLifetime) { if (overLifetime) {
if (overLifetime.scale) { if (overLifetime.scale) {
particle.scale = lerp(overLifetime.scale[0], overLifetime.scale[1], lifeRatio); particle.scale = particle.initialScale * lerpKeyframes(overLifetime.scale, lifeRatio);
} }
if (overLifetime.opacity) { if (overLifetime.opacity) {
particle.opacity = lerp(overLifetime.opacity[0], overLifetime.opacity[1], lifeRatio); particle.opacity = lerpKeyframes(overLifetime.opacity, lifeRatio);
} }
if (overLifetime.rotationSpeed) { if (overLifetime.rotationSpeed) {
particle.rotation += overLifetime.rotationSpeed * deltaTime; particle.rotation += overLifetime.rotationSpeed * deltaTime;
@ -642,7 +689,7 @@ var SpriteEffectInstance = class {
} }
particle.position.x += particle.velocity.x * deltaTime; particle.position.x += particle.velocity.x * deltaTime;
particle.position.y += particle.velocity.y * deltaTime; particle.position.y += particle.velocity.y * deltaTime;
this.syncMesh(particle); this.syncMesh(particle, resolution);
} }
} }
/** /**
@ -667,7 +714,7 @@ var SpriteEffectInstance = class {
* 파티클 상태를 Three.js 메쉬에 동기화 * 파티클 상태를 Three.js 메쉬에 동기화
* 정규화 좌표(0-1) NDC(-1~1) 변환, y축 반전 * 정규화 좌표(0-1) NDC(-1~1) 변환, y축 반전
*/ */
syncMesh(particle) { syncMesh(particle, resolution) {
const mesh = this.meshes[particle.index]; const mesh = this.meshes[particle.index];
if (!mesh) return; if (!mesh) return;
if (!particle.active) { if (!particle.active) {
@ -678,7 +725,8 @@ var SpriteEffectInstance = class {
mesh.position.x = particle.position.x * 2 - 1; mesh.position.x = particle.position.x * 2 - 1;
mesh.position.y = -(particle.position.y * 2 - 1); mesh.position.y = -(particle.position.y * 2 - 1);
mesh.position.z = -0.01; mesh.position.z = -0.01;
mesh.scale.set(particle.scale, particle.scale, 1); const aspect = resolution ? resolution.x / resolution.y : 1;
mesh.scale.set(particle.scale / aspect, particle.scale, 1);
mesh.rotation.z = particle.rotation; mesh.rotation.z = particle.rotation;
const mat = mesh.material; const mat = mesh.material;
mat.opacity = particle.opacity; mat.opacity = particle.opacity;
@ -718,9 +766,10 @@ var SpriteEffectInstance = class {
}; };
// src/engine/SpriteEffectManager.ts // src/engine/SpriteEffectManager.ts
var isPointInCircle = (point, center, radius) => { var isPointInCircle = (point, center, radius, resolution) => {
const aspect = resolution ? resolution.x / resolution.y : 1;
const dx = point.x - center.x; const dx = point.x - center.x;
const dy = point.y - center.y; const dy = (point.y - center.y) / aspect;
return dx * dx + dy * dy <= radius * radius; return dx * dx + dy * dy <= radius * radius;
}; };
var SpriteEffectManager = class { var SpriteEffectManager = class {
@ -748,8 +797,12 @@ var SpriteEffectManager = class {
for (const effectConfig of area.effects) { for (const effectConfig of area.effects) {
const key = `${area.id}::${effectConfig.id}`; const key = `${area.id}::${effectConfig.id}`;
activeKeys.add(key); activeKeys.add(key);
if (this.instances.has(key)) continue; const existing = this.instances.get(key);
console.log("[SpriteEffectManager] \uC778\uC2A4\uD134\uC2A4 \uC0DD\uC131:", key, effectConfig.spriteUrl); if (existing) {
existing.updateConfig(effectConfig);
continue;
}
console.log("[SpriteEffectManager] \uC778\uC2A4\uD134\uC2A4 \uC0DD\uC131:", key, effectConfig.emoji ?? effectConfig.spriteUrl);
const instance = new SpriteEffectInstance(effectConfig); const instance = new SpriteEffectInstance(effectConfig);
this.instances.set(key, instance); this.instances.set(key, instance);
this.effectGroup.add(instance.group); this.effectGroup.add(instance.group);
@ -769,12 +822,12 @@ var SpriteEffectManager = class {
* @param deltaTime 단위 프레임 시간 * @param deltaTime 단위 프레임 시간
* @param touchState 마우스/터치 상태 * @param touchState 마우스/터치 상태
*/ */
update(effectAreas, deltaTime, touchState) { update(effectAreas, deltaTime, touchState, resolution) {
const currentTouchingAreas = /* @__PURE__ */ new Set(); const currentTouchingAreas = /* @__PURE__ */ new Set();
if (touchState.isDragging && touchState.position) { if (touchState.isDragging && touchState.position) {
for (const area of effectAreas) { for (const area of effectAreas) {
const radius = area.radius ?? 0.1; const radius = area.radius ?? 0.1;
if (isPointInCircle(touchState.position, area.position, radius)) { if (isPointInCircle(touchState.position, area.position, radius, resolution)) {
currentTouchingAreas.add(area.id); currentTouchingAreas.add(area.id);
} }
} }
@ -790,7 +843,7 @@ var SpriteEffectManager = class {
instance.triggerBurst(touchState.position ?? area.position); instance.triggerBurst(touchState.position ?? area.position);
} }
} }
instance.update(deltaTime, area.position); instance.update(deltaTime, area.position, resolution);
} }
} }
this.previousTouchingAreas = currentTouchingAreas; this.previousTouchingAreas = currentTouchingAreas;
@ -1260,6 +1313,14 @@ var DEFAULT_AREA = {
SNAP_STEPS: 0 SNAP_STEPS: 0
}; };
// src/utils/describeSrc.ts
var MAX_LOGGED_LENGTH = 1e3;
function describeSrc(src) {
if (!src) return String(src);
if (src.length <= MAX_LOGGED_LENGTH) return src;
return `[\uC0DD\uB7B5: ${src.length.toLocaleString()}\uC790]`;
}
// src/components/ImageDistortion.tsx // src/components/ImageDistortion.tsx
var import_jsx_runtime = require("react/jsx-runtime"); var import_jsx_runtime = require("react/jsx-runtime");
var ImageDistortion = ({ var ImageDistortion = ({
@ -1324,7 +1385,7 @@ var ImageDistortion = ({
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."); 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; return;
} }
console.log("[ImageDistortion] \uCD08\uAE30\uD654 \uC2DC\uC791"); console.log("[ImageDistortion] v1.5.1 \uCD08\uAE30\uD654 \uC2DC\uC791");
const scene = new ThreeScene(containerRef.current); const scene = new ThreeScene(containerRef.current);
sceneRef.current = scene; sceneRef.current = scene;
const vertPath = vertexShaderPath || "/shaders/distortion.vert.glsl"; const vertPath = vertexShaderPath || "/shaders/distortion.vert.glsl";
@ -1346,10 +1407,13 @@ var ImageDistortion = ({
}, [vertexShaderPath, fragmentShaderPath]); }, [vertexShaderPath, fragmentShaderPath]);
(0, import_react4.useEffect)(() => { (0, import_react4.useEffect)(() => {
if (!imageSrc || !isReady) { if (!imageSrc || !isReady) {
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2A4\uD0B5:", { imageSrc, isReady }); console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2A4\uD0B5:", {
imageSrc: describeSrc(imageSrc),
isReady
});
return; return;
} }
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc); console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", describeSrc(imageSrc));
setImageLoaded(false); setImageLoaded(false);
const loader = new THREE4.TextureLoader(); const loader = new THREE4.TextureLoader();
loader.load( loader.load(
@ -1445,13 +1509,15 @@ var ImageDistortion = ({
}); });
if (spriteManagerRef.current) { if (spriteManagerRef.current) {
const mouseState = mouseInteractionHook.getMouseState(); const mouseState = mouseInteractionHook.getMouseState();
const resolution = sceneRef.current?.getResolution() ?? { x: 1, y: 1 };
spriteManagerRef.current.update( spriteManagerRef.current.update(
spriteEffectAreas, spriteEffectAreas,
deltaTime, deltaTime,
{ {
position: mouseState.position ?? null, position: mouseState.position ?? null,
isDragging: mouseState.isDragging isDragging: mouseState.isDragging
} },
resolution
); );
sceneRef.current?.render(); sceneRef.current?.render();
} }
@ -1900,7 +1966,8 @@ var EditorCanvas = ({
for (let i = spriteEffectAreas.length - 1; i >= 0; i--) { for (let i = spriteEffectAreas.length - 1; i >= 0; i--) {
const sa = spriteEffectAreas[i]; const sa = spriteEffectAreas[i];
const dx = clickPoint.x - sa.position.x; const dx = clickPoint.x - sa.position.x;
const dy = clickPoint.y - sa.position.y; const aspect = rect.width / rect.height;
const dy = (clickPoint.y - sa.position.y) / aspect;
const radius = sa.radius ?? 0.1; const radius = sa.radius ?? 0.1;
if (dx * dx + dy * dy <= radius * radius) { if (dx * dx + dy * dy <= radius * radius) {
setDraggingSpriteAreaId(sa.id); setDraggingSpriteAreaId(sa.id);

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

107
dist/index.mjs vendored
View File

@ -373,6 +373,7 @@ var SpriteParticlePool = class {
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
velocity: { x: 0, y: 0 }, velocity: { x: 0, y: 0 },
scale: 1, scale: 1,
initialScale: 1,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
age: 0, age: 0,
@ -422,6 +423,16 @@ var SpriteParticlePool = class {
// src/engine/SpriteEffectInstance.ts // src/engine/SpriteEffectInstance.ts
var randomRange = (min, max) => min + Math.random() * (max - min); var randomRange = (min, max) => min + Math.random() * (max - min);
var lerp = (a, b, t) => a + (b - a) * t; var lerp = (a, b, t) => a + (b - a) * t;
var lerpKeyframes = (values, t) => {
if (values.length === 2) {
return lerp(values[0], values[1], t);
}
const segments = values.length - 1;
const segT = t * segments;
const idx = Math.min(Math.floor(segT), segments - 1);
const localT = segT - idx;
return lerp(values[idx], values[idx + 1], localT);
};
var SpriteEffectInstance = class { var SpriteEffectInstance = class {
constructor(config) { constructor(config) {
this.texture = null; this.texture = null;
@ -452,7 +463,17 @@ var SpriteEffectInstance = class {
this.group.add(mesh); this.group.add(mesh);
return mesh; return mesh;
}); });
this.loadTexture(config.spriteUrl); if (config.emoji) {
this.createEmojiTexture(config.emoji);
} else if (config.spriteUrl) {
this.loadTexture(config.spriteUrl);
} else {
console.error("[SpriteEffectInstance] spriteUrl \uB610\uB294 emoji \uC911 \uD558\uB098\uB294 \uD544\uC218\uC785\uB2C8\uB2E4.");
}
}
/** 런타임 설정 업데이트 (maxParticles 제외) */
updateConfig(config) {
this.config = config;
} }
/** 텍스처 로드 */ /** 텍스처 로드 */
loadTexture(url) { loadTexture(url) {
@ -487,6 +508,31 @@ var SpriteEffectInstance = class {
} }
); );
} }
/** 이모지를 Canvas에 렌더링하여 텍스처 생성 */
createEmojiTexture(emoji) {
const size = 128;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) {
console.error("[SpriteEffectInstance] Canvas 2D \uCEE8\uD14D\uC2A4\uD2B8 \uC0DD\uC131 \uC2E4\uD328");
return;
}
ctx.font = "100px serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(emoji, size / 2, size / 2);
const texture = new THREE2.CanvasTexture(canvas);
this.texture = texture;
for (const mesh of this.meshes) {
const mat = mesh.material;
mat.map = texture;
mat.needsUpdate = true;
}
this.ready = true;
console.log(`[SpriteEffectInstance] \uC774\uBAA8\uC9C0 \uD14D\uC2A4\uCC98 \uC0DD\uC131 \uC644\uB8CC: ${emoji}`);
}
/** /**
* 파티클 1 방출 * 파티클 1 방출
* @param center 방출 중심 (정규화 좌표 0-1) * @param center 방출 중심 (정규화 좌표 0-1)
@ -511,7 +557,8 @@ var SpriteEffectInstance = class {
const speed = randomRange(config.initialSpeed[0], config.initialSpeed[1]); const speed = randomRange(config.initialSpeed[0], config.initialSpeed[1]);
particle.velocity.x = Math.cos(angleRad) * speed; particle.velocity.x = Math.cos(angleRad) * speed;
particle.velocity.y = Math.sin(angleRad) * speed; particle.velocity.y = Math.sin(angleRad) * speed;
particle.scale = randomRange(config.initialScale[0], config.initialScale[1]); particle.initialScale = randomRange(config.initialScale[0], config.initialScale[1]);
particle.scale = particle.initialScale;
particle.rotation = 0; particle.rotation = 0;
particle.opacity = 1; particle.opacity = 1;
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]); particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
@ -541,7 +588,7 @@ var SpriteEffectInstance = class {
this.emitOne(center); this.emitOne(center);
} }
} }
update(deltaTime, emitCenter) { update(deltaTime, emitCenter, resolution) {
if (!this.ready) return; if (!this.ready) return;
if (this.config.trigger === "ambient") { if (this.config.trigger === "ambient") {
this.updateAmbientEmit(deltaTime, emitCenter); this.updateAmbientEmit(deltaTime, emitCenter);
@ -562,10 +609,10 @@ var SpriteEffectInstance = class {
const lifeRatio = particle.age / particle.lifetime; const lifeRatio = particle.age / particle.lifetime;
if (overLifetime) { if (overLifetime) {
if (overLifetime.scale) { if (overLifetime.scale) {
particle.scale = lerp(overLifetime.scale[0], overLifetime.scale[1], lifeRatio); particle.scale = particle.initialScale * lerpKeyframes(overLifetime.scale, lifeRatio);
} }
if (overLifetime.opacity) { if (overLifetime.opacity) {
particle.opacity = lerp(overLifetime.opacity[0], overLifetime.opacity[1], lifeRatio); particle.opacity = lerpKeyframes(overLifetime.opacity, lifeRatio);
} }
if (overLifetime.rotationSpeed) { if (overLifetime.rotationSpeed) {
particle.rotation += overLifetime.rotationSpeed * deltaTime; particle.rotation += overLifetime.rotationSpeed * deltaTime;
@ -581,7 +628,7 @@ var SpriteEffectInstance = class {
} }
particle.position.x += particle.velocity.x * deltaTime; particle.position.x += particle.velocity.x * deltaTime;
particle.position.y += particle.velocity.y * deltaTime; particle.position.y += particle.velocity.y * deltaTime;
this.syncMesh(particle); this.syncMesh(particle, resolution);
} }
} }
/** /**
@ -606,7 +653,7 @@ var SpriteEffectInstance = class {
* 파티클 상태를 Three.js 메쉬에 동기화 * 파티클 상태를 Three.js 메쉬에 동기화
* 정규화 좌표(0-1) NDC(-1~1) 변환, y축 반전 * 정규화 좌표(0-1) NDC(-1~1) 변환, y축 반전
*/ */
syncMesh(particle) { syncMesh(particle, resolution) {
const mesh = this.meshes[particle.index]; const mesh = this.meshes[particle.index];
if (!mesh) return; if (!mesh) return;
if (!particle.active) { if (!particle.active) {
@ -617,7 +664,8 @@ var SpriteEffectInstance = class {
mesh.position.x = particle.position.x * 2 - 1; mesh.position.x = particle.position.x * 2 - 1;
mesh.position.y = -(particle.position.y * 2 - 1); mesh.position.y = -(particle.position.y * 2 - 1);
mesh.position.z = -0.01; mesh.position.z = -0.01;
mesh.scale.set(particle.scale, particle.scale, 1); const aspect = resolution ? resolution.x / resolution.y : 1;
mesh.scale.set(particle.scale / aspect, particle.scale, 1);
mesh.rotation.z = particle.rotation; mesh.rotation.z = particle.rotation;
const mat = mesh.material; const mat = mesh.material;
mat.opacity = particle.opacity; mat.opacity = particle.opacity;
@ -657,9 +705,10 @@ var SpriteEffectInstance = class {
}; };
// src/engine/SpriteEffectManager.ts // src/engine/SpriteEffectManager.ts
var isPointInCircle = (point, center, radius) => { var isPointInCircle = (point, center, radius, resolution) => {
const aspect = resolution ? resolution.x / resolution.y : 1;
const dx = point.x - center.x; const dx = point.x - center.x;
const dy = point.y - center.y; const dy = (point.y - center.y) / aspect;
return dx * dx + dy * dy <= radius * radius; return dx * dx + dy * dy <= radius * radius;
}; };
var SpriteEffectManager = class { var SpriteEffectManager = class {
@ -687,8 +736,12 @@ var SpriteEffectManager = class {
for (const effectConfig of area.effects) { for (const effectConfig of area.effects) {
const key = `${area.id}::${effectConfig.id}`; const key = `${area.id}::${effectConfig.id}`;
activeKeys.add(key); activeKeys.add(key);
if (this.instances.has(key)) continue; const existing = this.instances.get(key);
console.log("[SpriteEffectManager] \uC778\uC2A4\uD134\uC2A4 \uC0DD\uC131:", key, effectConfig.spriteUrl); if (existing) {
existing.updateConfig(effectConfig);
continue;
}
console.log("[SpriteEffectManager] \uC778\uC2A4\uD134\uC2A4 \uC0DD\uC131:", key, effectConfig.emoji ?? effectConfig.spriteUrl);
const instance = new SpriteEffectInstance(effectConfig); const instance = new SpriteEffectInstance(effectConfig);
this.instances.set(key, instance); this.instances.set(key, instance);
this.effectGroup.add(instance.group); this.effectGroup.add(instance.group);
@ -708,12 +761,12 @@ var SpriteEffectManager = class {
* @param deltaTime 단위 프레임 시간 * @param deltaTime 단위 프레임 시간
* @param touchState 마우스/터치 상태 * @param touchState 마우스/터치 상태
*/ */
update(effectAreas, deltaTime, touchState) { update(effectAreas, deltaTime, touchState, resolution) {
const currentTouchingAreas = /* @__PURE__ */ new Set(); const currentTouchingAreas = /* @__PURE__ */ new Set();
if (touchState.isDragging && touchState.position) { if (touchState.isDragging && touchState.position) {
for (const area of effectAreas) { for (const area of effectAreas) {
const radius = area.radius ?? 0.1; const radius = area.radius ?? 0.1;
if (isPointInCircle(touchState.position, area.position, radius)) { if (isPointInCircle(touchState.position, area.position, radius, resolution)) {
currentTouchingAreas.add(area.id); currentTouchingAreas.add(area.id);
} }
} }
@ -729,7 +782,7 @@ var SpriteEffectManager = class {
instance.triggerBurst(touchState.position ?? area.position); instance.triggerBurst(touchState.position ?? area.position);
} }
} }
instance.update(deltaTime, area.position); instance.update(deltaTime, area.position, resolution);
} }
} }
this.previousTouchingAreas = currentTouchingAreas; this.previousTouchingAreas = currentTouchingAreas;
@ -1199,6 +1252,14 @@ var DEFAULT_AREA = {
SNAP_STEPS: 0 SNAP_STEPS: 0
}; };
// src/utils/describeSrc.ts
var MAX_LOGGED_LENGTH = 1e3;
function describeSrc(src) {
if (!src) return String(src);
if (src.length <= MAX_LOGGED_LENGTH) return src;
return `[\uC0DD\uB7B5: ${src.length.toLocaleString()}\uC790]`;
}
// src/components/ImageDistortion.tsx // src/components/ImageDistortion.tsx
import { jsx } from "react/jsx-runtime"; import { jsx } from "react/jsx-runtime";
var ImageDistortion = ({ var ImageDistortion = ({
@ -1263,7 +1324,7 @@ var ImageDistortion = ({
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."); 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; return;
} }
console.log("[ImageDistortion] \uCD08\uAE30\uD654 \uC2DC\uC791"); console.log("[ImageDistortion] v1.5.1 \uCD08\uAE30\uD654 \uC2DC\uC791");
const scene = new ThreeScene(containerRef.current); const scene = new ThreeScene(containerRef.current);
sceneRef.current = scene; sceneRef.current = scene;
const vertPath = vertexShaderPath || "/shaders/distortion.vert.glsl"; const vertPath = vertexShaderPath || "/shaders/distortion.vert.glsl";
@ -1285,10 +1346,13 @@ var ImageDistortion = ({
}, [vertexShaderPath, fragmentShaderPath]); }, [vertexShaderPath, fragmentShaderPath]);
useEffect3(() => { useEffect3(() => {
if (!imageSrc || !isReady) { if (!imageSrc || !isReady) {
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2A4\uD0B5:", { imageSrc, isReady }); console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2A4\uD0B5:", {
imageSrc: describeSrc(imageSrc),
isReady
});
return; return;
} }
console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", imageSrc); console.log("[ImageDistortion] \uC774\uBBF8\uC9C0 \uB85C\uB4DC \uC2DC\uC791:", describeSrc(imageSrc));
setImageLoaded(false); setImageLoaded(false);
const loader = new THREE4.TextureLoader(); const loader = new THREE4.TextureLoader();
loader.load( loader.load(
@ -1384,13 +1448,15 @@ var ImageDistortion = ({
}); });
if (spriteManagerRef.current) { if (spriteManagerRef.current) {
const mouseState = mouseInteractionHook.getMouseState(); const mouseState = mouseInteractionHook.getMouseState();
const resolution = sceneRef.current?.getResolution() ?? { x: 1, y: 1 };
spriteManagerRef.current.update( spriteManagerRef.current.update(
spriteEffectAreas, spriteEffectAreas,
deltaTime, deltaTime,
{ {
position: mouseState.position ?? null, position: mouseState.position ?? null,
isDragging: mouseState.isDragging isDragging: mouseState.isDragging
} },
resolution
); );
sceneRef.current?.render(); sceneRef.current?.render();
} }
@ -1839,7 +1905,8 @@ var EditorCanvas = ({
for (let i = spriteEffectAreas.length - 1; i >= 0; i--) { for (let i = spriteEffectAreas.length - 1; i >= 0; i--) {
const sa = spriteEffectAreas[i]; const sa = spriteEffectAreas[i];
const dx = clickPoint.x - sa.position.x; const dx = clickPoint.x - sa.position.x;
const dy = clickPoint.y - sa.position.y; const aspect = rect.width / rect.height;
const dy = (clickPoint.y - sa.position.y) / aspect;
const radius = sa.radius ?? 0.1; const radius = sa.radius ?? 0.1;
if (dx * dx + dy * dy <= radius * radius) { if (dx * dx + dy * dy <= radius * radius) {
setDraggingSpriteAreaId(sa.id); setDraggingSpriteAreaId(sa.id);

2
dist/index.mjs.map vendored

File diff suppressed because one or more lines are too long

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@baekryang/responsive-image-canvas", "name": "@baekryang/responsive-image-canvas",
"version": "1.3.0", "version": "1.5.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@baekryang/responsive-image-canvas", "name": "@baekryang/responsive-image-canvas",
"version": "1.3.0", "version": "1.5.5",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.2", "@types/react": "^19.2.2",

View File

@ -1,6 +1,6 @@
{ {
"name": "@baekryang/responsive-image-canvas", "name": "@baekryang/responsive-image-canvas",
"version": "1.5.2", "version": "1.5.5",
"publishConfig": { "publishConfig": {
"registry": "https://git.bnovalab.com/api/packages/baekryang/npm/" "registry": "https://git.bnovalab.com/api/packages/baekryang/npm/"
}, },

View File

@ -8,6 +8,7 @@ import { SpriteEffectManager } from '@/engine/SpriteEffectManager';
import { useAnimationFrame } from '@/hooks/useAnimationFrame'; import { useAnimationFrame } from '@/hooks/useAnimationFrame';
import { useMouseInteraction } from '@/hooks/useMouseInteraction'; import { useMouseInteraction } from '@/hooks/useMouseInteraction';
import { SHADER_CONFIG } from '@/utils/constants'; import { SHADER_CONFIG } from '@/utils/constants';
import { describeSrc } from '@/utils/describeSrc';
import { MouseInteractionConfig } from '@/types/interaction'; import { MouseInteractionConfig } from '@/types/interaction';
/** /**
@ -147,11 +148,16 @@ export const ImageDistortion: React.FC<ImageDistortionProps> = ({
// 이미지 텍스처 로드 // 이미지 텍스처 로드
useEffect(() => { useEffect(() => {
if (!imageSrc || !isReady) { if (!imageSrc || !isReady) {
console.log('[ImageDistortion] 이미지 로드 스킵:', { imageSrc, isReady }); console.log('[ImageDistortion] 이미지 로드 스킵:', {
imageSrc: describeSrc(imageSrc),
isReady,
});
return; return;
} }
console.log('[ImageDistortion] 이미지 로드 시작:', imageSrc); // describeSrc: data: URL을 그대로 찍으면 DevTools가 그 한 줄을 렌더하느라
// 메인 스레드가 수 초간 멈춘다. 너무 길면 길이만 남기고 본문은 버린다.
console.log('[ImageDistortion] 이미지 로드 시작:', describeSrc(imageSrc));
setImageLoaded(false); setImageLoaded(false);
const loader = new THREE.TextureLoader(); const loader = new THREE.TextureLoader();

View File

@ -147,7 +147,8 @@ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
for (let i = spriteEffectAreas.length - 1; i >= 0; i--) { for (let i = spriteEffectAreas.length - 1; i >= 0; i--) {
const sa = spriteEffectAreas[i]; const sa = spriteEffectAreas[i];
const dx = clickPoint.x - sa.position.x; const dx = clickPoint.x - sa.position.x;
const dy = clickPoint.y - sa.position.y; const aspect = rect.width / rect.height;
const dy = (clickPoint.y - sa.position.y) / aspect;
const radius = sa.radius ?? 0.1; const radius = sa.radius ?? 0.1;
if (dx * dx + dy * dy <= radius * radius) { if (dx * dx + dy * dy <= radius * radius) {
setDraggingSpriteAreaId(sa.id); setDraggingSpriteAreaId(sa.id);

View File

@ -16,9 +16,10 @@ export interface SpriteEffectTouchState {
/** /**
* *
*/ */
const isPointInCircle = (point: Point, center: Point, radius: number): boolean => { const isPointInCircle = (point: Point, center: Point, radius: number, resolution?: { x: number; y: number }): boolean => {
const aspect = resolution ? resolution.x / resolution.y : 1;
const dx = point.x - center.x; const dx = point.x - center.x;
const dy = point.y - center.y; const dy = (point.y - center.y) / aspect;
return dx * dx + dy * dy <= radius * radius; return dx * dx + dy * dy <= radius * radius;
}; };
@ -97,7 +98,7 @@ export class SpriteEffectManager {
if (touchState.isDragging && touchState.position) { if (touchState.isDragging && touchState.position) {
for (const area of effectAreas) { for (const area of effectAreas) {
const radius = area.radius ?? 0.1; const radius = area.radius ?? 0.1;
if (isPointInCircle(touchState.position, area.position, radius)) { if (isPointInCircle(touchState.position, area.position, radius, resolution)) {
currentTouchingAreas.add(area.id); currentTouchingAreas.add(area.id);
} }
} }

27
src/utils/describeSrc.ts Normal file
View File

@ -0,0 +1,27 @@
/**
* .
*
* Firebase Storage의 URL도 300 ,
* URL 2,000. `data:` URL은
* . .
*/
const MAX_LOGGED_LENGTH = 1000;
/**
* ** ** .
*
* `data:` URL은 base64로 .
* `console.log` DevTools가 ·· **
* ** 0.41MB짜리 10 .
* .
*
* ** **. `data:`
* ,
* .
*/
export function describeSrc(src: string | null | undefined): string {
if (!src) return String(src);
if (src.length <= MAX_LOGGED_LENGTH) return src;
return `[생략: ${src.length.toLocaleString()}자]`;
}