From 7466e9216b8220a4dc4450e90e969b374ab0f509 Mon Sep 17 00:00:00 2001 From: qwreey Date: Mon, 10 Aug 2026 00:53:26 +0900 Subject: [PATCH] =?UTF-8?q?decide(tween):=20=EA=B0=92-=EB=A0=88=EB=B2=A8?= =?UTF-8?q?=20Tween=20=EB=9E=98=ED=8D=BC=EB=A1=9C=20=EC=9E=AC=EC=84=A4?= =?UTF-8?q?=EA=B3=84,=20pre-implementation-audit=201-1=20=ED=95=B4?= =?UTF-8?q?=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 독립 Dispatch 핸들러("v가 Store인 아무 k나 잡는 우선순위 최상위 핸들러") 모델을 PropertyHandler가 소비하는 값-레벨 래퍼(Tween)로 전환. State/ Source 언랩(범용 StoreBind)과 "이 값이 트윈 대상인가" 판단을 분리해, 일반 반응형 프로퍼티 바인딩이 Tween 파일을 거쳐가는지 불명확했던 구조적 모호함(pre-implementation-audit.md 1-1)을 해소. - Tween.Value는 plain T만(반응성은 바깥 :Compute가 전담, 이중 경로 방지) - hasBeenSet+활성 엔진 트윈을 3-상태 릴레이션 슬롯(RobloxTween|true|nil) 하나로 통합, 첫 세팅은 항상 애니메이션 없이 스냅 - 활성 트윈 정리 후에만 새 값 세팅(순서 뒤바뀌면 값이 덮어써질 위험) - 타입은 T'=T|Tween 치환만으로 기존 T|State 모양에 자동 통합 - useTween은 :Apply(Animate(...))로 해소, 새 옵션 필드 불필요 - PropertyHandler가 항상 매치되는 유일한 핸들러가 되어 Tween↔프로퍼티 handler-switch에 의존하던 retract 케이스가 사라짐 구 모델은 archive/tween-special-bind-key-reversed.md로 보존. 코퍼스 전체(bind-system-plan.md/architecture.md/modifier-plan.md/ROADMAP.md/ question.md/README.md/attribute-plan.md)의 stale Tween 참조 동기화, CLAUDE.md 세션 요약 추가. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 3 +- .../tween-special-bind-key-reversed.md | 68 ++++ .claude/base/architecture.md | 5 +- .claude/base/attribute-plan.md | 7 +- .claude/base/bind-system-plan.md | 118 +++--- .claude/base/modifier-plan.md | 36 +- .claude/question.md | 12 +- .claude/research/pre-implementation-audit.md | 12 +- .claude/research/tween-plan.md | 356 ++++++++++++------ CLAUDE.md | 114 ++++++ ROADMAP.md | 35 +- 11 files changed, 574 insertions(+), 192 deletions(-) create mode 100644 .claude/archive/tween-special-bind-key-reversed.md diff --git a/.claude/README.md b/.claude/README.md index 748ba9b..298fd35 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -54,7 +54,7 @@ | 문서 | 내용 | 우선순위 | |---|---|---| -| `tween-plan.md` | 트윈을 Store 밖 특수 bind key로 처리, 기본 오버라이드는 Cancel, 트윈 옵션 값 모양(TweenInfo vs 편의 필드)은 신규 열린 논의 | 중 — 세부 옵션만 남음 | +| `tween-plan.md` | **[2026-08-10 세션 재설계]** 트윈을 값-레벨 `Tween` 래퍼(PropertyHandler가 소비)로 전환, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`로 이전. 3-상태 릴레이션 슬롯(`RobloxTween\|true\|nil`)으로 hasBeenSet 억제, `T'=T\|Tween` 타입 치환, `useTween`은 `:Apply`/`:Compute`로 해소. 기본 오버라이드는 Cancel, 트윈 옵션 값 모양(TweenInfo vs 편의 필드)만 남음 | 중 — 세부 옵션만 남음 | | `existing-instance-bind-plan.md` | 이미 생성된 인스턴스 재바인드 — 착수 안 하되 "미지원" 확정도 안 함, 열린 가능성 유지 | 하 — v2 초기 스코프 제외 | | `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 | | `documentation-plan.md` | 문서 사이트 구조(초심자/api/심화/`quadnomicon` 4축, 백엔드별 트랙 분리) + UI 네이밍 컨벤션·Store 부작용 패턴·권장 이벤트 핸들링 3개 세부 문서 뼈대 | 하 — 착수 시점 미정, 구조/스코프만 합의된 상태 | @@ -80,6 +80,7 @@ | `observer-cleanup-contract-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** `Observer` 자체에 React `useEffect`식 cleanup 반환 계약을 추가하는 안 — 클로저로 이미 충분해 기각, `Effect`가 opt-in 상위 계층으로 이 패턴을 제공 | | `keyed-collection-state-method-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** 키 기반 동적 컬렉션 재조정 프리미티브를 `state:Keyed(...)` State 메소드로 두려던 초안 — Source 미사용 컴포넌트가 접근 못 한다는 반례로 철회, 현재는 자유 함수로 확정 | | `debug-channel-replicatedstorage-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** quad-debug 채널을 `ReplicatedStorage`에 자동 생성하던 초안 — 게임 트리 오염 부작용으로 기각, quad 모듈 자신의 트리+`CollectionService` 태그로 대체 | +| `tween-special-bind-key-reversed.md` | **[역전됨, 2026-08-10 신설]** 구 Tween 모델(`[Tween(key,tweenData...)] = storeValue` 특수 bind key, 우선순위 최상위 Dispatch 핸들러) — 값-레벨 `Tween` 래퍼 모델로 완전히 대체됨(`research/tween-plan.md`) | ## 참고 diff --git a/.claude/archive/tween-special-bind-key-reversed.md b/.claude/archive/tween-special-bind-key-reversed.md new file mode 100644 index 0000000..82775ab --- /dev/null +++ b/.claude/archive/tween-special-bind-key-reversed.md @@ -0,0 +1,68 @@ +# [역전됨] Tween = 우선순위 최상위 store-bind Dispatch 핸들러(`[Tween(key,tweenData...)] = storeValue`) — 값-레벨 `Tween` 래퍼로 대체됨 + +**역전 일시**: 2026-08-10. **원 확정 일시**: 2026-08-04(로드맵 인수인계 +라운드 전후, "확정된 방향: 트윈을 Store/반응 그래프 밖에 둔다" 최초 작성). +**현재 유효한 설계**: `research/tween-plan.md`(전면 재작성됨)가 최종 소스. +이 파일은 능동적으로 참고할 필요 없음(구현에 안 씀) — 왜 "Tween이 곧 +범용 store-bind 핸들러"였던 모델에서 "Tween은 PropertyHandler가 소비하는 +값-레벨 래퍼"로 넘어갔는지가 `quadnomicon` 소재로 가치 있어서 사유·원문을 +통째로 보존해둔 것. + +## 역전된 사례 — 원래 무엇을 확정했었나 + +**메커니즘**: Tween을 `[Tween(key, tweenData...)] = storeValue` 형태의 +특수 bind key로 제공. `k`는 무엇이든 받고 `v`가 Store(반응형 값)인 경우를 +잡아내는, **우선순위가 매우 높은 Dispatch 핸들러**. 처음 실행될 때는 +그냥 바인드로 필드를 쓰지만, 이후에는 store 값을 핸들해서 바뀔 때마다 +트윈을 처리: + +``` +[Tween(key, tweenData...)] = storeValue +``` + +핸들러 내부에서: (1) 라이프타임(`Connected`) 확인, (2) 사용자가 넘긴 +함수들을 거쳐 실제 값(`realv`) 계산, (3) `Dispatch.retractUnder`로 자기 +밑을 정리한 뒤 `realv`를 들고 `Dispatch.process(inst, k, realv)`를 재귀 +호출 — "store 바인드는 pluggable 바인드를 재실행하는 래핑"이라는 원칙의 +구체 사례. + +**override 정책**: 기본값 Cancel, 나머지 세 옵션(오버라이드/삭제 후 +재시작/끝점 이동 후 재시작)은 `retract(inst, k, v)`가 이전 값을 받아 +처리 — 이 부분은 새 모델에서도 그대로 유지됨(PropertyHandler 내부 +로직으로 위치만 이동). + +## 왜 역전됐나 + +`research/pre-implementation-audit.md` 우선순위1-1이 지적한 구조적 +모호함이 출발점 — 이 문서 전체에서 "`v`가 store인 값을 구독해 `realv`로 +재귀 process하는" 범용 메커니즘의 유일한 구체 예시가 항상 "Tween"으로만 +등장했음. 그런데 Tween(실제 애니메이션, override/cancel 정책)은 명백히 +더 좁고 별개인 기능이라, `Frame { BackgroundColor3 = store.color }`처럼 +애니메이션 없이 그냥 반응형으로 값만 바뀌길 원하는 가장 흔한 케이스가 +(a) 결국 이름은 "Tween"인 파일을 거쳐가며 "애니메이션 없음"으로 처리되는 +건지, (b) 각 핸들러가 범용 `Dispatch/StoreBind.luau` 유틸을 독립적으로 +써야 하는 건지 문서가 정하지 않은 상태로 남아있었음. + +2026-08-10 세션에서 사용자가 직접 제기한 재설계 방향("Tween 프리미티브를 +`V`에 넣는 식, 최종 Property가 알아서 `V`가 `isTween`이면 트윈 넣도록")으로 +해소 — State/Source 언랩(범용 StoreBind)과 "이 값이 트윈 대상인가" 판단을 +완전히 분리해, 후자를 Dispatch 우선순위 경쟁이 아니라 PropertyHandler +내부의 평범한 값 분기로 옮김. + +## 대체 모델과의 비교 + +| | 구 모델(우선순위 최상위 Dispatch 핸들러) | 신 모델(값-레벨 `Tween` 래퍼) | +|---|---|---| +| 매치 방식 | `isHandlable(inst,k,v) = isState(v)` — Tween이 범용 StoreBind 역할까지 겸함 | 범용 `StoreBind`가 State/Source를 언랩, `Tween` 여부는 `realv`를 받은 PropertyHandler가 직접 판단 | +| "애니메이션 없는 일반 반응형 바인딩"의 정체 | 불명확(이름이 Tween인 파일을 거쳐가는지 문서가 안 정함) | 명확함 — 그냥 `Dispatch/StoreBind.luau`, Tween과 완전히 무관 | +| 핸들러 타입 전환 | Tween↔프로퍼티 핸들러 사이에서 실제로 바뀜 → `retract`가 이 케이스의 대표 예시였음 | 항상 PropertyHandler 하나만 매치 → 이 `retract` 케이스 자체가 사라짐, 전환은 3-상태 릴레이션 슬롯으로 내부 처리 | +| 트윈 대상 값 타입 | Store 전체(`T`뿐 아니라 임의 반응형 값) | `Tween = {Value: T, ease...}` — `Value`는 plain `T`만, 반응성은 바깥 `:Compute`가 전담 | +| 진입 애니메이션 억제 | 별도 논의 없음 | 3-상태 슬롯(`RobloxTween\|true\|nil`)의 `hasBeenSet` 분기로 자동 해결 | + +부수적으로, 이 역전은 Tag가 이미 겪었던 것과 같은 종류의 단순화 — +"핸들러 *타입*이 실제로 안 바뀌면 `retract`가 필요 없어진다"는 결론을 +Tween에도 적용한 셈. Tag 역전(`archive/tag-hash-key-model-reversed.md`)이 +"핸들러 타입이 안 바뀐다는 전제가 실사용에서 깨졌다"는 방향이었다면, +Tween 역전은 반대로 "핸들러 타입을 애초에 안 바뀌게 재설계해서 전제 +자체를 성립시켰다"는 방향 — 같은 `retract`/핸들러-전환 문제를 서로 +반대 방향에서 접근한 두 사례로 대비해볼 만함(quadnomicon 소재). diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index a157fe7..982a850 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -138,6 +138,7 @@ quad/ │ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`), State/Source와 밀접 연관돼 같은 위치 │ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Overridden`(`base/modifier-plan.md`) │ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), CollectionService 글루는 quad-roblox Handlers/Tag.luau(`base/tag-plan.md`, 2026-08-08 세 번째 세션) +│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenTag`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`research/tween-plan.md`, 2026-08-10 세션 재설계) │ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`) │ ├── Dispatch/ │ │ ├── init.luau # process/retract 엔진, isHandlable 우선순위 스캔, `chains`(inst,k별 핸들러 체인)+`retractUnder`(`bind-system-plan.md` "Dispatch 체인" 절, 2026-08-08 세 번째 세션) @@ -156,13 +157,13 @@ quad/ ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) ├── LifetimeHandle.luau # bindLifetime/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용) ├── Handlers/ - │ ├── Property.luau + │ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`) │ ├── Event.luau # ReflectionService 기반 자동 판별 │ ├── Attribute.luau │ ├── Tag.luau # CollectionService 글루만(process/retract) — 값 타입/API는 quad-base Tag.luau(`base/tag-plan.md`) - │ ├── Tween.luau # 높은 우선순위 store-bind 핸들러 │ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작) │ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} }) + ├── Animate.luau # `Animate(condOrOpts, opts?)` 편의 콤비네이터 — `:Apply`/`:Compute`/`Tween{...}` 조합, base 프리미티브 아님(`research/tween-plan.md`) ├── DI/ │ └── init.luau # 제네릭 생성자 + ~25개 정적 필드(UIInstances) └── init.luau diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index f00c1c7..f3fa874 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -61,9 +61,10 @@ Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Att - **`retract` 불필요** — Tag와 같은 이유: 값이 뭐든(실제 값/`nil`) 항상 같은 `AttributeHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜). `retract`가 의미 있는 유일한 패턴("매치되는 핸들러 타입 자체가 바뀜", - Tween↔일반 프로퍼티가 실사례)에 해당 안 함 — `bind-system-plan.md` - "확정된 디스패치 모델" 절이 한때 Attribute도 retract 필요 예시로 들었던 - 걸 여기서 바로잡음. + `Tag(...)`↔`nil`이 실사례 — 2026-08-10 세션부터 Tween은 더 이상 이 + 패턴의 예시가 아님, `research/tween-plan.md`)에 해당 안 함 — + `bind-system-plan.md` "확정된 디스패치 모델" 절이 한때 Attribute도 + retract 필요 예시로 들었던 걸 여기서 바로잡음. - store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store`/`State` 값도 받음). diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 67b6b6f..9b59486 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -48,8 +48,9 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는 **`retract` 필드 자체는 생략 불가, no-op이라도 항상 정의할 것(2026-08-08 세션, 확정)** — `Dispatch.process`(아래 "확정된 디스패치 모델" 절)는 담당 핸들러 *타입*이 바뀔 때 이전 핸들러의 `retract`를 nil 체크 없이 - 무조건 호출함. 필드를 생략한 핸들러가 나중에(드물더라도, 예: Tween↔ - 프로퍼티 교체) 실제로 담당이 바뀌는 순간 `attempt to call a nil value`로 + 무조건 호출함. 필드를 생략한 핸들러가 나중에(드물더라도, 예: `Tag(...)`↔ + `nil` 교체 — `base/tag-plan.md`) 실제로 담당이 바뀌는 순간 `attempt to + call a nil value`로 바로 크래시 — "의미 있게 구현할 필요 없음"은 "구현이 사소해도 됨"이라는 뜻이지 "필드를 안 둬도 안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가 없으면 리뷰/린트에서 걸러내야 함(M2 착수 시 확인 목록에 추가). @@ -84,9 +85,9 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 부분은 `Dispatch.getHandler`로 이름이 공식화됨**(아래 `None` 센티널 절, 2026-08-07 여덟 번째 세션) — 이 절에서는 개념 설명이라 편의상 그냥 `process`로 계속 씀. -- 예시: Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를 - 잡아내는, 우선순위가 매우 높은 핸들러** — `v`가 store이면 그 값을 처리(구독)함. - 이 핸들러 안에서: +- 예시: `Dispatch/StoreBind.luau`(범용, 엔진 무관)는 **`k`는 무엇이든 받고 + `v`가 State/Source인 경우를 잡아내는, 우선순위가 매우 높은 핸들러** — + `v`가 반응형이면 그 값을 처리(구독)함. 이 핸들러 안에서: 1. 지금 이 처리가 실행되어도 되는지 라이프타임(`Connected`)을 확인 — 확인 안 하면 이미 Destroy된 대상에 대해 처리가 실행되는 문제가 생김. GC가 결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로 @@ -98,10 +99,15 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 아래 "Dispatch 체인" 절, 2026-08-08 세 번째 세션 — 오케스트레이터 이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 여덟 번째 세션) — 이게 바로 "store 바인드는 pluggable 바인드를 재실행하는 - 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 store가 - 아니라면 자연히 Tween의 store-bind 핸들러 `isHandlable`을 통과 못 - 하고 우선순위상 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 - 재귀 걱정 없음. + 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 반응형이 + 아니라면 자연히 `StoreBind`의 `isHandlable`을 통과 못 하고 우선순위상 + 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 재귀 걱정 없음. + **[정정, 2026-08-10 세션]** 이 예시는 원래 "Tween의 store-bind + 핸들러"였으나, Tween이 독립 Dispatch 핸들러가 아니라 PropertyHandler가 + 소비하는 값-레벨 래퍼(`Tween`)로 재설계되며(`research/ + tween-plan.md`, `archive/tween-special-bind-key-reversed.md`) 이 + 자리의 대표 예시에서 빠짐 — `NoneHandler`(아래 절)가 지금은 이 + 패턴의 남은 대표 예시. - **`retract(inst, k, v)`** (이전 초안의 "cleanup", 이름 변경 근거는 `base/lifecycle-pattern.md` 참고) — 이전 처리를 무르는/멈추는 함수. **오직 "같은 key에 새 값이 들어와서 이전 처리를 갈아치우는" 시나리오에만 존재** — @@ -111,18 +117,23 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 동작) — 그래서 프로퍼티 핸들러는 보통 `retract`가 필요 없음. - **`retract`가 실제로 의미 있는 유일한 패턴은 "같은 키에 대해 매치되는 핸들러 *타입 자체*가 사이클마다 바뀌는 경우"** (2026-08-07 여덟 번째 - 세션, 정정) — 예: 이전엔 Tween 핸들러가 매치돼 애니메이션이 실행 - 중이었는데, 다음 값이 더 이상 Tween 대상이 아니게 되어 일반 - PropertyHandler로 매치가 넘어가는 경우, 이전 Tween을 멈추는 게 - `retract`의 일. **Attribute는 여기 해당 안 함** — UICorner 숏핸드와 - 같은 패턴(값의 참/거짓/nil 여부와 무관하게 항상 같은 핸들러가 계속 - 담당, 추가/제거를 전부 `process` 자신이 처리)이라 핸들러 교체 자체가 - 안 일어남 — `base/attribute-plan.md`. **[정정, 2026-08-08 세 번째 - 세션] Tag는 더 이상 여기 해당하지 않음** — array-part 값 객체로 - 재설계되며(`base/tag-plan.md`, 구 모델은 `archive/ - tag-hash-key-model-reversed.md`) `Tag(...)`↔`nil` 사이에서 핸들러 - 타입 자체가 바뀌므로 `retract`가 의미 있어짐(전체 삭제), 같은 Tag끼리 - 바뀌는 diff는 `process`가 담당. + 세션, 정정) — 예: `Tag(...)`↔`nil` 사이에서 핸들러 타입 자체가 + 바뀌므로 `retract`가 의미 있어짐(전체 삭제), 같은 Tag끼리 바뀌는 + diff는 `process`가 담당(`base/tag-plan.md`, 2026-08-08 세 번째 세션 + — array-part 값 객체 재설계 이후, 구 모델은 `archive/ + tag-hash-key-model-reversed.md`). **Attribute는 여기 해당 안 함** — + UICorner 숏핸드와 같은 패턴(값의 참/거짓/nil 여부와 무관하게 항상 + 같은 핸들러가 계속 담당, 추가/제거를 전부 `process` 자신이 처리)이라 + 핸들러 교체 자체가 안 일어남 — `base/attribute-plan.md`. **[정정, + 2026-08-10 세션] Tween도 더 이상 여기 해당하지 않음** — 원래는 이 + 패턴의 대표 예시("Tween 핸들러가 매치돼 애니메이션 실행 중이었는데 + 값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로 매치가 + 넘어가는 경우")였으나, Tween이 독립 Dispatch 핸들러가 아니라 + PropertyHandler가 소비하는 값-레벨 래퍼(`Tween`)로 재설계되며 + 매치되는 핸들러가 항상 PropertyHandler 하나뿐이 되어 이 케이스 + 자체가 사라짐 — 트윈 취소/전환은 이제 PropertyHandler 내부의 + 3-상태 릴레이션 슬롯으로 처리(`research/tween-plan.md`, `archive/ + tween-special-bind-key-reversed.md`). - store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract`를 호출해주면 됨 — **정확한 전파 메커니즘은 아래 "Dispatch 체인" 절 참고**(재귀 재-dispatch에서 여러 단계가 겹칠 때 어느 슬롯에 뭘 추적하는지가 @@ -141,8 +152,12 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 명시화)**: 구조가 "`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이 중첩된" 모양이라, `inst`가 죽어 weak table 엔트리가 통째로 사라지는 순간 그 안에 중첩된 `k`별 Tween 인스턴스 릴레이션도 같이 GC됨 — - 별도 cleanup 로직 불필요(Tween 핸들러가 여기 담아두는 실제 Tween 인스턴스도 - 자동으로 같이 죽는 것까지 포함, `research/tween-plan.md` 참고). + 별도 cleanup 로직 불필요(PropertyHandler가 여기 담아두는 실제 엔진 Tween + 인스턴스도 자동으로 같이 죽는 것까지 포함). **[정정, 2026-08-10 세션]** + Tween은 더 이상 별도 "Tween 핸들러"가 아니라 PropertyHandler 내부 + 로직이므로, 이 슬롯이 실제로 담는 값은 `RobloxTween | true | nil` + 3-상태(첫 세팅 여부까지 같은 슬롯에 통합) — 상세는 `research/ + tween-plan.md` "3-상태 저장" 절 참고. - **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot 핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를 갱신해야 함 — `retract` 시점엔 그 추적(구독)만 풀면 됨. @@ -153,9 +168,9 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 둬야 할 문제가 아니라 오작동하는 handler/provider(`quad-roblox` 등) 쪽 버그로 간주 — **사용자 확정**("입력된 값이 다시 입력되면 무한루프 빠지겠지만, 그건 막기 힘들고 유저가 내기도 힘들어. 아예 quad-roblox나 - 프로바이더가 잘못 짠 코드일테니까"). Tween의 store-bind 재귀 케이스(위 - 78-79행)처럼 자연히 좁혀지는 경우가 일반적이고, 일반 사용자가 만들어낼 수 - 있는 상황이 아니라고 판단해 별도 가드 없이 진행. + 프로바이더가 잘못 짠 코드일테니까"). `StoreBind`의 재귀 케이스(위 절)처럼 + 자연히 좁혀지는 경우가 일반적이고, 일반 사용자가 만들어낼 수 있는 상황이 + 아니라고 판단해 별도 가드 없이 진행. - **props 순회 순서는 base 디스패치 드라이버가 명시적으로 두 단계로 고정한다 — 배열 파트(숫자 키, children/Ref류) 먼저, 해시 파트(문자열 키, @@ -177,13 +192,13 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로 부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸). -### `None` 센티널 — Tween store-bind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션) +### `None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션, 예시는 2026-08-10 세션에 StoreBind로 정정) `modifier-plan.md` "2-1"절의 "인라인 키로 modifier 필드를 명시적으로 지우기" 문제 — raw 저장 계층(Modifier 필드/인라인 props/`Peek`)에서 쓰는 `None` 센티널이 실제로 인스턴스에 반영될 때 base가 뭘 하는지가 이 문서의 층위. 결론: **새 메커니즘이 아니라 위 "확정된 디스패치 모델"의 -Tween store-bind 핸들러(65-79행)와 완전히 같은 모양의 핸들러 하나 추가.** +`StoreBind` 핸들러(위 절)와 완전히 같은 모양의 핸들러 하나 추가.** ``` NoneHandler.priority = <매우 높음> @@ -209,8 +224,8 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커. `Dispatch.process(inst, k, nil)`로 재귀 호출하는 순간 `None`은 더 이상 존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 키 `k`를 - 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 — Tween의 - store-bind 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는 + 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 — + `StoreBind` 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는 것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음. - **`Dispatch.process`/`Handler.process` 이름 겹침 — 소유자 네임스페이싱으로 해소, 새 이름 발명 안 함 (2026-08-07 여덟 번째 세션 후속).** 원래 @@ -222,7 +237,7 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 - `Dispatch.process(inst,k,v)` — 오케스트레이터: `getHandler` 호출 → 매치된 핸들러를 `(inst,k)` 체인 꼬리에 push → 그 핸들러의 `.process` 호출. **"이전 핸들러와 다르면 retract"라는 diff는 `Dispatch.process` - 자신의 일이 아님** — 재귀/래핑 핸들러(Tween/일반 store-bind/ + 자신의 일이 아님** — 재귀/래핑 핸들러(`StoreBind`/ `NoneHandler`)가 재-dispatch 전에 스스로 `Dispatch.retractUnder(inst, k, self, newV)`를 먼저 불러 자기 밑을 정리하는 책임을 짐(정확한 메커니즘·기각된 대안은 아래 "Dispatch 체인" 절 참고 — 전역 소유자 @@ -277,7 +292,7 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 만들 수 있는 것)로 바꿔야 하는지 검토 후 **기각, 지금 형태(모듈 require로 바로 닿는 flat 탑레벨 함수) 유지로 확정**: -- **재귀 재-dispatch가 요구하는 필연** — Tween/`NoneHandler`/`Dispatch/ +- **재귀 재-dispatch가 요구하는 필연** — `NoneHandler`/`Dispatch/ StoreBind.luau` 전부 자기 `process` 안에서 다시 `Dispatch.process(inst,k, realv)`를 호출함(위 "확정된 디스패치 모델"/"`None` 센티널" 절). 이게 성립하려면 Dispatch가 `canExecute`/`bindLifetime`(`base/ @@ -288,11 +303,11 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 - **순환참조로 보이는 건 착시 — 실제로는 단방향.** "Handler"라는 말이 두 가지를 가리켜서 헷갈릴 수 있음: (a) `Handler.luau`의 **타입 계약** (`isHandlable`/`priority`/`process`/`retract` 시그니처만 있는 순수 leaf, - Dispatch를 몰라도 됨) vs (b) `StoreBind.luau`/`Tween.luau`처럼 그 계약을 + Dispatch를 몰라도 됨) vs (b) `StoreBind.luau`처럼 그 계약을 **구현하는 concrete 값 모듈**(재귀호출 위해 Dispatch를 require함). 의존 방향은 항상 한쪽으로만 흐름 — `Handler.luau`(leaf) ← `Dispatch/init.luau` - (`addHandler(h: Handler)`가 `Handler` 타입만 참조) ← `StoreBind.luau`/ - `Tween.luau`(재귀호출 위해 Dispatch를 참조). `Handler.luau` 자신이 + (`addHandler(h: Handler)`가 `Handler` 타입만 참조) ← `StoreBind.luau` + (재귀호출 위해 Dispatch를 참조). `Handler.luau` 자신이 Dispatch를 되받아 참조하는 일이 없으니 타입 레벨에서도 사이클이 안 생김. 런타임에서도 마찬가지 — 어떤 handler의 `process`든 실제로 *호출*되는 시점은 컴포넌트가 렌더되는 시점이라, 그때는 이미 Dispatch 모듈 require가 @@ -304,9 +319,11 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 `.claude/question.md`가 2026-08-08 세션에 "quad-base/quad-roblox 중 어디 사는지 미확인"으로 남겨뒀던 항목, 이 결론으로 해소: quad-base, `Dispatch/Leaf.luau`, `Dispatch.addHandler`로 등록). quad-roblox의 - Property/Event/Tween 핸들러도 **같은** `Dispatch.addHandler` 레지스트리에 + Property/Event 핸들러도 **같은** `Dispatch.addHandler` 레지스트리에 등록됨 — base 기본 핸들러와 backend 핸들러가 별도 경로로 안 갈리고 - 전부 하나의 우선순위 스캔을 공유. + 전부 하나의 우선순위 스캔을 공유. **[정정, 2026-08-10 세션]** Tween은 + 더 이상 별도로 등록되는 핸들러가 아님 — Property 핸들러 내부에서 + 소비되는 값-레벨 래퍼로 재설계됨(`research/tween-plan.md`). - **모듈 재생성(`New()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히 풀림.** v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는 방식은 안 씀(위 "확정된 것" 절 — id 기반 조회 자체가 Ref로 대체되며 @@ -323,7 +340,7 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 ### Dispatch 체인 — 재귀 재-dispatch의 retract 전파, `Dispatch.retractUnder` (2026-08-08 세 번째 세션) -**문제**: Tween/`NoneHandler`/`StoreBind`처럼 자기 `process` 안에서 +**문제**: `NoneHandler`/`StoreBind`처럼 자기 `process` 안에서 `Dispatch.process(inst,k,realv)`를 다시 부르는 래핑 핸들러가 있으면, 같은 `(inst,k)`에 대해 "지금 누가 담당 중인가"를 슬롯 하나로 추적하는 순간 깨짐 — 래핑 핸들러 A 자신의 생명주기(예: StoreBind의 Observer 구독)와, @@ -395,10 +412,13 @@ end 중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야 하는 핸들러가 있어서, `base/tag-plan.md` 참고). 다만 `v`가 실제로 필요한지는 핸들러마다 다름 — Tag는 구조상 retract가 "더 이상 매치 안 될 때만" - 불리므로 `v`를 안 봐도 항상 전체 삭제가 맞음(무조건), Tween 같은 - 경우는 자기 `Relate` 저장분만 보고 `Cancel`하면 되니 역시 `v`를 꼭 - 안 봐도 됨 — `v`는 "계약상 항상 주어지지만 안 쓰는 핸들러가 있어도 - 됨" 정도로 이해할 것. + 불리므로 `v`를 안 봐도 항상 전체 삭제가 맞음(무조건) — `v`는 "계약상 + 항상 주어지지만 안 쓰는 핸들러가 있어도 됨" 정도로 이해할 것. + **[정정, 2026-08-10 세션]** 원래 두 번째 예시로 들었던 Tween(자기 + `Relate` 저장분만 보고 `Cancel`하면 되니 `v`를 꼭 안 봐도 됨)은 더 + 이상 유효한 예시가 아님 — PropertyHandler가 항상 매치되는 유일한 + 핸들러가 되어 이 `retract` 경로 자체가 사실상 안 쓰임(`research/ + tween-plan.md`). - **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고 B가 다시 A로 돌아오는 것)는 재귀 호출이 안 끝나 바로 스택오버플로가 나므로 애초에 일어날 수 없는 구조(각 핸들러는 최대 한 번씩만 그 @@ -676,9 +696,10 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 ## Ref — 도입 확정, 단 용도는 재정의됨 -**중요한 정정**: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(Tween 핸들러도 -`process(inst,k,v)`처럼 항상 대상 Instance를 직접 받으므로 — 위 "확정된 디스패치 -모델" 참고, `research/tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름: +**중요한 정정**: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(트윈을 +실제로 처리하는 PropertyHandler도 `process(inst,k,v)`처럼 항상 대상 +Instance를 직접 받으므로 — 위 "확정된 디스패치 모델" 참고, `research/ +tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름: - v1의 `Frame "id" {}` + `Store.GetObject(id)` 식 id 매핑은 폐기 확정 (`base/architecture.md` 5번 항목) — "비현실적"이라는 게 이유. @@ -2017,8 +2038,11 @@ T|State|nil`가 돌려주는 raw union을 사용자 코드가 분기하려면 수단이 필요했음)와 똑같은 필요가 quad의 다른 branded 타입에도 전부 적용됨 — `Observer`/`Effect`/`Tag`/`Attribute`/`Tween`/`Blocker`/`Store`/ `Source`/`Slot`/`None`까지, Handler 구현(`isHandlable`에서 "이 값이 -Tween인가/Store인가" 판별)과 사용자 코드 양쪽에서 반복적으로 필요해질 -수단이라 `isState` 하나만 만들고 끝내지 않고 전체를 일관된 메커니즘으로 +Store인가/Tag인가" 판별, 또는 PropertyHandler의 `process` 내부에서 +"이 값이 Tween인가" 판별 — 2026-08-10 세션부터 `isTween`은 `isHandlable`이 +아니라 값-레벨 분기에서만 쓰임, `research/tween-plan.md` 참고)과 사용자 +코드 양쪽에서 반복적으로 필요해질 수단이라 `isState` 하나만 만들고 +끝내지 않고 전체를 일관된 메커니즘으로 통합(component-composition-plan.md 4번 절이 이미 "`isSource`류 판별자로 (`isObserver`와 동일한 패턴)"라고 이 방향을 예견해뒀던 것과 맞아떨어짐). diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index ed8dfc7..718b321 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -85,9 +85,9 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스 - **실제 "지우기" 동작은 디스패치 쪽 `NoneHandler`가 담당** — merge가 끝난 뒤 최종 flatten 결과에 `None`이 남아있으면, base 드라이버가 그 키를 어떻게 처리하는지는 새 개념이 아니라 이미 확정된 디스패치 모델 그대로다. - 상세는 `base/bind-system-plan.md`의 "`None` 센티널 — Tween store-bind와 + 상세는 `base/bind-system-plan.md`의 "`None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용" 절 참고 — 핵심만 요약하면 `NoneHandler`도 - Tween의 store-bind 핸들러와 완전히 같은 모양(`isHandlable`이 `v == None`을 + `StoreBind` 핸들러와 완전히 같은 모양(`isHandlable`이 `v == None`을 잡고, `process`가 `v`를 진짜 `nil`로 바꿔 `process(inst, k, nil)`을 재귀 호출)이라 base 드라이버 자체엔 `None`을 아는 코드가 한 줄도 안 들어감 — 개별 프로퍼티/이벤트/UI shorthand 핸들러도 자기 시그니처에 `None`이 안 @@ -309,7 +309,7 @@ predicate(`Brand` 절)를 State/Source 쪽에도 적용해 **런타임에 직접 `isModifier`면 캐싱 전에 `error`). 새 체크 지점을 여러 곳에 흩는 게 아니라, "값이 State/Source의 값으로 확정되는" 이미 존재하는 몇 안 되는 지점에 `isModifier` 검사 한 줄씩 얹는 것뿐. -- **Slot/Tag/Attribute/Tween 등 다른 핸들러 계층 값은 여전히 아무 +- **Slot/Tag/Attribute 등 다른 핸들러 계층 값은 여전히 아무 문제 없이 State/Source에 담길 수 있음 — Modifier만의 예외임을 명확히.** (사용자 확인: "slot은 당연히 가능함, retract도 되는 애고 런타임 값이라") 이 값들은 전부 정상적으로 `process`/`retract` @@ -319,7 +319,14 @@ predicate(`Brand` 절)를 State/Source 쪽에도 적용해 **런타임에 직접 없음. Modifier만 유독 문제인 건 Modifier가 애초에 dispatch 경로를 아예 안 타는 유일한 존재(1번 절)라서, State/Source에 담기는 순간 "재귀 재-dispatch로 처리"할 대상 자체가 없어지기 때문 — 이 구분이 - 왜 Modifier만 막고 나머지는 다 허용하는지의 핵심 근거. + 왜 Modifier만 막고 나머지는 다 허용하는지의 핵심 근거. **[정정, + 2026-08-10 세션] `Tween`은 이 그룹에서 빠짐** — Tween이 독립 Dispatch + 핸들러(`process`/`retract`를 가진 dispatch 참가자)에서 PropertyHandler가 + 소비하는 값-레벨 래퍼로 재설계되며(`research/tween-plan.md`), `Tween`는 + 이제 `process`/`retract`가 없는 순수 raw 데이터 값 — `None`과 같은 + 분류. State/Source에 `Tween`가 담기는 것 자체는 여전히 문제없이 + 허용되지만(위 타입 대수 절 참고), 그 이유는 "재귀 dispatch 참가자라서"가 + 아니라 "그냥 raw 값이라서"로 바뀜. - **`Store`의 `T`는 Modifier가 될 수 없음(`base/store-semantics.md` "따름정리" 절)도 이 결정을 그대로 물려받음** — Source가 State를 구조적으로 만족하므로 별도로 다시 논증할 필요 없이 동일하게 적용됨. @@ -541,6 +548,27 @@ Source도 같이 잡아줌 — **[2026-08-07 여덟 번째 세션 정정] `isSou 아니라 4-1번 setter 분기가 이미 내부적으로 해야 하는 걸 public 유틸로 승격하는 것뿐. +### 10. `Tween`와의 타입 합성 — `T' = T | Tween` 치환만으로 해결 (2026-08-10 세션) + +`research/tween-plan.md`가 값-레벨 `Tween` 래퍼로 재설계되며, 프로퍼티류 +Modifier 필드 setter가 트윈 값도 받을 수 있어야 하는지가 자연히 따라오는 +질문이었음 — **답은 "이미 있는 `T | State` 필드 타입 모양에 새 케이스를 +추가할 필요가 없다"** — 위 4번 절이 확정한 필드 타입 모양(리터럴 `T` 또는 +`State`)에서 "이 필드의 `T`" 자체를 `T' = T | Tween`로 치환하면 +자동으로 `T | Tween | State>`가 나옴. 즉 `FrameModifier`류 +타입 생성 스크립트가 `Position` 필드를 만들 때 그냥 `T`를 `UDim2 | +Tween`로 바꿔서 기존 setter 시그니처 생성 로직에 그대로 넣으면 됨 — +Modifier의 제네릭 `__index`/`table.clone` 런타임(위 "런타임은 클래스별 +코드 없이" 절)에도 `Tween` 인지 로직을 전혀 추가할 필요 없음(setter는 +어차피 값을 그대로 baked 저장할 뿐, 그 값이 `Tween`인지는 나중에 +PropertyHandler가 판단). + +`Tween`가 Modifier 필드로 담기는 것도, `State>`처럼 State/Source +값으로 담기는 것도 둘 다 아무 문제 없음 — 7번 절의 "핸들러 계층 값 → +error" 규칙에 안 걸림(`Tween`는 `process`/`retract`를 가진 dispatch +참가자가 아니라 `None`처럼 순수 raw 데이터 값, 위 7번 절 "Slot/Tag/Attribute +등" 목록에서 Tween을 뺀 정정 참고). + ## 열린 질문 (`.claude/question.md`에도 취합) - **[해소됨]** Getter 정확한 이름/모양 — 2026-08-06 후속 세션에서 getter diff --git a/.claude/question.md b/.claude/question.md index 64c09df..14ce370 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -138,10 +138,12 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 정해두면 싼 지연리스크) + 2개 단순화후보. 전체는 그 문서 참고, 특히 사용자 판단이 필요한 것 위주로 요약: -- **Tween.luau가 "범용 store-bind 캐치올 핸들러"의 유일한 예시로 서술됨** — - 일반 반응형 프로퍼티 바인딩(`BackgroundColor3 = store.color`, 애니메이션 - 없음)이 결국 이름은 "Tween"인 파일을 거쳐가는 건지, 아니면 별도 범용 - `Handlers/StoreBind.luau`가 있어야 하는 건지 확정 필요 — 우선순위1-1. +- **[해소됨, 2026-08-10 세션]** Tween.luau가 "범용 store-bind 캐치올 + 핸들러"의 유일한 예시로 서술됨 — Tween을 독립 Dispatch 핸들러에서 + 값-레벨 래퍼(`Tween`, PropertyHandler가 소비)로 재설계해 해소. + 범용 State/Source 언랩은 `Dispatch/StoreBind.luau` 하나뿐, Tween 여부 + 판단은 완전히 별개(`research/tween-plan.md` 전면 재작성, 우선순위1-1 + 해소). - **[해소됨, 2026-08-09 세션]** `State`와 Ref/Slot이 Modifier 필드에 들어가는 것 — 이제 둘 다 `isX` predicate 기반 명시적 `error`로 통일(`base/modifier-plan.md` 4번/7번 절, `base/store-semantics.md` @@ -266,7 +268,7 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** | Batch(lexical) 기각, Context(+레이어드 Store) 기각 | `archive/batch-rejected.md`, `archive/context-rejected.md` | | Fusion/Vide 비교 리서치(주의: 일부 서술은 이후 라운드에서 뒤집힘, 문서 내 정정 표시 참고) | `reference/comparison-fusion-vide.md` | | v1 내부 동작 스냅샷 | `reference/quad-v1-architecture.md` | -| 트윈 오버라이드(기본값 Cancel), 세부 옵션만 남음 | `research/tween-plan.md` | +| 트윈 — 값-레벨 `Tween` 래퍼로 구조 확정(2026-08-10), 오버라이드 기본값 Cancel, 옵션 값 모양·옵션 키 이름만 남음 | `research/tween-plan.md` | | quad2-try(폐기된 이전 시도) 리서치 — OOP 상속/커스텀 파서/Slot 스텁/`Pipe` COW 전부 죽은 접근으로 확인, 반복 조사 금지 | `base/bind-system-plan.md` | --- diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index bc5872b..f23b8e0 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -36,7 +36,17 @@ M0 착수 전에 확인**. 우선순위 2는 지금 결정해두면 싼데 안 ## 우선순위 1 — M0~M4 착수 전 확인 권장 -### 1-1. Tween이 "범용 store-bind 캐치올 핸들러"의 유일한 예시로 쓰여, 일반 반응형 프로퍼티 바인딩과 혼동될 위험 +### 1-1. Tween이 "범용 store-bind 캐치올 핸들러"의 유일한 예시로 쓰여, 일반 반응형 프로퍼티 바인딩과 혼동될 위험 — [해소됨, 2026-08-10 세션] + +**해소**: 아래 "제안"과 같은 방향(별개 핸들러로 분리)이 아니라, 그보다 +더 근본적인 재설계로 해소됨 — Tween 자체가 더 이상 독립 Dispatch +핸들러가 아니라 PropertyHandler가 소비하는 값-레벨 래퍼(`Tween`)로 +바뀌어, "일반 store-bind와 Tween-store-bind가 같은 핸들러인지"라는 +질문 자체가 성립하지 않게 됨 — 범용 State/Source 언랩은 `Dispatch/ +StoreBind.luau` 하나뿐이고 Tween은 그 아래에서 나온 `realv`를 +PropertyHandler가 직접 판별. 상세는 `research/tween-plan.md`(전면 +재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`. 아래는 +원래 발견 당시 기록. **위치**: `base/bind-system-plan.md` "확정된 디스패치 모델" 절 67-79행 — "Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를 diff --git a/.claude/research/tween-plan.md b/.claude/research/tween-plan.md index f8439b3..d8ea3ec 100644 --- a/.claude/research/tween-plan.md +++ b/.claude/research/tween-plan.md @@ -1,57 +1,228 @@ -# Tween / 애니메이션 플러깅 (기본값 확정, 옵션 키 이름·값 모양만 남음) +# Tween / 애니메이션 플러깅 (구조 재확정 — 값-레벨 `Tween` 래퍼, 옵션 값 모양만 남음) -**상태**: research — 방향은 뚜렷하게 잡혀 있고(라이브러리가 트윈을 직접 -구현하지 않는다), `retract` 순서/오버라이드 기본값(Cancel)도 확정됨. 남은 건 -기본값 외 나머지 오버라이드 동작을 고르는 옵션 키의 정확한 이름/시그니처와, -트윈 옵션을 어떤 값 모양으로 받을지(아래 "트윈 옵션 값 모양" 절, 신규) -정도. **중요 — 놓치기 쉬운 포인트**: `retract`는 Destroy(완전 소멸) 시엔 -호출되지 않는다(아래 "`retract`는 완전 소멸 시엔 호출되지 않는다" 절) — -Tween 오버라이드 로직을 짤 때 "인스턴스가 파괴될 때도 이 코드가 실행될 -것"이라고 가정하면 틀림. 원본: +**상태**: research — **2026-08-10 세션에서 구조 전체가 재설계됨.** 기존 +"`v`가 Store인 아무 `k`나 잡는 우선순위 최상위 Dispatch 핸들러" 모델은 +`research/pre-implementation-audit.md` 1-1이 지적한 구조적 모호함("애니메이션 +없는 일반 반응형 프로퍼티 바인딩도 결국 이름이 Tween인 파일을 거쳐가는가")을 +명확히 답하지 못했음 — 대체된 새 모델(`Tween`를 PropertyHandler가 +소비하는 값-레벨 래퍼로 두는 것)이 이 모호함을 구조적으로 해소함, 아래 +"새 모델" 절부터가 최종 소스. **구 모델(특수 bind key `[Tween(key, +tweenData...)] = storeValue`)은 `archive/tween-special-bind-key-reversed.md`로 +이전됨** — 원문/역전 사유는 거기 보존, 이 문서는 새 모델만 서술. + +남은 건 옵션 값 모양(`TweenInfo` 그대로 vs 편의 필드)과 override 정책 +옵션 키의 정확한 이름/시그니처뿐 — M11 착수 시 확정. 원본: `.claude/initreq/raw-userinput.md` "트윈은 어떻게 할 것이냐" / "스토어 값은 항상 먼저 캐치한다" / "네임스페이스드 객체" 절. Fusion의 Tween/Spring이 -반응 그래프 안에 있는 설계는 명시적 반면교사 — [정정: 절 제목이 실제와 -달랐음] `reference/comparison-fusion-vide.md`의 "Fusion" 절 마지막 불릿 -("Tween/Spring이 State그래프 안의 1급 노드") 참고. +반응 그래프 안에 있는 설계는 명시적 반면교사 — `reference/ +comparison-fusion-vide.md`의 "Fusion" 절 마지막 불릿("Tween/Spring이 +State그래프 안의 1급 노드") 참고. -## 확정된 방향: 트윈을 Store/반응 그래프 밖에 둔다 +## 확정된 방향: 트윈을 Store/반응 그래프 밖에 둔다 (변경 없음) 렌더 라이브러리가 트윈을 직접 구현하는 건 처음부터 디자인이 잘못된 접근 — 렌더링 엔진(Roblox `TweenService`)의 애니메이션 관리를 전혀 안 따르게 되기 -때문. 대신: - -``` -[Tween(key, tweenData...)] = storeValue -``` - -형태의 **특수 bind key**로 제공. 처음 실행될 때는 그냥 바인드로 필드를 쓰지만, -이후에는 store 값을 핸들해서 바뀔 때마다 트윈을 처리. 아니면 사용자가 직접 -태그를 얻어 관리하게 둠(둘 다 허용 가능한 경로로 열어둘 것). +때문. quad는 트윈을 반응 그래프에 1급 노드로 편입시키지 않고, 트윈 대상 +값을 감싸는 얇은 값-레벨 래퍼(아래)로만 취급한다. **왜 이게 중요한가(Fusion 리서치에서 확인된 근거)**: Fusion은 Tween/Spring을 `GraphObject`(1급 반응 노드, `timeliness="eager"`)로 만들어서 Computed의 입력으로 자유롭게 합성 가능하게 했지만, 그 대가로 (1) 매 프레임 틱하는 외부 클럭 소스(Stopwatch/ExternalTime)를 반응 그래프에 통합해야 했고, (2) eager 재계산 노드가 무효화/전파 로직과 경쟁하게 됐고, (3) 트윈-입력 간 별도의 교차 lifetime -체크 기계장치가 필요해졌다. quad가 트윈을 특수 bind key로 빼면 이 세 가지 +체크 기계장치가 필요해졌다. quad가 트윈을 값-레벨 래퍼로 빼면 이 세 가지 복잡도를 전부 피할 수 있다 — 대신 트윈된 값이 Computed류의 추가 입력으로 -자유롭게 합성되진 못한다는 걸 감수(Fusion 대비 유일한 손해). +자유롭게 합성되진 못한다는 걸 감수(Fusion 대비 유일한 손해, 아래 새 모델에서도 +그대로 유지됨 — `Tween.Value`는 plain `T`만 받고 자체 반응 경로를 안 둠). -## 정정: Ref 불필요 — 핸들러는 항상 대상 Instance를 직접 받는다 +## 새 모델: `Tween` 값-레벨 래퍼, PropertyHandler가 소비 (2026-08-10 세션, 핵심 재설계) -**이전 초안의 전제가 틀렸음.** Tween 핸들러도 `base/bind-system-plan.md`의 -"확정된 디스패치 모델"을 그대로 따르는 store-bind 핸들러 중 하나 — `process(inst, -k, v)`가 항상 대상 Instance(`inst`)를 직접 받으므로, 트윈 대상을 얻기 위해 -Ref나 네임스페이스드 조회가 필요하지 않음. Tween의 store-bind 핸들러는 "`k`는 -무엇이든, `v`가 Store인 것"을 잡아내는 우선순위 매우 높은 핸들러로 등록되고, -`inst`는 이미 파라미터로 주어짐. (Ref 자체는 도입되지만 전혀 다른 -용도 — `base/bind-system-plan.md`의 Ref 절 참고.) +**동기**: 기존 모델("`k`는 무엇이든, `v`가 Store인 경우를 잡는 우선순위 +매우 높은 핸들러")은 State/Source 언랩(범용 StoreBind)과 Tween(실제 +애니메이션, 좁고 별개인 기능)을 같은 Dispatch 핸들러 하나로 뭉쳐서, +"`Frame { BackgroundColor3 = store.color }`처럼 애니메이션 없이 그냥 +반응형으로만 바뀌길 원하는 흔한 케이스가 결국 이름은 Tween인 파일을 +거쳐가는지"가 문서로 답이 안 됐음. -## `retract`(구 cleanup)로 확정된 오버라이드 시맨틱 +**해소**: State/Source 언랩(`Dispatch/StoreBind.luau`, 범용·엔진무관 — +`k`/`v`의 실제 타입과 무관하게 그냥 `isState(v)`만 보고 `realv`까지 +재귀적으로 풀어냄)은 완전히 별개로 유지하고, **"이 값이 트윈 대상인가"는 +최종 소비자(PropertyHandler)가 `realv`를 다 풀어낸 뒤 직접 판단**한다 — +별도 Dispatch 핸들러/우선순위 경쟁이 아니라, PropertyHandler 함수 내부의 +평범한 분기. -**스토어 값은 항상 먼저 캐치한다** — 그래야 `retract` 호출이 가능(이름 변경 -근거는 `base/lifecycle-pattern.md`). 이전 트윈을 취소하고 새 트윈을 만드는 게 -맞지만, "취소" 시점의 동작이 여러 갈래로 갈릴 수 있음: +```lua +Tween(opts: {Value: T, ease..., onOverride?...}) -> Tween +``` + +`Store({...})`와 같은 "`Type(args)`가 테이블 인자를 받는 팩토리" 컨벤션 — +Lua 문법상 `Tween{Value=target, ease=...}`처럼 괄호를 생략해 호출. + +**PropertyHandler.process(inst,k,realv)의 새 로직** — `realv`는 이미 +StoreBind가 State/Source 레이어를 전부 풀어낸 뒤의 값: + +1. `isTween(realv)`가 거짓이면 — 기존과 동일하게(아래 "3-상태 저장" 참고, + `hasBeenSet` 여부만 갱신하고) 즉시 세팅. +2. `isTween(realv)`가 참이면 — 아래 "3-상태 저장" 절의 분기를 따름. + +### `Tween.Value`는 plain `T`만 받음 — 내부에 별도 반응 경로를 안 둠 + +처음엔 `Tween.Value`도 `T | State`를 받아야 하나(내부에 자체 Observer를 +걸어 값이 바뀔 때마다 트윈을 재시작) 검토했으나 **불필요로 확정** — 이미 +바깥 `:Compute`가 소스 State 변경마다 새 `Tween{Value=v,...}` 테이블을 +통째로 재생성해 StoreBind 재귀 재-dispatch 경로를 타므로, `Tween` 값 +내부에 또 다른 반응 경로를 만들 이유가 없음. "같은 일 하는 두 번째 경로를 +만들지 않는다"는 이 프로젝트가 Effect의 deps/Ref의 대기 경로 등에서 이미 +여러 번 적용한 원칙과 정확히 같은 결. **`Tween = {Value: T, ease..., +onOverride?...}`로 확정** — `Value` 필드는 항상 plain `T`. + +### 3-상태 저장 — `RobloxTween | true | nil` (릴레이션 슬롯 하나로 `hasBeenSet` 통합) + +처음엔 "첫 세팅 여부(`hasBeenSet: boolean`)"와 "실행 중인 엔진 Tween +객체"를 별도 필드로 저장하려 했으나, **하나의 릴레이션 슬롯으로 통합** — +`relate:GetStrong(inst,k)`가 돌려주는 값의 3가지 상태: + +- **`nil`** — 이 `(inst,k)`가 이번 `inst`에서 한 번도 process된 적 없음 + (첫 세팅). +- **`true`** — 최소 한 번 세팅된 적 있음(직전 값이 plain이었든 `Tween` + 였든 무관), 지금은 활성 엔진 Tween 없음. +- **실제 엔진 `TweenBase` 인스턴스** — 지금 애니메이션이 진행 중, 새 값을 + 처리하기 전에 먼저 정리해야 함. + +**분기**: + +1. **`prev == nil`(첫 세팅)** — `realv`가 `Tween`든 plain이든 무관하게 + **애니메이션 없이 즉시 `Value`(또는 plain 값)로 세팅**, 슬롯엔 `true` + 저장. 엔진 기본값(예: Frame 기본 `Position`)에서 목표값으로 날아오는 + "첫 마운트 진입 애니메이션" 버그를 이걸로 방지. +2. **`prev == true`(세팅된 적 있음, 활성 트윈 없음)**: + - `realv`가 plain 값 → 즉시 세팅, 슬롯은 `true` 유지. + - `realv`가 `Tween` → 이제 정상적으로 애니메이션 시작(현재 인스턴스 + 프로퍼티 값에서 자연스럽게 출발), 슬롯에 새로 만든 엔진 Tween 객체 + 저장. +3. **`prev`가 엔진 Tween 객체(활성 트윈 있음)**: + - **먼저 override 정책(기본 Cancel, 아래 절)에 따라 이전 트윈을 정리 — + 반드시 그 정리가 끝난 뒤에 새 값을 세팅한다.** 순서가 뒤바뀌면 + 이전 트윈의 다음 인터폴레이션 프레임이 방금 세팅한 값을 덮어쓸 + 위험이 있음(엔진 트윈은 비동기로 계속 프로퍼티를 갱신 중이므로). + - 정리 후: `realv`가 plain 값이면 (정리 결과로 프로퍼티에 남은 현재 + 값 위에) 즉시 덮어쓰기 + 슬롯 `true`. `realv`가 `Tween`면 (같은 + 현재 값에서) 새 트윈 시작 + 슬롯을 새 엔진 Tween 객체로 갱신. + - plain 값이 들어와 진행 중인 트윈을 끝내는 경우, 기존 override + 정책의 4가지 옵션(Cancel/Override/Delete-restart/Move-to-end-restart, + 아래 절)은 원래 Tween→Tween 전환을 염두에 둔 것이라 Tween→plain + 전환에는 사실상 전부 "멈추고 그 자리에서 즉시 덮어쓴다"로 수렴하는 + 것으로 보임 — 별도 5번째 옵션이 필요해 보이진 않으나 **확정은 아님, + M11 착수 시 재확인**. + +**GC-안전성은 기존과 동일** — `Relate`가 `inst`로 weak-keyed되어 있어 +`inst`가 죽으면 이 슬롯(엔진 Tween 객체 포함)도 별도 정리 로직 없이 같이 +GC됨. `retract`는 이 케이스에서 거의 안 불림 — 아래 절 참고. + +### 왜 `retract`가 더 이상 필요 없는가 — Dispatch 체인 관점의 결과적 단순화 + +기존 모델에선 "Tween 핸들러가 매치되어 애니메이션이 실행 중이었는데, +다음 값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로 +핸들러 *타입*이 바뀌는" 경우가 `base/bind-system-plan.md`가 서술하는 +"`retract`가 실제로 의미를 갖는 유일한 패턴"의 대표 예시였음. 새 모델에선 +**매치되는 Dispatch 핸들러가 항상 PropertyHandler 하나뿐**(Tween 여부는 +값 내부 분기일 뿐 핸들러 매치 자체엔 영향 없음) — 이 시나리오 자체가 +Dispatch 레벨에서 사라짐. 트윈 취소/전환은 위 3-상태 저장 로직으로 +PropertyHandler 내부에서 처리 — Tag가 이미 하고 있는 "diff는 `process` +자신이 담당" 패턴과 같은 모양이라 새 개념 아님. (PropertyHandler의 +`retract` 필드 자체는 여전히 정의해둬야 함 — "필드 생략 불가" 규칙은 +예외 없는 일반 규칙 — 다만 실제로 호출될 일이 이 경로에선 사실상 없음.) + +### 타입 대수: `T' = T | Tween` — Modifier/State/Source에 새 타입 기계 불필요 + +지금 프로퍼티류 필드가 열려 있는 자리(Modifier setter, Ref, Store/Source +필드)는 전부 `T | State` 모양 하나로 통일돼 있음. 여기서 "이 필드의 +`T`" 자체를 `T' = T | Tween`로 치환하면 자동으로 `T | Tween | +State>`가 나옴 — Modifier/State/Source/StoreBind 코드엔 +`Tween` 인지 로직을 전혀 안 넣어도 됨(StoreBind는 원래도 페이로드 타입에 +무관하게 `isState`만 보고 언랩하는 opaque한 구조였음). `Tween`를 실제로 +해석하는 코드는 여전히 PropertyHandler 하나에만 존재. + +**핸들러 계층 UB 체크와도 안 부딪힘** — `Tween`는 `Ref`/`Observer`/ +`Slot`류처럼 `process`/`retract`를 가진 dispatch 참가자가 아니라 `None`/ +`Tag`처럼 순수 raw 데이터 값(별도 `TweenTag` Brand)이라, Modifier 필드/ +`State`가 막는 "핸들러 계층 값" 규칙(`base/modifier-plan.md`)에 +안 걸림 — 그 문서가 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch +참가자" 그룹으로 분류해뒀던 건 부정확했던 것으로 이번에 정정(아래 +"패키지 경계" 절 참고). + +## `useTween`(트윈 우회) — 해소됨, 새 옵션 필드 불필요 + +이전엔 `Tween{useTween=state}`처럼 `Tween` 생성자 안에 별도 +옵션 필드를 두는 방향으로 열려 있었으나, 값-레벨 래퍼 모델에선 **이미 +있는 `state:Apply(factory)`/`:Compute`만으로 공짜로 풀림** — 새 필드 +불필요: + +```lua +-- reduceMotion: State +Position = mySource:Apply(Animate(reduceMotion, {ease = ...})) +``` + +`Animate(reduceMotion, opts)`는 커링 팩토리로, 개념상 다음과 같은 모양: + +```lua +return function(state) + return state:Compute(function(v) + if reduceMotion:Get() then + return v + else + return Tween{Value = v, ease = opts.ease} + end + end) +end +``` + +`reduceMotion`이 바뀌면 `:Compute`가 재계산되어 StoreBind가 자연히 새 +`realv`(plain 또는 Tween-wrapped)로 재-dispatch — PropertyHandler는 평소처럼 +그 값만 보고 처리하면 됨, 우회 로직을 따로 알 필요 없음. **`Animate`는 +base 프리미티브가 아니라 quad-roblox가 제공하는 자유 함수 조합기**(아래 +"패키지 경계" 절) — `Modifier:Apply(Boldify(10))` 커링 패턴과 완전히 +같은 모양이라 base에 새로 추가할 게 없음. + +## 초기 진입 애니메이션(`initValue`) — 여전히 별개 문제, 위 hasBeenSet과 상충 방향 주의 + +`initValue`는 여전히 미확정(2026-08-09 세션 결론 유지: "필요성 낮은 +쪽으로 기움", 완전 폐기는 아님). 다만 이번 세션에서 **"3-상태 저장"의 +1번 분기(`hasBeenSet`)가 "첫 세팅은 무조건 애니메이션 없이 스냅"을 +기본 동작으로 확정**했으므로, 나중에 `initValue`(다이얼로그가 아래에서 +위로 슬라이드-인하는 것처럼 첫 마운트에도 애니메이션을 원하는 경우)가 +실제로 필요해지면 **이 억제 동작을 어떻게 명시적으로 우회할지**(예: +릴레이션 슬롯에 `nil` 대신 다른 초기 상태를 미리 심어두는 옵션)까지 +같이 설계해야 함 — 지금은 새 결정 없이 이 긴장 관계만 기록해둠. + +## `Animate` 콤비네이터 — quad-roblox 유틸(base 아님) + +`Animate(condOrOpts, opts?)`류 팩토리를 quad-roblox가 제공, `:Apply`로 +체이닝해서 쓰는 용도. 상세 시그니처는 미확정(예: `Animate({ease=..., +useAnimate=state})`처럼 조건과 옵션을 하나의 테이블로 합치는 +안도 검토 가치 있음 — 확정 아님, M11에서 정리). 핵심은 **base +프리미티브가 아니라는 것** — `Tween` 값 타입/`isTween`만 +base(`quad-base/Tween.luau`)에 있고, `Animate`는 이미 있는 `:Apply`/ +`:Compute`/`Tween{...}`를 조합한 quad-roblox 레벨 편의 함수라 나중에 +이름/모양을 자유롭게 바꿔도 base 계약에 영향이 없음 — 저비용 +고효율(사용자 표현) 엔지니어링으로 판단. + +## 패키지 경계 — `Tag`가 이미 밟은 것과 같은 분리 (2026-08-10 세션 확정) + +- **quad-base**: `Tween.luau` — 값 타입(`Tween(opts)` 팩토리, `isTween` + predicate/`TweenTag` Brand)만. 엔진 무관. +- **quad-roblox**: `Handlers/Property.luau`(기존 프로퍼티 세팅 로직에 + `isTween` 분기 + 3-상태 릴레이션 저장 + override 정책 추가) + + `Animate.luau`(편의 콤비네이터, 신규). +- **기존 `Handlers/Tween.luau`(독립 Dispatch 핸들러 파일) 자체는 더 + 이상 필요 없음** — `base/architecture.md` 소스트리 갱신 완료. + +## `retract`(구 cleanup)로 확정된 오버라이드 시맨틱 — Tween↔Tween 전환에서는 그대로 유지 + +**이 절의 4가지 옵션은 안 바뀜 — 다만 "Dispatch의 `retract` 호출"이 아니라 +"PropertyHandler 내부 로직이 참고하는 정책"으로 위치만 이동했다는 점에 +유의.** 이전 트윈을 취소하고 새 트윈을 만드는 게 맞지만, "취소" 시점의 +동작이 여러 갈래로 갈릴 수 있음: 1. 키 밸류를 받으면, 그로 인해 생성된 트윈을 얻어서 **멈춰버리기**. 2. 트윈 뒤에 **삭제하지 않고 오버라이드**(새 트윈이 이전 트윈의 현재 값에서 @@ -66,43 +237,11 @@ Ref나 네임스페이스드 조회가 필요하지 않음. Tween의 store-bind 동작과 일치. 이 기본값 외 나머지 세 동작(오버라이드/삭제 후 재시작/끝점 이동 후 재시작)은 -라이브러리가 강제하지 않고, `[Tween(key, tweenData, {onOverride=...})]`처럼 -키 설정으로 사용자가 고를 수 있게 열어둠 — `retract(inst, k, v)`가 이전 -값(v)을 받으므로 여기서 선택된 동작을 구현. `retract`가 접근해야 할 "이전에 -생성한 실제 Tween 객체"는 `base/relate-plan.md`가 정의하는 `Relate`(`inst`를 -weak 키로 하는 범용 릴레이션 프리미티브, 옛 가칭 `base.perInstanceState`를 -대체)에 담아두면 됨. **GC 확인(2026-08-07 여섯 번째 세션, 사용자 제안 -검증)**: 이 저장소는 -`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이 중첩된 -구조라, `inst`가 죽으면 그 안에 담긴 Tween 인스턴스 릴레이션도 별도 -정리 로직 없이 같이 GC됨 — `base/bind-system-plan.md`의 "핸들러 내부 -상태 저장" 절 "왜 GC-안전한가" 참고. +라이브러리가 강제하지 않고, `Tween{Value=..., ease=..., onOverride=...}`처럼 +`Tween` 생성 시 넘긴 옵션으로 사용자가 고를 수 있게 열어둠 — PropertyHandler가 +위 3-상태 저장의 3번 분기에서 이 옵션을 참고해 구현. -## `retract`는 완전 소멸(Destroy) 시엔 호출되지 않는다 - -`base/lifecycle-pattern.md`의 핵심 원칙: quad는 자신이 만든 Instance를 생명주기 -끝까지 그대로 들고 있는 소유자라, Destroy 이후에 실행해야 할 정리 로직이 없다 -— 오히려 Destroy된 대상에 `:Cancel()`/`:Stop()` 같은 메서드를 호출하면 에러남 -(대상이 죽으면 그 대상에 묶인 Tween도 함께 죽은 상태가 되므로). 따라서 -**`retract`는 "같은 key에 새 값이 들어와 이전 트윈을 갈아치울 때"만 호출되고, -Destroy 시점엔 아무 것도 안 함(라이프타임 `Connected` 체크로 처리 자체를 -멈추는 것만으로 충분).** - -**메모 — `retract`와 `canExecute`는 서로 다른 문제를 다룬다, 나중에 quadnomicon -급에서 제대로 설명 필요.** "그럼 값 교체가 아니라 값을 계속 관측하는 쪽 -(예: `state:Observer(fn)`으로 Tween을 건 경우)은 Destroy 시 어떻게 -정리되는가?"라는 질문이 자연스럽게 따라오는데, 이건 `retract`의 영역이 -아니라 `canExecute`(라이프타임 predicate, `base/lifecycle-pattern.md`의 -"생명 바인드 유틸" 절)의 영역이다 — Destroy되면 `retract` 호출 없이 그냥 -`canExecute`가 false가 되어 이후 처리 시도 자체가 조용히 no-op된다. -store-bind 일반(Tween 포함)도 같은 결이라 실제로는 이미 일관되게 명시돼 -있지만(`base/bind-system-plan.md` "확정된 디스패치 모델" 절), "왜 이 -경로엔 retract를 쓰고 저 경로엔 canExecute를 쓰는가"라는 내부 구조상의 -이유는 quadnomicon 콘텐츠로 풀어서 설명할 필요가 있음(`research/ -documentation-content-map.md` 심화 콘텐츠 후보에 메모) — 지금은 이 메모만 -남겨두고 상세 설명은 나중 문서화 단계로 미룸. - -## 트윈 옵션 값 모양 — TweenInfo 그대로 vs 편의 필드 (신규, 열린 논의) +## 트윈 옵션 값 모양 — TweenInfo 그대로 vs 편의 필드 (여전히 열린 논의) **아직 논의 시작 단계 — 나중에 더 다룰 주제로만 남겨둠.** Roblox의 `TweenInfo.new(time, easingStyle, easingDirection, repeatCount, reverses, @@ -114,66 +253,37 @@ delayTime)`는 순수 포지셔널 생성자인데, Luau엔 named call 문법이 재사용하고 싶은 경우엔 상관없지만, 대부분의 흔한 케이스(길이/이징만 바꾸고 싶음)에서 매번 포지셔널 생성자를 마주해야 함. 2. **편의 필드로 개별 인자를 받고 기본값을 제공** — 예: - `{Time=0.3, Style=Enum.EasingStyle.Quad, Reverses=false, ...}`처럼 + `Tween{Value=..., Time=0.3, Style=Enum.EasingStyle.Quad, Reverses=false, ...}`처럼 이름 붙은 키로 받고 흔한 기본값(예: `Time=0.2`, `Style=Quad`, `Direction=Out`)을 채워줌. 명시적으로 `TweenInfo`가 이미 있어서 재사용하고 싶다는 케이스에도 열어두면(예: `Info = someTweenInfo` 필드로), 둘 다 지원 가능. **현재 소견(확정 아님)**: 2번(편의 필드 + 기본값)이 흔한 사용 경험상 더 -낫다는 쪽으로 기움 — 대부분의 호출에서 named call이 없는 `TweenInfo.new`의 -가독성 문제를 피할 수 있고, 기본값 덕에 짧은 호출도 가능해짐. 다만 -구체적인 필드 이름/기본값/`TweenInfo` 재사용 경로의 정확한 문법은 아직 -확정 아님 — 나중 논의 대상으로 남김. +낫다는 쪽으로 기움 — 이번 세션의 모든 예시(`Tween{Value=..., ease=...}`)도 +자연스럽게 이 방향을 가정하고 있음. 다만 구체적인 필드 이름/기본값/ +`TweenInfo` 재사용 경로의 정확한 문법은 아직 확정 아님 — 나중 논의 대상으로 +남김. -## 초기 진입 애니메이션(`initValue`)과 트윈 우회(`useTween`) — 둘 다 미확정 (2026-08-09 세션) +## 네임스페이스드 객체 (더 이상 유효한 관심사 아님) -**아직 논의 시작 단계 — 나중에 더 다룰 주제로만 남겨둠.** 사용자가 두 -실사용 시나리오에서 출발한 제안: - -1. **`Tween{initValue=...}`(검토 후 필요성 낮은 쪽으로 기움)** — 동기: - 다이얼로그가 아래에서 위로 올라오며 열리는 것처럼, 처음 마운트될 때 - 시작 위치에서 목표값으로 자연스럽게 움직이는 "enter" 애니메이션을 - 내고 싶은데, 지금 구조로는 첫 마운트 시 이미 목표값이 프로퍼티에 - 세팅된 채로 시작해 트윈이 걸리지 않음. `initValue`로 먼저 그 값을 - 세팅해두고 그 다음 실제 목표값으로 트윈을 트리거하면 될 것 같다는 - 아이디어. **사용자가 스스로 재검토하며 "필요 없는 것 같다"는 쪽으로 - 기움** — 정확한 반례(재process/재마운트 시 `initValue`를 매번 다시 - 적용하면 안 되는데 "최초 1회"를 무엇으로 판별할지가 또 다른 설계 - 문제가 될 수 있음)는 다음 세션에서 확인 필요, 완전 폐기는 아직 아님. -2. **`Tween{useTween = state}`(필요성은 확인, 모양은 미정)** — - 동기: `Visible`로 여닫는 다이얼로그가 항상 아래에서 위로 슬라이드 - 올라와야 하는 게 기본이지만, 특정 상황(접근성 설정으로 애니메이션을 - 완전히 끄고 싶은 경우 등)엔 트윈을 건너뛰고 즉시 목표값으로 스냅해야 - 함 — 트윈을 무시하고 값을 그냥 세팅할 방법이 필요하다는 문제의식. - 제안 동작: 트윈이 실행되려는 시점에 먼저 `useTween:Get()`을 읽어서 - `false`면 진행 중이던 트윈을 전부 죽이고 목표값으로 즉시 `Set`, - `true`(또는 생략 시 기본값)면 평소대로 트윈. - -**결론(미확정)**: `initValue`는 필요 없어 보이지만(사용자 소견) 완전 -폐기는 아직 안 함. `useTween`류 트윈 우회 필요성은 실사용 근거가 -뚜렷해 유지될 가능성이 높지만, **정확한 옵션 키 모양과 문서화 방식이 -아직 전혀 안 정해짐**(사용자 강조: "어떤 모양을 주냐가, 문서화 어떤 -식으로 하냐가 엄청 중요한듯") — 위 "트윈 옵션 값 모양" 절의 편의 필드 -논의와 함께 M11 착수 전 나중 세션에서 마저 정리. - -## 네임스페이스드 객체 (성능상 이유로 보류) - -트윈 대상을 이름으로 찾는 별도 네임스페이스는 성능상 별로라고 판단 — -CollectionService를 쓰는 게 나아 보이지만, 트윈 전용 네임스페이스가 따로 있을 -필요가 있는지는 미정(단, 위 정정으로 이 절 자체의 필요성이 낮아짐 — 핸들러가 -이미 대상을 직접 받으므로 "나중에 이름으로 찾아서 트윈"할 필요 자체가 잘 -없을 수 있음). +기존 모델(핸들러가 대상을 이름으로 찾아야 하는 가능성)을 염두에 두고 +열어뒀던 절 — 새 모델에서는 PropertyHandler가 `inst`를 항상 직접 +받으므로(다른 모든 핸들러와 동일) 이 문제 자체가 성립하지 않음. 절 자체는 +과거 기록으로만 남김, 실행할 내용 없음. ## 열린 질문 (`.claude/question.md`에도 취합) - 기본값(Cancel)은 확정됨. 남은 건 나머지 세 동작(오버라이드/삭제 후 재시작/ - 끝점 이동 후 재시작)을 선택하는 옵션 키의 정확한 이름/시그니처 — 구현 - 단계에서 확정. -- 트윈 옵션 값 모양(위 "트윈 옵션 값 모양" 절, 신규) — `TweenInfo` 그대로 - 받을지 편의 필드+기본값으로 받을지, 소견은 후자 쪽이지만 확정 아님. -- 자연 완료(Completed) 시 per-instance 북키핑 정리 여부 — - `research/pre-implementation-audit.md` 2-10번 참고, M11 착수 시 확정. -- `initValue`(진입 애니메이션)/`useTween`(트윈 우회) — 위 절 참고, - 후자는 필요성은 확인됐으나 모양/문서화 미정, 전자는 필요성 자체가 - 낮은 쪽으로 기움. + 끝점 이동 후 재시작)을 선택하는 옵션 키의 정확한 이름/시그니처, 그리고 + Tween→plain 전환에 5번째 옵션이 필요한지 — 구현 단계에서 확정. +- 트윈 옵션 값 모양(위 절) — `TweenInfo` 그대로 받을지 편의 필드+기본값으로 + 받을지, 소견은 후자 쪽이지만 확정 아님. +- `Animate` 콤비네이터의 정확한 시그니처(조건/옵션 분리 vs 통합) — M11에서 + 정리. +- 자연 완료(Completed) 시 per-instance 북키핑 정리 여부(3-상태 슬롯을 + `true`로 되돌리는 시점) — `research/pre-implementation-audit.md` 2-10번 + 참고, M11 착수 시 확정. +- `initValue`(진입 애니메이션) — 위 절 참고, 필요성 자체가 낮은 쪽으로 + 기움, 완전 폐기는 아님. 필요해지면 hasBeenSet 억제 동작과의 상충을 + 같이 풀어야 함. diff --git a/CLAUDE.md b/CLAUDE.md index d91e129..06d55ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2542,3 +2542,117 @@ Instance에 직접 `.Parent =` 대입으로 자식을 넣거나 빼면 `lengthLi **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인 우선) — 이번 세션은 순수 문서 갭 보강이라 우선순위엔 영향 없음. + +## 2026-08-10 두 번째 세션 — Tween 구조 전면 재설계: 독립 Dispatch 핸들러 → +값-레벨 `Tween` 래퍼, `pre-implementation-audit.md` 1-1 완전 해소 + +사용자가 "트윈도 타입 문제가 있다 — 키 타입을 어떻게 하냐, Property +setter가 더 분발해서 `V`가 `isTween`이면 트윈 넣는 게 낫지 않냐"고 +제기하며 시작된 긴 단일 스레드. 기존 확정 모델(`[Tween(key, +tweenData...)] = storeValue`, `v`가 Store인 아무 `k`나 잡는 우선순위 +최상위 Dispatch 핸들러, 2026-08-04부터 확정)이 실은 +`research/pre-implementation-audit.md` 우선순위1-1이 이미 지적해뒀던 +구조적 모호함("애니메이션 없는 일반 반응형 프로퍼티 바인딩도 결국 +이름이 Tween인 파일을 거쳐가는가")을 안고 있었다는 걸 사용자 제안이 +정확히 겨냥한 것으로 드러나, 세션 내내 살을 붙여 완전히 재설계까지 +감. 구 모델은 `archive/tween-special-bind-key-reversed.md`로 이전, +`research/tween-plan.md`는 전면 재작성됨 — 상세 근거는 그 두 문서가 +최종 소스, 여기는 결정 흐름만 요약. + +**핵심 재설계**: State/Source 언랩(범용 `Dispatch/StoreBind.luau`, `k`/`v` +타입 무관)과 "이 값이 트윈 대상인가" 판단을 완전히 분리 — 후자는 별도 +Dispatch 핸들러/우선순위 경쟁이 아니라, **PropertyHandler가 `realv`를 +다 풀어낸 뒤 직접 하는 값-레벨 분기**(`isTween(realv)`)로 옮김. `Tween(opts: +{Value: T, ease...}) -> Tween`는 `Store({...})`와 같은 `Type(args)` +테이블 팩토리. 이 전환 하나로 우선순위1-1이 구조적으로 성립 불가능해짐 +— 범용 반응형 바인딩과 Tween이 애초에 같은 핸들러를 놓고 경쟁할 지점 +자체가 없어짐. + +**세션 중 순서대로 다듬어진 세부 결정들**(전부 최종적으로 `research/ +tween-plan.md`에 반영): + +1. **`Tween.Value`는 plain `T`만, 자체 반응 경로 없음** — 처음엔 `Value`도 + `T|State`를 받아 내부에 별도 Observer를 걸어야 하나 검토했으나, + 바깥 `:Compute`가 소스 변경마다 `Tween{Value=v,...}`를 통째로 재생성해 + StoreBind 재귀를 타므로 불필요함을 확인 — "같은 일 하는 두 번째 경로를 + 안 만든다" 원칙 재적용, `Tween = {Value: T, ease...}`로 확정. +2. **3-상태 릴레이션 슬롯으로 `hasBeenSet`과 활성 엔진 트윈 저장을 통합** — + `relate:GetStrong(inst,k)`가 `RobloxTween | true | nil` 중 하나: + `nil`=이 키 첫 세팅(애니메이션 없이 즉시 스냅, 기본값→목표값으로 + 날아오는 진입 애니메이션 버그 방지), `true`=세팅된 적 있음/활성 트윈 + 없음(정상 애니메이션 시작 가능), 엔진 객체=활성 트윈 있음(override + 정책대로 정리 먼저). 사용자가 직접 "hasBeenSet은 어차피 트윈에만 + 쓰이니 트윈 저장 슬롯 하나로 합치자"고 제안해 확정. +3. **활성 트윈이 있는데 plain 값이 들어오는 경우의 순서 규칙 신설** — + 먼저 override 정책대로 이전 트윈을 정리(멈추거나 끝냄)하고, **그 + 정리가 끝난 뒤에만** 새 값을 세팅. 순서가 뒤바뀌면 이전 트윈의 다음 + 인터폴레이션 프레임이 방금 세팅한 값을 덮어쓸 위험이 있어서 — 사용자가 + 직접 짚은 시퀀싱 버그. +4. **타입 대수: `T' = T | Tween` 치환만으로 해결, 새 타입 기계 불필요** — + 지금 프로퍼티류 필드가 전부 `T | State` 모양으로 통일돼 있는데, + 여기서 "이 필드의 `T`" 자체를 `T' = T | Tween`로 치환하면 자동으로 + `T | Tween | State>`가 나옴 — Modifier/State/Source/ + StoreBind 코드엔 `Tween` 인지 로직이 전혀 안 들어감(StoreBind는 원래도 + 페이로드 타입에 무관하게 `isState`만 봄), 타입 생성 스크립트가 필드 + 타입 문자열만 바꾸면 끝. 사용자가 직접 대수적으로 도출. +5. **`useTween` 우회 — 새 옵션 필드 없이 해소.** 이전엔 + `Tween{useTween=state}`처럼 별도 필드가 필요하다고 열어뒀으나, + 2026-08-07 일곱 번째 세션에 확정된 `state:Apply(factory)` sugar 위에 + `someState:Apply(Animate(reduceMotion, opts))`처럼 조건부로 `Tween{...}`를 + 씌우거나 안 씌우는 `:Compute` 팩토리 하나로 공짜로 풀림 — 새 base + 메커니즘 불필요. +6. **`Animate` 콤비네이터는 quad-roblox 유틸, base 프리미티브 아님** — + `Tween` 값 타입/`isTween`만 base(`quad-base/Tween.luau`)에 있고, + `Animate`는 이미 있는 `:Apply`/`:Compute`/`Tween{...}`를 조합한 편의 + 함수라 나중에 이름/모양을 자유롭게 바꿔도 base 계약에 영향 없음 — + 사용자 표현으로 "저비용 고효율 엔지니어링". +7. **패키지 경계는 Tag가 이미 밟은 분리를 그대로 재사용** — quad-base: + `Tween.luau`(값 타입만). quad-roblox: `Handlers/Property.luau`(isTween + 분기+3-상태 저장+override 정책 흡수, 기존 독립 `Handlers/Tween.luau` + 폐기) + `Animate.luau`(신규). +8. **부수 발견 — `retract`가 Tween 경로에서 사실상 필요 없어짐.** 기존 + 모델에서 "Tween↔프로퍼티 핸들러 타입 교체"가 `retract`가 실제로 + 의미를 갖는 유일한 대표 예시였는데, 새 모델에선 매치되는 Dispatch + 핸들러가 항상 PropertyHandler 하나뿐이라 이 케이스 자체가 사라짐 — + 트윈 취소/전환은 PropertyHandler 내부의 3-상태 슬롯 로직으로 대체(Tag가 + 이미 하는 "diff는 process 자신이 담당" 패턴과 같은 모양). `retract` + 필드 자체는 "생략 불가" 일반 규칙이라 여전히 정의는 해두되, 실제 + 호출은 거의 없어짐. Tag(핸들러 타입이 실제로 바뀌게 재설계되어 + `retract`가 필요해진 사례)와 Tween(핸들러 타입이 안 바뀌게 재설계되어 + `retract` 필요성이 사라진 사례)을 서로 반대 방향 사례로 archive 문서에 + 대비해둠 — quadnomicon 소재. +9. **`Tween`의 핸들러 계층 분류 정정** — `base/modifier-plan.md`가 + 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch 참가자"(State/Source에 + 담겨도 재귀 재-dispatch가 그대로 처리해주는 부류)로 묶어뒀는데, 이제 + `Tween`는 `process`/`retract`가 없는 순수 raw 데이터 값이라 `None`과 + 같은 분류로 정정 — Modifier 필드/`State`가 막는 "핸들러 + 계층 값 → error" 규칙에 안 걸린다는 결론은 안 바뀜(그냥 raw 값이라서로 + 근거가 바뀜). +10. **`initValue`(진입 애니메이션)와 hasBeenSet의 긴장 관계를 기록만 + 해둠** — hasBeenSet이 "첫 세팅은 무조건 스냅"을 기본 동작으로 + 확정했으므로, 나중에 `initValue`(다이얼로그 슬라이드-인 등)가 실제로 + 필요해지면 이 억제 동작을 명시적으로 우회하는 방법까지 같이 설계해야 + 함 — 새 결정 없이 상충 관계만 `research/tween-plan.md`에 남김. + +**여전히 열려있는 것**(M11 착수 시 확정): override 정책 4가지 중 기본값 +Cancel 외 세 옵션의 정확한 키 이름/시그니처, Tween→plain 전환에 5번째 +옵션이 필요한지, 트윈 옵션 값 모양(`TweenInfo` 그대로 vs 편의 필드 — 소견은 +후자), `Animate`의 정확한 시그니처(조건/옵션 분리 vs 통합). + +**반영된 파일**: `research/tween-plan.md`(전면 재작성, 최종 소스), +`archive/tween-special-bind-key-reversed.md`(신규, 구 모델 원문+역전 +사유), `base/bind-system-plan.md`(9곳 — "확정된 디스패치 모델"의 대표 +예시를 Tween에서 StoreBind로, `retract` 필요 패턴 예시를 Tag로 교체, +"Dispatch는 프리미티브가 아니다"/"Dispatch 체인" 절의 핸들러 목록에서 +Tween 제거, `None` 센티널 절 예시 갱신, Ref/Brand 절 문구 정정), +`base/architecture.md`(소스트리 — `quad-base/Tween.luau` 신설, +`quad-roblox/Handlers/Tween.luau` 삭제하고 `Handlers/Property.luau` +설명에 흡수, `Animate.luau` 신설), `base/modifier-plan.md`(핸들러 계층 +분류에서 Tween 제외 + 신규 "10. `Tween`와의 타입 합성" 절), +`research/pre-implementation-audit.md`(우선순위1-1 해소 표시), +`ROADMAP.md`(M11 전면 재작성, M2/M7 체크박스 갱신), `.claude/question.md`/ +`.claude/README.md`(참조 동기화). + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인 +우선) — 이번 세션도 순수 설계 확정/문서 정리라 M0 착수 우선순위 자체는 +그대로. M11 착수 시점이 오면 위 "여전히 열려있는 것" 목록부터 확인. diff --git a/ROADMAP.md b/ROADMAP.md index db42293..18b693e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -73,7 +73,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 네이밍 확정). "이 키를 지금 누가 담당 중인가" bookkeeping은 `Dispatch.drive`가 아니라 `Dispatch.process` 호출 자체 내부에서 갱신할 것(재귀 재-process 시에도 자연히 갱신되게 — 안 그러면 재귀 - 재디스패치를 쓰는 케이스(Tween store-bind, `NoneHandler`)에서 매 + 재디스패치를 쓰는 케이스(`StoreBind`, `NoneHandler`)에서 매 사이클 불필요한 `retract`가 반복 호출될 위험) - [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/ `process`/`retract` — `isHandlable`도 `inst`를 받도록 확정, 2026-08-07 @@ -137,7 +137,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절) - [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={handler,handler,...} (strong 순서 배열)}}`) + `Dispatch.retractUnder(inst,k,keep,v)` — - 재귀 재-dispatch(StoreBind/Tween/NoneHandler)의 retract를 다단 + 재귀 재-dispatch(StoreBind/NoneHandler)의 retract를 다단 체인까지 정확히 전파(2026-08-08 세 번째 세션, `base/ bind-system-plan.md` "Dispatch 체인" 절 — `pre-implementation-audit.md` 1-2번 "이전 핸들러 추적" 항목 해소). `Dispatch.process`가 매치될 @@ -282,8 +282,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 (이름 확정, `modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) + 이를 `nil`로 재디스패치하는 base 내장 `NoneHandler` (`bind-system-plan.md`의 `None` 센티널 절, M2 dispatch 엔진의 - "이전 매치 핸들러 추적" 항목과 함께 구현 — Tween store-bind 핸들러와 + "이전 매치 핸들러 추적" 항목과 함께 구현 — `StoreBind` 핸들러와 동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — 확정 완료 +- [ ] 프로퍼티류 필드 타입에 `T' = T | Tween` 치환 반영(타입 생성 + 스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween`로 만들면 + 끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md` + 10번, 2026-08-10 세션, `research/tween-plan.md`) ## M8 — Ref @@ -344,9 +348,28 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M11 — Tween -- [ ] `research/tween-plan.md` 남은 옵션 이름 확정(구조는 이미 확정) -- [ ] `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러, 기본 오버라이드 - Cancel) +**[2026-08-10 세션, 구조 재설계]** 독립 Dispatch 핸들러 모델에서 값-레벨 +`Tween` 래퍼 모델로 전환 — 상세는 `research/tween-plan.md`(전면 +재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`. + +- [ ] `quad-base/Tween.luau`(값 타입만 — `Tween(opts)` 팩토리, `isTween`/ + `TweenTag` Brand, `Value: T` plain만 받고 State 재귀 없음) +- [ ] `Handlers/Property.luau`에 `isTween(realv)` 분기 추가(기존 + `Handlers/Tween.luau` 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯 + (`RobloxTween | true | nil` — `nil`=첫 세팅, `true`=세팅됨/트윈 + 없음, 엔진 객체=활성 트윈) + 첫 세팅은 무조건 애니메이션 없이 + 스냅(hasBeenSet 억제) + 활성 트윈 정리는 override 정책 완료 후에만 + 새 값 세팅(순서 뒤바뀌면 트윈 다음 프레임이 방금 세팅한 값을 덮어씀) +- [ ] override 정책 4가지(기본 Cancel/Override/Delete-restart/ + Move-to-end-restart) 중 기본값 외 옵션 키 이름/시그니처 확정, + Tween→plain 전환에 5번째 옵션이 필요한지 확인 +- [ ] `research/tween-plan.md` "트윈 옵션 값 모양" 확정(TweenInfo 그대로 + vs 편의 필드+기본값 — 소견은 후자) +- [ ] `quad-roblox/Animate.luau`(편의 콤비네이터 — `:Apply`로 체이닝, + `useTween` 우회는 이걸로 자연히 커버되어 별도 옵션 필드 불필요, + 정확한 시그니처는 M11에서 확정) +- [ ] `initValue`(진입 애니메이션) 필요성 재검토 — 필요해지면 hasBeenSet + 억제 동작과의 상충부터 풀 것(`research/tween-plan.md` 참고) ## 특정 마일스톤에 안 묶이고 병행 가능