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
@@ -5,11 +5,23 @@ import { definePattern } from "./pattern.ts";
5
5
 
6
6
  const MAX_WORD = 14;
7
7
 
8
+ function chunks(s: string): string[] {
9
+ if ([...s].length <= MAX_WORD) return [s];
10
+ const out: string[] = [];
11
+ for (const w of s.split(/\s+/)) {
12
+ const last = out[out.length - 1];
13
+ if (last !== undefined && [...`${last} ${w}`].length <= MAX_WORD) out[out.length - 1] = `${last} ${w}`;
14
+ else out.push(w);
15
+ }
16
+ return out;
17
+ }
18
+
8
19
  function words(content: Record<string, unknown>): string[] {
9
20
  const out: string[] = [];
21
+ // 긴 글은 버리지 않고 띄어쓰기에서 MAX_WORD 안으로 나눈다 — 통째로 버리면 긴 제목 하나뿐인 장면에 띄울 낱말이 없어 대체 후보가 빠졌다
10
22
  const add = (v: unknown, unit?: unknown) => {
11
23
  if (typeof v === "number") out.push(`${v}${typeof unit === "string" ? unit : ""}`);
12
- else if (typeof v === "string" && v.trim()) out.push(v.trim());
24
+ else if (typeof v === "string" && v.trim()) out.push(...chunks(v.trim()));
13
25
  };
14
26
  const each = (list: unknown, key: string) => Array.isArray(list) && list.forEach((x) => (typeof x === "string" ? add(x) : x && typeof x === "object" && add((x as Record<string, unknown>)[key])));
15
27
  const side = (x: unknown) => {
@@ -20,7 +32,7 @@ function words(content: Record<string, unknown>): string[] {
20
32
  };
21
33
  add(content.prefix !== undefined && typeof content.value === "number" ? `${content.prefix}${content.value}${content.unit ?? ""}` : undefined);
22
34
  if (content.prefix === undefined) add(content.value, content.unit);
23
- for (const k of ["title", "term", "full", "ko", "label", "knob", "ratio", "change", "cost", "text", "who", "line", "url"]) add(content[k]);
35
+ for (const k of ["title", "term", "full", "ko", "label", "knob", "ratio", "change", "cost", "text", "who", "line", "url", "kicker", "sub"]) add(content[k]);
24
36
  side(content.a);
25
37
  side(content.b);
26
38
  side(content.before);
@@ -5,7 +5,7 @@ export { definePattern } from "./pattern.ts";
5
5
  export type { AspectName, CameraKind, Energy, Family, FitInfo, Kit, Pattern, PatternMeta, SpokenWord, StageLike, TextPolicy } from "./pattern.ts";
6
6
  export { order, reject, repick, score, selectAll } from "./select.ts";
7
7
  export type { Entry, Pick, Ranked, Rejected, SelectCtx, Slot } from "./select.ts";
8
- export { BRIDGE_KIND, cyclingText, dressAll, energyOf, SEAM_KINDS, seamKinds, seamSpots, STILL_BG } from "./transitions.ts";
8
+ export { BRIDGE_KIND, cyclingText, dressAll, energyOf, SEAM_KINDS, seamKinds, seamPool, seamsAllowed, seamSpots, STILL_BG } from "./transitions.ts";
9
9
  export type { Spot } from "./transitions.ts";
10
10
  export { catalog } from "./catalog.ts";
11
11
  export { build, contentHash, layout, MARK } from "./assemble.ts";
@@ -71,6 +71,8 @@ export interface Kit {
71
71
  hits: number[];
72
72
  /** 들리는 낱말 시각(장면 안 초) — kf narrate 를 지났을 때만 */
73
73
  say: SpokenWord[];
74
+ /** 자막 줄(장면 안 초) — 낱말 시각이 없는 프로젝트(목소리 없이 자막만)에서 항목이 말해지는 자리를 어림한다 */
75
+ lines: SpokenWord[];
74
76
  /** 테마 슬롯(themes/<id>/slots.ts) — 테마가 비운 자리는 패턴 기본으로 그린다 */
75
77
  slots: ThemeSlots;
76
78
  /** 감독이 dress 바탕·경계 덮기를 건 무대. 감싸개가 paint 다음에 stage.tick 을 부르니 패턴은 부르지 않는다 */
@@ -57,7 +57,8 @@ export default definePattern<Fill>({
57
57
  const x = (i % cols) * cell + gap / 2;
58
58
  const y = Math.floor(i / cols) * cell + gap / 2;
59
59
  const box = el("div", `position:absolute;left:${x}px;top:${y}px;width:${side}px;height:${side}px;border-radius:${Math.round(side * 0.18)}px;box-sizing:border-box;border:${Math.max(2, Math.round(side * 0.06))}px solid color-mix(in srgb, var(--kf-ink) 28%, transparent)`);
60
- const fillEl = el("div", `position:absolute;inset:-1px;border-radius:inherit;background:var(--kf-accent);transform-origin:50% 50%`);
60
+ // 칠 토큰(fill·fillLine)을 둔 테마는 그 결로 — 없으면 강조색 꽉 찬 칸 그대로
61
+ const fillEl = el("div", `position:absolute;inset:-1px;border-radius:inherit;background:var(--kf-fill, var(--kf-accent));box-shadow:inset 0 0 0 ${Math.max(2, Math.round(side * 0.06))}px var(--kf-fill-line, transparent);transform-origin:50% 50%`);
61
62
  box.append(fillEl);
62
63
  grid.append(box);
63
64
  return { fillEl };
@@ -84,7 +85,9 @@ export default definePattern<Fill>({
84
85
  stops.push({ t: ctx.dur + 0.4, idx: recap[after.length % recap.length] });
85
86
  const stopAt = stops.map((x) => x.t);
86
87
  const zoomAt = (at[k - 1] ?? 0) + 0.25;
87
- if (ctx.dur - zoomAt > 1.4) kit.stage.camera.pushIn(zoomAt, { scale: 1.07, dur: 0.9 });
88
+ // 머무는 짚개 테마는 이웃 장면이 첫 프레임부터 카메라를 잇는다 — 다가감을 장면 끝까지 늘여 그 움직임과 한 번으로 잇는다(사이가 뜨면 한 프레이즈에 두 번으로 센다)
89
+ const dwell = !!kit.slots.mark?.(document.createElement("div"), { ctx, seed: `${kit.seed}:mark` }).dwell;
90
+ if (ctx.dur - zoomAt > 1.4) kit.stage.camera.pushIn(zoomAt, { scale: 1.07, dur: dwell ? ctx.dur + 0.1 - zoomAt : 0.9 });
88
91
 
89
92
  const pos = (idx: number) => ({ x: (idx % cols) * cell - gap / 2, y: Math.floor(idx / cols) * cell - gap / 2 });
90
93
  return (t: number) => {
@@ -24,19 +24,30 @@ type MakeStage = (root: HTMLElement, ctx: SceneCtx, o: { panel?: boolean; dress?
24
24
 
25
25
  interface Line {
26
26
  scene: string;
27
+ text: string;
28
+ from?: number;
29
+ to?: number;
27
30
  words?: { text: string; from: number; to: number }[];
28
31
  }
29
32
 
30
33
  // 패턴이 박에 붙일 때 쓰는 간격 — 장면 문법 예시와 같은 값이라 예시에서 본 그림이 그대로 나온다
31
34
  const HIT_GAP = 0.3;
32
35
 
33
- // captions.json 의 낱말 시각은 영상 기준 초 — 장면 안 초로 옮긴다
34
- function wordsOf(glob: Record<string, unknown> | undefined, ctx: SceneCtx): SpokenWord[] {
36
+ // captions.json 의 시각은 영상 기준 초 — 장면 안 초로 옮긴다
37
+ function linesOf(glob: Record<string, unknown> | undefined, ctx: SceneCtx): Line[] {
35
38
  const lines = (Object.values(glob ?? {})[0] ?? []) as Line[];
36
- if (!Array.isArray(lines)) return [];
37
- return lines.filter((l) => l.scene === ctx.id).flatMap((l) => (l.words ?? []).map((w) => ({ text: w.text, from: w.from - ctx.from, to: w.to - ctx.from })));
39
+ return Array.isArray(lines) ? lines.filter((l) => l.scene === ctx.id) : [];
38
40
  }
39
41
 
42
+ const wordsOf = (lines: Line[], ctx: SceneCtx): SpokenWord[] => lines.flatMap((l) => (l.words ?? []).map((w) => ({ text: w.text, from: w.from - ctx.from, to: w.to - ctx.from })));
43
+
44
+ // 낱말 시각 없이 줄만 있는 자막(목소리 없이 자막만 단 프로젝트) — 시각이 없는 줄은 화면 자막(toCues)처럼 장면 안 양끝을 조금 띄운 구간에 편다.
45
+ // 빼면 패턴이 「자막이 말하는 순간」을 못 읽어 항목이 처음에 다 서고 그 뒤가 멈춘다
46
+ const timedLines = (lines: Line[], ctx: SceneCtx): SpokenWord[] => {
47
+ const pad = Math.min(0.25, ctx.dur * 0.05);
48
+ return lines.map((l) => (l.from !== undefined && l.to !== undefined ? { text: l.text, from: l.from - ctx.from, to: l.to - ctx.from } : { text: l.text, from: pad, to: ctx.dur - pad }));
49
+ };
50
+
40
51
  export function directed(pattern: Pattern<any>, d: Directed, deps: { makeStage: MakeStage; captions?: Record<string, unknown> }) {
41
52
  return {
42
53
  build(root: HTMLElement, ctx: SceneCtx) {
@@ -50,7 +61,8 @@ export function directed(pattern: Pattern<any>, d: Directed, deps: { makeStage:
50
61
  stage.box.prepend(layer);
51
62
  surface = slots.surface(layer, { ctx, seed: `${d.seed}:surface` });
52
63
  }
53
- const kit: Kit = { seed: d.seed, hits: hitTimes(ctx, HIT_GAP), say: wordsOf(deps.captions, ctx), slots, stage, policy: d.policy, first: d.first, last: d.last, loop: d.loop };
64
+ const lines = linesOf(deps.captions, ctx);
65
+ const kit: Kit = { seed: d.seed, hits: hitTimes(ctx, HIT_GAP), say: wordsOf(lines, ctx), lines: timedLines(lines, ctx), slots, stage, policy: d.policy, first: d.first, last: d.last, loop: d.loop };
54
66
  const paint = pattern.build(stage.cam, ctx, d.content, kit);
55
67
  return (t: number) => {
56
68
  surface?.(t);
@@ -6,6 +6,7 @@ import type { Energy } from "./pattern.ts";
6
6
  // 장면 사이 — 이음매(3D 전환 장면)를 어디에 몇 개 넣을지, 그 종류, 나머지 경계의 덮기, 장면마다의 바탕.
7
7
  // 이음매 기본은 dive, 같은 전환을 잇달아 쓰지 않는다. 덮기·바탕은 연출 수 메뉴(pickDress)의 규칙을 그대로 따른다
8
8
  // (이음매·드롭으로 들어가는 경계·1.2초 아래 장면엔 안 덮는다, 이어지는 장면은 다른 바탕).
9
+ // 테마가 허용 목록(theme.json transitions — 덮기·이음매 이름)을 주면 그 안에서만 고른다. 허용한 이음매가 없으면 이음매를 안 넣는다(컷).
9
10
 
10
11
  export interface BeatsLite {
11
12
  sections?: { t: number; kind: string; strength?: number }[];
@@ -66,6 +67,16 @@ export function seamSpots(types: readonly string[], spaces: readonly ("2d" | "3d
66
67
 
67
68
  export const SEAM_KINDS = ["dive", "portal", "cards", "zoomOut", "fold", "whip"] as const;
68
69
 
70
+ /** 고를 수 있는 이음매 종류 — 허용 목록이 없으면 SEAM_KINDS, 있으면 그 안의 것(gl:·smooth: 전환도 이름을 적으면 든다) */
71
+ export function seamPool(allow?: readonly string[] | null): string[] {
72
+ if (!allow) return [...SEAM_KINDS];
73
+ return [...SEAM_KINDS, ...allow.filter((k) => /^(gl|smooth):/.test(k))].filter((k) => allow.includes(k));
74
+ }
75
+
76
+ /** 이음매를 하나라도 넣을 수 있나 — 공간 잇기(rise·land)도 허용 목록을 따른다 */
77
+ export const seamsAllowed = (allow?: readonly string[] | null): boolean =>
78
+ !allow || seamPool(allow).length > 0 || Object.values(BRIDGE_KIND).some((k) => allow.includes(k));
79
+
69
80
  /** 2D↔3D 경계의 공간 잇기 — 2D→3D 는 주인공 상자가 3D 블록으로 일어서고(rise), 3D→2D 는 한 평면에 내려앉는다(land).
70
81
  * 이 판의 transition3d 에 아직 없으면 dive 로(장면이 모르는 kind 로 던지지 않게) */
71
82
  const known = new Set<string>(TRANSITIONS_3D);
@@ -75,18 +86,20 @@ export const BRIDGE_KIND = { up: known.has("rise") ? "rise" : "dive", down: know
75
86
  * 이음매 종류 — 공간 잇기 자리는 BRIDGE_KIND, 나머지 첫째는 틀이 정한 것(기본 dive), 다음부터는 씨앗으로 앞과 다른 것.
76
87
  * failed 는 kf check 에서 떨어진 종류(이음매 차례 번호로)
77
88
  */
78
- export function seamKinds(spots: readonly Spot[], seed: string, first = "dive", failed: Record<number, string[]> = {}): string[] {
89
+ export function seamKinds(spots: readonly Spot[], seed: string, first = "dive", failed: Record<number, string[]> = {}, allow?: readonly string[] | null): string[] {
90
+ const kinds = seamPool(allow);
91
+ const lead = kinds.includes(first) ? first : (kinds[0] ?? first);
79
92
  const out: string[] = [];
80
93
  let plain = 0;
81
94
  for (const [i, sp] of spots.entries()) {
82
95
  const bad = new Set([out[i - 1], ...(failed[i] ?? [])]);
83
- if (sp.bridge && !(failed[i] ?? []).includes(BRIDGE_KIND[sp.bridge])) {
96
+ if (sp.bridge && !(failed[i] ?? []).includes(BRIDGE_KIND[sp.bridge]) && (!allow || allow.includes(BRIDGE_KIND[sp.bridge]))) {
84
97
  out.push(BRIDGE_KIND[sp.bridge]);
85
98
  continue;
86
99
  }
87
- const pool = SEAM_KINDS.filter((k) => !bad.has(k));
88
- if (plain++ === 0 && !bad.has(first)) out.push(first);
89
- else out.push(pool.length ? pool[Math.floor(rand(`${seed}|seam`, i) * pool.length)] : first);
100
+ const pool = kinds.filter((k) => !bad.has(k));
101
+ if (plain++ === 0 && !bad.has(lead)) out.push(lead);
102
+ else out.push(pool.length ? pool[Math.floor(rand(`${seed}|seam`, i) * pool.length)] : lead);
90
103
  }
91
104
  return out;
92
105
  }
@@ -100,9 +113,9 @@ export interface Placed {
100
113
  /** 바탕 정지 — 움직이는 것은 다 내용 하나를 뜻해야 해서 바탕 수(햇살·집중선·모눈·빛 방울)는 감독 장면에 걸지 않는다 */
101
114
  export const STILL_BG = "still";
102
115
 
103
- /** 장면마다 글 수·경계 덮기 — 연출 수 메뉴의 고름 그대로(같은 씨앗이면 같은 고름), 바탕은 정지 */
104
- export function dressAll(seed: string, scenes: readonly Placed[], moves: Bundle | undefined, theme: string | null): Dress[] {
105
- return pickDress(seed, scenes, bundleFor(moves, theme ?? undefined)).map((d) => ({ ...d, bg: STILL_BG }));
116
+ /** 장면마다 글 수·경계 덮기 — 연출 수 메뉴의 고름 그대로(같은 씨앗이면 같은 고름), 바탕은 정지. allow 는 테마가 허용한 전환 */
117
+ export function dressAll(seed: string, scenes: readonly Placed[], moves: Bundle | undefined, theme: string | null, allow?: readonly string[] | null): Dress[] {
118
+ return pickDress(seed, scenes, bundleFor(moves, theme ?? undefined, allow)).map((d) => ({ ...d, bg: STILL_BG }));
106
119
  }
107
120
 
108
121
  // 감독 기본이 쓰는 글 수 — 낱말을 돌려 띄우되 자르지 않고(slamWords 는 긴 낱말이 화면 끝을 넘는다) 겹쳐 흐리지 않는 것(echoStack·ransom 은 겹친 벌이 장식이 된다)
@@ -1,4 +1,4 @@
1
- import { clamp, emit, progress, rand, spring, toEase, type Ease, type EaseName, type Style, safeTime } from "@kasaframe/core";
1
+ import { clamp, cue, emit, progress, rand, spring, toEase, type Ease, type EaseName, type SfxRole, type Style, safeTime } from "@kasaframe/core";
2
2
 
3
3
  export type MoveName = "fade" | "rise" | "drop" | "pop" | "blurIn" | "scaleIn" | "maskWipe" | "slide";
4
4
  export type Side = "left" | "right" | "top" | "bottom";
@@ -72,6 +72,9 @@ function check(name: string): MoveName {
72
72
  return name as MoveName;
73
73
  }
74
74
 
75
+ // 등장 소리 — 스며 나오는 것(fade·blurIn)은 소리 없이, 톡 튀어나오는 pop 은 붙기, 위에서 떨어지는 drop 은 착지. 낱낱이 나타나는 것이라 잔소리(minor)
76
+ const ENTER_SFX: Partial<Record<MoveName, SfxRole>> = { rise: "appear", drop: "land", pop: "stick", scaleIn: "appear", maskWipe: "appear", slide: "appear" };
77
+
75
78
  export function enter(name: MoveName, t: number, opts: MoveOpts = {}): Style {
76
79
  t = safeTime(t);
77
80
  const n = check(name);
@@ -80,6 +83,9 @@ export function enter(name: MoveName, t: number, opts: MoveOpts = {}): Style {
80
83
  const p = progress(t, at, dur);
81
84
  const e = toEase(opts.ease, n === "pop" ? (x) => x : undefined)(p);
82
85
  if (opts.emit !== false && p > 0 && p < 1) emit({ kind: "move", source: opts.source ?? n, scene: opts.scene, name: `enter:${n}`, value: 1 });
86
+ const role = ENTER_SFX[n];
87
+ // 같은 부품이 여러 칸을 늦춰 세우면(stagger) 칸마다 자리가 달라 하나씩 남는다
88
+ if (role && opts.emit !== false) cue(role, t - at, { source: `${opts.source ?? n}@${at.toFixed(3)}`, scene: opts.scene, minor: true });
83
89
  return shape(n, p, e, t - at, opts);
84
90
  }
85
91
 
@@ -1,4 +1,4 @@
1
- import { clamp, emit, noise1, progress, toEase, type Beats, type MotionRules, type PulseOpts, type Style, safeTime } from "@kasaframe/core";
1
+ import { clamp, cue, emit, noise1, progress, toEase, type Beats, type MotionRules, type PulseOpts, type Style, safeTime } from "@kasaframe/core";
2
2
 
3
3
  interface Tagged {
4
4
  source?: string;
@@ -63,7 +63,10 @@ export function dropBurst(t: number, opts: DropBurstOpts): DropBurstState {
63
63
  const local = t - at;
64
64
  const p = progress(t, at, 0.9);
65
65
  const flash = local < 0 ? 0 : local < 0.04 ? 1 : clamp(1 - (local - 0.04) / 0.22) ** 2;
66
- if (opts.emit !== false) emit({ kind: "flash", source, scene: opts.scene, value: flash });
66
+ if (opts.emit !== false) {
67
+ emit({ kind: "flash", source, scene: opts.scene, value: flash });
68
+ cue("drop", local, { source, scene: opts.scene });
69
+ }
67
70
  const rp = toEase("outCubic")(progress(t, at, 0.7));
68
71
  const ring = local < 0 ? { scale: 0, opacity: 0 } : { scale: 0.2 + 2.6 * rp, opacity: (1 - rp) * 0.9 };
69
72
  const kick = local < 0 ? 0 : Math.exp(-local / 0.12) * Math.cos(local * 28);
@@ -1,7 +1,7 @@
1
- import type { SceneCtx } from "@kasaframe/core";
1
+ import { clamp, type SceneCtx } from "@kasaframe/core";
2
2
  import { brushWipe } from "./brushWipe.ts";
3
3
  import { echoStack } from "./echoStack.ts";
4
- import { div } from "./fit.ts";
4
+ import { div, mark } from "./fit.ts";
5
5
  import { flood } from "./flood.ts";
6
6
  import { ghostStack } from "./ghostStack.ts";
7
7
  import { gridFloor } from "./gridFloor.ts";
@@ -164,6 +164,9 @@ export function dressText(name: string, el: HTMLElement, ctx: SceneCtx, o: { wor
164
164
  }
165
165
  }
166
166
 
167
+ // 짧은 페이드 한쪽 길이 — 경계 양쪽 합쳐 0.3초
168
+ const FADE = 0.15;
169
+
167
170
  /**
168
171
  * 장면 경계를 걸치는 덮기 — out 은 장면 끝에서 덮고, in 은 다음 장면 처음에 걷힌다. 다 덮인 채 서 있는 시간은 0.05초 안팎 —
169
172
  * 한 색 화면이 0.1초를 넘으면 멈춤 검사(frame.freeze)에 걸린다. 조리개는 끝까지 닫지 않고 바늘구멍에서 되돌아 연다. 두 장면이 경계의 영상 시각을 같은 씨앗으로 써서
@@ -181,6 +184,16 @@ export function dressWipe(name: string, el: HTMLElement, ctx: SceneCtx, side: "i
181
184
  case "irisBlades":
182
185
  // 다 열린 육각 구멍이 화면 모서리까지 덮게 — 변까지 거리가 대각선 반을 넘어야 모서리에 날이 안 남는다
183
186
  return irisBlades(el, { w, h, color: ink, seam: "rgba(255,255,255,.3)", radius: (Math.hypot(w, h) / 2 / Math.cos(Math.PI / 6)) * 1.04, stiffness: 260, damping: 32, steps: [{ at: B - 100, open: 1 }, { at: B - 0.32, open: 0 }, { at: B - 0.02, open: 1 }], scene: ctx.id });
187
+ case "fade": {
188
+ // 바탕색 막이 0.15초 짙어졌다 걷힌다 — 끝까지 덮지 않아 한 색 화면이 서지 않는다
189
+ const veil = div(el.ownerDocument, `position:absolute;inset:0;background:${color(ctx, "bg", "#000000")};opacity:0`);
190
+ el.append(veil);
191
+ return (t) => {
192
+ const k = side === "out" ? clamp((t - (B - FADE)) / FADE) : 1 - clamp(t / FADE);
193
+ veil.style.opacity = (0.9 * k).toFixed(3);
194
+ mark("fade", ctx.id, k > 0);
195
+ };
196
+ }
184
197
  case "flood": {
185
198
  const zero = { x: w / 2, y: h / 2, w: 0, h: 0, radius: 0 };
186
199
  return flood(el, { w, h, x: w / 2, y: h / 2, color: accent, steps: [{ at: B - 100, shape: zero }, { at: B - 0.28, shape: "cover" }, { at: B - 0.02, shape: zero }], scene: ctx.id });
@@ -27,6 +27,7 @@ export const MENU: readonly MenuItem[] = [
27
27
  { name: "neonSign", kind: "text", does: "네온 관 글자가 시동 걸리듯 깜빡이다 켜진다", example: "neonSign" },
28
28
  { name: "ledBoard", kind: "text", does: "LED 점 글판에 한 칸씩 찍힌다", example: "ledBoard" },
29
29
  { name: "none", kind: "wipe", does: "덮지 않고 바로 컷" },
30
+ { name: "fade", kind: "wipe", does: "바탕색으로 잠깐 옅어졌다 돌아온다(경계 양쪽 0.15초씩) — 큰 면이 화면을 쓸지 않는 조용한 경계" },
30
31
  { name: "brushWipe", kind: "wipe", does: "붓 획 띠가 번갈아 덮었다가 꼬리가 빠지며 걷힌다", example: "brushWipe" },
31
32
  { name: "irisBlades", kind: "wipe", does: "여섯 날 조리개가 닫혔다 열린다", example: "irisBlades" },
32
33
  { name: "flood", kind: "wipe", does: "가운데 점이 화면을 넘치게 덮었다가 오므라든다", example: "flood" },
@@ -51,7 +52,7 @@ const BASE: Required<Pool> = { bg: ["still"], text: ["bigWord", "echoStack", "sp
51
52
  // 테마마다 비유에 맞는 수 — 테마를 고르면 용도 묶음보다 앞에 둔다(테마 효과와 결이 같은 것만)
52
53
  export const THEME_POOLS: Readonly<Record<string, Pool>> = {
53
54
  "debug-hud": { text: ["splitFlap", "ledBoard", "ghostStack"], wipe: ["irisBlades", "flood"] },
54
- "og-measure": { text: ["splitFlap", "ghostStack", "echoStack"], wipe: ["irisBlades", "flood"] },
55
+ "og-measure": { text: ["splitFlap", "ghostStack", "echoStack"], wipe: ["fade"] },
55
56
  "kasaterm-cells": { text: ["ledBoard", "splitFlap", "slamWords"], wipe: ["flood", "irisBlades"] },
56
57
  "kawaii-sticker": { text: ["ransomLetters", "slamWords", "echoStack"], wipe: ["brushWipe", "flood"] },
57
58
  "riso-zine": { text: ["ransomLetters", "echoStack", "slamWords"], wipe: ["brushWipe", "flood"] },
@@ -62,21 +63,26 @@ export const THEME_POOLS: Readonly<Record<string, Pool>> = {
62
63
 
63
64
  const known = (kind: DressKind) => (n: string) => menuItem(n)?.kind === kind;
64
65
 
65
- /** 틀 묶음 + 테마 묶음 → 한 벌. 테마 수를 앞에, 메뉴에 없는 이름은 버린다 */
66
- export function bundleFor(tpl?: Bundle, theme?: string): Required<Bundle> {
66
+ /**
67
+ * 틀 묶음 + 테마 묶음 → 한 벌. 테마 수를 앞에, 메뉴에 없는 이름은 버린다.
68
+ * allow 는 테마가 허용한 경계 전환(theme.json transitions) — 주면 덮기는 그 안에서만, 남는 게 없으면 컷(none)
69
+ */
70
+ export function bundleFor(tpl?: Bundle, theme?: string, allow?: readonly string[] | null): Required<Bundle> {
67
71
  // 변주 이름(<세계>/<변주>)이면 그 세계 묶음
68
72
  const th = theme ? THEME_POOLS[theme.split("/")[0]] : undefined;
69
73
  const merge = (kind: DressKind, ...lists: (string[] | undefined)[]) => [...new Set(lists.flatMap((l) => l ?? []))].filter(known(kind));
74
+ const allowed = (list: string[]) => (allow ? list.filter((n) => allow.includes(n)) : list);
70
75
  const roles: Record<string, Pool> = {};
71
76
  for (const [role, p] of Object.entries(tpl?.roles ?? {})) {
72
77
  // 역할 몫은 틀이 이유를 대고 고른 것(훅은 첫 프레임부터 꽉 차는 글) — 테마 수는 그 뒤에 섞지 않는다
73
- roles[role] = { bg: p.bg?.filter(known("bg")), text: p.text?.filter(known("text")), wipe: p.wipe?.filter(known("wipe")) };
78
+ roles[role] = { bg: p.bg?.filter(known("bg")), text: p.text?.filter(known("text")), wipe: p.wipe && allowed(p.wipe.filter(known("wipe"))) };
74
79
  }
80
+ const wipe = allowed(merge("wipe", th?.wipe, tpl?.wipe ?? BASE.wipe));
75
81
  const bg = merge("bg", th?.bg, tpl?.bg ?? BASE.bg);
76
82
  return {
77
83
  bg: bg.length ? bg : BASE.bg,
78
84
  text: merge("text", th?.text, tpl?.text ?? BASE.text),
79
- wipe: merge("wipe", th?.wipe, tpl?.wipe ?? BASE.wipe),
85
+ wipe: wipe.length || !allow ? wipe : ["none"],
80
86
  roles,
81
87
  };
82
88
  }
@@ -1,4 +1,4 @@
1
- import { clamp, emit, progress, toEase, type Ease, type EaseName, type Style, safeTime } from "@kasaframe/core";
1
+ import { clamp, cue, emit, progress, toEase, type Ease, type EaseName, type Style, safeTime } from "@kasaframe/core";
2
2
 
3
3
  export type TransitionName = "whip" | "zoomThrough" | "slide" | "matchCut" | "crossfade";
4
4
 
@@ -43,6 +43,8 @@ export function transition(name: TransitionName, t: number, opts: TransitionOpts
43
43
  const sx = dir === "left" ? -1 : dir === "right" ? 1 : 0;
44
44
  const sy = dir === "up" ? -1 : dir === "down" ? 1 : 0;
45
45
  if (opts.emit !== false && raw > 0 && raw < 1) emit({ kind: "move", source: opts.source ?? name, scene: opts.scene, name: `transition:${name}`, value: 1 });
46
+ // 끊어 잇기(matchCut)·겹쳐 바꾸기(crossfade)는 소리 없이 — 휙 지나가는 것만
47
+ if (opts.emit !== false && name !== "matchCut" && name !== "crossfade") cue("whoosh", t - opts.at, { source: opts.source ?? name, scene: opts.scene });
46
48
  const both = raw > 0 && raw < 1;
47
49
  switch (name) {
48
50
  case "whip": {
@@ -1,5 +1,5 @@
1
1
  import { onSeek } from "@kasaframe/adapters";
2
- import { clamp, emit, frameSample, rand, safeTime, type BeatGrid, type KfStyle, type SceneCtx } from "@kasaframe/core";
2
+ import { clamp, cue, emit, frameSample, rand, safeTime, type BeatGrid, type KfStyle, type SceneCtx } from "@kasaframe/core";
3
3
  import { FIT, TRANSITIONS_3D, arrivalsOn, durOf, hump, rawProgress, resolveTiming, type FoldStyle, type Timing, type TimingOpts, type Transition3dName, type WindowRect } from "./rig.ts";
4
4
  import { camera, inPaint, makeScreen, placeholder, sharedRenderer, NO_FX, type CamFx, type Kit, type KindRun, type Palette, type Screen, type Source } from "./world.ts";
5
5
  import { liveNeighbors, type LiveProject } from "./live.ts";
@@ -206,6 +206,8 @@ export function transition3d(root: HTMLElement, ctxOrOpts?: SceneCtx | Transitio
206
206
  paint(t);
207
207
  const u = rawProgress(t, timing);
208
208
  if (o.emit !== false && u > 0 && u < 1) emit({ kind: "move", source, scene, name: `transition3d:${kind}`, value: 1 });
209
+ // 넘어가기 시작하는 순간(화면이 여럿이면 화면마다)에 휙 — 장면 앞 한 프레임에서 시작하는 기본 구간은 장면 시작으로
210
+ if (o.emit !== false) for (const a of arrivals.length > 1 ? arrivals.slice(0, -1) : [timing.at]) cue("whoosh", t - Math.max(0, a), { source: `${source}:${Math.max(0, a).toFixed(3)}`, scene });
209
211
  }) as Transition3dDraw;
210
212
  // seek 의 그리기 단계 뒤 — 영상은 엔진이 시각을 맞춘 뒤, DOM 은 이번 DOM 으로 찍힌 스냅숏(paint 이벤트) 안에서 올리고 같은 t 로 다시 그린다.
211
213
  // React 장면은 첫 seek 에서야 지어져 페이지 ready 가 그림을 못 기다리므로 여기서 받는다
@@ -1,4 +1,4 @@
1
- import { createBeats, silentBeats, type SceneCtx } from "@kasaframe/core";
1
+ import { createBeats, silentBeats, withoutSfx, type SceneCtx } from "@kasaframe/core";
2
2
  import type { KfProjectJson, KfSceneEntry, SceneModule } from "@kasaframe/adapters";
3
3
  import { reactScene } from "@kasaframe/adapters/react";
4
4
  import type { LiveDom, Source } from "./world.ts";
@@ -42,9 +42,11 @@ export function sceneSource(mod: SceneModule, ctx: SceneCtx, entry: Pick<KfScene
42
42
  from: entry.from,
43
43
  dur: entry.dur,
44
44
  beats: ctx.beats?.data ? createBeats(ctx.beats.data, entry.from) : silentBeats(entry.dur),
45
+ sfx: () => {},
45
46
  };
46
- if (mod.react) return reactScene(box, mod.react, sub);
47
- return mod.build?.(box, sub) ?? undefined;
47
+ // 한 벌 더 그린 장면은 소리를 안 낸다 — 진짜 장면이 이미 남겼다
48
+ const draw = mod.react ? reactScene(box, mod.react, sub) : mod.build?.(box, sub);
49
+ return draw ? (t: number) => withoutSfx(() => draw(t)) : undefined;
48
50
  },
49
51
  time,
50
52
  still,
@@ -1,6 +1,6 @@
1
1
  import { applyStyle, clamp, combine } from "@kasaframe/core";
2
2
  import { fitSize, textEm } from "@kasaframe/motion/moves";
3
- import { area, beatStops, decimalsOf, factRoll, glider, node, slotsOf, spaced, moving } from "../kit.ts";
3
+ import { area, decimalsOf, factRoll, glider, moving, node, slotsOf, spaced } from "../kit.ts";
4
4
  import { cssColor, objectFor } from "../objects.ts";
5
5
  import type { Pattern } from "../types.ts";
6
6
 
@@ -12,13 +12,13 @@ export interface BarsContent {
12
12
  }
13
13
 
14
14
  /**
15
- * 막대가 순위대로 박마다 한 줄씩 자라고 값이 같은 진행도로 굴러 오른다(막대 길이 = 값). 다 자라면 짚개가 강조 줄과 다른 줄을 번갈아 짚는다.
15
+ * 막대가 순위대로 박마다 한 줄씩 자라고 값이 같은 진행도로 굴러 오른다(막대 길이 = 값). 다 자라면 짚개가 강조 줄부터 짚는다(머무는 테마는 목소리가 다른 줄 이름을 말할 때만 그리로).
16
16
  * 단위·이름이 객체 사전의 막대 물체면(지연 ms) 그 색·모서리. 자라는 시각을 장면 끝까지 펴서 쉬지 않는 몫을 막대가 진다.
17
17
  */
18
18
  const pattern: Pattern<BarsContent> = {
19
19
  id: "bars.rank",
20
20
  type: "bars",
21
- does: "막대가 순위대로 박마다 자라고 값이 굴러 오른다, 다 자라면 짚개가 강조 줄과 다른 줄을 번갈아 짚는다",
21
+ does: "막대가 순위대로 박마다 자라고 값이 굴러 오른다, 다 자라면 짚개가 강조 줄부터 짚는다",
22
22
  energy: ["mid"],
23
23
  family: "grow",
24
24
  camera: ["none"],
@@ -93,10 +93,9 @@ const pattern: Pattern<BarsContent> = {
93
93
  });
94
94
  const hotLine = lines[Math.max(0, hot)];
95
95
  kit.stage?.hero(hotLine.val);
96
- // 다 자라면 짚개가 강조 줄 ↔ 다른 줄을 박마다 번갈아 짚는다 — 강조 줄과 견주는 몫
97
- const point = glider(host, [hotLine.row, ...lines.filter((L) => L !== hotLine).map((L) => L.row)], { hub: true, pad: 0.04 });
96
+ // 다 자라면 짚개가 강조 줄부터 짚는다 — 머무는 테마는 목소리가 다른 줄 이름을 말할 때만 그리로
97
+ const point = glider(host, [hotLine, ...lines.filter((L) => L !== hotLine)].map((L) => ({ el: L.row, text: L.r.label })), { kit, ctx, from: hotAt - 0.1, pad: 0.04 });
98
98
  const hotLand = slots.land(hotLine.val, { ctx, at: hotAt, seed: `${kit.seed}:hot` });
99
- const stops = beatStops(kit.hits, hotAt - 0.1, ctx.dur);
100
99
 
101
100
  return (t: number) => {
102
101
  moving("bars.rank", ctx);
@@ -111,7 +110,7 @@ const pattern: Pattern<BarsContent> = {
111
110
  L.val.style.left = `${labelW + trackW * (L.r.value / max) * roll.p + 16 * u}px`;
112
111
  applyStyle(L.val, L.i === hotLine.i ? combine(L.landed(t), hotLand(t)) : L.landed(t));
113
112
  }
114
- point(t, stops);
113
+ point(t);
115
114
  };
116
115
  },
117
116
  };
@@ -1,6 +1,6 @@
1
1
  import { applyStyle, clamp, spring } from "@kasaframe/core";
2
2
  import { fitSize } from "@kasaframe/motion/moves";
3
- import { ACCENT_TEXT, area, beatStops, decimalsOf, fmt, glider, moving, node, slotsOf } from "../kit.ts";
3
+ import { ACCENT_TEXT, area, decimalsOf, fmt, glider, moving, node, slotsOf } from "../kit.ts";
4
4
  import { cssColor, objectFor } from "../objects.ts";
5
5
  import type { BeforeAfter } from "./strike.ts";
6
6
  import type { Pattern } from "../types.ts";
@@ -92,8 +92,7 @@ const pattern: Pattern<BeforeAfter> = {
92
92
  const moveFrom = fillEnd + 0.15;
93
93
  const moveEnd = moveFrom + (count - 1) / rate;
94
94
  const landedAt = moveEnd + 0.05;
95
- const stops = beatStops(kit.hits, landedAt + 0.1, ctx.dur);
96
- const point = glider(host, [cells[0], label, val], { hub: true });
95
+ const point = glider(host, [{ el: cells[0] }, { el: label, text: after.label }, { el: val, text: fmt(after.value!, dec) }], { kit, ctx, from: landedAt + 0.1 });
97
96
  const writeA = slots.write(label, { ctx, text: before.label, at: 0, dur: 0.3, seed: `${kit.seed}:a` });
98
97
  const landed = slots.land(cells[0], { ctx, at: landedAt, seed: `${kit.seed}:land` });
99
98
 
@@ -119,7 +118,7 @@ const pattern: Pattern<BeforeAfter> = {
119
118
  // 라벨은 전 → 후로 바뀐다
120
119
  if (t >= landedAt) label.textContent = after.label;
121
120
  else writeA(t);
122
- point(t, stops);
121
+ point(t);
123
122
  };
124
123
  },
125
124
  };
@@ -1,6 +1,6 @@
1
1
  import { applyStyle, clamp, combine, ease, progress, spring } from "@kasaframe/core";
2
2
  import { textEm } from "@kasaframe/motion/moves";
3
- import { ACCENT_TEXT, area, beatStops, cameraOk, decimalsOf, factRoll, glider, node, slotsOf, moving } from "../kit.ts";
3
+ import { ACCENT_TEXT, area, cameraOk, decimalsOf, factRoll, glider, moving, node, slotsOf } from "../kit.ts";
4
4
  import type { Pattern } from "../types.ts";
5
5
 
6
6
  interface Side {
@@ -92,9 +92,8 @@ const pattern: Pattern<BeforeAfter> = {
92
92
  const writeChange = change ? slots.write(change, { ctx, text: c.change!, at: changeAt, dur: 0.3, seed: `${kit.seed}:ch` }) : null;
93
93
  const landed = slots.land(afterNum, { ctx, at: land, seed: `${kit.seed}:land` });
94
94
  if (cameraOk(ctx)) kit.stage?.camera.pushIn(land, { to: afterBox, scale: 1.1, dur: Math.max(0.6, ctx.dur - land) });
95
- // 앉은 뒤엔 짚개가 후 값 ↔ 바뀐 정도·전 값을 박마다 번갈아 짚는다
96
- const point = glider(host, [afterBox, ...(change ? [change] : []), beforeBox], { hub: true });
97
- const stops = beatStops(kit.hits, land + 0.1, ctx.dur);
95
+ // 앉으면 짚개가 후 값부터 짚는다 — 머무는 테마는 목소리가 바뀐 정도·전 값을 말할 때만 그리로
96
+ const point = glider(host, [{ el: afterBox, text: afterText }, ...(change ? [{ el: change, text: c.change }] : []), { el: beforeBox, text: beforeText }], { kit, ctx, from: land + 0.1 });
98
97
 
99
98
  return (t: number) => {
100
99
  moving("before-after.strike", ctx);
@@ -116,7 +115,7 @@ const pattern: Pattern<BeforeAfter> = {
116
115
  afterNum.textContent = shown;
117
116
  // 굴리는 동안 긴 글은 같은 폭에 줄여 넣는다 — 상자가 출렁이지 않게
118
117
  afterNum.style.fontSize = `${Math.round(bigPx * Math.min(1, textEm(afterText) / textEm(shown)))}px`;
119
- point(t, stops);
118
+ point(t);
120
119
  applyStyle(afterNum, combine({ y: (1 - ease.outExpo(progress(t, openAt, 0.35))) * bigPx * 1.1 }, landed(t)));
121
120
  };
122
121
  },
@@ -1,6 +1,6 @@
1
1
  import { clamp, spring, applyStyle } from "@kasaframe/core";
2
2
  import { typeText } from "@kasaframe/motion";
3
- import { area, beatStops, glider, moving, node, slotsOf } from "../kit.ts";
3
+ import { area, beatStops, dwells, glider, markOf, moving, node, readOver, reread, slotsOf } from "../kit.ts";
4
4
  import type { Pattern } from "../types.ts";
5
5
 
6
6
  export interface CodeContent {
@@ -22,13 +22,13 @@ const rowsOf = (c: CodeContent): Row[] => {
22
22
  };
23
23
 
24
24
  /**
25
- * 명령·코드 — 창 판 안에서 명령은 자모 단위로 쳐지고(블록 커서가 끝을 따라간다), 출력·코드 줄은 흘러 찍힌다. 지금 줄 뒤엔 강조색 칸이 글과 함께 늘어난다.
26
- * 다 서면 짚개가 마지막 줄(결과)과 다른 줄을 박마다 번갈아 짚는다. 치는 빠르기는 박아 둔다(명령 초당 36타·출력 90자) — 장면이 길다고 느리게 치지 않는다.
25
+ * 명령·코드 — 창 판 안에서 명령은 자모 단위로 쳐지고(블록 커서가 끝을 따라간다), 출력·코드 줄은 흘러 찍힌다. 지금 줄 뒤엔 짚는 칸이 글과 함께 늘어난다.
26
+ * 다 서면 줄이 둘 넘을 때 짚개가 마지막 줄(결과)부터 짚는다(머무는 테마는 목소리가 다른 줄을 말할 때만 그리로). 치는 빠르기는 박아 둔다(명령 초당 36타·출력 90자) — 장면이 길다고 느리게 치지 않는다.
27
27
  */
28
28
  const pattern: Pattern<CodeContent> = {
29
29
  id: "code.terminal",
30
30
  type: "code",
31
- does: "창 판 안에서 명령이 쳐지고 출력이 흘러 찍히며 지금 줄이 짚이고, 다 서면 짚개가 결과 줄과 다른 줄을 번갈아 짚는다",
31
+ does: "창 판 안에서 명령이 쳐지고 출력이 흘러 찍히며 지금 줄이 짚이고, 다 서면 짚개가 결과 줄부터 짚는다",
32
32
  energy: ["mid"],
33
33
  family: "type",
34
34
  camera: ["none"],
@@ -72,7 +72,9 @@ const pattern: Pattern<CodeContent> = {
72
72
  const text = node(line, "display:inline");
73
73
  return { r, line, prompt, text };
74
74
  });
75
- const cursor = node(body, `position:absolute;left:0;top:0;width:0.6em;height:1.2em;background:var(--kf-on-accent);visibility:hidden`);
75
+ const mark = markOf(kit, ctx);
76
+ // 블록 커서는 지금 줄 칸 위에 선다 — 테마 짚음 칸이 옅으면 칸 위 글색(onAccent)이 바탕에 묻혀 강조색으로
77
+ const cursor = node(body, `position:absolute;left:0;top:0;width:0.6em;height:1.2em;background:${mark ? "var(--kf-accent)" : "var(--kf-on-accent)"};visibility:hidden`);
76
78
 
77
79
  // 시각 — 명령은 초당 36타로 쳐지고, 출력·코드 줄은 초당 90자로 흘러 찍힌다. 줄 사이는 0.04초 — 박을 기다리면 그 사이가 멈춘 화면이다
78
80
  const cps = (r: Row) => (r.typed ? 36 : 90);
@@ -84,11 +86,16 @@ const pattern: Pattern<CodeContent> = {
84
86
  }
85
87
  const doneAt = at;
86
88
  const result = lines[lines.length - 1];
87
- const point = glider(body, [result.line, ...lines.slice(0, -1).map((l) => l.line)], { hub: true, pad: 0.15 });
88
- // 지금 줄 — 치거나 찍히는 줄 뒤에 강조색 칸이 붙어 글과 함께 늘어난다(작은 글 치기만으로는 화면이 바뀐 것으로 안 읽힌다)
89
- const nowBar = node(body, "position:absolute;left:0;top:0;background:var(--kf-accent);z-index:-1;visibility:hidden");
89
+ const point = glider(body, [result, ...lines.slice(0, -1)].map((l) => ({ el: l.line, text: l.r.text })), { kit, ctx, from: doneAt, pad: 0.15 });
90
+ // 한 줄뿐이고 훑는 테마면 짚개 대신 다 친 그 줄을 박마다 다시 짚는다
91
+ const again = lines.length < 2 && !dwells(kit, ctx) ? readOver(result.line, `${result.r.typed ? "$ " : ""}${result.r.text}`, ctx) : null;
92
+ const againStops = again ? beatStops(kit.hits, doneAt, ctx.dur) : [];
93
+ // 지금 줄 — 치거나 찍히는 줄 뒤에 짚는 칸이 붙어 글과 함께 늘어난다(작은 글 치기만으로는 화면이 바뀐 것으로 안 읽힌다). 칸 모양·줄 옮김은 테마 짚음
94
+ const nowBar = node(body, `position:absolute;left:0;top:0;z-index:-1;visibility:hidden${mark ? "" : ";background:var(--kf-accent)"}`);
95
+ const nowMark = mark?.(nowBar) ?? null;
96
+ const nowInk = nowMark ? nowMark.ink : "var(--kf-on-accent)";
97
+ const lineBox = (l: HTMLElement, extra = 0) => ({ x: -px * 0.25, y: l.offsetTop - px * 0.1, w: l.offsetWidth + px * 0.5 + extra, h: l.offsetHeight + px * 0.2 });
90
98
  body.style.isolation = "isolate";
91
- const stops = beatStops(kit.hits, doneAt, ctx.dur);
92
99
  const lands = lines.map((l, i) => slots.land(l.line, { ctx, at: starts[i], seed: `${kit.seed}:l${i}` }));
93
100
 
94
101
  return (t: number) => {
@@ -111,21 +118,23 @@ const pattern: Pattern<CodeContent> = {
111
118
  nowBar.style.visibility = live ? "visible" : "hidden";
112
119
  lines.forEach((l) => {
113
120
  const on = l === live;
114
- l.text.style.color = on ? "var(--kf-on-accent)" : "";
115
- if (l.prompt) l.prompt.style.color = on ? "var(--kf-on-accent)" : "var(--kf-accent-text, var(--kf-accent))";
121
+ l.text.style.color = on && nowInk ? nowInk : "";
122
+ if (l.prompt) l.prompt.style.color = on && nowInk ? nowInk : "var(--kf-accent-text, var(--kf-accent))";
116
123
  });
117
124
  if (live) {
118
- const extra = live.r.typed && cur ? px * 0.7 : 0;
119
- nowBar.style.transform = `translate(${(-px * 0.25).toFixed(1)}px, ${(live.line.offsetTop - px * 0.1).toFixed(1)}px)`;
120
- nowBar.style.width = `${(live.line.offsetWidth + px * 0.5 + extra).toFixed(1)}px`;
121
- nowBar.style.height = `${(live.line.offsetHeight + px * 0.2).toFixed(1)}px`;
125
+ const box = lineBox(live.line, live.r.typed && cur ? px * 0.7 : 0);
126
+ const b = nowMark ? nowMark.at(k > 0 ? lineBox(lines[k - 1].line) : null, box, clamp((t - starts[k]) / 0.3)) : box;
127
+ nowBar.style.transform = `translate(${b.x.toFixed(1)}px, ${b.y.toFixed(1)}px)`;
128
+ nowBar.style.width = `${b.w.toFixed(1)}px`;
129
+ nowBar.style.height = `${b.h.toFixed(1)}px`;
122
130
  }
123
131
  // 블록 커서는 치는 줄의 끝을 따라간다 — 다 치면 결과 줄 끝에서 짚개에 자리를 내준다
124
132
  const c2 = cur as { line: HTMLElement; chars: number } | null;
125
133
  cursor.style.visibility = c2 && t < doneAt ? "visible" : "hidden";
126
134
  // 접힌 줄은 몇째 줄 몇째 칸인지로 센다(고정폭이라 한 줄 = wrap 칸)
127
135
  if (c2) cursor.style.transform = `translate(${((c2.chars % wrap) * 0.6).toFixed(2)}em, ${(c2.line.offsetTop / px + Math.floor(c2.chars / wrap) * 1.45 + 0.12).toFixed(3)}em)`;
128
- point(t, stops);
136
+ point(t);
137
+ again?.(reread(t, againStops));
129
138
  };
130
139
  },
131
140
  };