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.
- 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/direct.ts +24 -4
- 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 +15 -6
- package/packages/director/src/fallback.ts +14 -2
- package/packages/director/src/index.ts +1 -1
- package/packages/director/src/pattern.ts +2 -0
- package/packages/director/src/patterns/fill/cells.ts +5 -2
- package/packages/director/src/scene.ts +17 -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 +14 -8
- 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 +364 -59
- package/packages/patterns/src/limit/gauge.ts +19 -13
- package/packages/patterns/src/quote/karaoke.ts +17 -12
- package/packages/patterns/src/sfx.ts +80 -0
- package/packages/patterns/src/steps/path.ts +15 -7
- package/packages/patterns/src/term-reveal/chips.ts +24 -10
- package/packages/patterns/src/term-reveal/expand.ts +17 -13
- package/packages/patterns/src/timeline/axis.ts +79 -25
- package/packages/patterns/src/title/mask.ts +15 -10
- 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 +13 -2
- 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/style.json +3 -0
- package/themes/og-measure/theme.json +15 -1
- package/themes/og-measure/variants/snow-blue.json +3 -0
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
import { applyStyle, clamp, combine, spring } from "@kasaframe/core";
|
|
2
2
|
import { fitSize, textEm } from "@kasaframe/motion/moves";
|
|
3
|
-
import { area, moving, node, slotsOf, spaced } from "../kit.ts";
|
|
3
|
+
import { area, FILL, FILL_LINE, fillEdge, followSpoken, moving, node, ON_FILL, slotsOf, spaced } from "../kit.ts";
|
|
4
4
|
import type { Pattern } from "../types.ts";
|
|
5
5
|
|
|
6
6
|
export interface StepsContent {
|
|
@@ -76,16 +76,19 @@ const pattern: Pattern<StepsContent> = {
|
|
|
76
76
|
const [x, y] = cardPos(i);
|
|
77
77
|
const card = node(host, `position:absolute;left:${x}px;top:${y}px;width:${cardW}px;height:${cardH}px;transform-origin:${vertical ? "0 50%" : "50% 0"}`);
|
|
78
78
|
const panel = slots.panel(card, { ctx, at: enters[i], seed: `${kit.seed}:c${i}` });
|
|
79
|
-
const
|
|
79
|
+
const edge = Math.max(2, Math.round(3 * u));
|
|
80
|
+
const fill = node(card, `position:absolute;inset:0;background:${FILL};${fillEdge(edge)};border-radius:var(--kf-radius-md)`);
|
|
81
|
+
// 차오르는 수위 — 칠 테두리 색 줄(토큰이 없는 테마엔 안 보인다). 옅은 칠은 차오름만으로는 화면이 거의 안 바뀐다
|
|
82
|
+
const level = node(card, `position:absolute;left:0;right:0;top:0;height:${edge}px;background:var(--kf-fill-line, transparent);visibility:hidden`);
|
|
80
83
|
const face = (lit: boolean) => {
|
|
81
|
-
const f = node(card, `position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:${vertical ? "flex-start" : "center"};gap:${Math.round(detailPx * 0.4)}px;padding:0 ${Math.round(labelPx * 0.4)}px;box-sizing:border-box;color:${lit ?
|
|
84
|
+
const f = node(card, `position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:${vertical ? "flex-start" : "center"};gap:${Math.round(detailPx * 0.4)}px;padding:0 ${Math.round(labelPx * 0.4)}px;box-sizing:border-box;color:${lit ? ON_FILL : "var(--kf-ink)"}`);
|
|
82
85
|
const label = node(f, `font-family:var(--kf-font-display);font-size:${labelPx}px;font-weight:${ctx.style.type?.weightDisplay ?? 800};line-height:1.08;word-break:keep-all;text-align:${vertical ? "left" : "center"};white-space:${vertical ? "nowrap" : "normal"}`);
|
|
83
86
|
const detail = showDetail && s.detail ? node(f, `font-family:var(--kf-font-sans);font-size:${detailPx}px;line-height:1.3;opacity:.85;text-align:${vertical ? "left" : "center"};word-break:keep-all`) : null;
|
|
84
87
|
return { f, label, detail };
|
|
85
88
|
};
|
|
86
89
|
const base = face(false);
|
|
87
90
|
const lit = face(true);
|
|
88
|
-
return { card, panel, fill, base, lit, s };
|
|
91
|
+
return { card, panel, fill, level, base, lit, s };
|
|
89
92
|
});
|
|
90
93
|
|
|
91
94
|
const walkFrom = enters[n - 1] + 0.3;
|
|
@@ -100,6 +103,8 @@ const pattern: Pattern<StepsContent> = {
|
|
|
100
103
|
]),
|
|
101
104
|
);
|
|
102
105
|
const lands = dots.map((d, i) => slots.land(d, { ctx, at: enters[i], seed: `${kit.seed}:n${i}` }));
|
|
106
|
+
// 머무는 짚개 테마는 지금 단계 차례마다 카메라가 그 카드 쪽으로 다가간다
|
|
107
|
+
followSpoken(kit, ctx, cards.map((cd, i) => ({ at: turns[i], el: cd.card })), { reach: 0.15, zoom: 1.03 });
|
|
103
108
|
|
|
104
109
|
return (t: number) => {
|
|
105
110
|
moving("steps.path", ctx);
|
|
@@ -113,14 +118,17 @@ const pattern: Pattern<StepsContent> = {
|
|
|
113
118
|
cards[i].panel(t);
|
|
114
119
|
applyStyle(cards[i].card, { scale: spring(t - enters[i], { from: 0.82, to: 1, stiffness: 300, damping: 16 }) });
|
|
115
120
|
const now = t >= turns[i];
|
|
116
|
-
|
|
117
|
-
dots[i].style.
|
|
118
|
-
dots[i].style.
|
|
121
|
+
// 칠이 옅은 테마도 길이 마디 뒤로 비치지 않게 바탕 위에 칠한다
|
|
122
|
+
dots[i].style.background = now ? `linear-gradient(${FILL}, ${FILL}), var(--kf-bg)` : "var(--kf-bg)";
|
|
123
|
+
dots[i].style.color = now ? ON_FILL : "var(--kf-ink)";
|
|
124
|
+
dots[i].style.borderColor = now ? FILL_LINE : "var(--kf-ink)";
|
|
119
125
|
// 그 단계 차례 내내 카드가 아래부터 차오른다 — 차례 끝자락엔 길이 다음 마디로 같이 이어진다
|
|
120
126
|
const k = clamp((t - turns[i]) / Math.max(0.05, ends[i] - turns[i]));
|
|
121
127
|
const clip = `inset(${((1 - k) * 100).toFixed(2)}% 0 0 0)`;
|
|
122
128
|
cards[i].fill.style.clipPath = clip;
|
|
123
129
|
cards[i].lit.f.style.clipPath = clip;
|
|
130
|
+
cards[i].level.style.visibility = k > 0 && k < 1 ? "visible" : "hidden";
|
|
131
|
+
cards[i].level.style.top = `${((1 - k) * 100).toFixed(2)}%`;
|
|
124
132
|
});
|
|
125
133
|
rails.forEach((r, i) => {
|
|
126
134
|
// 회색 길은 뒤 마디가 서기 직전에 그어지고, 강조색 길은 앞 단계 차례 끝자락에 다음 마디까지 이어진다
|
|
@@ -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,6 +1,6 @@
|
|
|
1
|
-
import { applyStyle, clamp
|
|
1
|
+
import { applyStyle, clamp } from "@kasaframe/core";
|
|
2
2
|
import { fitSize, textEm } from "@kasaframe/motion/moves";
|
|
3
|
-
import { ACCENT_TEXT, area, decimalsOf, fmt, moving, node, slotsOf, spaced } from "../kit.ts";
|
|
3
|
+
import { ACCENT_TEXT, area, decimalsOf, FILL, fillEdge, fmt, followSpoken, moving, node, ON_FILL, slotsOf, spaced } from "../kit.ts";
|
|
4
4
|
import type { Pattern } from "../types.ts";
|
|
5
5
|
|
|
6
6
|
export interface TimelineContent {
|
|
@@ -64,6 +64,8 @@ const pattern: Pattern<TimelineContent> = {
|
|
|
64
64
|
? `left:${axisX - trackT / 2}px;top:${a0}px;width:${trackT}px;height:${len}px`
|
|
65
65
|
: `left:${a0}px;top:${axisY - trackT / 2}px;width:${len}px;height:${trackT}px`;
|
|
66
66
|
const track = node(host, `position:absolute;${trackCss};background:var(--kf-raised);border:${Math.max(2, Math.round(2 * u))}px solid var(--kf-line);box-sizing:border-box;transform-origin:${vertical ? "50% 0" : "0 50%"}`);
|
|
67
|
+
const edge = Math.max(2, Math.round(3 * u));
|
|
68
|
+
const winPx = Math.round(chipPx * 0.95);
|
|
67
69
|
const win = c.window ? (() => {
|
|
68
70
|
const p0 = pos(c.window.from);
|
|
69
71
|
const p1 = pos(c.window.to);
|
|
@@ -74,29 +76,72 @@ const pattern: Pattern<TimelineContent> = {
|
|
|
74
76
|
// 세로 화면은 그 구간을 화면 폭 가득 옅게 칠한다 — 「이 시간대」가 축 옆 점선만으로는 작게 읽혔다
|
|
75
77
|
if (vertical) host.prepend(node(host, `position:absolute;left:0;right:0;top:${a0 + len * p0}px;height:${len * (p1 - p0)}px;background:color-mix(in srgb, var(--kf-accent) 14%, transparent)`));
|
|
76
78
|
const [lx, ly] = at((p0 + p1) / 2);
|
|
79
|
+
// 구간 이름은 눕히지 않는다 — 세로 화면은 오른쪽 딱지 줄에 두고 자리는 아래 줄 맞추기가 정한다
|
|
77
80
|
const label = node(host, vertical
|
|
78
|
-
? `position:absolute;left
|
|
81
|
+
? `position:absolute;left:0;top:0;font-family:var(--kf-font-display);font-size:${winPx}px;font-weight:800;line-height:1.2;color:${ACCENT_TEXT};white-space:nowrap`
|
|
79
82
|
: `position:absolute;left:${lx}px;top:${ly - trackT * 1.35 - chipPx * 1.75}px;transform:translate(-50%,-100%);font-family:var(--kf-font-sans);font-size:${chipPx}px;font-weight:700;color:${ACCENT_TEXT};white-space:nowrap`);
|
|
80
|
-
return { p0, p1, outline, label };
|
|
83
|
+
return { p0, p1, outline, label, y: ly };
|
|
81
84
|
})() : null;
|
|
82
|
-
const done = node(track, `position:absolute;${vertical ? "left:0;right:0;top:0;height:100%" : "top:0;bottom:0;left:0;width:100%"};background
|
|
85
|
+
const done = node(track, `position:absolute;${vertical ? "left:0;right:0;top:0;height:100%" : "top:0;bottom:0;left:0;width:100%"};background:${FILL};${fillEdge(edge)};transform-origin:${vertical ? "50% 0" : "0 50%"};transform:scale(0)`);
|
|
86
|
+
// 축 끝 값 — 세로 화면은 왼쪽(지금 읽음값과 같은 줄, 오른쪽은 딱지 줄)
|
|
83
87
|
const ends = [span.from, span.to].map((v, i) => {
|
|
84
88
|
const [x, y] = at(i);
|
|
85
89
|
return node(host, vertical
|
|
86
|
-
? `position:absolute;left:${x
|
|
90
|
+
? `position:absolute;left:${x - trackT / 2 - 10}px;top:${y}px;transform:translate(-100%, ${i ? "-100%" : "0"});font-family:var(--kf-font-mono);font-size:${Math.round(Math.max(44 * u, readPx * 0.8))}px;color:var(--kf-dim, var(--kf-ink));white-space:nowrap`
|
|
87
91
|
: `position:absolute;left:${x}px;top:${y + trackT * 0.7}px;transform:translateX(${i ? "-100%" : "0"});font-family:var(--kf-font-mono);font-size:${Math.round(readPx * 0.8)}px;color:var(--kf-dim, var(--kf-ink));white-space:nowrap`, `${fmt(v, dec)}${unit ? ` ${unit}` : ""}`);
|
|
88
92
|
});
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
|
|
94
|
+
// 표시 — 축을 가로지르는 눈금 하나와 딱지를 잇는 선. 큰 동그라미는 뜻 없이 이웃 표시·딱지를 덮었다
|
|
95
|
+
const chipCss = `padding:0.12em 0.4em;font-family:var(--kf-font-sans);font-size:${chipPx}px;font-weight:700;color:var(--kf-ink);background:var(--kf-raised);white-space:nowrap;line-height:1.2`;
|
|
96
|
+
// 자리는 바깥 칸이 잡고 내려앉음(land)은 안쪽 딱지에 — land 가 transform 을 통째로 다시 쓴다
|
|
97
|
+
const pins = marks.map((m) => {
|
|
98
|
+
const anchor = node(host, "position:absolute;left:0;top:0");
|
|
99
|
+
return { m, p: pos(m.at), anchor, chip: node(anchor, chipCss) };
|
|
100
|
+
});
|
|
101
|
+
// 세로 화면 딱지 줄 — 바라는 자리(표시 높이·구간 가운데)에서 서로 안 겹치게 밀고, 아래로 넘치면 거꾸로 올린다
|
|
102
|
+
const chipX = vertical ? axisX + trackT / 2 + trackT * 0.55 : 0;
|
|
103
|
+
const rowGap = chipPx * 0.22;
|
|
104
|
+
const slots2 = [...pins.map((p) => ({ want: at(p.p)[1], h: chipPx * 1.44, el: p.anchor })), ...(vertical && win ? [{ want: win.y, h: winPx * 1.2, el: win.label }] : [])].sort((x, y) => x.want - y.want);
|
|
105
|
+
const ys = slots2.map((x) => x.want);
|
|
106
|
+
if (vertical) {
|
|
107
|
+
for (let i = 0; i < slots2.length; i++) ys[i] = Math.max(slots2[i].want, i ? ys[i - 1] + (slots2[i - 1].h + slots2[i].h) / 2 + rowGap : a0 + slots2[i].h / 2);
|
|
108
|
+
const bottom = a0 + len + chipPx;
|
|
109
|
+
for (let i = slots2.length - 1; i >= 0; i--) ys[i] = Math.min(ys[i], i < slots2.length - 1 ? ys[i + 1] - (slots2[i].h + slots2[i + 1].h) / 2 - rowGap : bottom - slots2[i].h / 2);
|
|
110
|
+
}
|
|
111
|
+
const yOf = new Map(slots2.map((x, i) => [x.el, ys[i]]));
|
|
112
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
113
|
+
const svg = host.ownerDocument.createElementNS(SVG, "svg");
|
|
114
|
+
svg.setAttribute("style", "position:absolute;left:0;top:0;width:100%;height:100%;overflow:visible;pointer-events:none");
|
|
115
|
+
host.append(svg);
|
|
116
|
+
const ticks = pins.map((p, i) => {
|
|
117
|
+
const [x, y] = at(p.p);
|
|
118
|
+
const line = host.ownerDocument.createElementNS(SVG, "polyline");
|
|
119
|
+
line.setAttribute("fill", "none");
|
|
120
|
+
line.setAttribute("stroke-width", String(Math.max(3, Math.round(4 * u))));
|
|
121
|
+
line.setAttribute("stroke-linejoin", "round");
|
|
122
|
+
if (vertical) {
|
|
123
|
+
const cy = yOf.get(p.anchor)!;
|
|
124
|
+
const elbow = chipX - trackT * 0.25;
|
|
125
|
+
line.setAttribute("points", `${x - trackT / 2},${y} ${elbow},${y} ${elbow},${cy} ${chipX},${cy}`);
|
|
126
|
+
p.anchor.style.transform = `translate(${chipX}px, ${cy.toFixed(1)}px) translateY(-50%)`;
|
|
127
|
+
} else {
|
|
128
|
+
// 가로 화면은 딱지가 위아래 번갈아 — 눈금이 축을 가로질러 딱지까지 곧게
|
|
129
|
+
const side = i % 2 ? 1 : -1;
|
|
130
|
+
const cy = y + side * trackT * 1.25;
|
|
131
|
+
line.setAttribute("points", `${x},${y - side * trackT / 2} ${x},${cy}`);
|
|
132
|
+
p.anchor.style.transform = `translate(${x}px, ${cy}px) translate(-50%, ${side < 0 ? "-100%" : "0"})`;
|
|
133
|
+
}
|
|
134
|
+
svg.append(line);
|
|
135
|
+
return line;
|
|
98
136
|
});
|
|
99
|
-
|
|
137
|
+
if (vertical && win) win.label.style.transform = `translate(${chipX}px, ${yOf.get(win.label)!.toFixed(1)}px) translateY(-50%)`;
|
|
138
|
+
// 지금 줄 — 읽음값에서 축을 가로질러 딱지 줄 앞까지. 옅게 칠하는 테마는 지난 길이 차오르는 것만으로는 화면이 거의 안 바뀐다
|
|
139
|
+
const nowT = Math.max(2, Math.round(3 * u));
|
|
140
|
+
const nowLine = node(host, vertical
|
|
141
|
+
? `position:absolute;left:${w * 0.03}px;top:0;width:${chipX - trackT * 0.25 - w * 0.03}px;height:${nowT}px;margin-top:-${nowT / 2}px;background:var(--kf-accent);visibility:hidden`
|
|
142
|
+
: `position:absolute;left:0;top:${axisY - trackT * 0.65}px;width:${nowT}px;height:${trackT * 1.3}px;margin-left:-${nowT / 2}px;background:var(--kf-accent);visibility:hidden`);
|
|
143
|
+
// 읽음값 칩은 지금 줄 위에 앉는다 — 옅은 칠도 줄이 비치지 않게 바탕 위에 겹쳐 칠한다
|
|
144
|
+
const head = node(host, `position:absolute;left:0;top:0;padding:0.1em 0.35em;font-family:var(--kf-font-mono);font-size:${readPx}px;font-weight:700;color:${ON_FILL};background:linear-gradient(${FILL}, ${FILL}), var(--kf-bg);${fillEdge(edge)};white-space:nowrap;font-variant-numeric:tabular-nums;visibility:hidden`);
|
|
100
145
|
|
|
101
146
|
const hits = spaced(kit.hits.length ? kit.hits : [0], 0.3);
|
|
102
147
|
const drawAt = 0;
|
|
@@ -107,35 +152,44 @@ const pattern: Pattern<TimelineContent> = {
|
|
|
107
152
|
|
|
108
153
|
const writes = pins.map((p, i) => slots.write(p.chip, { ctx, text: p.m.label, at: pinAt[i], dur: 0.25, seed: `${kit.seed}:m${i}` }));
|
|
109
154
|
const writeWin = win ? slots.write(win.label, { ctx, text: c.window!.label, at: walkFrom - 0.3, dur: 0.3, seed: `${kit.seed}:w` }) : null;
|
|
110
|
-
const lands = pins.map((p, i) => slots.land(p.
|
|
155
|
+
const lands = pins.map((p, i) => slots.land(p.chip, { ctx, at: pinAt[i], seed: `${kit.seed}:p${i}` }));
|
|
156
|
+
// 머무는 짚개 테마는 지금이 표시에 닿을 때마다 카메라가 그 딱지 쪽으로 다가간다
|
|
157
|
+
followSpoken(kit, ctx, pins.map((p) => ({ at: walkFrom + p.p * (walkEnd - walkFrom), el: p.anchor })), { reach: 0.15, zoom: 1.03 });
|
|
111
158
|
|
|
112
159
|
return (t: number) => {
|
|
113
160
|
moving("timeline.axis", ctx);
|
|
114
161
|
const g = clamp((t - drawAt) / 0.3);
|
|
115
162
|
track.style.transform = vertical ? `scaleY(${g.toFixed(4)})` : `scaleX(${g.toFixed(4)})`;
|
|
116
|
-
for (const e of ends) e.style.visibility = g >= 1 ? "visible" : "hidden";
|
|
117
163
|
// 지금 — 고른 빠르기로 축 처음에서 끝까지
|
|
118
164
|
const now = clamp((t - walkFrom) / (walkEnd - walkFrom));
|
|
119
165
|
done.style.transform = vertical ? `scaleY(${now.toFixed(4)})` : `scaleX(${now.toFixed(4)})`;
|
|
120
166
|
const [hx, hy] = at(now);
|
|
121
167
|
head.style.visibility = t >= walkFrom ? "visible" : "hidden";
|
|
168
|
+
nowLine.style.visibility = head.style.visibility;
|
|
169
|
+
nowLine.style.transform = vertical ? `translateY(${hy.toFixed(1)}px)` : `translateX(${hx.toFixed(1)}px)`;
|
|
122
170
|
head.textContent = `${fmt(span.from + (span.to - span.from) * now, dec)}${unit ? ` ${unit}` : ""}`;
|
|
123
171
|
head.style.transform = vertical ? `translate(${(hx - trackT * 0.5 - 8).toFixed(1)}px, ${hy.toFixed(1)}px) translate(-100%, -50%)` : `translate(${hx.toFixed(1)}px, ${(hy - trackT * 0.5 - 8).toFixed(1)}px) translate(-50%, -100%)`;
|
|
172
|
+
// 세로 화면은 끝 값과 지금 읽음값이 같은 줄 — 지금이 그 끝 가까이 오면 끝 값을 비킨다(같은 값을 두 번 쓴다)
|
|
173
|
+
ends.forEach((e, i) => {
|
|
174
|
+
const near = vertical && Math.abs(hy - at(i)[1]) < readPx * 1.6 && t >= walkFrom;
|
|
175
|
+
e.style.visibility = g >= 1 && !near ? "visible" : "hidden";
|
|
176
|
+
});
|
|
124
177
|
if (win) win.label.style.visibility = t >= walkFrom - 0.3 ? "visible" : "hidden";
|
|
125
178
|
writeWin?.(t);
|
|
126
179
|
pins.forEach((p, i) => {
|
|
127
180
|
const on = t >= pinAt[i];
|
|
128
|
-
p.pin.style.visibility = on ? "visible" : "hidden";
|
|
129
181
|
p.chip.style.visibility = on ? "visible" : "hidden";
|
|
182
|
+
ticks[i].style.visibility = on ? "visible" : "hidden";
|
|
130
183
|
if (!on) return;
|
|
131
184
|
writes[i](t);
|
|
132
|
-
|
|
133
|
-
// 지금이 지나간 표시는 켜진다 — 강조 표시(hot)는 반전 칩으로
|
|
185
|
+
// 지금이 지나간 표시는 눈금이 켜진다 — 강조 표시(hot)는 딱지도 칠한 칸으로
|
|
134
186
|
const passed = now >= p.p - 1e-6 && t >= walkFrom;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
p.chip.style.background =
|
|
138
|
-
p.chip.style.color =
|
|
187
|
+
ticks[i].setAttribute("stroke", passed ? "var(--kf-accent)" : "var(--kf-dim, var(--kf-ink))");
|
|
188
|
+
const hot = passed && p.m.hot;
|
|
189
|
+
p.chip.style.background = hot ? FILL : "var(--kf-raised)";
|
|
190
|
+
p.chip.style.color = hot ? ON_FILL : "var(--kf-ink)";
|
|
191
|
+
p.chip.style.boxShadow = hot ? `inset 0 0 0 ${edge}px var(--kf-fill-line, transparent)` : "";
|
|
192
|
+
applyStyle(p.chip, lands[i](t));
|
|
139
193
|
});
|
|
140
194
|
};
|
|
141
195
|
},
|
|
@@ -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,
|
|
4
|
+
import { ACCENT_TEXT, area, beatStops, cameraOk, dwells, followSpoken, glider, moving, node, reread, riseName, sayTimes, 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"],
|
|
@@ -58,20 +58,25 @@ const pattern: Pattern<TitleContent> = {
|
|
|
58
58
|
const nameEnd = nameAt + each * (count - 1) + 0.3;
|
|
59
59
|
const settle = hits.find((b) => b >= nameEnd - 0.05) ?? nameEnd;
|
|
60
60
|
const sweepAt = settle;
|
|
61
|
-
|
|
61
|
+
// 머무는 짚개 테마는 부제를 자막·목소리가 말하는 순간에 세우고, 카메라가 이름에서 부제 쪽으로 서지 않고 다가간다 — 한 번에 길게 다가가면
|
|
62
|
+
// 앞뒤 느린 끝이 멈춘 화면이라 이름 하나뿐인 용어 장면이 2초 넘게 섰다. 이름 낱말을 말하는 순간에 나눠 세우면 짧은 장면은 낱말 사이가 도리어 멈춘다
|
|
63
|
+
const dwell = dwells(kit, ctx);
|
|
64
|
+
const subAt = dwell && sub ? sayTimes(kit, [c.sub], sweepAt + 0.3, Math.max(sweepAt + 0.3, ctx.dur - 1))[0] : sweepAt + 0.3;
|
|
62
65
|
const subDur = Math.max(0.3, ctx.dur - subAt - 0.08);
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
66
|
+
if (dwell) followSpoken(kit, ctx, [{ at: settle, el: name }, ...(sub ? [{ at: subAt, el: sub }] : [])], { reach: 0.5, zoom: 1.1 });
|
|
67
|
+
else if (cameraOk(ctx)) kit.stage?.camera.pushIn(settle, { to: name, scale: 1.07, dur: Math.max(0.6, ctx.dur - settle) });
|
|
68
|
+
// 이름이 서면 짚개가 이름과 위 작은 글·부제를 짚는다(짚기 방식은 테마 짚음). 작은 글이 없고 훑는 테마면 이름 글자를 박마다 다시 짚는다
|
|
69
|
+
const markFrom = Math.min(settle, nameAt + 0.35);
|
|
70
|
+
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 });
|
|
71
|
+
const again = !kicker && !sub && !dwell ? beatStops(kit.hits, markFrom, ctx.dur) : null;
|
|
67
72
|
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
73
|
const writeSub = sub ? slots.write(sub, { ctx, text: c.sub!, at: subAt, dur: subDur, seed: `${kit.seed}:sub` }) : null;
|
|
69
74
|
const landed = slots.land(name, { ctx, at: nameEnd, seed: `${kit.seed}:land` });
|
|
70
75
|
|
|
71
76
|
return (t: number) => {
|
|
72
77
|
moving("title.mask", ctx);
|
|
73
|
-
paintName(t, { riseAt: nameAt, each, fill: 0 });
|
|
74
|
-
point(t
|
|
78
|
+
paintName(t, { riseAt: nameAt, each, fill: 0, up: again ? reread(t, again) : undefined });
|
|
79
|
+
point(t);
|
|
75
80
|
applyStyle(name, landed(t));
|
|
76
81
|
|
|
77
82
|
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자를 넘는다";
|