kasaframe 0.1.5 → 0.1.6
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.
- package/NOTICE +13 -0
- package/README.md +2 -2
- package/docs/AGENTS.md +1 -0
- package/package.json +1 -1
- package/packages/adapters/src/project.ts +2 -0
- package/packages/check/package.json +1 -0
- package/packages/check/src/check.ts +8 -2
- package/packages/check/src/rules/frames.ts +4 -5
- package/packages/cli/src/commands/make.ts +15 -5
- package/packages/cli/src/commands/render.ts +34 -7
- package/packages/cli/src/mix.ts +66 -4
- package/packages/cli/src/segments.ts +1 -1
- package/packages/cli/src/sfx.ts +429 -0
- package/packages/core/src/frame.ts +7 -4
- package/packages/core/src/index.ts +3 -1
- package/packages/core/src/project.ts +19 -0
- package/packages/core/src/sfx.ts +70 -0
- package/packages/director/src/assemble.ts +7 -4
- package/packages/director/src/index.ts +1 -1
- package/packages/director/src/pattern.ts +2 -0
- package/packages/director/src/scene.ts +14 -5
- package/packages/director/src/transitions.ts +21 -8
- package/packages/motion/src/enter.ts +7 -1
- package/packages/motion/src/impact.ts +5 -2
- package/packages/motion/src/moves/dress.ts +15 -2
- package/packages/motion/src/moves/menu.ts +11 -5
- package/packages/motion/src/transition.ts +3 -1
- package/packages/motion/src/transition3d/index.ts +3 -1
- package/packages/motion/src/transition3d/live.ts +5 -3
- package/packages/patterns/src/bars/rank.ts +6 -7
- package/packages/patterns/src/before-after/shrink.ts +3 -4
- package/packages/patterns/src/before-after/strike.ts +4 -5
- package/packages/patterns/src/code/terminal.ts +25 -16
- package/packages/patterns/src/end/lockup.ts +13 -10
- package/packages/patterns/src/end/slam.ts +7 -5
- package/packages/patterns/src/hook-number/counter.ts +15 -9
- package/packages/patterns/src/hook-number/slam.ts +6 -8
- package/packages/patterns/src/kit.ts +281 -58
- package/packages/patterns/src/limit/gauge.ts +16 -11
- package/packages/patterns/src/quote/karaoke.ts +17 -12
- package/packages/patterns/src/sfx.ts +80 -0
- package/packages/patterns/src/term-reveal/chips.ts +24 -10
- package/packages/patterns/src/term-reveal/expand.ts +17 -13
- package/packages/patterns/src/title/mask.ts +9 -8
- package/packages/patterns/src/title/slam.ts +1 -1
- package/packages/patterns/src/types.ts +1 -0
- package/packages/plan/src/brief.ts +5 -2
- package/packages/themes/src/index.ts +22 -0
- package/packages/themes/src/slots.ts +32 -2
- package/packages/themes/src/types.ts +28 -0
- package/sfx/README.md +82 -0
- package/sfx/click/appear.wav +0 -0
- package/sfx/click/burst.wav +0 -0
- package/sfx/click/drop.wav +0 -0
- package/sfx/click/end.wav +0 -0
- package/sfx/click/hop.wav +0 -0
- package/sfx/click/land.wav +0 -0
- package/sfx/click/select.wav +0 -0
- package/sfx/click/stick.wav +0 -0
- package/sfx/click/tick.wav +0 -0
- package/sfx/click/type.wav +0 -0
- package/sfx/click/whoosh.wav +0 -0
- package/sfx/pop/appear.wav +0 -0
- package/sfx/pop/burst.wav +0 -0
- package/sfx/pop/drop.wav +0 -0
- package/sfx/pop/end.wav +0 -0
- package/sfx/pop/hop.wav +0 -0
- package/sfx/pop/land.wav +0 -0
- package/sfx/pop/select.wav +0 -0
- package/sfx/pop/stick.wav +0 -0
- package/sfx/pop/tick.wav +0 -0
- package/sfx/pop/type.wav +0 -0
- package/sfx/pop/whoosh.wav +0 -0
- package/sfx/tags.json +545 -0
- package/sfx/ui/appear.wav +0 -0
- package/sfx/ui/burst.wav +0 -0
- package/sfx/ui/drop.wav +0 -0
- package/sfx/ui/end.wav +0 -0
- package/sfx/ui/hop.wav +0 -0
- package/sfx/ui/land.wav +0 -0
- package/sfx/ui/select.wav +0 -0
- package/sfx/ui/stick.wav +0 -0
- package/sfx/ui/tick.wav +0 -0
- package/sfx/ui/type.wav +0 -0
- package/sfx/ui/whoosh.wav +0 -0
- package/themes/kawaii-sticker/effects.ts +15 -3
- package/themes/kawaii-sticker/theme.json +1 -0
- package/themes/og-measure/THEME.md +10 -1
- package/themes/og-measure/effects.ts +76 -59
- package/themes/og-measure/sample/scenes/end.ts +2 -2
- package/themes/og-measure/sample/scenes/gather.ts +2 -10
- package/themes/og-measure/sample/scenes/models.ts +2 -10
- package/themes/og-measure/sample/scenes/one.ts +2 -1
- package/themes/og-measure/sample/scenes/price.ts +4 -16
- package/themes/og-measure/slots.ts +29 -0
- package/themes/og-measure/still.png +0 -0
- package/themes/og-measure/theme.json +9 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { applyStyle, clamp, spring } from "@kasaframe/core";
|
|
2
2
|
import { fitSize, textEm } from "@kasaframe/motion/moves";
|
|
3
|
-
import { ACCENT_TEXT, area,
|
|
3
|
+
import { ACCENT_TEXT, area, glider, inverseOver, markOf, moving, node, slotsOf } from "../kit.ts";
|
|
4
4
|
import type { Pattern, SpokenWord } from "../types.ts";
|
|
5
5
|
|
|
6
6
|
export interface QuoteContent {
|
|
@@ -80,14 +80,23 @@ const pattern: Pattern<QuoteContent> = {
|
|
|
80
80
|
const mark = node(col, `font-family:var(--kf-font-display);font-size:${Math.round(px * 1.4)}px;line-height:.6;height:${Math.round(px * 0.6)}px;font-weight:900;color:${ACCENT_TEXT}`, "“");
|
|
81
81
|
const body = node(col, `font-family:var(--kf-font-display);font-size:${px}px;font-weight:${ctx.style.type?.weightDisplay ?? 800};line-height:1.3;color:var(--kf-ink);word-break:keep-all`);
|
|
82
82
|
kit.stage?.hero(body);
|
|
83
|
+
const shape = markOf(kit, ctx);
|
|
83
84
|
const spans = words.map((wd, i) => {
|
|
84
85
|
const sp = body.ownerDocument.createElement("span");
|
|
85
86
|
sp.style.cssText = "position:relative;white-space:nowrap";
|
|
86
87
|
sp.textContent = wd;
|
|
87
|
-
const hi = node(sp, "position:absolute;left:-0.08em;right:-0.08em;top:0.02em;bottom:0.02em;padding:0 0.08em;background:var(--kf-accent);color:var(--kf-on-accent);white-space:nowrap;visibility:hidden", wd);
|
|
88
88
|
body.append(sp);
|
|
89
89
|
if (i < words.length - 1) body.append(body.ownerDocument.createTextNode(" "));
|
|
90
|
-
|
|
90
|
+
// 읽는 낱말 반전 칸 — 테마가 짚는 칸 모양을 정했으면 그 칸(반전 짚음)
|
|
91
|
+
if (shape) return { sp, paint: inverseOver(sp, wd, shape) };
|
|
92
|
+
const hi = node(sp, "position:absolute;left:-0.08em;right:-0.08em;top:0.02em;bottom:0.02em;padding:0 0.08em;background:var(--kf-accent);color:var(--kf-on-accent);white-space:nowrap;visibility:hidden", wd);
|
|
93
|
+
return {
|
|
94
|
+
sp,
|
|
95
|
+
paint: (k: number) => {
|
|
96
|
+
hi.style.visibility = k > 0 ? "visible" : "hidden";
|
|
97
|
+
hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
91
100
|
});
|
|
92
101
|
const whoText = [c.who, c.role].filter(Boolean).join(" · ");
|
|
93
102
|
const who = whoText ? node(col, `align-self:flex-end;font-family:var(--kf-font-sans);font-size:${whoPx}px;font-weight:600;color:var(--kf-ink-soft, var(--kf-ink));white-space:nowrap;line-height:1.3;height:1.3em`) : null;
|
|
@@ -101,24 +110,20 @@ const pattern: Pattern<QuoteContent> = {
|
|
|
101
110
|
const times = kit.say.length ? timing(words, kit.say, readFrom, ctx.dur - 0.05) : even(words, readFrom, readTo);
|
|
102
111
|
const readEnd = times[times.length - 1][1];
|
|
103
112
|
const whoAt = Math.min(Math.max(readFrom, ctx.dur - whoDur - 0.6), readEnd);
|
|
104
|
-
// 다
|
|
105
|
-
const point = glider(host, who ? [body, who] : spans.map((x) => x.sp), { hub: !!who, pad: 0.06 });
|
|
106
|
-
const stops = beatStops(kit.hits, readEnd + 0.05, ctx.dur);
|
|
113
|
+
// 다 읽으면 짚개가 문장부터 짚는다(머무는 테마는 목소리가 누가를 말할 때만 그리로). 누가가 없으면 훑는 테마만 낱말을 차례로 — 머무는 테마엔 주인공이 없다
|
|
114
|
+
const point = glider(host, who ? [{ el: body, text: c.text }, { el: who, text: whoText }] : spans.map((x) => ({ el: x.sp })), { kit, ctx, from: readEnd + 0.05, hub: !!who, pad: 0.06 });
|
|
107
115
|
const writeWho = who ? slots.write(who, { ctx, text: whoText, at: whoAt, dur: whoDur, seed: `${kit.seed}:who` }) : null;
|
|
108
116
|
|
|
109
117
|
return (t: number) => {
|
|
110
118
|
moving("quote.karaoke", ctx);
|
|
111
119
|
applyStyle(mark, { scale: spring(t, { from: 0.4, to: 1, stiffness: 260, damping: 14 }) });
|
|
120
|
+
// 짚는 동안은 낱말 반전 칸을 걷는다 — 두 강조가 겹치면 무엇을 짚는지 흐려진다
|
|
112
121
|
spans.forEach((s, i) => {
|
|
113
122
|
const [a, b] = times[i];
|
|
114
|
-
|
|
115
|
-
s.hi.style.visibility = k > 0 ? "visible" : "hidden";
|
|
116
|
-
s.hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
|
|
123
|
+
s.paint(t >= readEnd + 0.05 ? 0 : clamp((t - a) / Math.max(0.05, b - a)));
|
|
117
124
|
});
|
|
118
125
|
writeWho?.(t);
|
|
119
|
-
|
|
120
|
-
if (t >= readEnd + 0.05) spans.forEach((x) => (x.hi.style.visibility = "hidden"));
|
|
121
|
-
point(t, stops);
|
|
126
|
+
point(t);
|
|
122
127
|
};
|
|
123
128
|
},
|
|
124
129
|
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { cue } from "@kasaframe/core";
|
|
2
|
+
import type { ThemeSlots } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
// 패턴이 부르는 테마 슬롯에 소리 자리를 단다 — 테마 슬롯도 패턴 기본 슬롯도 같은 자리에서 소리가 나고, 장면·테마 코드는 그대로다
|
|
5
|
+
|
|
6
|
+
/** 글 찍기 소리 간격(초) — 글자마다 내면 1초에 열 번이 넘어 밀도 상한에 다 잘린다 */
|
|
7
|
+
const TYPE_EVERY = 0.09;
|
|
8
|
+
const TYPE_MAX = 10;
|
|
9
|
+
|
|
10
|
+
export function withSfx<S extends ThemeSlots>(s: S): S {
|
|
11
|
+
const out: ThemeSlots = { ...s };
|
|
12
|
+
if (s.panel)
|
|
13
|
+
out.panel = (el, o) => {
|
|
14
|
+
const paint = s.panel!(el, o);
|
|
15
|
+
return (t) => {
|
|
16
|
+
cue("appear", t - o.at, { source: `panel:${o.seed}@${o.at.toFixed(3)}`, scene: o.ctx.id, weight: 0.8, minor: true });
|
|
17
|
+
paint(t);
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
if (s.land)
|
|
21
|
+
out.land = (el, o) => {
|
|
22
|
+
const style = s.land!(el, o);
|
|
23
|
+
return (t) => {
|
|
24
|
+
cue("land", t - o.at, { source: `land:${o.seed}@${o.at.toFixed(3)}`, scene: o.ctx.id });
|
|
25
|
+
return style(t);
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
if (s.write)
|
|
29
|
+
out.write = (el, o) => {
|
|
30
|
+
const paint = s.write!(el, o);
|
|
31
|
+
const n = Math.min(TYPE_MAX, Math.max(1, Math.floor(o.dur / TYPE_EVERY)));
|
|
32
|
+
return (t) => {
|
|
33
|
+
const k = Math.floor((t - o.at) / TYPE_EVERY);
|
|
34
|
+
for (const j of [k - 1, k]) if (j >= 0 && j < n) cue("type", t - o.at - j * TYPE_EVERY, { source: `write:${o.seed}:${j}@${o.at.toFixed(3)}`, scene: o.ctx.id, minor: true });
|
|
35
|
+
paint(t);
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
if (s.drop)
|
|
39
|
+
out.drop = (el, o) => {
|
|
40
|
+
const burst = s.drop!(el, o);
|
|
41
|
+
const at = o.ctx.beats.drop(o.drop);
|
|
42
|
+
return (t) => {
|
|
43
|
+
cue("drop", t - at, { source: `slot-drop:${o.drop}`, scene: o.ctx.id });
|
|
44
|
+
return burst(t);
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
return out as S;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 쾅 앉는 순간 — kit slam 이 부른다 */
|
|
51
|
+
export const slamCue = (t: number, at: number): void => cue("land", t - at, { source: `slam@${at.toFixed(3)}` });
|
|
52
|
+
|
|
53
|
+
/** 짚개가 요점(핵심 값)으로 옮겨 가는 순간 딸깍 — 요점만 짚으니 잔소리가 아니다 */
|
|
54
|
+
export const keyCue = (t: number, at: number): void => cue("select", t - at, { source: `glider@${at.toFixed(3)}`, weight: 0.8 });
|
|
55
|
+
|
|
56
|
+
/** 박마다 훑는 짚개가 주인공(핵심 값)에 닿는 순간 딸깍 — 곁가지에 닿는 것은 잔소리. 첫 정지점은 짚개가 나타나는 자리라 빼고 */
|
|
57
|
+
export function stopCues(t: number, stops: readonly number[], key: (k: number) => boolean = () => true): void {
|
|
58
|
+
for (let j = 1; j < stops.length - 1; j++)
|
|
59
|
+
if (t >= stops[j] && t < stops[j] + 0.2) cue("select", t - stops[j], { source: `glider@${stops[j].toFixed(3)}`, weight: 0.8, minor: !key(j) });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 숫자 굴림이 째깍거리는 시각 — 진행도 p(x)(감속)가 k/n 을 지나는 x 에. 빨리 구르는 앞은 촘촘, 앉기 전은 띄엄띄엄(슬롯 머신처럼).
|
|
64
|
+
* p 는 단조라 이분법으로 거꾸로 푼다
|
|
65
|
+
*/
|
|
66
|
+
export function rollCues(t: number, o: { at: number; dur: number; p: (x: number) => number; n?: number }): void {
|
|
67
|
+
if (t < o.at || t > o.at + o.dur + 0.2) return;
|
|
68
|
+
const n = o.n ?? Math.max(3, Math.min(10, Math.round(o.dur / 0.12)));
|
|
69
|
+
for (let k = 1; k <= n; k++) {
|
|
70
|
+
let lo = 0;
|
|
71
|
+
let hi = 1;
|
|
72
|
+
for (let i = 0; i < 24; i++) {
|
|
73
|
+
const mid = (lo + hi) / 2;
|
|
74
|
+
if (o.p(mid) < k / n) lo = mid;
|
|
75
|
+
else hi = mid;
|
|
76
|
+
}
|
|
77
|
+
const at = o.at + hi * o.dur;
|
|
78
|
+
cue(k === n ? "land" : "tick", t - at, { source: `roll:${k}@${o.at.toFixed(3)}`, weight: k === n ? 0.8 : 0.7, minor: k !== n });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { applyStyle, clamp, spring } from "@kasaframe/core";
|
|
2
2
|
import { fitSize, textEm } from "@kasaframe/motion/moves";
|
|
3
|
-
import { ACCENT_TEXT, area, inverseOver, moving, node, slotsOf } from "../kit.ts";
|
|
3
|
+
import { ACCENT_TEXT, area, dwells, followSpoken, glider, inverseOver, markOf, moving, node, sayTimes, slotsOf } from "../kit.ts";
|
|
4
4
|
import type { TermContent } from "./expand.ts";
|
|
5
5
|
import type { Pattern } from "../types.ts";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* 용어 해부 — 약자가 큰 판 위에 서고, 풀이 낱말이 박마다 하나씩 판 아래 칩으로 톡 붙는다(첫 글자는 강조색 — 약자 글자와 같은 글자).
|
|
9
9
|
* 다 붙으면 한국어 이름이 판 아래에 찍히고, 판이 아래부터 강조색으로 차올라 장면 끝까지 그 용어를 짚는다.
|
|
10
|
+
* 테마가 짚는 칸 모양을 정했으면 판을 칠하지 않는다 — 짚개가 약자부터 짚는다(머무는 테마는 목소리가 풀이·한국어 이름을 말할 때만 그리로).
|
|
10
11
|
*/
|
|
11
12
|
const pattern: Pattern<TermContent> = {
|
|
12
13
|
id: "term-reveal.chips",
|
|
@@ -42,11 +43,15 @@ const pattern: Pattern<TermContent> = {
|
|
|
42
43
|
const col = node(host, `display:flex;flex-direction:column;align-items:center;gap:${Math.round(h * 0.035)}px`);
|
|
43
44
|
const board = node(col, `position:relative;width:${boardW}px;height:${boardH}px`);
|
|
44
45
|
const panel = slots.panel(board, { ctx, at: 0, seed: `${kit.seed}:board` });
|
|
45
|
-
const
|
|
46
|
+
const mark = markOf(kit, ctx);
|
|
47
|
+
const fill = mark ? null : node(board, "position:absolute;inset:0;background:var(--kf-accent);border-radius:var(--kf-radius-md)");
|
|
46
48
|
const face = (lit: boolean) =>
|
|
47
49
|
node(board, `position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:var(--kf-font-display);font-size:${termPx}px;font-weight:${ctx.style.type?.weightDisplay ?? 800};letter-spacing:0.04em;color:${lit ? "var(--kf-on-accent)" : "var(--kf-ink)"}`, c.term);
|
|
48
|
-
face(false);
|
|
49
|
-
const lit = face(true);
|
|
50
|
+
const plain = face(false);
|
|
51
|
+
const lit = mark ? null : face(true);
|
|
52
|
+
// 짚개가 잴 약자 글 상자 — 판 전체가 아니라 글자에 칸이 앉게
|
|
53
|
+
const termText = mark ? node(plain, "position:relative", c.term) : null;
|
|
54
|
+
if (termText) plain.firstChild!.remove();
|
|
50
55
|
kit.stage?.hero(board);
|
|
51
56
|
const row = words.length ? node(col, `display:flex;flex-direction:${vertical ? "column" : "row"};align-items:center;gap:${Math.round(chipPx * 0.35)}px;min-height:${Math.round(chipPx * 1.5)}px`) : null;
|
|
52
57
|
const chips = words.map((wd) => {
|
|
@@ -57,17 +62,25 @@ const pattern: Pattern<TermContent> = {
|
|
|
57
62
|
});
|
|
58
63
|
const koBox = c.ko ? node(col, `position:relative;font-family:var(--kf-font-sans);font-size:${koPx}px;font-weight:700;white-space:nowrap;line-height:1.3;height:1.3em`) : null;
|
|
59
64
|
const ko = koBox ? node(koBox, "color:var(--kf-ink)") : null;
|
|
60
|
-
const koMark = koBox ? inverseOver(koBox, c.ko!) : null;
|
|
65
|
+
const koMark = koBox && !mark ? inverseOver(koBox, c.ko!) : null;
|
|
66
|
+
// 짚개가 이름 자리를 찍히기 전부터 짚는다 — 빈 상자는 폭이 0 이라 칸이 점으로 줄어든다
|
|
67
|
+
if (koBox && mark) koBox.style.cssText += `;min-width:${Math.round(textEm(c.ko!) * koPx)}px;text-align:center`;
|
|
61
68
|
|
|
62
|
-
const hits = kit.hits.length ? kit.hits : [0];
|
|
63
69
|
const boardAt = kit.first ? -0.3 : 0;
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
// 머무는 짚개 테마는 풀이 칩·한국어 이름을 목소리가 그것을 말할 때 붙인다(못 들으면 판이 열린 뒤부터 장면 끝 무렵까지 박에 붙여 고르게) —
|
|
71
|
+
// 처음에 몰아 띄우면 남은 시간이 빈다. 붙을 때마다 카메라가 그쪽으로 다가간다. 훑는 테마는 0.3초 간격 박에 붙인다(판 차오름이 끝까지 간다)
|
|
72
|
+
const hits = kit.hits.length ? kit.hits : [0];
|
|
73
|
+
const times = dwells(kit, ctx) ? sayTimes(kit, [...words, c.ko], 0.12, Math.max(0.6, ctx.dur - 0.9)) : null;
|
|
74
|
+
const chipAt = words.map((_, i) => times?.[i] ?? hits.find((b) => b >= 0.12 + i * 0.3) ?? 0.2 + i * 0.3);
|
|
75
|
+
const koAt = c.ko && times ? times[words.length] : (chipAt[chipAt.length - 1] ?? 0.2) + 0.3;
|
|
76
|
+
followSpoken(kit, ctx, [...chips.map((el, i) => ({ at: chipAt[i], el })), ...(koBox ? [{ at: koAt, el: koBox }] : [])]);
|
|
66
77
|
const koDur = c.ko ? clamp([...c.ko].length * 0.05, 0.25, 0.6) : 0;
|
|
67
78
|
// 판이 열리는 것만으로는 밝은 바탕에서 화면이 바뀐 것으로 안 읽힌다 — 차오름을 곧바로 시작한다
|
|
68
79
|
const fillFrom = 0;
|
|
69
80
|
const writeKo = ko ? slots.write(ko, { ctx, text: c.ko!, at: koAt, dur: koDur, seed: `${kit.seed}:ko` }) : null;
|
|
70
81
|
const lands = chips.map((ch, i) => slots.land(ch, { ctx, at: chipAt[i], seed: `${kit.seed}:c${i}` }));
|
|
82
|
+
// 판이 열리면 짚개가 약자부터 짚고, 머무는 테마는 목소리가 풀이·한국어 이름을 말할 때만 그리로 옮긴다 — 칠한 판이 차오르던 몫
|
|
83
|
+
const point = termText ? glider(host, [{ el: termText, text: c.term }, ...(row ? [{ el: row, text: words.join(" ") }] : []), ...(koBox ? [{ el: koBox, text: c.ko }] : [])], { kit, ctx, from: boardAt + 0.2 }) : null;
|
|
71
84
|
|
|
72
85
|
return (t: number) => {
|
|
73
86
|
moving("term-reveal.chips", ctx);
|
|
@@ -81,8 +94,9 @@ const pattern: Pattern<TermContent> = {
|
|
|
81
94
|
// 판은 칩이 붙기 시작할 때부터 장면 끝까지 차오른다 — 용어를 짚는 몫
|
|
82
95
|
const k = clamp((t - fillFrom) / Math.max(0.3, ctx.dur - fillFrom - 0.05));
|
|
83
96
|
const clip = `inset(${((1 - k) * 100).toFixed(2)}% 0 0 0)`;
|
|
84
|
-
fill.style.clipPath = clip;
|
|
85
|
-
lit.style.clipPath = clip;
|
|
97
|
+
if (fill) fill.style.clipPath = clip;
|
|
98
|
+
if (lit) lit.style.clipPath = clip;
|
|
99
|
+
point?.(t);
|
|
86
100
|
writeKo?.(t);
|
|
87
101
|
koMark?.((t - koAt - koDur) / Math.max(0.2, ctx.dur - koAt - koDur - 0.05));
|
|
88
102
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { applyStyle, clamp, ease, progress } from "@kasaframe/core";
|
|
2
2
|
import { fitSize, textEm } from "@kasaframe/motion/moves";
|
|
3
|
-
import { ACCENT_TEXT, area,
|
|
3
|
+
import { ACCENT_TEXT, area, dwells, fitOnce, followSpoken, glider, moving, node, sayTimes, slotsOf } from "../kit.ts";
|
|
4
4
|
import type { Pattern } from "../types.ts";
|
|
5
5
|
|
|
6
6
|
export interface TermContent {
|
|
@@ -21,12 +21,12 @@ function spell(term: string, full: string | undefined): string[] | null {
|
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* 용어 공개 — 약자가 크게 서고, 약자 글자마다 풀이 낱말이 그 글자에서 아래로 펴져 나온다(글자 → 낱말이 같은 첫 글자로 이어진다).
|
|
24
|
-
* 풀이가 약자와 안 맞으면 풀이 한 줄이 펴진다. 그다음 한국어 이름이 찍히고, 짚개가
|
|
24
|
+
* 풀이가 약자와 안 맞으면 풀이 한 줄이 펴진다. 그다음 한국어 이름이 찍히고, 짚개가 약자부터 짚는다(머무는 테마는 목소리가 풀이 낱말·이름을 말할 때만 그리로).
|
|
25
25
|
*/
|
|
26
26
|
const pattern: Pattern<TermContent> = {
|
|
27
27
|
id: "term-reveal.expand",
|
|
28
28
|
type: "term-reveal",
|
|
29
|
-
does: "약자가 서고 글자마다 풀이 낱말이 펴진다, 한국어 이름이 찍히고 짚개가
|
|
29
|
+
does: "약자가 서고 글자마다 풀이 낱말이 펴진다, 한국어 이름이 찍히고 짚개가 약자부터 짚는다",
|
|
30
30
|
energy: ["mid"],
|
|
31
31
|
family: "mask",
|
|
32
32
|
camera: ["none"],
|
|
@@ -83,17 +83,21 @@ const pattern: Pattern<TermContent> = {
|
|
|
83
83
|
// 펴기는 약자가 서자마자 낱말마다 이어서 — 사이에 빈 틈이 있으면 화면이 선 것으로 읽힌다
|
|
84
84
|
const unfoldAt = termAt + 0.06 * Math.max(0, chars.length - 1) + 0.2;
|
|
85
85
|
const each = parts.length > 1 ? 0.3 : 0;
|
|
86
|
-
const unfoldEnd = unfoldAt + each * Math.max(0, parts.length - 1) + 0.35;
|
|
87
|
-
const koAt = parts.length ? unfoldEnd : termAt + 0.2;
|
|
88
86
|
const koDur = c.ko ? clamp([...c.ko].length * 0.05, 0.25, 0.6) : 0;
|
|
89
|
-
|
|
87
|
+
// 머무는 짚개 테마는 풀이 낱말·한국어 이름·뜻을 목소리가 그것을 말할 때 펴고 찍는다(못 들으면 약자가 선 뒤부터 장면 끝 무렵까지 박에 붙여 고르게).
|
|
88
|
+
// 펴지고 찍힐 때마다 카메라가 그쪽으로 다가간다. 훑는 테마는 약자가 서자마자 낱말마다 0.3초 간격으로 이어 편다
|
|
89
|
+
const said = dwells(kit, ctx) ? sayTimes(kit, [...parts.map((_, i) => (words ?? [fullText])[i]), c.ko, c.meaning], unfoldAt, Math.max(unfoldAt + 0.3, ctx.dur - 0.9)) : null;
|
|
90
|
+
const partAt = parts.map((_, i) => said?.[i] ?? unfoldAt + i * each);
|
|
91
|
+
const unfoldEnd = unfoldAt + each * Math.max(0, parts.length - 1) + 0.35;
|
|
92
|
+
const koAt = said && (parts.length || kit.say.length || kit.lines.length) ? said[parts.length] : parts.length ? unfoldEnd : termAt + 0.2;
|
|
93
|
+
const meanAt = Math.max(koAt + koDur, said?.[parts.length + 1] ?? 0);
|
|
94
|
+
followSpoken(kit, ctx, [...parts.map((p, i) => ({ at: partAt[i], el: p.box })), ...(ko ? [{ at: koAt, el: ko }] : []), ...(meaning ? [{ at: meanAt, el: meaning }] : [])]);
|
|
90
95
|
const meanDur = meaning ? clamp([...c.meaning!].length * 0.035, 0.4, 1.2) : 0;
|
|
91
96
|
// 짚음은 첫 낱말이 다 펴지자마자 — 펴기와 겹쳐 장면 끝까지 이어진다
|
|
92
97
|
// 풀이 낱말이 없으면(약자가 아닌 용어) 펼 것이 없다 — 용어가 서자마자 짚기 시작한다
|
|
93
|
-
const markFrom = parts.length ?
|
|
94
|
-
// 짚개 —
|
|
95
|
-
const point = glider(host, [termEl, ...parts.map((p) => p.box), ...(ko ? [ko] : [])], {
|
|
96
|
-
const stops = beatStops(kit.hits, markFrom, ctx.dur);
|
|
98
|
+
const markFrom = parts.length ? partAt[0] + Math.max(each, 0.3) : termAt + 0.06 * Math.max(0, chars.length - 1) + 0.12;
|
|
99
|
+
// 짚개 — 약자부터 짚고, 머무는 테마는 목소리가 풀이 낱말·한국어 이름(풀이·이름이 없으면 뜻)을 말할 때만 그리로 옮긴다
|
|
100
|
+
const point = glider(host, [{ el: termEl, text: c.term }, ...parts.map((p, i) => ({ el: p.box, text: (words ?? [fullText])[i] })), ...(ko ? [{ el: ko, text: c.ko }] : []), ...(meaning && !parts.length && !ko ? [{ el: meaning, text: c.meaning }] : [])], { kit, ctx, from: markFrom });
|
|
97
101
|
|
|
98
102
|
const writeKo = ko ? slots.write(ko, { ctx, text: c.ko!, at: koAt, dur: koDur, seed: `${kit.seed}:ko` }) : null;
|
|
99
103
|
const writeMeaning = meaning ? slots.write(meaning, { ctx, text: c.meaning!, at: meanAt, dur: meanDur, seed: `${kit.seed}:m` }) : null;
|
|
@@ -106,18 +110,18 @@ const pattern: Pattern<TermContent> = {
|
|
|
106
110
|
const p = ease.outExpo(progress(t, termAt + i * 0.06, 0.3));
|
|
107
111
|
applyStyle(ch, { y: (1 - p) * termPx * 0.4, opacity: p });
|
|
108
112
|
// 약자 글자는 제 낱말이 펴질 때 강조색으로 — 글자와 낱말이 이어진다
|
|
109
|
-
const k = words ? clamp((t - (
|
|
113
|
+
const k = words ? clamp((t - ((partAt[i] ?? unfoldAt))) / 0.2) : 0;
|
|
110
114
|
ch.style.color = k > 0.5 ? ACCENT_TEXT : "var(--kf-ink)";
|
|
111
115
|
});
|
|
112
116
|
applyStyle(termEl, landed(t));
|
|
113
117
|
parts.forEach((pt, i) => {
|
|
114
118
|
// 고른 빠르기로 편다 — 감속 끝자락이 길면 그 사이가 멈춘 화면이 된다
|
|
115
|
-
const p = progress(t,
|
|
119
|
+
const p = progress(t, (partAt[i] ?? unfoldAt), 0.3);
|
|
116
120
|
pt.box.style.width = `${(p * pt.em * fullPx * 1.02).toFixed(1)}px`;
|
|
117
121
|
pt.box.style.visibility = p > 0 ? "visible" : "hidden";
|
|
118
122
|
});
|
|
119
123
|
// 풀이를 낱말 차례로 반전해 짚는다 — 펴기가 끝난 뒤 장면 끝까지
|
|
120
|
-
point(t
|
|
124
|
+
point(t);
|
|
121
125
|
writeKo?.(t);
|
|
122
126
|
writeMeaning?.(t);
|
|
123
127
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { applyStyle, clamp } from "@kasaframe/core";
|
|
2
2
|
import { sweep } from "@kasaframe/motion";
|
|
3
3
|
import { fitSize } from "@kasaframe/motion/moves";
|
|
4
|
-
import { ACCENT_TEXT, area, beatStops, cameraOk, glider, node, riseName, slotsOf
|
|
4
|
+
import { ACCENT_TEXT, area, beatStops, cameraOk, dwells, glider, moving, node, reread, riseName, slotsOf } from "../kit.ts";
|
|
5
5
|
import type { Pattern } from "../types.ts";
|
|
6
6
|
|
|
7
7
|
export interface TitleContent {
|
|
@@ -12,12 +12,12 @@ export interface TitleContent {
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* 이름이 글자마다 아래에서 가림막을 뚫고 올라와 서고(드러냄), 카메라가 그 이름으로 한 번 다가간다 — 다가감의 이유가 드러냄이다.
|
|
15
|
-
* 다가가는 동안 밑줄·부제가 서고, 짚개가
|
|
15
|
+
* 다가가는 동안 밑줄·부제가 서고, 작은 글이 있으면 짚개가 이름부터 짚는다(머무는 테마는 목소리가 작은 글을 말할 때만 그리로).
|
|
16
16
|
*/
|
|
17
17
|
const pattern: Pattern<TitleContent> = {
|
|
18
18
|
id: "title.mask",
|
|
19
19
|
type: "title",
|
|
20
|
-
does: "이름이 글자마다 가림막을 뚫고 서고, 다가가는 동안 짚개가
|
|
20
|
+
does: "이름이 글자마다 가림막을 뚫고 서고, 다가가는 동안 짚개가 이름부터 짚는다",
|
|
21
21
|
energy: ["mid"],
|
|
22
22
|
family: "mask",
|
|
23
23
|
camera: ["pushIn"],
|
|
@@ -61,17 +61,18 @@ const pattern: Pattern<TitleContent> = {
|
|
|
61
61
|
const subAt = sweepAt + 0.3;
|
|
62
62
|
const subDur = Math.max(0.3, ctx.dur - subAt - 0.08);
|
|
63
63
|
if (cameraOk(ctx)) kit.stage?.camera.pushIn(settle, { to: name, scale: 1.07, dur: Math.max(0.6, ctx.dur - settle) });
|
|
64
|
-
// 이름이
|
|
65
|
-
const
|
|
66
|
-
const
|
|
64
|
+
// 이름이 서면 짚개가 이름과 위 작은 글·부제를 짚는다(짚기 방식은 테마 짚음). 작은 글이 없고 훑는 테마면 이름 글자를 박마다 다시 짚는다
|
|
65
|
+
const markFrom = Math.min(settle, nameAt + 0.35);
|
|
66
|
+
const point = glider(host, [{ el: name, text: c.title }, ...(kicker ? [{ el: kicker, text: c.kicker }] : []), ...(sub ? [{ el: sub, text: c.sub }] : [])], { kit, ctx, from: markFrom });
|
|
67
|
+
const again = !kicker && !sub && !dwells(kit, ctx) ? beatStops(kit.hits, markFrom, ctx.dur) : null;
|
|
67
68
|
const writeKicker = kicker ? slots.write(kicker, { ctx, text: c.kicker!, at: 0, dur: Math.min(0.35, nameEnd * 0.6), seed: `${kit.seed}:kicker` }) : null;
|
|
68
69
|
const writeSub = sub ? slots.write(sub, { ctx, text: c.sub!, at: subAt, dur: subDur, seed: `${kit.seed}:sub` }) : null;
|
|
69
70
|
const landed = slots.land(name, { ctx, at: nameEnd, seed: `${kit.seed}:land` });
|
|
70
71
|
|
|
71
72
|
return (t: number) => {
|
|
72
73
|
moving("title.mask", ctx);
|
|
73
|
-
paintName(t, { riseAt: nameAt, each, fill: 0 });
|
|
74
|
-
point(t
|
|
74
|
+
paintName(t, { riseAt: nameAt, each, fill: 0, up: again ? reread(t, again) : undefined });
|
|
75
|
+
point(t);
|
|
75
76
|
applyStyle(name, landed(t));
|
|
76
77
|
|
|
77
78
|
applyStyle(bar, sweep(t, { at: sweepAt, dur: 0.3 }));
|
|
@@ -7,7 +7,7 @@ const pattern: Pattern<TitleContent> = {
|
|
|
7
7
|
...endSlam,
|
|
8
8
|
id: "title.slam",
|
|
9
9
|
type: "title",
|
|
10
|
-
does: "옅은 자리표 이름이 먼저 서고 드롭에 꽉 찬 이름이 쾅 내려앉는다, 부제·작은 글이 붙고 짚개가
|
|
10
|
+
does: "옅은 자리표 이름이 먼저 서고 드롭에 꽉 찬 이름이 쾅 내려앉는다, 부제·작은 글이 붙고 짚개가 이름부터 짚는다",
|
|
11
11
|
purposes: { prefer: ["promo", "launch", "ad", "mv"] },
|
|
12
12
|
fits(c, info) {
|
|
13
13
|
if (typeof c?.title !== "string" || !c.title.trim() || [...c.title].length > 16) return "title 이 비었거나 16자를 넘는다";
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { freezeLimits } from "@kasaframe/core";
|
|
2
3
|
import { captionMode } from "@kasaframe/motion/captions";
|
|
3
4
|
import { bundleFor, MENU, type Dress } from "@kasaframe/motion/menu";
|
|
4
5
|
import { questionsMarkdown, type Question } from "@kasaframe/gen";
|
|
5
|
-
import { loadTheme } from "@kasaframe/themes";
|
|
6
|
+
import { loadTheme, themeFreezeSec } from "@kasaframe/themes";
|
|
6
7
|
import type { GenSpec, ShotSpec } from "./types.ts";
|
|
7
8
|
import { join } from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -125,6 +126,8 @@ export function projectBrief(dir: string): string {
|
|
|
125
126
|
const rules = purposeRules(kf.template);
|
|
126
127
|
const tplFile = join(repo, "templates", kf.template, "template.json");
|
|
127
128
|
const tplMotion = existsSync(tplFile) ? (JSON.parse(readFileSync(tplFile, "utf8")).motion ?? {}) : {};
|
|
129
|
+
// kf check 와 같은 기준 — 틀이 style.json 을 덮고, 테마는 오류 기준을 늘리기만 한다(늘린 만큼은 경고로 남는다)
|
|
130
|
+
const freeze = freezeLimits({ ...(style.motion ?? {}), ...tplMotion }.maxFreezeSec, themeFreezeSec(kf.theme));
|
|
128
131
|
const colors = Object.keys(style.colors ?? {}).map((k: string) => `--kf-${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`);
|
|
129
132
|
const core = signatures("core");
|
|
130
133
|
const motion = signatures("motion");
|
|
@@ -135,7 +138,7 @@ export function projectBrief(dir: string): string {
|
|
|
135
138
|
...questionsMarkdown(plan.asks ?? [], (flags) => `npx kf make --answer ${dir} ${flags}`),
|
|
136
139
|
...narrationSection(dir, kf, plan),
|
|
137
140
|
`**이 영상의 규칙 — 용도 ${kf.template}(틀이 기본값을 덮는다, kf check 가 같은 값으로 잡는다)**`,
|
|
138
|
-
`1. 화면은 ${
|
|
141
|
+
`1. 화면은 ${freeze.error}초(60fps ${Math.round(freeze.error * 60)}프레임) 넘게 멈추지 않는다${freeze.error > freeze.warn ? `(테마 ${kf.theme} 가 늘린 기준 — ${freeze.warn}초 넘는 멈춤도 경고로 남는다)` : ""} — 끝 카드·큰 숫자·로고도 예외 없이 화면 안 움직임(숫자 굴러가기·칸 채우기·반짝임·글자 흐름·바탕 흐름)으로 계속 살아 있게.`,
|
|
139
142
|
`2. 같은 구도는 ${rules.staticSec.warn}초 넘기지 않는다(${rules.staticSec.error}초 넘으면 오류) — 새 요소·칸 채우기·숫자 키우기·컷·전환으로 바꾼다.`,
|
|
140
143
|
rules.beatMove === "every-beat"
|
|
141
144
|
? "3. 미세하게 흔들리는 것만으로 때우지 않는다 — 눈에 보이는 큰 움직임이 박마다 하나 이상."
|
|
@@ -39,6 +39,9 @@ export function checkTheme(dir: string): string[] {
|
|
|
39
39
|
if (m.status === "approved" && !m.approvedAt) bad.push("approved 인데 approvedAt 이 없다");
|
|
40
40
|
if (!Array.isArray(m.purposes) || !m.purposes.length) bad.push("theme.json purposes 가 비었다");
|
|
41
41
|
if (!Array.isArray(m.aspect) || !m.aspect.length) bad.push("theme.json aspect 가 비었다");
|
|
42
|
+
if (m.transitions !== undefined && (!Array.isArray(m.transitions) || !m.transitions.length || m.transitions.some((x) => typeof x !== "string" || !x))) bad.push("theme.json transitions 는 전환 이름 배열(비우려면 칸을 지운다)");
|
|
43
|
+
const freeze = m.motion?.maxFreezeSec;
|
|
44
|
+
if (m.motion !== undefined && (typeof m.motion !== "object" || (freeze !== undefined && !(typeof freeze === "number" && freeze > 0 && freeze <= 1)))) bad.push("theme.json motion.maxFreezeSec 는 0 보다 크고 1 이하인 초(멈춤 오류 기준 — 틀·style.json 값보다 늘리기만 한다)");
|
|
42
45
|
const n = m.effects?.length ?? 0;
|
|
43
46
|
if (n < 5 || n > 10) bad.push(`효과는 5–10개 — 지금 ${n}개`);
|
|
44
47
|
for (const e of m.effects ?? []) if (!["value", "layer", "rig"].includes(e.kind)) bad.push(`효과 ${e.name} kind 는 value|layer|rig`);
|
|
@@ -150,6 +153,25 @@ export const cellPath = (id: string): string => {
|
|
|
150
153
|
return join(cellsDir, world, `${variant ?? "base"}.png`);
|
|
151
154
|
};
|
|
152
155
|
|
|
156
|
+
// 세계의 theme.json — 변주는 세계 것을 따른다. 없는 테마(npm 묶음에 안 든 것 포함)면 null
|
|
157
|
+
function worldManifest(id: string | null | undefined): Partial<ThemeManifest> | null {
|
|
158
|
+
if (!id) return null;
|
|
159
|
+
const file = join(themesDir, splitThemeId(id).world, "theme.json");
|
|
160
|
+
return existsSync(file) ? readJson<Partial<ThemeManifest>>(file) : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 테마가 허용한 장면 사이 전환(theme.json transitions). 정하지 않았거나 없는 테마면 null(다 허용) */
|
|
164
|
+
export function themeTransitions(id: string | null | undefined): string[] | null {
|
|
165
|
+
const t = worldManifest(id)?.transitions;
|
|
166
|
+
return Array.isArray(t) ? t : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 테마가 정한 멈춤 오류 기준(theme.json motion.maxFreezeSec, 초) — 쓰는 쪽은 freezeLimits 로 틀·style.json 값과 견준다. 없으면 null */
|
|
170
|
+
export function themeFreezeSec(id: string | null | undefined): number | null {
|
|
171
|
+
const v = worldManifest(id)?.motion?.maxFreezeSec;
|
|
172
|
+
return typeof v === "number" && v > 0 ? v : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
153
175
|
/** 3D 무대(rig 효과)를 쓰는 테마인가 — 묻기 보기·견본 시트의 2D/3D 표시 */
|
|
154
176
|
export const themeDims = (t: Pick<ThemeManifest, "effects">): "2D" | "3D" => (t.effects.some((e) => e.kind === "rig") ? "3D" : "2D");
|
|
155
177
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/// <reference types="vite/client" />
|
|
2
|
-
// 페이지(Vite) 쪽. 장면 문법 패턴은 테마 이름을 모른다 — 일반 자리
|
|
2
|
+
// 페이지(Vite) 쪽. 장면 문법 패턴은 테마 이름을 모른다 — 일반 자리 일곱을 부르고, 테마가 themes/<id>/slots.ts 에서 자기 효과로 채운다.
|
|
3
3
|
// effects.ts 와 따로 두는 까닭: effects.ts 내보내기는 theme.json 효과 목록과 같아야 한다(테마 시험).
|
|
4
4
|
import type { SceneCtx, Style } from "@kasaframe/core";
|
|
5
5
|
import type { DropBurstState } from "@kasaframe/motion";
|
|
@@ -7,6 +7,31 @@ import { worldOf } from "./name.ts";
|
|
|
7
7
|
|
|
8
8
|
export type Paint = (t: number) => void;
|
|
9
9
|
|
|
10
|
+
/** 짚는 칸 자리 — 칸을 깔 상자(왼쪽 위·크기, 패턴이 정한 좌표 px) */
|
|
11
|
+
export interface MarkBox {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
w: number;
|
|
15
|
+
h: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MarkPaint {
|
|
19
|
+
/** 칸이 덮은 글의 색 — 비우면 글색을 그대로 둔다(칸이 옅게 글 뒤에 깔리는 테마) */
|
|
20
|
+
ink?: string;
|
|
21
|
+
/**
|
|
22
|
+
* 짚기 방식 — true 면 장면의 요점(주인공·그 순간 목소리가 말하는 값)에 내려앉아 머물고 다음 요점에서만 옮긴다. 비우면 박 정지점마다
|
|
23
|
+
* 주인공과 나머지를 번갈아 훑는다(내용이 다 선 뒤 쉬지 않는 몫을 짚개가 진다). 머무는 테마는 쉬는 구간을 멈춤 기준(theme.json
|
|
24
|
+
* motion.maxFreezeSec)과 내용 움직임으로 감당해야 한다
|
|
25
|
+
*/
|
|
26
|
+
dwell?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* u(0..1)만큼 간 칸 자리 — from 칸(앞 요점)에서 to 칸(새 요점)으로 옮겨 가는 중. from 이 null 이면 to 자리에 처음 나타나는 중.
|
|
29
|
+
* u 는 옮김 동안 고르게 흐르고 u=1 이면 to 그대로 — 머무는 짚개(dwell)는 0.3초 안팎에 옮겨 다음 요점까지 서고, 훑는 짚개는 박 정지점 사이를 내내 옮긴다.
|
|
30
|
+
* 감속·모양 바꿈(높이·칠 짙기)은 테마 몫이고 el 칠을 그 크기에 맞춰 바꿔도 된다
|
|
31
|
+
*/
|
|
32
|
+
at(from: MarkBox | null, to: MarkBox, u: number): MarkBox;
|
|
33
|
+
}
|
|
34
|
+
|
|
10
35
|
/**
|
|
11
36
|
* 모든 슬롯은 같은 t 면 같은 화면이고, 난수는 seed 로만. 비운 슬롯은 패턴이 제 기본으로 그린다.
|
|
12
37
|
* 화면 전체를 덮는 3D(three)는 surface 에서만 — 크롬이 WebGL 문맥을 몇 개만 살려 두므로 나머지 슬롯은 DOM·캔버스 2D 로.
|
|
@@ -33,9 +58,14 @@ export interface ThemeSlots {
|
|
|
33
58
|
trace?(el: HTMLElement, o: { ctx: SceneCtx; points: readonly (readonly [number, number])[]; at: number; dur: number; seed: string; tone?: "base" | "accent" }): Paint;
|
|
34
59
|
/** 드롭 터짐 — 그 테마의 드롭 연출. 돌려준 shake 는 패턴이 화면 틀에, punch 는 주인공에 준다. 드롭 번호로만 */
|
|
35
60
|
drop?(el: HTMLElement, o: { ctx: SceneCtx; drop: number }): (t: number) => DropBurstState;
|
|
61
|
+
/**
|
|
62
|
+
* 짚음 — 패턴이 「지금 볼 것」(장면의 요점·목소리가 말하는 값)을 짚는 칸(짚개·반전 짚음)의 모양. el = 패턴이 지은 빈 칸(position:absolute, 글 뒤에 깔린다).
|
|
63
|
+
* 칸 칠(바탕·테두리·모서리)은 여기서 하고, 자리·크기는 패턴이 프레임마다 돌려받은 at 으로 준다. 비우면 강조색 꽉 찬 칸 + onAccent 글
|
|
64
|
+
*/
|
|
65
|
+
mark?(el: HTMLElement, o: { ctx: SceneCtx; seed: string }): MarkPaint;
|
|
36
66
|
}
|
|
37
67
|
|
|
38
|
-
export const SLOT_NAMES = ["surface", "panel", "land", "write", "trace", "drop"] as const satisfies readonly (keyof ThemeSlots)[];
|
|
68
|
+
export const SLOT_NAMES = ["surface", "panel", "land", "write", "trace", "drop", "mark"] as const satisfies readonly (keyof ThemeSlots)[];
|
|
39
69
|
|
|
40
70
|
const mods = import.meta.glob("../../../themes/*/slots.ts", { eager: true }) as Record<string, { slots?: ThemeSlots }>;
|
|
41
71
|
|
|
@@ -29,6 +29,13 @@ export interface ThemeManifest {
|
|
|
29
29
|
purposes: string[];
|
|
30
30
|
/** 견본이 서는 화면비. 앞이 견본의 화면비 */
|
|
31
31
|
aspect: string[];
|
|
32
|
+
/** 장면 사이 전환 허용 목록 — 덮기(none·fade·brushWipe·irisBlades·flood)와 이음매(dive·portal·… · gl:<이름>) 이름. 없으면 다 허용, 모르는 이름은 허용 안 한 것과 같다 */
|
|
33
|
+
transitions?: string[];
|
|
34
|
+
/**
|
|
35
|
+
* 검사 기준 — maxFreezeSec 은 멈춤 오류 기준(초). 실제 오류 기준은 틀·style.json 값과 이것 중 큰 쪽이라 테마는 늘리기만 하고,
|
|
36
|
+
* 경고는 틀·style.json 값 그대로 남는다(core freezeLimits). 화면을 살려 두려고 칸을 돌리지 않고 쉬는 구간을 두는 세계가 쓴다
|
|
37
|
+
*/
|
|
38
|
+
motion?: { maxFreezeSec?: number };
|
|
32
39
|
effects: ThemeEffectInfo[];
|
|
33
40
|
/** 테마 폴더 기준 파일 이름. 기본 still.png · sample.mp4 */
|
|
34
41
|
still?: string;
|
|
@@ -37,6 +44,27 @@ export interface ThemeManifest {
|
|
|
37
44
|
vary?: ThemeVary;
|
|
38
45
|
/** 고르기 태그 — 분위기·밝기·용도·2D/3D */
|
|
39
46
|
tags?: ThemeTags;
|
|
47
|
+
/** 효과음 결 — 없으면 기본 결(ui)·기본 크기 */
|
|
48
|
+
sfx?: ThemeSfx;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* theme.json sfx — 그 세계가 내는 소리 결. 렌더가 장면 움직임에서 뽑은 소리 자리에 이 결의 파일을 깐다(레포 sfx/<set>/<역할>.wav).
|
|
53
|
+
* kf.json sfx 가 칸마다 덮고, kf.json sfx: false 나 kf render --sfx off 면 끈다
|
|
54
|
+
*/
|
|
55
|
+
export interface ThemeSfx {
|
|
56
|
+
/** 소리 결 — sfx/tags.json 의 sets 이름(pop 뽁·통 · click 딸깍·째깍 · ui 기본) */
|
|
57
|
+
set: string;
|
|
58
|
+
/** 둘레 곡(소리 자리 앞뒤 0.2초) 크기 대비 dB — 0 이면 곡과 비슷하게. 역할마다 렌더의 역할 크기(드롭 +2 · 째깍 -4 …)를 더한다 */
|
|
59
|
+
db?: number;
|
|
60
|
+
/** 1초에 몇 개까지(2초 창으로 센다 — 0.5 면 2초에 하나) — 넘으면 드롭·끝·휙·착지… 차례로 남긴다 */
|
|
61
|
+
perSec?: number;
|
|
62
|
+
/** 이 역할만 낸다 — 없으면 다 */
|
|
63
|
+
only?: string[];
|
|
64
|
+
/** 잔소리(낱낱이 나타남·숫자 째깍·타자)도 낸다 — 기본은 장면 바뀜·드롭·주인공 착지·짚개가 핵심 값에 닿는 순간만 */
|
|
65
|
+
chatter?: boolean;
|
|
66
|
+
/** 소리마다 곡을 이만큼(dB, 기본 3) 100ms 남짓 눌러 자리를 낸다 */
|
|
67
|
+
duck?: number;
|
|
40
68
|
}
|
|
41
69
|
|
|
42
70
|
export type TextureKind = "none" | "grain" | "paper" | "noise" | "scan" | "halftone";
|
package/sfx/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# 효과음 — 렌더가 장면 움직임 자리에 까는 짧은 소리
|
|
2
|
+
|
|
3
|
+
곡·목소리 위 세 번째 층. 장면 코드를 안 고쳐도 라이브러리 부품이 움직임이 시작하는 순간을 `__kf.events` 에 `sfx` 로 남기고(`@kasaframe/core` `cue`), `kf render` 가 **뜻 있는 자리만** 적게 골라 테마가 고른 **결**의 파일을 둘레 곡과 비슷한 크기로 깔고, 그 순간 곡을 짧게 눌러 자리를 낸다. 곡과 달리 npm 묶음에 실린다(전부 CC0).
|
|
4
|
+
|
|
5
|
+
- 결(`sets`)마다 역할 하나에 파일 하나 — `sfx/<결>/<역할>.wav`(48kHz 모노 16비트, 꼭대기 -1dBFS). 원본·겹친 소리·라이선스·`lead`(파일 시작 → 꼭대기 초)·`level`(100ms 창 RMS 최댓값 dBFS — 섞을 때 크기를 맞추는 기준)는 `tags.json`.
|
|
6
|
+
- **곡에 안 묻히는 건 2–6kHz 다.** 600–2000Hz 에 몸이 있는 소리는 6dB 키워도 곡 아래였다(렌더 보고서의 가려짐). 그래서 몸이 낮은 소리(뽁·통·쿵)는 그 대역의 짧은 어택을 꼭대기에 맞춰 겹쳐 짓는다(아래 표의 `+`).
|
|
7
|
+
- 테마가 결과 크기를 고른다 — `theme.json` 의 `sfx: { set, db, perSec, only, chatter, duck }`. 없으면 `ui` 결·둘레 곡 -2dB·1초 1개·잔소리 없음·곡 3dB 누름. 프로젝트 `kf.json` 의 `sfx` 가 칸마다 덮고, `sfx: false` 나 `kf render|make --sfx off` 면 끈다(끄면 소리는 효과음 이전과 바이트까지 같다).
|
|
8
|
+
- 다른 폴더를 쓰려면 `KF_SFX_DIR=<폴더>` — 그 폴더에도 같은 꼴의 `tags.json`.
|
|
9
|
+
- 다시 짓기: Kenney 묶음 zip 여섯(`https://kenney.nl/assets/<ui-audio|interface-sounds|impact-sounds|digital-audio|rpg-audio|casino-audio>`)을 한 폴더에 풀고 `node tools/sfx/build.ts --src <그 폴더>`. 묶음마다 `License.txt` 에서 CC0 를 확인하고 없으면 멈춘다.
|
|
10
|
+
|
|
11
|
+
## 결
|
|
12
|
+
|
|
13
|
+
| 결 | 소리 | 쓰는 세계 |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `pop` | 물방울 뽁·나무 통·둥근 쿵에 또렷한 톡 어택 | kawaii-sticker(둘레 곡과 같은 크기, 1초에 하나까지) |
|
|
16
|
+
| `click` | 역할마다 작은 스위치·시계 소리만(전부 2–6kHz) | og-measure(딸깍·째깍만, 둘레 곡 -4dB, 2초에 하나까지) |
|
|
17
|
+
| `ui` | 가벼운 UI 소리 — 퉁김·휙·주먹 쿵에 또렷한 어택 | 결을 안 고른 세계 |
|
|
18
|
+
|
|
19
|
+
## 역할 — 누가 저절로 남기나
|
|
20
|
+
|
|
21
|
+
기본으로 까는 것은 뜻 있는 자리 — 장면 바뀜·드롭·주인공 착지·짚개가 요점에 닿는 순간. 부품이 잔소리(`minor`)로 남긴 것(표의 「잔소리」)은 `chatter: true` 일 때만 깐다.
|
|
22
|
+
|
|
23
|
+
| 역할 | 뜻 | 저절로 남기는 부품 |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `appear` | 나타남 | 잔소리 — motion `enter`(rise·scaleIn·maskWipe·slide, fade·blurIn 은 소리 없이) · 패턴 판(panel) · og-measure 칸이 서는 순간 |
|
|
26
|
+
| `stick` | 톡 붙기 | kawaii 주인공 스티커(폭 400px 넘는 것 — 작은 이름표는 잔소리) · `stickOn` · 잔소리 — motion `enter("pop")` |
|
|
27
|
+
| `land` | 착지 | 패턴 `land` 슬롯·`slam`·숫자 굴림이 앉을 때 · og-measure 숫자 꽂힘 · 잔소리 — motion `enter("drop")` |
|
|
28
|
+
| `hop` | 통 뛰기 | 잔소리 — kawaii 스티커 `hops` |
|
|
29
|
+
| `whoosh` | 휙 전환 | 이음매(`transition3d` — 화면이 여럿이면 넘어갈 때마다) · `transition`(whip·zoomThrough·slide) · 컷 카메라(shotCam·trackCam) · 잔소리 — 떼어짐·테이프 |
|
|
30
|
+
| `tick` | 숫자 째깍 | 잔소리 — 패턴 숫자 굴림·카운터 · og-measure 계기판·점 |
|
|
31
|
+
| `select` | 짚기 딸깍 | 패턴 짚개가 요점으로 옮겨 갈 때 · og-measure 읽는 칸이 새 값에 닿을 때 |
|
|
32
|
+
| `type` | 타자 | 잔소리 — 패턴 `write` 슬롯(0.09초마다) · og-measure 로그 줄 |
|
|
33
|
+
| `drop` | 드롭 쿵 | motion `dropBurst`(slap·패턴 drop 슬롯) · beats.json 드롭 |
|
|
34
|
+
| `burst` | 팡 터짐 | kawaii `stickerBurst` |
|
|
35
|
+
| `end` | 끝 마무리 | og-measure 끝 도장 |
|
|
36
|
+
|
|
37
|
+
손으로 지은 움직임은 장면에서 `ctx.sfx("<역할>", 장면 초)` — 잔소리로 치지 않는다.
|
|
38
|
+
|
|
39
|
+
## 섞기
|
|
40
|
+
|
|
41
|
+
- 밀도: 같은 역할은 0.1초 안에 하나, 아무 소리끼리 0.25초 띄우고, 아무 2초 창에 `perSec`×2 개(적어도 하나)까지. 넘치면 드롭 > 끝 > 휙 > 착지 > 붙기·팡 > 뛰기 > 짚기 > 나타남 > 째깍 > 타자 차례로 남기고, 드롭 앞뒤 0.35초는 드롭만.
|
|
42
|
+
- 크기: 둘레 곡(소리 자리 앞뒤 0.2초) 크기 + `db` + 역할 크기(드롭 +2 … 째깍·타자 -4), 목소리가 있으면 4dB 더 낮게. 역할마다 저역을 걷는다(드롭 60Hz·그 밖 150–300Hz — 킥·베이스와 안 겹치게).
|
|
43
|
+
- 자리 내기: 소리마다 곡을 `duck` dB(기본 3) 소리 시작 10ms 앞부터 꼭대기 100ms 뒤까지 누른다.
|
|
44
|
+
- 가려짐: 소리가 차지하는 대역(제 가장 센 대역에서 10dB 안쪽) 가운데 곡을 가장 넘는 대역에서, 소리 순간(꼭대기 앞 10ms·뒤 90ms) 효과음 ÷ 눌린 곡이 4dB 가 안 되면 6dB 까지 더 키운다. 렌더 보고서 `sfx.hits` 에 소리마다 `band`·`smr`·`boost`·`over`(넓은 대역 크기 − 둘레 곡).
|
|
45
|
+
|
|
46
|
+
<!-- 파일 표 — tools/sfx/build.ts 가 짓는다 -->
|
|
47
|
+
|
|
48
|
+
| 파일 | 역할 | 길이 | 원본 | 라이선스 |
|
|
49
|
+
|---|---|---|---|---|
|
|
50
|
+
| `pop/appear.wav` | 나타남 | 0.15초 | Kenney Interface Sounds (1.0) — `drop_004.ogg` + Kenney Interface Sounds (1.0) — `select_001.ogg`(-6dB) | CC0 1.0 |
|
|
51
|
+
| `pop/stick.wav` | 톡 붙기 | 0.15초 | Kenney Interface Sounds (1.0) — `drop_002.ogg` + Kenney Interface Sounds (1.0) — `tick_004.ogg`(-4dB) | CC0 1.0 |
|
|
52
|
+
| `pop/land.wav` | 착지 | 0.12초 | Kenney Impact Sounds (1.0) — `impactWood_light_000.ogg` + Kenney Casino Audio (1.1) — `chip-lay-1.ogg`(-4dB) | CC0 1.0 |
|
|
53
|
+
| `pop/hop.wav` | 통 뛰기 | 0.30초 | Kenney Digital Audio — `phaseJump1.ogg` + Kenney UI SFX Set — `click5.ogg`(-6dB) | CC0 1.0 |
|
|
54
|
+
| `pop/whoosh.wav` | 휙 전환 | 0.25초 | Kenney Interface Sounds (1.0) — `maximize_002.ogg` | CC0 1.0 |
|
|
55
|
+
| `pop/tick.wav` | 숫자 째깍 | 0.02초 | Kenney Interface Sounds (1.0) — `tick_002.ogg` | CC0 1.0 |
|
|
56
|
+
| `pop/select.wav` | 짚기 딸깍 | 0.04초 | Kenney Interface Sounds (1.0) — `select_001.ogg` | CC0 1.0 |
|
|
57
|
+
| `pop/type.wav` | 타자 | 0.03초 | Kenney UI SFX Set — `click5.ogg` | CC0 1.0 |
|
|
58
|
+
| `pop/drop.wav` | 드롭 쿵 | 0.52초 | Kenney Impact Sounds (1.0) — `impactSoft_heavy_000.ogg` + Kenney Impact Sounds (1.0) — `impactMetal_light_000.ogg`(-3dB) | CC0 1.0 |
|
|
59
|
+
| `pop/burst.wav` | 팡 터짐 | 0.19초 | Kenney Digital Audio — `powerUp2.ogg` + Kenney Casino Audio (1.1) — `chips-collide-1.ogg`(-4dB) | CC0 1.0 |
|
|
60
|
+
| `pop/end.wav` | 끝 마무리 | 0.32초 | Kenney Interface Sounds (1.0) — `confirmation_003.ogg` | CC0 1.0 |
|
|
61
|
+
| `click/appear.wav` | 나타남 | 0.09초 | Kenney UI SFX Set — `click1.ogg` | CC0 1.0 |
|
|
62
|
+
| `click/stick.wav` | 톡 붙기 | 0.01초 | Kenney Interface Sounds (1.0) — `click_003.ogg` | CC0 1.0 |
|
|
63
|
+
| `click/land.wav` | 착지 | 0.14초 | Kenney Interface Sounds (1.0) — `switch_001.ogg` | CC0 1.0 |
|
|
64
|
+
| `click/hop.wav` | 통 뛰기 | 0.06초 | Kenney Interface Sounds (1.0) — `toggle_004.ogg` | CC0 1.0 |
|
|
65
|
+
| `click/whoosh.wav` | 휙 전환 | 0.22초 | Kenney Interface Sounds (1.0) — `scroll_004.ogg` | CC0 1.0 |
|
|
66
|
+
| `click/tick.wav` | 숫자 째깍 | 0.02초 | Kenney Interface Sounds (1.0) — `tick_001.ogg` | CC0 1.0 |
|
|
67
|
+
| `click/select.wav` | 짚기 딸깍 | 0.04초 | Kenney Interface Sounds (1.0) — `select_001.ogg` | CC0 1.0 |
|
|
68
|
+
| `click/type.wav` | 타자 | 0.05초 | Kenney UI SFX Set — `switch12.ogg` | CC0 1.0 |
|
|
69
|
+
| `click/drop.wav` | 드롭 쿵 | 0.21초 | Kenney RPG Audio — `metalLatch.ogg` | CC0 1.0 |
|
|
70
|
+
| `click/burst.wav` | 팡 터짐 | 0.13초 | Kenney Casino Audio (1.1) — `chip-lay-1.ogg` | CC0 1.0 |
|
|
71
|
+
| `click/end.wav` | 끝 마무리 | 0.11초 | Kenney Interface Sounds (1.0) — `toggle_002.ogg` | CC0 1.0 |
|
|
72
|
+
| `ui/appear.wav` | 나타남 | 0.13초 | Kenney Interface Sounds (1.0) — `pluck_001.ogg` + Kenney Interface Sounds (1.0) — `tick_004.ogg`(-6dB) | CC0 1.0 |
|
|
73
|
+
| `ui/stick.wav` | 톡 붙기 | 0.15초 | Kenney Interface Sounds (1.0) — `drop_001.ogg` + Kenney Interface Sounds (1.0) — `tick_004.ogg`(-6dB) | CC0 1.0 |
|
|
74
|
+
| `ui/land.wav` | 착지 | 0.15초 | Kenney Impact Sounds (1.0) — `impactGeneric_light_000.ogg` + Kenney Casino Audio (1.1) — `chip-lay-1.ogg`(-4dB) | CC0 1.0 |
|
|
75
|
+
| `ui/hop.wav` | 통 뛰기 | 0.14초 | Kenney Interface Sounds (1.0) — `pluck_002.ogg` + Kenney UI SFX Set — `click5.ogg`(-6dB) | CC0 1.0 |
|
|
76
|
+
| `ui/whoosh.wav` | 휙 전환 | 0.25초 | Kenney Interface Sounds (1.0) — `maximize_001.ogg` | CC0 1.0 |
|
|
77
|
+
| `ui/tick.wav` | 숫자 째깍 | 0.01초 | Kenney Interface Sounds (1.0) — `click_003.ogg` | CC0 1.0 |
|
|
78
|
+
| `ui/select.wav` | 짚기 딸깍 | 0.23초 | Kenney Interface Sounds (1.0) — `select_003.ogg` | CC0 1.0 |
|
|
79
|
+
| `ui/type.wav` | 타자 | 0.03초 | Kenney UI SFX Set — `click5.ogg` | CC0 1.0 |
|
|
80
|
+
| `ui/drop.wav` | 드롭 쿵 | 0.45초 | Kenney Impact Sounds (1.0) — `impactPunch_heavy_000.ogg` + Kenney Impact Sounds (1.0) — `impactMetal_light_000.ogg`(-3dB) | CC0 1.0 |
|
|
81
|
+
| `ui/burst.wav` | 팡 터짐 | 0.16초 | Kenney Casino Audio (1.1) — `chips-collide-1.ogg` | CC0 1.0 |
|
|
82
|
+
| `ui/end.wav` | 끝 마무리 | 0.14초 | Kenney Interface Sounds (1.0) — `switch_001.ogg` | CC0 1.0 |
|
|
Binary file
|
|
Binary file
|