Spaces:
Running
Running
v2-6: AE-style finishing (lens CA, light leaks, streaks), PBR coin sprites in pizza scene, end-card label fix
Browse files- bitcoin-saga/README.md +2 -1
- bitcoin-saga/dist/assets/index-biRZVsu1.js +0 -0
- bitcoin-saga/dist/index.html +1 -1
- bitcoin-saga/web/src/bitcoin/BitcoinApp.tsx +2 -2
- bitcoin-saga/web/src/bitcoin/engine/coingl.ts +33 -0
- bitcoin-saga/web/src/bitcoin/engine/render.ts +75 -0
- bitcoin-saga/web/src/bitcoin/engine/scenes1.ts +3 -0
- bitcoin-saga/web/src/bitcoin/engine/scenes3.ts +3 -1
bitcoin-saga/README.md
CHANGED
|
@@ -102,7 +102,7 @@
|
|
| 102 |
| **Mistral Voxtral TTS** | ❌ 实在用不了 | 官方演示 mistralai/voxtral-tts-demo 强制要求浏览器里交互式 Hugging Face 登录(OAuth),且后端需要 MISTRAL_API_KEY,无法脚本化调用 |
|
| 103 |
| **Mistral Voxtral(语音识别)** | ✅ 已接入 | tools/voxtral_align.py:13 段旁白逐段送 Voxtral 识别(文本相似度 0.62–0.97),用识别出的语音起点校准每一行字幕;由于画面节拍与音效都挂在字幕时间上,画面、音效、字幕三者与真实发音一起对齐(多数行修正 0.2–0.7 秒) |
|
| 104 |
| **Remotion** | ✅ 已接入 | 网页播放器改为 Remotion 合成(@remotion/player,60 帧时钟):画面逐帧由 Remotion 驱动,混音由 Remotion `<Audio>` 同步播放;章节跳转、横竖屏切换(播放不中断)、空格/方向键、全屏全部保留 |
|
| 105 |
-
| **After Effects** | ❌ | Adobe 商业软件,仅支持 Windows / macOS 且需授权,无法在 Linux 沙盒里运行;
|
| 106 |
|
| 107 |
步骤 & 推送:v2-1 计划(本节)→ v2-2 Blender 资产 → v2-3 Three.js 金币接入 → v2-4 ElevenLabs/Voxtral 声部 → v2-5 Remotion 播放器 → v2-6 逐帧打磨 + 最终推送。
|
| 108 |
|
|
@@ -116,3 +116,4 @@ cd bitcoin-saga/web && npm i && npm run build # 输出到 ../dist
|
|
| 116 |
- 推送 v2-3:Three.js 实时 PBR 金币接入(尾声主角金币:急转后停在 3/4 侧面缓慢呼吸,影棚光扫过镜面币心;2025 历史新高处 9 枚 3D 金币喷涌翻滚落下,小尺寸自动切换 Blender 减面 LOD);封面换成 Blender Cycles 光追金币;Voxtral 语音识别校准全部字幕/画面节拍/音效时间;母带加软限幅(峰值 1.16 → 0.999,响度不变)。
|
| 117 |
- 推送 v2-4:ElevenLabs / Voxtral TTS 实测不可用(见上表),旁白继续由 Qwen3-TTS 担任;Voxtral 语音识别已用于字幕对齐(并入 v2-3)。
|
| 118 |
- 推送 v2-5:Remotion 播放器接入:画面与音轨共用一个帧时钟;横竖屏切换后接着播放,空格暂停/继续、播放结束可重播。
|
|
|
|
|
|
| 102 |
| **Mistral Voxtral TTS** | ❌ 实在用不了 | 官方演示 mistralai/voxtral-tts-demo 强制要求浏览器里交互式 Hugging Face 登录(OAuth),且后端需要 MISTRAL_API_KEY,无法脚本化调用 |
|
| 103 |
| **Mistral Voxtral(语音识别)** | ✅ 已接入 | tools/voxtral_align.py:13 段旁白逐段送 Voxtral 识别(文本相似度 0.62–0.97),用识别出的语音起点校准每一行字幕;由于画面节拍与音效都挂在字幕时间上,画面、音效、字幕三者与真实发音一起对齐(多数行修正 0.2–0.7 秒) |
|
| 104 |
| **Remotion** | ✅ 已接入 | 网页播放器改为 Remotion 合成(@remotion/player,60 帧时钟):画面逐帧由 Remotion 驱动,混音由 Remotion `<Audio>` 同步播放;章节跳转、横竖屏切换(播放不中断)、空格/方向键、全屏全部保留 |
|
| 105 |
+
| **After Effects** | ❌ 实在用不了(效果已用代码复刻) | Adobe 商业软件,仅支持 Windows / macOS 且需授权,无法在 Linux 沙盒里运行;AE 常用的成片效果改由渲染器逐帧实现:镜头色差(转场时加强)、转场漏光、横向光条、高光辉光、暗角、胶片颗粒 |
|
| 106 |
|
| 107 |
步骤 & 推送:v2-1 计划(本节)→ v2-2 Blender 资产 → v2-3 Three.js 金币接入 → v2-4 ElevenLabs/Voxtral 声部 → v2-5 Remotion 播放器 → v2-6 逐帧打磨 + 最终推送。
|
| 108 |
|
|
|
|
| 116 |
- 推送 v2-3:Three.js 实时 PBR 金币接入(尾声主角金币:急转后停在 3/4 侧面缓慢呼吸,影棚光扫过镜面币心;2025 历史新高处 9 枚 3D 金币喷涌翻滚落下,小尺寸自动切换 Blender 减面 LOD);封面换成 Blender Cycles 光追金币;Voxtral 语音识别校准全部字幕/画面节拍/音效时间;母带加软限幅(峰值 1.16 → 0.999,响度不变)。
|
| 117 |
- 推送 v2-4:ElevenLabs / Voxtral TTS 实测不可用(见上表),旁白继续由 Qwen3-TTS 担任;Voxtral 语音识别已用于字幕对齐(并入 v2-3)。
|
| 118 |
- 推送 v2-5:Remotion 播放器接入:画面与音轨共用一个帧时钟;横竖屏切换后接着播放,空格暂停/继续、播放结束可重播。
|
| 119 |
+
- 推送 v2-6:AE 式成片后期(镜头色差、转场漏光与光条,字幕/HUD 不受影响保持清晰);披萨场景 420 枚金币换成 Three.js 光照金币(32 个旋转角度预渲染,下落时旋转、落定后停住);尾声区块链标签在片尾标题出现时自动隐去,不再与标题文字重叠;全片横竖屏逐秒排版检查 + 导出测试通过。
|
bitcoin-saga/dist/assets/index-biRZVsu1.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
bitcoin-saga/dist/index.html
CHANGED
|
@@ -7,7 +7,7 @@
|
|
| 7 |
<title>账本永不眠 · 比特币发展历程 2008 → 2026</title>
|
| 8 |
<meta name="description" content="网页实时渲染的比特币 17 年动画纪录片:旁白、配乐、音效,浏览器本地导出 MP4。" />
|
| 9 |
<style>html,body{margin:0;background:#07070a}</style>
|
| 10 |
-
<script type="module" crossorigin src="./assets/index-
|
| 11 |
<link rel="stylesheet" crossorigin href="./assets/index-2e2AWXEI.css">
|
| 12 |
</head>
|
| 13 |
<body>
|
|
|
|
| 7 |
<title>账本永不眠 · 比特币发展历程 2008 → 2026</title>
|
| 8 |
<meta name="description" content="网页实时渲染的比特币 17 年动画纪录片:旁白、配乐、音效,浏览器本地导出 MP4。" />
|
| 9 |
<style>html,body{margin:0;background:#07070a}</style>
|
| 10 |
+
<script type="module" crossorigin src="./assets/index-biRZVsu1.js"></script>
|
| 11 |
<link rel="stylesheet" crossorigin href="./assets/index-2e2AWXEI.css">
|
| 12 |
</head>
|
| 13 |
<body>
|
bitcoin-saga/web/src/bitcoin/BitcoinApp.tsx
CHANGED
|
@@ -12,13 +12,13 @@ import { renderSoundtrack } from './engine/audio'
|
|
| 12 |
import { exportMp4, exportSupported, type ExportOpts, type ExportProgress } from './engine/export'
|
| 13 |
import { COVERS, COPY, drawCover, charLen, tagString, type CoverSpec } from './engine/covers'
|
| 14 |
import { coin3d } from './engine/coin'
|
| 15 |
-
import { coin3dGL, renderCoinGL } from './engine/coingl'
|
| 16 |
import { SCENES, TOTAL, fmtTime, sceneIndexAt } from './timeline'
|
| 17 |
|
| 18 |
type Orient = 'land' | 'port'
|
| 19 |
|
| 20 |
// frame-inspection hook (used for automated QA screenshots)
|
| 21 |
-
if (typeof window !== 'undefined') (window as unknown as Record<string, unknown>).__btc = { draw, drawCover, COVERS, SCENES, TOTAL, loadFonts, renderSoundtrack, exportMp4, coin3d, coin3dGL, renderCoinGL }
|
| 22 |
|
| 23 |
function useIsPhonePortrait() {
|
| 24 |
const q = '(max-width: 820px) and (orientation: portrait)'
|
|
|
|
| 12 |
import { exportMp4, exportSupported, type ExportOpts, type ExportProgress } from './engine/export'
|
| 13 |
import { COVERS, COPY, drawCover, charLen, tagString, type CoverSpec } from './engine/covers'
|
| 14 |
import { coin3d } from './engine/coin'
|
| 15 |
+
import { coin3dGL, coinSprite, renderCoinGL } from './engine/coingl'
|
| 16 |
import { SCENES, TOTAL, fmtTime, sceneIndexAt } from './timeline'
|
| 17 |
|
| 18 |
type Orient = 'land' | 'port'
|
| 19 |
|
| 20 |
// frame-inspection hook (used for automated QA screenshots)
|
| 21 |
+
if (typeof window !== 'undefined') (window as unknown as Record<string, unknown>).__btc = { draw, drawCover, COVERS, SCENES, TOTAL, loadFonts, renderSoundtrack, exportMp4, coin3d, coin3dGL, coinSprite, renderCoinGL }
|
| 22 |
|
| 23 |
function useIsPhonePortrait() {
|
| 24 |
const q = '(max-width: 820px) and (orientation: portrait)'
|
bitcoin-saga/web/src/bitcoin/engine/coingl.ts
CHANGED
|
@@ -224,3 +224,36 @@ export function coin3dGL(ctx: Ctx, x: number, y: number, R: number, yaw: number,
|
|
| 224 |
}
|
| 225 |
ctx.restore()
|
| 226 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
}
|
| 225 |
ctx.restore()
|
| 226 |
}
|
| 227 |
+
|
| 228 |
+
// ---------------------------------------------------------------- sprite sheet for coin swarms
|
| 229 |
+
// Hundreds of small coins (e.g. the 10,000 BTC pizza) can't each get a live WebGL render, so the same lit PBR coin
|
| 230 |
+
// is rendered once at 32 spin angles into a sheet; each small coin then picks the frame for its own spin.
|
| 231 |
+
const SPR_N = 32, SPR_CELL = 96
|
| 232 |
+
let sheet: HTMLCanvasElement | null = null
|
| 233 |
+
function coinSheet() {
|
| 234 |
+
if (sheet || !gl) return sheet
|
| 235 |
+
const c = document.createElement('canvas')
|
| 236 |
+
c.width = SPR_CELL * 8; c.height = SPR_CELL * (SPR_N / 8)
|
| 237 |
+
const g = c.getContext('2d')!
|
| 238 |
+
g.imageSmoothingEnabled = true; g.imageSmoothingQuality = 'high'
|
| 239 |
+
for (let i = 0; i < SPR_N; i++) {
|
| 240 |
+
const src = renderCoinGL(300, (i / SPR_N) * Math.PI * 2, -0.28, 0.9, 1.25)
|
| 241 |
+
if (!src) return null
|
| 242 |
+
g.drawImage(src, 0, 0, gl.size, gl.size, (i % 8) * SPR_CELL, Math.floor(i / 8) * SPR_CELL, SPR_CELL, SPR_CELL)
|
| 243 |
+
}
|
| 244 |
+
sheet = c
|
| 245 |
+
return sheet
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/** Small lit coin from the sprite sheet (face radius R). Returns false when WebGL is unavailable. */
|
| 249 |
+
export function coinSprite(ctx: Ctx, x: number, y: number, R: number, yaw: number, alpha = 1) {
|
| 250 |
+
const sh = coinSheet()
|
| 251 |
+
if (!sh || alpha <= 0) return false
|
| 252 |
+
const f = ((Math.round((yaw / (Math.PI * 2)) * SPR_N) % SPR_N) + SPR_N) % SPR_N
|
| 253 |
+
const S = R * FIT * 2
|
| 254 |
+
const a = ctx.globalAlpha
|
| 255 |
+
ctx.globalAlpha = a * alpha
|
| 256 |
+
ctx.drawImage(sh, (f % 8) * SPR_CELL, Math.floor(f / 8) * SPR_CELL, SPR_CELL, SPR_CELL, x - S / 2, y - S / 2, S, S)
|
| 257 |
+
ctx.globalAlpha = a
|
| 258 |
+
return true
|
| 259 |
+
}
|
bitcoin-saga/web/src/bitcoin/engine/render.ts
CHANGED
|
@@ -144,6 +144,79 @@ function transition(ctx: Ctx, T: number, W: number, H: number) {
|
|
| 144 |
}
|
| 145 |
}
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
// ---------------------------------------------------------------- HUD
|
| 148 |
function hud(ctx: Ctx, T: number, W: number, H: number, P: boolean, hudY: number) {
|
| 149 |
const i = sceneIndexAt(T)
|
|
@@ -306,6 +379,8 @@ export function draw(ctx: Ctx, T: number, outW: number, outH: number) {
|
|
| 306 |
}
|
| 307 |
|
| 308 |
transition(ctx, T, W, H)
|
|
|
|
|
|
|
| 309 |
const L = makeSC(ctx, SCENES[i], T, P).L
|
| 310 |
hud(ctx, T, W, H, P, L.hudY)
|
| 311 |
subtitles(ctx, T, W, P, L.subY)
|
|
|
|
| 144 |
}
|
| 145 |
}
|
| 146 |
|
| 147 |
+
|
| 148 |
+
// ---------------------------------------------------------------- "After Effects" style finishing
|
| 149 |
+
// Lens chromatic aberration (red channel scaled about the centre — fringes grow toward the edges, kick at cuts)
|
| 150 |
+
// and drifting light leaks that bloom through each scene change. Applied under HUD/subtitles so text stays crisp.
|
| 151 |
+
let caLayer: HTMLCanvasElement | null = null
|
| 152 |
+
function lensCA(ctx: Ctx, T: number) {
|
| 153 |
+
let k = 0.0011
|
| 154 |
+
for (let i = 1; i < SCENES.length; i++) {
|
| 155 |
+
const d = Math.abs(T - SCENES[i].start)
|
| 156 |
+
if (d < 0.4) k += 0.0075 * Math.pow(1 - d / 0.4, 2)
|
| 157 |
+
}
|
| 158 |
+
const cv = ctx.canvas, w = cv.width, h = cv.height
|
| 159 |
+
if (!caLayer) caLayer = document.createElement('canvas')
|
| 160 |
+
if (caLayer.width !== w || caLayer.height !== h) { caLayer.width = w; caLayer.height = h }
|
| 161 |
+
const g = caLayer.getContext('2d')!
|
| 162 |
+
g.globalCompositeOperation = 'copy'
|
| 163 |
+
g.drawImage(cv, 0, 0)
|
| 164 |
+
g.globalCompositeOperation = 'multiply'
|
| 165 |
+
g.fillStyle = '#ff0000'
|
| 166 |
+
g.fillRect(0, 0, w, h)
|
| 167 |
+
ctx.save()
|
| 168 |
+
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
| 169 |
+
ctx.globalAlpha = 1
|
| 170 |
+
ctx.globalCompositeOperation = 'multiply'
|
| 171 |
+
ctx.fillStyle = '#00ffff'
|
| 172 |
+
ctx.fillRect(0, 0, w, h)
|
| 173 |
+
ctx.globalCompositeOperation = 'lighter'
|
| 174 |
+
const sw = w * (1 + k), sh = h * (1 + k)
|
| 175 |
+
ctx.drawImage(caLayer, (w - sw) / 2, (h - sh) / 2, sw, sh)
|
| 176 |
+
ctx.restore()
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function lightLeaks(ctx: Ctx, T: number, W: number, H: number) {
|
| 180 |
+
ctx.save()
|
| 181 |
+
ctx.globalCompositeOperation = 'screen'
|
| 182 |
+
for (let i = 1; i < SCENES.length; i++) {
|
| 183 |
+
const d = T - SCENES[i].start
|
| 184 |
+
if (d < -0.5 || d > 2.2) continue
|
| 185 |
+
const e = d < 0 ? easeOut(inv(-0.5, 0, d)) : 1 - easeInOut(inv(0.1, 2.2, d))
|
| 186 |
+
if (e <= 0.002) continue
|
| 187 |
+
const tint = MOOD[SCENES[i].mood]
|
| 188 |
+
const side = rnd(i, 71) < 0.5 ? -1 : 1
|
| 189 |
+
const blobs: [number, number, number, string, number][] = [
|
| 190 |
+
// x, y, radius, colour, strength
|
| 191 |
+
[W * (0.5 + side * 0.42) - side * d * W * 0.07, H * (0.18 + rnd(i, 3) * 0.3), Math.max(W, H) * 0.55, '#ff8a2a', 0.34],
|
| 192 |
+
[W * (0.5 - side * 0.38) + side * d * W * 0.05, H * (0.62 + rnd(i, 5) * 0.25), Math.max(W, H) * 0.42, tint, 0.2],
|
| 193 |
+
[W * (0.5 + side * 0.5), H * 0.5, Math.max(W, H) * 0.25, '#fff1d6', 0.16],
|
| 194 |
+
]
|
| 195 |
+
for (const [x, y, r, col, a] of blobs) {
|
| 196 |
+
const g = ctx.createRadialGradient(x, y, 0, x, y, r)
|
| 197 |
+
g.addColorStop(0, rgba(col, a * e))
|
| 198 |
+
g.addColorStop(0.45, rgba(col, a * e * 0.35))
|
| 199 |
+
g.addColorStop(1, rgba(col, 0))
|
| 200 |
+
ctx.fillStyle = g
|
| 201 |
+
ctx.fillRect(0, 0, W, H)
|
| 202 |
+
}
|
| 203 |
+
// thin anamorphic streak across the flash
|
| 204 |
+
const f = Math.max(0, 1 - Math.abs(d) / 0.35)
|
| 205 |
+
if (f > 0) {
|
| 206 |
+
const y = H * (0.42 + rnd(i, 9) * 0.16)
|
| 207 |
+
const g = ctx.createLinearGradient(0, y - 3, 0, y + 3)
|
| 208 |
+
g.addColorStop(0, rgba('#ffd27a', 0)); g.addColorStop(0.5, rgba('#ffd27a', 0.55 * f)); g.addColorStop(1, rgba('#ffd27a', 0))
|
| 209 |
+
ctx.fillStyle = g
|
| 210 |
+
ctx.fillRect(0, y - 3, W, 6)
|
| 211 |
+
const g2 = ctx.createLinearGradient(0, y - 40, 0, y + 40)
|
| 212 |
+
g2.addColorStop(0, rgba('#ff9a3a', 0)); g2.addColorStop(0.5, rgba('#ff9a3a', 0.16 * f)); g2.addColorStop(1, rgba('#ff9a3a', 0))
|
| 213 |
+
ctx.fillStyle = g2
|
| 214 |
+
ctx.fillRect(0, y - 40, W, 80)
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
ctx.restore()
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
// ---------------------------------------------------------------- HUD
|
| 221 |
function hud(ctx: Ctx, T: number, W: number, H: number, P: boolean, hudY: number) {
|
| 222 |
const i = sceneIndexAt(T)
|
|
|
|
| 379 |
}
|
| 380 |
|
| 381 |
transition(ctx, T, W, H)
|
| 382 |
+
lightLeaks(ctx, T, W, H)
|
| 383 |
+
lensCA(ctx, T)
|
| 384 |
const L = makeSC(ctx, SCENES[i], T, P).L
|
| 385 |
hud(ctx, T, W, H, P, L.hudY)
|
| 386 |
subtitles(ctx, T, W, P, L.subY)
|
bitcoin-saga/web/src/bitcoin/engine/scenes1.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { C, FONT, clamp, easeInOut, easeOut, easeOutBack, easeOutExpo, fmtInt, h
|
|
| 3 |
import { bigNumber, cube, fieldRows, glowDot, panel, priceChart, text, typeText, wrap, type Cam, type Box } from './gfx'
|
| 4 |
import { day } from './price'
|
| 5 |
import type { SC } from './sc'
|
|
|
|
| 6 |
|
| 7 |
const GENESIS_HASH = '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f'
|
| 8 |
|
|
@@ -250,6 +251,8 @@ export function s02(c: SC) {
|
|
| 250 |
const r = lerp(11, 7, gi)
|
| 251 |
const sp = Math.abs(Math.cos(t * 3 + i))
|
| 252 |
ctx.globalAlpha = appear * (1 - inv(0.85, 1, gather) * 0.55)
|
|
|
|
|
|
|
| 253 |
ctx.fillStyle = sp > 0.5 ? '#ffd27a' : '#e09a1c'
|
| 254 |
ctx.beginPath(); ctx.ellipse(x, y, r * (0.35 + 0.65 * sp), r, 0, 0, Math.PI * 2); ctx.fill()
|
| 255 |
ctx.strokeStyle = 'rgba(120,60,0,0.6)'
|
|
|
|
| 3 |
import { bigNumber, cube, fieldRows, glowDot, panel, priceChart, text, typeText, wrap, type Cam, type Box } from './gfx'
|
| 4 |
import { day } from './price'
|
| 5 |
import type { SC } from './sc'
|
| 6 |
+
import { coinSprite } from './coingl'
|
| 7 |
|
| 8 |
const GENESIS_HASH = '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f'
|
| 9 |
|
|
|
|
| 251 |
const r = lerp(11, 7, gi)
|
| 252 |
const sp = Math.abs(Math.cos(t * 3 + i))
|
| 253 |
ctx.globalAlpha = appear * (1 - inv(0.85, 1, gather) * 0.55)
|
| 254 |
+
// lit PBR coin sprite: spins while falling, slows to a stop as it lands in the pizza
|
| 255 |
+
if (coinSprite(ctx, x, y, lerp(12, 7, gi), (t * 3 + i) * (1 - gi) + rnd(i, 6) * 6.28 * gi)) continue
|
| 256 |
ctx.fillStyle = sp > 0.5 ? '#ffd27a' : '#e09a1c'
|
| 257 |
ctx.beginPath(); ctx.ellipse(x, y, r * (0.35 + 0.65 * sp), r, 0, 0, Math.PI * 2); ctx.fill()
|
| 258 |
ctx.strokeStyle = 'rgba(120,60,0,0.6)'
|
bitcoin-saga/web/src/bitcoin/engine/scenes3.ts
CHANGED
|
@@ -310,11 +310,13 @@ export function s12(c: SC) {
|
|
| 310 |
const fly = easeInOut(inv(0, B(2) + 0.5, v + 0.9))
|
| 311 |
const cam: Cam = { x: 0, y: 1.2, z: lerp(-14, 9 * gap - 4, fly) + Math.max(0, t - (B(2) + 1.5)) * 0.6, yaw: Math.sin(t * 0.2) * 0.06, pitch: -0.06, f: P ? 900 : 1100, cx: W / 2, cy: H * (P ? 0.42 : 0.46) }
|
| 312 |
const pts: V3[] = MILESTONES.map((_, i) => [Math.sin(i * 1.3) * 3.2, Math.cos(i * 0.9) * 1.4, i * gap])
|
|
|
|
|
|
|
| 313 |
for (let i = MILESTONES.length - 1; i >= 0; i--) {
|
| 314 |
if (i < MILESTONES.length - 1) beam(ctx, cam, pts[i], pts[i + 1], C.gold, 0.7, 1.8)
|
| 315 |
const dz = pts[i][2] - cam.z
|
| 316 |
const a = clamp(inv(0.5, 4, dz)) * clamp(1 - inv(70, 100, dz))
|
| 317 |
-
cube(ctx, cam, pts[i], { size: 1.3, yaw: 0.6 + t * 0.3 + i, pitch: -0.3, label: MILESTONES[i][0], sub: MILESTONES[i][1], alpha: a, color: MILESTONES[i][0] === '?' ? C.white : C.gold })
|
| 318 |
}
|
| 319 |
// ---- 3D coin: rises out of the chain as a hero, then settles above the title
|
| 320 |
const ca = inv(B(1) - 0.6, B(1) + 0.9, v)
|
|
|
|
| 310 |
const fly = easeInOut(inv(0, B(2) + 0.5, v + 0.9))
|
| 311 |
const cam: Cam = { x: 0, y: 1.2, z: lerp(-14, 9 * gap - 4, fly) + Math.max(0, t - (B(2) + 1.5)) * 0.6, yaw: Math.sin(t * 0.2) * 0.06, pitch: -0.06, f: P ? 900 : 1100, cx: W / 2, cy: H * (P ? 0.42 : 0.46) }
|
| 312 |
const pts: V3[] = MILESTONES.map((_, i) => [Math.sin(i * 1.3) * 3.2, Math.cos(i * 0.9) * 1.4, i * gap])
|
| 313 |
+
// the chain recedes into the background once the closing title cards come in, so labels never fight the type
|
| 314 |
+
const recede = 1 - 0.85 * easeInOut(after(2, 1.6))
|
| 315 |
for (let i = MILESTONES.length - 1; i >= 0; i--) {
|
| 316 |
if (i < MILESTONES.length - 1) beam(ctx, cam, pts[i], pts[i + 1], C.gold, 0.7, 1.8)
|
| 317 |
const dz = pts[i][2] - cam.z
|
| 318 |
const a = clamp(inv(0.5, 4, dz)) * clamp(1 - inv(70, 100, dz))
|
| 319 |
+
cube(ctx, cam, pts[i], { size: 1.3, yaw: 0.6 + t * 0.3 + i, pitch: -0.3, label: MILESTONES[i][0], sub: MILESTONES[i][1], alpha: a * recede, color: MILESTONES[i][0] === '?' ? C.white : C.gold })
|
| 320 |
}
|
| 321 |
// ---- 3D coin: rises out of the chain as a hero, then settles above the title
|
| 322 |
const ca = inv(B(1) - 0.6, B(1) + 0.9, v)
|