- SpriteEffectTrigger에 hold 추가 - 포인터가 영역 안에 머무는 동안 그 자리에서 계속 방출 - 매니저가 hover 영역을 따로 잡아 방출 중심을 포인터 위치로 넘김 (마우스는 올려두기만 해도, 터치는 누르는 동안) - 버전 1.7.0으로 올림
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
import * as THREE from 'three';
|
|
import type { Point } from '@/types';
|
|
import type { SpriteEffectConfig, SpriteSheetConfig } from '@/types/spriteEffect';
|
|
import { SpriteParticlePool, type SpriteParticle } from './SpriteParticlePool';
|
|
|
|
/**
|
|
* 범위 내 랜덤 값 생성
|
|
*/
|
|
const randomRange = (min: number, max: number): number =>
|
|
min + Math.random() * (max - min);
|
|
|
|
/**
|
|
* 선형 보간
|
|
*/
|
|
const lerp = (a: number, b: number, t: number): number =>
|
|
a + (b - a) * t;
|
|
|
|
/**
|
|
* 키프레임 보간 (2개: 선형, 3개: 전반/후반 분할)
|
|
*/
|
|
const lerpKeyframes = (values: number[], t: number): number => {
|
|
if (values.length === 2) {
|
|
return lerp(values[0], values[1], t);
|
|
}
|
|
// 3개 이상: 균등 구간 분할
|
|
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);
|
|
};
|
|
|
|
/**
|
|
* 단일 SpriteEffectConfig에 대응하는 런타임 이펙트 인스턴스
|
|
* 텍스처 로딩, 메쉬 풀링, 방출/업데이트 로직을 관리
|
|
*/
|
|
export class SpriteEffectInstance {
|
|
private config: SpriteEffectConfig;
|
|
private pool: SpriteParticlePool;
|
|
private meshes: THREE.Mesh[];
|
|
private geometry: THREE.PlaneGeometry;
|
|
private material: THREE.MeshBasicMaterial;
|
|
private texture: THREE.Texture | null = null;
|
|
private ready = false;
|
|
private emitAccumulator = 0;
|
|
|
|
/** 메쉬를 담는 그룹 (외부에서 씬에 추가) */
|
|
readonly group: THREE.Group;
|
|
|
|
constructor(config: SpriteEffectConfig) {
|
|
this.config = config;
|
|
this.pool = new SpriteParticlePool(config.maxParticles);
|
|
this.group = new THREE.Group();
|
|
|
|
// 공유 지오메트리 (1x1 평면)
|
|
this.geometry = new THREE.PlaneGeometry(1, 1);
|
|
|
|
// 블렌드 모드 결정
|
|
const blending = config.blendMode === 'additive'
|
|
? THREE.AdditiveBlending
|
|
: THREE.NormalBlending;
|
|
|
|
// 공유 머티리얼 (텍스처 로드 전까지 투명)
|
|
this.material = new THREE.MeshBasicMaterial({
|
|
transparent: true,
|
|
depthTest: false,
|
|
depthWrite: false,
|
|
blending,
|
|
opacity: 0,
|
|
});
|
|
|
|
// 메쉬 풀 사전 생성
|
|
this.meshes = Array.from({ length: config.maxParticles }, () => {
|
|
const mesh = new THREE.Mesh(this.geometry, this.material.clone());
|
|
mesh.visible = false;
|
|
mesh.renderOrder = 1;
|
|
this.group.add(mesh);
|
|
return mesh;
|
|
});
|
|
|
|
// 이모지 또는 URL 텍스처 로드
|
|
if (config.emoji) {
|
|
this.createEmojiTexture(config.emoji);
|
|
} else if (config.spriteUrl) {
|
|
this.loadTexture(config.spriteUrl);
|
|
} else {
|
|
console.error('[SpriteEffectInstance] spriteUrl 또는 emoji 중 하나는 필수입니다.');
|
|
}
|
|
}
|
|
|
|
/** 런타임 설정 업데이트 (maxParticles 제외) */
|
|
updateConfig(config: SpriteEffectConfig): void {
|
|
this.config = config;
|
|
}
|
|
|
|
/** 텍스처 로드 */
|
|
private loadTexture(url: string): void {
|
|
const loader = new THREE.TextureLoader();
|
|
loader.load(
|
|
url,
|
|
(texture) => {
|
|
this.texture = texture;
|
|
|
|
const sheet = this.config.spriteSheet;
|
|
if (sheet) {
|
|
// 스프라이트 시트: 첫 프레임만 표시하도록 UV 설정
|
|
texture.repeat.set(1 / sheet.columns, 1 / sheet.rows);
|
|
texture.offset.set(0, 1 - 1 / sheet.rows);
|
|
}
|
|
|
|
// 각 메쉬에 독립적인 텍스처 클론 적용 (프레임별 UV 독립 제어)
|
|
for (const mesh of this.meshes) {
|
|
const mat = mesh.material as THREE.MeshBasicMaterial;
|
|
if (sheet) {
|
|
const cloned = texture.clone();
|
|
cloned.repeat.copy(texture.repeat);
|
|
cloned.offset.copy(texture.offset);
|
|
mat.map = cloned;
|
|
} else {
|
|
mat.map = texture;
|
|
}
|
|
mat.needsUpdate = true;
|
|
}
|
|
this.ready = true;
|
|
console.log(`[SpriteEffectInstance] 텍스처 로드 성공: ${url}`, texture.image.width, 'x', texture.image.height);
|
|
},
|
|
undefined,
|
|
(error) => {
|
|
console.error(`[SpriteEffectInstance] 텍스처 로드 실패: ${url}`, error);
|
|
}
|
|
);
|
|
}
|
|
|
|
/** 이모지를 Canvas에 렌더링하여 텍스처 생성 */
|
|
private createEmojiTexture(emoji: string): void {
|
|
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 컨텍스트 생성 실패');
|
|
return;
|
|
}
|
|
|
|
ctx.font = '100px serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(emoji, size / 2, size / 2);
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
this.texture = texture;
|
|
|
|
// 각 메쉬에 텍스처 적용 (이모지는 스프라이트 시트 불필요)
|
|
for (const mesh of this.meshes) {
|
|
const mat = mesh.material as THREE.MeshBasicMaterial;
|
|
mat.map = texture;
|
|
mat.needsUpdate = true;
|
|
}
|
|
|
|
this.ready = true;
|
|
console.log(`[SpriteEffectInstance] 이모지 텍스처 생성 완료: ${emoji}`);
|
|
}
|
|
|
|
/**
|
|
* 파티클 1개 방출
|
|
* @param center 방출 중심 (정규화 좌표 0-1)
|
|
*/
|
|
private emitOne(center: Point): void {
|
|
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.initialScale = randomRange(config.initialScale[0], config.initialScale[1]);
|
|
particle.scale = particle.initialScale;
|
|
const rotationRange = config.initialRotation;
|
|
particle.rotation = rotationRange
|
|
? (randomRange(rotationRange[0], rotationRange[1]) * Math.PI) / 180
|
|
: 0;
|
|
particle.opacity = 1;
|
|
particle.lifetime = randomRange(config.lifetime[0], config.lifetime[1]);
|
|
particle.age = 0;
|
|
particle.frameTime = 0;
|
|
particle.frameIndex = 0;
|
|
}
|
|
|
|
/**
|
|
* ambient 모드: 매 프레임 누적기 기반 방출
|
|
*/
|
|
private updateAmbientEmit(deltaTime: number, center: Point): void {
|
|
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: Point): void {
|
|
if (!this.ready) return;
|
|
const count = this.config.burstCount ?? 1;
|
|
for (let i = 0; i < count; i++) {
|
|
this.emitOne(center);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 매 프레임 업데이트
|
|
* @param deltaTime 초 단위 프레임 시간
|
|
* @param emitCenter 방출 중심 (정규화 좌표 0-1)
|
|
* @param holdActive hold 이펙트에서 포인터가 영역 안에 있는지 - 있는 동안만 방출한다
|
|
*/
|
|
private _logCounter = 0;
|
|
|
|
update(deltaTime: number, emitCenter: Point, resolution?: { x: number; y: number }, holdActive = false): void {
|
|
if (!this.ready) return;
|
|
|
|
// 지속 방출 - ambient는 항상, hold는 포인터가 머무는 동안만
|
|
const emitting = this.config.trigger === 'ambient'
|
|
|| (this.config.trigger === 'hold' && holdActive);
|
|
if (emitting) {
|
|
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] 활성 파티클: ${activeParticles.length}개, 첫 파티클 pos=(${p.position.x.toFixed(3)}, ${p.position.y.toFixed(3)}), scale=${p.scale.toFixed(3)}, opacity=${p.opacity.toFixed(3)}`);
|
|
}
|
|
for (const particle of activeParticles) {
|
|
particle.age += deltaTime;
|
|
|
|
// 수명 초과 시 회수
|
|
if (particle.age >= particle.lifetime) {
|
|
this.pool.release(particle);
|
|
this.syncMesh(particle);
|
|
continue;
|
|
}
|
|
|
|
const lifeRatio = particle.age / particle.lifetime;
|
|
|
|
// overLifetime 보간 적용
|
|
if (overLifetime) {
|
|
if (overLifetime.scale) {
|
|
// overLifetime.scale은 initialScale에 대한 배율로 적용
|
|
particle.scale = particle.initialScale * lerpKeyframes(overLifetime.scale, lifeRatio);
|
|
}
|
|
if (overLifetime.opacity) {
|
|
particle.opacity = lerpKeyframes(overLifetime.opacity, lifeRatio);
|
|
}
|
|
if (overLifetime.rotationSpeed) {
|
|
particle.rotation += overLifetime.rotationSpeed * deltaTime;
|
|
}
|
|
if (overLifetime.velocityDamping !== undefined) {
|
|
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;
|
|
|
|
// Three.js 메쉬 동기화
|
|
this.syncMesh(particle, resolution);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 스프라이트 시트 프레임 진행
|
|
*/
|
|
private updateSpriteFrame(particle: SpriteParticle, deltaTime: number, sheet: SpriteSheetConfig): void {
|
|
particle.frameTime += deltaTime;
|
|
const frameDuration = 1 / sheet.fps;
|
|
|
|
if (particle.frameTime >= frameDuration) {
|
|
particle.frameTime -= frameDuration;
|
|
const nextFrame = particle.frameIndex + 1;
|
|
|
|
if (nextFrame >= sheet.totalFrames) {
|
|
// 루프 여부 확인 (기본: true)
|
|
if (sheet.loop !== false) {
|
|
particle.frameIndex = 0;
|
|
}
|
|
// loop=false면 마지막 프레임 유지
|
|
} else {
|
|
particle.frameIndex = nextFrame;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 파티클 상태를 Three.js 메쉬에 동기화
|
|
* 정규화 좌표(0-1) → NDC(-1~1) 변환, y축 반전
|
|
*/
|
|
private syncMesh(particle: SpriteParticle, resolution?: { x: number; y: number }): void {
|
|
const mesh = this.meshes[particle.index];
|
|
if (!mesh) return;
|
|
|
|
if (!particle.active) {
|
|
mesh.visible = false;
|
|
return;
|
|
}
|
|
|
|
mesh.visible = true;
|
|
|
|
// 좌표 변환: 정규화(0-1) → NDC(-1~1), y 반전
|
|
mesh.position.x = particle.position.x * 2 - 1;
|
|
mesh.position.y = -(particle.position.y * 2 - 1);
|
|
mesh.position.z = -0.01; // 카메라가 -z를 바라보므로 음수가 앞쪽
|
|
|
|
// 종횡비 보정: OrthographicCamera(-1,1,1,-1) 기준 정사각형 NDC에서 직사각형 뷰포트 왜곡 방지
|
|
const aspect = resolution ? resolution.x / resolution.y : 1;
|
|
mesh.scale.set(particle.scale / aspect, particle.scale, 1);
|
|
mesh.rotation.z = particle.rotation;
|
|
|
|
const mat = mesh.material as THREE.MeshBasicMaterial;
|
|
mat.opacity = particle.opacity;
|
|
|
|
// 스프라이트 시트 UV 오프셋 업데이트
|
|
const sheet = this.config.spriteSheet;
|
|
if (sheet && mat.map) {
|
|
const col = particle.frameIndex % sheet.columns;
|
|
const row = Math.floor(particle.frameIndex / sheet.columns);
|
|
mat.map.offset.set(
|
|
col / sheet.columns,
|
|
1 - (row + 1) / sheet.rows,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 텍스처 로딩 완료 여부
|
|
*/
|
|
isReady(): boolean {
|
|
return this.ready;
|
|
}
|
|
|
|
/**
|
|
* 리소스 정리
|
|
*/
|
|
dispose(): void {
|
|
if (this.texture) {
|
|
this.texture.dispose();
|
|
this.texture = null;
|
|
}
|
|
this.geometry.dispose();
|
|
for (const mesh of this.meshes) {
|
|
(mesh.material as THREE.MeshBasicMaterial).dispose();
|
|
}
|
|
this.material.dispose();
|
|
// 그룹에서 모든 메쉬 제거
|
|
while (this.group.children.length > 0) {
|
|
this.group.remove(this.group.children[0]);
|
|
}
|
|
}
|
|
}
|