Spaces:
Running
Running
v2-7: true 3D blocks, tombs, doors, globe, logos, dominoes; pizza coins; 2K export & sharper playback
Browse files- .gitattributes +7 -0
- bitcoin-saga/README.md +1 -0
- bitcoin-saga/dist/assets/coin-BvnbCOr2.glb +3 -0
- bitcoin-saga/dist/assets/coin-hero-CaTT8WxI.webp +0 -0
- bitcoin-saga/dist/assets/coin-lo-CvE-GDyL.glb +0 -0
- bitcoin-saga/dist/assets/earth-D5ORXZE1.jpg +3 -0
- bitcoin-saga/dist/assets/earth-lights-D2b9Grhk.jpg +3 -0
- bitcoin-saga/dist/assets/earth-normal-D6bb61qB.jpg +3 -0
- bitcoin-saga/dist/assets/earth-spec-Bcy67onS.jpg +0 -0
- bitcoin-saga/dist/assets/index-BFEJsoIQ.js +0 -0
- bitcoin-saga/dist/assets/sans-sc-400-DzbASIVW.woff2 +0 -0
- bitcoin-saga/dist/assets/sans-sc-700-BCWBA9ha.woff2 +0 -0
- bitcoin-saga/dist/assets/sans-sc-900-17ZIJI_E.woff2 +0 -0
- bitcoin-saga/dist/assets/serif-sc-700-C6u61Hq7.woff2 +0 -0
- bitcoin-saga/dist/assets/serif-sc-900-n7dwsg69.woff2 +0 -0
- bitcoin-saga/dist/index.html +1 -1
- bitcoin-saga/web/src/bitcoin/BitcoinApp.tsx +9 -4
- bitcoin-saga/web/src/bitcoin/assets/3d/coin-hero.webp +0 -0
- bitcoin-saga/web/src/bitcoin/assets/3d/coin-lo.glb +0 -0
- bitcoin-saga/web/src/bitcoin/assets/3d/coin.glb +2 -2
- bitcoin-saga/web/src/bitcoin/assets/3d/earth-lights.jpg +3 -0
- bitcoin-saga/web/src/bitcoin/assets/3d/earth-normal.jpg +3 -0
- bitcoin-saga/web/src/bitcoin/assets/3d/earth-spec.jpg +0 -0
- bitcoin-saga/web/src/bitcoin/assets/3d/earth.jpg +3 -0
- bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-400.woff2 +0 -0
- bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-700.woff2 +0 -0
- bitcoin-saga/web/src/bitcoin/assets/fonts/sans-sc-900.woff2 +0 -0
- bitcoin-saga/web/src/bitcoin/assets/fonts/serif-sc-700.woff2 +0 -0
- bitcoin-saga/web/src/bitcoin/assets/fonts/serif-sc-900.woff2 +0 -0
- bitcoin-saga/web/src/bitcoin/engine/audio.ts +34 -7
- bitcoin-saga/web/src/bitcoin/engine/coingl.ts +2 -0
- bitcoin-saga/web/src/bitcoin/engine/cues.ts +84 -0
- bitcoin-saga/web/src/bitcoin/engine/export.ts +4 -4
- bitcoin-saga/web/src/bitcoin/engine/gfx.ts +10 -1
- bitcoin-saga/web/src/bitcoin/engine/logos.ts +6 -0
- bitcoin-saga/web/src/bitcoin/engine/render.ts +7 -2
- bitcoin-saga/web/src/bitcoin/engine/scenes1.ts +11 -10
- bitcoin-saga/web/src/bitcoin/engine/scenes2.ts +241 -108
- bitcoin-saga/web/src/bitcoin/engine/scenes3.ts +78 -63
- bitcoin-saga/web/src/bitcoin/engine/stage3d.ts +639 -0
- 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
|
bitcoin-saga/dist/assets/earth-lights-D2b9Grhk.jpg
ADDED
|
Git LFS Details
|
bitcoin-saga/dist/assets/earth-normal-D6bb61qB.jpg
ADDED
|
Git LFS Details
|
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-
|
| 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={
|
| 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
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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
|
bitcoin-saga/web/src/bitcoin/assets/3d/earth-normal.jpg
ADDED
|
Git LFS Details
|
bitcoin-saga/web/src/bitcoin/assets/3d/earth-spec.jpg
ADDED
|
bitcoin-saga/web/src/bitcoin/assets/3d/earth.jpg
ADDED
|
Git LFS Details
|
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 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ?
|
| 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.
|
| 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 +
|
| 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.
|
| 277 |
if (pr <= 0.5) return
|
| 278 |
-
|
| 279 |
-
ctx.
|
|
|
|
|
|
|
| 280 |
const pg = ctx.createRadialGradient(px - pr * 0.35, py - pr * 0.4, pr * 0.1, px, py, pr)
|
| 281 |
-
pg.addColorStop(0, '#
|
| 282 |
ctx.fillStyle = pg
|
| 283 |
ctx.beginPath(); ctx.arc(px, py, pr, 0, Math.PI * 2); ctx.fill()
|
| 284 |
-
ctx
|
| 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),
|
| 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 [
|
|
|
|
| 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.
|
| 114 |
if (p <= 0) return
|
| 115 |
-
const
|
| 116 |
-
const
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 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
|
| 141 |
-
const
|
| 142 |
-
const
|
| 143 |
-
//
|
| 144 |
-
const
|
| 145 |
-
const
|
| 146 |
-
const
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
}
|
| 170 |
-
//
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 222 |
-
|
| 223 |
-
const
|
| 224 |
-
const
|
| 225 |
-
const
|
| 226 |
-
|
| 227 |
-
const
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 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 |
-
|
| 241 |
-
|
| 242 |
-
ctx.
|
| 243 |
-
ctx.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
|
|
|
|
| 245 |
const ib = L.info
|
| 246 |
-
const cp = after(2, 0.5)
|
| 247 |
if (cp > 0) {
|
| 248 |
-
const cb = P ? { x: 90, y:
|
| 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 ?
|
| 253 |
}
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
if (
|
| 257 |
-
const
|
| 258 |
-
const
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
for (let i = 0; i < n; i++) {
|
| 263 |
-
const
|
| 264 |
-
if (
|
| 265 |
-
const
|
| 266 |
-
const
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
}
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
}
|
| 274 |
-
void
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 33 |
if (doorA > 0) {
|
| 34 |
ctx.save()
|
| 35 |
ctx.globalAlpha *= doorA
|
| 36 |
-
//
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 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.
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
ctx.
|
| 124 |
-
ctx.
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
ctx.
|
| 130 |
-
ctx.
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
for (let i = 0; i < s.length; i++) {
|
| 135 |
ctx.save()
|
| 136 |
-
ctx.
|
| 137 |
-
|
|
|
|
| 138 |
ctx.restore()
|
| 139 |
}
|
|
|
|
|
|
|
| 140 |
ctx.restore()
|
| 141 |
-
//
|
| 142 |
-
|
| 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, '
|
| 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 |
-
|
| 102 |
-
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
beads('
|
| 185 |
-
|
|
|
|
|
|
|
| 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):
|