kasaframe 0.1.1 → 0.1.3
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 +10 -8
- package/README.md +16 -1
- package/app/server/engine.ts +133 -15
- package/app/server/main.ts +7 -3
- package/app/web/main.ts +218 -36
- package/app/web/style.css +37 -4
- package/bin/hooks.mjs +14 -1
- package/bin/kf.mjs +20 -2
- package/docs/AGENTS.md +64 -16
- package/music/README.md +31 -7
- package/package.json +2 -2
- package/packages/adapters/src/project.ts +1 -1
- package/packages/check/src/check.ts +10 -3
- package/packages/check/src/rules/assets.ts +4 -1
- package/packages/check/src/rules/frames.ts +2 -1
- package/packages/check/src/rules/layout.ts +30 -6
- package/packages/cli/package.json +2 -1
- package/packages/cli/src/blur.ts +15 -3
- package/packages/cli/src/commands/bench.ts +123 -0
- package/packages/cli/src/commands/blender.ts +72 -49
- package/packages/cli/src/commands/direct.ts +210 -0
- package/packages/cli/src/commands/doctor.ts +9 -1
- package/packages/cli/src/commands/look.ts +9 -2
- package/packages/cli/src/commands/lyrics.ts +47 -1
- package/packages/cli/src/commands/make.ts +729 -150
- package/packages/cli/src/commands/music.ts +78 -0
- package/packages/cli/src/commands/narrate.ts +238 -0
- package/packages/cli/src/commands/new.ts +14 -5
- package/packages/cli/src/commands/render.ts +94 -60
- package/packages/cli/src/commands/series.ts +110 -0
- package/packages/cli/src/commands/setup.ts +42 -3
- package/packages/cli/src/commands/theme.ts +89 -2
- package/packages/cli/src/deps.ts +6 -6
- package/packages/cli/src/kf.ts +14 -3
- package/packages/cli/src/mix.ts +131 -0
- package/packages/cli/src/pool.ts +34 -0
- package/packages/cli/src/segments.ts +402 -0
- package/packages/core/src/index.ts +1 -1
- package/packages/core/src/project.ts +11 -0
- package/packages/director/package.json +23 -0
- package/packages/director/src/assemble.ts +361 -0
- package/packages/director/src/brief.ts +122 -0
- package/packages/director/src/catalog.ts +49 -0
- package/packages/director/src/content.ts +343 -0
- package/packages/director/src/fallback.ts +58 -0
- package/packages/director/src/index.ts +13 -0
- package/packages/director/src/pattern.ts +90 -0
- package/packages/director/src/patterns/fill/cells.ts +115 -0
- package/packages/director/src/patterns/formula/stack.ts +93 -0
- package/packages/director/src/patterns/util.ts +58 -0
- package/packages/director/src/scene.ts +62 -0
- package/packages/director/src/select.ts +163 -0
- package/packages/director/src/transitions.ts +115 -0
- package/packages/gen/PROVIDERS.md +39 -4
- package/packages/gen/src/ask.ts +132 -0
- package/packages/gen/src/audio.ts +91 -0
- package/packages/gen/src/blender.ts +54 -0
- package/packages/gen/src/capabilities.ts +151 -0
- package/packages/gen/src/generate.ts +4 -2
- package/packages/gen/src/image/codex.ts +117 -0
- package/packages/gen/src/index.ts +10 -2
- package/packages/gen/src/keys.ts +26 -0
- package/packages/gen/src/music/elevenlabs.ts +41 -0
- package/packages/gen/src/music/index.ts +120 -0
- package/packages/gen/src/music/library.ts +73 -0
- package/packages/gen/src/music/lyria.ts +52 -0
- package/packages/gen/src/music/types.ts +28 -0
- package/packages/gen/src/registry.ts +6 -3
- package/packages/gen/src/tts/dashscope.ts +64 -0
- package/packages/gen/src/tts/elevenlabs.ts +84 -0
- package/packages/gen/src/tts/gemini.ts +73 -0
- package/packages/gen/src/tts/index.ts +241 -0
- package/packages/gen/src/tts/local.ts +42 -0
- package/packages/gen/src/tts/types.ts +44 -0
- package/packages/gen/src/types.ts +4 -0
- package/packages/gen/src/video/h3.ts +5 -0
- package/packages/gen/src/video/http.ts +1 -1
- package/packages/jizura/src/index.ts +2 -2
- package/packages/jizura/src/install.ts +198 -155
- package/packages/jizura/src/layer.ts +75 -32
- package/packages/jizura/src/pin.ts +4 -1
- package/packages/jizura/src/vendor.ts +140 -0
- package/packages/jizura/src/vite.ts +34 -7
- package/packages/jizura/vendor/JIZURA_LICENSE.txt +21 -0
- package/packages/jizura/vendor/NOTICE +43 -0
- package/packages/jizura/vendor/engine.json +47 -0
- package/packages/jizura/vendor/fonts/BlackHanSans-Regular.woff2 +0 -0
- package/packages/jizura/vendor/fonts/DotGothic16-Regular.woff2 +0 -0
- package/packages/jizura/vendor/fonts/GowunBatang-Bold.woff2 +0 -0
- package/packages/jizura/vendor/fonts/IBMPlexMono-Medium.ttf +0 -0
- package/packages/jizura/vendor/fonts/IBMPlexMono-SemiBold.ttf +0 -0
- package/packages/jizura/vendor/fonts/NotoSansKR-VF.woff2 +0 -0
- package/packages/jizura/vendor/fonts/NotoSerifKR-VF.woff2 +0 -0
- package/packages/jizura/vendor/fonts/OFL-blackhansans.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-dotgothic16.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-gowunbatang.txt +94 -0
- package/packages/jizura/vendor/fonts/OFL-ibmplexmono.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-notosanskr.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-notoserifkr.txt +94 -0
- package/packages/jizura/vendor/fonts/fonts.json +52 -0
- package/packages/jizura/vendor/jizura_engine.js +35024 -0
- package/packages/motion/README.md +11 -0
- package/packages/motion/package.json +1 -0
- package/packages/motion/src/captions.ts +32 -24
- package/packages/motion/src/moves/brushWipe.ts +14 -1
- package/packages/motion/src/moves/dress.ts +191 -0
- package/packages/motion/src/moves/echoStack.ts +3 -3
- package/packages/motion/src/moves/fit.ts +36 -0
- package/packages/motion/src/moves/ghostStack.ts +14 -5
- package/packages/motion/src/moves/imageSwarm.ts +8 -4
- package/packages/motion/src/moves/index.ts +23 -4
- package/packages/motion/src/moves/karaokeLine.ts +17 -3
- package/packages/motion/src/moves/menu.ts +138 -0
- package/packages/motion/src/moves/neonSign.ts +2 -1
- package/packages/motion/src/moves/slamWords.ts +14 -7
- package/packages/motion/src/moves/sparkTrail.ts +16 -0
- package/packages/motion/src/moves/splitFlap.ts +4 -2
- package/packages/motion/src/moves/stepWords.ts +19 -8
- package/packages/motion/src/moves/tickerRows.ts +13 -9
- package/packages/motion/src/moves/tokenPicker.ts +4 -3
- package/packages/motion/src/moves/wordWall.ts +23 -21
- package/packages/motion/src/moves/worldRoll.ts +2 -1
- package/packages/motion/src/transition3d/index.ts +10 -3
- package/packages/motion/src/transition3d/rig.ts +107 -9
- package/packages/motion/src/transition3d/shatter.ts +5 -2
- package/packages/motion/src/transition3d/space.ts +173 -0
- package/packages/motion/src/transition3d/whip.ts +19 -6
- package/packages/motion/src/transition3d/world.ts +54 -0
- package/packages/motion/src/transitions2d/catalog.ts +1 -1
- package/packages/patterns/package.json +15 -0
- package/packages/patterns/src/bars/rank.ts +119 -0
- package/packages/patterns/src/before-after/shrink.ts +127 -0
- package/packages/patterns/src/before-after/strike.ts +125 -0
- package/packages/patterns/src/code/terminal.ts +133 -0
- package/packages/patterns/src/compare/bars.ts +142 -0
- package/packages/patterns/src/compare/duel.ts +103 -0
- package/packages/patterns/src/end/lockup.ts +85 -0
- package/packages/patterns/src/end/slam.ts +91 -0
- package/packages/patterns/src/flow/graph.ts +250 -0
- package/packages/patterns/src/hook-number/counter.ts +101 -0
- package/packages/patterns/src/hook-number/roll.ts +179 -0
- package/packages/patterns/src/hook-number/slam.ts +91 -0
- package/packages/patterns/src/index.ts +38 -0
- package/packages/patterns/src/kit.ts +449 -0
- package/packages/patterns/src/limit/gauge.ts +117 -0
- package/packages/patterns/src/list/cards.ts +131 -0
- package/packages/patterns/src/list/cycle.ts +22 -0
- package/packages/patterns/src/media/panels.ts +100 -0
- package/packages/patterns/src/objects.ts +234 -0
- package/packages/patterns/src/quote/karaoke.ts +126 -0
- package/packages/patterns/src/quote/slam.ts +22 -0
- package/packages/patterns/src/screen/focus.ts +85 -0
- package/packages/patterns/src/steps/path.ts +137 -0
- package/packages/patterns/src/steps/stack.ts +111 -0
- package/packages/patterns/src/term-reveal/chips.ts +92 -0
- package/packages/patterns/src/term-reveal/expand.ts +127 -0
- package/packages/patterns/src/timeline/axis.ts +144 -0
- package/packages/patterns/src/title/mask.ts +85 -0
- package/packages/patterns/src/title/slam.ts +23 -0
- package/packages/patterns/src/toggle-sim/switch.ts +92 -0
- package/packages/patterns/src/trend/columns.ts +108 -0
- package/packages/patterns/src/trend/draw.ts +140 -0
- package/packages/patterns/src/types.ts +67 -0
- package/packages/patterns/src/words/moves.ts +42 -0
- package/packages/patterns/src/words/slam.ts +4 -0
- package/packages/plan/src/asks.ts +256 -0
- package/packages/plan/src/brief.ts +136 -7
- package/packages/plan/src/captions.ts +5 -2
- package/packages/plan/src/index.ts +6 -2
- package/packages/plan/src/llm.ts +64 -2
- package/packages/plan/src/look.ts +3 -1
- package/packages/plan/src/plan.ts +67 -13
- package/packages/plan/src/series.ts +76 -0
- package/packages/plan/src/types.ts +53 -2
- package/packages/themes/package.json +5 -2
- package/packages/themes/src/index.ts +9 -0
- package/packages/themes/src/slotkit.ts +79 -0
- package/packages/themes/src/slots.ts +46 -0
- package/packages/themes/src/stage3d.ts +20 -0
- package/series/og-shorts.json +14 -0
- package/skills/kasaframe/SKILL.md +4 -3
- package/templates/_shared/project/lib/stage.ts +70 -50
- package/templates/_shared/scenes/default.ts +10 -7
- package/templates/_shared/scenes/drop.ts +11 -8
- package/templates/_shared/scenes/end.ts +9 -6
- package/templates/_shared/scenes/hook.ts +8 -6
- package/templates/_shared/scenes/step.ts +13 -4
- package/templates/ad/template.json +20 -1
- package/templates/demo/template.json +11 -0
- package/templates/explainer/template.json +54 -3
- package/templates/launch/template.json +34 -1
- package/templates/mv/scenes/default.ts +11 -8
- package/templates/mv/template.json +4 -2
- package/templates/promo/template.json +42 -1
- package/templates/shorts/scenes/hook.ts +8 -5
- package/templates/shorts/template.json +72 -3
- package/templates/square/template.json +21 -1
- package/themes/debug-hud/slots.ts +99 -0
- package/themes/og-measure/slots.ts +88 -0
- package/themes/riso-zine/slots.ts +81 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { applyStyle, clamp, combine, spring } from "@kasaframe/core";
|
|
2
|
+
import { fitSize, textEm } from "@kasaframe/motion/moves";
|
|
3
|
+
import { area, beatStops, fitOnce, glider, moving, node, riseName, slotsOf } from "../kit.ts";
|
|
4
|
+
import type { Pattern } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
export interface EndContent {
|
|
7
|
+
title: string;
|
|
8
|
+
line?: string;
|
|
9
|
+
url?: string;
|
|
10
|
+
/** assets 기준 그림 */
|
|
11
|
+
logo?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 끝 카드 — 로고가 앉고 이름이 글자마다 서고, 한 줄·주소가 찍히는 동안 짚개가 이름 → 한 줄 → 주소를 박마다 옮겨 다닌다.
|
|
16
|
+
*/
|
|
17
|
+
const pattern: Pattern<EndContent> = {
|
|
18
|
+
id: "end.lockup",
|
|
19
|
+
type: "end",
|
|
20
|
+
does: "로고가 앉고 이름이 서고 한 줄·주소가 찍히는 동안, 짚개가 이름·한 줄·주소를 박마다 옮겨 다닌다",
|
|
21
|
+
energy: ["low", "mid"],
|
|
22
|
+
family: "mask",
|
|
23
|
+
camera: ["none"],
|
|
24
|
+
bg: "stage",
|
|
25
|
+
aspects: ["16:9", "9:16", "1:1"],
|
|
26
|
+
dur: { min: 1.6, max: 6 },
|
|
27
|
+
fits(c) {
|
|
28
|
+
if (typeof c?.title !== "string" || !c.title.trim()) return "title 이 비었다";
|
|
29
|
+
if ([...c.title].length > 20) return "title 이 20자를 넘는다";
|
|
30
|
+
if (c.line && [...c.line].length > 40) return "line 이 40자를 넘는다";
|
|
31
|
+
if (c.url && [...c.url].length > 40) return "url 이 40자를 넘는다";
|
|
32
|
+
return true;
|
|
33
|
+
},
|
|
34
|
+
build(root, ctx, c, kit) {
|
|
35
|
+
const slots = slotsOf(kit);
|
|
36
|
+
const { w, h, vertical, u } = area(ctx, kit);
|
|
37
|
+
// 어림 크기는 첫 프레임 폭 맞춤(fitOnce — 폭 86%·높이 18%/30%)보다 넉넉히 작게 — 맞춤이 실제로 줄이면 첫 프레임이 언제냐에 따라 크기가 갈린다
|
|
38
|
+
const titlePx = fitSize(c.title, w * (vertical ? 0.8 : 0.84), h * (vertical ? 0.17 : 0.26), { hShare: 1, max: Math.round((vertical ? 320 : 300) * u), min: Math.round(72 * u) });
|
|
39
|
+
// 작은 글도 틀 최소 글 크기(쇼츠 48px)를 넘게. 세로 화면은 한 줄·주소도 폭을 넉넉히 쓴다
|
|
40
|
+
const linePx = vertical && c.line ? Math.round(Math.max(52 * u, Math.min(titlePx * 0.7, (w * 0.86) / textEm(c.line)))) : Math.round(Math.max(52 * u, titlePx * 0.22));
|
|
41
|
+
const urlPx = vertical && c.url ? Math.round(Math.max(52 * u, Math.min(titlePx * 0.5, (w * 0.8) / textEm(c.url)))) : Math.round(Math.max(52 * u, titlePx * 0.18));
|
|
42
|
+
const logoPx = Math.round(titlePx * 1.1);
|
|
43
|
+
|
|
44
|
+
const host = node(root, "position:absolute;inset:0;display:flex;align-items:center;justify-content:center");
|
|
45
|
+
// 세로 화면은 이름·한 줄·주소를 위아래로 벌려 세운다(높이의 ⅗) — 가운데 한 덩이로 모으면 위쪽 ⅕ 에만 몰려 아래가 빈다
|
|
46
|
+
const spread = vertical && (c.line || c.url || c.logo);
|
|
47
|
+
const col = node(host, `display:flex;flex-direction:column;align-items:center;${spread ? `height:${Math.round(h * 0.7)}px;justify-content:space-between` : `gap:${Math.round(titlePx * (vertical ? 0.5 : 0.16))}px`}`);
|
|
48
|
+
const logo = c.logo ? node(col, `width:${logoPx}px;height:${logoPx}px;background:center/contain no-repeat url("${ctx.asset(c.logo)}")`) : null;
|
|
49
|
+
const name = node(col, `position:relative;display:flex;font-family:var(--kf-font-display);font-size:${titlePx}px;font-weight:${ctx.style.type?.weightDisplay ?? 800};line-height:1;letter-spacing:-0.02em;color:var(--kf-ink)`);
|
|
50
|
+
kit.stage?.hero(name);
|
|
51
|
+
const paintName = riseName(name, c.title, ctx);
|
|
52
|
+
const line = c.line ? node(col, `font-family:var(--kf-font-sans);font-size:${linePx}px;font-weight:600;color:var(--kf-ink-soft, var(--kf-ink));text-align:center;line-height:1.3;height:1.3em;white-space:nowrap`) : null;
|
|
53
|
+
const url = c.url ? node(col, `font-family:var(--kf-font-mono);font-size:${urlPx}px;color:var(--kf-dim, var(--kf-ink));line-height:1.3;height:1.3em;white-space:nowrap`) : null;
|
|
54
|
+
|
|
55
|
+
const count = Array.from(c.title).length;
|
|
56
|
+
const each = clamp(0.4 / count, 0.025, 0.07);
|
|
57
|
+
const logoAt = 0;
|
|
58
|
+
const nameAt = logo ? 0.15 : kit.first ? -0.2 : 0;
|
|
59
|
+
const nameEnd = nameAt + each * (count - 1) + 0.3;
|
|
60
|
+
const lineAt = nameEnd;
|
|
61
|
+
const lineDur = line ? clamp([...c.line!].length * 0.03, 0.25, 0.6) : 0;
|
|
62
|
+
const urlAt = lineAt + lineDur;
|
|
63
|
+
const urlDur = url ? clamp([...c.url!].length * 0.03, 0.25, 0.6) : 0;
|
|
64
|
+
// 짚개는 이름이 서자마자 이름 → 한 줄 → 주소를 박마다 옮겨 다닌다 — 장면이 길어도 빠르기는 박이 정해 화면이 서지 않는다
|
|
65
|
+
const point = glider(host, [name, line, url].filter((e): e is HTMLDivElement => !!e), { hub: true });
|
|
66
|
+
const fit = fitOnce(name, w * 0.86, h * (vertical ? 0.18 : 0.3), c.title);
|
|
67
|
+
const stops = beatStops(kit.hits, nameEnd - 0.15, ctx.dur);
|
|
68
|
+
|
|
69
|
+
const writeLine = line ? slots.write(line, { ctx, text: c.line!, at: lineAt, dur: lineDur, seed: `${kit.seed}:line` }) : null;
|
|
70
|
+
const writeUrl = url ? slots.write(url, { ctx, text: c.url!, at: urlAt, dur: urlDur, seed: `${kit.seed}:url` }) : null;
|
|
71
|
+
const logoLand = logo ? slots.land(logo, { ctx, at: logoAt + 0.2, seed: `${kit.seed}:logo` }) : null;
|
|
72
|
+
|
|
73
|
+
return (t: number) => {
|
|
74
|
+
moving("end.lockup", ctx);
|
|
75
|
+
if (logo && logoLand) applyStyle(logo, combine({ scale: spring(t - logoAt, { from: 0.6, to: 1, stiffness: 280, damping: 16 }) }, logoLand(t)));
|
|
76
|
+
fit();
|
|
77
|
+
paintName(t, { riseAt: nameAt, each, fill: 0 });
|
|
78
|
+
point(t, stops);
|
|
79
|
+
writeLine?.(t);
|
|
80
|
+
writeUrl?.(t);
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export default pattern;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { applyStyle, clamp, combine, spring } from "@kasaframe/core";
|
|
2
|
+
import { fitSize, textEm } from "@kasaframe/motion/moves";
|
|
3
|
+
import { area, beatStops, cameraOk, fitOnce, glider, moving, node, sceneDrop, slam, slotsOf } from "../kit.ts";
|
|
4
|
+
import type { EndContent } from "./lockup.ts";
|
|
5
|
+
import type { Pattern } from "../types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 끝 카드를 쾅 — 이름이 외곽선으로 먼저 자리를 잡고, 드롭(없으면 첫 박)에 꽉 찬 이름이 내려앉으며 테마 드롭이 터진다.
|
|
9
|
+
* 주소 칩이 톡 붙고, 반전 칸이 이름을 짚어 마지막 프레임에 끝난다. 끝이 가장 센 순간인 영상(홍보·공개)용.
|
|
10
|
+
*/
|
|
11
|
+
const pattern: Pattern<EndContent> = {
|
|
12
|
+
id: "end.slam",
|
|
13
|
+
type: "end",
|
|
14
|
+
does: "외곽선 이름이 먼저 서고 드롭에 꽉 찬 이름이 쾅 내려앉는다, 주소 칩이 붙고 반전 칸이 이름을 짚는다",
|
|
15
|
+
energy: ["high"],
|
|
16
|
+
family: "slam",
|
|
17
|
+
camera: ["impact"],
|
|
18
|
+
bg: "stage",
|
|
19
|
+
aspects: ["16:9", "9:16", "1:1"],
|
|
20
|
+
dur: { min: 1.6, max: 5 },
|
|
21
|
+
purposes: { prefer: ["promo", "launch", "ad"], avoid: ["explainer"] },
|
|
22
|
+
fits(c) {
|
|
23
|
+
if (typeof c?.title !== "string" || !c.title.trim() || [...c.title].length > 16) return "title 이 비었거나 16자를 넘는다";
|
|
24
|
+
if (c.url && [...c.url].length > 32) return "url 이 32자를 넘는다";
|
|
25
|
+
return true;
|
|
26
|
+
},
|
|
27
|
+
build(root, ctx, c, kit) {
|
|
28
|
+
const slots = slotsOf(kit);
|
|
29
|
+
const { w, h, vertical, u } = area(ctx, kit);
|
|
30
|
+
const titlePx = fitSize(c.title, w * 0.94, h * (vertical ? 0.2 : 0.4), { hShare: 1, max: Math.round(380 * u), min: Math.round(80 * u) });
|
|
31
|
+
// 세로 화면은 이름 한 줄이 가는 띠라 한 줄·주소를 키우고 사이를 띄워 화면을 세로로도 쓴다
|
|
32
|
+
const urlPx = Math.round(Math.max(40 * u, titlePx * (vertical ? 0.28 : 0.2)));
|
|
33
|
+
const weight = ctx.style.type?.weightDisplay ?? 800;
|
|
34
|
+
const font = `font-family:var(--kf-font-display);font-size:inherit;font-weight:${weight};line-height:1;letter-spacing:-0.03em;white-space:pre`;
|
|
35
|
+
|
|
36
|
+
const host = node(root, "position:absolute;inset:0;display:flex;align-items:center;justify-content:center");
|
|
37
|
+
// 세로 화면은 이름·한 줄·주소를 위아래로 쌓아 높이 ⅔ 로 벌린다 — 가로로 붙이면 위쪽 가는 띠 하나가 되어 아래가 빈다
|
|
38
|
+
const spread = vertical && !!(c.line || c.url);
|
|
39
|
+
const col = node(host, `position:relative;display:flex;flex-direction:column;align-items:center;${spread ? `height:${Math.round(h * 0.66)}px;justify-content:space-between` : `gap:${Math.round(titlePx * (vertical ? 0.6 : 0.22))}px`}`);
|
|
40
|
+
const stage = node(col, `position:relative;font-size:${titlePx}px`);
|
|
41
|
+
// 폭 86% — 쾅의 출렁임(±8%)까지 안전 영역 가장자리 띠에 안 닿게
|
|
42
|
+
const fit = fitOnce(stage, w * 0.86, h * (vertical ? 0.24 : 0.42));
|
|
43
|
+
// 쾅 전 자리표 — 옅게 채운 이름(외곽선만으로는 밝은 바탕에서 드러남이 화면 변화로 안 읽힌다)
|
|
44
|
+
const ghost = node(stage, `${font};color:var(--kf-ink);opacity:.16`, c.title);
|
|
45
|
+
const name = node(stage, `position:absolute;left:0;top:0;${font};color:var(--kf-ink);transform-origin:50% 60%`, c.title);
|
|
46
|
+
kit.stage?.hero(stage);
|
|
47
|
+
const line = c.line || c.url ? node(col, spread ? `display:flex;flex-direction:column;gap:${Math.round(h * 0.06)}px;align-items:center;line-height:1.3` : `display:flex;gap:0.6em;align-items:center;font-size:${urlPx}px;line-height:1.3;height:1.4em`) : null;
|
|
48
|
+
// 세로면 한 줄·주소를 폭을 넉넉히 쓰는 크기로 따로 — 이름보다는 작게
|
|
49
|
+
const sizeOf = (text: string, share: number, cap: number) => `font-size:${Math.round(Math.max(52 * u, Math.min(titlePx * cap, (w * share) / textEm(text))))}px;`;
|
|
50
|
+
const lineEl = c.line ? node(line!, `${spread ? sizeOf(c.line, 0.86, 0.6) : ""}font-family:var(--kf-font-sans);font-weight:600;color:var(--kf-ink-soft, var(--kf-ink));white-space:nowrap`) : null;
|
|
51
|
+
const urlEl = c.url ? node(line!, `${spread ? sizeOf(c.url, 0.7, 0.42) : ""}font-family:var(--kf-font-mono);font-weight:700;color:var(--kf-on-accent);background:var(--kf-accent);padding:0.05em 0.45em;white-space:nowrap`) : null;
|
|
52
|
+
// 드롭 터짐은 화면 전체 층에 — 카메라 층(안전 영역)에 깔면 터짐이 네모 판으로 보인다
|
|
53
|
+
const burst = node(kit.stage?.box ?? root, "position:absolute;inset:0;pointer-events:none;z-index:50");
|
|
54
|
+
|
|
55
|
+
// 드롭이 장면 앞쪽(1.6초 안)일 때만 그 드롭에 쾅 — 늦은 드롭까지 이름을 비워 두면 그 사이가 멈춘 화면이다
|
|
56
|
+
const found = sceneDrop(ctx);
|
|
57
|
+
const di = found !== null && ctx.beats.drop(found) <= 1.6 ? found : null;
|
|
58
|
+
const hits = kit.hits.length ? kit.hits : [0];
|
|
59
|
+
// 드롭이 없으면 곧바로 쾅 — 기다릴 이유가 없다
|
|
60
|
+
const at = di !== null ? ctx.beats.drop(di) : (hits.find((b) => b >= 0.1) ?? 0.12);
|
|
61
|
+
const dropPaint = di !== null && slots.drop ? slots.drop(burst, { ctx, drop: di }) : null;
|
|
62
|
+
if (di === null && kit.stage && cameraOk(ctx)) kit.stage.camera.impact(at, { amount: 0.04 });
|
|
63
|
+
const lineAt = at + 0.2;
|
|
64
|
+
const urlAt = lineAt + (c.line ? 0.35 : 0);
|
|
65
|
+
const writeLine = lineEl ? slots.write(lineEl, { ctx, text: c.line!, at: lineAt, dur: 0.35, seed: `${kit.seed}:line` }) : null;
|
|
66
|
+
const writeUrl = urlEl ? slots.write(urlEl, { ctx, text: c.url!, at: urlAt, dur: 0.3, seed: `${kit.seed}:url` }) : null;
|
|
67
|
+
const landed = slots.land(name, { ctx, at, seed: `${kit.seed}:land` });
|
|
68
|
+
// 앉은 뒤엔 짚개가 이름 ↔ 한 줄·주소를 박마다 번갈아 짚는다
|
|
69
|
+
const point = glider(host, [stage, ...(lineEl ? [lineEl] : []), ...(urlEl ? [urlEl] : [])], { hub: true });
|
|
70
|
+
const stops = beatStops(kit.hits, at + 0.2, ctx.dur);
|
|
71
|
+
|
|
72
|
+
return (t: number) => {
|
|
73
|
+
moving("end.slam", ctx);
|
|
74
|
+
fit();
|
|
75
|
+
const s = dropPaint?.(t);
|
|
76
|
+
applyStyle(name, combine(slam(t, at), landed(t), s?.punch));
|
|
77
|
+
name.style.visibility = t >= at - 0.16 ? "visible" : "hidden";
|
|
78
|
+
// 쾅 전엔 외곽선 이름을 왼쪽부터 짚어 자리를 알린다 — 앉은 뒤엔 꽉 찬 이름을 마지막 프레임까지 짚는다
|
|
79
|
+
ghost.style.opacity = t < at ? "0.16" : "0";
|
|
80
|
+
ghost.style.clipPath = t < at ? `inset(0 ${((1 - clamp(t / Math.max(0.1, at - 0.16))) * 100).toFixed(2)}% 0 0)` : "";
|
|
81
|
+
point(t, stops);
|
|
82
|
+
if (line) line.style.visibility = t >= lineAt ? "visible" : "hidden";
|
|
83
|
+
writeLine?.(t);
|
|
84
|
+
writeUrl?.(t);
|
|
85
|
+
if (urlEl) applyStyle(urlEl, t >= urlAt ? { scale: spring(t - urlAt, { from: 0.5, to: 1, stiffness: 320, damping: 14 }) } : { opacity: 0 });
|
|
86
|
+
if (s) applyStyle(host, s.shake);
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export default pattern;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { applyStyle, clamp, combine, spring } from "@kasaframe/core";
|
|
2
|
+
import { fitSize, textEm } from "@kasaframe/motion/moves";
|
|
3
|
+
import { partial } from "@kasaframe/themes/slotkit";
|
|
4
|
+
import { area, node, slotsOf, spaced, moving } from "../kit.ts";
|
|
5
|
+
import { cssColor, objectCss, objectFor, objectById } from "../objects.ts";
|
|
6
|
+
import type { Pattern } from "../types.ts";
|
|
7
|
+
|
|
8
|
+
export interface FlowContent {
|
|
9
|
+
nodes: { id: string; label: string; group?: string }[];
|
|
10
|
+
edges?: { from: string; to: string; label?: string }[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Pt = [number, number];
|
|
14
|
+
|
|
15
|
+
// 깊이(가장 긴 들어오는 길) 차례로 칸을 나눈다 — 거꾸로 가는 연결은 깊이를 정하는 데 안 쓴다
|
|
16
|
+
function layers(c: FlowContent): Map<string, number> {
|
|
17
|
+
const edges = c.edges?.length ? c.edges : c.nodes.slice(1).map((n, i) => ({ from: c.nodes[i].id, to: n.id }));
|
|
18
|
+
const depth = new Map(c.nodes.map((n) => [n.id, 0]));
|
|
19
|
+
for (let k = 0; k < c.nodes.length; k++) {
|
|
20
|
+
for (const e of edges) {
|
|
21
|
+
const a = depth.get(e.from);
|
|
22
|
+
const b = depth.get(e.to);
|
|
23
|
+
if (a === undefined || b === undefined) continue;
|
|
24
|
+
const order = c.nodes.findIndex((n) => n.id === e.to) > c.nodes.findIndex((n) => n.id === e.from);
|
|
25
|
+
if (order && b < a + 1) depth.set(e.to, a + 1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return depth;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 구조 — 상자가 깊이 차례로 서고 연결선이 그어진 뒤, 요청 하나(객체 사전 request)가 연결을 따라 끝 상자까지 가고 응답(response)이
|
|
33
|
+
* 같은 길로 돌아온다(장면이 남으면 되풀이). 지나간 길은 강조색으로 남고 닿은 상자가 켜지며 닿을 때마다 툭 — 움직이는 것은 「무엇이 어디로 흐르나」 하나뿐.
|
|
34
|
+
*/
|
|
35
|
+
const pattern: Pattern<FlowContent> = {
|
|
36
|
+
id: "flow.graph",
|
|
37
|
+
type: "flow",
|
|
38
|
+
does: "상자가 깊이 차례로 서고 연결이 그어진 뒤, 요청 하나가 연결을 따라 끝까지 간다",
|
|
39
|
+
energy: ["mid"],
|
|
40
|
+
family: "draw",
|
|
41
|
+
camera: ["none"],
|
|
42
|
+
bg: "stage",
|
|
43
|
+
aspects: ["16:9", "9:16", "1:1"],
|
|
44
|
+
dur: { min: 3, max: 9 },
|
|
45
|
+
themes: { "debug-hud": 0.4, "kasaterm-cells": 0.4 },
|
|
46
|
+
fits(c) {
|
|
47
|
+
const nodes = c?.nodes;
|
|
48
|
+
if (!Array.isArray(nodes) || nodes.length < 2) return "nodes 가 둘보다 적다";
|
|
49
|
+
if (nodes.length > 8) return "nodes 가 여덟을 넘는다";
|
|
50
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
51
|
+
if (ids.size !== nodes.length) return "node id 가 겹친다";
|
|
52
|
+
for (const [i, n] of nodes.entries()) if (typeof n?.label !== "string" || !n.label.trim() || [...n.label].length > 14) return `nodes[${i}].label 이 비었거나 14자를 넘는다`;
|
|
53
|
+
for (const e of c.edges ?? []) if (!ids.has(e.from) || !ids.has(e.to)) return `연결 ${e.from}→${e.to} 의 상자가 없다`;
|
|
54
|
+
const depth = layers(c);
|
|
55
|
+
const perCol = new Map<number, number>();
|
|
56
|
+
for (const d of depth.values()) perCol.set(d, (perCol.get(d) ?? 0) + 1);
|
|
57
|
+
if (Math.max(...perCol.values()) > 4) return "한 깊이에 상자가 넷을 넘는다";
|
|
58
|
+
return true;
|
|
59
|
+
},
|
|
60
|
+
build(root, ctx, c, kit) {
|
|
61
|
+
const slots = slotsOf(kit);
|
|
62
|
+
const { w, h, vertical, u } = area(ctx, kit);
|
|
63
|
+
const edges = c.edges?.length ? c.edges : c.nodes.slice(1).map((n, i) => ({ from: c.nodes[i].id, to: n.id, label: undefined as string | undefined }));
|
|
64
|
+
const depth = layers(c);
|
|
65
|
+
const cols = Math.max(...depth.values()) + 1;
|
|
66
|
+
const colOf = (d: number) => c.nodes.filter((n) => depth.get(n.id) === d);
|
|
67
|
+
const maxPer = Math.max(...Array.from({ length: cols }, (_, d) => colOf(d).length));
|
|
68
|
+
|
|
69
|
+
// 칸 — 흐름 방향(가로면 가로)으로 깊이, 그 직각으로 같은 깊이의 상자
|
|
70
|
+
const along = vertical ? h : w;
|
|
71
|
+
const across = vertical ? w : h;
|
|
72
|
+
const cell = (along * 0.92) / cols;
|
|
73
|
+
// 상자는 칸을 넉넉히 — 이름은 띄어쓰기에서 두 줄까지, 사전 물체는 이름 위에
|
|
74
|
+
const boxL = Math.min(cell * 0.74, along * 0.3);
|
|
75
|
+
const boxA = Math.min((across * 0.86) / maxPer - 40 * u, boxL * (vertical ? 0.5 : 0.95));
|
|
76
|
+
// 세로 화면은 흐름이 위아래 — 상자는 폭을 넉넉히(같은 깊이 상자끼리 나눠), 높이는 칸의 60%
|
|
77
|
+
const bw = vertical ? Math.min(w * 0.78, (w * 0.88) / maxPer - 30 * u) : boxL;
|
|
78
|
+
const bh = vertical ? cell * 0.6 : boxA;
|
|
79
|
+
const pieces = c.nodes.flatMap((n) => n.label.split(/\s+/));
|
|
80
|
+
const longest = pieces.reduce((a, p) => (textEm(p) > textEm(a) ? p : a), "");
|
|
81
|
+
const labelPx = fitSize(longest, bw * (vertical ? 0.6 : 0.8), bh, { hShare: vertical ? 0.4 : 0.26, max: Math.round(80 * u), min: Math.round(28 * u) });
|
|
82
|
+
const glyphPx = Math.round(Math.min(bh * 0.36, labelPx * 1.7));
|
|
83
|
+
|
|
84
|
+
const pos = new Map<string, { x: number; y: number }>();
|
|
85
|
+
for (let d = 0; d < cols; d++) {
|
|
86
|
+
const list = colOf(d);
|
|
87
|
+
list.forEach((n, i) => {
|
|
88
|
+
const a = (along - cell * cols) / 2 + cell * (d + 0.5);
|
|
89
|
+
const b = (across / (list.length + 1)) * (i + 1);
|
|
90
|
+
pos.set(n.id, vertical ? { x: b, y: a } : { x: a, y: b });
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const host = node(root, "position:absolute;inset:0");
|
|
95
|
+
const wires = node(host, "position:absolute;inset:0");
|
|
96
|
+
const trailLayer = node(host, "position:absolute;inset:0");
|
|
97
|
+
const hits = spaced(kit.hits.length ? kit.hits : [0], 0.3);
|
|
98
|
+
|
|
99
|
+
// 상자는 깊이 차례로 — 같은 깊이는 같은 박. 선은 뒤 상자가 서기 직전에 그어진다
|
|
100
|
+
const order = [...c.nodes].sort((a, b) => depth.get(a.id)! - depth.get(b.id)!);
|
|
101
|
+
// 요청이 길을 열며 간다 — 첫 상자가 서고 곧 요청이 떠나, 다음 깊이 상자는 요청이 그쪽으로 출발할 때 서고 닿을 때 차오른다.
|
|
102
|
+
// 상자를 먼저 다 세워 두고 기다리면 밝은 바탕에서 흰 카드가 서는 것만으로는 화면이 바뀐 것으로 안 읽힌다
|
|
103
|
+
const goFrom = 0.25;
|
|
104
|
+
const leg = 0.45;
|
|
105
|
+
const colAt = Array.from({ length: cols }, (_, d) => (d === 0 ? 0 : goFrom + (d - 1) * leg));
|
|
106
|
+
const at = (id: string) => colAt[depth.get(id)!];
|
|
107
|
+
|
|
108
|
+
const boxes = new Map(
|
|
109
|
+
order.map((n, i) => {
|
|
110
|
+
const p = pos.get(n.id)!;
|
|
111
|
+
const box = node(host, `position:absolute;left:${p.x - bw / 2}px;top:${p.y - bh / 2}px;width:${bw}px;height:${bh}px;transform-origin:50% 50%`);
|
|
112
|
+
const paintPanel = slots.panel(box, { ctx, at: at(n.id), seed: `${kit.seed}:n${i}` });
|
|
113
|
+
const lit = node(box, "position:absolute;inset:0;border-radius:var(--kf-radius-md);visibility:hidden");
|
|
114
|
+
// 지금 지나가는 것(요청·응답)이 닿는 동안 그 색으로 상자가 차오르는 겹
|
|
115
|
+
const sweep = node(box, "position:absolute;inset:0;border-radius:var(--kf-radius-md);visibility:hidden");
|
|
116
|
+
const face = node(box, `position:absolute;inset:0;display:flex;flex-direction:${vertical ? "row" : "column"};align-items:center;justify-content:center;gap:${Math.round(labelPx * 0.35)}px;color:var(--kf-ink)`);
|
|
117
|
+
const obj = objectFor(n.label);
|
|
118
|
+
const glyph = obj ? node(face, objectCss(obj, glyphPx)) : null;
|
|
119
|
+
const label = node(face, `font-family:var(--kf-font-sans);font-size:${labelPx}px;font-weight:700;line-height:1.1;text-align:center;word-break:keep-all;max-width:${Math.round(bw * 0.86)}px`);
|
|
120
|
+
const write = slots.write(label, { ctx, text: n.label, at: at(n.id) + 0.05, dur: 0.25, seed: `${kit.seed}:t${i}` });
|
|
121
|
+
const landed = slots.land(box, { ctx, at: at(n.id), seed: `${kit.seed}:land${i}` });
|
|
122
|
+
return [n.id, { box, paintPanel, lit, sweep, face, glyph, write, landed, outline: !!obj?.shape2d.outline, color: obj ? cssColor(obj.color) : "" }] as const;
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const route = (a: string, b: string): Pt[] => {
|
|
127
|
+
const p = pos.get(a)!;
|
|
128
|
+
const q = pos.get(b)!;
|
|
129
|
+
if (vertical) {
|
|
130
|
+
const sy = p.y + bh / 2;
|
|
131
|
+
const ty = q.y - bh / 2;
|
|
132
|
+
const my = (sy + ty) / 2;
|
|
133
|
+
return [[p.x, sy], [p.x, my], [q.x, my], [q.x, ty]];
|
|
134
|
+
}
|
|
135
|
+
const sx = p.x + bw / 2;
|
|
136
|
+
const tx = q.x - bw / 2;
|
|
137
|
+
const mx = (sx + tx) / 2;
|
|
138
|
+
return [[sx, p.y], [mx, p.y], [mx, q.y], [tx, q.y]];
|
|
139
|
+
};
|
|
140
|
+
const wired = edges.map((e, i) => {
|
|
141
|
+
const pts = route(e.from, e.to);
|
|
142
|
+
const drawAt = at(e.to);
|
|
143
|
+
return { e, pts, paint: slots.trace(wires, { ctx, points: pts, at: drawAt, dur: leg, seed: `${kit.seed}:e${i}` }), drawAt, i };
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// 요청의 길 — 첫 상자에서 연결을 따라 끝 상자까지(갈림이면 먼저 적힌 쪽)
|
|
147
|
+
const path: typeof wired = [];
|
|
148
|
+
let cur = order[0].id;
|
|
149
|
+
for (let k = 0; k < edges.length; k++) {
|
|
150
|
+
const next = wired.find((x) => x.e.from === cur && !path.includes(x));
|
|
151
|
+
if (!next) break;
|
|
152
|
+
path.push(next);
|
|
153
|
+
cur = next.e.to;
|
|
154
|
+
}
|
|
155
|
+
// 요청은 한 연결에 0.45초 — 끝에 닿으면 응답(사전 response)이 같은 길을 거꾸로 돌아오고, 장면이 남으면 다시 요청이 간다
|
|
156
|
+
const L = Math.max(1, path.length);
|
|
157
|
+
const trip = L * leg;
|
|
158
|
+
const res = objectById("response")!;
|
|
159
|
+
const req = objectById("request")!;
|
|
160
|
+
const dotPx = Math.round(Math.min(bh * 0.5, 0.075 * Math.min(w, h)));
|
|
161
|
+
const dot = node(host, `position:absolute;left:0;top:0;margin:-${dotPx / 2}px 0 0 -${dotPx / 2}px;${objectCss(req, dotPx)};box-shadow:0 0 0 ${Math.round(dotPx * 0.18)}px color-mix(in srgb, var(--kf-accent) 30%, transparent)`);
|
|
162
|
+
const trails = path.map(() => {
|
|
163
|
+
const svg = host.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
164
|
+
svg.setAttribute("style", "position:absolute;inset:0;width:100%;height:100%;overflow:visible");
|
|
165
|
+
const line = host.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "polyline");
|
|
166
|
+
line.setAttribute("fill", "none");
|
|
167
|
+
line.setAttribute("stroke", "var(--kf-accent)");
|
|
168
|
+
line.setAttribute("stroke-width", String(Math.round(10 * u)));
|
|
169
|
+
line.setAttribute("stroke-linejoin", "round");
|
|
170
|
+
line.setAttribute("stroke-linecap", "round");
|
|
171
|
+
svg.append(line);
|
|
172
|
+
trailLayer.append(svg);
|
|
173
|
+
return line;
|
|
174
|
+
});
|
|
175
|
+
const tripColor = (k: number) => cssColor(k % 2 === 1 ? res.color : req.color);
|
|
176
|
+
// 지금 길 — 몇 번째 왕복(k)·몇 번째 연결(i)·연결 안 진행(q). 출발 전이면 null
|
|
177
|
+
const where = (t: number) => {
|
|
178
|
+
if (!path.length || t < goFrom) return null;
|
|
179
|
+
const k = Math.floor((t - goFrom) / trip);
|
|
180
|
+
const local = t - goFrom - k * trip;
|
|
181
|
+
const i = Math.min(L - 1, Math.floor(local / leg));
|
|
182
|
+
return { k, i, q: clamp((local - i * leg) / leg), back: k % 2 === 1 };
|
|
183
|
+
};
|
|
184
|
+
const destOf = (k: number, i: number) => (k % 2 === 1 ? path[L - 1 - i].e.from : path[i].e.to);
|
|
185
|
+
// 그 상자에 요청·응답이 마지막으로 닿은 시각 — 닿을 때마다 상자가 한 번 툭
|
|
186
|
+
const lastArrival = (id: string, t: number): { when: number; k: number } | null => {
|
|
187
|
+
if (!path.length || t < goFrom) return null;
|
|
188
|
+
// 출발 상자는 첫 요청이 떠나는 순간 켜진다
|
|
189
|
+
let best: { when: number; k: number } | null = id === order[0].id ? { when: goFrom, k: 0 } : null;
|
|
190
|
+
for (let k = 0; goFrom + k * trip <= t; k++) {
|
|
191
|
+
for (let i = 0; i < L; i++) {
|
|
192
|
+
const when = goFrom + k * trip + (i + 1) * leg;
|
|
193
|
+
if (when > t) break;
|
|
194
|
+
if (destOf(k, i) === id) best = { when, k };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return best;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
return (t: number) => {
|
|
201
|
+
moving("flow.graph", ctx);
|
|
202
|
+
for (const [id, b] of boxes) {
|
|
203
|
+
const on = t >= at(id);
|
|
204
|
+
b.box.style.visibility = on ? "visible" : "hidden";
|
|
205
|
+
if (!on) continue;
|
|
206
|
+
b.paintPanel(t);
|
|
207
|
+
b.write(t);
|
|
208
|
+
const pop = spring(t - at(id), { from: 0.7, to: 1, stiffness: 320, damping: 17 });
|
|
209
|
+
// 상자는 지나가는 것이 닿은 뒤 한 연결 동안만 켜져 있다 — 「지금 처리 중인 마디」. 계속 켜 두면 다음 왕복부터 화면이 안 바뀐다
|
|
210
|
+
const arrived = lastArrival(id, t);
|
|
211
|
+
const last = arrived && t - arrived.when < leg ? arrived : null;
|
|
212
|
+
const now = where(t);
|
|
213
|
+
const coming = now && destOf(now.k, now.i) === id ? now : null;
|
|
214
|
+
b.lit.style.visibility = last ? "visible" : "hidden";
|
|
215
|
+
if (last) b.lit.style.background = tripColor(last.k);
|
|
216
|
+
b.sweep.style.visibility = coming ? "visible" : "hidden";
|
|
217
|
+
if (coming) {
|
|
218
|
+
b.sweep.style.background = tripColor(coming.k);
|
|
219
|
+
// 오는 쪽에서 차오른다 — 요청은 위(왼쪽)에서, 응답은 아래(오른쪽)에서
|
|
220
|
+
const r = ((1 - coming.q) * 100).toFixed(2);
|
|
221
|
+
b.sweep.style.clipPath = vertical ? (coming.back ? `inset(${r}% 0 0 0)` : `inset(0 0 ${r}% 0)`) : coming.back ? `inset(0 0 0 ${r}%)` : `inset(0 ${r}% 0 0)`;
|
|
222
|
+
}
|
|
223
|
+
const litNow = !!last || (coming !== null && coming.q > 0.5);
|
|
224
|
+
b.face.style.color = litNow ? "var(--kf-on-accent)" : "var(--kf-ink)";
|
|
225
|
+
if (b.glyph) b.glyph.style.setProperty(b.outline ? "border-color" : "background", litNow ? "var(--kf-on-accent)" : b.color);
|
|
226
|
+
const bump = arrived ? spring(t - arrived.when, { from: 1.06, to: 1, stiffness: 300, damping: 16 }) : 1;
|
|
227
|
+
applyStyle(b.box, combine({ scale: pop * bump }, b.landed(t)));
|
|
228
|
+
}
|
|
229
|
+
for (const wv of wired) wv.paint(t);
|
|
230
|
+
let shown = false;
|
|
231
|
+
// 첫 길(요청)은 지나간 자리에 강조색 자국을 남긴다
|
|
232
|
+
path.forEach((p, k) => {
|
|
233
|
+
const q = clamp((t - (goFrom + k * leg)) / leg);
|
|
234
|
+
const drawn = partial(p.pts, q);
|
|
235
|
+
trails[k].setAttribute("points", q > 0 ? drawn.map((v) => `${v[0].toFixed(1)},${v[1].toFixed(1)}`).join(" ") : "");
|
|
236
|
+
});
|
|
237
|
+
const now = where(t);
|
|
238
|
+
if (now) {
|
|
239
|
+
const p = path[now.back ? L - 1 - now.i : now.i];
|
|
240
|
+
const [x, y] = partial(p.pts, now.back ? 1 - now.q : now.q).at(-1)!;
|
|
241
|
+
dot.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px)`;
|
|
242
|
+
dot.style.background = tripColor(now.k);
|
|
243
|
+
shown = true;
|
|
244
|
+
}
|
|
245
|
+
dot.style.visibility = shown ? "visible" : "hidden";
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export default pattern;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { applyStyle, clamp } from "@kasaframe/core";
|
|
2
|
+
import { textEm } from "@kasaframe/motion/moves";
|
|
3
|
+
import { area, beatStops, decimalsOf, fitOnce, glider, moving, node, odometer, slotsOf, timesSign } from "../kit.ts";
|
|
4
|
+
import type { HookNumber } from "./roll.ts";
|
|
5
|
+
import type { Pattern } from "../types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 계수기 — 숫자 자리마다 판이 서고 주행계 띠가 굴러 값에 앉는다(끝자리 띠가 연속으로 구르고 윗자리는 올림 때만). 굴림 2.2초로 박아 둔다.
|
|
9
|
+
* 앉은 뒤엔 짚개가 숫자와 라벨을 박마다 번갈아 짚는다. 롤(roll)이 「값이 바뀐다」라면 이쪽은 「센다」.
|
|
10
|
+
*/
|
|
11
|
+
const pattern: Pattern<HookNumber> = {
|
|
12
|
+
id: "hook-number.counter",
|
|
13
|
+
type: "hook-number",
|
|
14
|
+
does: "숫자 자리마다 판이 서고 주행계 띠가 굴러 값에 앉은 뒤, 짚개가 숫자와 라벨을 번갈아 짚는다",
|
|
15
|
+
energy: ["low", "mid"],
|
|
16
|
+
family: "flap",
|
|
17
|
+
camera: ["none"],
|
|
18
|
+
bg: "stage",
|
|
19
|
+
aspects: ["16:9", "9:16", "1:1"],
|
|
20
|
+
dur: { min: 2.6, max: 9 },
|
|
21
|
+
themes: { "debug-hud": 0.5, "kasaterm-cells": 0.5 },
|
|
22
|
+
fits(c) {
|
|
23
|
+
if (!Number.isFinite(c?.value) || c.value < 0) return "value 가 0 이상 수가 아니다";
|
|
24
|
+
if (c.from !== undefined && (!Number.isFinite(c.from) || c.from < 0)) return "from 이 0 이상 수가 아니다";
|
|
25
|
+
if (typeof c.label !== "string" || !c.label.trim() || [...c.label].length > 24) return "label 이 비었거나 24자를 넘는다";
|
|
26
|
+
const dec = c.decimals ?? decimalsOf(c.value);
|
|
27
|
+
const text = c.value.toFixed(dec);
|
|
28
|
+
if (text.replace(".", "").length > 7) return "자리가 일곱을 넘는다";
|
|
29
|
+
return true;
|
|
30
|
+
},
|
|
31
|
+
build(root, ctx, c, kit) {
|
|
32
|
+
const slots = slotsOf(kit);
|
|
33
|
+
const { w, h, vertical, u } = area(ctx, kit);
|
|
34
|
+
const from = c.from ?? 0;
|
|
35
|
+
const dec = c.decimals ?? Math.max(decimalsOf(c.value), decimalsOf(from));
|
|
36
|
+
// 쉼표 없이 자리를 세운다(주행계 띠는 숫자 칸만) — 자리 수는 큰 쪽 기준
|
|
37
|
+
const template = Math.max(c.value, from).toFixed(dec);
|
|
38
|
+
const unit = c.unit ?? "";
|
|
39
|
+
const digits = template.replace(".", "").length;
|
|
40
|
+
// 주행계 띠 한 칸 0.62em(고정폭 글꼴 0.6em + 틈) — 앉은 뒤 글도 같은 칸 폭으로
|
|
41
|
+
const cellEm = 0.62;
|
|
42
|
+
// 세로 화면은 단위를 숫자 아래로 내려 위아래로 쓴다 — 숫자는 폭을 다 쓰고 높이도 ⅓ 넘게. 배수 기호(×)만은 늘 옆에 붙인다
|
|
43
|
+
// 세로 높이 상한 — 라벨 줄(0.24)·틈(0.18)·숫자판(1.1 + 위아래 여백 0.16)·틈(0.06)·단위(0.5)를 다 쌓아 안전 영역 86% 안에 들게
|
|
44
|
+
// (넘치면 라벨이 플랫폼 UI 자리로 올라간다)
|
|
45
|
+
const times = timesSign(unit);
|
|
46
|
+
const beside = !!unit && (!vertical || times);
|
|
47
|
+
const gapEm = times ? 0.06 : 0.2;
|
|
48
|
+
// 세로 화면은 숫자판 아래에 출처(두 줄까지)를 세운다 — 숫자 장면은 출처가 화면에 서야 하고, 단위가 옆에 붙으면 아래가 비어 세로 채움에 못 미친다
|
|
49
|
+
const srcText = vertical && c.source ? `출처 · ${c.source}` : "";
|
|
50
|
+
const srcPx = srcText ? Math.round(Math.max(28 * u, Math.min(40 * u, (w * 0.92 * 2 * 0.95) / (textEm(srcText) * 1.05)))) : 0;
|
|
51
|
+
// 폭의 0.3 은 단위 앞 틈과 숫자판 좌우 여백 — 빼고 재면 단위를 옆에 둔 줄이 안전 영역 가장자리에 붙는다
|
|
52
|
+
const numPx = Math.round(Math.min((w * 0.86) / (digits * cellEm + (beside ? 0.5 * textEm(unit) + 0.3 : 0)), vertical ? (h * 0.86 - srcPx * 2.6) / (0.24 + 0.18 + 1.26 + (unit && !beside ? 0.56 : 0) + (srcText ? 0.18 : 0)) : h * 0.42));
|
|
53
|
+
const lineH = Math.round(numPx * 1.1);
|
|
54
|
+
const labelPx = Math.round(Math.max(52 * u, numPx * (vertical ? 0.2 : 0.18)));
|
|
55
|
+
|
|
56
|
+
const host = node(root, "position:absolute;inset:0;display:flex;align-items:center;justify-content:center");
|
|
57
|
+
const col = node(host, `display:flex;flex-direction:column;align-items:center;gap:${Math.round(numPx * 0.18)}px`);
|
|
58
|
+
const label = node(col, `font-family:var(--kf-font-sans);font-size:${labelPx}px;font-weight:700;color:var(--kf-ink);white-space:nowrap;line-height:1.2;height:1.2em`);
|
|
59
|
+
const row = node(col, `display:flex;flex-direction:${beside || !unit ? "row" : "column"};align-items:center;gap:${Math.round(numPx * (beside ? gapEm : 0.06))}px`);
|
|
60
|
+
const board = node(row, `position:relative;padding:${Math.round(numPx * 0.08)}px ${Math.round(numPx * 0.12)}px`);
|
|
61
|
+
const panel = slots.panel(board, { ctx, at: 0, seed: `${kit.seed}:board` });
|
|
62
|
+
const num = node(board, `position:relative;font-family:var(--kf-font-mono);font-size:${numPx}px;font-weight:800;color:var(--kf-ink);line-height:${lineH}px;height:${lineH}px`);
|
|
63
|
+
const odo = odometer(num, { template, decimals: dec, linePx: lineH });
|
|
64
|
+
const plain = node(board, `position:absolute;left:${Math.round(numPx * 0.12)}px;top:${Math.round(numPx * 0.08)}px;font-family:var(--kf-font-mono);font-size:${numPx}px;font-weight:800;color:var(--kf-ink);letter-spacing:0.02em;line-height:${lineH}px;white-space:pre;visibility:hidden`, c.value.toFixed(dec).padStart(template.length, " "));
|
|
65
|
+
const unitEl = unit ? node(row, `font-family:var(--kf-font-display);font-size:${Math.round(numPx * 0.5)}px;line-height:1;font-weight:800;color:var(--kf-accent-text, var(--kf-accent));white-space:nowrap`, unit) : null;
|
|
66
|
+
const source = srcText ? node(col, `max-width:${Math.round(w * 0.92)}px;font-family:var(--kf-font-mono);font-size:${srcPx}px;line-height:1.3;min-height:1.3em;color:var(--kf-dim, var(--kf-ink));word-break:keep-all;overflow-wrap:anywhere;text-align:center`) : null;
|
|
67
|
+
kit.stage?.hero(board);
|
|
68
|
+
|
|
69
|
+
// 라벨 폭은 글꼴마다 다르다(고정폭 테마는 빈칸까지 한 칸) — 글자 수 어림 대신 그 글꼴로 재서 안전 영역 폭 92% 안에
|
|
70
|
+
const fitLabel = fitOnce(label, w * 0.92, labelPx, c.label);
|
|
71
|
+
|
|
72
|
+
const land = Math.min(2.4, ctx.dur - 0.6);
|
|
73
|
+
// 배수 기호 하나를 칸으로 짚으면 색 네모 안 × — 닫기 단추로 읽힌다
|
|
74
|
+
const point = glider(host, [board, label, ...(unitEl && !times ? [unitEl] : [])], { hub: true });
|
|
75
|
+
const stops = beatStops(kit.hits, land + 0.15, ctx.dur);
|
|
76
|
+
const writeLabel = slots.write(label, { ctx, text: c.label, at: 0, dur: 0.35, seed: `${kit.seed}:l` });
|
|
77
|
+
const landed = slots.land(board, { ctx, at: land, seed: `${kit.seed}:land` });
|
|
78
|
+
// 출처는 앉은 뒤에 찍고 장면 끝 0.35초 전에 다 찍는다 — 테마 글 찍기가 마지막 글자를 앉힐 틈(끝 프레임에 덜 찍힌 글자가 안 남게)
|
|
79
|
+
const srcDur = clamp([...srcText].length * 0.035, 0.3, 0.9);
|
|
80
|
+
const writeSource = source ? slots.write(source, { ctx, text: srcText, at: Math.max(0.4, Math.min(land + 0.15, ctx.dur - srcDur - 0.35)), dur: srcDur, seed: `${kit.seed}:src` }) : null;
|
|
81
|
+
|
|
82
|
+
return (t: number) => {
|
|
83
|
+
moving("hook-number.counter", ctx);
|
|
84
|
+
fitLabel();
|
|
85
|
+
panel(t);
|
|
86
|
+
writeLabel(t);
|
|
87
|
+
writeSource?.(t);
|
|
88
|
+
// 고른 빠르기로 센다 — 감속하면 끝자리 띠가 서서 그 사이가 멈춘 화면이 된다
|
|
89
|
+
const p = clamp((t - 0.1) / (land - 0.1));
|
|
90
|
+
const v = from + (c.value - from) * p;
|
|
91
|
+
if (odo) odo(p >= 1 ? c.value : v);
|
|
92
|
+
num.style.visibility = odo && t < land ? "visible" : "hidden";
|
|
93
|
+
plain.style.visibility = !odo || t >= land ? "visible" : "hidden";
|
|
94
|
+
if (!odo) plain.textContent = (p >= 1 ? c.value : v).toFixed(dec).padStart(template.length, " ");
|
|
95
|
+
applyStyle(board, landed(t));
|
|
96
|
+
point(t, stops);
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export default pattern;
|