import { Model } from '@hatiolab/things-scene' import type Carrier from './carrier' import type MCSCarrierHolder from './mcs-carrier-holder' import { parseIfJsonString } from './utils/data-value' // 사라진 carrier 의 dispose 지연. 같은 macrotask 안에서 holder A 의 사라짐 // 보고와 holder B 의 등장 보고가 순차로 들어오는 reparent 시나리오 (= 같은 // frame snapshot 의 여러 record 적용) 를 보호하기 위해 setTimeout(0) 의 // next-macrotask delay 만 둔다. 과거 1000ms grace 가 있었으나 seek 시 2단계 // 갱신 (1초 후 carrier 사라짐) 부작용이 명확하고 frame 누락 bouncing 깜빡임 // 보호 효용은 작다 판단되어 0 으로 단축 (2026-06-09). const DYING_TIMEOUT = 0 /** * Carrier 가 들고 있는 4 키 묶음. data.CARRIERNAME 의 형제 키 (CARRIERTYPE, * EMPTYTYPE, CARRIERSTATUS) 를 한 item 으로 묶는다. holder.data 의 *대표* * 와 ADDITIONS 의 *각 항목* 모두 동일 shape 으로 정규화돼 stack diff 에 * 들어간다. * * 주의 — 외부 시스템이 ADDITIONS 의 각 항목 키를 다른 이름으로 (예: * NAME / TYPE / EMPTY / STATUS) 줄 가능성에 대비해 이 build 함수가 단일 * 정규화 지점이 된다. 키 매핑 변경은 여기 한 곳만 손대면 된다. */ export interface CarrierStackItem { CARRIERNAME: string CARRIERTYPE?: string EMPTYTYPE?: string CARRIERSTATUS?: string } export default class CarrierManager { private carriers: Map = new Map() private dyingTimers: Map> = new Map() private root: any constructor(root: any) { this.root = root } get(carrierName: string): Carrier | undefined { return this.carriers.get(carrierName) } /** * Holder 의 data 변경에 대응해 carrier pool 을 동기화. * * 정책: * - holder.data 가 *single source of truth*. 대표(CARRIERNAME) + * ADDITIONS[] 를 하나의 *stack list* 로 정규화한 뒤 before/after 의 * 이름 set diff 로 추가/제거/유지 분기. * - "대표" 와 "ADDITIONS" 는 *holder.data 안의 키 위치 차이일 뿐* 이고, * carrier 인스턴스끼리는 **종속/그룹 관계 없음 — 모두 독립 동등**. * stack 안의 위치 (stackIndex) 만 carrier.state 에 주입한다. 2D render * skip 등의 판정은 *위치 (= parent.components 의 마지막 여부)* 로 한다. * - 동일 CARRIERNAME 이 *다른 holder* 에 새로 등장하면 reparent (애니메이션). * *해당 carrier 만 이동* — 같은 stack 의 다른 carrier 는 따라가지 않는다. * - 사라진 carrier 는 next-macrotask 에 dispose. 같은 macrotask 안에서 다른 * holder 가 같은 이름을 보고하면 cancel 되어 reparent 로 전환. */ update(holder: MCSCarrierHolder, after: Record, before: Record): void { const afterStack = this._buildStack(after) const beforeStack = this._buildStack(before) const afterNames = new Set(afterStack.map(i => i.CARRIERNAME)) const beforeNames = new Set(beforeStack.map(i => i.CARRIERNAME)) // 1) 사라진 것 → dying schedule (자기 holder 의 자식인 carrier 만) for (const name of beforeNames) { if (afterNames.has(name)) continue const carrier = this.carriers.get(name) if (carrier && carrier.parent === holder) { this.scheduleDying(name) } } // 2) afterStack 의 *순서대로* 처리 — bottom (ADDITIONS[0]) 부터 top (대표) // 그래야 parent.components 가 자연스럽게 같은 순서로 정렬되어 stack index // 가 일관된다. afterStack.forEach((item, idx) => { const name = item.CARRIERNAME this.cancelDying(name) let carrier = this.carriers.get(name) const { width, height } = holder.state if (!carrier) { carrier = Model.compile( { id: name, type: 'Carrier', left: 0, top: 0, width, height, showText: holder.getState('showCarrierText') || false, stackIndex: idx }, this.root.app ) as unknown as Carrier this.carriers.set(name, carrier) carrier.data = item holder.addComponent(carrier, { silent: true }) } else if (carrier.parent !== holder && !beforeNames.has(name)) { // 다른 holder 에 있던 carrier 가 이번 update 에서 *새로 등장* 한 경우만 // reparent. before 에도 같은 이름이 있었으면 ping-pong (옛 시점의 // 반복 보고) 이므로 trigger 하지 않는다. (F05 의 bouncing 방지) ;(holder as any).reparent(carrier, { animated: true, duration: 2000, ease: 'inout' }) } if (carrier.parent === holder) { carrier.setState('stackIndex', idx) carrier.data = item } }) // *_모든 carrier 추가 완료 후* — 각자의 transform 강제 갱신. 한 줄로 처리 // 안 되는 이유: cz override 의 offset 계산이 *형제 carrier 의 _realObject. // effectiveDepth 누적*에 의존하는데, 첫 iteration 시점엔 자기 뒤 carrier 의 // _realObject 가 아직 없어 offset 이 부족하게 평가될 수 있다. 모두 빌드된 // 뒤 한 번 더 갱신해야 적층 정확. afterStack.forEach(item => { const carrier = this.carriers.get(item.CARRIERNAME) if (carrier && carrier.parent === holder) { ;(carrier as any)._realObject?.updateTransform?.() } }) } /** * data.CARRIERNAME (대표) + data.MULTICARRIERS[] 를 하나의 stack list 로 정규화. * 적층 순서: multiCarriers[0..n-1] (bottom → up) + 대표 (top, 맨 마지막). * (데이터 키는 모두 대문자 전제 — 케이스 정규화 없이 직접 읽는다) */ private _buildStack(data: any): CarrierStackItem[] { if (!data) return [] const stack: CarrierStackItem[] = [] // MULTICARRIERS 값이 JSON 문자열로 올 수 있어 파싱 후 처리. const list = parseIfJsonString(data.MULTICARRIERS) if (Array.isArray(list)) { for (const a of list) { if (a && typeof a === 'object' && a.CARRIERNAME) { // 항목 *원본 그대로* — 외부 시스템이 임의 키를 추가로 보낼 수 있고, // 누락된 4 키는 default 없이 그대로 비워둔다 (carrier.data 에 흘리는 // 가공 최소화 — F03 같은 기존 테스트의 raw shape 기대와도 정합). stack.push({ ...a } as CarrierStackItem) } } } if (data.CARRIERNAME) { // 대표는 *holder.data 의 carrier 관련 키만 추출* — showText / carrierDepth // 같은 holder 자체 속성은 carrier 로 흘리지 않는다. 명시되지 않은 키는 // 생략 (default 안 채움). const repr: any = { CARRIERNAME: data.CARRIERNAME } if ('CARRIERTYPE' in data) repr.CARRIERTYPE = data.CARRIERTYPE if ('EMPTYTYPE' in data) repr.EMPTYTYPE = data.EMPTYTYPE if ('CARRIERSTATUS' in data) repr.CARRIERSTATUS = data.CARRIERSTATUS stack.push(repr) } return stack } /** * carrier dispose 를 *next macrotask* 로 미룬다. 동기 즉시 dispose 가 아닌 * 이유: 같은 macrotask 안에서 holder A 의 사라짐 보고가 step 1 에 들어오고 * holder B 의 등장 보고가 (별도 carrier-manager.update 호출의) step 2 에 * 들어오는 reparent 시나리오를 보호하기 위함. 즉시 dispose 면 step 1 에서 * carrier 인스턴스가 파괴돼 step 2 의 reparent 분기가 *새 인스턴스 생성* * 으로 떨어지고, 3D mesh 재구축 + 이동 애니메이션 손실이 발생. * * 같은 macrotask 안에서 cancelDying 호출이 도달하면 clearTimeout 으로 * dispose 회수 → reparent 정상. */ private scheduleDying(carrierName: string): void { this.cancelDying(carrierName) const timer = setTimeout(() => { this.dyingTimers.delete(carrierName) const carrier = this.carriers.get(carrierName) if (carrier) { this.carriers.delete(carrierName) if (!carrier.disposed) { carrier.removeSelf(true) } } }, DYING_TIMEOUT) this.dyingTimers.set(carrierName, timer) } private cancelDying(carrierName: string): void { const timer = this.dyingTimers.get(carrierName) if (timer) { clearTimeout(timer) this.dyingTimers.delete(carrierName) } } onCarrierDisposed(carrierName: string): void { this.cancelDying(carrierName) this.carriers.delete(carrierName) } dispose(): void { for (const timer of this.dyingTimers.values()) { clearTimeout(timer) } this.dyingTimers.clear() for (const carrier of this.carriers.values()) { if (!carrier.disposed) { carrier.removeSelf(true) } } this.carriers.clear() } }