Ethscriptions commited on
Commit
0a26c9f
·
verified ·
1 Parent(s): 709fe2c

v2-7: true 3D blocks, tombs, doors, globe, logos, dominoes; pizza coins; 2K export & sharper playback

Browse files
Files changed (41) hide show
  1. .gitattributes +7 -0
  2. bitcoin-saga/README.md +1 -0
  3. bitcoin-saga/dist/assets/coin-BvnbCOr2.glb +3 -0
  4. bitcoin-saga/dist/assets/coin-hero-CaTT8WxI.webp +0 -0
  5. bitcoin-saga/dist/assets/coin-lo-CvE-GDyL.glb +0 -0
  6. bitcoin-saga/dist/assets/earth-D5ORXZE1.jpg +3 -0
  7. bitcoin-saga/dist/assets/earth-lights-D2b9Grhk.jpg +3 -0
  8. bitcoin-saga/dist/assets/earth-normal-D6bb61qB.jpg +3 -0
  9. bitcoin-saga/dist/assets/earth-spec-Bcy67onS.jpg +0 -0
  10. bitcoin-saga/dist/assets/index-BFEJsoIQ.js +0 -0
  11. bitcoin-saga/dist/assets/sans-sc-400-DzbASIVW.woff2 +0 -0
  12. bitcoin-saga/dist/assets/sans-sc-700-BCWBA9ha.woff2 +0 -0
  13. bitcoin-saga/dist/assets/sans-sc-900-17ZIJI_E.woff2 +0 -0
  14. bitcoin-saga/dist/assets/serif-sc-700-C6u61Hq7.woff2 +0 -0
  15. bitcoin-saga/dist/assets/serif-sc-900-n7dwsg69.woff2 +0 -0
  16. bitcoin-saga/dist/index.html +1 -1
  17. bitcoin-saga/web/src/bitcoin/BitcoinApp.tsx +9 -4
  18. bitcoin-saga/web/src/bitcoin/assets/3d/coin-hero.webp +0 -0
  19. bitcoin-saga/web/src/bitcoin/assets/3d/coin-lo.glb +0 -0
  20. bitcoin-saga/web/src/bitcoin/assets/3d/coin.glb +2 -2
  21. bitcoin-saga/web/src/bitcoin/assets/3d/earth-lights.jpg +3 -0
  22. bitcoin-saga/web/src/bitcoin/assets/3d/earth-normal.jpg +3 -0
  23. bitcoin-saga/web/src/bitcoin/assets/3d/earth-spec.jpg +0 -0
  24. bitcoin-saga/web/src/bitcoin/assets/3d/earth.jpg +3 -0
  25. bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-400.woff2 +0 -0
  26. bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-700.woff2 +0 -0
  27. bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-900.woff2 +0 -0
  28. bitcoin-saga/web/src/bitcoin/assets/fonts/serif-sc-700.woff2 +0 -0
  29. bitcoin-saga/web/src/bitcoin/assets/fonts/serif-sc-900.woff2 +0 -0
  30. bitcoin-saga/web/src/bitcoin/engine/audio.ts +34 -7
  31. bitcoin-saga/web/src/bitcoin/engine/coingl.ts +2 -0
  32. bitcoin-saga/web/src/bitcoin/engine/cues.ts +84 -0
  33. bitcoin-saga/web/src/bitcoin/engine/export.ts +4 -4
  34. bitcoin-saga/web/src/bitcoin/engine/gfx.ts +10 -1
  35. bitcoin-saga/web/src/bitcoin/engine/logos.ts +6 -0
  36. bitcoin-saga/web/src/bitcoin/engine/render.ts +7 -2
  37. bitcoin-saga/web/src/bitcoin/engine/scenes1.ts +11 -10
  38. bitcoin-saga/web/src/bitcoin/engine/scenes2.ts +241 -108
  39. bitcoin-saga/web/src/bitcoin/engine/scenes3.ts +78 -63
  40. bitcoin-saga/web/src/bitcoin/engine/stage3d.ts +639 -0
  41. bitcoin-saga/web/src/bitcoin/tools/blender/coin.py +20 -13
.gitattributes CHANGED
@@ -79,3 +79,10 @@ bitcoin-saga/dist/assets/draco_decoder-Z1_iN-Ht.wasm filter=lfs diff=lfs merge=l
79
  bitcoin-saga/dist/assets/coin-D2GSon5Y.glb filter=lfs diff=lfs merge=lfs -text
80
  bitcoin-saga/dist/assets/draco_decoder-C32yEggz.wasm filter=lfs diff=lfs merge=lfs -text
81
  bitcoin-saga/dist/assets/studio-IzHMd0JK.hdr filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
79
  bitcoin-saga/dist/assets/coin-D2GSon5Y.glb filter=lfs diff=lfs merge=lfs -text
80
  bitcoin-saga/dist/assets/draco_decoder-C32yEggz.wasm filter=lfs diff=lfs merge=lfs -text
81
  bitcoin-saga/dist/assets/studio-IzHMd0JK.hdr filter=lfs diff=lfs merge=lfs -text
82
+ bitcoin-saga/web/src/bitcoin/assets/3d/earth.jpg filter=lfs diff=lfs merge=lfs -text
83
+ bitcoin-saga/web/src/bitcoin/assets/3d/earth-lights.jpg filter=lfs diff=lfs merge=lfs -text
84
+ bitcoin-saga/web/src/bitcoin/assets/3d/earth-normal.jpg filter=lfs diff=lfs merge=lfs -text
85
+ bitcoin-saga/dist/assets/earth-lights-D2b9Grhk.jpg filter=lfs diff=lfs merge=lfs -text
86
+ bitcoin-saga/dist/assets/earth-normal-D6bb61qB.jpg filter=lfs diff=lfs merge=lfs -text
87
+ bitcoin-saga/dist/assets/coin-BvnbCOr2.glb filter=lfs diff=lfs merge=lfs -text
88
+ bitcoin-saga/dist/assets/earth-D5ORXZE1.jpg filter=lfs diff=lfs merge=lfs -text
bitcoin-saga/README.md CHANGED
@@ -117,3 +117,4 @@ cd bitcoin-saga/web && npm i && npm run build # 输出到 ../dist
117
  - 推送 v2-4:ElevenLabs / Voxtral TTS 实测不可用(见上表),旁白继续由 Qwen3-TTS 担任;Voxtral 语音识别已用于字幕对齐(并入 v2-3)。
118
  - 推送 v2-5:Remotion 播放器接入:画面与音轨共用一个帧时钟;横竖屏切换后接着播放,空格暂停/继续、播放结束可重播。
119
  - 推送 v2-6:AE 式成片后期(镜头色差、转场漏光与光条,字幕/HUD 不受影响保持清晰);披萨场景 420 枚金币换成 Three.js 光照金币(32 个旋转角度预渲染,下落时旋转、落定后停住);尾声区块链标签在片尾标题出现时自动隐去,不再与标题文字重叠;全片横竖屏逐秒排版检查 + 导出测试通过。
 
 
117
  - 推送 v2-4:ElevenLabs / Voxtral TTS 实测不可用(见上表),旁白继续由 Qwen3-TTS 担任;Voxtral 语音识别已用于字幕对齐(并入 v2-3)。
118
  - 推送 v2-5:Remotion 播放器接入:画面与音轨共用一个帧时钟;横竖屏切换后接着播放,空格暂停/继续、播放结束可重播。
119
  - 推送 v2-6:AE 式成片后期(镜头色差、转场漏光与光条,字幕/HUD 不受影响保持清晰);披萨场景 420 枚金币换成 Three.js 光照金币(32 个旋转角度预渲染,下落时旋转、落定后停住);尾声区块链标签在片尾标题出现时自动隐去,不再与标题文字重叠;全片横竖屏逐秒排版检查 + 导出测试通过。
120
+ - 推送 v2-7:全片区块改为 Three.js 真 3D(带光照/反射的金属立方体,区块号刻在面上随区块一起转动,不再穿模);披萨上的红点换成稍大的比特币金币;Mt.Gox 段落墓碑改为 3D 石碑;2021–2024 段落:金币登场 → 讲资产负债表时飞入 MicroStrategy / Tesla / Square 公司标牌 → 讲萨尔瓦多时 3D 地球转到中美洲并标注 → 讲 Taproot 时展示区块 #709,632 与默克尔脚本树 → 金币回归;2022 多米诺重做为 3D 骨牌,每一次碰撞都与旁白节拍和撞击音效对齐,随后接 3D 区块链「每 ~10 分钟一个新区块」;ETF 大门改为 3D 双开门向外打开(配开门音效);战略储备印章中心换 3D 金币、环形文字清晰;片尾价格数据改为「不构成投资建议」;清晰度:高分屏播放超采样到 2560×1440,色差减半,新增 2K 导出,码率提升约 2 倍。
bitcoin-saga/dist/assets/coin-BvnbCOr2.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1f097788296b619678493879b604b01e7c4631286ce7e212dbe458ffdc04d2f6
3
+ size 340400
bitcoin-saga/dist/assets/coin-hero-CaTT8WxI.webp ADDED
bitcoin-saga/dist/assets/coin-lo-CvE-GDyL.glb ADDED
Binary file (86 kB). View file
 
bitcoin-saga/dist/assets/earth-D5ORXZE1.jpg ADDED

Git LFS Details

  • SHA256: 60b853dd49fa63a70d770420e147070825ff061a7af4cd2267ad275c9b9c693d
  • Pointer size: 131 Bytes
  • Size of remote file: 354 kB
bitcoin-saga/dist/assets/earth-lights-D2b9Grhk.jpg ADDED

Git LFS Details

  • SHA256: a76547a76e82e16f9650a74028928a82ae64dd06596d0aece8aa1eb492bd2051
  • Pointer size: 131 Bytes
  • Size of remote file: 179 kB
bitcoin-saga/dist/assets/earth-normal-D6bb61qB.jpg ADDED

Git LFS Details

  • SHA256: f4f8d422732ec32ac39826da07127582d30405738e9e5fe3580856ae43b32180
  • Pointer size: 131 Bytes
  • Size of remote file: 265 kB
bitcoin-saga/dist/assets/earth-spec-Bcy67onS.jpg ADDED
bitcoin-saga/dist/assets/index-BFEJsoIQ.js ADDED
The diff for this file is too large to render. See raw diff
 
bitcoin-saga/dist/assets/sans-sc-400-DzbASIVW.woff2 ADDED
Binary file (71 kB). View file
 
bitcoin-saga/dist/assets/sans-sc-700-BCWBA9ha.woff2 ADDED
Binary file (72.2 kB). View file
 
bitcoin-saga/dist/assets/sans-sc-900-17ZIJI_E.woff2 ADDED
Binary file (72.1 kB). View file
 
bitcoin-saga/dist/assets/serif-sc-700-C6u61Hq7.woff2 ADDED
Binary file (94.1 kB). View file
 
bitcoin-saga/dist/assets/serif-sc-900-n7dwsg69.woff2 ADDED
Binary file (92.6 kB). View file
 
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-biRZVsu1.js"></script>
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-BFEJsoIQ.js"></script>
11
  <link rel="stylesheet" crossorigin href="./assets/index-2e2AWXEI.css">
12
  </head>
13
  <body>
bitcoin-saga/web/src/bitcoin/BitcoinApp.tsx CHANGED
@@ -13,12 +13,14 @@ import { exportMp4, exportSupported, type ExportOpts, type ExportProgress } from
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)'
@@ -57,13 +59,16 @@ function Film({ audio, ready }: FilmProps) {
57
  const frame = useCurrentFrame()
58
  const { width, height, fps } = useVideoConfig()
59
  const ref = useRef<HTMLCanvasElement>(null)
 
 
 
60
  useLayoutEffect(() => {
61
  const c = ref.current
62
  if (c) draw(c.getContext('2d')!, frame / fps, c.width, c.height)
63
- }, [frame, fps, width, height, ready])
64
  return (
65
  <AbsoluteFill style={{ background: '#07070a' }}>
66
- <canvas ref={ref} width={width} height={height} style={{ width: '100%', height: '100%', display: 'block' }} />
67
  {audio && <Audio src={audio} />}
68
  </AbsoluteFill>
69
  )
@@ -243,7 +248,7 @@ function Exporter({ defaultOrient }: { defaultOrient: Orient }) {
243
  </div>
244
  </label>
245
  <label>分辨率
246
- <div className="seg">{([1080, 720] as const).map((r) => <button key={r} className={opts.res === r ? 'on' : ''} disabled={busy} onClick={() => setOpts({ ...opts, res: r })}>{r}p</button>)}</div>
247
  </label>
248
  <label>背景音乐
249
  <div className="seg">
 
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 * as stage3d from './engine/stage3d'
17
+ import { stageCanvas } from './engine/stage3d'
18
  import { SCENES, TOTAL, fmtTime, sceneIndexAt } from './timeline'
19
 
20
  type Orient = 'land' | 'port'
21
 
22
  // frame-inspection hook (used for automated QA screenshots)
23
+ if (typeof window !== 'undefined') (window as unknown as Record<string, unknown>).__btc = { draw, drawCover, COVERS, SCENES, TOTAL, loadFonts, renderSoundtrack, exportMp4, coin3d, coin3dGL, coinSprite, renderCoinGL, stageCanvas, stage3d }
24
 
25
  function useIsPhonePortrait() {
26
  const q = '(max-width: 820px) and (orientation: portrait)'
 
59
  const frame = useCurrentFrame()
60
  const { width, height, fps } = useVideoConfig()
61
  const ref = useRef<HTMLCanvasElement>(null)
62
+ // supersample on HiDPI screens (up to 2560×1440) so the preview is as crisp as the 2K export
63
+ const k = typeof window !== 'undefined' && (window.devicePixelRatio || 1) > 1 ? 4 / 3 : 1
64
+ const cw = Math.round(width * k), ch = Math.round(height * k)
65
  useLayoutEffect(() => {
66
  const c = ref.current
67
  if (c) draw(c.getContext('2d')!, frame / fps, c.width, c.height)
68
+ }, [frame, fps, width, height, ready, cw])
69
  return (
70
  <AbsoluteFill style={{ background: '#07070a' }}>
71
+ <canvas ref={ref} width={cw} height={ch} style={{ width: '100%', height: '100%', display: 'block' }} />
72
  {audio && <Audio src={audio} />}
73
  </AbsoluteFill>
74
  )
 
248
  </div>
249
  </label>
250
  <label>分辨率
251
+ <div className="seg">{([1440, 1080, 720] as const).map((r) => <button key={r} className={opts.res === r ? 'on' : ''} disabled={busy} onClick={() => setOpts({ ...opts, res: r })}>{r === 1440 ? '2K' : r + 'p'}</button>)}</div>
252
  </label>
253
  <label>背景音乐
254
  <div className="seg">
bitcoin-saga/web/src/bitcoin/assets/3d/coin-hero.webp CHANGED
bitcoin-saga/web/src/bitcoin/assets/3d/coin-lo.glb CHANGED
Binary files a/bitcoin-saga/web/src/bitcoin/assets/3d/coin-lo.glb and b/bitcoin-saga/web/src/bitcoin/assets/3d/coin-lo.glb differ
 
bitcoin-saga/web/src/bitcoin/assets/3d/coin.glb CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:2f9adc102b5295684f649395e2f67b166fe601b9d87af7950316f2ca66d64067
3
- size 578380
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1f097788296b619678493879b604b01e7c4631286ce7e212dbe458ffdc04d2f6
3
+ size 340400
bitcoin-saga/web/src/bitcoin/assets/3d/earth-lights.jpg ADDED

Git LFS Details

  • SHA256: a76547a76e82e16f9650a74028928a82ae64dd06596d0aece8aa1eb492bd2051
  • Pointer size: 131 Bytes
  • Size of remote file: 179 kB
bitcoin-saga/web/src/bitcoin/assets/3d/earth-normal.jpg ADDED

Git LFS Details

  • SHA256: f4f8d422732ec32ac39826da07127582d30405738e9e5fe3580856ae43b32180
  • Pointer size: 131 Bytes
  • Size of remote file: 265 kB
bitcoin-saga/web/src/bitcoin/assets/3d/earth-spec.jpg ADDED
bitcoin-saga/web/src/bitcoin/assets/3d/earth.jpg ADDED

Git LFS Details

  • SHA256: 60b853dd49fa63a70d770420e147070825ff061a7af4cd2267ad275c9b9c693d
  • Pointer size: 131 Bytes
  • Size of remote file: 354 kB
bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-400.woff2 CHANGED
Binary files a/bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-400.woff2 and b/bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-400.woff2 differ
 
bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-700.woff2 CHANGED
Binary files a/bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-700.woff2 and b/bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-700.woff2 differ
 
bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-900.woff2 CHANGED
Binary files a/bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-900.woff2 and b/bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-900.woff2 differ
 
bitcoin-saga/web/src/bitcoin/assets/fonts/serif-sc-700.woff2 CHANGED
Binary files a/bitcoin-saga/web/src/bitcoin/assets/fonts/serif-sc-700.woff2 and b/bitcoin-saga/web/src/bitcoin/assets/fonts/serif-sc-700.woff2 differ
 
bitcoin-saga/web/src/bitcoin/assets/fonts/serif-sc-900.woff2 CHANGED
Binary files a/bitcoin-saga/web/src/bitcoin/assets/fonts/serif-sc-900.woff2 and b/bitcoin-saga/web/src/bitcoin/assets/fonts/serif-sc-900.woff2 differ
 
bitcoin-saga/web/src/bitcoin/engine/audio.ts CHANGED
@@ -1,5 +1,6 @@
1
  // Procedural soundtrack: music (D minor, ~84 BPM) + SFX synced to scene events + narration.
2
  // Rendered once with an OfflineAudioContext → one AudioBuffer used by both player and exporter.
 
3
  import { SCENES, TOTAL, sceneIndexAt, type Scene } from '../timeline'
4
  import { rnd } from './util'
5
 
@@ -293,6 +294,23 @@ function clackNoise(g: G, t: number, vol: number) {
293
  const e = g.ac.createGain(); perc(e, t, vol, 0.06)
294
  n.connect(f); f.connect(e); send(g, e, g.sfx, 0.3)
295
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  function blip(g: G, t: number, freq = 1320, vol = 0.07) {
297
  { const lt = t - g.o; if (lt < 0 || lt >= g.len) return; t = lt }
298
  const o = g.ac.createOscillator(); o.type = 'sine'; o.frequency.value = freq
@@ -355,14 +373,23 @@ function sfx(g: G) {
355
  { const s = S[6]; wind(g, s.start, s.end + 0.5, 0.1) }
356
  // s07 global
357
  { const s = S[7]; whoosh(g, s.start + 0.3, 1.8, 0.15); for (let i = 0; i < 3; i++) blip(g, B(s, i + 1), 1100, 0.06) }
358
- // s08 dominoes + steady blocks
359
  { const s = S[8]
360
- for (let i = 0; i < 4; i++) {
361
- const at = B(s, 0) + 1.1 + i * 0.9 + (i === 3 ? (s.subs[1]?.t ?? 0) - (s.subs[0]?.t ?? 0) - 1.6 : 0)
362
- clack(g, at + 0.62, i === 3 ? 0.55 : 0.38)
363
- if (i === 3) boom(g, at + 0.65, 0.45)
364
- }
365
- for (let i = 0; i < 10; i++) blip(g, B(s, 3) + i * 0.35, 880, 0.05) }
 
 
 
 
 
 
 
 
 
366
  // s09 ETF + halving
367
  { const s = S[9]
368
  riser(g, s.start - 0.2, s.start + 0.9, 0.08)
 
1
  // Procedural soundtrack: music (D minor, ~84 BPM) + SFX synced to scene events + narration.
2
  // Rendered once with an OfflineAudioContext → one AudioBuffer used by both player and exporter.
3
+ import { BLOCK_N, blockLand, doorCues, dominoPlan } from './cues'
4
  import { SCENES, TOTAL, sceneIndexAt, type Scene } from '../timeline'
5
  import { rnd } from './util'
6
 
 
294
  const e = g.ac.createGain(); perc(e, t, vol, 0.06)
295
  n.connect(f); f.connect(e); send(g, e, g.sfx, 0.3)
296
  }
297
+ /** heavy door hinge: a slow, gritty low tone gliding down + filtered noise */
298
+ function creak(g: G, t: number, dur: number) {
299
+ { const lt = t - g.o; if (lt < 0 || lt >= g.len) return; t = lt }
300
+ const o = g.ac.createOscillator(); o.type = 'sawtooth'
301
+ o.frequency.setValueAtTime(92, t); o.frequency.linearRampToValueAtTime(64, t + dur)
302
+ const f = g.ac.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 420; f.Q.value = 3
303
+ const e = g.ac.createGain(); env(e, t, 0.07, 0.25, dur - 0.6, 0.35)
304
+ const lfo = g.ac.createOscillator(); lfo.frequency.value = 23
305
+ const lg = g.ac.createGain(); lg.gain.value = 0.035
306
+ lfo.connect(lg); lg.connect(e.gain)
307
+ o.connect(f); f.connect(e); send(g, e, g.sfx, 0.45)
308
+ o.start(t); o.stop(t + dur + 0.1); lfo.start(t); lfo.stop(t + dur + 0.1)
309
+ const n = noiseSrc(g, t, dur)
310
+ const nf = g.ac.createBiquadFilter(); nf.type = 'lowpass'; nf.frequency.value = 300
311
+ const ne = g.ac.createGain(); env(ne, t, 0.09, 0.4, dur - 0.8, 0.4)
312
+ n.connect(nf); nf.connect(ne); send(g, ne, g.sfx, 0.5)
313
+ }
314
  function blip(g: G, t: number, freq = 1320, vol = 0.07) {
315
  { const lt = t - g.o; if (lt < 0 || lt >= g.len) return; t = lt }
316
  const o = g.ac.createOscillator(); o.type = 'sine'; o.frequency.value = freq
 
373
  { const s = S[6]; wind(g, s.start, s.end + 0.5, 0.1) }
374
  // s07 global
375
  { const s = S[7]; whoosh(g, s.start + 0.3, 1.8, 0.15); for (let i = 0; i < 3; i++) blip(g, B(s, i + 1), 1100, 0.06) }
376
+ // s08 dominoes + steady blocks — same choreography as the picture (cues.ts)
377
  { const s = S[8]
378
+ const ds = dominoPlan(B(s, 0), B(s, 1))
379
+ click(g, ds[0].T, 0.08)
380
+ ds.forEach((d, i) => {
381
+ const big = d.n ? 1 : 0
382
+ if (i < ds.length - 1) clack(g, d.hit, 0.16 + 0.1 * (d.h / 1.2) + big * 0.08)
383
+ else { thud(g, d.hit, 0.75); boom(g, d.hit + 0.02, 0.6); clackNoise(g, d.hit, 0.4) }
384
+ })
385
+ for (let i = 0; i < BLOCK_N; i++) { thud(g, blockLand(B(s, 3), i), 0.22); bell(g, blockLand(B(s, 3), i) + 0.01, mtof(i % 2 ? 86 : 81), 0.05, 0.9) }
386
+ }
387
+ // s09 doors: crack of light on "ETF", heavy swing on "大门就此打开"
388
+ { const s = S[9]
389
+ const q = doorCues(B(s, 1), B(s, 2))
390
+ whoosh(g, q.crack, 0.9, 0.06)
391
+ creak(g, q.swing, q.stop - q.swing)
392
+ thud(g, q.stop - 0.1, 0.4); riser(g, q.swing, q.stop, 0.1) }
393
  // s09 ETF + halving
394
  { const s = S[9]
395
  riser(g, s.start - 0.2, s.start + 0.9, 0.08)
bitcoin-saga/web/src/bitcoin/engine/coingl.ts CHANGED
@@ -43,6 +43,8 @@ const FIT = 1.16 // coin radius 1 → half the view height incl. margin for the
43
  function materials(name: string) {
44
  const base = { color: new THREE.Color(1.0, 0.72, 0.3), metalness: 1, envMapIntensity: 1.15 }
45
  if (name === 'Field') return new THREE.MeshPhysicalMaterial({ ...base, roughness: 0.075, clearcoat: 0.6, clearcoatRoughness: 0.04 })
 
 
46
  if (name === 'Edge') return new THREE.MeshPhysicalMaterial({ ...base, color: new THREE.Color(0.96, 0.66, 0.26), roughness: 0.2, anisotropy: 0.6 })
47
  // frosted relief (₿, legend, beads, emblem): satin finish, reads brighter and softer than the mirror field
48
  return new THREE.MeshPhysicalMaterial({ ...base, color: new THREE.Color(1.0, 0.76, 0.36), roughness: 0.34, sheen: 0.3, sheenColor: new THREE.Color(1, 0.85, 0.55) })
 
43
  function materials(name: string) {
44
  const base = { color: new THREE.Color(1.0, 0.72, 0.3), metalness: 1, envMapIntensity: 1.15 }
45
  if (name === 'Field') return new THREE.MeshPhysicalMaterial({ ...base, roughness: 0.075, clearcoat: 0.6, clearcoatRoughness: 0.04 })
46
+ // recessed legend band: dark satin antique gold, so the polished raised letters read crisply at any angle
47
+ if (name === 'Band') return new THREE.MeshPhysicalMaterial({ ...base, color: new THREE.Color(0.28, 0.15, 0.045), roughness: 0.6, envMapIntensity: 0.6 })
48
  if (name === 'Edge') return new THREE.MeshPhysicalMaterial({ ...base, color: new THREE.Color(0.96, 0.66, 0.26), roughness: 0.2, anisotropy: 0.6 })
49
  // frosted relief (₿, legend, beads, emblem): satin finish, reads brighter and softer than the mirror field
50
  return new THREE.MeshPhysicalMaterial({ ...base, color: new THREE.Color(1.0, 0.76, 0.36), roughness: 0.34, sheen: 0.3, sheenColor: new THREE.Color(1, 0.85, 0.55) })
bitcoin-saga/web/src/bitcoin/engine/cues.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared choreography timings: the picture (scenes) and the sound effects (audio) both read these,
2
+ // so every clack, door creak and block "ding" lands on the exact frame where it happens on screen.
3
+ // All functions take the narration clause start times in whatever clock the caller uses (relative or absolute).
4
+
5
+ /** One domino in the 2022 cascade (small → big chain, each knocked over by its predecessor). */
6
+ export type Domino = {
7
+ /** label on the big faces ('' = small unlabeled domino) */
8
+ n: string
9
+ date: string
10
+ /** height / thickness / width (world units) */
11
+ h: number
12
+ th: number
13
+ w: number
14
+ /** x of the front-bottom pivot edge */
15
+ px: number
16
+ /** time it starts to tip (= contact from the previous one) */
17
+ T: number
18
+ /** time its top edge hits the next domino (or the floor for the last one) */
19
+ hit: number
20
+ }
21
+
22
+ /** sin(contact angle) = gap / height; the gap scales with the domino */
23
+ export const DOMINO_GAP = 0.5
24
+ export const DOMINO_THC = Math.asin(DOMINO_GAP)
25
+
26
+ /**
27
+ * b0 = "多米诺骨牌接连倒下", b1 = "LUNA 归零,FTX 一夜崩塌".
28
+ * 8 small dominoes run during "接连倒下", LUNA tips exactly on "LUNA", FTX (the giant) is hit on "FTX" and lands on "崩塌".
29
+ */
30
+ export function dominoPlan(b0: number, b1: number): Domino[] {
31
+ const big: [string, string, number][] = [['LUNA', '2022-05', 1.35], ['3AC', '2022-06', 1.8], ['CELSIUS', '2022-07', 2.4], ['FTX', '2022-11', 3.3]]
32
+ const smallN = 8
33
+ const s0 = b0 + 1.45
34
+ const sDt = (b1 - 0.05 - s0) / smallN
35
+ const times: number[] = []
36
+ for (let i = 0; i < smallN; i++) times.push(s0 + i * sDt)
37
+ times.push(b1 - 0.05, b1 + 0.47, b1 + 1.0, b1 + 1.6)
38
+ const out: Domino[] = []
39
+ let x = 0
40
+ for (let i = 0; i < times.length; i++) {
41
+ const isBig = i >= smallN
42
+ const h = isBig ? big[i - smallN][2] : 0.62 + (i / smallN) * 0.5
43
+ const th = h * 0.17, w = h * 0.72
44
+ if (i > 0) x += out[i - 1].h * DOMINO_GAP + th // previous pivot → this pivot: gap + this thickness
45
+ out.push({ n: isBig ? big[i - smallN][0] : '', date: isBig ? big[i - smallN][1] : '', h, th, w, px: x, T: times[i], hit: 0 })
46
+ }
47
+ for (let i = 0; i < out.length; i++) out[i].hit = i + 1 < out.length ? out[i + 1].T : out[i].T + 1.05
48
+ return out
49
+ }
50
+
51
+ /** tilt angle (rad) of domino i at time v */
52
+ export function dominoAngle(ds: Domino[], i: number, v: number): number {
53
+ const d = ds[i]
54
+ if (v <= d.T) return 0
55
+ const last = i === ds.length - 1
56
+ if (last) {
57
+ // free fall to the floor, tiny settle bounce
58
+ const u = (v - d.T) / (d.hit - d.T)
59
+ if (u < 1) return (Math.PI / 2) * u * u
60
+ const k = v - d.hit
61
+ return Math.PI / 2 - 0.05 * Math.abs(Math.sin(k * 18)) * Math.exp(-k * 9)
62
+ }
63
+ if (v < d.hit) {
64
+ const u = (v - d.T) / (d.hit - d.T)
65
+ return DOMINO_THC * u * u
66
+ }
67
+ // leaning on the next one as it goes down
68
+ const nxt = dominoAngle(ds, i + 1, v)
69
+ return Math.min(DOMINO_THC + nxt * 0.72, Math.PI / 2 - 0.2)
70
+ }
71
+
72
+ /** "每 ~10 分钟 · 一个新区块": a new block lands every BLOCK_DT seconds after b3 */
73
+ export const BLOCK_DT = 0.5
74
+ export const BLOCK_N = 8
75
+ export const blockLand = (b3: number, i: number) => b3 + 0.35 + i * BLOCK_DT
76
+
77
+ /** Wall Street doors: a crack of light on "ETF", swing fully open on "大门就此打开" */
78
+ export function doorOpen(b1: number, b2: number, v: number) {
79
+ const crack = 0.07 * smooth((v - (b1 + 0.6)) / 0.8)
80
+ const full = smooth((v - (b2 - 0.35)) / 2.0)
81
+ return crack + (1 - crack) * full
82
+ }
83
+ export const doorCues = (b1: number, b2: number) => ({ crack: b1 + 0.6, swing: b2 - 0.35, stop: b2 + 1.65 })
84
+ function smooth(x: number) { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x) }
bitcoin-saga/web/src/bitcoin/engine/export.ts CHANGED
@@ -4,7 +4,7 @@ import { draw, loadFonts } from './render'
4
  import { renderSoundtrack, SR } from './audio'
5
  import { TOTAL } from '../timeline'
6
 
7
- export type ExportOpts = { fps: 24 | 30 | 60; orient: 'land' | 'port'; res: 1080 | 720; /** include background music (narration + SFX are always kept) */ music?: boolean; /** QA only: limit duration */ limit?: number }
8
  export type ExportProgress = { phase: string; p: number; eta?: number }
9
 
10
  export function exportSupported() {
@@ -12,7 +12,7 @@ export function exportSupported() {
12
  }
13
 
14
  async function pickVideoCodec(w: number, h: number, fps: number, bitrate: number) {
15
- for (const codec of ['avc1.640033', 'avc1.4d0033', 'avc1.42003e', 'avc1.640028']) {
16
  const cfg: VideoEncoderConfig = { codec, width: w, height: h, bitrate, framerate: fps, avc: { format: 'avc' } }
17
  try {
18
  const r = await VideoEncoder.isConfigSupported(cfg)
@@ -37,10 +37,10 @@ async function pickAudio(): Promise<{ cfg: AudioEncoderConfig; mux: 'aac' | 'opu
37
  }
38
 
39
  export async function exportMp4(o: ExportOpts, onProgress: (p: ExportProgress) => void, signal: { cancelled: boolean }): Promise<Blob> {
40
- const long = o.res, short = o.res === 1080 ? 1920 : 1280
41
  const W = o.orient === 'land' ? short : long
42
  const H = o.orient === 'land' ? long : short
43
- const mbps = (o.fps === 60 ? 9 : o.fps === 30 ? 8 : 6) * (o.res === 720 ? 0.55 : 1)
44
  onProgress({ phase: '准备音轨', p: 0 })
45
  await loadFonts() // fonts + 3D coin assets (GLB / HDRI) must be resident before the first frame
46
  const audio = await renderSoundtrack({ music: o.music !== false })
 
4
  import { renderSoundtrack, SR } from './audio'
5
  import { TOTAL } from '../timeline'
6
 
7
+ export type ExportOpts = { fps: 24 | 30 | 60; orient: 'land' | 'port'; res: 1440 | 1080 | 720; /** include background music (narration + SFX are always kept) */ music?: boolean; /** QA only: limit duration */ limit?: number }
8
  export type ExportProgress = { phase: string; p: number; eta?: number }
9
 
10
  export function exportSupported() {
 
12
  }
13
 
14
  async function pickVideoCodec(w: number, h: number, fps: number, bitrate: number) {
15
+ for (const codec of ['avc1.640034', 'avc1.640033', 'avc1.4d0033', 'avc1.42003e', 'avc1.640028']) {
16
  const cfg: VideoEncoderConfig = { codec, width: w, height: h, bitrate, framerate: fps, avc: { format: 'avc' } }
17
  try {
18
  const r = await VideoEncoder.isConfigSupported(cfg)
 
37
  }
38
 
39
  export async function exportMp4(o: ExportOpts, onProgress: (p: ExportProgress) => void, signal: { cancelled: boolean }): Promise<Blob> {
40
+ const long = o.res, short = o.res === 1440 ? 2560 : o.res === 1080 ? 1920 : 1280
41
  const W = o.orient === 'land' ? short : long
42
  const H = o.orient === 'land' ? long : short
43
+ const mbps = (o.fps === 60 ? 20 : o.fps === 30 ? 16 : 13) * (o.res === 1440 ? 1.6 : o.res === 720 ? 0.5 : 1)
44
  onProgress({ phase: '准备音轨', p: 0 })
45
  await loadFonts() // fonts + 3D coin assets (GLB / HDRI) must be resident before the first frame
46
  const audio = await renderSoundtrack({ music: o.music !== false })
bitcoin-saga/web/src/bitcoin/engine/gfx.ts CHANGED
@@ -145,6 +145,9 @@ export type CubeOpts = {
145
  scale?: [number, number, number]
146
  }
147
 
 
 
 
148
  /** draws a glowing block; returns projected screen center + approx radius */
149
  export function cube(ctx: Ctx, cam: Cam, c: V3, o: CubeOpts) {
150
  const color = o.color ?? C.gold
@@ -158,6 +161,12 @@ export function cube(ctx: Ctx, cam: Cam, c: V3, o: CubeOpts) {
158
  const cp = wp.map((p) => toCam(p, cam))
159
  if (cp.every((p) => p[2] < 0.1)) return null
160
  const sp = cp.map((p) => proj(p, cam))
 
 
 
 
 
 
161
  const solid = o.solid ?? 1
162
  ctx.save()
163
  ctx.globalAlpha *= a
@@ -211,7 +220,7 @@ export function cube(ctx: Ctx, cam: Cam, c: V3, o: CubeOpts) {
211
  const cx = sp.reduce((s, p) => s + p[0], 0) / 8
212
  const cy = sp.reduce((s, p) => s + p[1], 0) / 8
213
  const r = Math.max(...sp.map((p) => Math.hypot(p[0] - cx, p[1] - cy)))
214
- if (o.label && solid > 0.3) {
215
  const zc = cp.reduce((s, p) => s + p[2], 0) / 8
216
  const fs = (o.size * cam.f) / zc * 0.42
217
  if (fs > 6) {
 
145
  scale?: [number, number, number]
146
  }
147
 
148
+ /** optional real-3D renderer for cubes (installed by stage3d) */
149
+ export const cubeHook: { fn: ((cam: Cam, c: V3, o: CubeOpts) => boolean) | null } = { fn: null }
150
+
151
  /** draws a glowing block; returns projected screen center + approx radius */
152
  export function cube(ctx: Ctx, cam: Cam, c: V3, o: CubeOpts) {
153
  const color = o.color ?? C.gold
 
161
  const cp = wp.map((p) => toCam(p, cam))
162
  if (cp.every((p) => p[2] < 0.1)) return null
163
  const sp = cp.map((p) => proj(p, cam))
164
+ const hooked = !!cubeHook.fn
165
+ if (cubeHook.fn && cubeHook.fn(cam, c, o)) {
166
+ const hx = sp.reduce((s, p) => s + p[0], 0) / 8
167
+ const hy = sp.reduce((s, p) => s + p[1], 0) / 8
168
+ return { x: hx, y: hy, r: Math.max(...sp.map((p) => Math.hypot(p[0] - hx, p[1] - hy))) }
169
+ }
170
  const solid = o.solid ?? 1
171
  ctx.save()
172
  ctx.globalAlpha *= a
 
220
  const cx = sp.reduce((s, p) => s + p[0], 0) / 8
221
  const cy = sp.reduce((s, p) => s + p[1], 0) / 8
222
  const r = Math.max(...sp.map((p) => Math.hypot(p[0] - cx, p[1] - cy)))
223
+ if (o.label && solid > 0.3 && !hooked) {
224
  const zc = cp.reduce((s, p) => s + p[2], 0) / 8
225
  const fs = (o.size * cam.f) / zc * 0.42
226
  if (fs > 6) {
bitcoin-saga/web/src/bitcoin/engine/logos.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ // Company marks from Simple Icons (CC0, simpleicons.org) — 24×24 SVG paths. Trademarks belong to their owners.
2
+ export const LOGOS: Record<string, { name: string; hex: string; path: string }> = {
3
+ microstrategy: { name: 'MicroStrategy', hex: '#D9232E', path: 'M9.095 2.572h5.827v18.856H9.096zM0 2.572h5.825v18.856H.001zm18.174 0v18.854H24V8.33z' },
4
+ tesla: { name: 'Tesla', hex: '#CC0000', path: 'M12 5.362l2.475-3.026s4.245.09 8.471 2.054c-1.082 1.636-3.231 2.438-3.231 2.438-.146-1.439-1.154-1.79-4.354-1.79L12 24 8.619 5.034c-3.18 0-4.188.354-4.335 1.792 0 0-2.146-.795-3.229-2.43C5.28 2.431 9.525 2.34 9.525 2.34L12 5.362l-.004.002H12v-.002zm0-3.899c3.415-.03 7.326.528 11.328 2.28.535-.968.672-1.395.672-1.395C19.625.612 15.528.015 12 0 8.472.015 4.375.61 0 2.349c0 0 .195.525.672 1.396C4.674 1.989 8.585 1.435 12 1.46v.003z' },
5
+ square: { name: 'Square', hex: '#3E4348', path: 'M4.01 0A4.01 4.01 0 000 4.01v15.98c0 2.21 1.8 4 4.01 4.01h15.98C22.2 24 24 22.2 24 19.99V4A4.01 4.01 0 0019.99 0H4zm1.62 4.36h12.74c.7 0 1.26.57 1.26 1.27v12.74c0 .7-.56 1.27-1.26 1.27H5.63c-.7 0-1.26-.57-1.26-1.27V5.63a1.27 1.27 0 011.26-1.27zm3.83 4.35a.73.73 0 00-.73.73v5.09c0 .4.32.72.72.72h5.1a.73.73 0 00.73-.72V9.44a.73.73 0 00-.73-.73h-5.1Z' },
6
+ }
bitcoin-saga/web/src/bitcoin/engine/render.ts CHANGED
@@ -8,6 +8,7 @@ import { s00, s01, s02, s03, s04 } from './scenes1'
8
  import { s05, s06, s07, s08 } from './scenes2'
9
  import { s09, s10, s11, s12 } from './scenes3'
10
  import { loadCoinGL } from './coingl'
 
11
 
12
  const SCENE_FNS: ((c: SC) => void)[] = [s00, s01, s02, s03, s04, s05, s06, s07, s08, s09, s10, s11, s12]
13
 
@@ -150,7 +151,7 @@ function transition(ctx: Ctx, T: number, W: number, H: number) {
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)
@@ -360,7 +361,9 @@ export function draw(ctx: Ctx, T: number, outW: number, outH: number) {
360
  if (a <= 0.001) continue
361
  if (a >= 0.999) {
362
  ctx.save()
 
363
  try { SCENE_FNS[k](makeSC(ctx, s, T, P)) } catch (e) { console.error('scene', k, e) }
 
364
  ctx.restore()
365
  } else {
366
  const lc = layerFor(outW, outH)
@@ -369,7 +372,9 @@ export function draw(ctx: Ctx, T: number, outW: number, outH: number) {
369
  l.clearRect(0, 0, outW, outH)
370
  l.setTransform(outW / W, 0, 0, outH / H, 0, 0)
371
  l.globalAlpha = 1
 
372
  try { SCENE_FNS[k](makeSC(l, s, Math.min(T, s.end + 0.45), P)) } catch (e) { console.error('scene', k, e) }
 
373
  ctx.save()
374
  ctx.setTransform(1, 0, 0, 1, 0, 0)
375
  ctx.globalAlpha = a
@@ -400,7 +405,7 @@ export function loadFonts() {
400
  return fontsP
401
  }
402
  async function doLoadFonts() {
403
- const coinP = loadCoinGL() // Blender-modelled GLB + studio HDRI → real-time PBR coin
404
  const faces: [string, string, string][] = [
405
  ['Film Serif SC', serif700, '700'], ['Film Serif SC', serif900, '900'],
406
  ['Film Sans SC', sans400, '400'], ['Film Sans SC', sans700, '700'], ['Film Sans SC', sans900, '900'],
 
8
  import { s05, s06, s07, s08 } from './scenes2'
9
  import { s09, s10, s11, s12 } from './scenes3'
10
  import { loadCoinGL } from './coingl'
11
+ import { loadStage, bindStage, endStage } from './stage3d'
12
 
13
  const SCENE_FNS: ((c: SC) => void)[] = [s00, s01, s02, s03, s04, s05, s06, s07, s08, s09, s10, s11, s12]
14
 
 
151
  // and drifting light leaks that bloom through each scene change. Applied under HUD/subtitles so text stays crisp.
152
  let caLayer: HTMLCanvasElement | null = null
153
  function lensCA(ctx: Ctx, T: number) {
154
+ let k = 0.0005
155
  for (let i = 1; i < SCENES.length; i++) {
156
  const d = Math.abs(T - SCENES[i].start)
157
  if (d < 0.4) k += 0.0075 * Math.pow(1 - d / 0.4, 2)
 
361
  if (a <= 0.001) continue
362
  if (a >= 0.999) {
363
  ctx.save()
364
+ bindStage(ctx)
365
  try { SCENE_FNS[k](makeSC(ctx, s, T, P)) } catch (e) { console.error('scene', k, e) }
366
+ endStage(ctx)
367
  ctx.restore()
368
  } else {
369
  const lc = layerFor(outW, outH)
 
372
  l.clearRect(0, 0, outW, outH)
373
  l.setTransform(outW / W, 0, 0, outH / H, 0, 0)
374
  l.globalAlpha = 1
375
+ bindStage(l)
376
  try { SCENE_FNS[k](makeSC(l, s, Math.min(T, s.end + 0.45), P)) } catch (e) { console.error('scene', k, e) }
377
+ endStage(l)
378
  ctx.save()
379
  ctx.setTransform(1, 0, 0, 1, 0, 0)
380
  ctx.globalAlpha = a
 
405
  return fontsP
406
  }
407
  async function doLoadFonts() {
408
+ const coinP = Promise.all([loadCoinGL(), loadStage()]) // Blender-modelled GLB coin + Three.js 3D stage (blocks, Earth, portal…)
409
  const faces: [string, string, string][] = [
410
  ['Film Serif SC', serif700, '700'], ['Film Serif SC', serif900, '900'],
411
  ['Film Sans SC', sans400, '400'], ['Film Sans SC', sans700, '700'], ['Film Sans SC', sans900, '900'],
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
  import { coinSprite } from './coingl'
7
 
8
  const GENESIS_HASH = '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f'
@@ -121,6 +122,7 @@ export function s01(c: SC) {
121
  size: P ? 2.6 : 2.4, yaw: 0.6 + t * 0.22, pitch: -0.42 + Math.sin(t * 0.4) * 0.05,
122
  build, solid, label: mined ? '#0' : '', sub: mined ? 'GENESIS' : '', glow: 1 + pulse * 2,
123
  })
 
124
  // orbiting satoshi particles
125
  ctx.save()
126
  ctx.globalCompositeOperation = 'lighter'
@@ -273,19 +275,17 @@ export function s02(c: SC) {
273
  const jr = (rnd(i, k + 5) - 0.5) * 0.05, ja = (rnd(i, k + 9) - 0.5) * 0.12
274
  const rr = (rf + (rf ? jr : 0)) * R, aa = an + k * 0.35 + ja
275
  const px = x + Math.cos(aa) * rr, py = y + Math.sin(aa) * rr
276
- const pr = R * 0.105 * Math.max(0, easeOutBack(inv(i * 0.035, i * 0.035 + 0.4, a)))
277
  if (pr <= 0.5) return
278
- ctx.fillStyle = 'rgba(40,6,4,0.45)'
279
- ctx.beginPath(); ctx.arc(px + pr * 0.12, py + pr * 0.16, pr, 0, Math.PI * 2); ctx.fill()
 
 
280
  const pg = ctx.createRadialGradient(px - pr * 0.35, py - pr * 0.4, pr * 0.1, px, py, pr)
281
- pg.addColorStop(0, '#f0644a'); pg.addColorStop(0.6, '#c42a1e'); pg.addColorStop(1, '#7e140e')
282
  ctx.fillStyle = pg
283
  ctx.beginPath(); ctx.arc(px, py, pr, 0, Math.PI * 2); ctx.fill()
284
- ctx.fillStyle = 'rgba(90,12,8,0.55)'
285
- for (let d = 0; d < 3; d++) {
286
- const da = rnd(i * 3 + d, k + 21) * Math.PI * 2, dr = rnd(i * 3 + d, k + 23) * pr * 0.55
287
- ctx.beginPath(); ctx.arc(px + Math.cos(da) * dr, py + Math.sin(da) * dr, pr * 0.12, 0, Math.PI * 2); ctx.fill()
288
- }
289
  })
290
  ctx.restore()
291
  })
@@ -350,10 +350,11 @@ export function s03(c: SC) {
350
  const e = easeOut(br)
351
  const dir = [gx + (rnd(i) - 0.5), gy + (rnd(i, 1) - 0.5) + 0.3, gz + (rnd(i, 2) - 0.5)]
352
  cube(ctx, cam, [gx * 1.07 + dir[0] * e * 4, gy * 1.07 + dir[1] * e * 4 - br * br * 5, gz * 1.07 + dir[2] * e * 3], {
353
- size: 0.5, yaw: 0.6 + rnd(i, 4) * e * 6, pitch: -0.4 + rnd(i, 5) * e * 5, color: C.red, alpha: 1 - inv(0.6, 1, br), solid: 0.8,
354
  })
355
  }
356
  }
 
357
  const lx = gb.x, ly = gb.y + (P ? 170 : 230)
358
  text(ctx, 'MT.GOX · 2014-02', lx, ly, 24, { font: FONT.mono, align: 'center', color: C.red, alpha: after(2, 0.5), spacing: 4 })
359
  bigNumber(ctx, 850000 * easeOutExpo(after(3, 1.2)), lx, ly + (P ? 80 : 90), P ? 70 : 78, { prefix: '−', suffix: ' BTC', align: 'center', color: '#ff6a5a', alpha: after(3, 0.4) })
 
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 { flush3D, tomb, portal, globe, logoTile, block } from './stage3d'
7
  import { coinSprite } from './coingl'
8
 
9
  const GENESIS_HASH = '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f'
 
122
  size: P ? 2.6 : 2.4, yaw: 0.6 + t * 0.22, pitch: -0.42 + Math.sin(t * 0.4) * 0.05,
123
  build, solid, label: mined ? '#0' : '', sub: mined ? 'GENESIS' : '', glow: 1 + pulse * 2,
124
  })
125
+ flush3D(ctx)
126
  // orbiting satoshi particles
127
  ctx.save()
128
  ctx.globalCompositeOperation = 'lighter'
 
275
  const jr = (rnd(i, k + 5) - 0.5) * 0.05, ja = (rnd(i, k + 9) - 0.5) * 0.12
276
  const rr = (rf + (rf ? jr : 0)) * R, aa = an + k * 0.35 + ja
277
  const px = x + Math.cos(aa) * rr, py = y + Math.sin(aa) * rr
278
+ const pr = R * 0.13 * Math.max(0, easeOutBack(inv(i * 0.035, i * 0.035 + 0.4, a)))
279
  if (pr <= 0.5) return
280
+ // a little gold bitcoin coin instead of a pepperoni slice (face-on so the ₿ reads)
281
+ ctx.fillStyle = 'rgba(40,10,2,0.5)'
282
+ ctx.beginPath(); ctx.ellipse(px + pr * 0.14, py + pr * 0.2, pr * 1.02, pr * 0.98, 0, 0, Math.PI * 2); ctx.fill()
283
+ if (coinSprite(ctx, px, py, pr, Math.sin(t * 1.3 + i * 0.7 + k) * 0.18)) return
284
  const pg = ctx.createRadialGradient(px - pr * 0.35, py - pr * 0.4, pr * 0.1, px, py, pr)
285
+ pg.addColorStop(0, '#ffe7a3'); pg.addColorStop(0.6, '#e2a52c'); pg.addColorStop(1, '#8a5a10')
286
  ctx.fillStyle = pg
287
  ctx.beginPath(); ctx.arc(px, py, pr, 0, Math.PI * 2); ctx.fill()
288
+ text(ctx, '₿', px, py, pr * 1.2, { font: FONT.sans, weight: 800, align: 'center', color: '#6b3e05' })
 
 
 
 
289
  })
290
  ctx.restore()
291
  })
 
350
  const e = easeOut(br)
351
  const dir = [gx + (rnd(i) - 0.5), gy + (rnd(i, 1) - 0.5) + 0.3, gz + (rnd(i, 2) - 0.5)]
352
  cube(ctx, cam, [gx * 1.07 + dir[0] * e * 4, gy * 1.07 + dir[1] * e * 4 - br * br * 5, gz * 1.07 + dir[2] * e * 3], {
353
+ size: 0.5, yaw: 0.6 + rnd(i, 4) * e * 6, pitch: -0.4 + rnd(i, 5) * e * 5, color: C.red, alpha: 1 - inv(0.6, 1, br),
354
  })
355
  }
356
  }
357
+ flush3D(ctx)
358
  const lx = gb.x, ly = gb.y + (P ? 170 : 230)
359
  text(ctx, 'MT.GOX · 2014-02', lx, ly, 24, { font: FONT.mono, align: 'center', color: C.red, alpha: after(2, 0.5), spacing: 4 })
360
  bigNumber(ctx, 850000 * easeOutExpo(after(3, 1.2)), lx, ly + (P ? 80 : 90), P ? 70 : 78, { prefix: '−', suffix: ' BTC', align: 'center', color: '#ff6a5a', alpha: after(3, 0.4) })
bitcoin-saga/web/src/bitcoin/engine/scenes2.ts CHANGED
@@ -1,8 +1,12 @@
1
  // Scenes 05–08
2
- import { C, FONT, clamp, easeInOut, easeOut, easeOutBack, easeOutExpo, fmtInt, inv, lerp, rgba, rnd } from './util'
 
3
  import { bigNumber, beam, cube, glowDot, panel, priceChart, project, text, type Cam, type V3 } from './gfx'
4
  import { day } from './price'
5
  import type { SC } from './sc'
 
 
 
6
 
7
  // ------------------------------------------------------------------ s05 分歧 2017
8
  export function s05(c: SC) {
@@ -37,9 +41,11 @@ export function s05(c: SC) {
37
  const br = blocks.filter((b) => b.label === 'SegWit' || b.label === 'BCH')
38
  br.forEach((b) => beam(ctx, cam, f0.p, b.p, b.col, b.a, 1.6))
39
  for (const b of sorted) cube(ctx, cam, b.p, { size: 0.8, yaw: 0.5, pitch: -0.25, color: b.col ?? C.gold, alpha: b.a, label: b.label })
 
40
  // branch tags
41
  const tag = (p: V3, s1: string, s2: string, col: string, a: number, dy: number) => {
42
- const [x, y] = project(p, cam)
 
43
  text(ctx, s1, x, y + dy, P ? 30 : 30, { font: FONT.mono, weight: 700, align: 'center', color: col, alpha: a, glow: 12 })
44
  text(ctx, s2, x, y + dy + 32, 20, { font: FONT.mono, align: 'center', color: C.dim, alpha: a })
45
  }
@@ -108,26 +114,36 @@ export function s06(c: SC) {
108
  // obituaries
109
  const ob = after(4, 0.1)
110
  if (ob > 0) {
 
111
  const ys = ['2018', '2019', '2020']
 
 
 
 
 
 
 
 
 
112
  ys.forEach((y, i) => {
113
- const p = after(4, 0.5, i * 0.45)
114
  if (p <= 0) return
115
- const x = P ? 230 + i * 310 : ib.x + 100 + i * 200
116
- const yy = P ? ib.y + 300 : ib.y + 420
117
- ctx.save()
118
- ctx.translate(x, yy - (1 - easeOutBack(p)) * 300)
119
- ctx.rotate((rnd(i, 7) - 0.5) * 0.2)
120
- ctx.globalAlpha *= p
121
- ctx.fillStyle = 'rgba(30,36,44,0.92)'
122
- ctx.strokeStyle = rgba(C.ice, 0.7)
123
- ctx.lineWidth = 2
124
- ctx.beginPath()
125
- ctx.moveTo(-70, 70); ctx.lineTo(-70, -30); ctx.arc(0, -30, 70, Math.PI, 0); ctx.lineTo(70, 70); ctx.closePath()
126
- ctx.fill(); ctx.stroke()
127
- text(ctx, 'R.I.P.', 0, -30, 32, { font: FONT.serif, weight: 900, align: 'center', color: C.white })
128
- text(ctx, 'BITCOIN', 0, 6, 18, { font: FONT.mono, weight: 700, align: 'center', color: C.ice })
129
- text(ctx, y, 0, 44, 24, { font: FONT.mono, weight: 700, align: 'center', color: C.dim })
130
- ctx.restore()
131
  })
132
  text(ctx, '讣告', P ? W / 2 : ib.x + 300, P ? ib.y + 420 : ib.y + 560, 30, { font: FONT.cnSerif, align: 'center', color: C.ice, alpha: after(4, 0.5, 1.4), spacing: 12 })
133
  }
@@ -136,54 +152,113 @@ export function s06(c: SC) {
136
  // ------------------------------------------------------------------ s07 机构与国家
137
  const SV = { lat: 13.7, lon: -89.2 }
138
  export function s07(c: SC) {
139
- const { ctx, t, v, P, L, B, after, W } = c
140
- const gb = P ? { cx: W / 2, cy: 640, r: 300 } : { cx: L.main.x + L.main.w / 2 - 40, cy: L.main.y + L.main.h / 2 + 10, r: 330 }
141
- const rise = easeOutBack(inv(0.3, 2.2, t))
142
- const ga = inv(0, 1.2, t)
143
- // target: bring El Salvador to face at clause 2
144
- const focus = easeInOut(after(2, 2))
145
- const lon0 = lerp(t * 18, 89.2, focus) * (Math.PI / 180)
146
- const tilt = lerp(0.35, -13.7 * (Math.PI / 180) + 0.1, focus)
147
- ctx.save()
148
- ctx.globalAlpha *= ga
149
- ctx.translate(0, (1 - rise) * 200)
150
- glowDot(ctx, gb.cx, gb.cy, gb.r * 1.6, C.gold, 0.25)
151
- ctx.fillStyle = 'rgba(12,9,4,0.9)'
152
- ctx.beginPath(); ctx.arc(gb.cx, gb.cy, gb.r, 0, Math.PI * 2); ctx.fill()
153
- ctx.strokeStyle = rgba(C.gold, 0.6); ctx.lineWidth = 2; ctx.stroke()
154
- // dot lattice
155
- const N = 900
156
- for (let i = 0; i < N; i++) {
157
- const y = 1 - (i / (N - 1)) * 2
158
- const rr = Math.sqrt(1 - y * y)
159
- const th = i * 2.399963 + lon0
160
- let x = Math.cos(th) * rr, z = Math.sin(th) * rr
161
- let yy = y
162
- ;[yy, z] = [yy * Math.cos(tilt) - z * Math.sin(tilt), yy * Math.sin(tilt) + z * Math.cos(tilt)]
163
- if (z < 0) continue
164
- const land = rnd(Math.floor(i / 7), 11) > 0.45
165
- ctx.fillStyle = rgba(land ? C.gold2 : C.gold, (land ? 0.25 : 0.08) + z * (land ? 0.6 : 0.15))
166
- const s = 1.2 + z * 2.2
167
- ctx.fillRect(gb.cx + x * gb.r - s / 2, gb.cy - yy * gb.r - s / 2, s, s)
168
- void x
 
 
 
 
 
 
169
  }
170
- // El Salvador marker
171
- const la = (SV.lat * Math.PI) / 180, lo = (SV.lon * Math.PI) / 180 + lon0
172
- let mx = Math.cos(la) * Math.sin(lo), my = Math.sin(la), mz = Math.cos(la) * Math.cos(lo)
173
- ;[my, mz] = [my * Math.cos(tilt) - mz * Math.sin(tilt), my * Math.sin(tilt) + mz * Math.cos(tilt)]
174
- const sa = after(2, 0.6)
175
- if (mz > 0 && sa > 0) {
176
- const px = gb.cx + mx * gb.r, py = gb.cy - my * gb.r
177
- for (let k = 0; k < 3; k++) {
178
- const ph = ((t * 0.8 + k / 3) % 1)
179
- ctx.strokeStyle = rgba(C.gold2, (1 - ph) * sa)
180
- ctx.lineWidth = 2
181
- ctx.beginPath(); ctx.arc(px, py, 10 + ph * 90, 0, Math.PI * 2); ctx.stroke()
 
 
 
 
 
 
 
 
 
182
  }
183
- glowDot(ctx, px, py, 60, C.gold2, sa)
184
- text(ctx, 'EL SALVADOR', px + 24, py - 20, 26, { font: FONT.mono, weight: 700, color: C.white, alpha: sa, glow: 10 })
185
  }
186
- ctx.restore()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  // cards
188
  const ib = L.info
189
  const cards = [
@@ -218,59 +293,117 @@ export function s07(c: SC) {
218
  // ------------------------------------------------------------------ s08 多米诺
219
  export function s08(c: SC) {
220
  const { ctx, t, v, P, L, B, after, W } = c
221
- const m = P ? { x: 0, y: 260, w: W, h: 700 } : L.main
222
- const cam: Cam = { x: P ? 3.9 : 2.5, y: 2.6, z: -13, yaw: -0.32, pitch: -0.2, f: P ? 560 : 900, cx: m.x + m.w / 2, cy: m.y + m.h * (P ? 0.42 : 0.15) }
223
- const names = ['LUNA', '3AC', 'CELSIUS', 'FTX']
224
- const dates = ['2022-05', '2022-06', '2022-07', '2022-11']
225
- const H2 = 1.6
226
- // each domino tips over around its base front edge (rotation about z axis)
227
- const order = names.map((n, i) => ({ n, i, x: i * 2.2 }))
228
- for (const d of order.slice().reverse()) {
229
- const at = B(0) + 1.1 + d.i * (P ? 0.9 : 0.9) + (d.i === 3 ? (B(1) - B(0) - 1.6) : 0)
230
- const fall = easeIn2(inv(at, at + 0.7, v))
231
- const ang = -fall * 1.25
232
- // pivot at (x + 0.35, -H2)
233
- const px = d.x + 0.35, py = -H2
234
- const cx = px + (-0.35) * Math.cos(ang) - H2 * Math.sin(ang)
235
- const cy = py + (-0.35) * Math.sin(ang) + H2 * Math.cos(ang)
236
- cube(ctx, cam, [cx, cy, 0], { size: 1, scale: [0.35, H2, 0.9], roll: ang, color: fall > 0.5 ? C.red : C.gold, label: d.n, alpha: inv(0.2 + d.i * 0.15, 0.8 + d.i * 0.15, t) })
237
- const [sx, sy] = project([d.x, -H2 - 0.6, 0.9], cam)
238
- text(ctx, dates[d.i], sx, sy + 24, 18, { font: FONT.mono, align: 'center', color: fall > 0.5 ? C.red : C.dim, alpha: inv(0.4, 1, t) })
239
  }
240
- // floor line
241
- const a0 = project([-2, -H2, 0], cam), a1 = project([10, -H2, 0], cam)
242
- ctx.strokeStyle = rgba(C.white, 0.15); ctx.lineWidth = 1.5
243
- ctx.beginPath(); ctx.moveTo(a0[0], a0[1]); ctx.lineTo(a1[0], a1[1]); ctx.stroke()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
 
245
  const ib = L.info
246
- const cp = after(2, 0.5)
247
  if (cp > 0) {
248
- const cb = P ? { x: 90, y: 1040, w: 900, h: 230 } : { x: ib.x, y: ib.y + 160, w: ib.w, h: 300 }
249
  const d0 = day('2022-01-01'), d1 = day('2022-12-31')
250
  priceChart(ctx, cb, d0, d1, lerp(d0, d1, easeInOut(after(2, 2))), { color: C.red, yMin: 14000, yMax: 48000, markers: [{ d: day('2022-11-22'), label: '$15,794', color: C.red, below: true }] }, cp)
251
  const val = 47000 - (47000 - 15794) * easeOutExpo(after(2, 2))
252
- bigNumber(ctx, val, P ? W / 2 : ib.x, P ? 1000 : ib.y + 110, P ? 76 : 96, { color: '#ff7a6a', align: P ? 'center' : 'left', alpha: cp * (1 - after(3, 0.5) * 0.7) })
253
  }
254
- // but the blocks keep coming
255
- const k = after(3, 0.6)
256
- if (k > 0) {
257
- const y = P ? 1480 : ib.y + 590
258
- const x0 = P ? 110 : ib.x
259
- const span = P ? 860 : ib.w
260
- const n = 10
261
- const lt = v - B(3)
 
 
 
 
 
 
 
 
262
  for (let i = 0; i < n; i++) {
263
- const p = inv(i * 0.35, i * 0.35 + 0.25, lt)
264
- if (p <= 0) continue
265
- const s = 40
266
- const x = x0 + (i / (n - 1)) * (span - s)
267
- ctx.fillStyle = rgba(C.gold, 0.2 + 0.6 * p)
268
- ctx.fillRect(x, y - s * easeOutBack(p), s, s * easeOutBack(p))
269
- ctx.strokeStyle = C.gold2; ctx.lineWidth = 1.5
270
- ctx.strokeRect(x, y - s * easeOutBack(p), s, s * easeOutBack(p))
 
 
 
 
 
 
 
271
  }
272
- text(ctx, '每 ~10 分钟 · 一个新区块', P ? W / 2 : x0, y + 52, P ? 32 : 30, { font: FONT.cnSerif, weight: 700, align: P ? 'center' : 'left', color: C.gold2, alpha: k })
 
 
 
 
 
 
 
 
273
  }
274
- void fmtInt; void clamp
 
 
 
 
275
  }
276
- const easeIn2 = (x: number) => x * x
 
1
  // Scenes 05–08
2
+ import { C, FONT, clamp, easeInOut, easeOut, easeOutBack, easeOutExpo, fmtInt, inv, lerp, rgba, rnd, win } from './util'
3
+ import { coin3dGL } from './coingl'
4
  import { bigNumber, beam, cube, glowDot, panel, priceChart, project, text, type Cam, type V3 } from './gfx'
5
  import { day } from './price'
6
  import type { SC } from './sc'
7
+ import { BLOCK_DT, BLOCK_N, blockLand, dominoAngle, dominoPlan } from './cues'
8
+ import { flush3D, tomb, portal, globe, logoTile, block } from './stage3d'
9
+ import { rays } from './scenes3'
10
 
11
  // ------------------------------------------------------------------ s05 分歧 2017
12
  export function s05(c: SC) {
 
41
  const br = blocks.filter((b) => b.label === 'SegWit' || b.label === 'BCH')
42
  br.forEach((b) => beam(ctx, cam, f0.p, b.p, b.col, b.a, 1.6))
43
  for (const b of sorted) cube(ctx, cam, b.p, { size: 0.8, yaw: 0.5, pitch: -0.25, color: b.col ?? C.gold, alpha: b.a, label: b.label })
44
+ flush3D(ctx)
45
  // branch tags
46
  const tag = (p: V3, s1: string, s2: string, col: string, a: number, dy: number) => {
47
+ const [x0, y] = project(p, cam)
48
+ const x = Math.min(x0, W - 190)
49
  text(ctx, s1, x, y + dy, P ? 30 : 30, { font: FONT.mono, weight: 700, align: 'center', color: col, alpha: a, glow: 12 })
50
  text(ctx, s2, x, y + dy + 32, 20, { font: FONT.mono, align: 'center', color: C.dim, alpha: a })
51
  }
 
114
  // obituaries
115
  const ob = after(4, 0.1)
116
  if (ob > 0) {
117
+ // three real 3D granite headstones slam down into the snow, one after another
118
  const ys = ['2018', '2019', '2020']
119
+ const U = P ? 150 : 104
120
+ const yy = P ? ib.y + 290 : ib.y + 420
121
+ const cam: Cam = { x: 0, y: 2.1, z: -10, yaw: 0, pitch: Math.atan2(1.3, 10), f: U * 10, cx: P ? W / 2 : ib.x + ib.w / 2, cy: yy }
122
+ // snow mound (2D, behind)
123
+ const mound = project([0, -0.12, 0], cam)
124
+ ctx.save(); ctx.translate(mound[0], mound[1]); ctx.scale(1, 0.16)
125
+ const mg = ctx.createRadialGradient(0, 0, 0, 0, 0, U * 3.4)
126
+ mg.addColorStop(0, rgba('#e8f4ff', 0.5 * ob)); mg.addColorStop(0.7, rgba('#9fc8e8', 0.18 * ob)); mg.addColorStop(1, 'rgba(160,200,232,0)')
127
+ ctx.fillStyle = mg; ctx.beginPath(); ctx.arc(0, 0, U * 3.4, 0, Math.PI * 2); ctx.fill(); ctx.restore()
128
  ys.forEach((y, i) => {
129
+ const p = after(4, 0.45, i * 0.45)
130
  if (p <= 0) return
131
+ const drop = 1 - p * p // gravity
132
+ const land = Math.max(0, v - (B(4) + i * 0.45 + 0.45))
133
+ const wob = Math.sin(land * 16) * 0.05 * Math.exp(-land * 6)
134
+ tomb(cam, [(i - 1) * 1.9, drop * 3.2, (i === 1 ? -0.3 : 0.15)], { year: y, yaw: (rnd(i, 7) - 0.5) * 0.5, roll: (rnd(i, 8) - 0.5) * 0.12 + wob, pitch: -0.05, alpha: Math.min(1, p * 3), scale: 0.95 })
135
+ })
136
+ flush3D(ctx, { bloom: 0.5 })
137
+ // powder puff where each one lands
138
+ ys.forEach((_, i) => {
139
+ const land = v - (B(4) + i * 0.45 + 0.45)
140
+ if (land < 0 || land > 1.2) return
141
+ const q = project([(i - 1) * 1.9, 0, 0], cam)
142
+ for (let k = 0; k < 14; k++) {
143
+ const an = Math.PI + rnd(k, i + 30) * Math.PI
144
+ const r = U * (0.4 + land * 1.4 * (0.5 + rnd(k, i + 40)))
145
+ glowDot(ctx, q[0] + Math.cos(an) * r, q[1] + Math.sin(an) * r * 0.35, 6 + rnd(k, i + 50) * 8, '#e8f4ff', 0.55 * (1 - land / 1.2))
146
+ }
147
  })
148
  text(ctx, '讣告', P ? W / 2 : ib.x + 300, P ? ib.y + 420 : ib.y + 560, 30, { font: FONT.cnSerif, align: 'center', color: C.ice, alpha: after(4, 0.5, 1.4), spacing: 12 })
149
  }
 
152
  // ------------------------------------------------------------------ s07 机构与国家
153
  const SV = { lat: 13.7, lon: -89.2 }
154
  export function s07(c: SC) {
155
+ const { ctx, t, v, P, L, B, after, W, H } = c
156
+ const m = P ? { x: 60, y: 280, w: 960, h: 680 } : L.main
157
+ const mcx = m.x + m.w / 2, mcy = m.y + m.h / 2
158
+ const cam: Cam = { x: 0, y: 0, z: -9, yaw: 0, pitch: 0, f: P ? 880 : 1000, cx: mcx, cy: mcy }
159
+ // phases: coin rises → company balance sheets → El Salvador on the globe → Taproot → coin returns at the new high
160
+ const aCoin1 = win(v, -1, B(2) - 0.1, 0.8, 0.5)
161
+ const aLogos = win(v, B(1) - 0.2, B(2) - 0.1, 0.5, 0.5)
162
+ const aGlobe = win(v, B(2) - 0.3, B(3) + 0.1, 0.7, 0.6)
163
+ const aTap = win(v, B(3) - 0.2, 99, 0.6, 0.1) * (1 - 0.8 * easeInOut(after(4, 0.8)))
164
+ const aCoin2 = easeInOut(after(4, 0.9, 0.1))
165
+
166
+ // ---- 1) the coin stands back up (then holds above the balance-sheet tiles)
167
+ if (aCoin1 > 0) {
168
+ const rise = easeOutBack(inv(-0.6, 1.6, v))
169
+ const up = easeInOut(after(1, 1.0, -0.2))
170
+ const R = lerp(P ? 250 : 230, P ? 130 : 110, up) * (0.4 + 0.6 * rise)
171
+ const cy = lerp(mcy + (1 - rise) * 240, m.y + (P ? 120 : 110), up)
172
+ const spin = (1 - easeOut(inv(-0.6, 3.2, v))) * Math.PI * 5
173
+ rays(ctx, mcx, cy, R * 3, aCoin1 * 0.7, t)
174
+ coin3dGL(ctx, mcx, cy, R, spin + 0.3 + 0.2 * Math.sin(t * 0.6), { tilt: -0.08, light: 0.35 + 0.5 * Math.sin(t * 0.4), alpha: aCoin1, glow: 0.9, shadow: 1 - up })
175
+ }
176
+ // ---- 2) "资产负债表": real company marks on gold-framed plaques fly in
177
+ if (aLogos > 0) {
178
+ const slugs = ['microstrategy', 'tesla', 'square']
179
+ const notes = ['2020-08 · 首家上市公司', '2021-02 · $1.5B', '2020-10 · $50M']
180
+ slugs.forEach((slug, i) => {
181
+ const p = easeOutBack(after(1, 0.9, 0.15 + i * 0.35))
182
+ if (p <= 0) return
183
+ const x = (i - 1) * (P ? 3.0 : 3.1)
184
+ logoTile(cam, [x, P ? -0.55 : -0.35, (1 - p) * -4], { slug, yaw: (1 - p) * Math.PI * 1.5 + Math.sin(t * 0.8 + i) * 0.12 + (i - 1) * -0.18, pitch: 0.05, scale: P ? 0.95 : 1, alpha: aLogos * Math.min(1, p * 2) })
185
+ })
186
+ flush3D(ctx, { bloom: 0.5 })
187
+ slugs.forEach((_, i) => {
188
+ const q = project([(i - 1) * (P ? 3.0 : 3.1), (P ? -0.55 : -0.35) - 1.25, 0], cam)
189
+ text(ctx, notes[i], q[0], q[1] + 10, P ? 20 : 19, { font: FONT.mono, align: 'center', color: C.gold2, alpha: aLogos * after(1, 0.5, 0.6 + i * 0.35) })
190
+ })
191
  }
192
+ // ---- 3) "萨尔瓦多": the Earth turns to Central America, pin + ripples
193
+ if (aGlobe > 0) {
194
+ const g = globe(cam, [0, 0, 0], { radius: (P ? 2.9 : 2.75) * (0.8 + 0.2 * easeOut(after(2, 0.9, -0.3))), spin: t * 0.25, focus: easeInOut(after(2, 2.2, -0.3)), lat: SV.lat, lon: SV.lon, pin: easeOutBack(after(2, 0.6, 1.0)), alpha: aGlobe }, W, H)
195
+ glowDot(ctx, mcx, mcy, (P ? 2.9 : 2.75) * cam.f / 9 * 1.5, '#6fb4ff', 0.18 * aGlobe)
196
+ flush3D(ctx, { bloom: 0.7 })
197
+ const sa = after(2, 0.6, 1.1) * aGlobe
198
+ if (g && g.pinVisible && sa > 0) {
199
+ const [px, py] = g.pin
200
+ ctx.save(); ctx.globalCompositeOperation = 'lighter'
201
+ for (let k = 0; k < 3; k++) {
202
+ const ph = (t * 0.8 + k / 3) % 1
203
+ ctx.strokeStyle = rgba(C.gold2, (1 - ph) * sa); ctx.lineWidth = 2
204
+ ctx.beginPath(); ctx.ellipse(px, py + 12, 8 + ph * 70, (8 + ph * 70) * 0.55, 0, 0, Math.PI * 2); ctx.stroke()
205
+ }
206
+ ctx.restore()
207
+ const lx = px + 70, ly = py - 70
208
+ ctx.save(); ctx.fillStyle = rgba('#05070d', 0.6 * sa); ctx.beginPath(); ctx.roundRect(lx - 14, ly - 40, 290, 100, 12); ctx.fill(); ctx.restore()
209
+ ctx.strokeStyle = rgba(C.white, 0.8 * sa); ctx.lineWidth = 1.5
210
+ ctx.beginPath(); ctx.moveTo(px + 6, py - 6); ctx.lineTo(lx - 8, ly + 8); ctx.lineTo(lx + 250, ly + 8); ctx.stroke()
211
+ text(ctx, 'EL SALVADOR', lx, ly - 4, P ? 28 : 28, { font: FONT.mono, weight: 700, color: C.white, alpha: sa, glow: 10 })
212
+ text(ctx, '2021-09-07 · 法定货币', lx, ly + 36, P ? 22 : 22, { font: FONT.sans, weight: 700, color: C.gold2, alpha: sa })
213
  }
 
 
214
  }
215
+ // ---- 4) "Taproot": block #709,632 with its Merkle (MAST) tree of spending paths
216
+ if (aTap > 0) {
217
+ const ta = after(3, 0.8, -0.2)
218
+ const bcy = P ? -0.2 : 0.05
219
+ const tcam: Cam = { ...cam, cy: m.y + m.h * (P ? 0.3 : 0.27) }
220
+ // tree in 2D (behind the block)
221
+ const root = { x: mcx, y: tcam.cy + 120 }
222
+ const lvl1 = [-1, 1].map((sx) => ({ x: mcx + sx * (P ? 220 : 240), y: root.y + 140 }))
223
+ const leaves = [-3, -1, 1, 3].map((sx) => ({ x: mcx + sx * (P ? 115 : 125), y: root.y + 300 }))
224
+ const names = ['SCHNORR', 'KEY PATH', 'MAST', 'TAPSCRIPT']
225
+ const grow = (k: number) => after(3, 0.4, 0.15 + k * 0.25)
226
+ ctx.save(); ctx.globalAlpha *= aTap
227
+ ctx.globalCompositeOperation = 'lighter'
228
+ const edge = (a: { x: number; y: number }, b: { x: number; y: number }, p: number) => {
229
+ if (p <= 0) return
230
+ const mx = a.x + (b.x - a.x) * p, my = a.y + (b.y - a.y) * p
231
+ for (const [w, col] of [[6, rgba(C.gold, 0.25)], [1.8, '#ffe6b0']] as [number, string][]) {
232
+ ctx.strokeStyle = col; ctx.lineWidth = w
233
+ ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.bezierCurveTo(a.x, (a.y + my) / 2, mx, (a.y + my) / 2, mx, my); ctx.stroke()
234
+ }
235
+ }
236
+ lvl1.forEach((n) => edge(root, n, grow(0)))
237
+ leaves.forEach((n, k) => edge(lvl1[k < 2 ? 0 : 1], n, grow(1)))
238
+ ctx.restore()
239
+ ;[root, ...lvl1].forEach((n, k) => glowDot(ctx, n.x, n.y, 26, C.gold2, aTap * (k ? grow(0) : ta)))
240
+ leaves.forEach((n, k) => {
241
+ const p = grow(2 + k * 0.5) * aTap
242
+ if (p <= 0) return
243
+ glowDot(ctx, n.x, n.y, 30, C.gold2, p)
244
+ ctx.save(); ctx.globalAlpha *= p
245
+ ctx.fillStyle = 'rgba(20,14,6,0.9)'; ctx.strokeStyle = rgba(C.gold, 0.9); ctx.lineWidth = 1.5
246
+ const bw = P ? 112 : 118
247
+ ctx.beginPath(); ctx.roundRect(n.x - bw / 2, n.y + 22, bw, 38, 8); ctx.fill(); ctx.stroke()
248
+ ctx.restore()
249
+ text(ctx, names[k], n.x, n.y + 48, P ? 17 : 17, { font: FONT.mono, weight: 700, align: 'center', color: C.white, alpha: p })
250
+ })
251
+ text(ctx, 'MERKLE ROOT', root.x + 30, root.y + 6, 16, { font: FONT.mono, color: C.dim, alpha: aTap * grow(0) })
252
+ block(tcam, [0, bcy, 0], { size: 1.05, yaw: 0.55 + t * 0.35, pitch: 0.32, label: '#709,632', sub: 'TAPROOT', alpha: aTap * Math.min(1, ta * 1.5), glow: 1.2 })
253
+ flush3D(ctx, { bloom: 1 })
254
+ }
255
+ // ---- 5) the coin returns at the new high
256
+ if (aCoin2 > 0) {
257
+ const R = (P ? 210 : 190) * (0.5 + 0.5 * easeOutBack(aCoin2))
258
+ const cy = mcy - (P ? 20 : 10)
259
+ rays(ctx, mcx, cy, R * 3.2, aCoin2 * (0.6 + 0.2 * Math.sin(t * 1.6)), t)
260
+ coin3dGL(ctx, mcx, cy, R, (1 - easeOut(after(4, 2.5))) * Math.PI * 4 + 0.3 + 0.2 * Math.sin(t * 0.6), { tilt: -0.08, light: 0.4 + 0.5 * Math.sin(t * 0.5), alpha: aCoin2, glow: 1, shadow: 0.6 })
261
+ }
262
  // cards
263
  const ib = L.info
264
  const cards = [
 
293
  // ------------------------------------------------------------------ s08 多米诺
294
  export function s08(c: SC) {
295
  const { ctx, t, v, P, L, B, after, W } = c
296
+ const m = P ? { x: 0, y: 260, w: W, h: 760 } : L.main
297
+ // ---- the cascade: small dominoes run on "接连倒下", LUNA tips on "LUNA", the giant FTX lands on "崩塌"
298
+ const ds = dominoPlan(B(0), B(1))
299
+ const ftx = ds[ds.length - 1]
300
+ const blocksOn = after(3, 0.7)
301
+ const fly = easeInOut(inv(B(0) + 0.6, ftx.hit + 0.6, v))
302
+ const cam: Cam = {
303
+ x: lerp(0.6, P ? 6.2 : 5.4, fly), y: lerp(1.7, 3.4, fly), z: lerp(-5.6, P ? -14 : -11.5, fly),
304
+ yaw: lerp(0.42, 0.22, fly), pitch: lerp(0.2, 0.26, fly),
305
+ f: P ? 900 : 1000, cx: m.x + m.w / 2, cy: m.y + m.h * (P ? 0.74 : 0.56),
 
 
 
 
 
 
 
 
306
  }
307
+ const shake = Math.exp(-Math.max(0, v - ftx.hit) * 6) * (v > ftx.hit ? 14 : 0)
308
+ ctx.save()
309
+ ctx.translate((rnd(Math.floor(t * 50)) - 0.5) * shake, (rnd(Math.floor(t * 50), 2) - 0.5) * shake)
310
+ ctx.globalAlpha *= 1 - blocksOn * 0.72
311
+ // polished floor (2D, behind) with a soft red light pool where the giant lands
312
+ const fl = [project([-3, 0, -3], cam), project([16, 0, -3], cam), project([16, 0, 6], cam), project([-3, 0, 6], cam)]
313
+ const fg = ctx.createLinearGradient(0, fl[2][1], 0, fl[0][1])
314
+ fg.addColorStop(0, 'rgba(255,255,255,0)'); fg.addColorStop(1, 'rgba(255,240,220,0.05)')
315
+ ctx.fillStyle = fg
316
+ ctx.beginPath(); fl.forEach((q, k) => (k ? ctx.lineTo(q[0], q[1]) : ctx.moveTo(q[0], q[1]))); ctx.closePath(); ctx.fill()
317
+ const land = project([ftx.px + ftx.h * 0.5, 0, 0], cam)
318
+ glowDot(ctx, land[0], land[1], 420, C.red, 0.35 * inv(ftx.T, ftx.hit + 0.3, v))
319
+ // contact shadows
320
+ ds.forEach((d, i) => {
321
+ const a = dominoAngle(ds, i, v)
322
+ const sx = d.px - d.th / 2 + Math.sin(a) * d.h * 0.5
323
+ const q = project([sx, 0, 0], cam)
324
+ const r = (cam.f * (d.h * 0.45 + Math.sin(a) * d.h * 0.4)) / Math.max(1, toCamZ([sx, 0, 0], cam))
325
+ ctx.save(); ctx.translate(q[0], q[1]); ctx.scale(1, 0.32)
326
+ const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r)
327
+ g.addColorStop(0, 'rgba(0,0,0,0.55)'); g.addColorStop(1, 'rgba(0,0,0,0)')
328
+ ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill(); ctx.restore()
329
+ })
330
+ // dominoes (real 3D: obsidian bodies, gold plates; the label sits on the big faces and turns with the domino)
331
+ const appear = inv(0, 0.8, t)
332
+ ds.forEach((d, i) => {
333
+ const a = dominoAngle(ds, i, v)
334
+ const c0: V3 = [d.px - (d.th / 2) * Math.cos(a) + (d.h / 2) * Math.sin(a), (d.th / 2) * Math.sin(a) + (d.h / 2) * Math.cos(a), 0]
335
+ const down = v > d.hit
336
+ block(cam, c0, { size: 1, scale: [d.th / 2, d.h / 2, d.w / 2], roll: -a, domino: true, color: down && d.n ? C.red : C.gold, label: d.n, sub: d.date, alpha: appear, glow: d.n ? 1 : 0.6 })
337
+ })
338
+ flush3D(ctx, { bloom: 0.8 })
339
+ // date tags float above the four named dominoes until they go down
340
+ ds.forEach((d) => {
341
+ if (!d.n) return
342
+ const q = project([d.px - d.th / 2, d.h + 0.45, 0], cam)
343
+ const edge = Math.min(q[0] - 60, W - 60 - q[0])
344
+ const ta = inv(0.4, 1.2, t) * (1 - inv(d.T, d.T + 0.3, v)) * clamp(edge / 80, 0, 1)
345
+ if (ta <= 0) return
346
+ text(ctx, d.n, q[0], q[1] - 8, 24, { font: FONT.mono, weight: 700, align: 'center', color: C.white, alpha: ta })
347
+ text(ctx, d.date, q[0], q[1] + 18, 16, { font: FONT.mono, align: 'center', color: C.dim, alpha: ta })
348
+ })
349
+ ctx.restore()
350
 
351
+ // ---- price chart in the info column
352
  const ib = L.info
353
+ const cp = after(2, 0.5) * (1 - (P ? blocksOn : blocksOn * 0.85))
354
  if (cp > 0) {
355
+ const cb = P ? { x: 90, y: 1170, w: 900, h: 260 } : { x: ib.x, y: ib.y + 160, w: ib.w, h: 300 }
356
  const d0 = day('2022-01-01'), d1 = day('2022-12-31')
357
  priceChart(ctx, cb, d0, d1, lerp(d0, d1, easeInOut(after(2, 2))), { color: C.red, yMin: 14000, yMax: 48000, markers: [{ d: day('2022-11-22'), label: '$15,794', color: C.red, below: true }] }, cp)
358
  const val = 47000 - (47000 - 15794) * easeOutExpo(after(2, 2))
359
+ bigNumber(ctx, val, P ? W / 2 : ib.x, P ? 1120 : ib.y + 110, P ? 76 : 96, { color: '#ff7a6a', align: P ? 'center' : 'left', alpha: cp })
360
  }
361
+
362
+ // ---- "but the blocks keep coming": a real 3D chain, one new block lands every beat (~10 minutes each)
363
+ if (blocksOn > 0) {
364
+ const n = BLOCK_N
365
+ const landT = (i: number) => blockLand(B(3), i)
366
+ let landed = 0
367
+ for (let i = 0; i < n; i++) landed += easeInOut(inv(landT(i) - 0.15, landT(i) + 0.35, v))
368
+ const SP = 2.5
369
+ const bc: Cam = { x: (landed - 1) * SP - (P ? 0 : 1.2), y: 1.6, z: -10.5, yaw: -0.12, pitch: 0.1, f: P ? 900 : 980, cx: m.x + m.w / 2, cy: m.y + m.h * 0.5 }
370
+ ctx.save()
371
+ ctx.globalAlpha *= blocksOn
372
+ // chain links
373
+ for (let i = 1; i < n; i++) {
374
+ const la = inv(landT(i), landT(i) + 0.2, v)
375
+ if (la > 0) beam(ctx, bc, [(i - 1) * SP, 0, 0], [i * SP, 0, 0], C.gold, la * 0.9, 2)
376
+ }
377
  for (let i = 0; i < n; i++) {
378
+ const u = inv(landT(i) - 0.32, landT(i), v)
379
+ if (u <= 0) continue
380
+ const y = (1 - u * u) * 4.2 + (v > landT(i) ? Math.sin((v - landT(i)) * 22) * 0.12 * Math.exp(-(v - landT(i)) * 8) : 0)
381
+ const flash = v > landT(i) ? Math.exp(-(v - landT(i)) * 5) : 0
382
+ block(bc, [i * SP, y, 0], { size: 0.85, yaw: 0.55 + (v - landT(i)) * 0.18, pitch: -0.32, color: C.gold, label: fmtInt(764993 + i).replace(/^/, '#'), sub: '~10 MIN', alpha: Math.min(1, u * 2), glow: 1 + flash * 1.5 })
383
+ }
384
+ flush3D(ctx, { bloom: 1.1 })
385
+ // 10-minute clock: the hand sweeps once per block
386
+ const ccx = P ? W / 2 : m.x + m.w - 120, ccy = P ? m.y + 90 : m.y + 110, cr = 56
387
+ const cyc = clamp((v - landT(0) + BLOCK_DT) / BLOCK_DT, 0, n)
388
+ ctx.strokeStyle = rgba(C.gold, 0.5); ctx.lineWidth = 2
389
+ ctx.beginPath(); ctx.arc(ccx, ccy, cr, 0, Math.PI * 2); ctx.stroke()
390
+ for (let k = 0; k < 12; k++) {
391
+ const a = (k / 12) * Math.PI * 2
392
+ ctx.beginPath(); ctx.moveTo(ccx + Math.cos(a) * cr * 0.82, ccy + Math.sin(a) * cr * 0.82); ctx.lineTo(ccx + Math.cos(a) * cr * 0.94, ccy + Math.sin(a) * cr * 0.94); ctx.stroke()
393
  }
394
+ const ha = (cyc % 1) * Math.PI * 2 - Math.PI / 2
395
+ ctx.strokeStyle = C.gold2; ctx.lineWidth = 4; ctx.lineCap = 'round'
396
+ ctx.beginPath(); ctx.moveTo(ccx, ccy); ctx.lineTo(ccx + Math.cos(ha) * cr * 0.78, ccy + Math.sin(ha) * cr * 0.78); ctx.stroke()
397
+ ctx.beginPath(); ctx.arc(ccx, ccy, cr + 8, -Math.PI / 2, -Math.PI / 2 + (cyc % 1) * Math.PI * 2); ctx.strokeStyle = rgba(C.gold2, 0.8); ctx.lineWidth = 3; ctx.stroke()
398
+ text(ctx, '10:00', ccx, ccy + cr + 34, 20, { font: FONT.mono, weight: 700, align: 'center', color: C.gold2 })
399
+ ctx.restore()
400
+ const ty = P ? m.y + m.h + 40 : m.y + m.h - 30
401
+ text(ctx, '每 ~10 分钟 · 一个新区块', m.x + m.w / 2, ty, P ? 44 : 46, { font: FONT.cnSerif, weight: 900, align: 'center', color: C.white, alpha: blocksOn, glow: 18, glowColor: C.gold, spacing: 4 })
402
+ text(ctx, 'TICK · TOCK · NEXT BLOCK', m.x + m.w / 2, ty + (P ? 50 : 50), 18, { font: FONT.mono, align: 'center', color: C.gold, alpha: blocksOn, spacing: 6 })
403
  }
404
+ void easeOutBack
405
+ }
406
+ const toCamZ = (p: V3, cam: Cam) => {
407
+ const dx = p[0] - cam.x, dz = p[2] - cam.z
408
+ return dx * Math.sin(cam.yaw) + dz * Math.cos(cam.yaw)
409
  }
 
bitcoin-saga/web/src/bitcoin/engine/scenes3.ts CHANGED
@@ -1,12 +1,14 @@
1
  // Scenes 09–12
2
  import { C, FONT, clamp, easeInOut, easeOut, easeOutBack, easeOutExpo, fmtInt, inv, lerp, rgba, rnd } from './util'
3
- import { beam, bigNumber, cube, glowDot, panel, priceChart, text, typeText, type Cam, type V3 } from './gfx'
4
  import { ATH, LAST, day } from './price'
5
  import { supplyAt } from './scenes1'
6
  import type { SC } from './sc'
 
7
  import { coin3dGL } from './coingl'
 
8
 
9
- function rays(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, a: number, t: number, col = C.gold) {
10
  if (a <= 0) return
11
  ctx.save()
12
  ctx.globalCompositeOperation = 'lighter'
@@ -29,42 +31,36 @@ export function s09(c: SC) {
29
  const m = P ? { x: 90, y: 300, w: 900, h: 760 } : L.main
30
  const cx = m.x + m.w / 2, cy = m.y + m.h / 2
31
  const doorA = inv(0.1, 0.9, t) * (1 - after(3, 0.6))
32
- const open = easeInOut(after(1, 2.2))
33
  if (doorA > 0) {
34
  ctx.save()
35
  ctx.globalAlpha *= doorA
36
- // light behind
37
- glowDot(ctx, cx, cy, m.h * 0.9 * (0.3 + open), C.gold2, 0.9 * open)
38
- rays(ctx, cx, cy, m.h * 0.9, open, t)
39
- text(ctx, 'SPOT BITCOIN ETF', cx, cy - 10, P ? 64 : 78, { font: FONT.serif, weight: 900, align: 'center', color: '#fff6dd', glow: 40, glowColor: C.gold, alpha: open })
40
- text(ctx, '2024-01-11 · 11 只现货 ETF 同日上市', cx, cy + 56, P ? 28 : 30, { font: FONT.sans, weight: 700, align: 'center', color: C.ink, alpha: open * 0.0 })
41
- text(ctx, '2024-01-11 · 11 只现货 ETF 同日上市', cx, cy + 60, P ? 28 : 30, { font: FONT.sans, weight: 700, align: 'center', color: C.white, alpha: open })
42
- // doors
43
- const dw = m.w / 2
44
- for (const side of [-1, 1]) {
45
- const x0 = side < 0 ? m.x - open * dw * 0.98 : cx + open * dw * 0.98
46
- const g = ctx.createLinearGradient(x0, 0, x0 + dw, 0)
47
- g.addColorStop(0, '#16110a'); g.addColorStop(0.5, '#241a0c'); g.addColorStop(1, '#120d07')
48
- ctx.fillStyle = g
49
- ctx.fillRect(x0, m.y + 70, dw, m.h - 70)
50
- ctx.strokeStyle = rgba(C.gold, 0.55)
51
- ctx.lineWidth = 2
52
- ctx.strokeRect(x0 + 18, m.y + 90, dw - 36, m.h - 110)
53
- for (let k = 1; k < 6; k++) {
54
- const xx = x0 + 18 + ((dw - 36) * k) / 6
55
- ctx.strokeStyle = rgba(C.gold, 0.18)
56
- ctx.beginPath(); ctx.moveTo(xx, m.y + 110); ctx.lineTo(xx, m.y + m.h - 40); ctx.stroke()
57
- }
58
- // handle
59
- ctx.fillStyle = C.gold
60
- ctx.fillRect(side < 0 ? x0 + dw - 40 : x0 + 28, cy - 50, 12, 100)
61
- }
62
- // lintel
63
- ctx.fillStyle = '#1a140b'
64
- ctx.fillRect(m.x - 10, m.y, m.w + 20, 70)
65
- ctx.strokeStyle = C.gold; ctx.lineWidth = 2
66
- ctx.strokeRect(m.x - 10, m.y, m.w + 20, 70)
67
- text(ctx, 'W A L L S T R E E T', cx, m.y + 48, 30, { font: FONT.serif, weight: 700, align: 'center', color: C.gold2 })
68
  ctx.restore()
69
  }
70
  // halving block 840,000
@@ -75,6 +71,7 @@ export function s09(c: SC) {
75
  const flash = Math.exp(-Math.max(0, v - B(3) - 0.9) * 4) * (v > B(3) + 0.9 ? 1 : 0)
76
  glowDot(ctx, cam.cx, cam.cy, 380 + flash * 300, C.gold, (0.3 + flash) * hb)
77
  cube(ctx, cam, [0, (1 - drop) * 6, 0], { size: 2.2, yaw: 0.7 + t * 0.25, pitch: -0.4, label: '#840,000', sub: 'HALVING IV', alpha: hb, glow: 1 + flash * 2 })
 
78
  text(ctx, '6.25 → 3.125 BTC', cam.cx, cam.cy + (P ? 330 : 330), 46, { font: FONT.mono, weight: 700, align: 'center', color: C.gold2, alpha: hb, glow: 16 })
79
  text(ctx, '2024-04-20 · 第四次减半', cam.cx, cam.cy + (P ? 384 : 384), 28, { font: FONT.sans, align: 'center', color: C.white, alpha: hb })
80
  }
@@ -111,41 +108,58 @@ export function s09(c: SC) {
111
  }
112
 
113
  // ------------------------------------------------------------------ s10 国家储备 + 新高
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  function seal(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, t: number, a: number) {
115
  if (a <= 0) return
116
  ctx.save()
117
  ctx.globalAlpha *= a
118
  ctx.translate(x, y)
119
- glowDot(ctx, 0, 0, r * 1.7, C.gold, 0.35)
120
- ctx.fillStyle = 'rgba(20,14,5,0.92)'
121
- ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill()
122
- ctx.strokeStyle = C.gold2; ctx.lineWidth = 4
123
- ctx.stroke()
124
- ctx.lineWidth = 1.5
125
- ctx.beginPath(); ctx.arc(0, 0, r * 0.93, 0, Math.PI * 2); ctx.stroke()
126
- ctx.beginPath(); ctx.arc(0, 0, r * 0.62, 0, Math.PI * 2); ctx.stroke()
127
- // rotating legend
128
- const s = '★ STRATEGIC BITCOIN RESERVE ★ EXECUTIVE ORDER · 2025-03-06 '
129
- ctx.save()
130
- ctx.rotate(t * 0.12)
131
- ctx.font = `700 ${r * 0.1}px ${FONT.mono}`
132
- ctx.fillStyle = C.gold2
133
- ctx.textAlign = 'center'
134
- for (let i = 0; i < s.length; i++) {
135
  ctx.save()
136
- ctx.rotate((i / s.length) * Math.PI * 2)
137
- ctx.fillText(s[i], 0, -r * 0.74)
 
138
  ctx.restore()
139
  }
 
 
140
  ctx.restore()
141
- // stars ring
142
- for (let i = 0; i < 13; i++) {
143
- const an = (i / 13) * Math.PI * 2 - t * 0.05
144
- ctx.fillStyle = C.gold
145
- ctx.beginPath(); ctx.arc(Math.cos(an) * r * 0.52, Math.sin(an) * r * 0.52, r * 0.018, 0, Math.PI * 2); ctx.fill()
146
- }
147
- text(ctx, '₿', 0, r * 0.17, r * 0.5, { font: FONT.serif, weight: 900, align: 'center', color: C.gold2, glow: 30 })
148
- ctx.restore()
149
  }
150
 
151
  export function s10(c: SC) {
@@ -312,12 +326,13 @@ export function s12(c: SC) {
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)
323
  const settle = easeInOut(inv(B(2) - 0.5, B(2) + 0.9, v))
@@ -368,7 +383,7 @@ export function s12(c: SC) {
368
  const end = inv(D - 5.5, D - 4, t)
369
  if (end > 0) {
370
  text(ctx, '比特币发展历程 · 2008 → 2026', W / 2, P ? H * 0.68 : 860, P ? 30 : 28, { font: FONT.sans, weight: 700, align: 'center', color: C.white, alpha: end, spacing: 6 })
371
- text(ctx, '价格数据 Surf Data API · 旁白 Qwen3-TTS · 配乐与音效 程序化合成', W / 2, P ? H * 0.71 : 905, P ? 20 : 20, { font: FONT.mono, align: 'center', color: C.dim, alpha: end })
372
  }
373
  void easeOut; void easeOutBack; void easeOutExpo; void glowDot; void bigNumber; void panel; void priceChart; void rnd; void fmtInt
374
  }
 
1
  // Scenes 09–12
2
  import { C, FONT, clamp, easeInOut, easeOut, easeOutBack, easeOutExpo, fmtInt, inv, lerp, rgba, rnd } from './util'
3
+ import { beam, bigNumber, cube, glowDot, panel, priceChart, project, text, typeText, type Cam, type V3 } from './gfx'
4
  import { ATH, LAST, day } from './price'
5
  import { supplyAt } from './scenes1'
6
  import type { SC } from './sc'
7
+ import { flush3D, tomb, portal, globe, logoTile, block } from './stage3d'
8
  import { coin3dGL } from './coingl'
9
+ import { doorOpen } from './cues'
10
 
11
+ export function rays(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, a: number, t: number, col = C.gold) {
12
  if (a <= 0) return
13
  ctx.save()
14
  ctx.globalCompositeOperation = 'lighter'
 
31
  const m = P ? { x: 90, y: 300, w: 900, h: 760 } : L.main
32
  const cx = m.x + m.w / 2, cy = m.y + m.h / 2
33
  const doorA = inv(0.1, 0.9, t) * (1 - after(3, 0.6))
34
+ const open = doorOpen(B(1), B(2), v)
35
  if (doorA > 0) {
36
  ctx.save()
37
  ctx.globalAlpha *= doorA
38
+ // slow push-in towards the doorway, slight low angle for grandeur
39
+ const push = easeInOut(inv(0, B(3), v))
40
+ const camX = lerp(1.6, 0.7, push), camZ = lerp(-13.5, -10.6, push)
41
+ const cam: Cam = { x: camX, y: lerp(1.9, 2.1, push), z: camZ, yaw: Math.atan2(-camX, -camZ) * 0.85, pitch: lerp(-0.07, -0.03, push), f: P ? 950 : 1000, cx, cy: cy + (P ? 60 : 30) }
42
+ const dc = project([0, 1.95, 0.3], cam)
43
+ // light pouring out of the doorway (2D, behind the stone portal → visible only through the opening and around it)
44
+ const lit = Math.min(1, open * 1.4)
45
+ glowDot(ctx, dc[0], dc[1], m.h * (0.35 + 0.8 * lit), C.gold2, 0.25 + 0.75 * lit)
46
+ rays(ctx, dc[0], dc[1], m.h * 1.1, lit, t)
47
+ const hall = ctx.createRadialGradient(dc[0], dc[1], 0, dc[0], dc[1], m.h * 0.45)
48
+ hall.addColorStop(0, rgba('#fff6dd', 0.95 * lit)); hall.addColorStop(1, rgba(C.gold, 0))
49
+ ctx.fillStyle = hall; ctx.fillRect(dc[0] - m.h * 0.45, dc[1] - m.h * 0.45, m.h * 0.9, m.h * 0.9)
50
+ portal(cam, [0, 0, 0], { open })
51
+ flush3D(ctx, { bloom: 1.2 })
52
+ // light spill on the steps
53
+ const st = project([0, -0.1, -1.2], cam)
54
+ ctx.save(); ctx.translate(st[0], st[1]); ctx.scale(1, 0.18)
55
+ ctx.globalCompositeOperation = 'lighter'
56
+ const sg = ctx.createRadialGradient(0, 0, 0, 0, 0, 420)
57
+ sg.addColorStop(0, rgba(C.gold2, 0.45 * lit)); sg.addColorStop(1, rgba(C.gold, 0))
58
+ ctx.fillStyle = sg; ctx.beginPath(); ctx.arc(0, 0, 420, 0, Math.PI * 2); ctx.fill(); ctx.restore()
59
+ // the title steps out of the light once the leaves are wide open
60
+ const ta = inv(0.55, 0.95, open)
61
+ const ty = P ? m.y + m.h + 30 : m.y + m.h - 40
62
+ text(ctx, 'SPOT BITCOIN ETF', cx, ty - 46, P ? 60 : 64, { font: FONT.serif, weight: 900, align: 'center', color: '#fff6dd', glow: 40, glowColor: C.gold, alpha: ta, spacing: 4 })
63
+ text(ctx, '2024-01-11 · 11 只现货 ETF 同日上市', cx, ty + 4, P ? 28 : 28, { font: FONT.sans, weight: 700, align: 'center', color: C.white, alpha: ta })
 
 
 
 
 
 
64
  ctx.restore()
65
  }
66
  // halving block 840,000
 
71
  const flash = Math.exp(-Math.max(0, v - B(3) - 0.9) * 4) * (v > B(3) + 0.9 ? 1 : 0)
72
  glowDot(ctx, cam.cx, cam.cy, 380 + flash * 300, C.gold, (0.3 + flash) * hb)
73
  cube(ctx, cam, [0, (1 - drop) * 6, 0], { size: 2.2, yaw: 0.7 + t * 0.25, pitch: -0.4, label: '#840,000', sub: 'HALVING IV', alpha: hb, glow: 1 + flash * 2 })
74
+ flush3D(ctx, { bloom: 1 + flash })
75
  text(ctx, '6.25 → 3.125 BTC', cam.cx, cam.cy + (P ? 330 : 330), 46, { font: FONT.mono, weight: 700, align: 'center', color: C.gold2, alpha: hb, glow: 16 })
76
  text(ctx, '2024-04-20 · 第四次减半', cam.cx, cam.cy + (P ? 384 : 384), 28, { font: FONT.sans, align: 'center', color: C.white, alpha: hb })
77
  }
 
108
  }
109
 
110
  // ------------------------------------------------------------------ s10 国家储备 + 新高
111
+ /** text along a circle, upright and legible: top arc reads left→right over the top, bottom arc reads left→right under */
112
+ function arcText(ctx: CanvasRenderingContext2D, s: string, r: number, size: number, top: boolean, spacing = 0.12) {
113
+ ctx.font = `700 ${size}px ${FONT.mono}`
114
+ const ws = [...s].map((ch) => ctx.measureText(ch).width + size * spacing)
115
+ const total = ws.reduce((a, b) => a + b, 0)
116
+ const span = total / r
117
+ let a = (top ? -Math.PI / 2 : Math.PI / 2) + (top ? -span / 2 : span / 2)
118
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
119
+ ;[...s].forEach((ch, k) => {
120
+ const half = ws[k] / 2 / r
121
+ a += top ? half : -half
122
+ ctx.save()
123
+ ctx.translate(Math.cos(a) * r, Math.sin(a) * r)
124
+ ctx.rotate(top ? a + Math.PI / 2 : a - Math.PI / 2)
125
+ ctx.fillText(ch, 0, 0)
126
+ ctx.restore()
127
+ a += top ? half : -half
128
+ })
129
+ }
130
+
131
+ /** the reserve seal: an engraved gold medallion ring with clean arc lettering around the real 3D coin */
132
  function seal(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, t: number, a: number) {
133
  if (a <= 0) return
134
  ctx.save()
135
  ctx.globalAlpha *= a
136
  ctx.translate(x, y)
137
+ glowDot(ctx, 0, 0, r * 1.8, C.gold, 0.32)
138
+ // ring band
139
+ const band = ctx.createRadialGradient(0, 0, r * 0.66, 0, 0, r)
140
+ band.addColorStop(0, '#120c05'); band.addColorStop(0.5, '#1d1408'); band.addColorStop(1, '#0d0904')
141
+ ctx.fillStyle = band
142
+ ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.arc(0, 0, r * 0.66, 0, Math.PI * 2, true); ctx.fill('evenodd')
143
+ const rim = ctx.createLinearGradient(-r, -r, r, r)
144
+ rim.addColorStop(0, '#ffe3a1'); rim.addColorStop(0.45, '#c98a1c'); rim.addColorStop(0.55, '#f7c35a'); rim.addColorStop(1, '#7a4d0c')
145
+ ctx.strokeStyle = rim
146
+ ctx.lineWidth = r * 0.03; ctx.beginPath(); ctx.arc(0, 0, r * 0.985, 0, Math.PI * 2); ctx.stroke()
147
+ ctx.lineWidth = r * 0.012; ctx.beginPath(); ctx.arc(0, 0, r * 0.93, 0, Math.PI * 2); ctx.stroke()
148
+ ctx.beginPath(); ctx.arc(0, 0, r * 0.67, 0, Math.PI * 2); ctx.stroke()
149
+ // lettering (gold, with a soft engraved shadow)
150
+ const tr = r * 0.8, fs = r * 0.095
151
+ for (const pass of [0, 1]) {
 
152
  ctx.save()
153
+ if (pass === 0) { ctx.translate(0, fs * 0.06); ctx.fillStyle = 'rgba(0,0,0,0.8)' } else { ctx.fillStyle = '#ffd98a'; ctx.shadowColor = rgba(C.gold, 0.8); ctx.shadowBlur = fs * 0.5 }
154
+ arcText(ctx, 'STRATEGIC BITCOIN RESERVE', tr, fs, true)
155
+ arcText(ctx, 'EXECUTIVE ORDER · 2025-03-06', tr, fs * 0.82, false)
156
  ctx.restore()
157
  }
158
+ // stars at 9 and 3 o'clock separating the arcs
159
+ for (const sx of [-1, 1]) text(ctx, '★', sx * tr, fs * 0.36, fs * 1.05, { font: FONT.mono, align: 'center', color: '#ffd98a' })
160
  ctx.restore()
161
+ // the real coin in the middle, slowly turning so the light runs over it
162
+ coin3dGL(ctx, x, y, r * 0.6, 0.35 * Math.sin(t * 0.6) + 0.2, { tilt: -0.06, light: 0.4 + 0.5 * Math.sin(t * 0.45), alpha: a, glow: 0.5, shadow: 0 })
 
 
 
 
 
 
163
  }
164
 
165
  export function s10(c: SC) {
 
326
  const pts: V3[] = MILESTONES.map((_, i) => [Math.sin(i * 1.3) * 3.2, Math.cos(i * 0.9) * 1.4, i * gap])
327
  // the chain recedes into the background once the closing title cards come in, so labels never fight the type
328
  const recede = 1 - 0.85 * easeInOut(after(2, 1.6))
329
+ for (let i = 0; i < MILESTONES.length - 1; i++) beam(ctx, cam, pts[i], pts[i + 1], C.gold, 0.7 * recede, 1.8)
330
  for (let i = MILESTONES.length - 1; i >= 0; i--) {
 
331
  const dz = pts[i][2] - cam.z
332
  const a = clamp(inv(0.5, 4, dz)) * clamp(1 - inv(70, 100, dz))
333
  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 })
334
  }
335
+ flush3D(ctx)
336
  // ---- 3D coin: rises out of the chain as a hero, then settles above the title
337
  const ca = inv(B(1) - 0.6, B(1) + 0.9, v)
338
  const settle = easeInOut(inv(B(2) - 0.5, B(2) + 0.9, v))
 
383
  const end = inv(D - 5.5, D - 4, t)
384
  if (end > 0) {
385
  text(ctx, '比特币发展历程 · 2008 → 2026', W / 2, P ? H * 0.68 : 860, P ? 30 : 28, { font: FONT.sans, weight: 700, align: 'center', color: C.white, alpha: end, spacing: 6 })
386
+ text(ctx, '本片仅作历史科普 · 不构成任何投资建议', W / 2, P ? H * 0.71 : 908, P ? 26 : 24, { font: FONT.cnSerif, weight: 700, align: 'center', color: C.dim, alpha: end, spacing: 4 })
387
  }
388
  void easeOut; void easeOutBack; void easeOutExpo; void glowDot; void bigNumber; void panel; void priceChart; void rnd; void fmtInt
389
  }
bitcoin-saga/web/src/bitcoin/engine/stage3d.ts ADDED
@@ -0,0 +1,639 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Real-time 3D stage (Three.js / WebGL) for everything in the film that is an object rather than a graphic:
2
+ // blocks, dominoes, tombstones, the Wall Street portal, the Earth, logo tiles.
3
+ // • the scenes keep their own lightweight camera (gfx.Cam: position, yaw, pitch, focal length in px, principal point);
4
+ // it is converted into an exact Three.js camera (custom projection matrix) so 3D objects line up pixel-perfectly
5
+ // with the 2D graphics drawn around them.
6
+ // • objects are queued during a scene's draw and rendered in one pass by flush3D(ctx) — the transparent WebGL frame
7
+ // is composited into the 2D film frame at that point (2D drawn before = behind, after = in front) plus a soft bloom.
8
+ // • lighting: the Blender studio HDRI (image-based lighting, PMREM) + a key light; Khronos PBR-Neutral tone mapping.
9
+ // • labels are painted onto the faces (canvas textures, emissive), so block numbers turn with their block.
10
+ // Pure function of the arguments → deterministic frames for the MP4 export.
11
+ import * as THREE from 'three'
12
+ import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js'
13
+ import { HDRLoader } from 'three/examples/jsm/loaders/HDRLoader.js'
14
+ import hdrUrl from '../assets/3d/studio.hdr?url'
15
+ import earthUrl from '../assets/3d/earth.jpg?url'
16
+ import earthSpecUrl from '../assets/3d/earth-spec.jpg?url'
17
+ import earthNormUrl from '../assets/3d/earth-normal.jpg?url'
18
+ import earthLightsUrl from '../assets/3d/earth-lights.jpg?url'
19
+ import { cubeHook, type Cam, type CubeOpts, type V3 } from './gfx'
20
+ import { FONT } from './util'
21
+ import { LOGOS } from './logos'
22
+
23
+ type Ctx = CanvasRenderingContext2D
24
+
25
+ type St = {
26
+ renderer: THREE.WebGLRenderer
27
+ scene: THREE.Scene
28
+ camera: THREE.PerspectiveCamera
29
+ key: THREE.DirectionalLight
30
+ earth: { map: THREE.Texture; rough: THREE.Texture; normal: THREE.Texture; lights: THREE.Texture } | null
31
+ w: number
32
+ h: number
33
+ }
34
+ let st: St | null = null
35
+ let loadP: Promise<boolean> | null = null
36
+
37
+ function loadTex(url: string, srgb: boolean) {
38
+ return new THREE.TextureLoader().loadAsync(url).then((t) => {
39
+ t.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace
40
+ t.anisotropy = 8
41
+ return t
42
+ })
43
+ }
44
+
45
+ async function init(): Promise<boolean> {
46
+ try {
47
+ const canvas = document.createElement('canvas')
48
+ const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true, preserveDrawingBuffer: true, premultipliedAlpha: true, powerPreference: 'high-performance' })
49
+ renderer.setPixelRatio(1)
50
+ renderer.setClearColor(0x000000, 0)
51
+ renderer.toneMapping = THREE.NeutralToneMapping
52
+ renderer.toneMappingExposure = 1.0
53
+ renderer.outputColorSpace = THREE.SRGBColorSpace
54
+ const [hdr, map, spec, normal, lights] = await Promise.all([
55
+ new HDRLoader().loadAsync(hdrUrl), loadTex(earthUrl, true), loadTex(earthSpecUrl, false), loadTex(earthNormUrl, false), loadTex(earthLightsUrl, true),
56
+ ])
57
+ hdr.mapping = THREE.EquirectangularReflectionMapping
58
+ const pm = new THREE.PMREMGenerator(renderer)
59
+ const env = pm.fromEquirectangular(hdr).texture
60
+ hdr.dispose(); pm.dispose()
61
+ // ocean = glossy, land = matte: invert the specular mask into a roughness map
62
+ const sc = document.createElement('canvas'); sc.width = spec.image.width; sc.height = spec.image.height
63
+ const sg = sc.getContext('2d')!
64
+ sg.filter = 'invert(1) brightness(0.75)'; sg.drawImage(spec.image as CanvasImageSource, 0, 0)
65
+ sg.filter = 'none'; sg.globalCompositeOperation = 'lighter'; sg.fillStyle = 'rgb(40,40,40)'; sg.fillRect(0, 0, sc.width, sc.height)
66
+ const rough = new THREE.CanvasTexture(sc); rough.colorSpace = THREE.NoColorSpace
67
+ const scene = new THREE.Scene()
68
+ scene.environment = env
69
+ scene.add(new THREE.HemisphereLight(0xfff2dd, 0x1a1208, 0.35))
70
+ const key = new THREE.DirectionalLight(0xfff0d8, 2.2); key.position.set(-4, 6, 8); scene.add(key)
71
+ const rim = new THREE.DirectionalLight(0xffc070, 1.6); rim.position.set(6, 2, -6); scene.add(rim)
72
+ const camera = new THREE.PerspectiveCamera(40, 16 / 9, 0.1, 400)
73
+ st = { renderer, scene, camera, key, earth: { map, rough, normal, lights }, w: 0, h: 0 }
74
+ // real 3D block; while a block is still assembling (solid < 1) the 2D glowing wireframe is drawn as well
75
+ cubeHook.fn = (cam, c, o) => { block(cam, c, o); return (o.solid ?? 1) >= 0.999 }
76
+ return true
77
+ } catch (e) {
78
+ console.warn('[stage3d] WebGL stage unavailable, using 2D fallbacks:', e)
79
+ st = null
80
+ return false
81
+ }
82
+ }
83
+ export function loadStage() {
84
+ if (!loadP) loadP = init()
85
+ return loadP
86
+ }
87
+ export const stageReady = () => !!st
88
+
89
+ // ---------------------------------------------------------------- coordinate conversion
90
+ // gfx space: x right, y up, camera looks along +z. Three: camera looks along -z → mirror z.
91
+ const toThree = (p: V3) => new THREE.Vector3(p[0], p[1], -p[2])
92
+ /** gfx rot(yaw,pitch,roll) = Rz·Rx·Ry; conjugated by the z-mirror the x/y angles flip sign */
93
+ const euler = (yaw = 0, pitch = 0, roll = 0) => new THREE.Euler(-pitch, -yaw, roll, 'ZXY')
94
+
95
+ function setCamera(cam: Cam, W: number, H: number) {
96
+ const c = st!.camera
97
+ c.position.copy(toThree([cam.x, cam.y, cam.z]))
98
+ c.rotation.set(-cam.pitch, -cam.yaw, 0, 'YXZ')
99
+ c.updateMatrixWorld(true)
100
+ const n = 0.1, f = 400
101
+ c.projectionMatrix.set(
102
+ (2 * cam.f) / W, 0, 1 - (2 * cam.cx) / W, 0,
103
+ 0, (2 * cam.f) / H, (2 * cam.cy) / H - 1, 0,
104
+ 0, 0, -(f + n) / (f - n), (-2 * f * n) / (f - n),
105
+ 0, 0, -1, 0,
106
+ )
107
+ c.projectionMatrixInverse.copy(c.projectionMatrix).invert()
108
+ }
109
+
110
+ /** screen position (design px) of a Three world point under the current camera */
111
+ function screenOf(v: THREE.Vector3, W: number, H: number): [number, number, number] {
112
+ const p = v.clone().project(st!.camera)
113
+ return [(p.x + 1) * 0.5 * W, (1 - p.y) * 0.5 * H, p.z]
114
+ }
115
+
116
+ // ---------------------------------------------------------------- textures
117
+ const texCache = new Map<string, THREE.CanvasTexture>()
118
+ function canvasTex(key: string, w: number, h: number, paint: (g: CanvasRenderingContext2D, w: number, h: number) => void, srgb = true) {
119
+ let t = texCache.get(key)
120
+ if (t) return t
121
+ const c = document.createElement('canvas'); c.width = w; c.height = h
122
+ const g = c.getContext('2d')!
123
+ paint(g, w, h)
124
+ t = new THREE.CanvasTexture(c)
125
+ t.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace
126
+ t.anisotropy = 8
127
+ t.generateMipmaps = true
128
+ t.minFilter = THREE.LinearMipmapLinearFilter
129
+ texCache.set(key, t)
130
+ return t
131
+ }
132
+
133
+ type Tint = 'gold' | 'red' | 'white' | 'ice' | 'green'
134
+ const INK: Record<Tint, { line: string; text: string; sub: string; body: [number, number, number] }> = {
135
+ gold: { line: '#f7a823', text: '#fff1cf', sub: '#ffc75a', body: [0.2, 0.12, 0.04] },
136
+ red: { line: '#ff4a3a', text: '#ffe2dc', sub: '#ff8a7a', body: [0.22, 0.035, 0.025] },
137
+ white: { line: '#dfe7f0', text: '#ffffff', sub: '#b8c4d0', body: [0.09, 0.095, 0.11] },
138
+ ice: { line: '#7fd3ff', text: '#eaf8ff', sub: '#a8e2ff', body: [0.05, 0.1, 0.14] },
139
+ green: { line: '#7ee0b0', text: '#eafff4', sub: '#a8f0cc', body: [0.04, 0.14, 0.09] },
140
+ }
141
+ const tintOf = (color?: string): Tint => {
142
+ const c = (color ?? '').toLowerCase()
143
+ if (c.includes('ff3b3b') || c.includes('red') || c === '#ff5a4a') return 'red'
144
+ if (c.includes('ffffff') || c.includes('f4f1ea') || c === '#fff' || c.includes('white')) return 'white'
145
+ if (c.includes('7fd3ff')) return 'ice'
146
+ if (c.includes('7ee0b0')) return 'green'
147
+ return 'gold'
148
+ }
149
+
150
+ function fitFont(g: CanvasRenderingContext2D, s: string, weight: number, family: string, maxW: number, maxH: number) {
151
+ let size = maxH
152
+ g.font = `${weight} ${size}px ${family}`
153
+ const w = g.measureText(s).width
154
+ if (w > maxW) size *= maxW / w
155
+ g.font = `${weight} ${size}px ${family}`
156
+ return size
157
+ }
158
+
159
+ /** block face: framed plate with the block number (and a sub line), painted for colour (map) or glow (emissive) */
160
+ function faceTex(label: string, sub: string, tint: Tint, aspect: number, emissive: boolean) {
161
+ const vertical = aspect > 1.7 // tall domino faces: text runs up the face
162
+ const W = 512, H = Math.round(Math.min(1024, Math.max(128, 512 * aspect)))
163
+ return canvasTex(`face|${label}|${sub}|${tint}|${aspect.toFixed(2)}|${emissive ? 1 : 0}`, W, H, (g) => {
164
+ const ink = INK[tint]
165
+ if (!emissive) {
166
+ const bg = g.createLinearGradient(0, 0, W, H)
167
+ bg.addColorStop(0, '#2a1d0c'); bg.addColorStop(0.5, '#120c05'); bg.addColorStop(1, '#1d1408')
168
+ g.fillStyle = bg; g.fillRect(0, 0, W, H)
169
+ // fine brushed lines
170
+ g.globalAlpha = 0.07
171
+ for (let i = 0; i < 90; i++) { g.fillStyle = i % 2 ? '#ffffff' : '#000000'; g.fillRect(0, (i * 37) % H, W, 1) }
172
+ g.globalAlpha = 1
173
+ } else { g.fillStyle = '#000'; g.fillRect(0, 0, W, H) }
174
+ const m = Math.min(W, H) * 0.07
175
+ g.strokeStyle = ink.line; g.lineWidth = Math.max(3, Math.min(W, H) * 0.018)
176
+ g.globalAlpha = emissive ? 0.85 : 1
177
+ g.strokeRect(m, m, W - 2 * m, H - 2 * m)
178
+ g.lineWidth *= 0.45; g.globalAlpha = emissive ? 0.35 : 0.6
179
+ g.strokeRect(m * 1.7, m * 1.7, W - 3.4 * m, H - 3.4 * m)
180
+ g.globalAlpha = 1
181
+ // corner ticks
182
+ g.lineWidth = Math.max(4, Math.min(W, H) * 0.03)
183
+ const k = Math.min(W, H) * 0.16
184
+ for (const [x, y, sx, sy] of [[m, m, 1, 1], [W - m, m, -1, 1], [m, H - m, 1, -1], [W - m, H - m, -1, -1]]) {
185
+ g.beginPath(); g.moveTo(x, y + sy * k); g.lineTo(x, y); g.lineTo(x + sx * k, y); g.stroke()
186
+ }
187
+ if (!label) {
188
+ // top / bottom faces: a small engraved ₿
189
+ g.fillStyle = ink.line; g.globalAlpha = emissive ? 0.5 : 0.8
190
+ g.textAlign = 'center'; g.textBaseline = 'middle'
191
+ g.font = `900 ${Math.min(W, H) * 0.32}px ${FONT.serif}`
192
+ g.fillText('₿', W / 2, H / 2)
193
+ g.globalAlpha = 1
194
+ return
195
+ }
196
+ g.save()
197
+ g.translate(W / 2, H / 2)
198
+ let fw = W, fh = H
199
+ if (vertical) { g.rotate(-Math.PI / 2); fw = H; fh = W }
200
+ g.textAlign = 'center'; g.textBaseline = 'middle'
201
+ const lineH = sub ? fh * 0.3 : fh * 0.38
202
+ const s1 = fitFont(g, label, 700, FONT.mono, fw * 0.8, lineH)
203
+ g.fillStyle = emissive ? ink.text : ink.text
204
+ if (!emissive) { g.shadowColor = 'rgba(0,0,0,0.8)'; g.shadowBlur = s1 * 0.12; g.shadowOffsetY = s1 * 0.04 }
205
+ g.fillText(label, 0, sub ? -fh * 0.1 : 0)
206
+ if (sub) {
207
+ fitFont(g, sub, 700, FONT.mono, fw * 0.72, fh * 0.13)
208
+ g.fillStyle = ink.sub
209
+ g.fillText(sub, 0, fh * 0.2)
210
+ }
211
+ g.restore()
212
+ })
213
+ }
214
+
215
+ // ---------------------------------------------------------------- pooled objects
216
+ const pools = new Map<string, THREE.Object3D[]>()
217
+ const used = new Map<string, number>()
218
+ const live: THREE.Object3D[] = []
219
+ function take<T extends THREE.Object3D>(kind: string, make: () => T): T {
220
+ let list = pools.get(kind)
221
+ if (!list) { list = []; pools.set(kind, list) }
222
+ const i = used.get(kind) ?? 0
223
+ used.set(kind, i + 1)
224
+ if (!list[i]) { list[i] = make(); st!.scene.add(list[i]) }
225
+ const o = list[i] as T
226
+ o.visible = true
227
+ live.push(o)
228
+ return o
229
+ }
230
+
231
+ const geoCache = new Map<string, THREE.BufferGeometry>()
232
+ function roundedBox(w: number, h: number, d: number) {
233
+ const k = `${w.toFixed(3)}|${h.toFixed(3)}|${d.toFixed(3)}`
234
+ let g = geoCache.get(k)
235
+ if (!g) { g = new RoundedBoxGeometry(w, h, d, 4, Math.min(w, h, d) * 0.08); geoCache.set(k, g) }
236
+ return g
237
+ }
238
+ const planeGeo = new THREE.PlaneGeometry(1, 1)
239
+
240
+ type BlockObj = THREE.Group & { body: THREE.Mesh; faces: THREE.Mesh[] }
241
+ function makeBlock(): BlockObj {
242
+ const g = new THREE.Group() as BlockObj
243
+ g.body = new THREE.Mesh(planeGeo, new THREE.MeshPhysicalMaterial({ metalness: 1, roughness: 0.24, clearcoat: 1, clearcoatRoughness: 0.08, transparent: true }))
244
+ g.add(g.body)
245
+ g.faces = []
246
+ for (let i = 0; i < 6; i++) {
247
+ const m = new THREE.Mesh(planeGeo, new THREE.MeshPhysicalMaterial({
248
+ metalness: 0.55, roughness: 0.32, clearcoat: 1, clearcoatRoughness: 0.06, transparent: true,
249
+ emissive: new THREE.Color(1, 1, 1), emissiveIntensity: 1.3, polygonOffset: true, polygonOffsetFactor: -2,
250
+ }))
251
+ g.faces.push(m); g.add(m)
252
+ }
253
+ return g
254
+ }
255
+
256
+ export type BlockOpts = CubeOpts & { domino?: boolean }
257
+ /** a real block: rounded polished-metal body; number plates on the side faces, ₿ plates on top & bottom */
258
+ export function block(cam: Cam, c: V3, o: BlockOpts) {
259
+ if (!st) return
260
+ const a = (o.alpha ?? 1) * Math.min(1, o.solid ?? 1)
261
+ if (a <= 0.003) return
262
+ queue(cam)
263
+ const sc = o.scale ?? [1, 1, 1]
264
+ const grow = 1
265
+ const w = 2 * o.size * sc[0] * grow, h = 2 * o.size * sc[1] * grow, d = 2 * o.size * sc[2] * grow
266
+ const tint = tintOf(o.color)
267
+ const b = take('block', makeBlock)
268
+ b.position.copy(toThree(c))
269
+ b.rotation.copy(euler(o.yaw, o.pitch, o.roll))
270
+ b.body.geometry = roundedBox(w, h, d)
271
+ const bm = b.body.material as THREE.MeshPhysicalMaterial
272
+ const body = o.domino ? [0.025, 0.022, 0.02] : INK[tint].body
273
+ bm.color.setRGB(body[0], body[1], body[2]); bm.roughness = o.domino ? 0.12 : 0.24; bm.metalness = o.domino ? 0.35 : 1
274
+ bm.opacity = a; bm.depthWrite = a > 0.98
275
+ const label = o.label ?? '', sub = o.sub ?? ''
276
+ const inset = Math.min(w, h, d) * 0.1
277
+ const glow = o.glow ?? 1
278
+ // +z, -z, +x, -x, +y, -y
279
+ const F: [number, number, number, number, number, number, number, boolean][] = [
280
+ [0, 0, d / 2, 0, 0, w, h, true], [0, 0, -d / 2, 0, Math.PI, w, h, true],
281
+ [w / 2, 0, 0, 0, Math.PI / 2, d, h, true], [-w / 2, 0, 0, 0, -Math.PI / 2, d, h, true],
282
+ [0, h / 2, 0, -Math.PI / 2, 0, w, d, false], [0, -h / 2, 0, Math.PI / 2, 0, w, d, false],
283
+ ]
284
+ F.forEach(([x, y, z, rx, ry, fw, fh, side], i) => {
285
+ const m = b.faces[i]
286
+ const pw = Math.max(0.01, fw - inset * 2), ph = Math.max(0.01, fh - inset * 2)
287
+ const eps = 0.004 * Math.max(w, h, d)
288
+ m.position.set(x + Math.sign(x) * eps, y + Math.sign(y) * eps, z + Math.sign(z) * eps)
289
+ m.rotation.set(rx, ry, 0, 'YXZ')
290
+ m.scale.set(pw, ph, 1)
291
+ const asp = ph / pw
292
+ const mat = m.material as THREE.MeshPhysicalMaterial
293
+ const L = side ? label : '', S = side ? sub : ''
294
+ mat.map = faceTex(L, S, tint, asp, false)
295
+ mat.emissiveMap = faceTex(L, S, tint, asp, true)
296
+ mat.emissiveIntensity = 1.25 * glow
297
+ mat.opacity = a; mat.depthWrite = a > 0.98
298
+ mat.needsUpdate = mat.userData.k !== mat.map.uuid
299
+ mat.userData.k = mat.map.uuid
300
+ })
301
+ }
302
+
303
+ // ---------------------------------------------------------------- tombstone
304
+ type TombObj = THREE.Group & { stone: THREE.Mesh; plate: THREE.Mesh; base: THREE.Mesh; snow: THREE.Mesh }
305
+ const graniteTex = () => canvasTex('granite', 512, 512, (g, w, h) => {
306
+ g.fillStyle = '#5d636b'; g.fillRect(0, 0, w, h)
307
+ for (let i = 0; i < 26000; i++) {
308
+ const x = (Math.sin(i * 12.9898) * 43758.5453) % 1, y = (Math.sin(i * 78.233) * 12543.1234) % 1
309
+ const v = (Math.sin(i * 3.17) * 9999) % 1
310
+ const c = 60 + Math.abs(v) * 120
311
+ g.fillStyle = `rgba(${c},${c + 4},${c + 10},0.55)`
312
+ g.fillRect(Math.abs(x) * w, Math.abs(y) * h, 1 + Math.abs(v) * 2.2, 1 + Math.abs(v) * 2.2)
313
+ }
314
+ })
315
+ function tombShape() {
316
+ const s = new THREE.Shape()
317
+ const w = 0.62, hh = 1.0, r = 0.62
318
+ s.moveTo(-w, 0); s.lineTo(-w, hh); s.absarc(0, hh, r, Math.PI, 0, true); s.lineTo(w, 0); s.lineTo(-w, 0)
319
+ return s
320
+ }
321
+ let tombGeo: THREE.ExtrudeGeometry | null = null
322
+ function makeTomb(): TombObj {
323
+ if (!tombGeo) {
324
+ tombGeo = new THREE.ExtrudeGeometry(tombShape(), { depth: 0.26, bevelEnabled: true, bevelThickness: 0.05, bevelSize: 0.045, bevelSegments: 4, curveSegments: 48 })
325
+ tombGeo.translate(0, 0, -0.13)
326
+ // planar UVs scaled to the stone
327
+ const uv = tombGeo.attributes.uv as THREE.BufferAttribute
328
+ for (let i = 0; i < uv.count; i++) uv.setXY(i, uv.getX(i) * 0.6, uv.getY(i) * 0.6)
329
+ }
330
+ const g = new THREE.Group() as TombObj
331
+ const stoneMat = new THREE.MeshPhysicalMaterial({ map: graniteTex(), color: 0xb9c2cc, roughness: 0.42, metalness: 0, clearcoat: 0.5, clearcoatRoughness: 0.2, transparent: true })
332
+ g.stone = new THREE.Mesh(tombGeo, stoneMat); g.add(g.stone)
333
+ g.plate = new THREE.Mesh(planeGeo, new THREE.MeshStandardMaterial({ transparent: true, roughness: 0.6, metalness: 0, polygonOffset: true, polygonOffsetFactor: -2 }))
334
+ g.plate.position.set(0, 0.93, 0.13 + 0.051); g.plate.scale.set(1.08, 1.5, 1); g.add(g.plate)
335
+ g.base = new THREE.Mesh(new RoundedBoxGeometry(1.7, 0.22, 0.7, 3, 0.04), stoneMat); g.base.position.set(0, -0.11, 0.05); g.add(g.base)
336
+ const snow = new THREE.SphereGeometry(1, 48, 16, 0, Math.PI * 2, 0, Math.PI / 2)
337
+ g.snow = new THREE.Mesh(snow, new THREE.MeshPhysicalMaterial({ color: 0xf4f8ff, roughness: 0.75, sheen: 1, sheenColor: new THREE.Color(0.8, 0.9, 1), transparent: true }))
338
+ g.snow.scale.set(0.5, 0.11, 0.2); g.snow.position.set(0, 1.6, 0); g.add(g.snow)
339
+ return g
340
+ }
341
+ const tombText = (year: string) => canvasTex(`tomb|${year}`, 512, 712, (g, w, h) => {
342
+ g.clearRect(0, 0, w, h)
343
+ g.textAlign = 'center'; g.textBaseline = 'middle'
344
+ const engrave = (s: string, y: number, size: number, font: string, weight = 900) => {
345
+ g.font = `${weight} ${size}px ${font}`
346
+ g.fillStyle = 'rgba(255,255,255,0.35)'; g.fillText(s, w / 2 + size * 0.03, y + size * 0.04) // lit lower edge
347
+ g.fillStyle = 'rgba(10,12,16,0.92)'; g.fillText(s, w / 2, y) // carved shadow
348
+ g.fillStyle = 'rgba(200,220,240,0.55)'; g.fillText(s, w / 2 - size * 0.015, y - size * 0.02)
349
+ }
350
+ engrave('R.I.P.', h * 0.26, 128, FONT.serif)
351
+ g.fillStyle = 'rgba(10,12,16,0.7)'; g.fillRect(w * 0.2, h * 0.39, w * 0.6, 4)
352
+ engrave('BITCOIN', h * 0.5, 84, FONT.mono, 700)
353
+ engrave(year, h * 0.7, 96, FONT.serif)
354
+ })
355
+ export function tomb(cam: Cam, p: V3, o: { yaw?: number; pitch?: number; roll?: number; scale?: number; year: string; alpha?: number }) {
356
+ if (!st) return
357
+ const a = o.alpha ?? 1
358
+ if (a <= 0.003) return
359
+ queue(cam)
360
+ const t = take('tomb', makeTomb)
361
+ t.position.copy(toThree(p))
362
+ t.rotation.copy(euler(o.yaw, o.pitch, o.roll))
363
+ t.scale.setScalar(o.scale ?? 1)
364
+ const pm = t.plate.material as THREE.MeshStandardMaterial
365
+ const tex = tombText(o.year)
366
+ if (pm.map !== tex) { pm.map = tex; pm.needsUpdate = true }
367
+ for (const m of [t.stone.material, pm, t.snow.material] as THREE.Material[]) { m.opacity = a; m.depthWrite = a > 0.98 }
368
+ }
369
+
370
+ // ---------------------------------------------------------------- Wall Street portal (doors swing out towards the viewer)
371
+ type PortalObj = THREE.Group & { left: THREE.Group; right: THREE.Group; mats: THREE.Material[] }
372
+ const doorTex = (emissive: boolean) => canvasTex(`door|${emissive ? 1 : 0}`, 512, 1280, (g, w, h) => {
373
+ if (emissive) { g.fillStyle = '#000'; g.fillRect(0, 0, w, h) } else {
374
+ const bg = g.createLinearGradient(0, 0, w, 0)
375
+ bg.addColorStop(0, '#2b1f0e'); bg.addColorStop(0.5, '#3b2a12'); bg.addColorStop(1, '#22180a')
376
+ g.fillStyle = bg; g.fillRect(0, 0, w, h)
377
+ }
378
+ g.strokeStyle = '#f7a823'
379
+ // six raised panels
380
+ for (let r = 0; r < 3; r++) for (let c = 0; c < 2; c++) {
381
+ const x = 50 + c * 216, y = 60 + r * 400, pw = 196, ph = 360
382
+ if (!emissive) {
383
+ const pg = g.createLinearGradient(x, y, x + pw, y + ph)
384
+ pg.addColorStop(0, '#4a3517'); pg.addColorStop(1, '#1b1308')
385
+ g.fillStyle = pg; g.fillRect(x, y, pw, ph)
386
+ }
387
+ g.lineWidth = 6; g.globalAlpha = emissive ? 0.55 : 1
388
+ g.strokeRect(x, y, pw, ph)
389
+ g.lineWidth = 2; g.globalAlpha = emissive ? 0.3 : 0.7
390
+ g.strokeRect(x + 16, y + 16, pw - 32, ph - 32)
391
+ g.globalAlpha = 1
392
+ }
393
+ // studs
394
+ g.fillStyle = '#ffd27a'
395
+ for (let r = 0; r < 3; r++) for (const xx of [24, w - 24]) { g.beginPath(); g.arc(xx, 240 + r * 400, 7, 0, Math.PI * 2); g.fill() }
396
+ })
397
+ const lintelTex = (emissive: boolean) => canvasTex(`lintel|${emissive ? 1 : 0}`, 1600, 200, (g, w, h) => {
398
+ if (emissive) { g.fillStyle = '#000'; g.fillRect(0, 0, w, h) } else {
399
+ const bg = g.createLinearGradient(0, 0, 0, h); bg.addColorStop(0, '#3a2a12'); bg.addColorStop(1, '#17100a')
400
+ g.fillStyle = bg; g.fillRect(0, 0, w, h)
401
+ }
402
+ g.strokeStyle = '#f7a823'; g.lineWidth = 5; g.strokeRect(14, 14, w - 28, h - 28)
403
+ g.fillStyle = emissive ? '#ffd27a' : '#ffe2a6'
404
+ g.textAlign = 'center'; g.textBaseline = 'middle'
405
+ g.font = `700 110px ${FONT.serif}`
406
+ ;(g as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = '28px'
407
+ g.fillText('WALL STREET', w / 2 + 14, h / 2 + 6)
408
+ })
409
+ function makePortal(): PortalObj {
410
+ const g = new THREE.Group() as PortalObj
411
+ const marble = new THREE.MeshPhysicalMaterial({ color: 0x2a2116, roughness: 0.22, metalness: 0.2, clearcoat: 1, clearcoatRoughness: 0.05, transparent: true })
412
+ const gold = new THREE.MeshPhysicalMaterial({ color: 0xf0a43a, roughness: 0.22, metalness: 1, transparent: true })
413
+ const doorMat = new THREE.MeshPhysicalMaterial({ map: doorTex(false), emissiveMap: doorTex(true), emissive: 0xffffff, emissiveIntensity: 0.9, roughness: 0.3, metalness: 0.75, clearcoat: 0.8, transparent: true })
414
+ const lintelMat = new THREE.MeshPhysicalMaterial({ map: lintelTex(false), emissiveMap: lintelTex(true), emissive: 0xffffff, emissiveIntensity: 1.1, roughness: 0.3, metalness: 0.6, transparent: true })
415
+ g.mats = [marble, gold, doorMat, lintelMat]
416
+ // two fluted columns each side
417
+ const col = new THREE.CylinderGeometry(0.2, 0.22, 4.2, 48, 1)
418
+ const pos = col.attributes.position as THREE.BufferAttribute
419
+ for (let i = 0; i < pos.count; i++) { // flutes
420
+ const x = pos.getX(i), z = pos.getZ(i), a = Math.atan2(z, x), r = Math.hypot(x, z)
421
+ if (r < 0.05) continue
422
+ const k = 1 - 0.06 * Math.max(0, Math.cos(a * 16))
423
+ pos.setXYZ(i, x * k, pos.getY(i), z * k)
424
+ }
425
+ col.computeVertexNormals()
426
+ const cap = new RoundedBoxGeometry(0.62, 0.16, 0.62, 2, 0.03)
427
+ for (const x of [-2.75, -2.15, 2.15, 2.75]) {
428
+ const c = new THREE.Mesh(col, marble); c.position.set(x, 2.1, 0); g.add(c)
429
+ const t1 = new THREE.Mesh(cap, gold); t1.position.set(x, 4.25, 0); g.add(t1)
430
+ const t2 = new THREE.Mesh(cap, marble); t2.position.set(x, 0.08, 0); g.add(t2)
431
+ }
432
+ // entablature + pediment
433
+ const lintel = new THREE.Mesh(new THREE.BoxGeometry(6.3, 0.62, 0.7), [marble, marble, marble, marble, lintelMat, marble])
434
+ lintel.position.set(0, 4.64, 0); g.add(lintel)
435
+ const ped = new THREE.Shape(); ped.moveTo(-3.3, 0); ped.lineTo(3.3, 0); ped.lineTo(0, 0.9); ped.lineTo(-3.3, 0)
436
+ const pg = new THREE.ExtrudeGeometry(ped, { depth: 0.6, bevelEnabled: true, bevelSize: 0.04, bevelThickness: 0.04, bevelSegments: 2 })
437
+ pg.translate(0, 0, -0.3)
438
+ const pm = new THREE.Mesh(pg, marble); pm.position.set(0, 4.98, 0); g.add(pm)
439
+ const trim = new THREE.Mesh(new THREE.BoxGeometry(6.5, 0.07, 0.74), gold); trim.position.set(0, 4.97, 0); g.add(trim)
440
+ const trim2 = new THREE.Mesh(new THREE.BoxGeometry(6.5, 0.07, 0.74), gold); trim2.position.set(0, 4.32, 0); g.add(trim2)
441
+ // steps
442
+ for (let i = 0; i < 3; i++) {
443
+ const s = new THREE.Mesh(new RoundedBoxGeometry(6.6 + i * 0.5, 0.16, 1 + i * 0.45, 2, 0.03), marble)
444
+ s.position.set(0, -0.08 - i * 0.16, 0.1 + i * 0.22); g.add(s)
445
+ }
446
+ // door leaves hinged at the outer jambs (each leaf: 0.95 wide × 3.9 high)
447
+ const leafGeo = new RoundedBoxGeometry(0.95, 3.9, 0.12, 2, 0.02)
448
+ const mk = (side: number) => {
449
+ const hinge = new THREE.Group(); hinge.position.set(side * 1.9, 1.95, 0)
450
+ const leaf = new THREE.Mesh(leafGeo, doorMat); leaf.position.set(-side * 0.475, 0, 0); hinge.add(leaf)
451
+ const ring = new THREE.Mesh(new THREE.TorusGeometry(0.09, 0.022, 16, 40), gold)
452
+ ring.position.set(-side * 0.82, -0.1, 0.09); hinge.add(ring)
453
+ g.add(hinge)
454
+ return hinge
455
+ }
456
+ g.left = mk(-1); g.right = mk(1)
457
+ return g
458
+ }
459
+ /** open: 0 closed … 1 fully open (each leaf swings ~105° outwards, towards the camera) */
460
+ export function portal(cam: Cam, p: V3, o: { open: number; yaw?: number; scale?: number; alpha?: number }) {
461
+ if (!st) return
462
+ const a = o.alpha ?? 1
463
+ if (a <= 0.003) return
464
+ queue(cam)
465
+ const g = take('portal', makePortal)
466
+ g.position.copy(toThree(p)); g.rotation.copy(euler(o.yaw ?? 0)); g.scale.setScalar(o.scale ?? 1)
467
+ const ang = o.open * 1.84
468
+ g.left.rotation.y = -ang // left leaf hinge at -x: negative turn swings it towards +Z (the viewer)
469
+ g.right.rotation.y = ang
470
+ for (const m of g.mats) { m.opacity = a; m.depthWrite = a > 0.98 }
471
+ }
472
+
473
+ // ---------------------------------------------------------------- Earth
474
+ type GlobeObj = THREE.Group & { earth: THREE.Mesh; atmo: THREE.Mesh; pin: THREE.Group; mats: THREE.Material[] }
475
+ function makeGlobe(): GlobeObj {
476
+ const g = new THREE.Group() as GlobeObj
477
+ const E = st!.earth!
478
+ const mat = new THREE.MeshPhysicalMaterial({
479
+ map: E.map, roughnessMap: E.rough, roughness: 1, metalness: 0, normalMap: E.normal, normalScale: new THREE.Vector2(0.9, 0.9),
480
+ emissiveMap: E.lights, emissive: new THREE.Color(1.0, 0.72, 0.35), emissiveIntensity: 0.55, clearcoat: 0.25, clearcoatRoughness: 0.3, transparent: true,
481
+ envMapIntensity: 0.6,
482
+ })
483
+ g.earth = new THREE.Mesh(new THREE.SphereGeometry(1, 128, 64), mat); g.add(g.earth)
484
+ // atmosphere: fresnel rim, additive
485
+ const atmoMat = new THREE.ShaderMaterial({
486
+ transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.BackSide,
487
+ uniforms: { op: { value: 1 } },
488
+ vertexShader: 'varying vec3 vN; varying vec3 vV; void main(){ vec4 mv = modelViewMatrix*vec4(position,1.); vN = normalize(normalMatrix*normal); vV = normalize(-mv.xyz); gl_Position = projectionMatrix*mv; }',
489
+ fragmentShader: 'uniform float op; varying vec3 vN; varying vec3 vV; void main(){ float f = pow(clamp(1.0 - abs(dot(vN, vV)), 0., 1.), 2.2); float k = smoothstep(0.0, 0.9, f); gl_FragColor = vec4(vec3(0.45,0.7,1.0)*k*1.4 + vec3(1.0,0.7,0.3)*k*k*0.5, k)*op; }',
490
+ })
491
+ g.atmo = new THREE.Mesh(new THREE.SphereGeometry(1.09, 96, 48), atmoMat); g.add(g.atmo)
492
+ // pin
493
+ const pinMat = new THREE.MeshPhysicalMaterial({ color: 0xffc040, emissive: 0xffa020, emissiveIntensity: 2.2, metalness: 0.6, roughness: 0.2, transparent: true })
494
+ g.pin = new THREE.Group()
495
+ const head = new THREE.Mesh(new THREE.SphereGeometry(0.035, 24, 16), pinMat); head.position.y = 0.11; g.pin.add(head)
496
+ const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.006, 0.006, 0.11, 12), pinMat); stem.position.y = 0.055; g.pin.add(stem)
497
+ g.earth.add(g.pin)
498
+ g.mats = [mat, pinMat]
499
+ return g
500
+ }
501
+ /** Equirect lat/lon → point on the Three unit sphere (matches SphereGeometry's UV layout) */
502
+ function latLon(lat: number, lon: number) {
503
+ const phi = ((lon + 180) / 360) * Math.PI * 2, la = (lat * Math.PI) / 180
504
+ return new THREE.Vector3(-Math.cos(phi) * Math.cos(la), Math.sin(la), Math.sin(phi) * Math.cos(la))
505
+ }
506
+ export type GlobeOut = { pin: [number, number]; pinVisible: boolean; cx: number; cy: number; r: number }
507
+ /** spin: free rotation (rad); focus 0..1 turns the marked place (lat, lon) to face the camera */
508
+ export function globe(cam: Cam, p: V3, o: { radius: number; spin: number; focus: number; lat: number; lon: number; pin: number; alpha?: number }, W: number, H: number): GlobeOut | null {
509
+ if (!st || !st.earth) return null
510
+ const a = o.alpha ?? 1
511
+ if (a <= 0.003) return null
512
+ queue(cam)
513
+ const g = take('globe', makeGlobe)
514
+ g.position.copy(toThree(p)); g.scale.setScalar(o.radius)
515
+ const q = latLon(o.lat, o.lon)
516
+ const yawT = -Math.atan2(q.x, q.z)
517
+ // shortest path from the free spin to the target
518
+ let dy = yawT - o.spin; dy = Math.atan2(Math.sin(dy), Math.cos(dy))
519
+ g.earth.rotation.set(0.38 + ((o.lat * Math.PI) / 180 - 0.38 + 0.12) * o.focus, o.spin + dy * o.focus, 0, 'XYZ')
520
+ g.pin.position.copy(q)
521
+ g.pin.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), q.clone().normalize())
522
+ g.pin.scale.setScalar(Math.max(0.001, o.pin))
523
+ g.pin.visible = o.pin > 0.01
524
+ for (const m of g.mats) { m.opacity = a; m.depthWrite = a > 0.98 }
525
+ ;(g.atmo.material as THREE.ShaderMaterial).uniforms.op.value = a
526
+ // where the pin head lands on screen
527
+ setCamera(cam, W, H)
528
+ g.updateMatrixWorld(true)
529
+ const head = q.clone().multiplyScalar(1.11).applyMatrix4(g.earth.matrixWorld)
530
+ const centre = g.getWorldPosition(new THREE.Vector3())
531
+ const [px, py] = screenOf(head, W, H)
532
+ const [cx, cy] = screenOf(centre, W, H)
533
+ const edge = centre.clone().add(new THREE.Vector3(o.radius, 0, 0).applyQuaternion(st.camera.quaternion))
534
+ const [ex, ey] = screenOf(edge, W, H)
535
+ const facing = head.clone().sub(centre).normalize().dot(st.camera.position.clone().sub(centre).normalize())
536
+ return { pin: [px, py], pinVisible: facing > 0.15, cx, cy, r: Math.hypot(ex - cx, ey - cy) }
537
+ }
538
+
539
+ // ---------------------------------------------------------------- logo tiles (company marks on glossy plaques)
540
+ const logoTex = (slug: string, emissive: boolean) => canvasTex(`logo|${slug}|${emissive ? 1 : 0}`, 512, 640, (g, w, h) => {
541
+ const L = LOGOS[slug]
542
+ if (!emissive) {
543
+ const bg = g.createLinearGradient(0, 0, w, h); bg.addColorStop(0, '#f5efe4'); bg.addColorStop(1, '#d8cfc0')
544
+ g.fillStyle = bg; g.fillRect(0, 0, w, h)
545
+ } else { g.fillStyle = '#000'; g.fillRect(0, 0, w, h) }
546
+ g.strokeStyle = '#f7a823'; g.lineWidth = 10; g.globalAlpha = emissive ? 0.7 : 1
547
+ g.strokeRect(18, 18, w - 36, h - 36); g.globalAlpha = 1
548
+ if (!emissive) {
549
+ g.save(); g.translate(w / 2 - 150, 90); g.scale(300 / 24, 300 / 24)
550
+ g.fillStyle = L.hex === '#3E4348' ? '#111418' : L.hex
551
+ g.fill(new Path2D(L.path)); g.restore()
552
+ g.fillStyle = '#16120c'; g.textAlign = 'center'; g.textBaseline = 'middle'
553
+ fitFont(g, L.name.toUpperCase(), 700, FONT.mono, w * 0.8, 60)
554
+ g.fillText(L.name.toUpperCase(), w / 2, h - 120)
555
+ }
556
+ })
557
+ type TileObj = THREE.Group & { body: THREE.Mesh; face: THREE.Mesh; back: THREE.Mesh }
558
+ function makeTile(): TileObj {
559
+ const g = new THREE.Group() as TileObj
560
+ g.body = new THREE.Mesh(new RoundedBoxGeometry(1.6, 2.0, 0.16, 3, 0.05), new THREE.MeshPhysicalMaterial({ color: 0xf0a43a, metalness: 1, roughness: 0.2, transparent: true }))
561
+ g.add(g.body)
562
+ const fm = () => new THREE.MeshPhysicalMaterial({ roughness: 0.18, metalness: 0, clearcoat: 1, clearcoatRoughness: 0.03, emissive: 0xffffff, emissiveIntensity: 1, transparent: true, polygonOffset: true, polygonOffsetFactor: -2 })
563
+ g.face = new THREE.Mesh(planeGeo, fm()); g.face.scale.set(1.44, 1.8, 1); g.face.position.z = 0.081; g.add(g.face)
564
+ g.back = new THREE.Mesh(planeGeo, fm()); g.back.scale.set(1.44, 1.8, 1); g.back.position.z = -0.081; g.back.rotation.y = Math.PI; g.add(g.back)
565
+ return g
566
+ }
567
+ export function logoTile(cam: Cam, p: V3, o: { slug: string; yaw?: number; pitch?: number; roll?: number; scale?: number; alpha?: number }) {
568
+ if (!st) return
569
+ const a = o.alpha ?? 1
570
+ if (a <= 0.003) return
571
+ queue(cam)
572
+ const g = take('tile', makeTile)
573
+ g.position.copy(toThree(p)); g.rotation.copy(euler(o.yaw, o.pitch, o.roll)); g.scale.setScalar(o.scale ?? 1)
574
+ for (const f of [g.face, g.back]) {
575
+ const m = f.material as THREE.MeshPhysicalMaterial
576
+ const t = logoTex(o.slug, false)
577
+ if (m.map !== t) { m.map = t; m.emissiveMap = logoTex(o.slug, true); m.needsUpdate = true }
578
+ m.opacity = a; m.depthWrite = a > 0.98
579
+ }
580
+ const bm = g.body.material as THREE.Material; bm.opacity = a; bm.depthWrite = a > 0.98
581
+ }
582
+
583
+ // ---------------------------------------------------------------- frame queue + compositing
584
+ let pendingCam: Cam | null = null
585
+ function queue(cam: Cam) {
586
+ if (pendingCam && pendingCam !== cam && pendingCtx) flush3D(pendingCtx)
587
+ pendingCam = cam
588
+ }
589
+ let pendingCtx: Ctx | null = null
590
+ /** the renderer calls this before each scene so queued objects know which 2D canvas to land on */
591
+ export function bindStage(ctx: Ctx) { pendingCtx = ctx }
592
+
593
+ let bloomC: HTMLCanvasElement | null = null
594
+ /** render everything queued since the last flush and composite it into ctx (with a soft highlight bloom) */
595
+ export function flush3D(ctx: Ctx, o: { bloom?: number; exposure?: number } = {}) {
596
+ if (!st || !pendingCam) { resetQueue(); return }
597
+ const cam = pendingCam
598
+ const cw = ctx.canvas.width, ch = ctx.canvas.height
599
+ const P = ch > cw
600
+ const W = P ? 1080 : 1920, H = P ? 1920 : 1080
601
+ if (st.w !== cw || st.h !== ch) { st.renderer.setSize(cw, ch, false); st.w = cw; st.h = ch }
602
+ setCamera(cam, W, H)
603
+ st.renderer.toneMappingExposure = o.exposure ?? 1.0
604
+ st.renderer.render(st.scene, st.camera)
605
+ const src = st.renderer.domElement
606
+ ctx.save()
607
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
608
+ ctx.globalCompositeOperation = 'source-over'
609
+ ctx.drawImage(src, 0, 0)
610
+ const bloom = o.bloom ?? 1
611
+ if (bloom > 0 && 'filter' in ctx) {
612
+ // quarter-res bright-pass + blur, added back on top
613
+ if (!bloomC) bloomC = document.createElement('canvas')
614
+ const bw = Math.max(1, Math.round(cw / 4)), bh = Math.max(1, Math.round(ch / 4))
615
+ if (bloomC.width !== bw || bloomC.height !== bh) { bloomC.width = bw; bloomC.height = bh }
616
+ const b = bloomC.getContext('2d')!
617
+ b.clearRect(0, 0, bw, bh)
618
+ b.filter = 'brightness(0.85) contrast(3) saturate(1.3) blur(3px)'
619
+ b.drawImage(src, 0, 0, bw, bh)
620
+ b.filter = 'none'
621
+ ctx.globalCompositeOperation = 'lighter'
622
+ ctx.globalAlpha *= 0.55 * bloom
623
+ ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'
624
+ ctx.drawImage(bloomC, 0, 0, cw, ch)
625
+ }
626
+ ctx.restore()
627
+ resetQueue()
628
+ }
629
+ function resetQueue() {
630
+ for (const o of live) o.visible = false
631
+ live.length = 0
632
+ used.clear()
633
+ pendingCam = null
634
+ }
635
+ /** drop anything queued but not flushed (safety net at scene boundaries) */
636
+ export function endStage(ctx: Ctx) { if (pendingCam) flush3D(ctx) }
637
+
638
+ /** debug/QA: the WebGL canvas of the last flush */
639
+ export const stageCanvas = () => st?.renderer.domElement ?? null
bitcoin-saga/web/src/bitcoin/tools/blender/coin.py CHANGED
@@ -26,6 +26,7 @@ GOLD = (1.0, 0.71, 0.29)
26
  M_FIELD = mat('Field', GOLD, 1.0, 0.06)
27
  M_RELIEF = mat('Relief', (1.0, 0.74, 0.34), 1.0, 0.36)
28
  M_EDGE = mat('Edge', (0.95, 0.66, 0.26), 1.0, 0.24)
 
29
 
30
  H = 0.08 # half thickness at rim top
31
  FZ = 0.052 # field height
@@ -37,6 +38,7 @@ def profile():
37
  top = [(0.0, FZ - 0.004, 'f'), (0.30, FZ - 0.002, 'f'), (0.60, FZ, 'f'),
38
  # raised lip ring around the inner field
39
  (0.638, FZ, 'f'), (0.648, FZ + 0.009, 'r'), (0.662, FZ + 0.009, 'r'), (0.672, FZ, 'f'),
 
40
  (0.88, FZ, 'f'), (0.895, FZ + 0.004, 'f'), (0.902, H - 0.002, 'f'), (0.915, H, 'f'),
41
  (0.975, H, 'f'), (0.992, H - 0.006, 'f'), (1.0, H - 0.016, 'e')]
42
  edge = [(1.0, H - 0.016 - i * (2 * (H - 0.016)) / 6, 'e') for i in range(1, 6)]
@@ -66,11 +68,11 @@ def build_body():
66
  elif prof[i + 1][0] == 0: vs = [A[i], A[i + 1], B[i]]
67
  f = bm.faces.new(vs)
68
  ta, tb = tags[i], tags[i + 1]
69
- f.material_index = 2 if (ta == 'e' and tb == 'e') else 1 if 'r' in (ta, tb) else 0
70
  bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-6)
71
  bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
72
  me = bpy.data.meshes.new('Body'); bm.to_mesh(me); bm.free()
73
- for m in (M_FIELD, M_RELIEF, M_EDGE): me.materials.append(m)
74
  ob = bpy.data.objects.new('Body', me); scene.collection.objects.link(ob)
75
  return ob
76
 
@@ -98,8 +100,10 @@ def to_mesh(ob, material, flip=False):
98
  bpy.ops.object.convert(target='MESH')
99
  ob.data.materials.clear(); ob.data.materials.append(material)
100
  if flip:
101
- ob.rotation_euler.y = math.pi # turn over about the vertical axis, so the reverse reads upright when the coin spins
102
- bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
 
 
103
  return ob
104
 
105
  # ------------------------------------------------------------------ ₿ glyph (y-down design space → flip y)
@@ -126,7 +130,7 @@ def ring_text(name, body, radius, cap, z, flip=False, lead=7):
126
  sp.points[i].co = (radius * math.cos(a), radius * math.sin(a), 0, 1)
127
  path = bpy.data.objects.new(name + 'Path', cu); scene.collection.objects.link(path)
128
  td = bpy.data.curves.new(name, 'FONT'); td.body = body
129
- td.font = bpy.data.fonts.load(FONT); td.size = cap * 1.42; td.extrude = 0.006; td.bevel_depth = 0.0015; td.bevel_resolution = 1
130
  td.resolution_u = 3; td.space_character = 1.0
131
  ob = bpy.data.objects.new(name, td); scene.collection.objects.link(ob)
132
  # fit the text length to the circumference
@@ -156,6 +160,7 @@ def beads(name, r, br, n, z, flip=False):
156
  me.materials.append(M_RELIEF)
157
  ob = bpy.data.objects.new(name, me); scene.collection.objects.link(ob)
158
  ob.location.z = -z if flip else z
 
159
  return ob
160
 
161
  # ------------------------------------------------------------------ isometric block emblem (reverse side)
@@ -175,14 +180,16 @@ def block_emblem(z):
175
 
176
  # ------------------------------------------------------------------ build
177
  body = build_body()
178
- gl = curve_obj('Glyph', glyph_loops(), 0.011, bevel=0.006, z=FZ + 0.011, rot=math.radians(-14), scale=0.75)
179
- gl.location.x = 0.01
180
- to_mesh(gl, M_RELIEF)
181
- ring_text('LegendF', 'BITCOIN \u00b7 PEER-TO-PEER ELECTRONIC CASH \u00b7 MMIX \u00b7', 0.705, 0.072, FZ + 0.006)
182
- ring_text('LegendR', 'GENESIS BLOCK \u00b7 03 JAN 2009 \u00b7 21 000 000 \u00b7 DON\u2019T TRUST, VERIFY \u00b7', 0.705, 0.064, FZ + 0.006, flip=True, lead=13)
183
- beads('BeadsF', 0.857, 0.0115, 132, FZ)
184
- beads('BeadsR', 0.857, 0.0115, 132, FZ, flip=True)
185
- block_emblem(FZ)
 
 
186
 
187
  # smooth shading by angle on everything
188
  for ob in list(scene.objects):
 
26
  M_FIELD = mat('Field', GOLD, 1.0, 0.06)
27
  M_RELIEF = mat('Relief', (1.0, 0.74, 0.34), 1.0, 0.36)
28
  M_EDGE = mat('Edge', (0.95, 0.66, 0.26), 1.0, 0.24)
29
+ M_BAND = mat('Band', (0.5, 0.32, 0.1), 1.0, 0.6) # recessed satin legend band → raised polished letters read clearly
30
 
31
  H = 0.08 # half thickness at rim top
32
  FZ = 0.052 # field height
 
38
  top = [(0.0, FZ - 0.004, 'f'), (0.30, FZ - 0.002, 'f'), (0.60, FZ, 'f'),
39
  # raised lip ring around the inner field
40
  (0.638, FZ, 'f'), (0.648, FZ + 0.009, 'r'), (0.662, FZ + 0.009, 'r'), (0.672, FZ, 'f'),
41
+ (0.678, FZ - 0.005, 'b'), (0.775, FZ - 0.005, 'b'), (0.872, FZ - 0.005, 'b'),
42
  (0.88, FZ, 'f'), (0.895, FZ + 0.004, 'f'), (0.902, H - 0.002, 'f'), (0.915, H, 'f'),
43
  (0.975, H, 'f'), (0.992, H - 0.006, 'f'), (1.0, H - 0.016, 'e')]
44
  edge = [(1.0, H - 0.016 - i * (2 * (H - 0.016)) / 6, 'e') for i in range(1, 6)]
 
68
  elif prof[i + 1][0] == 0: vs = [A[i], A[i + 1], B[i]]
69
  f = bm.faces.new(vs)
70
  ta, tb = tags[i], tags[i + 1]
71
+ f.material_index = 2 if (ta == 'e' and tb == 'e') else 3 if (ta == 'b' and tb == 'b') else 1 if 'r' in (ta, tb) else 0
72
  bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-6)
73
  bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
74
  me = bpy.data.meshes.new('Body'); bm.to_mesh(me); bm.free()
75
+ for m in (M_FIELD, M_RELIEF, M_EDGE, M_BAND): me.materials.append(m)
76
  ob = bpy.data.objects.new('Body', me); scene.collection.objects.link(ob)
77
  return ob
78
 
 
100
  bpy.ops.object.convert(target='MESH')
101
  ob.data.materials.clear(); ob.data.materials.append(material)
102
  if flip:
103
+ # turn over about the vertical axis (x,y,z → -x,y,-z), so the reverse reads upright when the coin spins
104
+ ob.rotation_euler.y = math.pi
105
+ ob.location = (-ob.location.x, ob.location.y, -ob.location.z)
106
+ bpy.ops.object.transform_apply(location=True, rotation=True, scale=False)
107
  return ob
108
 
109
  # ------------------------------------------------------------------ ₿ glyph (y-down design space → flip y)
 
130
  sp.points[i].co = (radius * math.cos(a), radius * math.sin(a), 0, 1)
131
  path = bpy.data.objects.new(name + 'Path', cu); scene.collection.objects.link(path)
132
  td = bpy.data.curves.new(name, 'FONT'); td.body = body
133
+ td.font = bpy.data.fonts.load(FONT); td.size = cap * 1.38; td.extrude = 0.009; td.bevel_depth = 0.002; td.bevel_resolution = 1
134
  td.resolution_u = 3; td.space_character = 1.0
135
  ob = bpy.data.objects.new(name, td); scene.collection.objects.link(ob)
136
  # fit the text length to the circumference
 
160
  me.materials.append(M_RELIEF)
161
  ob = bpy.data.objects.new(name, me); scene.collection.objects.link(ob)
162
  ob.location.z = -z if flip else z
163
+ if flip: ob.rotation_euler.y = math.pi
164
  return ob
165
 
166
  # ------------------------------------------------------------------ isometric block emblem (reverse side)
 
180
 
181
  # ------------------------------------------------------------------ build
182
  body = build_body()
183
+ def front_side(flip):
184
+ g = curve_obj('Glyph', glyph_loops(), 0.011, bevel=0.006, z=FZ + 0.011, rot=math.radians(-14), scale=0.75)
185
+ g.location.x = 0.01
186
+ to_mesh(g, M_RELIEF, flip)
187
+ # z = band floor (FZ-0.005) + half extrude, so letters stand ~0.013 proud of the band
188
+ ring_text('Legend', 'BITCOIN \u00b7 SATOSHI NAKAMOTO \u00b7 2009 \u00b7', 0.712, 0.104, FZ + 0.004, flip=flip, lead=7)
189
+ beads('Beads', 0.857, 0.0115, 132, FZ, flip=flip)
190
+ # both faces carry the same design (the reverse is the obverse turned about the vertical axis → reads upright when spun)
191
+ front_side(False)
192
+ front_side(True)
193
 
194
  # smooth shading by angle on everything
195
  for ob in list(scene.objects):