kasaframe 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/NOTICE +13 -0
  2. package/README.md +2 -2
  3. package/docs/AGENTS.md +1 -0
  4. package/package.json +1 -1
  5. package/packages/adapters/src/project.ts +2 -0
  6. package/packages/check/package.json +1 -0
  7. package/packages/check/src/check.ts +8 -2
  8. package/packages/check/src/rules/frames.ts +4 -5
  9. package/packages/cli/src/commands/direct.ts +24 -4
  10. package/packages/cli/src/commands/make.ts +15 -5
  11. package/packages/cli/src/commands/render.ts +34 -7
  12. package/packages/cli/src/mix.ts +66 -4
  13. package/packages/cli/src/segments.ts +1 -1
  14. package/packages/cli/src/sfx.ts +429 -0
  15. package/packages/core/src/frame.ts +7 -4
  16. package/packages/core/src/index.ts +3 -1
  17. package/packages/core/src/project.ts +19 -0
  18. package/packages/core/src/sfx.ts +70 -0
  19. package/packages/director/src/assemble.ts +15 -6
  20. package/packages/director/src/fallback.ts +14 -2
  21. package/packages/director/src/index.ts +1 -1
  22. package/packages/director/src/pattern.ts +2 -0
  23. package/packages/director/src/patterns/fill/cells.ts +5 -2
  24. package/packages/director/src/scene.ts +17 -5
  25. package/packages/director/src/transitions.ts +21 -8
  26. package/packages/motion/src/enter.ts +7 -1
  27. package/packages/motion/src/impact.ts +5 -2
  28. package/packages/motion/src/moves/dress.ts +15 -2
  29. package/packages/motion/src/moves/menu.ts +11 -5
  30. package/packages/motion/src/transition.ts +3 -1
  31. package/packages/motion/src/transition3d/index.ts +3 -1
  32. package/packages/motion/src/transition3d/live.ts +5 -3
  33. package/packages/patterns/src/bars/rank.ts +6 -7
  34. package/packages/patterns/src/before-after/shrink.ts +3 -4
  35. package/packages/patterns/src/before-after/strike.ts +4 -5
  36. package/packages/patterns/src/code/terminal.ts +25 -16
  37. package/packages/patterns/src/end/lockup.ts +13 -10
  38. package/packages/patterns/src/end/slam.ts +14 -8
  39. package/packages/patterns/src/hook-number/counter.ts +15 -9
  40. package/packages/patterns/src/hook-number/slam.ts +6 -8
  41. package/packages/patterns/src/kit.ts +364 -59
  42. package/packages/patterns/src/limit/gauge.ts +19 -13
  43. package/packages/patterns/src/quote/karaoke.ts +17 -12
  44. package/packages/patterns/src/sfx.ts +80 -0
  45. package/packages/patterns/src/steps/path.ts +15 -7
  46. package/packages/patterns/src/term-reveal/chips.ts +24 -10
  47. package/packages/patterns/src/term-reveal/expand.ts +17 -13
  48. package/packages/patterns/src/timeline/axis.ts +79 -25
  49. package/packages/patterns/src/title/mask.ts +15 -10
  50. package/packages/patterns/src/title/slam.ts +1 -1
  51. package/packages/patterns/src/types.ts +1 -0
  52. package/packages/plan/src/brief.ts +5 -2
  53. package/packages/themes/src/index.ts +22 -0
  54. package/packages/themes/src/slots.ts +32 -2
  55. package/packages/themes/src/types.ts +28 -0
  56. package/sfx/README.md +82 -0
  57. package/sfx/click/appear.wav +0 -0
  58. package/sfx/click/burst.wav +0 -0
  59. package/sfx/click/drop.wav +0 -0
  60. package/sfx/click/end.wav +0 -0
  61. package/sfx/click/hop.wav +0 -0
  62. package/sfx/click/land.wav +0 -0
  63. package/sfx/click/select.wav +0 -0
  64. package/sfx/click/stick.wav +0 -0
  65. package/sfx/click/tick.wav +0 -0
  66. package/sfx/click/type.wav +0 -0
  67. package/sfx/click/whoosh.wav +0 -0
  68. package/sfx/pop/appear.wav +0 -0
  69. package/sfx/pop/burst.wav +0 -0
  70. package/sfx/pop/drop.wav +0 -0
  71. package/sfx/pop/end.wav +0 -0
  72. package/sfx/pop/hop.wav +0 -0
  73. package/sfx/pop/land.wav +0 -0
  74. package/sfx/pop/select.wav +0 -0
  75. package/sfx/pop/stick.wav +0 -0
  76. package/sfx/pop/tick.wav +0 -0
  77. package/sfx/pop/type.wav +0 -0
  78. package/sfx/pop/whoosh.wav +0 -0
  79. package/sfx/tags.json +545 -0
  80. package/sfx/ui/appear.wav +0 -0
  81. package/sfx/ui/burst.wav +0 -0
  82. package/sfx/ui/drop.wav +0 -0
  83. package/sfx/ui/end.wav +0 -0
  84. package/sfx/ui/hop.wav +0 -0
  85. package/sfx/ui/land.wav +0 -0
  86. package/sfx/ui/select.wav +0 -0
  87. package/sfx/ui/stick.wav +0 -0
  88. package/sfx/ui/tick.wav +0 -0
  89. package/sfx/ui/type.wav +0 -0
  90. package/sfx/ui/whoosh.wav +0 -0
  91. package/themes/kawaii-sticker/effects.ts +15 -3
  92. package/themes/kawaii-sticker/theme.json +1 -0
  93. package/themes/og-measure/THEME.md +13 -2
  94. package/themes/og-measure/effects.ts +76 -59
  95. package/themes/og-measure/sample/scenes/end.ts +2 -2
  96. package/themes/og-measure/sample/scenes/gather.ts +2 -10
  97. package/themes/og-measure/sample/scenes/models.ts +2 -10
  98. package/themes/og-measure/sample/scenes/one.ts +2 -1
  99. package/themes/og-measure/sample/scenes/price.ts +4 -16
  100. package/themes/og-measure/slots.ts +29 -0
  101. package/themes/og-measure/still.png +0 -0
  102. package/themes/og-measure/style.json +3 -0
  103. package/themes/og-measure/theme.json +15 -1
  104. package/themes/og-measure/variants/snow-blue.json +3 -0
@@ -2,7 +2,9 @@ import { clamp, ease, progress, spring, type SceneCtx, type Style } from "@kasaf
2
2
  import { mark } from "@kasaframe/motion/moves";
3
3
  import { typeDuration, typeText } from "@kasaframe/motion";
4
4
  import { partial } from "@kasaframe/themes/slotkit";
5
- import type { Kit, ThemeSlots } from "./types.ts";
5
+ import type { MarkBox, MarkPaint } from "@kasaframe/themes/slots";
6
+ import { keyCue, rollCues, slamCue, stopCues, withSfx } from "./sfx.ts";
7
+ import type { Kit, SpokenWord, ThemeSlots } from "./types.ts";
6
8
 
7
9
  // 패턴들이 같이 쓰는 몫. 모듈 맨 위에서 DOM 을 만지지 않는다 — 감독이 Node 에서 meta·fits 를 읽는다.
8
10
 
@@ -24,6 +26,17 @@ export const timesSign = (unit: string): boolean => /^[×✕✖xX]$/.test(unit.t
24
26
  /** 강조색 글 — 테마의 accentText(바탕 위에서 읽히는 강조색). 면은 accent, 글은 이것 */
25
27
  export const ACCENT_TEXT = "var(--kf-accent-text, var(--kf-accent))";
26
28
 
29
+ /**
30
+ * 칠한 칸 — 진행·점유·지금처럼 「찼다」를 보이는 면(FILL)과 그 위 글(ON_FILL)·테두리(FILL_LINE·fillEdge). 테마가 칠 토큰(style.json colors
31
+ * fill·onFill·fillLine)을 주면 그 결(og-measure: 파랑/10 바탕·/45 테두리·accent 글 — 칠한 배지가 없는 세계), 없으면 강조색 꽉 찬 면과 onAccent 글
32
+ */
33
+ export const FILL = "var(--kf-fill, var(--kf-accent))";
34
+ export const ON_FILL = "var(--kf-on-fill, var(--kf-on-accent))";
35
+ /** 칠한 칸 둘레 선 색 — 토큰이 없으면 강조색(원래 강조색으로 칠하던 테두리) */
36
+ export const FILL_LINE = "var(--kf-fill-line, var(--kf-accent))";
37
+ /** 칠한 면 안쪽 테두리 — 토큰이 없는 테마엔 안 보인다(면만 칠하던 그대로) */
38
+ export const fillEdge = (px: number): string => `box-shadow:inset 0 0 0 ${px}px var(--kf-fill-line, transparent)`;
39
+
27
40
  /** 스타일·틀이 카메라를 끈 영상(cameraMove: off)이면 패턴은 카메라 대신 화면 안 강조로 */
28
41
  export const cameraOk = (ctx: SceneCtx): boolean => ctx.style.motion?.cameraMove !== "off";
29
42
 
@@ -58,12 +71,40 @@ export function inkLike(ctx: SceneCtx): boolean {
58
71
  return !Number.isFinite(a) || !Number.isFinite(b) || (hi + 0.05) / (lo + 0.05) < 3;
59
72
  }
60
73
 
74
+ // 글을 짚는 겹의 칠 — 강조색 글. 강조색 글이 본문 글과 같은 테마(HUD)면 반전 칸으로(같은 색으로 채우면 아무것도 안 바뀐다)
75
+ const readCss = (ctx: SceneCtx): string => (inkLike(ctx) ? "background:var(--kf-accent);color:var(--kf-on-accent)" : `color:${ACCENT_TEXT}`);
76
+
77
+ /**
78
+ * 다시 짚기 진행도 — 정지점 사이마다 0→1→0 으로 박마다 한 번 차올랐다 빠진다. 마지막 사이(장면 끝)는 차오르기만 해 끝 프레임에 다 짚혀 있다.
79
+ * 첫 정지점 전엔 0. 훑는 짚개를 쓰는 테마에서 짚을 대상이 하나뿐인 장면(짚개 없음)이 그 글을 박마다 강조로 채웠다 비워 쉬지 않는 몫
80
+ */
81
+ export function reread(t: number, stops: readonly number[]): number {
82
+ if (stops.length < 2 || t < stops[0]) return 0;
83
+ let j = 0;
84
+ while (j + 2 < stops.length && stops[j + 1] <= t) j++;
85
+ const p = clamp((t - stops[j]) / Math.max(1e-3, stops[j + 1] - stops[j]));
86
+ return j === stops.length - 2 ? p : 1 - Math.abs(2 * p - 1);
87
+ }
88
+
89
+ /**
90
+ * 글 상자 el(position:relative) 위에 같은 글의 강조 겹(readCss) — p 만큼 아래부터 글 전체에 차오른다(reread 로 다시 짚기).
91
+ * 왼쪽부터 짚으면 빈칸·가는 글자(1·i)를 지나는 동안 화면이 거의 안 바뀌어 멈춘 화면이 된다 — 차오름은 모든 글자를 함께 지난다
92
+ */
93
+ export function readOver(el: HTMLElement, text: string, ctx: SceneCtx): (p: number) => void {
94
+ const hi = node(el, `position:absolute;inset:0;${readCss(ctx)};white-space:inherit;text-align:inherit;visibility:hidden;pointer-events:none`, text);
95
+ return (p) => {
96
+ const k = clamp(p);
97
+ hi.style.visibility = k > 0 ? "visible" : "hidden";
98
+ hi.style.clipPath = `inset(${((1 - k) * 100).toFixed(2)}% 0 0 0)`;
99
+ };
100
+ }
101
+
61
102
  /**
62
- * 큰 이름 — 글자마다 가림막 아래에서 올라와 서고(riseAt 부터 each 간격), fill 진행도(0..1)만큼 강조가 왼쪽부터 글자를 짚는다.
63
- * 강조색 글이 본문 글과 같은 테마(HUD)면 반전 칸으로 — 같은 색으로 채우면 아무것도 안 바뀐다
103
+ * 큰 이름 — 글자마다 가림막 아래에서 올라와 서고(riseAt 부터 each 간격), fill 진행도(0..1)만큼 강조가 왼쪽부터 글자를 짚는다(readCss).
104
+ * up 을 주면 강조가 모든 글자에 아래부터 그만큼 차오른다(다시 짚기 — readOver 와 같은 결)
64
105
  */
65
- export function riseName(el: HTMLElement, text: string, ctx: SceneCtx): (t: number, o: { riseAt: number; each: number; fill: number }) => void {
66
- const hiCss = inkLike(ctx) ? "background:var(--kf-accent);color:var(--kf-on-accent)" : `color:${ACCENT_TEXT}`;
106
+ export function riseName(el: HTMLElement, text: string, ctx: SceneCtx): (t: number, o: { riseAt: number; each: number; fill: number; up?: number }) => void {
107
+ const hiCss = readCss(ctx);
67
108
  odoStyle(el);
68
109
  const glyphs = Array.from(text).map((ch) => {
69
110
  const clip = node(el, "display:inline-block;overflow:hidden;padding:0.06em 0 0.12em;margin:-0.06em 0 -0.12em");
@@ -87,9 +128,9 @@ export function riseName(el: HTMLElement, text: string, ctx: SceneCtx): (t: numb
87
128
  g.firstChild.remove();
88
129
  }
89
130
  if (!up) g.dataset.kfOdo = g.dataset.ch ?? "";
90
- const k = clamp(fp - i);
131
+ const k = clamp(o.up ?? fp - i);
91
132
  hi.style.visibility = k > 0 ? "visible" : "hidden";
92
- hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
133
+ hi.style.clipPath = o.up === undefined ? `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)` : `inset(${((1 - k) * 100).toFixed(2)}% 0 0 0)`;
93
134
  });
94
135
  };
95
136
  }
@@ -151,16 +192,68 @@ export function odometer(el: HTMLElement, o: { template: string; decimals: numbe
151
192
  };
152
193
  }
153
194
 
195
+ /** 테마 짚음 슬롯을 이 장면에 묶은 것 — 칸 요소를 받아 칠하고 자리 함수를 준다 */
196
+ export type Marker = (el: HTMLElement) => MarkPaint;
197
+
198
+ /** 테마가 짚는 칸 모양(mark 슬롯)을 채웠으면 그것, 아니면 null — 짚개·반전 짚음이 패턴 기본(강조색 꽉 찬 칸)으로 그린다 */
199
+ export const markOf = (kit: Kit, ctx: SceneCtx): Marker | null => {
200
+ const slot = kit.slots.mark;
201
+ return slot ? (el) => slot(el, { ctx, seed: `${kit.seed}:mark` }) : null;
202
+ };
203
+
204
+ // 짚는 칸 요소 — 대상 글 뒤에 깔린다(놓는 쪽 상자에 isolation). 칠은 테마 슬롯이나 plainMark 가 한다
205
+ const MARK_CSS = "position:absolute;left:0;top:0;width:0;height:0;z-index:-1;visibility:hidden;pointer-events:none";
206
+
207
+ // 테마가 비운 짚음 — 강조색 꽉 찬 칸이 크기 그대로 미끄러진다(직선 7 + smoothstep 3 — 훑는 동안 정지점에 닿아 서면 그 사이가 멈춘 화면)
208
+ function plainMark(el: HTMLElement): MarkPaint {
209
+ el.style.background = "var(--kf-accent)";
210
+ el.style.borderRadius = "var(--kf-radius-sm)";
211
+ return {
212
+ ink: "var(--kf-on-accent)",
213
+ at(from, to, u) {
214
+ const a = from ?? to;
215
+ const e = 0.7 * u + 0.3 * u * u * (3 - 2 * u);
216
+ const mix = (p: number, q: number) => p + (q - p) * e;
217
+ return { x: mix(a.x, to.x), y: mix(a.y, to.y), w: mix(a.w, to.w), h: mix(a.h, to.h) };
218
+ },
219
+ };
220
+ }
221
+
222
+ function place(el: HTMLElement, b: MarkBox): void {
223
+ el.style.transform = `translate(${b.x.toFixed(1)}px, ${b.y.toFixed(1)}px)`;
224
+ el.style.width = `${b.w.toFixed(1)}px`;
225
+ el.style.height = `${b.h.toFixed(1)}px`;
226
+ }
227
+
154
228
  /**
155
229
  * 반전 짚음 — el(글 상자, position:relative — 쓰기 슬롯이 글을 갈아 끼우는 요소면 그 부모에 건다) 위에 강조색 면 + onAccent 글 겹을 깔고 p 만큼 왼쪽부터 드러낸다.
156
- * 옅은 형광펜(accentSoft)은 밝은 바탕에서 화면이 바뀐 것으로 안 읽혀 이쪽이 쉬지 않는 몫을 진다
230
+ * 옅은 형광펜(accentSoft)은 밝은 바탕에서 화면이 바뀐 것으로 안 읽혀 이쪽이 쉬지 않는 몫을 진다.
231
+ * 테마 짚음(mark)이 있으면 그 칸이 글 뒤에 p 만큼 나타나고(모양은 테마 몫), 칸 위 글은 테마 ink 로
157
232
  */
158
- export function inverseOver(el: HTMLElement, text: string): (p: number) => void {
159
- const hi = node(el, "position:absolute;inset:0;background:var(--kf-accent);color:var(--kf-on-accent);white-space:inherit;text-align:inherit;visibility:hidden;pointer-events:none", text);
233
+ export function inverseOver(el: HTMLElement, text: string, mark: Marker | null = null): (p: number) => void {
234
+ if (!mark) {
235
+ const hi = node(el, "position:absolute;inset:0;background:var(--kf-accent);color:var(--kf-on-accent);white-space:inherit;text-align:inherit;visibility:hidden;pointer-events:none", text);
236
+ return (p) => {
237
+ const k = clamp(p);
238
+ hi.style.visibility = k > 0 ? "visible" : "hidden";
239
+ hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
240
+ };
241
+ }
242
+ el.style.isolation = "isolate";
243
+ const block = node(el, MARK_CSS);
244
+ const shape = mark(block);
245
+ const hi = shape.ink ? node(el, `position:absolute;inset:0;color:${shape.ink};white-space:inherit;text-align:inherit;visibility:hidden;pointer-events:none`, text) : null;
160
246
  return (p) => {
161
247
  const k = clamp(p);
248
+ const w = el.offsetWidth;
249
+ const h = el.offsetHeight;
250
+ const pad = (parseFloat(getComputedStyle(el).fontSize) || 20) * 0.12;
251
+ const b = shape.at(null, { x: -pad, y: -pad * 0.5, w: w + pad * 2, h: h + pad }, k);
252
+ block.style.visibility = k > 0 ? "visible" : "hidden";
253
+ place(block, b);
254
+ if (!hi) return;
162
255
  hi.style.visibility = k > 0 ? "visible" : "hidden";
163
- hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
256
+ hi.style.clipPath = `inset(${b.y.toFixed(1)}px ${(w - b.x - b.w).toFixed(1)}px ${(h - b.y - b.h).toFixed(1)}px ${b.x.toFixed(1)}px)`;
164
257
  };
165
258
  }
166
259
 
@@ -175,6 +268,7 @@ export function sceneDrop(ctx: SceneCtx): number | null {
175
268
  * 등장 전엔 opacity 0 이라 그 사이를 다른 움직임이 채워야 한다
176
269
  */
177
270
  export function slam(t: number, at: number, from = 1.9): Style {
271
+ slamCue(t, at);
178
272
  const lead = 0.16;
179
273
  if (t < at - lead) return { opacity: 0 };
180
274
  if (t < at) {
@@ -210,9 +304,9 @@ export function fitOnce(el: HTMLElement, maxW: number, maxPx: number, text?: str
210
304
  }
211
305
 
212
306
  /**
213
- * 짚개의 정지점 — from 뒤의 박을 간격 gap 넘게 골라 쓰고(빈 곳은 고르게 메운다), 마지막 정지점은 장면 끝(to)이다.
214
- * 끝 프레임에서 칸이 대상에 거의 앉아 있어야 표지·견본 스틸에 반쯤 걸친 띠가 안 찍힌다. 칸은 그 순간까지 움직이니(직선 7) 서지는 않는다.
215
- * 장면이 길어도 짚개 빠르기는 박이 정한다 — 장면 길이에 비례해 느려지는 차오름은 긴 장면에서 멈춘 화면이 된다
307
+ * 박 정지점 — from 뒤의 박을 간격 gap 넘게 골라 쓰고(빈 곳은 고르게 메운다), 마지막 정지점은 장면 끝(to)이다.
308
+ * 끝 프레임에서 옮겨 다니는 틀이 대상에 거의 앉아 있어야 표지·견본 스틸에 반쯤 걸친 틀이 안 찍힌다.
309
+ * 장면이 길어도 빠르기는 박이 정한다 — 장면 길이에 비례해 느려지는 차오름은 긴 장면에서 멈춘 화면이 된다
216
310
  */
217
311
  export function beatStops(hits: readonly number[], from: number, to: number, gap = 0.4): number[] {
218
312
  const out = [from];
@@ -225,18 +319,13 @@ export function beatStops(hits: readonly number[], from: number, to: number, gap
225
319
  return out;
226
320
  }
227
321
 
228
- /**
229
- * 짚개 — 강조색 칸이 대상 사이를 정지점마다 옮겨 다니며 「지금 볼 항」을 짚는다. 정지점 사이를 내내 움직인다(직선 7 + smoothstep 3 —
230
- * 닿아서 서면 그 사이가 멈춘 화면). 칸은 대상 글 뒤에 깔리고(host 에 isolation), 칸이 앉은 대상 글은 onAccent 로 바뀐다.
231
- * 대상 자리는 그 프레임에 잰다 — 대상이 굴러가거나 내려앉아도 따라간다
232
- */
233
322
  // 글자를 두 색으로 — over 사각형(글 상자 안 좌표) 안은 띠 위 글색, 밖은 그 글의 원래 색. 글자 모양으로 오려 칠하니 반쯤 겹쳐도 겹친 글자 부분만 바뀐다.
234
323
  // 글색(color)은 건드리지 않아 원래 색을 늘 다시 읽을 수 있다. 칠하기 전 인라인 값을 그대로 되돌린다 — 테마 슬롯이 글에 칠한 배경을 지우면
235
324
  // 한 번 짚고 지나간 뒤로 그림이 바뀌어 같은 시각이 다르게 그려졌다(frame.nondeterministic)
236
325
  const TONE_PROPS = ["-webkit-text-fill-color", "background-image", "background-color", "background-repeat", "background-size", "background-position", "-webkit-background-clip", "background-clip"];
237
326
  const toneSaved = new WeakMap<HTMLElement, [string, string, string][]>();
238
327
 
239
- function paintTone(el: HTMLElement, over: { x: number; y: number; w: number; h: number } | null): void {
328
+ function paintTone(el: HTMLElement, over: MarkBox | null, ink = "var(--kf-on-accent)"): void {
240
329
  const s = el.style;
241
330
  if (!over) {
242
331
  const saved = toneSaved.get(el);
@@ -250,7 +339,7 @@ function paintTone(el: HTMLElement, over: { x: number; y: number; w: number; h:
250
339
  el.dataset.kfTone = "";
251
340
  s.setProperty("-webkit-text-fill-color", "transparent");
252
341
  s.backgroundColor = "currentColor";
253
- s.backgroundImage = "linear-gradient(var(--kf-on-accent), var(--kf-on-accent))";
342
+ s.backgroundImage = `linear-gradient(${ink}, ${ink})`;
254
343
  s.backgroundRepeat = "no-repeat";
255
344
  s.backgroundSize = `${over.w.toFixed(1)}px ${over.h.toFixed(1)}px`;
256
345
  s.backgroundPosition = `${over.x.toFixed(1)}px ${over.y.toFixed(1)}px`;
@@ -258,17 +347,22 @@ function paintTone(el: HTMLElement, over: { x: number; y: number; w: number; h:
258
347
  s.setProperty("background-clip", "text");
259
348
  }
260
349
 
261
- // 대상 안에 바탕이 칠해진 요소가 있나 — 투명·알파 0 이면 없는 것
350
+ // 대상 안에 글 뒤를 가리는 판이 있나 — 바탕이 칠해졌고(투명·알파 0 이면 없는 것) 대상 상자의 절반 넘게 덮는 것만.
351
+ // 판 위 구분선·모서리 눈금처럼 가는 장식(og-measure 의 위 구분선 3px)을 판으로 읽으면 띠는 숫자 뒤에 훤히 보이는데 글은 원래 색으로 남았다.
352
+ // 넓이는 변환 전 크기로 잰다 — 판이 내려앉는 중(scale)이어도 같은 답이 나오게
262
353
  function hasPlate(root: HTMLElement): boolean {
354
+ const area = (el: HTMLElement) => el.offsetWidth * el.offsetHeight;
355
+ const whole = area(root);
263
356
  const els = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
264
357
  return els.some((el) => {
265
358
  // 짚개가 글에 칠한 배경(표식 data-kf-tone)은 판이 아니다 — 앞 프레임에 칠했는지와 상관없이 같은 답이 나오게
266
359
  if (el.dataset.kfTone !== undefined) return false;
267
360
  const cs = getComputedStyle(el);
268
- if (cs.backgroundImage !== "none") return true;
269
361
  const m = /rgba?\(([^)]+)\)/.exec(cs.backgroundColor);
270
- const a = m ? Number(m[1].split(",")[3] ?? 1) : 0;
271
- return a > 0.05;
362
+ const painted = cs.backgroundImage !== "none" || (m ? Number(m[1].split(",")[3] ?? 1) : 0) > 0.05;
363
+ if (!painted) return false;
364
+ // SVG 처럼 레이아웃 크기가 없는 것과 아직 자리를 안 잡은 대상은 칠했으면 판으로 둔다
365
+ return !(el instanceof HTMLElement) || !whole || area(el) >= whole * 0.5;
272
366
  });
273
367
  }
274
368
 
@@ -287,9 +381,216 @@ function leavesOf(root: HTMLElement): HTMLElement[] {
287
381
  return out;
288
382
  }
289
383
 
290
- export function glider(host: HTMLElement, targets: HTMLElement[], o: { pad?: number; hub?: boolean } = {}): (t: number, stops: readonly number[], until?: number) => void {
384
+ /** 요점 — 그 시각부터 짚개가 내려앉아 머무는 대상 */
385
+ export interface Focus {
386
+ at: number;
387
+ el: HTMLElement;
388
+ }
389
+
390
+ // 낱말·글을 견줄 꼴 — 문장부호를 떼고 소문자로
391
+ const bare = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}.]+/gu, " ").trim();
392
+
393
+ // 목소리 낱말 w 가 글 text 를 말했나 — 낱말이 글의 한 마디이거나 거기에 조사·어미가 붙은 꼴(「캐시가」 = 캐시). 두 글자 아래 마디는 숫자만
394
+ function said(w: string, text: string): boolean {
395
+ const word = bare(w);
396
+ if (!word) return false;
397
+ return bare(text)
398
+ .split(" ")
399
+ .some((tk) => (tk.length >= 2 || /^\d/.test(tk)) && (word === tk || (word.startsWith(tk) && word.length - tk.length <= 3) || (tk.startsWith(word) && word.length >= 2)));
400
+ }
401
+
402
+ /**
403
+ * 짚을 요점과 그 시각 — 첫 대상(장면이 주인공·강조로 세운 것)에 from 에 내려앉고, 그 뒤엔 목소리가 다른 대상의 글을 말하는 낱말 시각에만
404
+ * 그리로 옮긴다(주인공 글을 다시 말하면 돌아온다). 나타나는 차례로 훑지 않는다 — 요점이 바뀌지 않으면 머문다.
405
+ * 대상 글은 다 찍힌 뒤의 글(패턴이 아는 내용)로 견준다 — 찍히는 중인 화면 글로 견주면 시각마다 답이 달라진다.
406
+ * 짚을 것이 하나뿐이면 빈 목록 — 하나뿐인 것을 짚는 칸은 「지금 볼 것」을 가리키지 못하고 의미 없는 네모로 남는다
407
+ */
408
+ export function keyPoints(say: readonly { text: string; from: number }[], from: number, points: readonly { el: HTMLElement; text?: string }[]): Focus[] {
409
+ if (points.length < 2) return [];
410
+ const out: Focus[] = [{ at: from, el: points[0].el }];
411
+ for (const w of say) {
412
+ if (w.from < from) continue;
413
+ const hit = points.find((p) => p.text && said(w.text, p.text));
414
+ if (hit && hit.el !== out[out.length - 1].el) out.push({ at: w.from, el: hit.el });
415
+ }
416
+ return out;
417
+ }
418
+
419
+ /** 들리는 낱말 — 목소리 낱말 시각이 없으면(자막만) 자막 줄을 낱말로 나눠 줄 안에서 놓인 자리만큼의 시각으로 */
420
+ export function spokenWords(kit: Kit): readonly SpokenWord[] {
421
+ if (kit.say.length) return kit.say;
422
+ return kit.lines.flatMap((l) => {
423
+ const n = Math.max(1, l.text.length);
424
+ let pos = 0;
425
+ return l.text.split(/\s+/).filter(Boolean).map((text) => {
426
+ const i = l.text.indexOf(text, pos);
427
+ pos = i + text.length;
428
+ return { text, from: l.from + ((l.to - l.from) * i) / n, to: l.from + ((l.to - l.from) * pos) / n };
429
+ });
430
+ });
431
+ }
432
+
433
+ /** 글 text 가 [from, to) 안에서 말해지는 시각 — 목소리 낱말이 있으면 그 글을 말하는 첫 낱말, 없으면 자막 줄 안에서 그 글이 놓인 자리만큼. 못 찾으면 null */
434
+ export function heardAt(kit: Kit, text: string, from: number, to: number): number | null {
435
+ if (kit.say.length) return kit.say.find((w) => w.from >= from - 0.05 && w.from < to && said(w.text, text))?.from ?? null;
436
+ const key = bare(text).split(" ").find((tk) => tk.length >= 2 || /^\d/.test(tk));
437
+ for (const l of key ? kit.lines : []) {
438
+ const i = bare(l.text).indexOf(key!);
439
+ const at = l.from + ((l.to - l.from) * i) / Math.max(1, bare(l.text).length);
440
+ if (i >= 0 && at >= from - 0.05 && at < to) return at;
441
+ }
442
+ return null;
443
+ }
444
+
445
+ /**
446
+ * 항목이 말해지는 시각 — 목소리 낱말(kit.say)이 그 글을 말하는 첫 시각, 낱말 시각 없이 자막 줄(kit.lines)만 있으면 줄 안에서 그 글이 놓인 자리만큼.
447
+ * 못 찾은 항목은 앞뒤로 찾은 항목 사이(처음은 from, 끝은 to)를 고르게 메우고, 하나도 없으면 [from, to] 를 고르게 — 셋 다 박에 붙인다(0.15초 안).
448
+ * 항목 차례대로 늦어지기만 한다. 감독 장면은 목소리가 길이를 정해 4–9초인데 내용은 2–3초 안에 다 서 — 항목을 말하는 순간에 나눠 세워 그 사이를 메운다
449
+ */
450
+ export function sayTimes(kit: Kit, texts: readonly (string | undefined)[], from: number, to: number): number[] {
451
+ const found = texts.map((tx) => (tx ? heardAt(kit, tx, from, to) : null));
452
+ const out = found.map((x) => (x === null ? NaN : clamp(x, from, to)));
453
+ // 못 찾은 줄 — 앞 닻(없으면 from)과 뒤 닻(없으면 to) 사이를 고르게
454
+ for (let i = 0; i < out.length; ) {
455
+ if (!Number.isNaN(out[i])) {
456
+ i++;
457
+ continue;
458
+ }
459
+ let j = i;
460
+ while (j < out.length && Number.isNaN(out[j])) j++;
461
+ const a = i > 0 ? out[i - 1] : from;
462
+ const b = j < out.length ? out[j] : to;
463
+ const lead = i === 0 ? 0 : 1;
464
+ const span = j - i + lead + (j < out.length ? 1 : 0) - 1;
465
+ for (let k = i; k < j; k++) out[k] = a + ((b - a) * (k - i + lead)) / Math.max(1, span);
466
+ i = j;
467
+ }
468
+ let last = -Infinity;
469
+ return out.map((x, i) => {
470
+ const near = kit.hits.reduce((best, h) => (Math.abs(h - x) < Math.abs(best - x) ? h : best), Infinity);
471
+ const snapped = found[i] === null && Math.abs(near - x) < 0.15 ? near : x;
472
+ last = Math.max(snapped, last);
473
+ return last;
474
+ });
475
+ }
476
+
477
+ // 카메라 움직임 하나는 천천히 떠나 천천히 선다 — 긴 구간에 한 번 걸면 앞뒤가 멈춘 화면이 된다(제목 하나뿐인 13초 장면이 2초 넘게 섰다).
478
+ // STEP 초마다 두 배 길이의 다가감을 겹쳐 걸고 목표를 그 다가감이 끝날 시각의 경로 자리로 두면, 서는 데 없이 고른 빠르기로 간다
479
+ const STEP = 0.8;
480
+
481
+ /**
482
+ * 지금 말하는 항목 쪽으로 다가가는 카메라 — 장면 첫 프레임부터 첫 항목(주인공) 쪽으로, 항목이 말해질 때마다 그 구간 동안 그쪽으로 고른 빠르기로 옮겨 가고,
483
+ * 배율은 장면 내내 고르게 가까워진다. 장면 끝까지 서지 않는 한 번의 움직임 — 내용이 다 선 뒤 쉬는 구간을 뜻 있는 움직임으로 메운다(다가감의 이유가 그 순간 말하는 것).
484
+ * 머무는 짚개(dwell) 테마만 — 훑는 테마는 짚개가 쉬지 않는 몫을 진다. 카메라를 끈 영상엔 안 건다.
485
+ * zoom 은 장면 끝 배율(기본 항목마다 1.02, 1.08 까지). reach < 1 이면 항목 가운데까지 가지 않고 화면 가운데에서 그 몫만큼만 — 내용이 안전 영역 위아래 끝까지 찬
486
+ * 장면(단계·시간축)은 항목에 끝까지 가거나 크게 다가가면 반대쪽 끝 글이 안전 영역 밖으로 밀린다
487
+ */
488
+ export function followSpoken(kit: Kit, ctx: SceneCtx, shots: readonly Focus[], o: { reach?: number; zoom?: number } = {}): void {
489
+ if (!cameraOk(ctx) || !kit.stage || !dwells(kit, ctx)) return;
490
+ const stage = kit.stage;
491
+ const cam = stage.cam;
492
+ const reach = o.reach ?? 1;
493
+ const sorted = [...shots].filter((s) => s.at < ctx.dur - 0.2).sort((a, b) => a.at - b.at);
494
+ if (!sorted.length) return;
495
+ // 첫 프레임부터 주인공 쪽으로 — 앞 장면의 다가감과 끊김 없이 이어져 한 번으로 센다(틀이 카메라를 프레이즈당 한 번 허락한다). 0 에서 시작하면
496
+ // 첫 프레임엔 아직 안 움직여 사이가 뜬다. 아직 안 찍힌(빈) 항목 쪽으로 먼저 가면 화면이 비켜 내용이 안전 영역 밖으로 밀린다
497
+ const hero = cam.querySelector<HTMLElement>("[data-kf-hero]") ?? sorted[0].el;
498
+ const list = [{ at: -0.05, el: hero }, ...sorted.filter((s) => s.at > 0.05)];
499
+ const zoom = o.zoom ?? Math.min(1.08, 1 + 0.02 * list.length);
500
+ const mid = (): [number, number] => [cam.offsetWidth / 2, cam.offsetHeight / 2];
501
+ // 카메라가 재는 것과 같은 층 안 좌표(층 배율을 걷어 낸)로 잰 항목 가운데를 층 가운데 쪽으로 reach 만큼 당긴다. 크기도 넘겨 카메라가
502
+ // 항목이 화면 86% 안에 들게 배율을 누르게 한다 — 빼면 넓은 숫자판 끝 단위가 화면 밖으로 밀렸다
503
+ const spot = (el: HTMLElement): { x: number; y: number; w?: number; h?: number } => {
504
+ const r = el.getBoundingClientRect();
505
+ const [cx, cy] = mid();
506
+ if (r.width < 1 || r.height < 1) return { x: cx, y: cy };
507
+ const lr = cam.getBoundingClientRect();
508
+ const k = lr.width / (cam.offsetWidth || 1) || 1;
509
+ return { x: cx + ((r.left + r.width / 2 - lr.left) / k - cx) * reach, y: cy + ((r.top + r.height / 2 - lr.top) / k - cy) * reach, w: r.width / k, h: r.height / k };
510
+ };
511
+ // 장면 글 전체 상자(층 안 좌표) — 주인공만 가운데로 데려오면 옆 글(숫자판 밖 단위 따위)이 화면 밖으로 밀린다. 한 프레임에 한 번만 잰다
512
+ let boxAt = NaN;
513
+ let box: { l: number; t: number; r: number; b: number } | null = null;
514
+ const textBox = (t: number) => {
515
+ if (t === boxAt) return box;
516
+ boxAt = t;
517
+ box = null;
518
+ const lr = cam.getBoundingClientRect();
519
+ const k = lr.width / (cam.offsetWidth || 1) || 1;
520
+ for (const el of cam.querySelectorAll<HTMLElement>("*")) {
521
+ if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent!.trim())) continue;
522
+ const r = el.getBoundingClientRect();
523
+ if (r.width < 1 || r.height < 1) continue;
524
+ const [l, top] = [(r.left - lr.left) / k, (r.top - lr.top) / k];
525
+ box = box ? { l: Math.min(box.l, l), t: Math.min(box.t, top), r: Math.max(box.r, l + r.width / k), b: Math.max(box.b, top + r.height / k) } : { l, t: top, r: l + r.width / k, b: top + r.height / k };
526
+ }
527
+ return box;
528
+ };
529
+ // 그 배율에서 글 상자가 가장자리 4% 띠 안으로 들게 초점을 묶는다(띠는 검사 layout.edge 와 같은 몫)
530
+ const keepIn = (f: number, lo: number, hi: number, size: number, sc: number): number => {
531
+ const [min, max] = [hi - (size * 0.96 - size / 2) / sc, lo + (size / 2 - size * 0.04) / sc];
532
+ return min <= max ? clamp(f, min, max) : (min + max) / 2;
533
+ };
534
+ // 경로 — 항목 차례마다 그 구간 동안 앞 항목 자리에서 그 항목 자리로
535
+ const path = (t: number, when: number, sc: number): { x: number; y: number; w?: number; h?: number } => {
536
+ let k = 0;
537
+ while (k + 1 < list.length && list[k + 1].at <= when) k++;
538
+ const u = clamp((when - list[k].at) / Math.max(0.1, (list[k + 1]?.at ?? ctx.dur) - list[k].at));
539
+ const [cx, cy] = mid();
540
+ const a = k ? spot(list[k - 1].el) : { x: cx, y: cy };
541
+ const b = spot(list[k].el);
542
+ const [x, y] = [a.x + (b.x - a.x) * u, a.y + (b.y - a.y) * u];
543
+ const tb = textBox(t);
544
+ if (!tb) return { x, y, w: b.w, h: b.h };
545
+ return { x: keepIn(x, tb.l, tb.r, cam.offsetWidth, sc), y: keepIn(y, tb.t, tb.b, cam.offsetHeight, sc), w: b.w, h: b.h };
546
+ };
547
+ for (let at = -0.05; at < ctx.dur; at += STEP) {
548
+ const when = at + STEP * 2;
549
+ const sc = 1 + (zoom - 1) * clamp((when + 0.05) / (ctx.dur + 0.05));
550
+ stage.camera.pushIn(at, { to: (t: number) => path(t, when, sc), scale: sc, dur: STEP * 2 });
551
+ }
552
+ }
553
+
554
+ // 요점 사이 옮김 — 스타일가이드 자리 옮김 300ms. 다음 요점이 그보다 가까우면 닿을 틈을 남기고 더 빨리
555
+ const MOVE = 0.3;
556
+ const REST = 0.03;
557
+
558
+ /** 짚을 대상 — text 는 다 찍힌 뒤의 글(목소리 낱말과 견준다) */
559
+ export interface Point {
560
+ el: HTMLElement;
561
+ text?: string;
562
+ }
563
+
564
+ /** 테마 짚음이 요점에 머무는 방식(mark 의 dwell)인가 — 칸 모양만 묻는다(붙이지 않는 빈 칸에 칠하게 하고 읽는다) */
565
+ export const dwells = (kit: Kit, ctx: SceneCtx): boolean => !!markOf(kit, ctx)?.(document.createElement("div")).dwell;
566
+
567
+ /**
568
+ * 짚개 — 칸이 「지금 볼 것」을 짚는다. 짚기 방식은 테마 짚음(mark)이 고른다:
569
+ * - 머묾(dwell): 첫 대상(장면이 주인공·강조로 세운 것)에 from 에 내려앉아 머물고, 목소리가 다른 대상의 글을 말할 때만 0.3초에 옮긴다(keyPoints).
570
+ * hub 가 아닌 목록(차례로 늘어선 낱말·줄)은 주인공이 없어 짚지 않는다
571
+ * - 훑기(비운 테마): from 부터 박 정지점(beatStops — to 까지)마다 대상 사이를 내내 옮긴다. hub 면 주인공과 나머지를 번갈아 —
572
+ * 작은 글 사이를 옮겨 다니면 칸이 조금밖에 안 움직여 멈춘 화면으로 읽힌다. 마지막 정지점(장면 끝)이 주인공이 되게 차례를 맞춘다
573
+ * 짚을 것이 하나뿐이면 짚지 않는다(의미 없는 네모). until 부터는 걷는다. 칸 모양·옮겨 가는 길·칸 위 글색도 테마 짚음(비우면 강조색 꽉 찬 칸),
574
+ * 칸은 대상 글 뒤에 깔리고(host 에 isolation) 칸이 덮은 글은 그 짚음의 ink 로 바뀐다. 대상 자리는 그 프레임에 잰다 — 대상이 굴러가거나 내려앉아도 따라간다
575
+ */
576
+ export function glider(host: HTMLElement, points: readonly Point[], o: { kit: Kit; ctx: SceneCtx; from: number; to?: number; until?: number; hub?: boolean; pad?: number }): (t: number) => void {
577
+ const hub = o.hub ?? true;
578
+ if (points.length < 2) return () => {};
291
579
  host.style.isolation = "isolate";
292
- const block = node(host, "position:absolute;left:0;top:0;width:0;height:0;background:var(--kf-accent);border-radius:var(--kf-radius-sm);z-index:-1;visibility:hidden;pointer-events:none");
580
+ const block = node(host, MARK_CSS);
581
+ const shape = markOf(o.kit, o.ctx)?.(block) ?? plainMark(block);
582
+ const focus = shape.dwell ? (hub ? keyPoints(spokenWords(o.kit), o.from, points) : []) : null;
583
+ if (focus && !focus.length) {
584
+ block.remove();
585
+ return () => {};
586
+ }
587
+ const targets = focus ? [...new Set(focus.map((f) => f.el))] : points.map((p) => p.el);
588
+ const stops = focus ? [] : beatStops(o.kit.hits, o.from, o.to ?? o.ctx.dur);
589
+ const until = o.until ?? Infinity;
590
+ const n = targets.length;
591
+ // 훑기 차례 — 마지막 정지점(장면 끝)이 주인공이 되게 맞춘다. 끝 프레임(표지·견본)에 곁가지 글이 아니라 주인공이 짚혀 있다
592
+ const base = hub ? (stops.length - 1) % 2 : 0;
593
+ const order = (k: number) => (hub ? ((k + base) % 2 === 0 ? 0 : 1 + (((k + base - 1) / 2) % (n - 1))) : k % n);
293
594
  let toned = new Set<HTMLElement>();
294
595
  const pad = o.pad ?? 0.12;
295
596
  // 프레임마다 잰다 — 처음 그린 프레임에서 한 번만 재면 탭마다 첫 프레임이 달라 같은 시각이 다르게 그려졌다(frame.nondeterministic)
@@ -306,50 +607,52 @@ export function glider(host: HTMLElement, targets: HTMLElement[], o: { pad?: num
306
607
  const fs = parseFloat(getComputedStyle(el).fontSize) || 20;
307
608
  return { x: b.x - fs * pad, y: b.y - fs * pad * 0.5, w: b.w + fs * pad * 2, h: b.h + fs * pad };
308
609
  };
309
- return (t, stops, until = Infinity) => {
310
- const n = targets.length;
311
- if (!n || t < stops[0] || t >= until) {
312
- block.style.visibility = "hidden";
313
- for (const el of toned) paintTone(el, null);
314
- toned = new Set();
315
- return;
610
+ // 이 프레임 칸 자리 — 아직 안 나타났거나 걷었으면 null
611
+ const where = (t: number): MarkBox | null => {
612
+ if (t >= until) return null;
613
+ if (focus) {
614
+ let k = -1;
615
+ while (k + 1 < focus.length && focus[k + 1].at <= t) k++;
616
+ if (k < 0) return null;
617
+ keyCue(t, focus[k].at);
618
+ const next = focus[k + 1];
619
+ const move = Math.max(0.1, Math.min(MOVE, (next ? next.at - focus[k].at : Infinity) - REST));
620
+ return shape.at(k ? rect(focus[k - 1].el) : null, rect(focus[k].el), clamp((t - focus[k].at) / move));
316
621
  }
622
+ if (t < stops[0]) return null;
623
+ stopCues(t, stops, (k) => order(k) === 0);
317
624
  let j = 0;
318
625
  while (j + 1 < stops.length && stops[j + 1] <= t) j++;
319
626
  const a = stops[j];
320
627
  const b = stops[j + 1] ?? a + 1;
321
- const p = clamp((t - a) / Math.max(1e-3, b - a));
322
- const e = 0.7 * p + 0.3 * p * p * (3 - 2 * p);
323
- // hub 면 주인공(첫 대상)과 나머지를 번갈아 — 작은 글 사이를 옮겨 다니면 칸이 조금밖에 안 움직여 멈춘 화면으로 읽힌다.
324
- // 마지막 정지점(장면 끝)이 주인공이 되게 차례를 맞춘다 — 끝 프레임(표지·견본)에 곁가지 글이 아니라 주인공이 짚혀 있다
325
- const base = o.hub ? (stops.length - 1) % 2 : 0;
326
- const order = (k: number) => (o.hub && n > 1 ? ((k + base) % 2 === 0 ? 0 : 1 + (((k + base - 1) / 2) % (n - 1))) : k % n);
327
- const from = rect(targets[order(j)]);
328
- const to = rect(targets[order(j + 1)]);
329
- const mix = (u: number, v: number) => u + (v - u) * e;
628
+ return shape.at(rect(targets[order(j)]), rect(targets[order(j + 1)]), clamp((t - a) / Math.max(1e-3, b - a)));
629
+ };
630
+ return (t) => {
631
+ const box = where(t);
632
+ if (!box) {
633
+ block.style.visibility = "hidden";
634
+ for (const el of toned) paintTone(el, null);
635
+ toned = new Set();
636
+ return;
637
+ }
330
638
  block.style.visibility = "visible";
331
- block.style.transform = `translate(${mix(from.x, to.x).toFixed(1)}px, ${mix(from.y, to.y).toFixed(1)}px)`;
332
- block.style.width = `${mix(from.w, to.w).toFixed(1)}px`;
333
- block.style.height = `${mix(from.h, to.h).toFixed(1)}px`;
334
- // 띠와 겹친 글자 부분만 흰 글로 — 글자에 「겹친 사각형만 on-accent, 나머지는 원래 글색」인 배경을 깔고 글자 모양으로 오린다.
335
- // 통째로 덮였을 때만 글색을 바꾸면, 쉬지 않고 옮겨 가는 띠는 거의 늘 반쯤 걸쳐 있어 띠 위 글이 검은 채로 남았다
336
- const bx = mix(from.x, to.x);
337
- const by = mix(from.y, to.y);
338
- const bw = mix(from.w, to.w);
339
- const bh = mix(from.h, to.h);
639
+ place(block, box);
340
640
  const now = new Set<HTMLElement>();
641
+ // 띠와 겹친 글자 부분만 띠 위 글색으로 — 글자에 「겹친 사각형만 ink, 나머지는 원래 글색」인 배경을 깔고 글자 모양으로 오린다.
642
+ // 통째로 덮였을 때만 글색을 바꾸면, 옮겨 가는 동안 반쯤 걸친 띠 위 글이 원래 색으로 남았다.
341
643
  // 불투명한 판을 품은 대상(테마 panel 이 깔린 숫자판·계기 판)은 띠가 그 판 뒤로 가려 안 보인다 — 거기 글을 흰 글로 칠하면 흰 판 위 흰 글이 된다
342
- for (const el of targets.filter((x) => !opaqueOf(x)).flatMap(leavesOf)) {
644
+ const ink = shape.ink;
645
+ for (const el of ink ? targets.filter((x) => !opaqueOf(x)).flatMap(leavesOf) : []) {
343
646
  const r = boxOf(el);
344
- const x0 = Math.max(bx, r.x);
345
- const y0 = Math.max(by, r.y);
346
- const x1 = Math.min(bx + bw, r.x + r.w);
347
- const y1 = Math.min(by + bh, r.y + r.h);
647
+ const x0 = Math.max(box.x, r.x);
648
+ const y0 = Math.max(box.y, r.y);
649
+ const x1 = Math.min(box.x + box.w, r.x + r.w);
650
+ const y1 = Math.min(box.y + box.h, r.y + r.h);
348
651
  if (x1 - x0 < 1 || y1 - y0 < 1) continue;
349
652
  // 글 상자에 변환(배율)이 걸려 있으면 배경 좌표는 변환 전 크기로
350
653
  const sx = (el.offsetWidth || r.w) / (r.w || 1);
351
654
  const sy = (el.offsetHeight || r.h) / (r.h || 1);
352
- paintTone(el, { x: (x0 - r.x) * sx, y: (y0 - r.y) * sy, w: (x1 - x0) * sx, h: (y1 - y0) * sy });
655
+ paintTone(el, { x: (x0 - r.x) * sx, y: (y0 - r.y) * sy, w: (x1 - x0) * sx, h: (y1 - y0) * sy }, ink);
353
656
  now.add(el);
354
657
  }
355
658
  for (const el of toned) if (!now.has(el)) paintTone(el, null);
@@ -400,6 +703,7 @@ export function factRoll(t: number, o: { from: number; to: number; at: number; d
400
703
  const dec = o.decimals ?? decimalsOf(o.to);
401
704
  const x = progress(t, o.at, o.dur);
402
705
  const p = 0.75 * ease.outCubic(x) + 0.25 * x;
706
+ rollCues(t, { at: o.at, dur: o.dur, p: (u) => 0.75 * ease.outCubic(u) + 0.25 * u });
403
707
  const lo = Math.min(o.from, o.to);
404
708
  const hi = Math.max(o.from, o.to);
405
709
  const value = p >= 1 ? o.to : clamp(o.from + (o.to - o.from) * p, lo, hi);
@@ -446,5 +750,6 @@ const DEFAULT_SLOTS: Required<Pick<ThemeSlots, "panel" | "land" | "write" | "tra
446
750
 
447
751
  /** 테마 슬롯 + 비운 자리는 기본 */
448
752
  export function slotsOf(kit: Kit): ThemeSlots & typeof DEFAULT_SLOTS {
449
- return { ...DEFAULT_SLOTS, ...kit.slots } as ThemeSlots & typeof DEFAULT_SLOTS;
753
+ // 소리 자리는 감싸개가 단다 — 테마 슬롯이든 기본이든 같은 자리에서
754
+ return withSfx({ ...DEFAULT_SLOTS, ...kit.slots }) as ThemeSlots & typeof DEFAULT_SLOTS;
450
755
  }
@@ -1,6 +1,6 @@
1
1
  import { applyStyle, clamp, spring } from "@kasaframe/core";
2
2
  import { fitSize, textEm } from "@kasaframe/motion/moves";
3
- import { area, beatStops, decimalsOf, factRoll, fmt, glider, moving, node, slotsOf } from "../kit.ts";
3
+ import { area, decimalsOf, dwells, factRoll, FILL, fillEdge, fmt, followSpoken, glider, moving, node, ON_FILL, sayTimes, slotsOf } from "../kit.ts";
4
4
  import type { Pattern } from "../types.ts";
5
5
 
6
6
  export interface LimitContent {
@@ -12,12 +12,12 @@ export interface LimitContent {
12
12
 
13
13
  /**
14
14
  * 한계 — 굵은 계기가 차올라 끝의 위험 칸(한계)에 부딪힌다. 값이 있으면 계기와 같은 진행도로 굴러 그 값에 닿는다.
15
- * 부딪힌 뒤 치르는 것 한 낱말이 딱지로 붙고, 짚개가 한계 문장·계기·치르는 것을 번갈아 박마다 짚는다.
15
+ * 부딪힌 뒤 치르는 것 한 낱말이 딱지로 붙고, 짚개가 한계 문장부터 짚는다(머무는 테마는 목소리가 계기 값·치르는 것을 말할 때만 그리로).
16
16
  */
17
17
  const pattern: Pattern<LimitContent> = {
18
18
  id: "limit.gauge",
19
19
  type: "limit",
20
- does: "계기가 차올라 끝의 위험 칸에 부딪히고, 치르는 것이 붙은 뒤 짚개가 한계·계기·대가를 번갈아 짚는다",
20
+ does: "계기가 차올라 끝의 위험 칸에 부딪히고, 치르는 것이 붙은 뒤 짚개가 한계부터 짚는다",
21
21
  energy: ["low", "mid"],
22
22
  family: "grow",
23
23
  camera: ["none"],
@@ -58,7 +58,7 @@ const pattern: Pattern<LimitContent> = {
58
58
  const row = vertical ? node(col, "position:relative;display:flex;justify-content:center;width:100%") : col;
59
59
  const line = Math.max(2, Math.round(3 * u));
60
60
  const gauge = node(row, `position:relative;width:${gw}px;height:${gh}px;background:var(--kf-raised);border:${line}px solid var(--kf-line);box-sizing:border-box`);
61
- const fill = node(gauge, vertical ? "position:absolute;left:0;right:0;bottom:0;height:100%;background:var(--kf-accent);transform-origin:50% 100%;transform:scaleY(0)" : "position:absolute;left:0;top:0;bottom:0;width:100%;background:var(--kf-accent);transform-origin:0 50%;transform:scaleX(0)");
61
+ const fill = node(gauge, `position:absolute;${vertical ? "left:0;right:0;bottom:0;height:100%;transform-origin:50% 100%;transform:scaleY(0)" : "left:0;top:0;bottom:0;width:100%;transform-origin:0 50%;transform:scaleX(0)"};background:${FILL};${fillEdge(line)}`);
62
62
  const danger = node(gauge, vertical ? `position:absolute;left:0;right:0;top:0;height:${(1 - wall) * 100}%;background:var(--kf-danger, var(--kf-accent));opacity:.28` : `position:absolute;left:${wall * 100}%;right:0;top:0;bottom:0;background:var(--kf-danger, var(--kf-accent));opacity:.28`);
63
63
  const bar = Math.max(4, Math.round(6 * u));
64
64
  const wallEl = node(
@@ -68,7 +68,7 @@ const pattern: Pattern<LimitContent> = {
68
68
  : `position:absolute;left:${wall * 100}%;top:-${Math.round(gh * 0.3)}px;bottom:-${Math.round(gh * 0.3)}px;width:${bar}px;margin-left:-${bar / 2}px;background:var(--kf-danger, var(--kf-ink))`,
69
69
  );
70
70
  const val = valText
71
- ? node(gauge, vertical ? `position:absolute;left:0;right:0;display:flex;justify-content:center;font-family:var(--kf-font-mono);font-size:${valPx}px;font-weight:700;color:var(--kf-on-accent);white-space:nowrap;font-variant-numeric:tabular-nums;line-height:1.2` : `position:absolute;top:0;bottom:0;display:flex;align-items:center;padding:0 ${Math.round(gh * 0.25)}px;font-family:var(--kf-font-mono);font-size:${valPx}px;font-weight:700;color:var(--kf-on-accent);white-space:nowrap;font-variant-numeric:tabular-nums`)
71
+ ? node(gauge, vertical ? `position:absolute;left:0;right:0;display:flex;justify-content:center;font-family:var(--kf-font-mono);font-size:${valPx}px;font-weight:700;color:${ON_FILL};white-space:nowrap;font-variant-numeric:tabular-nums;line-height:1.2` : `position:absolute;top:0;bottom:0;display:flex;align-items:center;padding:0 ${Math.round(gh * 0.25)}px;font-family:var(--kf-font-mono);font-size:${valPx}px;font-weight:700;color:${ON_FILL};white-space:nowrap;font-variant-numeric:tabular-nums`)
72
72
  : null;
73
73
  const costCss = `font-family:var(--kf-font-sans);font-size:${costPx}px;font-weight:800;color:var(--kf-ink);border:${Math.max(3, Math.round(4 * u))}px solid var(--kf-danger, var(--kf-ink));padding:0.08em 0.45em;line-height:1.2;white-space:nowrap`;
74
74
  // 세로면 치르는 것은 계기 위 바깥, 위험 칸 바로 위 오른쪽에 — 계기 안에 두면 짚개 띠가 불투명한 계기 판 뒤로만 오가 화면이 안 바뀐다
@@ -78,24 +78,30 @@ const pattern: Pattern<LimitContent> = {
78
78
  const hits = kit.hits.length ? kit.hits : [0];
79
79
  const labelDur = clamp([...c.label].length * 0.03, 0.3, 0.7);
80
80
  const fillAt = labelDur * 0.5;
81
- // 계기는 1–2.2초 안에 벽에 닿는다 — 장면이 길다고 느리게 차오르면 그 사이가 멈춘 화면이다
81
+ // 머무는 짚개 테마는 목소리가 한계를 말하는 순간 벽에 닿는다 — 그때까지 고른 빠르기로 차오르고, 치르는 것은 그것을 말할 때 붙고,
82
+ // 닿은 뒤엔 카메라가 한계·치르는 것 쪽으로 다가간다. 훑는 테마·목소리 없는 영상은 1–2.2초 안 박에 닿는다
83
+ const tail = Math.max(fillAt + 1, ctx.dur - 0.6);
84
+ const heard = dwells(kit, ctx) && (kit.say.length > 0 || kit.lines.length > 0);
85
+ const [heardLabel, heardCost] = heard ? sayTimes(kit, [c.label, c.cost], fillAt + 0.9, tail) : [0, 0];
82
86
  const hitWant = clamp(ctx.dur * 0.45, fillAt + 0.9, fillAt + 2.2);
83
- const hitAt = hits.reduce((best, b) => (Math.abs(b - hitWant) < Math.abs(best - hitWant) && b > fillAt + 0.6 ? b : best), hitWant);
84
- const costAt = hitAt + 0.1;
87
+ const hitAt = heard ? heardLabel : hits.reduce((best, b) => (Math.abs(b - hitWant) < Math.abs(best - hitWant) && b > fillAt + 0.6 ? b : best), hitWant);
88
+ const costAt = Math.max(hitAt + 0.1, heardCost);
85
89
  const markFrom = hitAt + 0.2;
90
+ // 한계 문장·대가는 계기 위아래 끝이라 끝까지 다가가면 반대쪽 글(값·문장)이 화면 밖으로 밀린다
91
+ followSpoken(kit, ctx, [{ at: hitAt, el: labelBox }, ...(cost ? [{ at: costAt, el: cost }] : [])], { reach: 0.4 });
86
92
 
87
- const writeLabel = slots.write(label, { ctx, text: c.label, at: 0, dur: labelDur, seed: `${kit.seed}:l` });
93
+ // 한계 문장은 목소리가 말하는 순간 찍힌다(그 밖엔 처음부터)
94
+ const writeLabel = slots.write(label, { ctx, text: c.label, at: heard ? Math.max(0, hitAt - labelDur) : 0, dur: labelDur, seed: `${kit.seed}:l` });
88
95
  const writeCost = cost ? slots.write(cost, { ctx, text: c.cost!, at: costAt, dur: 0.25, seed: `${kit.seed}:c` }) : null;
89
96
  const landed = slots.land(wallEl, { ctx, at: hitAt, seed: `${kit.seed}:wall` });
90
- // 벽에 닿은 뒤엔 짚개가 한계 문장 ↔ 계기 ↔ 치르는 것을 번갈아 박마다 짚는다
97
+ // 벽에 닿으면 짚개가 한계 문장부터 짚는다 — 머무는 테마는 목소리가 치르는 것을 말할 때만 그리로
91
98
  // 세로면 계기 판은 짚지 않는다 — 띠가 판 뒤로 가려 그 사이가 멈춘 화면이 된다. 한계 문장 ↔ 대가를 오간다
92
- const point = glider(host, vertical && cost ? [labelBox, cost] : [labelBox, gauge, ...(cost ? [cost] : [])], { hub: true });
93
- const stops = beatStops(kit.hits, markFrom, ctx.dur);
99
+ const point = glider(host, vertical && cost ? [{ el: labelBox, text: c.label }, { el: cost, text: c.cost }] : [{ el: labelBox, text: c.label }, { el: gauge }, ...(cost ? [{ el: cost, text: c.cost }] : [])], { kit, ctx, from: markFrom });
94
100
 
95
101
  return (t: number) => {
96
102
  moving("limit.gauge", ctx);
97
103
  writeLabel(t);
98
- point(t, stops);
104
+ point(t);
99
105
  // 고른 빠르기로 차올라 벽에 부딪힌다 — 감속하면 부딪힘이 없고 끝자락이 멈춘 화면이 된다
100
106
  const p = clamp((t - fillAt) / (hitAt - fillAt));
101
107
  fill.style.transform = vertical ? `scaleY(${(p * wall).toFixed(4)})` : `scaleX(${(p * wall).toFixed(4)})`;