diff --git a/incoming/pattern-study/README.md b/incoming/pattern-study/README.md index 5b34027a4..80101f307 100644 --- a/incoming/pattern-study/README.md +++ b/incoming/pattern-study/README.md @@ -1,6 +1,6 @@ # 패턴 연구 2026-10 -새 패턴 118개를 마흔 갈래로 나눠 만들었어요. 1판에서 열세 갈래로 23개를 만들고, 2판에서 1판에 없던 아홉 갈래로 16개를, 3판에서 하나를, 4판에서 새 갈래인 직물로 하나를 더했어요. 5판은 새 패턴 없이 1판의 Euclid Pulse 를 다시 짰고, 6판은 세운 패널의 128줄을 위아래로 다 쓰는 패턴 하나를 더했어요. 7판은 또렷한 모양이 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 8판은 새 갈래인 기계로 하나를 더했어요. 9판은 새 패턴 없이 1판의 Sprout 을 다시 짰어요. 10판은 세운 패널을 떨어지는 블록의 우물로 쓰는 패턴 하나를 더했어요. 11판은 여러 색이 꽤 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 12판은 새 갈래인 천체로 하나를 더했어요. 13판은 새 패턴 없이 1판의 Glyph Rain 을 다시 짰어요. 14판은 절차가 바뀌기 전에 띄운 바퀴라서, 세운 패널의 높이를 다 떨어지는 폭포 하나를 더했어요. 15판은 바뀐 절차의 첫 판이라 열 갈래에 새 패턴을 하나씩, 모두 열 개를 더했어요. 도시와 음악 갈래는 그때 새로 열었어요. 16판도 열 갈래에 하나씩 열 개를 더했고, 바다와 곤충 갈래를 새로 열었어요. 17판도 열 갈래에 하나씩 열 개를 더했고, 전자 회로와 장난감 갈래를 새로 열었어요. 18판도 열 갈래에 하나씩 열 개를 더했고, 유리와 광물·결정, 놀이공원 갈래를 새로 열었어요. 19판도 열 갈래에 하나씩 열 개를 더했고, 부엌과 버섯·균사 갈래를 새로 열었어요. 20판도 열 갈래에 하나씩 열 개를 더했고, 새와 화학·실험 갈래를 새로 열었어요. 21판도 열 갈래에 하나씩 열 개를 더했고, 종이와 운동·경기 갈래를 새로 열었어요. [index.html](index.html) 을 브라우저로 열면 전부 한 장에서 돌아가요. 맨 위 지도에서 점을 누르면 그 패턴으로 가고, 카드의 슬라이더가 노브 넷이에요. 마음에 드는 것은 「코드 복사」로 가져가 Pattern Lab 의 CODE 칸에 붙여 넣으면 같은 그림이 나와요. 페이지는 파일 하나라서 바깥에서 받아 오는 것이 없어요. +새 패턴 128개를 마흔두 갈래로 나눠 만들었어요. 1판에서 열세 갈래로 23개를 만들고, 2판에서 1판에 없던 아홉 갈래로 16개를, 3판에서 하나를, 4판에서 새 갈래인 직물로 하나를 더했어요. 5판은 새 패턴 없이 1판의 Euclid Pulse 를 다시 짰고, 6판은 세운 패널의 128줄을 위아래로 다 쓰는 패턴 하나를 더했어요. 7판은 또렷한 모양이 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 8판은 새 갈래인 기계로 하나를 더했어요. 9판은 새 패턴 없이 1판의 Sprout 을 다시 짰어요. 10판은 세운 패널을 떨어지는 블록의 우물로 쓰는 패턴 하나를 더했어요. 11판은 여러 색이 꽤 멀리 흘러가는 지도 칸에 패턴 하나를 더했어요. 12판은 새 갈래인 천체로 하나를 더했어요. 13판은 새 패턴 없이 1판의 Glyph Rain 을 다시 짰어요. 14판은 절차가 바뀌기 전에 띄운 바퀴라서, 세운 패널의 높이를 다 떨어지는 폭포 하나를 더했어요. 15판은 바뀐 절차의 첫 판이라 열 갈래에 새 패턴을 하나씩, 모두 열 개를 더했어요. 도시와 음악 갈래는 그때 새로 열었어요. 16판도 열 갈래에 하나씩 열 개를 더했고, 바다와 곤충 갈래를 새로 열었어요. 17판도 열 갈래에 하나씩 열 개를 더했고, 전자 회로와 장난감 갈래를 새로 열었어요. 18판도 열 갈래에 하나씩 열 개를 더했고, 유리와 광물·결정, 놀이공원 갈래를 새로 열었어요. 19판도 열 갈래에 하나씩 열 개를 더했고, 부엌과 버섯·균사 갈래를 새로 열었어요. 20판도 열 갈래에 하나씩 열 개를 더했고, 새와 화학·실험 갈래를 새로 열었어요. 21판도 열 갈래에 하나씩 열 개를 더했고, 종이와 운동·경기 갈래를 새로 열었어요. 22판도 열 갈래에 하나씩 열 개를 더했고, 몸과 비행 갈래를 새로 열었어요. [index.html](index.html) 을 브라우저로 열면 전부 한 장에서 돌아가요. 맨 위 지도에서 점을 누르면 그 패턴으로 가고, 카드의 슬라이더가 노브 넷이에요. 마음에 드는 것은 「코드 복사」로 가져가 Pattern Lab 의 CODE 칸에 붙여 넣으면 같은 그림이 나와요. 페이지는 파일 하나라서 바깥에서 받아 오는 것이 없어요. 패널은 세로로 서요. 64 LED 너비에 128 LED 높이가 기본이라서 모든 패턴의 머리에 `@matrix 64x128` 이 있어요. 레포의 패널 설정은 그대로예요. `shared/constants.json` 의 `PANEL_GEOM_DEFAULT` 는 `128x64_SINGLE` 이고, 패턴이 가로세로를 바꾼 판을 적으면 펌웨어의 `firmware/patternflow/src/core_canvas.h` 가 90도 돌려 패널을 꽉 채워요. 1판 23개는 처음에 가로 128×64 로 만들었다가 2판에서 세로로 다시 짰어요. @@ -219,6 +219,21 @@ | [Slinky](patterns/slinky.js) | 장난감 | 슬링키가 패널 높이만큼 지그재그로 꺾인 계단을 한 칸씩 뒤집히며 내려가요. 코일이 활처럼 휘어 아래 칸으로 쏟아지고, 내려앉은 칸마다 계단등이 켜져요. | | [Woodpecker](patterns/woodpecker.js) | 새 | 새벽 숲의 자작나무 줄기가 128줄을 다 쓰고, 오색딱따구리가 뻣뻣한 꽁지로 버티며 껑충껑충 오르다 멈춰 머리가 흐려지도록 두드려 부스러기를 튀기고 구멍을 내요. 꼭대기에 닿으면 물결치듯 날아 밑동으로 내려가 다시 오르고, K1 을 누르면 다음 걸음 뒤에 바로 두드려요. | +### 22판 + +| 패턴 | 갈래 | 한 줄 | +| --- | --- | --- | +| [Bowling Lane](patterns/bowling-lane.js) | 운동·경기 | 볼러 뒤에서 본 10핀 레인이에요. 공이 오일 위로 제 반사를 끌며 휘어 올라가 핀을 흩고, 스위프 바가 데드우드를 쓸어 낸 뒤 핀이 다시 서요. 처음에는 블랙라이트 아래 핀과 화살표, 오일이 빛나고, K4 를 올리면 나무 레인에 하우스 조명이 켜져요. | +| [Circulation](patterns/circulation.js) | 몸 | 해부도처럼 머리부터 발끝까지 선 사람이에요. 심장이 뛸 때마다 붉은 피의 맥이 동맥을 타고 머리와 손발 끝까지 번져 가고, 푸른 정맥혈은 심장 쪽으로 거슬러 올라오며 폐가 박동과 숨에 분홍빛으로 물들어요. 발치의 모니터에는 심전도와 맥박 수가 떠요. | +| [Flame Test](patterns/flame-test.js) | 화학·실험 | 분젠 버너의 파란 불꽃에 손이 니크롬 고리를 넣으면 불꽃이 그 염의 색으로 터져요. 구리는 청록, 나트륨은 짙은 노랑으로 타고 알갱이가 튀다가 파랗게 돌아오면, 고리를 염산에 씻어 이름표 붙은 다음 시계접시로 가요. | +| [Flipdot](patterns/flipdot.js) | 글자 | 버스 앞에 다는 형광 노랑 플립 디스크 행선판을 세워 놓았어요. 위 칸에 노선 번호, 아래 칸에 행선지가 7줄 점 글꼴로 서고, 쪽이 바뀔 때마다 디스크가 한 줄씩 넘어가며 판 전체를 노랗게 뒤집었다가 반전된 경유지 쪽을 써요. 넘어가는 동안의 디스크는 가는 모서리로 보여요. | +| [Galilean Moons](patterns/galilean-moons.js) | 천체 | 목성의 네 위성을 관측 도표로 그려요. 위쪽 접안렌즈에서는 줄무늬 목성이 돌고 이오의 그림자가 원반을 지나가며, 아래 도표에서는 위성마다 제 사인 곡선을 그리며 날짜가 위로 올라와요. | +| [Liftoff](patterns/liftoff.js) | 비행 | 새벽 물 건너 발사대에서 로켓이 하얀 산소 김을 흘리며 서 있다가 김구름 속에 불을 붙이고 솟아올라요. 카메라가 따라 고개를 들면 하늘이 파랑에서 별 뜬 검정으로 바뀌고, Max-Q 와 단 분리를 지나 밝은 점이 되면 새 로켓이 발사대에 서요. | +| [Night Flight](patterns/night-flight.js) | 도시 | 여객기 창으로 내려다본 밤 도시예요. 1 LED 가로등이 바둑판 길과 대로, 강 위 다리를 따라 레이스처럼 이어지고, 고속도로에는 흰 전조등과 빨간 미등이 흘러요. 도시는 초록 항법등과 흰 섬광등이 달린 날개 끝을 지나 왼쪽 아래로 천천히 미끄러져요. | +| [Pin Art](patterns/pin-art.js) | 장난감 | 핀 아트 장난감을 앞에서 봐요. 엇갈린 격자의 강철 핀 4096개가 패널을 채우고, 뒤에서 펼친 손이 눌러 들어오면 밀려 나온 핀만 비스듬한 등빛을 받아 빛나요. 장난감을 기울이면 핀이 물결치며 평평해지고 열쇠가 다음으로 눌러 들어와요. | +| [Senbazuru](patterns/senbazuru.js) | 종이 | 붉은 옻칠 고리에 실로 꿴 종이학이 줄줄이 매달려 금박 학 아래로 주황에서 초록, 파랑을 지나 보라와 분홍까지 띠를 이루고, 실 끝에는 금 구슬과 비단 술이 달려요. 어두운 절 방에 외풍이 불면 줄이 흔들리고 학이 실을 축으로 돌면서 접힌 면이 하나씩 등불을 받아 번쩍여요. K1 을 누르면 돌풍이 불어 학 하나가 떨어져 나뭇잎처럼 바닥으로 내려앉아요. | +| [Weaver](patterns/weaver.js) | 새 | 강 위로 뻗은 아카시아 가지 끝에서 수컷 마을베짜기새가 둥지를 지어요. 갈대 잎을 찢어 문 초록 띠를 벽에 꿰어 당기면 둥지가 고리에서 둥근 방으로, 다시 아래로 열린 입구로 한 가닥씩 자라고, 묵은 띠는 짚 색으로 바래요. K1 을 누르면 암컷이 날아와 둥지를 살피고, 다 지은 둥지가 퇴짜를 맞으면 수컷이 끊어 강에 떨어뜨리고 새 고리부터 다시 지어요. | + ## 1판 갈래마다 노브 넷의 자리는 갈래가 달라도 거의 같아요. K1 은 색이고 K2 는 빠르기예요. K3 은 모양을 바꾸고, K4 는 그 갈래만의 손잡이예요. 그래서 처음 보는 패턴도 K1 과 K2 는 손이 먼저 알아요. @@ -841,6 +856,50 @@ K4 Spate 0 이면 물이 실 몇 가닥으로 갈라져요. 처음 값 0.06 이 주제 4 로 장난감 갈래에 셋째 패턴을 냈어요. 세운 패널을 지그재그 계단실로 쓰고, 슬링키가 꼭대기 층계참에서 바닥까지 한 칸씩 뒤집히며 내려가요. 계단은 한 판에 세 칸이고 디딤판 12 LED, 챌판 6줄이에요. 판 끝은 22줄 깊이로 층계참에 떨어져요. 같은 평면에 판을 엇갈려 쌓으면 다음 판 첫 칸이 늘 윗판 마지막 칸 바로 밑에 와서, 그만큼 깊어야 슬링키가 층계참에서 돌아선 뒤 윗판 끝 밑으로 빠져나가요. 슬링키는 지름 10 LED 고리 36개로 그렸어요. 한 걸음은 윗끝이 넘어가 아래 칸에 닿는 토막, 코일이 활을 지나 아래 더미로 옮겨 가는 토막, 아랫끝이 들려 따라오는 토막으로 나뉘어요. 고리마다 더미와 활 사이 자리를 끊김 없이 옮겨서 코일이 굳은 꼴로 움직이지 않고 활을 따라 흘러가요. 층계참에서는 윗끝이 벽에 부딪혀 되튀며 방향을 바꾸고, 바닥에 내려서면 손이 집어 들어 매달린 채 흔들리는 슬링키를 꼭대기로 옮겨요. 첫 판은 밝은 청록 벽에 K4 가 Stretch 였고, `thirds` 99/99/99, 바뀐 LED 9.9/33.1/32.9% 로 주제는 바로 넘었어요. 그런데 모양 바뀜이 0.014 로 Galton Board 의 0.025 아래라 축 끝을 늘였고, K2 Speed 와 K4 Stretch 는 0.02 였어요. 걷는 슬링키는 250 LED 남짓이라 어디에 있든 4×16 블록 몇 개만 바꿔요. 창으로 드는 볕 무늬를 K4 로 둔 판은 청록 벽 위에서 유리판처럼 보여서 뺐고, 벽등을 단 어스름 판은 장면이 어두워지며 K2 가 0.01 로 내려갔어요. 그래서 슬링키가 내려앉는 칸마다 계단등이 켜져 8초 동안 남게 했어요. 2초 화면에 없던 불빛이 6초 동안 열 칸 남짓 쌓이니 모양 바뀜이 0.058, K2 가 0.05 가 됐어요. 따뜻한 불빛이 청록 벽에서 민트색으로 보여서 벽지를 짙은 파랑으로 바꿨더니, 이번에는 가장자리가 0.099 로 Fire 의 0.120 아래로 떨어졌어요. 어스름에 어두워진 벽지 줄무늬가 6~40 사이의 무른 턱만 남겼거든요. 줄무늬와 무늬 밝기를 1.38 배에서 1.7 배로 올리자 0.258 이 됐어요. 마지막 값은 움직임 0.115, 모양 바뀜 0.056, `thirds` 99/99/99, 바뀐 LED 12.1/36.5/36.7% 이고, 노브는 0.16, 0.04, 0.09, 0.30 이에요. 계단과 벽을 빼고 슬링키와 그림자만 세면 켜진 LED 는 0.4/2.6/3.0% 이고, 켜진 계단등까지 세면 20.9/16.5/8.6% 예요. 작은 물체 하나가 지나가기만 하는 그림은 모양 바뀜과 속도 노브가 함께 바닥에 머물러요. 둘 다 2초 무렵의 화면과 견주는 값이라, 물체가 지나간 자리에 남는 흔적을 두면 같이 올라가요. +## 22판 + +22판도 새 패턴 열 개를 열 갈래에 하나씩 냈어요. 주제 1 로 Pin Art 와 Flipdot, Night Flight 를 짰고, 셋 다 겨눈 `*` 칸에 섰어요. 씨앗 열여섯으로 재도 셋 다 그 칸을 떠나지 않아요. Night Flight 가 선 가장자리 × 촘촘함 1,4 는 무른데 아주 잘아야 하는 칸이라, 이제 그 지도는 16칸이 다 찼어요. 주제 2 로 몸 갈래를 Circulation 으로, 비행 갈래를 Liftoff 로 열었어요. 주제 4 는 Circulation 과 Liftoff, Senbazuru, Bowling Lane, Galilean Moons 로 다섯이에요. 다섯 다 3분의 1마다 켜진 LED 도, 2~8초 동안 24/255 넘게 바뀐 LED 도 5% 를 넘어요. Flame Test 와 Weaver 는 주제 없이 남은 자리로 짰어요. 종이와 운동·경기는 패턴이 하나뿐이던 갈래라 이번에 둘째가 섰고, 새와 화학·실험에는 셋째가, 천체와 글자, 장난감, 도시에는 넷째가 섰어요. 주제 3 은 앞 바퀴 PR 에 창작자 글이 없어서 하지 않았어요. 열 개를 다 넣고 재도 일곱 축의 양 끝은 그대로라서 칸 경계는 14판과 같아요. `--gaps` 가 찍는 빈 칸은 6 에서 3 으로 줄었고, 채운 세 칸이 다 겨눈 칸이에요. 남은 셋 가운데 둘은 촘촘함 × 모양 바뀜 지도의 3,4 와 4,3 이라, Pin Art 와 Flipdot 이 선 두 칸 옆이에요. + +### Pin Art + +주제 1 로 촘촘함 × 모양 바뀜 4,4 칸을 겨눴어요. 촘촘함이 0.299 를 넘고 모양 바뀜이 0.332 를 넘어야 하는 자리이고, 21판까지 촘촘함이 그만큼 높은 일곱 패턴은 모양 바뀜이 모두 0.16 아래였어요. 핀 아트 장난감의 핀 머리는 x + y 가 짝수인 LED 이고 나머지는 틈이라, 이웃 쌍이 모두 핀과 틈의 쌍이에요. 그래서 촘촘함은 핀과 틈의 밝기 차이로, 4×16 칸의 평균은 둘을 더한 값의 절반으로 정해져요. 첫 판은 틈을 늘 검게 두어 두 값이 같이 움직였고, 손이 패널의 0.566 을 덮고 솟은 핀이 219 일 때 촘촘함 0.296, 모양 바뀜 0.313 이었어요. 솟은 핀 사이의 틈에 드러난 핀대를 밝히자 두 값이 갈라졌어요. 2초의 평평한 판은 핀 105 에 틈 0 이고 다 눌린 손은 핀 241 에 틈 157 이라, 이웃 차이는 105 와 84 로 비슷한데 칸 평균은 0.21 에서 0.78 로 올라가요. 모양 바뀜은 덮인 넓이에 그 차이를 곱한 값과 거의 같아서 0.613 × 0.575 = 0.352 가 잰 값 0.357 이에요. 손을 0.674 까지 키운 판은 0.404 로 축 위 끝 0.413 에 가까워서 손목을 좁혔어요. 등을 바로 위에서 왼쪽 위로 옮기자 오른쪽 아래로 드리운 그림자가 평평한 핀을 덮어 촘촘함이 0.330 에서 0.316 으로 내려갔고, 평평한 핀을 98 에서 106 으로 올려서 0.346 이 됐어요. 난수를 쓰지 않아서 씨앗 열여섯이 다 촘촘함 0.346, 모양 바뀜 0.357 로 같아요. 손 다음에는 열쇠와 가리비, 장난감 차, 얼굴이 차례로 눌러 들어와요. + +### Flipdot + +주제 1 로 촘촘함 × 모양 바뀜 3,3 칸을 겨눴어요. 촘촘함이 0.195~0.299 이고 모양 바뀜이 0.219~0.332 인 자리예요. 디스크는 2×2 LED 를 3 LED 간격으로 놓아 20×40 개이고, 노란 디스크 하나가 이웃 쌍 여덟에 240 쯤의 턱을 만들어서 판 전체가 노랄 때 촘촘함이 0.37, 4×16 칸 평균이 0.36 이에요. 두 값이 노란 몫에 거의 같은 비율로 붙어 있으니, 2초 화면은 노란 몫이 낮고 2~8초 평균은 높아야 했어요. 그래서 행선지 쪽은 검은 바탕에 노란 글자로 두고, 판을 노랗게 채운 뒤 쓰는 경유지 쪽은 노란 바탕에 검은 글자로 뒤집었어요. 첫 판은 노선 42 에 디스크 밝기를 0.93~1.0 으로 흔들어서 촘촘함 0.263, 모양 바뀜 0.248 이었어요. 밝기를 0.96~1.0 으로 올리고 반전 쪽을 3.0초에서 2.2초로 줄이자 0.244 와 0.256 이 됐어요. 노선을 42 에서 17 로 바꿔 위 칸 디스크를 100 에서 80 으로 줄이자, 첫 쪽의 노란 몫이 0.273 에서 0.248 로 내려가며 모양 바뀜이 0.265 가 됐어요. 0.36 × (1 − 0.248) 셈과 맞아요. 첫 채움을 2.3초에서 2.15초로 당기자 Wipe 노브의 반응이 0.04 에서 0.05 로 올랐어요. 2~3초 창에 물결이 더 오래 들어가서예요. 쪽 넘김에 난수가 없어서 씨앗 열여섯이 촘촘함 0.245, 모양 바뀜 0.264~0.266 이에요. + +### Night Flight + +주제 1 로 가장자리 × 촘촘함 1,4 칸을 겨눴어요. 가장자리가 0.305 아래이고 촘촘함이 0.299 를 넘어야 하는 자리로, 21판까지 아무 패턴도 서지 않은 칸이에요. 날카로운 쌍은 1 LED 가로등이 맡고 나머지 쌍은 모두 34~40 으로 무르게 했어요. 가로등은 x + y 가 짝수인 칸에만 서서 옆으로 맞닿지 않아요. 땅은 모든 LED 를 이웃과 반대쪽으로 18.5 기울인 체커 결이라 땅끼리의 쌍이 37 언저리예요. 도시를 한 걸음에 왼쪽으로 1, 아래로 1 옮기면 x + y 가 그대로라서, 흘러가는 동안에도 가로등이 결이 미는 칸을 떠나지 않아요. 첫 판은 가로등이 LED 의 17% 라 가장자리 0.350, 촘촘함 0.346 이었어요. 대로 간격을 34 에서 44 로, 동네 길 간격을 6 에서 8 로 넓혀 가로등을 12.7% 로 줄이자 가장자리가 0.26 으로 내려왔고, 촘촘함은 0.318 로 칸 끝에 붙었어요. LED 흰빛 두 색을 섞으면 가장 밝은 채널이 240 으로 떨어져서, 등 색마다 가장 밝은 채널을 255 로 되돌리고 땅을 조금 어둡게 했어요. 날카로운 쌍의 평균이 213 에서 224 로 오르며 0.333 이 됐어요. 땅과 강물의 색을 섞는 자리에서도 가장 밝은 채널이 0.68 까지 내려가 강가의 쌍이 41~60 으로 넘어갔어요. 섞은 색도 가장 밝은 채널을 1 로 맞춰 그 쌍들을 무른 쪽으로 돌렸어요. 날개는 결을 ±10 으로 줄여 매끈하게 보이게 했고, 날개 안의 쌍은 20 언저리로 남아 가장자리를 올리지 않아요. 씨앗 열여섯이 촘촘함 0.313~0.343, 가장자리 0.236~0.283 으로 모두 1,4 칸에 들어요. + +### Circulation + +주제 2 로 몸 갈래를 열고, 주제 4 로 세운 패널의 128줄을 사람 하나로 채웠어요. 해부도의 사람이 1번 줄부터 127번 줄까지 서고, 박동마다 맥이 1초에 80 LED 로 동맥을 내려가며 정맥혈은 1초에 30 LED 로 심장 쪽으로 올라와요. 표의 `thirds` 는 34/50/26 이고, 2~8초 동안 24/255 넘게 바뀐 LED 는 위에서부터 30.1/30.6/22.1% 예요. 아래 3분의 1에는 발치의 심전도 모니터가 들어가서, 그것을 빼고 다리만 세면 씨앗 열여섯에서 16.4~17.3% 예요. 처음 판은 바탕을 (8, 14, 32) 남색으로 칠해서 `thirds` 가 100/77/37, 채움이 0.71 로 나왔어요. 가장 밝은 채널이 24 를 넘으면 켜진 것으로 세니 바탕 전체가 켜진 셈이었고, 위쪽도 21 아래로 내리자 34/52/29 와 0.39 가 됐어요. K3 Detail 은 처음에 0.008 로 걸렸어요. 굵은 줄기와 첫 곁가지가 이미 몸 LED 2618개 가운데 1943개를 덮어서 더 그을 자리가 없었어요. 줄기를 가늘게 하고 첫 곁가지를 Detail 1 에서 반만 밝히자 0.025 가 됐어요. 동맥이 쉴 때의 밝기를 0.5 에서 0.32 로 낮추자 맥이 지나가는 것이 보였어요. K1 을 누르면 놀란 듯 심장이 한 번 세게 뛰고 맥박이 72 에서 145 로 올랐다가 몇 초에 걸쳐 내려와요. + +### Liftoff + +주제 2 로 비행 갈래를 열고 주제 4 로 세운 패널의 128줄을 다 쓰게 했어요. 로켓 발의 높이는 512 × (1 − exp(−t²/128)) 줄이라 처음에는 4t² 로 오르다 나중에 멈추고, 카메라는 코끝을 15번 줄에 두도록 고개를 들어 하늘색이 그 각도를 따라 어두워져요. 패널은 이륙 2.3초 전에 열려서 2~8초 창에는 점화부터 탑을 벗어나 57줄을 틸트하는 오름까지 들어요. 하늘을 다 칠해서 `thirds` 는 99/99/100 이고, 2~8초 동안 바뀐 LED 는 53.5/49.3/100% 예요. 하늘을 검게 지우고 재니 켜진 LED 가 3.7/19.8/70.3%, 바뀐 LED 가 8.2/41.0/96.5% 여서 위 3분의 1의 변화는 대부분 고개를 드는 하늘에서 와요. 코끝 자리를 8번 줄로 올리면 로켓만의 위 3분의 1은 10.5% 가 되지만 카메라가 늦게 움직여 전체 값은 54.1% 에서 39.5% 로 떨어졌어요. K3 Stages 는 단 수만 바꿀 때 0.03 이었고, 첫 단 불빛의 세기를 단 수에 묶어 김구름과 물이 같이 밝아지게 하자 0.04 가 됐어요. 처음 판은 34 KB 였는데, 연기 알갱이를 필드 열세 개짜리 Float32Array 하나에 담고 탈출 탑 분리와 케이블 마스트를 빼서 24,995 바이트로 줄였어요. + +### Senbazuru + +주제 4 로 종이 갈래에 둘째 패턴을 냈어요. 21판 「다음 판 감」이 세운 패널에 맞는다고 적어 둔, 실에 꿰어 늘어뜨린 천 마리 종이학이에요. 학은 면 열네 개짜리 입체이고, 면마다 따로 빛을 받아서 실을 축으로 돌면 등불 쪽 날개가 밝아졌다 어두워져요. 두 배 해상도로 그린 뒤 줄여요. 학의 날개폭이 8 LED 일 때는 학이 아니라 V 자 무늬로 보였어요. 그래서 날개폭을 11 LED 로 키우고, 내려다보는 각을 0.38 rad 로 주고, 한 LED 의 3분의 1만 덮는 목도 0.36 의 몫을 받게 했어요. 그러자 목과 꼬리가 읽혔고 바뀐 LED 도 54.8/81.3/60.8% 에서 68.9/90.7/65.2% 로 올랐어요. 방과 둥근 창을 다 그려서 `thirds` 는 52/84/79 로 아무것도 말해 주지 않고, 바뀐 LED 로 세면 63.3/87.4/72.7% 예요. 위 3분의 1은 고리에 붙어 있어 흔들림이 거의 0 인데도 63% 가 바뀌어요. 한 줄 끈에 매달린 고리가 통째로 비틀리는 것을 멈추면 57.3% 이고, 학이 실을 축으로 도는 것까지 멈추면 46.4% 예요. 씨앗 열여섯에서 아래 3분의 1은 45.0~77.0% 였어요. 줄마다 길이와 술의 높이가 씨앗을 따라 달라서예요. K4 Light 가 0.16 으로 가장 높은 것은 등불이 옮겨 가면 접힌 면의 명암과 벽에 진 학 그림자가 함께 움직여서예요. + +### Bowling Lane + +주제 4 로 운동·경기 갈래의 둘째 패턴을 짰어요. 볼러 뒤에서 본 10핀 레인이 아래의 어프로치와 파울 라인에서 꼭대기의 핀세터까지 128줄을 다 써요. 레인은 원근으로 40 LED 에서 32 LED 로 좁아지고, 핀 덱만 1 ft 에 4.7줄로 가깝게 당겨서 열 핀의 삼각형이 읽혀요. 공은 Speed 2 에서 파울 라인부터 핀까지 2.5초쯤 걸려서 2~8초 창 안에 세 3분의 1을 다 지나가요. 핀은 원판 충돌로 풀었는데 Hook 0.6 에서는 첫 공 39개 가운데 37개가 스트라이크였고, 처음 자리 Hook 0 의 곧은 공은 7-10 이나 6-7-10 같은 스플릿을 자주 남겨요. 레인을 다 그려서 `thirds` 는 82/99/99 이고, 2~8초 동안 바뀐 LED 는 59.5/48.5/32.6% 예요. 공을 던지지 않게 하고 재면 5.2/9.5/6.6% 라서, 바뀐 LED 는 거의 다 공과 그 빛에서 와요. 처음 판은 노브 반응이 0.02, 0.02, 0.01, 0.26 이었어요. 볼러는 누구나 화살표를 보고 던져서, Hook 마다 공의 길이 2~3초 무렵 화살표 언저리에서 만났거든요. 블랙라이트에서 오일이 빛나게 하고 Hook 이 오일 길이를 46 ft 에서 30 ft 까지 줄이며 서는 자리를 24 in 벌리게 하자 K3 가 0.03 이 됐고, 캡핑 조명이 보드에 공 색을 16/255 비추게 하자 K1 이 0.03 에서 0.06 으로 올랐어요. + +### Galilean Moons + +주제 4 로 천체 갈래에 행성의 궤도를 처음 넣었어요. 「다음 판 감」이 행성의 궤도는 아직 없다고 적어 둔 자리예요. Sky & Telescope 가 달마다 싣는 목성 위성 도표를 세운 패널에 옮겼어요. 위 33줄은 지름 29 LED 의 목성을 크게 본 접안렌즈이고, 35번 줄은 도표와 같은 배율의 낮은 배율 시야예요. 38번 줄부터는 하루를 10줄로 그린 도표가 아래로 9일을 보여 줘요. 위성의 자리는 Meeus 의 낮은 정확도 이론으로 2026년 10월 23일부터 셈해서, 위성이 목성 뒤로 숨는 때와 그림자에 드는 때, 서쪽으로 비킨 목성 그림자가 실제대로 나와요. 처음 판은 하루를 6줄로 그려 이오 곡선이 한 줄에 4 LED 씩 옆으로 뛰며 갈고리처럼 보였어요. 모양 바뀜도 0.022 로 축 아래 끝 0.025 아래였어요. 10줄로 바꾸자 사인이 읽혔고 모양 바뀜은 0.031 이 됐어요. 도표는 바탕을 다 칠해서 `thirds` 가 33/100/100 이고, 바뀐 LED 는 26.5/65.7/62.8% 예요. 어두운 접안렌즈만 세면 19.9% 이고, 목성의 자전을 멈추면 그 가운데 확대 시야가 19.5% 에서 12.9% 로 내려가요. K4 Seeing 은 확대 시야만 흐리게 해서 처음에 0.039 였어요. 번지는 빛을 원반 가장자리에서 90 에서 110 으로, 폭을 7 에서 10 LED 로 넓히자 0.054 가 됐어요. 위성 수를 고르는 노브는 1 에서 열려 이오 하나만 보여서, K3 는 배율을 바꾸는 Power 로 두었어요. 0 에서 칼리스토의 궤도까지 들어오고, 올리면 바깥 위성부터 옆으로 빠져나가요. + +### Flame Test + +지도 주제 없이 남은 자리로 화학·실험 갈래에 셋째 패턴을 더했어요. 20판 「다음 판 감」이 아직 없다고 적어 둔 불꽃 반응의 색이에요. 학교 실험실의 불꽃 반응이고, Fire 와 달리 불꽃을 시뮬레이션하지 않고 줄마다 폭과 흔들림을 정해 그려요. 버너는 21번 열에 서서 공기 구멍을 반쯤 연 파란 불꽃이 79번 줄에서 58줄 올라가고, 염이 들면 70줄 남짓까지 커져요. `thirds` 는 100/99/85 인데 2~8초 동안 바뀐 LED 는 16.6/42.3/4.6% 라서 아래 3분의 1은 거의 서 있어요. 그 창에서 고리는 염산 비커까지만 가고 시계접시에는 9.3초가 지나서야 가거든요. 불꽃빛의 몫을 0.42 에서 0.52 로 올려도 아래 3분의 1은 3.8% 에서 5.1% 로만 올랐는데, 검은 실험대의 반사율이 0.17 이라 색이 바뀌어도 24/255 를 넘게 움직이는 LED 가 적어요. 처음 판은 염의 색이 파란 불꽃을 덜 덮어서 칼슘이 연어색, 스트론튬이 분홍이었고, 파란 몫을 1 − 1.4 × 염 으로 빼자 주황과 빨강으로 읽혔어요. 이름표를 테이프 한 줄에 적었을 때는 "CuNaLiBa" 로 붙어 읽혀서, 테이프를 8열마다 한 칸씩 끊었어요. K2 Speed 는 처음에 0.03 이었고, 염이 4.5초 만에 다 타게 하고 색이 가득한 때를 1.6초에서 1.3초로 줄여 0.05 가 됐어요. K1 Salt 의 0 은 구리부터 줄을 차례로 돌아서, 처음 불꽃이 빨강이 아니라 청록이에요. + +### Weaver + +지도 주제 없이 남은 자리로 새 갈래에 셋째 패턴을 냈어요. 「아직 없는 갈래」가 적어 둔 둥지 짓는 새예요. 노란 몸에 검은 가면과 빨간 눈을 한 수컷 마을베짜기새가 아래쪽 갈대에서 띠를 찢어 가운데 둥지로 날라서, 2~8초 안에 128줄을 한 번 오르내려요. 첫 판은 둥지 가닥을 선으로만 그려서 다 지은 둥지에 고리 구멍이 뚫려 하늘이 비쳤어요. 둥지의 LED 마다 어느 가닥이 그 자리를 닫는지 정해 두고 그 가닥이 들어갔을 때만 벽을 깔자 속이 찬 공이 됐어요. 그 벽을 가닥 색의 0.3~0.5 로 어둡게 깔았더니 가닥이 비는 자리가 눈구멍처럼 패여 해골로 보였어요. 0.68 로 밝히고 비스듬한 결을 넣자 짚 공으로 읽혔고, 어두운 곳은 아래 입구 끝 하나만 남겼어요. K1 Plumage 는 새 색만 바꾸면 새가 패널의 1% 남짓이라 문턱에 닿기 어려워 보였어요. 그래서 종마다 둥지 꼴과 서식지가 같이 바뀌게 해서 0.05 가 됐어요. 바야베짜기새는 긴 관 둥지에 논이 깔려요. 모양 바뀜은 처음에 0.017 이라 축 아래 끝 0.025 밖이었어요. 하늘 아닌 곳에 40% 깊이의 구름 그림자 두 줄을 1초에 3 LED 씩 지나가게 하자 씨앗 열여섯에서 0.040~0.047 이 됐어요. 2~8초 동안 바뀐 LED 는 17.5/46.7/54.5% 에서 28.5/47.8/77.1% 로 늘었어요. 첫 판의 프레임은 3.8 ms 였어요. 갈대 키와 놓인 줄에 소수가 남아 Float32Array 를 소수 칸으로 읽은 탓이었고, 정수로 맞추자 1.3~1.9 ms 가 됐어요. + ## 지도 지도의 자리는 손으로 매긴 점수가 아니라 잰 값이에요. 패턴마다 처음 노브 값으로 Lab 엔진에서 8초를 돌리고, 2초부터 6초 동안의 화면을 재요. LED 하나의 밝기는 세 채널 가운데 가장 밝은 값으로 봐요. @@ -883,7 +942,7 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 ## 다음 판 감 -10월 23일 `dev`(`4c2f36b`)의 108개에 21판의 열 개를 더한 118개에서 잰 값이에요. 패턴 바퀴는 주제 안에서 무엇을 할지 고를 때 이 절을 읽고, 한 것은 빼고 새로 본 것을 더해요. +10월 23일 `dev`(`692565f`)의 118개에 22판의 열 개를 더한 128개에서 잰 값이에요. 패턴 바퀴는 주제 안에서 무엇을 할지 고를 때 이 절을 읽고, 한 것은 빼고 새로 본 것을 더해요. ### 지도의 빈 칸 @@ -891,35 +950,34 @@ ESP32-S3 로 옮길 때 걸릴 것도 있어요. Reaction Diffusion 은 Node 에 | 지도 | 칸 | 그 자리 | | --- | --- | --- | -| 촘촘함 × 모양 바뀜 | 4,4 | 아주 잘고 멀리 흘러가요. 촘촘함이 0.299 를 넘고 모양 바뀜이 0.332 를 넘어야 해요. 촘촘함이 그만큼 높은 일곱 가운데 모양 바뀜이 가장 큰 것은 21판의 Sandpile 0.151 이고, 20판의 Millefiori 가 0.087 이에요. Sandpile 은 한 줌이 다 무너진 더미를 떨어진 자리에서 번지는 고리로 드러내서, 고리 사이에는 레이스가 가만히 서요. 16판의 Lunation 은 2~8초 동안 모든 LED 가 밝아지기만 해서 움직임 0.058 로 모양 바뀜 0.348 을 냈어요. 같은 꾀로 잔 결을 한쪽으로 쓸어 드러내면 닿을 수 있어요. | -| 촘촘함 × 모양 바뀜 | 3,4 | 잘고 멀리 흘러가요. 촘촘함이 0.195~0.299 이고 모양 바뀜이 0.332 를 넘어야 해요. 21판 Sandpile 은 K1 을 누르면 쟁반이 기울어 모래를 쏟아요. 3~6초에 누른 판은 촘촘함 0.146~0.265 에 모양 바뀜 0.476~0.479 였어요. 모양 바뀜이 축 끝 0.413 을 넘어서, 다 쏟지 않고 쟁반의 절반쯤만 비우는 그림이면 이 칸이에요. | -| 가장자리 × 촘촘함 | 1,4 | 번지는데 아주 잘아요. 가장자리가 0.305 아래이고 촘촘함이 0.299 를 넘어야 해요. 20판 Sunglint 가 가장자리 0.484, 촘촘함 0.336 으로 바로 옆 2,4 칸에 섰어요. 은빛 물면 칸을 27~38 로 올리고 반짝임을 0.62 배로 줄인 시험판은 가장자리가 0.332 까지 내려왔지만 촘촘함이 0.278 로 떨어지고 바다가 체커판으로만 보였어요. 이웃 쌍의 4분의 1쯤이 230 언저리로 날카롭고 60% 쯤이 35 넘게 무르게 달라야 닿아요. 21판 Pancakes 가 바로 아래 1,3 칸에 가장자리 0.238, 촘촘함 0.228 로 섰어요. 모든 LED 를 이웃과 반대쪽으로 17 기울인 체커 결 위에 1 LED 표시를 결이 미는 칸에만 둔 판이라, 무른 쌍이 34 언저리예요. 그 결로 1,4 에 가려면 날카로운 쌍을 0.29 쯤으로 두고 그 차이를 평균 180 넘게 키워야 셈이 맞아요. | +| 촘촘함 × 모양 바뀜 | 3,4 | 잘고 멀리 흘러가요. 촘촘함이 0.195~0.299 이고 모양 바뀜이 0.332 를 넘어야 해요. 21판 Sandpile 은 K1 을 누르면 쟁반이 기울어 모래를 쏟아요. 3~6초에 누른 판은 촘촘함 0.146~0.265 에 모양 바뀜 0.476~0.479 였어요. 모양 바뀜이 축 끝 0.413 을 넘어서, 다 쏟지 않고 쟁반의 절반쯤만 비우는 그림이면 이 칸이에요. 22판 Pin Art 의 셈을 그대로 쓰면 닿아요. 핀과 틈이 번갈아 서는 체커 결에서 촘촘함은 핀과 틈의 차이로, 칸 평균은 둘의 합의 절반으로 정해져요. 평평한 판을 핀 60 에 틈 0 으로, 눌린 자리를 핀 250 에 틈 190 으로 두면 두 자리 다 이웃 차이가 60 언저리라 촘촘함이 0.24 에 머물고, 패널의 절반만 눌러도 칸 평균이 0.74 바뀌어 모양 바뀜이 0.37 이에요. | +| 촘촘함 × 모양 바뀜 | 4,3 | 아주 잘고 꽤 흘러가요. 촘촘함이 0.299 를 넘고 모양 바뀜이 0.219~0.332 여야 해요. 22판 Pin Art 가 바로 위 4,4 칸에 촘촘함 0.346, 모양 바뀜 0.357 로 서요. 모양 바뀜은 바뀐 넓이에 칸 평균의 차이를 곱한 값과 거의 같아서, 같은 핀에 손보다 작은 물건이 패널의 절반쯤만 누르면 0.29 언저리예요. 22판 Flipdot 은 노란 디스크의 몫을 따라 촘촘함과 칸 평균이 같이 움직이고, 판 전체가 노랄 때 촘촘함이 0.37 이에요. 2초 화면의 노란 몫을 0.2 쯤에 두고 나머지 창을 거의 노랗게 두면 모양 바뀜이 0.36 × 0.8 = 0.29 예요. | -촘촘함이 0.299 를 넘는 패턴이 20판에서 셋 늘었고, 셋 다 그 값을 다른 길로 냈어요. Agate 는 1 LED 띠를 벽까지의 거리로 그어서, 축에 나란한 띠는 이웃 쌍의 절반에서만 차이가 나고 대각선 띠는 70% 쯤에서 나요. 방향이 고르게 섞이면 띠 너비 0.85 LED 언저리가 가장 높았어요. Millefiori 는 촘촘함을 가장 밝은 채널로 재서 코발트와 카나리아 노랑이 맞닿아도 턱이 0 이었어요. 고리마다 밝은 유리와 그 유리를 어둡게 뽑은 유리를 번갈아 넣어야 했어요. Sunglint 는 반짝임을 물결 띠마다 체커판의 한쪽 칸에만 둬서 반짝임끼리 닿지 않게 했어요. 셋 다 채움이 0.85 를 넘어서, 촘촘함 × 채움 4,4 는 꺼진 LED 없이도 닿는 칸이었어요. 21판의 Sandpile 이 일곱째예요. 칸 하나가 LED 하나이고 알 셋 옆의 알 둘이 가장 흔한 이웃 쌍이라, 밝기를 0, 2, 1, 3 차례로 올려 더미 안의 촘촘함이 0.40 언저리예요. 판의 75% 는 덮여야 0.3 을 넘어요. +촘촘함이 0.299 를 넘는 패턴이 20판에서 셋 늘었고, 셋 다 그 값을 다른 길로 냈어요. Agate 는 1 LED 띠를 벽까지의 거리로 그어서, 축에 나란한 띠는 이웃 쌍의 절반에서만 차이가 나고 대각선 띠는 70% 쯤에서 나요. 방향이 고르게 섞이면 띠 너비 0.85 LED 언저리가 가장 높았어요. Millefiori 는 촘촘함을 가장 밝은 채널로 재서 코발트와 카나리아 노랑이 맞닿아도 턱이 0 이었어요. 고리마다 밝은 유리와 그 유리를 어둡게 뽑은 유리를 번갈아 넣어야 했어요. Sunglint 는 반짝임을 물결 띠마다 체커판의 한쪽 칸에만 둬서 반짝임끼리 닿지 않게 했어요. 셋 다 채움이 0.85 를 넘어서, 촘촘함 × 채움 4,4 는 꺼진 LED 없이도 닿는 칸이었어요. 21판의 Sandpile 이 일곱째예요. 칸 하나가 LED 하나이고 알 셋 옆의 알 둘이 가장 흔한 이웃 쌍이라, 밝기를 0, 2, 1, 3 차례로 올려 더미 안의 촘촘함이 0.40 언저리예요. 판의 75% 는 덮여야 0.3 을 넘어요. 22판의 Pin Art 와 Night Flight 가 여덟째와 아홉째예요. Pin Art 는 핀과 틈의 체커 결이라 이웃 쌍이 모두 핀과 틈의 쌍이고, Night Flight 는 Pancakes 의 체커 결에 1 LED 가로등을 LED 의 12.7% 만큼 얹어서 가장자리 0.258 에 촘촘함 0.328 이에요. 촘촘함과 가장자리는 가장 밝은 채널로 재서, 색을 섞는 자리마다 섞은 뒤 가장 밝은 채널을 다시 맞춰야 했어요. 땅과 강물을 섞은 자리에서 가장 밝은 채널이 0.68 까지 내려가 체커 결의 37 이 41~60 으로 넘어갔고, 그대로 둔 판은 가장자리가 0.05 높았어요. -모양 바뀜이 높은 칸을 겨누면 움직이는 것과 바탕의 밝기가 달라야 해요. 3판의 Lava Lamp 는 방울이 화면 높이를 다 오르는데도 왁스와 물이 둘 다 밝아서 모양 바뀜이 0.068 이에요. 4판의 Jacquard Loom 도 천이 1초에 4 LED 씩 내려가는데, 씨실 여섯 색이 모두 밝기 끝까지라 0.041 이에요. 19판의 Rabbit Ears 도 첫 판에서는 그림이 눈 속으로 사라져도 블록 평균이 0.5 쯤으로 같아서 0.153 이었어요. 바다를 어둡게 하고 신호가 없을 때 눈을 밝게 올리자 0.286 이 됐어요. 모양 바뀜의 칸은 4 LED 너비에 16 LED 높이라서, 옆으로 쓸고 가는 것이 위아래로 오르내리는 것보다 네 배 잘 잡혀요. 7판의 Searchlights 가 그렇게 0.357 까지 갔어요. 색이 많아도 모양 바뀜은 밝기만 봐요. 11판의 Balloon Box 는 천의 모든 색을 밝기 끝에 둬서, 모양 바뀜과 가장자리는 풍선과 어두운 하늘의 차이에서만 와요. 16판의 Domino Run 은 꺼지는 쪽에서 모양 바뀜을 얻었어요. 넘어지는 도미노는 같은 4×16 칸 안에서 눕기만 해서 처음에 0.096 이었고, 낮은 램프가 누운 면을 스치게 하자 켜진 LED 가 0.44 에서 0.15 로 줄며 0.261 이 됐어요. 모양 바뀜은 2초 화면에서 멀어진 거리라 6초 동안 바뀐 양을 넘지 못해서, 움직임의 여섯 배가 끝이에요. 16판의 Lunation 은 2~8초 동안 어두워지는 LED 가 하나도 없게 짜서 움직임 0.058 에 모양 바뀜 0.348 로 그 끝에 섰어요. 아래 끝도 끝이에요. 모양 바뀜 축의 아래 끝은 Galton Board 의 0.025 이고, 17판의 열 개 가운데 여섯이 처음 판에 그 아래였어요. Ant Farm 0.010, One Bit 0.014, Marble Run 0.019, Circuit Board 0.021, Crossroads 0.023, Shoreline 0.029 예요. 바탕이 서 있고 작은 것만 움직이면 4×16 칸 평균이 거의 그대로라서, 블록만 한 것이 천천히 바뀌어야 해요. Ant Farm 은 젖은 모래 얼룩, Circuit Board 는 데워지는 선, Marble Run 은 구슬이 지나며 켜는 플라스틱 조각, One Bit 는 오르내리는 안개 둑으로 0.04~0.06 에 섰어요. 18판에서는 Server Rack 의 첫 판이 0.024 였어요. 깜박이는 LED 는 4×16 칸 안에서 평균이 지워져서, 백업이 쓴 드라이브의 빛길을 켜 둔 채 남기고 부하 물결을 넓혀 0.098 이 됐어요. 거꾸로 1초가 안 되는 섬광은 모양 바뀜을 크게 올려요. 모양 바뀜은 첫 화면에서 가장 멀어진 프레임 하나로 정해져서, 18판 Drop Tower 의 0.4초 남짓 보이는 브레이크 섬광이 0.087 을 0.163 으로 올렸어요. 19판에서는 Helter Skelter 의 첫 판이 0.021 이었어요. 전구 체이스가 나선을 따라 내려가며 미끄럼틀과 탑을 밝히고 8초마다 전구가 1.5초 다 켜지게 하자 0.069 가 됐어요. 21판의 Bread Mould 는 곰팡이로 어두워진 빵을 밝은 새 빵이 옆에서 밀어내는 일을 2.4초에 두어 3.5초에 0.356 에 닿았어요. 도마를 늙은 곰팡이와 새 속살 사이의 밝기 100 쯤에 두어서, 빵이 덮는 넓이만 모양 바뀜에 들어가요.아래 끝을 넘을 뻔한 패턴도 있었어요. 21판의 Slinky 는 250 LED 남짓한 스프링이 계단을 내려가기만 할 때 0.014 로 Galton Board 아래였고, 내려앉은 칸마다 계단등이 켜져 8초 남게 하자 0.056 이 됐어요. +모양 바뀜이 높은 칸을 겨누면 움직이는 것과 바탕의 밝기가 달라야 해요. 3판의 Lava Lamp 는 방울이 화면 높이를 다 오르는데도 왁스와 물이 둘 다 밝아서 모양 바뀜이 0.068 이에요. 4판의 Jacquard Loom 도 천이 1초에 4 LED 씩 내려가는데, 씨실 여섯 색이 모두 밝기 끝까지라 0.041 이에요. 19판의 Rabbit Ears 도 첫 판에서는 그림이 눈 속으로 사라져도 블록 평균이 0.5 쯤으로 같아서 0.153 이었어요. 바다를 어둡게 하고 신호가 없을 때 눈을 밝게 올리자 0.286 이 됐어요. 모양 바뀜의 칸은 4 LED 너비에 16 LED 높이라서, 옆으로 쓸고 가는 것이 위아래로 오르내리는 것보다 네 배 잘 잡혀요. 7판의 Searchlights 가 그렇게 0.357 까지 갔어요. 색이 많아도 모양 바뀜은 밝기만 봐요. 11판의 Balloon Box 는 천의 모든 색을 밝기 끝에 둬서, 모양 바뀜과 가장자리는 풍선과 어두운 하늘의 차이에서만 와요. 16판의 Domino Run 은 꺼지는 쪽에서 모양 바뀜을 얻었어요. 넘어지는 도미노는 같은 4×16 칸 안에서 눕기만 해서 처음에 0.096 이었고, 낮은 램프가 누운 면을 스치게 하자 켜진 LED 가 0.44 에서 0.15 로 줄며 0.261 이 됐어요. 모양 바뀜은 2초 화면에서 멀어진 거리라 6초 동안 바뀐 양을 넘지 못해서, 움직임의 여섯 배가 끝이에요. 16판의 Lunation 은 2~8초 동안 어두워지는 LED 가 하나도 없게 짜서 움직임 0.058 에 모양 바뀜 0.348 로 그 끝에 섰어요. 아래 끝도 끝이에요. 모양 바뀜 축의 아래 끝은 Galton Board 의 0.025 이고, 17판의 열 개 가운데 여섯이 처음 판에 그 아래였어요. Ant Farm 0.010, One Bit 0.014, Marble Run 0.019, Circuit Board 0.021, Crossroads 0.023, Shoreline 0.029 예요. 바탕이 서 있고 작은 것만 움직이면 4×16 칸 평균이 거의 그대로라서, 블록만 한 것이 천천히 바뀌어야 해요. Ant Farm 은 젖은 모래 얼룩, Circuit Board 는 데워지는 선, Marble Run 은 구슬이 지나며 켜는 플라스틱 조각, One Bit 는 오르내리는 안개 둑으로 0.04~0.06 에 섰어요. 18판에서는 Server Rack 의 첫 판이 0.024 였어요. 깜박이는 LED 는 4×16 칸 안에서 평균이 지워져서, 백업이 쓴 드라이브의 빛길을 켜 둔 채 남기고 부하 물결을 넓혀 0.098 이 됐어요. 거꾸로 1초가 안 되는 섬광은 모양 바뀜을 크게 올려요. 모양 바뀜은 첫 화면에서 가장 멀어진 프레임 하나로 정해져서, 18판 Drop Tower 의 0.4초 남짓 보이는 브레이크 섬광이 0.087 을 0.163 으로 올렸어요. 19판에서는 Helter Skelter 의 첫 판이 0.021 이었어요. 전구 체이스가 나선을 따라 내려가며 미끄럼틀과 탑을 밝히고 8초마다 전구가 1.5초 다 켜지게 하자 0.069 가 됐어요. 21판의 Bread Mould 는 곰팡이로 어두워진 빵을 밝은 새 빵이 옆에서 밀어내는 일을 2.4초에 두어 3.5초에 0.356 에 닿았어요. 도마를 늙은 곰팡이와 새 속살 사이의 밝기 100 쯤에 두어서, 빵이 덮는 넓이만 모양 바뀜에 들어가요.아래 끝을 넘을 뻔한 패턴도 있었어요. 21판의 Slinky 는 250 LED 남짓한 스프링이 계단을 내려가기만 할 때 0.014 로 Galton Board 아래였고, 내려앉은 칸마다 계단등이 켜져 8초 남게 하자 0.056 이 됐어요. 22판에서는 아래 끝을 넘은 첫 판이 둘이었어요. Weaver 는 0.017 이었고, 하늘 아닌 곳에 구름 그림자 두 줄을 1초에 3 LED 씩 지나가게 하자 0.044 가 됐어요. Galilean Moons 는 0.022 였고, 도표의 하루를 6줄에서 10줄로 늘리자 0.031 이 됐어요. 높은 쪽은 셈으로 맞출 수 있었어요. Pin Art 는 0.613 × 0.575 = 0.352 가 잰 값 0.357 이고, Flipdot 은 0.36 × (1 − 0.248) 이 잰 값 0.265 예요. 둘 다 바뀐 넓이에 칸 평균의 차이를 곱한 셈이에요. 움직임 축의 아래 끝도 끝이에요. 아래 끝은 Star Trails 의 0.019 이고, 18판의 Stained Glass 는 첫 판이 0.007, Peg Light 는 0.023 이었어요. 블록만 한 구름 그림자가 1초에 5 LED 로 지나가면 모양 바뀜은 0.036 이어도 1초 사이에 바뀌는 빛은 작아요. Stained Glass 는 그림자를 11 LED 덩어리로 얼룩지게 하고 1초에 18 LED 로 지나가게 해서 0.027 이 됐어요. Peg Light 는 1초에 페그 100개를 꽂아도 LED 하나가 한 번 바뀌는 셈이라 그 몫이 0.012 였고, 켜진 페그마다 ±3.5% 로 1.4 Hz 에 일렁이게 해서 0.035 가 됐어요. 13판에서 채움 축의 아래 끝이 Glyph Rain 의 0.059 에서 Dragon Fold 의 0.117 로 올라가서, 채움 칸의 경계가 0.257, 0.530, 0.802 에서 0.302, 0.558, 0.815 로 옮겨졌어요. 그래서 복잡도 × 채움 3,1 은 한동안 `*` 가 빠졌고, 18판 뒤에는 다시 `*` 가 붙어 있었어요. 19판의 Foxfire 가 채움 0.196, 복잡도 2.001 로 그 칸을 채웠어요. -15판부터 21판까지의 일흔 개는 어느 축의 끝도 넘지 않아서 칸 경계가 14판과 같아요. 씨앗을 타는 패턴은 씨앗 여럿으로 재서 칸을 확인해요. 15판의 Marbling 은 처음 판이 씨앗 열여섯 가운데 일곱만 겨눈 칸에 들었고, 연구의 씨앗이 그 일곱 가운데 가장 높았어요. 패턴의 slug 로 씨앗을 정하니 이름만 바꿔서 재면 돼요. 복잡도 축은 어두운 기울기의 끝자리 흔들림도 세요. 16판의 Fireflies 는 채널마다 4 의 배수로 반올림하자 눈에는 같은 화면의 복잡도가 0.490 에서 0.310 으로 내려갔어요. 거꾸로 17판의 Frost 는 22/255 아래로 누른 바탕에 채널마다 ±4.5 의 결을 넣어 복잡도가 1.712 에서 2.383 이 됐고, Spectrogram 은 같은 그림의 색표를 256칸에서 1024칸으로 늘려 2.151 에서 2.443 이 됐어요. deflate 는 같은 세 바이트가 거듭 나오는 것을 찾아서, 색이 몇 가지냐가 밝기보다 크게 들어요. 다른 여섯 축은 가장 밝은 채널로 재고 deflate 만 세 채널을 다 읽어요. 18판의 Zen Garden 은 볕 든 자갈에서 가장 밝은 빨강을 두고 초록과 파랑만 알갱이마다 ±15 흔들어서, 촘촘함과 가장자리는 그대로 둔 채 복잡도를 1.287 에서 1.618 로 올렸어요. 복잡도를 다른 축과 따로 고르는 손잡이예요. 내보내는 색을 끊는 단계는 가장자리를 움직여요. 같은 그림을 8 단계로 끊으면 이웃 차이가 0 아니면 8 이상이라 잔 차이가 다 무른 계단으로 들어서 가장자리가 0.454 였고, 6 단계로 끊으면 차이가 꼭 6 인 쌍이 셈에서 빠져 0.585 였어요. 19판에서는 잡음 값의 가짓수가 손잡이였어요. Needlepoint 는 털실 결을 약한 두 채널에 ±6 의 연속 값으로 흔들자 복잡도가 1.373 에서 2.164 로 뛰었고, -10, 0, +10 세 값을 네 LED 에 하나꼴로 넣자 1.888 이었어요. Rabbit Ears 의 회색 눈은 12 단계라 deflate 로 LED 당 0.60 바이트밖에 안 됐어요. 그 패턴의 복잡도는 눈이 아니라 그림 위에 얹힌 옅은 색 낟알에서 와요. 패널 가장자리의 부드러운 비네트 하나도 LED 마다 값을 달리 만들어 복잡도를 0.2 쯤 올렸어요. Smalti 는 15/255 아래의 줄눈에서 한 채널만 5~11 로 흔든 결이 복잡도를 0.14 올렸어요. 20판의 Moth Lamp 는 꺼진 LED 가 deflate 로 15 비트쯤, 켜진 LED 가 23.5 비트쯤이라 복잡도가 1.875 + 1.06 × 채움 언저리에 묶여 있었어요. 짙은 주황 등빛에서는 켜진 벽돌의 파랑이 24 아래에 머물러 2.1 에서 멈췄고, 등을 금빛 고압 나트륨으로 바꿔 켜진 LED 의 세 채널이 다 24 를 넘게 하자 채움 0.274 에서 2.201 이 됐어요. 21판에서는 내보내는 값을 끊는 단위가 결의 크기보다 센 손잡이였어요. Bread Mould 의 같은 화면이 끊지 않으면 2.870, 짝수로 끊으면 2.366, 3 단위로 끊으면 2.023 이에요. Pancakes 는 체커 결의 흔들림을 세 값에서 두 값으로 줄이자 다른 값은 그대로인 채 복잡도만 2.361 에서 2.276 으로 내려갔어요. 나무껍질과 숲을 LED 마다 다른 빛으로 그린 Woodpecker 는 연구의 씨앗으로 2.583 인데, 씨앗 열여섯으로 재면 2.727 까지 올라 축 끝 2.735 에 붙어요. +15판부터 22판까지의 여든 개는 어느 축의 끝도 넘지 않아서 칸 경계가 14판과 같아요. 씨앗을 타는 패턴은 씨앗 여럿으로 재서 칸을 확인해요. 15판의 Marbling 은 처음 판이 씨앗 열여섯 가운데 일곱만 겨눈 칸에 들었고, 연구의 씨앗이 그 일곱 가운데 가장 높았어요. 패턴의 slug 로 씨앗을 정하니 이름만 바꿔서 재면 돼요. 복잡도 축은 어두운 기울기의 끝자리 흔들림도 세요. 16판의 Fireflies 는 채널마다 4 의 배수로 반올림하자 눈에는 같은 화면의 복잡도가 0.490 에서 0.310 으로 내려갔어요. 거꾸로 17판의 Frost 는 22/255 아래로 누른 바탕에 채널마다 ±4.5 의 결을 넣어 복잡도가 1.712 에서 2.383 이 됐고, Spectrogram 은 같은 그림의 색표를 256칸에서 1024칸으로 늘려 2.151 에서 2.443 이 됐어요. deflate 는 같은 세 바이트가 거듭 나오는 것을 찾아서, 색이 몇 가지냐가 밝기보다 크게 들어요. 다른 여섯 축은 가장 밝은 채널로 재고 deflate 만 세 채널을 다 읽어요. 18판의 Zen Garden 은 볕 든 자갈에서 가장 밝은 빨강을 두고 초록과 파랑만 알갱이마다 ±15 흔들어서, 촘촘함과 가장자리는 그대로 둔 채 복잡도를 1.287 에서 1.618 로 올렸어요. 복잡도를 다른 축과 따로 고르는 손잡이예요. 내보내는 색을 끊는 단계는 가장자리를 움직여요. 같은 그림을 8 단계로 끊으면 이웃 차이가 0 아니면 8 이상이라 잔 차이가 다 무른 계단으로 들어서 가장자리가 0.454 였고, 6 단계로 끊으면 차이가 꼭 6 인 쌍이 셈에서 빠져 0.585 였어요. 19판에서는 잡음 값의 가짓수가 손잡이였어요. Needlepoint 는 털실 결을 약한 두 채널에 ±6 의 연속 값으로 흔들자 복잡도가 1.373 에서 2.164 로 뛰었고, -10, 0, +10 세 값을 네 LED 에 하나꼴로 넣자 1.888 이었어요. Rabbit Ears 의 회색 눈은 12 단계라 deflate 로 LED 당 0.60 바이트밖에 안 됐어요. 그 패턴의 복잡도는 눈이 아니라 그림 위에 얹힌 옅은 색 낟알에서 와요. 패널 가장자리의 부드러운 비네트 하나도 LED 마다 값을 달리 만들어 복잡도를 0.2 쯤 올렸어요. Smalti 는 15/255 아래의 줄눈에서 한 채널만 5~11 로 흔든 결이 복잡도를 0.14 올렸어요. 20판의 Moth Lamp 는 꺼진 LED 가 deflate 로 15 비트쯤, 켜진 LED 가 23.5 비트쯤이라 복잡도가 1.875 + 1.06 × 채움 언저리에 묶여 있었어요. 짙은 주황 등빛에서는 켜진 벽돌의 파랑이 24 아래에 머물러 2.1 에서 멈췄고, 등을 금빛 고압 나트륨으로 바꿔 켜진 LED 의 세 채널이 다 24 를 넘게 하자 채움 0.274 에서 2.201 이 됐어요. 21판에서는 내보내는 값을 끊는 단위가 결의 크기보다 센 손잡이였어요. Bread Mould 의 같은 화면이 끊지 않으면 2.870, 짝수로 끊으면 2.366, 3 단위로 끊으면 2.023 이에요. Pancakes 는 체커 결의 흔들림을 세 값에서 두 값으로 줄이자 다른 값은 그대로인 채 복잡도만 2.361 에서 2.276 으로 내려갔어요. 나무껍질과 숲을 LED 마다 다른 빛으로 그린 Woodpecker 는 연구의 씨앗으로 2.583 인데, 씨앗 열여섯으로 재면 2.727 까지 올라 축 끝 2.735 에 붙어요. ### 아직 없는 갈래 -`families.json` 의 마흔 갈래에 없는 것 가운데 세운 패널에 맞을 것을 적어 둬요. 20판이 연 새 갈래는 Kingfisher 와 21판의 Woodpecker 예요. 둥지를 짓는 새나 밤하늘을 건너는 철새 떼가 아직 없어요. 20판이 연 화학·실험 갈래는 Chromatography 와 21판의 Chemical Garden 이에요. 불꽃 반응의 색은 아직 없어요. 21판이 연 종이 갈래는 Pop-up Book 하나예요. 접힌 종이학이 펼쳐지는 그림이나 실에 꿰어 위에서 늘어뜨린 천 마리 종이학이 세운 패널에 맞아요. 21판이 연 운동·경기 갈래도 Lap Pool 하나예요. 장대높이뛰기나 실내 암벽처럼 위아래로 움직이는 경기가 그 갈래에 맞아요. 몸 갈래도 없어요. 심장이 뛰며 피가 혈관을 도는 그림이 128줄을 다 써요. 19판이 연 부엌 갈래는 Popcorn 과 20판의 Kettle, 21판의 Pancakes 이고, 버섯·균사 갈래는 Foxfire 와 20판의 Veiled Lady, 21판의 Bread Mould 예요. 18판이 연 유리 갈래는 Stained Glass 와 19판의 Smalti, 20판의 Millefiori 예요. 광물·결정 갈래는 Bismuth 와 19판의 Dripstone, 20판의 Agate 이고, 놀이공원 갈래는 Drop Tower 와 19판의 Helter Skelter, 20판의 High Striker 예요. 관람차는 Drop Tower 뒤에 테두리로만 서요. 17판이 연 전자 회로 갈래는 Circuit Board 와 18판의 Server Rack, 21판의 Nixie 이고, 장난감 갈래는 Marble Run 과 18판의 Peg Light, 21판의 Slinky 예요. 16판이 연 바다 갈래는 Abyss 와 17판의 Shoreline, 20판의 Sunglint 이고, 곤충 갈래는 Fireflies 와 17판의 Ant Farm, 20판의 Moth Lamp 예요. 15판이 연 도시 갈래는 Metro Map 과 16판의 Neon Alley, 17판의 Crossroads 예요. 15판이 연 음악 갈래는 Player Piano 와 16판의 Harp, 18판의 Turntables 예요. 4판이 연 직물 갈래는 Jacquard Loom 과 15판의 Plait, 19판의 Needlepoint 이고, 8판이 연 기계 갈래는 Gear Train 과 15판의 Cam Wave, 19판의 Paternoster 예요. 12판이 연 천체 갈래는 Star Trails 와 15판의 Analemma, 16판의 Lunation 이고, 행성의 궤도는 아직 없어요. 리듬·펄스 갈래는 1판의 Euclid Pulse 와 Pulse Bars 뒤로 20판의 Change Ringing 이 셋째예요. +`families.json` 의 마흔두 갈래에 없는 것 가운데 세운 패널에 맞을 것을 적어 둬요. 22판이 연 몸 갈래는 Circulation 하나예요. 숨 쉬는 폐나 뇌를 오가는 신경 신호가 그 갈래에 맞아요. 22판이 연 비행 갈래도 Liftoff 하나예요. 밤 활주로로 내려오는 여객기의 착륙등과 진입등이 세운 패널에 맞아요. 무대와 공연 갈래는 아직 없어요. 막이 오르고 조명 다리에서 빛이 내려오는 극장은 128줄을 다 써요. 20판이 연 새 갈래는 Kingfisher 와 21판의 Woodpecker, 22판의 Weaver 예요. 밤하늘을 건너는 철새 떼가 아직 없어요. 20판이 연 화학·실험 갈래는 Chromatography 와 21판의 Chemical Garden, 22판의 Flame Test 예요. 21판이 연 종이 갈래는 Pop-up Book 과 22판의 Senbazuru 예요. 접힌 종이학이 펼쳐지는 그림은 아직 없어요. 21판이 연 운동·경기 갈래는 Lap Pool 과 22판의 Bowling Lane 이에요. 장대높이뛰기나 실내 암벽처럼 위아래로 움직이는 경기는 아직 없어요. 19판이 연 부엌 갈래는 Popcorn 과 20판의 Kettle, 21판의 Pancakes 이고, 버섯·균사 갈래는 Foxfire 와 20판의 Veiled Lady, 21판의 Bread Mould 예요. 18판이 연 유리 갈래는 Stained Glass 와 19판의 Smalti, 20판의 Millefiori 예요. 광물·결정 갈래는 Bismuth 와 19판의 Dripstone, 20판의 Agate 이고, 놀이공원 갈래는 Drop Tower 와 19판의 Helter Skelter, 20판의 High Striker 예요. 관람차는 Drop Tower 뒤에 테두리로만 서요. 17판이 연 전자 회로 갈래는 Circuit Board 와 18판의 Server Rack, 21판의 Nixie 이고, 장난감 갈래는 Marble Run 과 18판의 Peg Light, 21판의 Slinky, 22판의 Pin Art 예요. 16판이 연 바다 갈래는 Abyss 와 17판의 Shoreline, 20판의 Sunglint 이고, 곤충 갈래는 Fireflies 와 17판의 Ant Farm, 20판의 Moth Lamp 예요. 15판이 연 도시 갈래는 Metro Map 과 16판의 Neon Alley, 17판의 Crossroads, 22판의 Night Flight 예요. 15판이 연 음악 갈래는 Player Piano 와 16판의 Harp, 18판의 Turntables 예요. 4판이 연 직물 갈래는 Jacquard Loom 과 15판의 Plait, 19판의 Needlepoint 이고, 8판이 연 기계 갈래는 Gear Train 과 15판의 Cam Wave, 19판의 Paternoster 예요. 12판이 연 천체 갈래는 Star Trails 와 15판의 Analemma, 16판의 Lunation, 22판의 Galilean Moons 예요. 위성의 궤도는 Galilean Moons 가 그렸고, 해를 도는 행성은 아직 없어요. 리듬·펄스 갈래는 1판의 Euclid Pulse 와 Pulse Bars 뒤로 20판의 Change Ringing 이 셋째예요. ### 멋진 것 더 멋지게 이제는 창작자가 댓글로 고른 패턴만 고쳐요. 고를 때 볼 잰 값이에요. -노브 반응이 두 자리로 0.03 인 노브는 넷이에요. Hourglass 의 K2 Flow 가 0.029 로 가장 낮고, 같은 패턴의 K4 Layers 가 0.030 이에요. Flip Clock 의 K4 Light 가 0.032, Sparks 의 K1 Spread 가 0.034 예요. 13판의 Glyph Rain 은 K1 Hue 가 0.028 에서 0.066 으로, K4 Mutate 가 0.033 에서 0.066 으로 올랐어요. 판이 거의 빈 패턴은 노브가 바꿀 LED 가 적어서 노브가 다 낮게 나와요. Glyph Rain 은 채움이 0.059 에서 0.215 가 되자 노브 넷이 다 올랐어요. 9판의 Sprout 은 K1 Hue 가 0.045 로 올랐어요. 5판의 Euclid Pulse 는 K4 Steps 가 0.05 로 넷 가운데 가장 낮아요. 처음 자리의 Pulses 3 에서는 Steps 가 빛 조각의 폭만 바꿔요. 15판의 열 개는 가장 낮은 노브가 Ivy 의 K2 Speed 0.049 예요. 빠르기 노브는 새로 시작한 그림의 2~3초를 봐서, 빠르기만 바꾸는 노브는 낮게 나와요. 1 LED 너비 선이 주인공인 패턴은 선 옆 1 LED 에 그 빛을 옅게 묻히면 노브 반응이 두 배쯤 돼요. 15판의 Player Piano 는 그렇게 노브 넷이 0.02 언저리에서 0.05~0.08 로 올랐어요. 16판의 열 개는 가장 낮은 노브가 Fireflies 의 K4 Coupling 0.04 예요. 대부분이 어두운 그림은 노브 반응이 화면 평균 빛의 두 배쯤에서 멈춰요. Fireflies 는 평균 밝기가 0.03 언저리이고, 16판의 Wireworld 는 전자가 더하는 빛이 프레임 차이로 0.046 이라 K3 Pulses 가 0.05 예요. 노브 하나가 바탕을 바꾸게 하면 쉽게 올라가서, Fireflies 의 K1 Moon 은 0.08 이에요. 17판의 Frost 도 K1 Lamp 가 가로등 둘레의 서리만 물들일 때는 0.01~0.02 였고, 하늘 아래쪽과 눈 덮인 길까지 물들이자 0.05 가 됐어요. 17판의 열 개는 가장 낮은 노브가 Crossroads 의 K2 Pace 0.04 예요. 노브 검사는 다섯 설정을 같은 씨앗으로 돌려서, 노브가 붓질 수나 개미 수를 바꾸면 난수 차례가 갈려 반응이 부풀어요. 17판 Brushstrokes 의 K2~K4 와 Ant Farm 의 K3 에는 그 몫도 들어 있어요. 18판의 열 개는 가장 낮은 노브가 Server Rack 과 Turntables 의 K1~K3 으로 0.04 언저리예요. 큰 물건 하나에서 작은 부분만 움직이는 노브는 낮게 나와요. 18판 Longcase Clock 의 K3 은 처음에 추의 진폭을 바꾸는 Swing 이었는데 0.0036 으로 검사에 걸렸고, 촛불 자리를 옮기는 Candle 로 바꾸자 0.11 이 됐어요. 미리 돌리기로 처음 상태를 맞출 때는 빠르기 노브를 같이 봐야 해요. Server Rack 은 어느 빠르기에서든 2초에 같은 상태가 오게 맞췄다가 K2 가 0.0091 로 걸렸어요. 맞추는 시점은 패턴 시간에 두고 미리 돌리기는 노브 빠르기로 돌려야 빠르기마다 그림이 갈려요. 19판의 열 개는 가장 낮은 노브가 Foxfire 의 K1 Glow 와 Paternoster 의 K4 Busy 로 0.033 이에요. 패널의 10분의 1쯤 덮는 것의 색만 바꾸는 노브는 0.03 에 못 미쳐요. 19판 Popcorn 의 K4 는 처음에 버터 색을 바꾸는 Butter 였는데 0.012~0.02 여서, 더미 높이를 정하는 Heap 으로 바꿨어요. Dust Motes 의 Dust 는 먼지 수만 바꿀 때 0.011 이었고, 빛줄기 안개를 먼지 양에 묶자 0.04 가 됐어요. 노브 반응은 세 채널의 차이를 평균해요. Helter Skelter 의 빨강과 크림 줄무늬는 차이가 초록과 파랑에만 있어서, 탑이 눈에 띄게 비틀려도 0.02 였어요. 20판의 열 개는 가장 낮은 노브가 Kingfisher 의 K3 Fish 와 Kettle 의 K3 Fill 로 둘 다 0.040 이에요. 물고기 수나 물 높이만 바꿀 때는 0.012 와 0.038 이었고, Kingfisher 는 물풀과 갈대가 함께 늘게, Kettle 은 물이 많을수록 늦게 끓게 해서 올렸어요. 21판의 열 개는 가장 낮은 노브가 Woodpecker 의 K2 Speed 와 Lap Pool 의 K1 Stroke 로 둘 다 0.044 예요. 사람 넷의 영법만 바꿀 때 Stroke 는 0.02 였고, 평영과 접영이 바닥의 햇빛 무늬에 짧은 물결을 섞게 해서 올렸어요. 주인공 뒤에 서는 것을 더하는 노브는 가려져서 낮게 나와요. Pop-up Book 의 K3 Layers 는 뒤에 카드를 더할 때 0.01 이었고, 여섯째 층을 맨 앞에 서는 터널북 틀로 바꾸자 0.05 가 됐어요. Fiddlehead 의 K3 Fronds 도 늘어난 잎이 가운데 포기 뒤에서 자랄 때 0.02~0.03 이었고, 양옆 포기의 잎을 가운데 위로 기울게 하자 0.047 이 됐어요. +노브 반응이 두 자리로 0.03 인 노브는 넷이에요. Hourglass 의 K2 Flow 가 0.029 로 가장 낮고, 같은 패턴의 K4 Layers 가 0.030 이에요. Flip Clock 의 K4 Light 가 0.032, Sparks 의 K1 Spread 가 0.034 예요. 13판의 Glyph Rain 은 K1 Hue 가 0.028 에서 0.066 으로, K4 Mutate 가 0.033 에서 0.066 으로 올랐어요. 판이 거의 빈 패턴은 노브가 바꿀 LED 가 적어서 노브가 다 낮게 나와요. Glyph Rain 은 채움이 0.059 에서 0.215 가 되자 노브 넷이 다 올랐어요. 9판의 Sprout 은 K1 Hue 가 0.045 로 올랐어요. 5판의 Euclid Pulse 는 K4 Steps 가 0.05 로 넷 가운데 가장 낮아요. 처음 자리의 Pulses 3 에서는 Steps 가 빛 조각의 폭만 바꿔요. 15판의 열 개는 가장 낮은 노브가 Ivy 의 K2 Speed 0.049 예요. 빠르기 노브는 새로 시작한 그림의 2~3초를 봐서, 빠르기만 바꾸는 노브는 낮게 나와요. 1 LED 너비 선이 주인공인 패턴은 선 옆 1 LED 에 그 빛을 옅게 묻히면 노브 반응이 두 배쯤 돼요. 15판의 Player Piano 는 그렇게 노브 넷이 0.02 언저리에서 0.05~0.08 로 올랐어요. 16판의 열 개는 가장 낮은 노브가 Fireflies 의 K4 Coupling 0.04 예요. 대부분이 어두운 그림은 노브 반응이 화면 평균 빛의 두 배쯤에서 멈춰요. Fireflies 는 평균 밝기가 0.03 언저리이고, 16판의 Wireworld 는 전자가 더하는 빛이 프레임 차이로 0.046 이라 K3 Pulses 가 0.05 예요. 노브 하나가 바탕을 바꾸게 하면 쉽게 올라가서, Fireflies 의 K1 Moon 은 0.08 이에요. 17판의 Frost 도 K1 Lamp 가 가로등 둘레의 서리만 물들일 때는 0.01~0.02 였고, 하늘 아래쪽과 눈 덮인 길까지 물들이자 0.05 가 됐어요. 17판의 열 개는 가장 낮은 노브가 Crossroads 의 K2 Pace 0.04 예요. 노브 검사는 다섯 설정을 같은 씨앗으로 돌려서, 노브가 붓질 수나 개미 수를 바꾸면 난수 차례가 갈려 반응이 부풀어요. 17판 Brushstrokes 의 K2~K4 와 Ant Farm 의 K3 에는 그 몫도 들어 있어요. 18판의 열 개는 가장 낮은 노브가 Server Rack 과 Turntables 의 K1~K3 으로 0.04 언저리예요. 큰 물건 하나에서 작은 부분만 움직이는 노브는 낮게 나와요. 18판 Longcase Clock 의 K3 은 처음에 추의 진폭을 바꾸는 Swing 이었는데 0.0036 으로 검사에 걸렸고, 촛불 자리를 옮기는 Candle 로 바꾸자 0.11 이 됐어요. 미리 돌리기로 처음 상태를 맞출 때는 빠르기 노브를 같이 봐야 해요. Server Rack 은 어느 빠르기에서든 2초에 같은 상태가 오게 맞췄다가 K2 가 0.0091 로 걸렸어요. 맞추는 시점은 패턴 시간에 두고 미리 돌리기는 노브 빠르기로 돌려야 빠르기마다 그림이 갈려요. 19판의 열 개는 가장 낮은 노브가 Foxfire 의 K1 Glow 와 Paternoster 의 K4 Busy 로 0.033 이에요. 패널의 10분의 1쯤 덮는 것의 색만 바꾸는 노브는 0.03 에 못 미쳐요. 19판 Popcorn 의 K4 는 처음에 버터 색을 바꾸는 Butter 였는데 0.012~0.02 여서, 더미 높이를 정하는 Heap 으로 바꿨어요. Dust Motes 의 Dust 는 먼지 수만 바꿀 때 0.011 이었고, 빛줄기 안개를 먼지 양에 묶자 0.04 가 됐어요. 노브 반응은 세 채널의 차이를 평균해요. Helter Skelter 의 빨강과 크림 줄무늬는 차이가 초록과 파랑에만 있어서, 탑이 눈에 띄게 비틀려도 0.02 였어요. 20판의 열 개는 가장 낮은 노브가 Kingfisher 의 K3 Fish 와 Kettle 의 K3 Fill 로 둘 다 0.040 이에요. 물고기 수나 물 높이만 바꿀 때는 0.012 와 0.038 이었고, Kingfisher 는 물풀과 갈대가 함께 늘게, Kettle 은 물이 많을수록 늦게 끓게 해서 올렸어요. 21판의 열 개는 가장 낮은 노브가 Woodpecker 의 K2 Speed 와 Lap Pool 의 K1 Stroke 로 둘 다 0.044 예요. 사람 넷의 영법만 바꿀 때 Stroke 는 0.02 였고, 평영과 접영이 바닥의 햇빛 무늬에 짧은 물결을 섞게 해서 올렸어요. 주인공 뒤에 서는 것을 더하는 노브는 가려져서 낮게 나와요. Pop-up Book 의 K3 Layers 는 뒤에 카드를 더할 때 0.01 이었고, 여섯째 층을 맨 앞에 서는 터널북 틀로 바꾸자 0.05 가 됐어요. Fiddlehead 의 K3 Fronds 도 늘어난 잎이 가운데 포기 뒤에서 자랄 때 0.02~0.03 이었고, 양옆 포기의 잎을 가운데 위로 기울게 하자 0.047 이 됐어요. 22판의 열 개는 가장 낮은 노브가 Circulation 의 K3 Detail 0.025 이고, 이 연구에서 두 자리로 0.02 인 노브는 이것 하나예요. 굵은 줄기와 첫 곁가지가 처음 자리에서 몸 LED 2618개 가운데 1943개를 덮어서 처음에 0.008 이었어요. 세운 패널에 사람 하나를 세우면 몸이 판의 32% 쯤이라, 몸 안에서만 바뀌는 노브는 화면 평균으로 나뉘어 낮게 나와요. 22판 Bowling Lane 의 K2 Speed 와 K3 Hook 은 0.03 이에요. 노브 검사가 보는 2~3초 무렵에 공의 길이 Hook 마다 화살표 언저리에서 만나서, 길만 바꿀 때 K3 는 0.01 이었어요. 빛나는 오일의 길이를 Hook 에 묶어 늘 보이는 결과를 하나 붙이자 올랐어요. 패널의 4분의 1만 바꾸는 노브는 그 안을 흐려도 0.04 언저리에서 멈춰요. Galilean Moons 의 K4 Seeing 은 접안렌즈 안만 흐릴 때 0.039 였고, 원반 둘레로 번지는 빛을 넓히자 0.054 가 됐어요. Flock, Harmonograph, Big Type 은 처음 화면이 빨강이에요. ### 세로 64×128 -2~8초 동안 켜진 LED 가 5% 아래인 3분의 1이 있는 패턴이 넷이에요. `study.ts` 표의 `thirds` 칸이 이 값이에요. Jelly 4.5% 와 Pulse Bars 2.6% 는 위 3분의 1이 비고, Sparks 2.0% 와 Dragon Fold 4.9% 는 아래 3분의 1이 비어요. Dragon Fold 는 문턱 바로 아래예요. 6판의 Jacob's Ladder 는 위, 가운데, 아래가 27.0%, 21.4%, 35.1% 예요. 햇빛층에서 심해까지 내려가는 바다는 16판의 Abyss 가 했고, 덩굴은 15판의 Ivy 가 했어요. 어두운 판 위의 가는 빛 하나는 128줄을 다 지나가도 3분의 1마다 10% 를 넘기 어려워요. 6판의 처음 아크는 가운데 3분의 1을 7.4% 켰어요. 9판의 Sprout 은 접시꽃 둘이 위 3분의 1을 8.1% 켜요. 이렇게 철이 도는 패턴은 2~8초가 어느 철에 걸리느냐에 따라 값이 몇 배씩 달라서, 씨앗 여럿으로 재 봐야 해요. 테두리를 그리는 패턴은 테두리를 빼고 재요. 10판의 Tetromino Well 은 양쪽 벽 2 LED 만으로 3분의 1마다 6.3% 를 켜서, 벽을 넣어 재면 위 3분의 1이 늘 문턱을 넘어요. 바탕을 다 그리는 패턴은 `thirds` 칸이 3분의 1마다 90 을 넘어요. 14판의 Waterfall 은 98/93/97 이에요. 그런 패턴은 2~8초 동안 밝기가 24/255 넘게 바뀌는 LED 로 세어야 그림이 128줄을 쓰는지 보여요. 그렇게 세면 Waterfall 의 위 3분의 1은 25.9% 예요. 15판의 Ivy 는 벽을 그려 `thirds` 가 84/89/82 이고, 바뀐 LED 로 세면 48.7%, 70.6%, 29.2% 예요. 가만히 있는 가는 선은 켜져 있어도 바뀌지 않아요. 15판의 Incense 는 곧은 연기 실이 가운데 3분의 1을 지나도 바뀐 LED 가 3.7% 였고, 소용돌이가 그 3분의 1 안에서 시작되게 하자 15.5% 가 됐어요. 16판의 Abyss 는 물을 다 칠해서 `thirds` 가 100/80/22 이고, 바뀐 LED 로 세면 84.5%, 59.3%, 36.0% 예요. 한밤층은 아귀와 불꽃, 잠수정 등빛뿐이라 아래 3분의 1이 가장 낮아요. 16판의 Harp 는 액자를 빼고 줄만 재면 48/53/12 이고, 바뀐 LED 는 아래 3분의 1이 9.8% 예요. 거기는 받침과 공명통 발이고, 줄은 300 LED 남짓한 삼각형에만 있어요. 17판의 Marble Run 은 레일과 리프트가 서 있어서 `thirds` 가 39/30/68 이고, 바뀐 LED 는 40.7%, 54.3%, 59.7% 예요. 17판의 Ant Farm 은 모래가 바탕이라 `thirds` 가 100/97/96 이고, 바뀐 LED 는 6.9%, 34.5%, 53.4% 예요. 위 3분의 1은 모래 윗면과 그 위의 어두운 유리라서 개미가 드나드는 입구와 더미만 바뀌어요. 18판의 Server Rack 은 앞판을 13~23 으로 24 아래에 두어서, `thirds` 63/63/61 이 다 드라이브와 코드와 LED 의 빛이에요. 바뀐 LED 셈은 2~8초 가운데 한 프레임만 24 를 넘어도 그 LED 를 세서, 무작위 반짝임이 값을 부풀려요. 18판의 Pint 는 잔 기포 반짝임을 켜면 50.3%, 60.2%, 40.0% 이고 끄면 50.2%, 46.8%, 26.3% 예요. 빛의 흔들림도 그래요. Longcase Clock 은 촛불일 때 41.8%, 45.6%, 45.3% 이고 흔들림 없는 달빛에서는 2.6%, 4.2%, 5.9% 예요. 자라는 그림은 미리 돌리기를 시간 대신 상태로 멈추면 씨앗마다 같은 자리에서 열려요. 18판의 Bismuth 는 무리 꼭대기가 20번 줄에 닿을 때 멈춰요. 14번 줄까지 기다리면 2초에 이미 다 자라 있어서 모양 바뀜이 0.027 로 떨어졌어요. 19판의 Dripstone 은 잘린 암반이 4/255 쯤이라 위아래가 검게 보여 `thirds` 가 68/99/77 이었고, 암반에 제 빛을 주자 92/98/99 가 됐어요. Foxfire 는 땅을 80번 줄에 두었을 때 위 3분의 1이 5% 였고, 64번 줄로 올리고 그루터기를 세우자 16% 가 됐어요. 쌓이는 그림은 2~8초에 쌓이는 자리가 보이게 열어야 해요. Popcorn 은 더미가 높게 시작하면 아래 3분의 1의 바뀐 LED 가 4.4% 였고, 낮게 시작해 자라게 하자 27.9% 가 됐어요. 넓은 면을 물들이는 조명은 반짝임 없이도 바뀐 LED 를 올려요. Dripstone 은 세 조명이 15초에 한 바퀴 번갈아 켜져서 57.6%, 84.9%, 79.2% 예요. 20판에서는 한 번 일어나는 일이 2~8초 창에 드는지가 값을 갈랐어요. High Striker 는 7.3초에 종이 한 번 울려서 위 3분의 1의 바뀐 LED 가 6.5% 에서 95.6% 가 됐고, Kettle 은 7.1초에 스위치가 꺼지는 것이 창에 들어 모양 바뀜이 0.065 에서 0.168 이 됐어요. 위로 오르는 앞선은 모양 바뀜을 적게 내요. Chromatography 는 6초 동안 앞선이 26줄을 올라도 블록이 16줄 높이라 0.040 이었어요. 21판의 세로 일곱은 Nixie 를 빼고 방이나 바탕을 다 그려서 `thirds` 가 3분의 1마다 70 을 넘었어요. 그런 그림은 바탕을 지우거나 멈추고 한 번 더 재야 주인공이 128줄을 쓰는지 보여요. Chemical Garden 은 유리와 방을 검게 지우자 정원만의 위 3분의 1이 6.6% 였고, 씨앗 열여섯 가운데 하나는 3.2% 였어요. Lap Pool 은 바닥 햇빛 무늬의 시간을 멈추자 바뀐 LED 가 3분의 1마다 18.7~52.1% 였어요. Woodpecker 는 새를 빼고 재도 바뀐 LED 가 57.3/35.1/50.0% 라서, 그 값은 거의 다 잎 사이로 움직이는 햇빛 반점에서 와요. 조금 위에서 본 장면은 세로를 싸게 얻어요. Pop-up Book 은 누운 페이지가 깊이의 5분의 1 줄만 써서, 책을 아래 34줄에 두고도 선 카드가 위 3분의 1까지 닿아요. 닉시관 하나는 지름이 높이의 절반쯤이라 Nixie 의 IN-18 은 54 LED 너비에 109줄로 서요.Slinky 는 계단과 벽을 빼고 스프링과 그림자만 세면 켜진 LED 가 0.4/2.6/3.0% 이고, 스프링이 켠 계단등까지 세면 20.9/16.5/8.6% 예요. +2~8초 동안 켜진 LED 가 5% 아래인 3분의 1이 있는 패턴이 넷이에요. `study.ts` 표의 `thirds` 칸이 이 값이에요. Jelly 4.5% 와 Pulse Bars 2.6% 는 위 3분의 1이 비고, Sparks 2.0% 와 Dragon Fold 4.9% 는 아래 3분의 1이 비어요. Dragon Fold 는 문턱 바로 아래예요. 6판의 Jacob's Ladder 는 위, 가운데, 아래가 27.0%, 21.4%, 35.1% 예요. 햇빛층에서 심해까지 내려가는 바다는 16판의 Abyss 가 했고, 덩굴은 15판의 Ivy 가 했어요. 어두운 판 위의 가는 빛 하나는 128줄을 다 지나가도 3분의 1마다 10% 를 넘기 어려워요. 6판의 처음 아크는 가운데 3분의 1을 7.4% 켰어요. 9판의 Sprout 은 접시꽃 둘이 위 3분의 1을 8.1% 켜요. 이렇게 철이 도는 패턴은 2~8초가 어느 철에 걸리느냐에 따라 값이 몇 배씩 달라서, 씨앗 여럿으로 재 봐야 해요. 테두리를 그리는 패턴은 테두리를 빼고 재요. 10판의 Tetromino Well 은 양쪽 벽 2 LED 만으로 3분의 1마다 6.3% 를 켜서, 벽을 넣어 재면 위 3분의 1이 늘 문턱을 넘어요. 바탕을 다 그리는 패턴은 `thirds` 칸이 3분의 1마다 90 을 넘어요. 14판의 Waterfall 은 98/93/97 이에요. 그런 패턴은 2~8초 동안 밝기가 24/255 넘게 바뀌는 LED 로 세어야 그림이 128줄을 쓰는지 보여요. 그렇게 세면 Waterfall 의 위 3분의 1은 25.9% 예요. 15판의 Ivy 는 벽을 그려 `thirds` 가 84/89/82 이고, 바뀐 LED 로 세면 48.7%, 70.6%, 29.2% 예요. 가만히 있는 가는 선은 켜져 있어도 바뀌지 않아요. 15판의 Incense 는 곧은 연기 실이 가운데 3분의 1을 지나도 바뀐 LED 가 3.7% 였고, 소용돌이가 그 3분의 1 안에서 시작되게 하자 15.5% 가 됐어요. 16판의 Abyss 는 물을 다 칠해서 `thirds` 가 100/80/22 이고, 바뀐 LED 로 세면 84.5%, 59.3%, 36.0% 예요. 한밤층은 아귀와 불꽃, 잠수정 등빛뿐이라 아래 3분의 1이 가장 낮아요. 16판의 Harp 는 액자를 빼고 줄만 재면 48/53/12 이고, 바뀐 LED 는 아래 3분의 1이 9.8% 예요. 거기는 받침과 공명통 발이고, 줄은 300 LED 남짓한 삼각형에만 있어요. 17판의 Marble Run 은 레일과 리프트가 서 있어서 `thirds` 가 39/30/68 이고, 바뀐 LED 는 40.7%, 54.3%, 59.7% 예요. 17판의 Ant Farm 은 모래가 바탕이라 `thirds` 가 100/97/96 이고, 바뀐 LED 는 6.9%, 34.5%, 53.4% 예요. 위 3분의 1은 모래 윗면과 그 위의 어두운 유리라서 개미가 드나드는 입구와 더미만 바뀌어요. 18판의 Server Rack 은 앞판을 13~23 으로 24 아래에 두어서, `thirds` 63/63/61 이 다 드라이브와 코드와 LED 의 빛이에요. 바뀐 LED 셈은 2~8초 가운데 한 프레임만 24 를 넘어도 그 LED 를 세서, 무작위 반짝임이 값을 부풀려요. 18판의 Pint 는 잔 기포 반짝임을 켜면 50.3%, 60.2%, 40.0% 이고 끄면 50.2%, 46.8%, 26.3% 예요. 빛의 흔들림도 그래요. Longcase Clock 은 촛불일 때 41.8%, 45.6%, 45.3% 이고 흔들림 없는 달빛에서는 2.6%, 4.2%, 5.9% 예요. 자라는 그림은 미리 돌리기를 시간 대신 상태로 멈추면 씨앗마다 같은 자리에서 열려요. 18판의 Bismuth 는 무리 꼭대기가 20번 줄에 닿을 때 멈춰요. 14번 줄까지 기다리면 2초에 이미 다 자라 있어서 모양 바뀜이 0.027 로 떨어졌어요. 19판의 Dripstone 은 잘린 암반이 4/255 쯤이라 위아래가 검게 보여 `thirds` 가 68/99/77 이었고, 암반에 제 빛을 주자 92/98/99 가 됐어요. Foxfire 는 땅을 80번 줄에 두었을 때 위 3분의 1이 5% 였고, 64번 줄로 올리고 그루터기를 세우자 16% 가 됐어요. 쌓이는 그림은 2~8초에 쌓이는 자리가 보이게 열어야 해요. Popcorn 은 더미가 높게 시작하면 아래 3분의 1의 바뀐 LED 가 4.4% 였고, 낮게 시작해 자라게 하자 27.9% 가 됐어요. 넓은 면을 물들이는 조명은 반짝임 없이도 바뀐 LED 를 올려요. Dripstone 은 세 조명이 15초에 한 바퀴 번갈아 켜져서 57.6%, 84.9%, 79.2% 예요. 20판에서는 한 번 일어나는 일이 2~8초 창에 드는지가 값을 갈랐어요. High Striker 는 7.3초에 종이 한 번 울려서 위 3분의 1의 바뀐 LED 가 6.5% 에서 95.6% 가 됐고, Kettle 은 7.1초에 스위치가 꺼지는 것이 창에 들어 모양 바뀜이 0.065 에서 0.168 이 됐어요. 위로 오르는 앞선은 모양 바뀜을 적게 내요. Chromatography 는 6초 동안 앞선이 26줄을 올라도 블록이 16줄 높이라 0.040 이었어요. 21판의 세로 일곱은 Nixie 를 빼고 방이나 바탕을 다 그려서 `thirds` 가 3분의 1마다 70 을 넘었어요. 그런 그림은 바탕을 지우거나 멈추고 한 번 더 재야 주인공이 128줄을 쓰는지 보여요. Chemical Garden 은 유리와 방을 검게 지우자 정원만의 위 3분의 1이 6.6% 였고, 씨앗 열여섯 가운데 하나는 3.2% 였어요. Lap Pool 은 바닥 햇빛 무늬의 시간을 멈추자 바뀐 LED 가 3분의 1마다 18.7~52.1% 였어요. Woodpecker 는 새를 빼고 재도 바뀐 LED 가 57.3/35.1/50.0% 라서, 그 값은 거의 다 잎 사이로 움직이는 햇빛 반점에서 와요. 조금 위에서 본 장면은 세로를 싸게 얻어요. Pop-up Book 은 누운 페이지가 깊이의 5분의 1 줄만 써서, 책을 아래 34줄에 두고도 선 카드가 위 3분의 1까지 닿아요. 닉시관 하나는 지름이 높이의 절반쯤이라 Nixie 의 IN-18 은 54 LED 너비에 109줄로 서요.Slinky 는 계단과 벽을 빼고 스프링과 그림자만 세면 켜진 LED 가 0.4/2.6/3.0% 이고, 스프링이 켠 계단등까지 세면 20.9/16.5/8.6% 예요. 22판의 세로 다섯 가운데 넷은 바탕을 다 칠해서 `thirds` 가 말해 주는 것이 적었어요. 바탕을 검게 지운 사본으로 다시 재면 Liftoff 는 로켓만의 위 3분의 1이 켜진 LED 3.7%, 바뀐 LED 8.2% 라서, 그 3분의 1의 변화는 대부분 카메라가 고개를 들며 어두워지는 하늘에서 와요. Bowling Lane 은 공을 던지지 않게 하면 바뀐 LED 가 5.2/9.5/6.6% 로 떨어져서, 그 값은 거의 다 공과 그 빛이에요. Circulation 은 바탕을 21 아래로 내려 몸만 켜진 LED 로 세게 해서 `thirds` 가 34/50/26 이에요. 남은 자리로 짠 Flame Test 는 실험대가 어두워 아래 3분의 1의 바뀐 LED 가 4.6% 였어요. 불꽃빛의 몫을 0.42 에서 0.52 로 올려도 1.3 포인트만 올라서, 어두운 바닥은 빛을 비추는 것보다 2~8초 창 안에 그 자리로 가는 움직임이 있어야 바뀐 LED 가 올라요. ## 다시 돌리기 diff --git a/incoming/pattern-study/families.json b/incoming/pattern-study/families.json index a80b1a256..a1530ebed 100644 --- a/incoming/pattern-study/families.json +++ b/incoming/pattern-study/families.json @@ -38,5 +38,7 @@ "bird": { "label": "새", "color": "#778800" }, "chemistry": { "label": "화학·실험", "color": "#00bb00" }, "paper": { "label": "종이", "color": "#996600" }, - "sport": { "label": "운동·경기", "color": "#337777" } + "sport": { "label": "운동·경기", "color": "#337777" }, + "body": { "label": "몸", "color": "#bb5566" }, + "flight": { "label": "비행", "color": "#9944ee" } } diff --git a/incoming/pattern-study/index.html b/incoming/pattern-study/index.html index 32770cba9..6aa28c834 100644 --- a/incoming/pattern-study/index.html +++ b/incoming/pattern-study/index.html @@ -141,7 +141,7 @@

패턴 연구

- + diff --git a/incoming/pattern-study/patterns/bowling-lane.js b/incoming/pattern-study/patterns/bowling-lane.js new file mode 100644 index 000000000..695fc7524 --- /dev/null +++ b/incoming/pattern-study/patterns/bowling-lane.js @@ -0,0 +1,596 @@ +// ===== Patternflow pattern ===== +// Title: Bowling Lane +// Family: sport +// Idea: A ten-pin lane seen from behind the bowler, foul line to pinsetter, where a hooking ball rolls up through the oil over its reflection, scatters the pins and the sweep clears them for the next rack, under house lights or blacklight. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after cosmic bowling on ten-pin lanes with ball-tracking lane lights +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Ball=0..1, Speed=0.2..3, Hook=0..1, Lights=0..1 +// +// Knob 1: Ball. The colour of the ball, which the lane lights and the projected trail follow: cyan at 0, round through blue, pink, red, orange and green. +// Knob 2: Speed. How fast everything goes: at 2 the ball takes about 2.5 s from the foul line to the pins. +// Knob 3: Hook. How far the oil runs and how much the ball curves: at 0 the oil runs 46 ft and a straight ball hits the head pin on the nose and leaves pins. Near 0.6 it hooks into the pocket and strikes; at 1 the oil stops at 30 ft and it crosses to the Brooklyn side. +// Knob 4: Lights. Cosmic bowling at 0, the pins, arrows and oil glowing under blacklight, up to the full house lights on the wood at 1. +// Buttons: K1 tap throws the next ball now. + +const W = 64; +const H = 128; +const N = W * H; +const CX = 31.5; +const { min, max, floor, ceil, round, sin, cos, abs, sqrt, exp, atan2, sign, hypot, PI } = Math; +const rnd = () => Math.random(); +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; +function hash(i, j) { + let h = (Math.imul(i, 374761393) + Math.imul(j, 668265263)) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} +function hsv(h, s, v) { + h = (((h % 1) + 1) % 1) * 6; + const k = (n) => v * (1 - s * cl(min((n + h) % 6, 4 - ((n + h) % 6)), 0, 1)); + return [k(5), k(3), k(1)]; +} + +// d is feet past the foul line, x inches from the middle board. The lane +// narrows in perspective; the pin deck is drawn nearer, 4.7 rows a foot. +const Z0 = 255.8; +const sc = (d) => 246.6 / (d + Z0); +const RD = 4.7; +const D0 = -16; +const NT = 1312; +const YT = new Float32Array(NT + 1); +{ + const ramp = (d) => sm(57, 59.5, d); + let i1 = 0; + let i2 = 0; + for (let k = 0; k < 960; k++) { + const d = (k + 0.5) / 16; + i1 += (1 - ramp(d)) / (d + Z0) ** 2 / 16; + i2 += ramp(d) / 16; + } + // Foul line on row 106, head pin on row 36. + const G = (70 - RD * i2) / i1; + const rate = (d) => (G / (d + Z0) ** 2) * (1 - ramp(d)) + RD * ramp(d); + YT[1216] = 36; + for (let i = 1216; i < NT; i++) YT[i + 1] = YT[i] - rate(D0 + (i + 0.5) / 16) / 16; + for (let i = 1216; i > 0; i--) YT[i - 1] = YT[i] + rate(D0 + (i - 0.5) / 16) / 16; +} +const rowOf = (d) => { + const f = cl((d - D0) * 16, 0, NT - 0.001); + const i = f | 0; + return YT[i] + (YT[i + 1] - YT[i]) * (f - i); +}; +const DR = new Float32Array(H); +for (let y = 0, i = NT; y < H; y++) { + while (i > 0 && YT[i] < y) i--; + DR[y] = i >= NT ? 66 : D0 + (i + (YT[i] - y) / (YT[i] - YT[i + 1])) / 16; +} +// The pit edge and the row under the masking unit. +const PIT = round(rowOf(62.85)); +const MB = 11; +const proj = (x, d, h) => { + const s = sc(d); + return [CX + x * s, rowOf(d) - h * 0.85 * s, s]; +}; +const SPOT = []; +for (let r = 0; r < 4; r++) for (let i = 0; i <= r; i++) SPOT.push([(i - r / 2) * 12, r * 10.392]); +const bx = (b) => 20.75 - (b - 0.5) * 1.064; +const DOTS = [3, 5, 8, 11, 14, 26, 29, 32, 35, 37]; +// Pin radius in inches, base to crown. +const PROF = [1.0, 1.7, 2.38, 2.15, 1.6, 1.0, 0.92, 1.2, 1.15, 0.6]; +const HOLES = [[-0.3, 0.5, 0.81], [0.3, 0.5, 0.81], [0, -0.4, 0.92]]; +// Maple approach, maple heads, pine, maple pin deck. +const WOOD = [[234, 178, 112], [228, 168, 100], [212, 134, 68], [238, 188, 124]]; +const OILC = [0.4, 0.7, 1.4]; +const SEG = [0.45, 0.55, 0.8, 0.6, 0.6, 0.45]; + +// The still picture: house light colour A, blacklight glow F, wash share WM. +function paint(P) { + const A = (P.A = new Float32Array(N * 3)), F = (P.F = new Float32Array(N * 3)), WM = (P.WM = new Float32Array(N)); + const put = (x, y, a, f, wm = WM[y * W + x]) => { + if (x < 0 || x >= W || y < MB || y >= H) return; + const o = (y * W + x) * 3; + for (let c = 0; c < 3; c++) { + A[o + c] = a[c]; + F[o + c] = f[c]; + } + WM[y * W + x] = wm; + }; + for (let y = MB; y < H; y++) { + const d = DR[y], s = sc(d); + const lit = d < 57 ? 0.9 + 0.08 * cos((2 * PI * (d + 3)) / 15) : 1.12; + const oil = sm(44, 36, d) * (d > 0 ? 1 : 0); + for (let x = 0; x < W; x++) { + const X = (x - CX) / s, ax = abs(X); + if (y < PIT || (d > 57 && ax > 30.5)) { + // Kickbacks and the pit curtain. + const k = (x * 2 + y) % 11; + if (ax > 30.5) put(x, y, k < 2 ? [70, 70, 80] : [28, 28, 34], k < 1 ? [200, 30, 150] : k < 2 ? [60, 20, 140] : [14, 6, 34], 0); + else put(x, y, [10, 10, 12], [6, 3, 16], 0); + } else if (ax <= 20.75 || ax > 43.75 || (d < 0 && (ax <= 30 || ax > 34.5))) { + const far = ax > 43.75, lx = far ? ax - 64.5 : X, a = [0, 0, 0]; + let tone = 0; + for (const e of [-0.25, 0.25]) { + const k = floor((20.75 - lx - e / s) / 1.064); + const kind = d < 0 ? 0 : d < 15.2 + (k & 3) * 0.7 ? 1 : d < 56 + (k % 3) * 0.6 ? 2 : 3; + const t = 0.88 + 0.14 * hash(k + 40, kind) + 0.06 * (hash(k + 40, floor(d * 2.5) + 99) - 0.5); + for (let c = 0; c < 3; c++) a[c] += WOOD[kind][c] * t * (far ? 0.7 : 1) * lit * 0.5; + tone += t / 2; + } + // Ceiling lights mirrored in the oil. + const glare = far ? 0 : 70 * oil * (exp(-(((X - 9) / 3.5) ** 2)) + exp(-(((X + 9) / 3.5) ** 2))) * (0.5 + 0.5 * cos((2 * PI * d) / 15)) ** 2; + const u = (far ? 0.7 : 1) * (d < 0 ? 0.8 : 1) * tone; + let f = [22 * u, 8 * u, 46 * u]; + if (!far && d > 2 && d < 55 && hash(x, y * 7 + 3) < 0.014) f = hsv(hash(y, x), 0.7, 180); + put(x, y, a.map((v) => v + glare), f, far ? 0.4 : 1); + } else if (ax <= 30 || ax > 34.5) { + const g = ((ax <= 30 ? ax - 25.375 : ax - 39.125) / 4.625) ** 2, v = (0.4 + 0.6 * g) * lit; + put(x, y, [70 * v, 72 * v, 84 * v], g > 0.75 ? [30, 12, 80] : [8, 4, 22], 0.5); + } else put(x, y, [50 * lit, 38 * lit, 32 * lit], [10, 5, 20], 0); + } + } + for (let x = 0; x < W; x++) { + const ax = abs((x - CX) / sc(0)); + if (ax <= 30 || ax > 34.5) put(x, round(rowOf(0)), [16, 16, 18], [255, 30, 90]); + } + // Foul line, arrows, dots, range finders and pin spots. + for (let b = 5; b <= 35; b += 5) { + const dt = 16 - abs(20 - b) * 0.267, ty = round(rowOf(dt)), ax = round(CX + bx(b) * sc(dt)); + for (let j = 0; j < 4; j++) for (let i = -(j >> 1); i <= j >> 1; i++) put(ax + i, ty + j, [96, 34, 22], [255, 120, 24]); + } + for (const b of DOTS) put(round(CX + bx(b) * sc(7)), round(rowOf(7)), [96, 40, 26], [80, 255, 110]); + for (const b of [...DOTS, 20]) put(round(CX + bx(b) * sc(-12)), round(rowOf(-12)), [90, 50, 30], [70, 220, 100]); + for (let y = round(rowOf(40)); y <= round(rowOf(34)); y++) for (const b of [10, 30]) put(round(CX + bx(b) * sc(DR[y])), y, [100, 46, 30], [40, 190, 255]); + for (const [X, Z] of SPOT) put(round(CX + X * sc(60 + Z / 12)), round(rowOf(60 + Z / 12)), [70, 46, 30], [60, 40, 130]); +} + +const rack = (h) => SPOT.map(([X, Z]) => ({ X, Z, vx: 0, vz: 0, th: 0, om: 0, ph: 0, w: 0, h, vh: 0, st: h ? 4 : 0 })); + +export function setup(params) { + paint(params); + Object.assign(params, { + buf: new Float32Array(N * 3), + tx: new Float32Array(H), + tt: new Float32Array(H).fill(-99), + t: 0, ph: 0, tm: 1.5, first: true, ball: 1, full: true, fl: 0, mark: 0, seg: -1, pins: rack(0), B: null, + ra: 0, ax: [-1, 0, 0], hs: 46, zs: -9, pt: 0, path: [0, 0, 0, 0, 40], bd: -13, bh: 0, bt: 0, + m: 1, hook: 0, oil: 46, Hh: 0.1, U: 1, c1: [0, 1, 1], c2: [0, 0, 1], + }); +} + +const pathX = (P, d) => { + const [x0, xb, sb, a, db] = P.path; + return d < db ? x0 + sb * d : xb + sb * (d - db) - (a * (d - db) ** 2) / 2; +}; +const pathS = (P, d) => (d < P.path[4] ? P.path[2] : P.path[2] - P.path[3] * (d - P.path[4])); + +// Out toward the gutter until the oil thins, then the hook back. A spare ball +// moves the whole line onto the standing pins. +function throwBall(P) { + const h = P.hook, db = P.oil - 6, e = 60 - db; + let x60 = (h < 0.55 ? 0.4 + (2.3 * h) / 0.55 : 2.7 - (4.7 * (h - 0.55)) / 0.45) + (rnd() - 0.5) * 1.6; + let x0 = 12 - 24 * h; + const xs = x0 + ((x60 - x0) * db) / 60; + let xb = xs + (16 - xs) * min(1, h * 1.4); + const line = () => { + const sb = (xb - x0) / db; + return [sb, ((xb + e * sb - x60) * 2) / (e * e)]; + }; + const up = P.pins.filter((p) => p.st === 0); + if (P.ball === 2 && up.length) { + const z = min(...up.map((p) => p.Z)), front = up.filter((p) => p.Z < z + 11), [sb, a] = line(); + const shift = front.reduce((s, p) => s + p.X, 0) / front.length - x60 - ((sb - a * e) * z) / 12 + (rnd() - 0.5) * 2; + x0 += shift; + xb += shift; + x60 += shift; + } + x0 = cl(x0, -17, 17); + xb = cl(xb, -17, 17); + P.path = [x0, xb, ...line(), db]; + Object.assign(P, { ph: 1, bt: 0, bd: -13, ra: 0 }); + const b = 0.9 * h; + const k = [-cos(b), 0.3 * sin(b), sin(b)]; + P.ax = k.map((v) => v / hypot(...k)); +} + +// The swing brings the ball in from below the panel, then it rolls. +function roll(P, h) { + P.bt += h; + const y0 = rowOf(P.bd), v = 25 - 0.07 * P.bd; + if (P.bt < 0.45) { + P.bd = -13 + 30 * P.bt; + P.bh = 10 * (1 - P.bt / 0.45) ** 2; + } else { + P.bd += v * h; + P.bh = 0; + P.ra += v * 0.36 * h; + } + const d = P.bd, x = CX + pathX(P, max(0, d)) * sc(d); + for (let y = max(0, round(rowOf(d))); y <= round(y0) && y < H; y++) { + P.tx[y] = x; + P.tt[y] = P.t; + } + if (d >= 58.6) { + P.B = { X: pathX(P, d), Z: (d - 60) * 12, vx: pathS(P, d) * v, vz: 12 * v, h: 0, vh: 0, st: -1 }; + P.ph = 2; + P.pt = 0; + } +} + +function pts(p) { + if (p.th < 0.35) return [[p.X, p.Z, 0.5]]; + const L = 15 * sin(p.th); + return [0.12, 0.5, 0.88].map((f) => [p.X + cos(p.ph) * L * f, p.Z + sin(p.ph) * L * f, f]); +} + +function kick(p, dvx, dvz, f) { + p.vx += dvx; + p.vz += dvz; + if (p.st < 0) return; + const dv = sqrt(dvx * dvx + dvz * dvz); + if (p.st === 0) { + if (p.th < 0.05) p.ph = atan2(dvz, dvx); + p.om += dv / 7; + if (dv > 130) Object.assign(p, { vh: dv * 0.28, om: dv / 7 + 5, st: 1, th: max(p.th, 0.05) }); + } else p.w += (15 * sin(p.th) * (f - 0.5) * (cos(p.ph) * dvz - sin(p.ph) * dvx)) / 70; +} + +// Ball and pins are discs on the deck; pins rock, topple past 11 degrees, fly +// when hit hard and spin off the kickbacks. +function collide(P) { + const bodies = []; + if (P.B && P.B.h > -3) bodies.push([P.B, 15, 4.3, [[P.B.X, P.B.Z, 0.5]]]); + for (const p of P.pins) if (p.st < 2 && p.h < 14) bodies.push([p, 3.5, 2.4, pts(p)]); + for (let i = 0; i < bodies.length; i++) { + const [a, ma, ra, pa] = bodies[i]; + for (const [qx] of pa) { + const sg = sign(qx), lim = 30.5 - ra; + if (abs(qx) > lim) { + a.X -= qx - sg * lim; + if (a.vx * sg > 0) a.vx *= -0.5; + } + } + for (let j = i + 1; j < bodies.length; j++) { + const [b, mb, rb, pb] = bodies[j]; + const R = ra + rb, ia = 1 / ma, ib = 1 / mb; + for (const qa of pa) + for (const qb of pb) { + const dx = qb[0] - qa[0], dz = qb[1] - qa[1], dd = dx * dx + dz * dz; + if (dd >= R * R || dd < 1e-6) continue; + const D = sqrt(dd), nx = dx / D, nz = dz / D, pen = (R - D) / (ia + ib); + a.X -= nx * pen * ia; + a.Z -= nz * pen * ia; + b.X += nx * pen * ib; + b.Z += nz * pen * ib; + const vn = (b.vx - a.vx) * nx + (b.vz - a.vz) * nz; + if (vn >= 0) continue; + const J = (-1.5 * vn) / (ia + ib); + kick(a, -J * nx * ia, -J * nz * ia, qa[2]); + kick(b, J * nx * ib, J * nz * ib, qb[2]); + } + } + } +} + +function move(P, dt) { + const B = P.B; + if (B) { + B.X += B.vx * dt; + B.Z += B.vz * dt; + P.ra += B.vz * 0.03 * dt; + if (B.Z > 34.2) { + B.vh -= 386 * dt; + B.h += B.vh * dt; + if (B.Z > 44) B.vz = 0; + } + } + for (const p of P.pins) { + if (p.st >= 3) continue; + if (p.st === 2 || p.h > 0 || p.vh > 0) { + p.vh -= 386 * dt; + p.h += p.vh * dt; + p.th += p.om * dt; + if (p.st === 2) { + if (p.Z > 46) p.vz = 0; + if (p.h < -40) p.st = 3; + } else if (p.h <= 0) { + if (sin(p.th) < 0) p.ph += PI; + Object.assign(p, { h: 0, vh: p.vh < -80 ? -p.vh * 0.3 : 0, th: PI / 2, om: 0, st: 1 }); + } + } else if (p.st === 0) { + if (p.th > 0 || p.om > 0) { + p.om += 60 * sin(p.th - 0.2) * dt; + p.th += p.om * dt; + if (p.th < 0) { + p.th = -p.th; + p.om *= -0.4; + p.ph += PI; + if (abs(p.om) < 0.3) p.th = p.om = 0; + } + if (p.th > 0.2) p.st = 1; + } + } else if (p.th < PI / 2) { + p.om += 64 * sin(p.th) * dt; + p.th += p.om * dt; + if (p.th >= PI / 2) { + p.th = PI / 2; + p.om = 0; + } + } + if (p.st < 2 && p.h <= 0) { + const v = hypot(p.vx, p.vz), dec = (p.st ? 110 : 400) * dt, k = v > dec ? (v - dec) / v : 0; + p.vx *= k; + p.vz *= k; + p.w *= exp(-2.5 * dt); + } + p.X += p.vx * dt; + p.Z += p.vz * dt; + p.ph += p.w * dt; + if (p.st < 2 && p.h < 8 && p.Z + 7.5 * sin(p.th) * sin(p.ph) > 34.2) p.st = 2; + } +} + +function physics(P, h) { + for (let s = 0, n = ceil(h / 0.004); s < n; s++) { + move(P, h / n); + collide(P); + } +} + +function endPins(P) { + const up = P.pins.filter((p) => p.st === 0).length; + if (!up) { + P.fl = P.ball === 1 ? 1 : 0.8; + P.mark = P.ball; + } + Object.assign(P, { full: !up || P.ball === 2, ph: 3, tm: 0, seg: -1, B: null }); +} + +// Sweep drops, table lifts standing pins, sweep rakes the deadwood into the +// pit and returns, table sets the pins or a new rack, sweep lifts. +function sweep(P, h) { + let u = (P.tm += h), k = 0; + while (k < 6 && u > SEG[k]) u -= SEG[k++]; + if (k >= 6) return Object.assign(P, { ph: 0, tm: 0.7, ball: P.full ? 1 : 2, hs: 46 }); + const e = sm(0, 1, u / SEG[k]); + if (k !== P.seg) { + P.seg = k; + if (k === 4 && P.full) P.pins = rack(46); + if (k === 5) for (const p of P.pins) if (p.st === 4) p.st = p.h = 0; + } + if (k === 0) P.hs = 46 - 45 * e; + else if (k === 1) { + if (!P.full) for (const p of P.pins) if (p.st === 0 || p.st === 4) Object.assign(p, { st: 4, h: 46 * e, th: 0, om: 0, vx: 0, vz: 0 }); + } else if (k === 2) { + P.zs = -9 + 47 * e; + for (const p of P.pins) + if (p.st < 2 && p.h < 12 && p.Z < P.zs + 3) { + p.Z = P.zs + 3; + p.vz = max(p.vz, 0); + if (p.st === 0) Object.assign(p, { st: 1, th: 1.3, ph: PI / 2 }); + } + } else if (k === 3) P.zs = 38 - 47 * e; + else if (k === 4) { + for (const p of P.pins) if (p.st === 4) p.h = 46 * (1 - e); + } else P.hs = 1 + 45 * e; +} + +function throwNow(P) { + if (P.ph === 1) return; + if (P.ph >= 2) { + if (P.ph === 2) endPins(P); + P.pins = P.full ? rack(0) : P.pins.filter((p) => p.st === 0 || p.st === 4).map((p) => Object.assign(p, { st: 0, h: 0, th: 0, om: 0 })); + Object.assign(P, { ball: P.full ? 1 : 2, hs: 46, B: null }); + } + throwBall(P); +} + +function step(P, h) { + P.t += h; + P.fl = max(0, P.fl - h * 0.5); + let ph = h; + if (P.ph === 0) { + // The first ball goes at 1.5 s at any speed. + if ((P.tm -= P.first ? h / P.m : h) <= 0) { + P.first = false; + throwBall(P); + } + } else if (P.ph === 1) roll(P, h); + else if (P.ph === 2) { + // Pin action in slow motion at first. + ph = h * (0.45 + 0.55 * sm(0.5, 1, P.pt)); + P.pt += ph; + if (P.pt > 1.6) endPins(P); + } else sweep(P, h); + physics(P, ph); +} + +export function update(dt, input, params) { + const P = params; + const k = input.knobValues || [0, 2, 0, 0.06]; + P.c1 = hsv(0.52 + k[0], 0.9, 1); + P.c2 = hsv(0.66 + k[0], 0.7, 1); + P.m = cl(k[1], 0.2, 3) / 2; + P.hook = cl(k[2], 0, 1); + P.oil = 46 - 16 * P.hook; + const L = cl(k[3], 0, 1); + P.Hh = 0.1 + 0.9 * L * L; + P.U = 1 - L; + if (input.btnPressed && input.btnPressed[0]) throwNow(P); + step(P, cl(dt, 0, 0.1) * P.m); +} + +function add(buf, x, y, c, v) { + if (x < 0 || x >= W || y < MB || y >= H) return; + const o = (y * W + x) * 3; + for (let i = 0; i < 3; i++) buf[o + i] += c[i] * v; +} +function blend(buf, x, y, r, g, b, a) { + if (x < 0 || x >= W || y < MB || y >= H) return; + const o = (y * W + x) * 3; + [r, g, b].forEach((v, i) => (buf[o + i] += (v - buf[o + i]) * a)); +} + +// Capping lights that chase, follow the ball and light the gutters, and the +// projected trail. +function lights(P) { + const { buf, c1, t, U, fl, tx, tt } = P; + const db = P.ph === 1 ? P.bd : P.ph === 2 ? 70 : -99; + for (let y = round(rowOf(57)); y < H; y++) { + const d = DR[y], s = sc(d); + let v = 0.16 + 0.34 * max(0, sin(2 * PI * (d / 9 - t * 0.55))) ** 6; + if (db > -50) v += d < db ? 0.9 * exp((d - db) / 7) : exp(-(d - db) * 1.5); + v = (v + fl * (0.5 + 0.5 * sin(d * 0.7 - t * 14))) * 200 * (0.45 + 0.55 * U); + for (const sg of [-1, 1]) { + const xs = CX + sg * 32.25 * s, x = floor(xs), f = xs - x; + [0.2, 1 - f * 0.8, 0.2 + f * 0.8, 0.2].forEach((k, i) => add(buf, x + i - 1, y, c1, v * k)); + if (d > 0) for (let X = 21; X < 30; X += 1 / s) add(buf, round(CX + sg * X * s), y, c1, v * 0.4 * ((X - 20.75) / 9.25) ** 1.5); + } + const age = t - tt[y]; + if (age < 4) { + const I = min(1, age * 8) * exp(-age / 1.2) * 140 * U, w = 4.3 * s + 0.8; + for (let x = max(0, floor(tx[y] - w)); x <= min(W - 1, tx[y] + w); x++) { + const f = 1 - ((x - tx[y]) / w) ** 2; + if (f > 0) add(buf, x, y, c1, I * f); + } + } + } +} + +// Marbled shell and holes turning with the roll. +function drawBall(P, cx, cy, r, alpha, mirror, clip) { + const { buf, c1, c2, Hh, U } = P; + const [k0, k1, k2] = P.ax, ca = cos(P.ra), sa = sin(P.ra); + for (let y = floor(cy - r - 1); y <= cy + r + 1 && y < clip; y++) + for (let x = floor(cx - r - 1); x <= cx + r + 1; x++) { + const u = (x - cx) / r, v = (y - cy) / r, q = u * u + v * v; + const cov = cl((1 - sqrt(q)) * r + 0.5, 0, 1); + if (cov <= 0) continue; + const nz = sqrt(max(0, 1 - q)), ny = mirror ? v : -v; + const kd = (k0 * u + k1 * ny + k2 * nz) * (1 - ca); + const X = u * ca + (k1 * nz - k2 * ny) * sa + k0 * kd; + const Y = ny * ca + (k2 * u - k0 * nz) * sa + k1 * kd; + const Z = nz * ca + (k0 * ny - k1 * u) * sa + k2 * kd; + const sw = (0.5 + 0.5 * sin(3.2 * X + 2.4 * sin(2.6 * Y + 1.8 * Z) + 2 * Z * X)) ** 2; + let hole = 1; + for (const [hx, hy, hz] of HOLES) if (hx * X + hy * Y + hz * Z > 0.972) hole = 0.12; + const lit = (0.25 + 0.75 * max(0, -0.35 * u + 0.6 * ny + 0.72 * nz)) * Hh * 1.15 + U * (0.45 + 0.6 * nz); + const sp = mirror ? 0 : 230 * Hh * max(0, -0.2 * u + 0.5 * ny + 0.84 * nz) ** 24; + const col = [0, 1, 2].map((c) => (c1[c] * (1 - sw) * 0.9 + c2[c] * sw) * 255 * lit * hole + sp); + blend(buf, x, y, col[0], col[1], col[2], cov * (mirror ? alpha * max(0, 1 - (y - cy + r) / (2 * r)) : alpha)); + } +} + +// A pin's profile swept from base to crown, tilted by th toward ph. +function drawPin(P, p) { + const { buf, Hh, U } = P; + const [x0, y0, s] = proj(p.X, 60 + p.Z / 12, p.h - (abs(p.X) > 21 && p.st === 1 ? 2.5 : 0)); + const hx = cos(p.ph) * 15 * sin(p.th) * s; + const hy = (-sin(p.ph) * 15 * sin(p.th) * RD) / 12 - 15 * cos(p.th) * 0.85 * s; + const L2 = hx * hx + hy * hy, clip = p.Z > 34.2 ? PIT : H; + for (let y = floor(min(y0, y0 + hy) - 3); y <= max(y0, y0 + hy) + 3 && y < clip; y++) + for (let x = floor(min(x0, x0 + hx) - 3); x <= max(x0, x0 + hx) + 3; x++) { + const px = x - x0, py = y - y0; + const t = L2 > 0.01 ? cl((px * hx + py * hy) / L2, 0, 1) : 0; + const ex = px - t * hx, ey = py - t * hy, f = t * 9, i = min(8, f | 0); + const rw = (PROF[i] + (PROF[i + 1] - PROF[i]) * (f - i)) * s; + const cov = cl(rw - sqrt(ex * ex + ey * ey) + 0.5, 0, 1); + if (cov <= 0) continue; + const lit = (0.82 - (0.18 * ex) / (rw + 0.3)) * Hh; + if (t > 0.6 && t < 0.73) blend(buf, x, y, 215 * Hh + 255 * U, 30 * Hh + 40 * U, 40 * Hh + 150 * U, cov); + else blend(buf, x, y, 240 * lit + 180 * U, 240 * lit + 192 * U, 234 * lit + 255 * U, cov); + } +} + +function drawSweep(P) { + const { buf, Hh, U, hs, zs } = P; + const [xl, yb, s] = proj(-31, 60 + zs / 12, hs); + const th = 3.4 * s; + for (let y = floor(yb - th); y <= yb; y++) + for (let x = round(xl); x <= round(2 * CX - xl); x++) { + const top = y < yb - th + 1 ? 60 : 0; + const hz = ((x + 64) >> 1) % 3 === 0; + blend(buf, x, y, (140 + top) * Hh + (hz ? 255 : 40) * U, (142 + top) * Hh + (hz ? 210 : 20) * U, (152 + top) * Hh + (hz ? 40 : 70) * U, 1); + } + for (const x of [round(CX - 29.5 * s), round(CX + 29.5 * s)]) for (let y = MB; y < yb - th; y++) blend(buf, x, y, 80 * Hh + 50 * U, 80 * Hh + 30 * U, 92 * Hh + 120 * U, 1); +} + +// The masking unit, with the blacklight tube along its lip. +function hood(P) { + const { buf, Hh, U, c1, c2, fl, t } = P; + for (let y = 0; y < MB; y++) + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3, wy = 4.4 + 2.4 * sin(x * 0.11 + 0.8), b1 = max(0, 1 - abs(y - wy)); + const b2 = max(0, 1 - abs(y - wy - 2.5) * 1.3) * sm(4, 14, x), pulse = 0.55 + 0.45 * sin(x * 0.35 - t * 5); + const tw = hash(x, y) < 0.07 ? (0.5 + 0.5 * sin(t * 4 + hash(y, x) * 40)) * 150 * U : 0; + const fx = fl > 0 ? hsv(x / 40 - t * 2, 1, fl * 200) : [0, 0, 0]; + for (let c = 0; c < 3; c++) + buf[o + c] = + y === MB - 1 + ? 150 * Hh + [110, 50, 255][c] * U + : [20, 30, 88][c] * Hh * (0.85 + y * 0.03) + [12, 4, 30][c] * U + b1 * ([190, 220, 255][c] * Hh + c1[c] * 255 * U * pulse) + b2 * ([255, 140, 40][c] * Hh + c2[c] * 230 * U) + tw + fx[c]; + } + // X for a strike, a slash for a spare. + if (fl > 0.05) + for (let j = -3; j <= 3; j++) + for (const i of P.mark === 1 ? [j, -j] : [-j]) { + const o = ((5 + j) * W + 32 + i) * 3; + for (let c = 0; c < 3; c++) buf[o + c] = 255 * min(1, fl * 2); + } +} + +export function draw(display, params) { + const P = params; + const { buf, A, F, WM, Hh, U, c1 } = P; + // The lane lights wash the boards in slow waves; the oil glows. + for (let y = MB; y < H; y++) { + const d = DR[y], s = sc(d), wave = 16 + 9 * sin(2 * PI * (d / 14 - P.t * 0.5)); + for (let x = 0; x < W; x++) { + const i = y * W + x, o = i * 3, wm = WM[i] * wave * U; + const ax = abs(x - CX) / s; + const oil = WM[i] === 1 && d > 0 ? sm(P.oil + 3, P.oil - 4, d + ax * 0.2) * (64 - ax * ax * 0.08) * U : 0; + for (let c = 0; c < 3; c++) buf[o + c] = A[o + c] * Hh + F[o + c] * U + c1[c] * wm + oil * OILC[c]; + } + } + lights(P); + let ball = null; + if (P.ph === 1) ball = [pathX(P, max(0, P.bd)), P.bd, P.bh, H]; + else if (P.B && P.B.h > -14) ball = [P.B.X, 60 + P.B.Z / 12, P.B.h, P.B.Z > 34.2 ? PIT : H]; + let bs = null; + if (ball) { + const [x, d, h, clip] = ball; + const [cx, cy, s] = proj(x, d, 4.3 + h), fy = rowOf(d), r = 4.3 * s, R = r * 3.4; + bs = [cx, cy, r, clip, d]; + // Halo, shadow, reflection. + for (let y = floor(cy - R); y <= cy + R && y < clip; y++) + for (let x2 = floor(cx - R); x2 <= cx + R; x2++) { + const q = 1 - hypot(x2 - cx, (y - cy) * 1.2) / R; + if (q > 0) add(buf, x2, y, c1, q * q * 110 * U); + const e = ((x2 - cx) / (r * 1.1)) ** 2 + ((y - fy) / (r * 0.45)) ** 2; + if (e < 1 && clip === H && y >= MB && y < H && x2 >= 0 && x2 < W) + for (let c = 0; c < 3; c++) buf[(y * W + x2) * 3 + c] *= 1 - 0.55 * Hh * (1 - e) * exp(-h / 6); + } + if (d > 0 && d < P.oil + 2) drawBall(P, cx, 2 * fy - cy + h * 1.7 * s, r, 0.15 + 0.4 * sm(P.oil + 2, P.oil - 8, d), true, H); + } + // Deck objects far to near. + const items = P.pins.filter((p) => p.st < 3).map((p) => [p.Z + (p.st === 1 ? 7 * sin(p.ph) : 0), p]); + if (P.hs < 45) items.push([P.zs - 0.5, 0]); + if (bs && bs[4] > 57) items.push([(bs[4] - 60) * 12, 1]); + items.sort((a, b) => b[0] - a[0]); + for (const [, it] of items) { + if (it === 0) drawSweep(P); + else if (it === 1) drawBall(P, bs[0], bs[1], bs[2], 1, false, bs[3]); + else drawPin(P, it); + } + if (bs && bs[4] <= 57) drawBall(P, bs[0], bs[1], bs[2], 1, false, bs[3]); + hood(P); + for (let p = 0, o = 0; p < N; p++, o += 3) display.setPixel(p % W, (p / W) | 0, min(255, buf[o]), min(255, buf[o + 1]), min(255, buf[o + 2])); +} diff --git a/incoming/pattern-study/patterns/circulation.js b/incoming/pattern-study/patterns/circulation.js new file mode 100644 index 000000000..f0c45faf8 --- /dev/null +++ b/incoming/pattern-study/patterns/circulation.js @@ -0,0 +1,480 @@ +// ===== Patternflow pattern ===== +// Title: Circulation +// Family: body +// Idea: A standing figure drawn head to feet like an anatomical chart, whose heart beats lub-dub and sends each pulse of red blood out along the arteries to the head, hands and feet while blue blood flows back up the veins and the lungs flush pink. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after the anatomical wall charts of the human circulatory system +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Chart=0..1, Rate=1..5, Detail=1..4, Effort=0..1 +// +// Knob 1: Chart. The red and blue anatomical chart at 0, an angiogram with the skeleton showing at 0.5, a thermal camera at 1. +// Knob 2: Rate. The resting heart rate, 36 a minute at 1, 72 at 2, 180 at 5; the faster, the closer the pulses follow each other. +// Knob 3: Detail. How far the vessels branch, from the named arteries and veins drawn bold at 1 to the capillaries at 4. +// Knob 4: Effort. From rest to a hard run: the heart beats faster and harder, the breath deepens and the arms and legs flush with blood. +// Buttons: K1 tap startles the figure: one hard beat at once and a rush of adrenaline that races the heart for a few seconds. + +const W = 64, H = 128, N = W * H; +const { min, max, abs, exp, sin, cos, atan2, floor, ceil, round, hypot, PI } = Math; +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); +const smooth = (a, b, v) => ((v = clamp((v - a) / (b - a), 0, 1)), v * v * (3 - 2 * v)); +const g2 = (z) => exp(-z * z); +const rnd = () => Math.random(); +const F32 = (n) => new Float32Array(n); + +// Pulse tables, 100 steps a second; the arterial pulse and the venous return +// run at CA and CV LEDs a second. +const KS = 100, KN = 520, CA = 80, CV = 30; +// Vessel kinds: 0 artery, 1 vein, 2 and 3 the lungs' arteries and veins. +// Branch levels 1..4: spacing, length, radius; the turns a blocked branch tries. +const GAP = [0, 7, 3, 2.2, 1.8], LEN = [0, 9, 7, 5, 3.5], RAD = [0, 0.52, 0.43, 0.37, 0.33]; +const TURN = [0, 0.5, -0.5, 1, -1]; + +// The trunk's half-width row by row, from the base of the neck to the crotch. +const TY = [22.5, 24, 25.5, 27.5, 31, 36, 44, 51, 57, 63, 67, 70.5]; +const TW = [4.6, 9.5, 13, 14.3, 13.8, 13, 11.6, 10.3, 11, 12.4, 11.2, 6]; +// Arm and leg axes on the viewer's left, x y radius per joint. +const ARM = [18.3, 28.4, 3.4, 12.8, 47, 2.6, 8.6, 64.5, 2.2, 7.6, 70.5, 2.1, 6.6, 76, 1.3]; +const LEG = [26.4, 66, 5.9, 26.2, 94, 3.7, 25.8, 119.5, 2, 24, 124.8, 2.2]; +// Capsules x1 y1 r1 x2 y2 r2 of the neck, an ear, a thumb, a calf and the +// limbs; the body is folded at the middle, so they stand for both sides. +const CAPS = [[31.5, 14, 4, 31.5, 24, 4.2], [25.1, 8.8, 1.2, 25.3, 11.6, 1], [7, 66.6, 1, 5.3, 70.6, 0.8], [25.6, 98, 3.5, 25.6, 110, 2.6]]; +for (const L of [ARM, LEG]) for (let i = 0; i < L.length - 3; i += 3) CAPS.push([0, 1, 2, 3, 4, 5].map((j) => L[i + j])); +// The lungs: centre, radii, hilum. The heart's centre. +const LUNG = [[25, 38.6, 5.4, 11.6, 27.6, 34], [38.2, 39.2, 5.1, 11, 35.6, 34.5]]; +const HX = 34.4, HY = 38.4; +// Artery and vein colours from the big trunks to the capillaries. +const RED = [[238, 30, 40], [238, 30, 40], [232, 38, 70], [215, 46, 110], [190, 56, 150]]; +const BLUE = [[46, 70, 240], [46, 70, 240], [66, 68, 236], [96, 66, 226], [128, 66, 214]]; +// The readout's 3x5 letters, five rows of three bits, and its heart. +const GLYPH = { + 0: 0x7b6f, 1: 0x2c97, 2: 0x73e7, 3: 0x73cf, 4: 0x5bc9, 5: 0x79cf, 6: 0x79ef, 7: 0x7292, 8: 0x7bef, 9: 0x7bcf, + B: 0x6bae, P: 0x6ba4, M: 0x5fed, +}; +const HEART = [10, 31, 31, 14, 4]; + +// A colour table from stops of position and r g b. +const lut = (s) => + Uint8Array.from({ length: 768 }, (_, n) => { + const v = ((n / 3) | 0) / 255, c = n % 3; + let j = 0; + while (j < s.length - 8 && v > s[j + 4]) j += 4; + return s[j + 1 + c] + (s[j + 5 + c] - s[j + 1 + c]) * clamp((v - s[j]) / (s[j + 4] - s[j]), 0, 1); + }); +// The angiogram and the thermal chart are one value through a colour table; +// the weights are in the order pal() reads them. +const CHARTS = [ + null, + [lut([0, 0, 0, 0, 0.15, 8, 20, 38, 0.4, 42, 80, 112, 0.7, 150, 196, 220, 1, 240, 252, 255]), 0.04, 0.1, 0.07, 0.14, 0, 0.06, 0, 0, 0.74, 0.05, 0.07, 0.5, 0.5, 0.36, 0.2, 0.5, 0.3, 0.95], + [lut([0, 0, 0, 10, 0.2, 34, 0, 86, 0.4, 145, 0, 140, 0.6, 232, 64, 36, 0.8, 255, 172, 0, 1, 255, 255, 210]), 0.07, 0.2, -0.05, 0.08, 0.3, 0.2, 0.18, 0.12, 0, 0.5, 0.16, 0.7, 0.3, 0.56, 0.12, 0.8, 0.2, 1], +]; + +// Distance to a segment, less a radius running r0..r1 along it. +function seg(x, y, x0, y0, x1, y1, r0, r1) { + const dx = x1 - x0, dy = y1 - y0; + const t = clamp(((x - x0) * dx + (y - y0) * dy) / (dx * dx + dy * dy || 1e-6), 0, 1); + return hypot(x - x0 - dx * t, y - y0 - dy * t) - r0 - (r1 - r0) * t; +} + +// Signed distance to the skin, negative inside. +function sdBody(x, y) { + let d = (hypot((x - 31.5) / 6.4, (y - 9.8) / 8.6) - 1) * 6.4, i = 0; + while (i < TY.length - 2 && y > TY[i + 1]) i++; + const u = clamp((y - TY[i]) / (TY[i + 1] - TY[i]), 0, 1); + d = min(d, max(abs(x - 31.5) - TW[i] - (TW[i + 1] - TW[i]) * u, TY[0] - y, y - TY[TY.length - 1])); + const xr = x < 31.5 ? x : 63 - x; + for (const c of CAPS) d = min(d, seg(xr, y, c[0], c[1], c[3], c[4], c[2], c[5])); + return d; +} + +function lung(x, y, j, br) { + const L = LUNG[j], ry = L[3] * (1 + 0.07 * br); + const dy = (y - L[1] - (ry - L[3]) * 0.8) / ry; + return hypot(((x - L[0]) / (L[2] * (1 + 0.04 * br))) * (1 + 0.35 * max(0, -dy)), dy); +} + +// The heart in its own frame: v runs from the base to the apex, u across. +function heart(P, x, y, s) { + const dx = x - HX, dy = y - HY, k = s * 1.12; + const v = (P.hv = (dx * 0.55 + dy * 0.835) / k), u = (P.hu = (dx * 0.835 - dy * 0.55) / k); + return v < 0 ? hypot(u / 5, v / 4.6) : hypot(u / (5 * (1 - 0.8 * (v / 7) ** 1.5) + 0.05), v / 7); +} + +// Points along a limb axis: t counts joints, f is the share of the radius off +// the axis, toward the outside of the limb when positive. +function limb(L, list) { + const out = []; + for (let i = 0; i < list.length; i += 2) { + const t = list[i], k = min(floor(t), L.length / 3 - 2), u = t - k; + const [x0, y0, r0, x1, y1, r1] = L.slice(k * 3, k * 3 + 6); + const dx = x1 - x0, dy = y1 - y0, l = hypot(dx, dy), r = (r0 + (r1 - r0) * u) * list[i + 1]; + out.push(x0 + dx * u - (dy / l) * r, y0 + dy * u + (dx / l) * r); + } + return out; +} + +export function setup(params) { + const P = params; + Object.assign(P, { t: 0, started: false, beats: [], ph: 0, br: 0, brPh: 0, adr: 0, det: -1, eh: 0, last: -9, f: {}, TA: F32(KN) }); + Object.assign(P, { TV: F32(KN), TP: F32(KN), hud: F32(N), c0: F32(3), c1: F32(3) }); + body(P), bones(P), vessels(P); +} + +// Skin mask, rim, depth, muscle, and the way through the body from the heart. +function body(P) { + const [m, rim, dep, mus] = ["m", "rim", "dep", "mus"].map((k) => (P[k] = F32(N))); + const geo = (P.geo = F32(N).fill(1e4)), room = (P.room = new Uint8Array(N)); + P.tex = F32(N).map(() => 0.75 + 0.35 * rnd()); + for (let p = 0; p < N; p++) { + const x = p % W, y = (p / W) | 0, sd = sdBody(x, y), lu = min(lung(x, y, 0, 0), lung(x, y, 1, 0)); + // Room to grow: 1 in the body but not the lungs or heart, 2 in the lungs. + room[p] = lu < 0.9 ? 2 : sd < -0.6 && lu > 1.1 && hypot(x - HX, y - HY) > 6.5 ? 1 : 0; + [m[p], rim[p], dep[p]] = [clamp(0.5 - sd, 0, 1), clamp(1 - abs(sd + 0.7) / 1.1, 0, 1), clamp(-sd / 5, 0, 1)]; + mus[p] = m[p] * (y > 68 ? 1 : smooth(12.5, 14.5, abs(x - 31.5))); + } + geo[38 * W + 34] = 0; + const NB = [1, 1, W, 1, W + 1, 1.414, W - 1, 1.414]; + for (let pass = 0; pass < 4; pass++) + for (let q = 0, sg = pass & 1 ? 1 : -1; q < N; q++) { + const p = sg > 0 ? N - 1 - q : q; + if (m[p] > 0.2) + for (let j = 0; j < 8; j += 2) if (geo[p + sg * NB[j]] + NB[j + 1] < geo[p]) geo[p] = geo[p + sg * NB[j]] + NB[j + 1]; + } + P.core = Float32Array.from(geo, (g) => exp(-g / 55)); +} + +// The skeleton for the angiogram: radius, brightness and a polyline for each +// bone of the left half, which is mirrored. +const BONES = [ + [0.5, 0.8, 26.5, 12.6, 26.9, 15.4, 28.6, 17.3, 31.6, 17.8], [0.55, 0.8, 31.6, 25.4, 25, 24.6, 19.5, 26.4], [0.7, 0.7, 31.6, 26.6, 31.6, 41], + [0.6, 0.75, 31.6, 63.5, 27.5, 62.4, 23.2, 59.2, 21, 60.5, 21.6, 64.5, 25, 68.8, 29.5, 69.8, 31.6, 69.6], + [1, 0.9, 26.2, 66.8, 26.4, 70, 26.2, 92], [0.85, 0.9, 26.6, 96, 26.4, 118.5], [0.45, 0.7, 24, 97, 24.4, 118.5], + [0.7, 0.75, 25.8, 120.5, 24.6, 124.5, 23.2, 126.2], [0.95, 0.9, 18.3, 28.4, 12.9, 46.6], + [0.55, 0.8, 12.2, 47.8, 7.9, 64.2], [0.55, 0.8, 13.6, 47.6, 9.5, 64.4], [0.45, 0.7, 8.6, 65.6, 6.4, 76], +]; +function bones(P) { + const bone = (P.bone = F32(N)), skull = [0.5, 0.9], all = [skull, ...BONES]; + for (let a = 0; a < 2; a += 0.2) skull.push(31.5 - 5.6 * sin(a), 9.6 - 7.6 * cos(a)); + for (let y = 18.5; y < 64; y += 2.7) all.push([0.8, 0.85, 30.8, y, 32.2, y]); + for (let i = 0; i < 8; i++) { + const y = 27.6 + i * 2.5, w = 21.7 + i * 0.3; + all.push([0.42, 0.65, 30.6, y, 26, y - 0.9, w, y + 1.2, w + 0.2, y + 3.4, w + 3 + i * 0.4, y + 5.2, 29.8 - i * 0.2, y + 6.2 - i * 0.2]); + } + for (const [r, v, ...q] of all) + for (let i = 2; i < q.length; i += 2) { + const [x0, y0, x1, y1] = q.slice(i - 2, i + 2); + for (let y = max(0, floor(min(y0, y1) - r - 1)); y <= min(H - 1, ceil(max(y0, y1) + r + 1)); y++) + for (let x = max(0, floor(min(x0, x1) - r - 1)); x <= min(31, ceil(max(x0, x1) + r + 1)); x++) { + const a = clamp(0.5 - seg(x, y, x0, y0, x1, y1, r, r), 0, 1) * v, p = y * W + x; + if (a > bone[p]) bone[p] = bone[p + 63 - 2 * x] = a; + } + } +} + +// The named vessels; four levels of branches grow between them. +function vessels(P) { + const ves = (P.ves = []); + const add = (kind, parent, r, pts) => (ves.push({ kind, parent, r, pts, lv: 0 }), ves[ves.length - 1]); + const pair = (kind, pr, pl, r, pts) => [add(kind, pr, r, pts), add(kind, pl, r, pts.map((v, i) => (i & 1 ? v : 63 - v)))]; + // Arteries, from the aorta out. + const ao = add(0, null, 1.3, [33.4, 35.5, 32.4, 31.6, 32.7, 29, 34, 27.6, 35.8, 28.4, 36.1, 31, 34.9, 35, 33.9, 40, 33.4, 47, 33, 55, 31.5, 62.5]); + const bc = add(0, ao, 1, [32.9, 28.3, 30.8, 26.9, 29.6, 25.6]); + const carR = add(0, bc, 0.8, [29.6, 25.6, 30, 21.5, 30, 17.5, 29.6, 15]); + const carL = add(0, ao, 0.8, [34.4, 27.8, 33.4, 24.5, 33, 21.5, 33, 17.5, 33.4, 15]); + pair(0, carR, carL, 0.6, [29.6, 15, 29.1, 12.2, 29.6, 9.6, 30.8, 8]); + pair(0, carR, carL, 0.55, [29.6, 15.4, 27.6, 13.6, 26.3, 10.2, 26.6, 6, 28.4, 3]); + const subR = add(0, bc, 0.9, [29.6, 25.6, 26.2, 26, 22.6, 26.7, 19.8, 28]); + const subL = add(0, ao, 0.9, [35.8, 28.4, 37.8, 26.6, 40.6, 26.8, 43.2, 28]); + const [brR, brL] = pair(0, subR, subL, 0.8, [19.8, 28, ...limb(ARM, [0.15, -0.3, 0.5, -0.28, 0.85, -0.22, 1, -0.1])]); + pair(0, brR, brL, 0.55, limb(ARM, [1, -0.1, 1.2, 0.35, 1.6, 0.42, 2, 0.42, 2.3, 0.15, 2.5, -0.2])); + const [ilR, ilL] = pair(0, ao, ao, 1, [31.5, 62.5, 30, 64.2, 28.6, 66.3]); + const [feR, feL] = pair(0, ilR, ilL, 0.85, [28.6, 66.3, ...limb(LEG, [0.08, -0.32, 0.4, -0.3, 0.75, -0.24, 1, -0.12])]); + pair(0, feR, feL, 0.55, limb(LEG, [1, -0.12, 1.25, -0.32, 1.6, -0.36, 1.95, -0.4, 2.3, -0.15, 2.75, 0.1])); + pair(0, feR, feL, 0.55, limb(LEG, [1.02, 0, 1.2, 0.36, 1.6, 0.42, 2, 0.32, 2.45, 0.2, 2.95, 0.35])); + // Veins, from the venae cavae out; the sinus over the brain joins the jugulars. + const svc = add(1, null, 1.2, [30.3, 35.6, 29.9, 31, 29.9, 27.4]); + const bvR = add(1, svc, 1, [29.9, 27.4, 28.2, 26, 26.6, 25.4]); + const bvL = add(1, svc, 0.9, [29.9, 27.4, 32.4, 26.3, 34.8, 25.6, 36.4, 25.4]); + pair(1, bvR, bvL, 0.8, [28.4, 25.8, 28.4, 21.5, 28.5, 17.5, 28.3, 14.4, 27.6, 11.6, 27.9, 7.2, 29.4, 4.6, 31.5, 3.9]); + const [axR, axL] = pair(1, bvR, bvL, 0.9, [26.6, 25.4, 23.2, 26.1, 20.4, 27.8]); + pair(1, axR, axL, 0.55, [20.4, 27.8, ...limb(ARM, [0.2, -0.68, 0.6, -0.64, 1, -0.68, 1.5, -0.78, 2, -0.78, 2.25, -0.45])]); + pair(1, axR, axL, 0.5, [21.2, 27, ...limb(ARM, [0.08, 0.5, 0.5, 0.64, 1, 0.7, 1.5, 0.78, 2, 0.78, 2.25, 0.5])]); + const ivc = add(1, null, 1.2, [30.5, 42.5, 30.8, 48, 31, 56, 31.4, 62.8]); + const [icR, icL] = pair(1, ivc, ivc, 1, [31.4, 62.8, 30, 65.4, 29.8, 67.2]); + pair(1, icR, icL, 0.8, [29.8, 67.2, ...limb(LEG, [0.1, -0.54, 0.4, -0.54, 0.75, -0.46, 1, -0.3, 1.3, -0.05, 1.7, 0, 2, 0.02, 2.4, -0.1])]); + pair(1, icR, icL, 0.5, [30, 68, ...limb(LEG, [0.15, -0.8, 0.5, -0.82, 1, -0.82, 1.5, -0.86, 2, -0.85, 2.4, -0.5, 2.8, -0.3])]); + // The lungs: blue blood out along the pulmonary arteries, red back. + const pt = add(2, null, 1.1, [32.4, 37, 31.6, 33.4, 31.8, 31.4]); + pair(2, pt, pt, 0.8, [31.8, 31.4, 29, 31.8, 26.4, 33.4, 25, 35]); + add(3, null, 0.8, [35, 36.2, 31.4, 37, 28, 37.4, 25.6, 38.6]); + add(3, null, 0.8, [35.6, 35.8, 37.6, 36.6, 39.2, 37.8]); + for (const v of ves) samples(v); + grow(P); +} + +// Resample a vessel every 0.4 LED with its distance from the heart. +function samples(v) { + const { pts, parent: q } = v; + let d = v.d0 || 0, best = 1e9; + if (q && v.d0 === undefined) + for (let i = 0; i < q.x.length; i++) { + const e = hypot(q.x[i] - pts[0], q.y[i] - pts[1]); + if (e < best) [best, d] = [e, q.d[i] + e]; + } + [v.x, v.y, v.d] = [[pts[0]], [pts[1]], [d]]; + for (let i = 2; i < pts.length; i += 2) { + const x0 = pts[i - 2], y0 = pts[i - 1], dx = pts[i] - x0, dy = pts[i + 1] - y0, l = hypot(dx, dy), n = max(1, ceil(l / 0.4)); + for (let k = 1; k <= n; k++) v.x.push(x0 + (dx * k) / n), v.y.push(y0 + (dy * k) / n), v.d.push(d + (l * k) / n); + d += l; + } +} + +// Branches grow level by level, shuffled so arteries and veins take their room +// in turn; a branch bends away from the skin or another vessel, or stops. +function grow(P) { + const occ = new Uint8Array(N); + const mark = (v) => v.x.forEach((x, i) => (occ[round(v.y[i]) * W + round(x)] = 1)); + P.ves.forEach(mark); + let front = P.ves.slice(); + for (let lv = 1; lv <= 4; lv++) { + const next = []; + front.sort(() => rnd() - 0.5); + for (const v of front) { + const n = v.x.length, want = v.kind >= 2 ? 2 : 1; + for (let s = GAP[lv] * rnd(); s < n * 0.4 - 0.8; s += GAP[lv] * (0.6 + 0.8 * rnd())) { + const i = min(n - 2, round(s / 0.4)), len = LEN[lv] * (0.5 + rnd()); + let a = atan2(v.y[i + 1] - v.y[i], v.x[i + 1] - v.x[i]) + (rnd() < 0.5 ? -1 : 1) * (0.35 + 0.6 * rnd()); + let x = v.x[i], y = v.y[i]; + const pts = [x, y]; + for (let st = 1; st <= len; st++) { + a += (rnd() - 0.5) * 0.7; + const turn = TURN.find((tu) => { + const nx = x + cos(a + tu), ny = y + sin(a + tu); + const q = round(ny) * W + round(nx); + return P.room[q] === want && (st < 2 + v.r || !occ[q]); + }); + if (turn === undefined) break; + a += turn; + pts.push((x += cos(a)), (y += sin(a))); + } + if (pts.length < 6) continue; + const b = { kind: v.kind, parent: v, r: RAD[lv], pts, lv, d0: v.d[i] }; + samples(b), mark(b), next.push(b); + } + } + P.ves.push(...(front = next)); + } +} + +// The vessels Detail shows in a red and a blue layer: weight, key (seconds +// after a beat), level, pulse or flow. Low detail draws the trunks bold. +function raster(P, det) { + for (const k of ["wR", "kR", "lR", "mR", "wB", "kB", "lB", "mB"]) (P[k] = P[k] || F32(N)).fill(0); + for (const v of P.ves) { + const vis = clamp(((det - 1) * 7) / 6 + 1.5 - v.lv, 0, 1); + if (vis <= 0) continue; + const red = v.kind === 0 || v.kind === 3, flow = v.kind & 1; + const [w, kk, l, mo] = red ? [P.wR, P.kR, P.lR, P.mR] : [P.wB, P.kB, P.lB, P.mB]; + const r = v.r * (v.lv ? 1 : 1.25 - 0.15 * (det - 1)), g = v.lv ? 0 : 0.9, e0 = r + g + 0.5; + for (let i = 0; i < v.x.length; i++) { + const x = v.x[i], y = v.y[i], key = flow ? max(0, 0.3 + (125 - v.d[i]) / CV) : v.d[i] / CA; + for (let py = max(0, ceil(y - e0)); py <= min(H - 1, floor(y + e0)); py++) + for (let px = max(0, ceil(x - e0)); px <= min(W - 1, floor(x + e0)); px++) { + const e = hypot(px - x, py - y), p = py * W + px; + let a = clamp(r + 0.5 - e, 0, 1); + if (g) a = max(a, 0.16 * clamp(e0 - e, 0, 1)); + if ((a *= vis) > w[p]) [w[p], kk[p], l[p], mo[p]] = [a, key, v.lv, flow]; + } + } + } +} + +export function update(dt, input, params) { + const P = params; + if (!P.ves) setup(P); + const k = input.knobValues || [0, 2, 1, 0.06]; + dt = clamp(dt || 0, 0, 0.1); + P.chart = clamp(k[0], 0, 1); + const rate = clamp(k[1], 1, 5), det = clamp(k[2], 1, 4), E = (P.E = clamp(k[3], 0, 1)); + if (abs(det - P.det) > 0.02) raster(P, (P.det = det)); + let kick = false; + if (input.btnPressed && input.btnPressed[0]) { + P.adr = 1; + if (P.t - P.last > 0.28) [P.ph, kick] = [1, true]; + } + P.adr *= exp(-dt / 5); + P.bpm = min(200, 36 * rate * (1 + 0.6 * E * E) + 75 * P.adr); + const T = (P.T = 60 / P.bpm); + P.tsys = clamp(0.16 + 0.17 * T, 0.2, 0.36); + P.stroke = 1 + 0.45 * E + 0.5 * P.adr; + if (!P.started) { + // Open as if the heart had been beating for a while. + P.started = true; + for (let i = 12; i >= 1; i--) P.beats.push([-(i - 0.6) * T, P.stroke]); + [P.ph, P.last, P.brPh] = [0.6, -0.6 * T, 0.15]; + } + P.t += dt; + if ((P.ph += dt / T) >= 1) { + P.last = P.t - (P.ph = kick ? 0 : P.ph % 1) * T; + P.beats.push([P.last, P.stroke * (kick ? 1.5 : 1)]); + } + while (P.beats.length && P.t - P.beats[0][0] > 5.6) P.beats.shift(); + P.brPh += dt / max(1, 4.4 - 2.6 * E - 1.2 * P.adr); + P.br = (0.5 - 0.5 * cos(2 * PI * P.brPh)) * (0.55 + 0.45 * E + 0.4 * P.adr); + P.eh = (P.eh + dt * 16) % 18; + tables(P), readout(P); +} + +// Each beat's waves by the time since it: TA the sharp arterial pulse with its +// dicrotic notch, TP the softer flush of the tissue, TV the venous swell. +function tables(P) { + const { TA, TV, TP, t } = P, td = 0.13 * (1 + 0.4 * P.E); + TA.fill(0), TV.fill(0), TP.fill(0); + const span = (age, a, b, fn) => { + for (let k = max(0, ceil((age - a) * KS)); k <= min(KN - 1, floor((age + b) * KS)); k++) fn(k, age - k / KS); + }; + for (const [tb, a] of P.beats) { + const age = t - tb; + span(age, 0.9, 0, (k, s) => (TA[k] += a * ((1 - exp(-s / 0.02)) * exp(-s / td) + 0.22 * g2((s - 0.3) / 0.05)))); + span(age, 1.4, 0, (k, s) => (TP[k] += a * (1 - exp(-s / 0.06)) * exp(-s / 0.26))); + span(age, 0.4, 0.4, (k, s) => (TV[k] += a * g2(s / 0.13))); + } +} + +const look = (T, key) => { + const f = key * KS, i = f | 0; + return i < KN - 1 ? T[i] + (T[i + 1] - T[i]) * (f - i) : 0; +}; + +function ecgAt(P, tt) { + let v = 0; + for (const [tb] of P.beats) { + const s = tt - tb; + if (s > -0.3 && s < 0.6) + v += 0.13 * g2((s + 0.16) / 0.03) - 0.12 * g2((s + 0.022) / 0.01) + g2(s / 0.016) - 0.28 * g2((s - 0.03) / 0.012) + 0.3 * g2((s - P.tsys + 0.07) / 0.055); + } + return v; +} + +// The monitor beside the feet: an ECG sweep on the left, the rate on the right. +function readout(P) { + const { hud } = P; + hud.fill(0, 100 * W, 120 * W); + for (let y = 104; y < 117; y += 3) for (let x = 2; x < 19; x += 3) hud[y * W + x] = 0.12; + for (let c = 0; c < 18; c++) { + const a = (P.eh - c + 18) % 18; + if (a > 16.4) continue; + let lo = 999, hi = 0; + for (let s = 0; s <= 6; s++) { + const yy = round(112 - 8 * ecgAt(P, P.t - (a + s / 6) / 16)); + [lo, hi] = [min(lo, yy), max(hi, yy)]; + } + for (let y = max(101, lo); y <= min(118, hi); y++) hud[y * W + c + 1] = a < 1 ? 1 : 0.95 - (0.55 * a) / 18; + } + const fl = 0.45 + 0.55 * g2((P.t - P.last - 0.05) / 0.08); + HEART.forEach((row, j) => { + for (let i = 0; i < 5; i++) if ((row >> (4 - i)) & 1) hud[(105 + j) * W + 46 + i] = fl; + }); + const put = (s, x0, y0, b) => { + for (let n = 0; n < s.length; n++) + for (let q = 0; q < 15; q++) if ((GLYPH[s[n]] >> (14 - q)) & 1) hud[(y0 + ((q / 3) | 0)) * W + x0 + n * 4 + (q % 3)] = b; + }; + const txt = String(round(P.bpm)); + put(txt, 63 - txt.length * 4, 105, 0.95); + put("BPM", 51, 112, 0.55); +} + +export function draw(display, params) { + const P = params; + if (!P.wR) return; + const { m, rim, dep, core, geo, bone, tex, mus, hud, TA, TV, TP, wR, kR, lR, mR, wB, kB, lB, mB, f, c0, c1, E, br } = P; + const fc = P.chart * 2, i0 = min(1, floor(fc)), u = smooth(0.3, 0.7, fc - i0); + // The heart squeezes in systole and flashes on the first sound, less on the second. + const since = P.t - P.last, sys = since < P.tsys ? sin((PI * since) / P.tsys) ** 0.7 : 0; + const s = 1 - (0.1 + 0.08 * E + 0.05 * P.adr) * sys; + const flash = g2((since - 0.03) / 0.06) + 0.6 * g2((since - P.tsys) / 0.045); + // The more capillaries drawn, the more the skin flushes as a pulse passes. + const bla = 0.3 + 0.24 * (P.det - 1); + for (let y = 0, p = 0; y < H; y++) + for (let x = 0; x < W; x++, p++) { + Object.assign(f, { y, m: m[p], rim: rim[p], dep: dep[p], core: core[p], mus: mus[p], bone: bone[p], hud: hud[p], lw: 0, hw: 0, ho: 0 }); + f.bl = m[p] > 0 ? look(TP, geo[p] / CA + 0.04) * (bla + 0.3 * E + 0.7 * E * mus[p]) : 0; + if (x > 17 && x < 46 && y > 24 && y < 54) { + const e0 = lung(x, y, 0, br), e1 = lung(x, y, 1, br), e = min(e0, e1), L = LUNG[e0 < e1 ? 0 : 1]; + if (e < 1.05) { + f.lw = clamp((1.03 - e) * 7, 0, 1) * m[p]; + f.lrim = clamp(1 - abs(e - 0.92) / 0.1, 0, 1); + f.ox = clamp(0.2 + 0.4 * br + 0.5 * look(TP, hypot(x - L[4], y - L[5]) / 40 + 0.06), 0, 1.2); + f.lt = tex[p]; + } + } + if ((f.wb = wB[p]) > 0) [f.bm, f.bp, f.lb] = [mB[p], look(mB[p] ? TV : TA, kB[p]), lB[p]]; + if ((f.wr = wR[p]) > 0) [f.rm, f.rp, f.lr] = [mR[p], look(mR[p] ? TV : TA, kR[p]), lR[p]]; + if (x > 24 && x < 45 && y > 27 && y < 49) { + const e = heart(P, x, y, s); + f.ho = clamp((1.3 - e) * 5, 0, 1); + if (e < 1.1) { + f.hw = clamp((1.02 - e) * 5, 0, 1); + f.hs = smooth(-0.9, 0.3, P.hu); + f.hr = clamp(1 - abs(e - 0.86) / 0.16, 0, 1); + f.hb = 0.62 + 0.45 * flash + 0.2 * sys - (abs(P.hu + 0.3) < 0.4 && P.hv > -2 ? 0.25 : 0); + } + } + if (i0 === 0) anat(f, c0); + else pal(f, CHARTS[1], c0, E); + if (u > 0) { + pal(f, CHARTS[i0 + 1], c1, E); + for (let c = 0; c < 3; c++) c0[c] += (c1[c] - c0[c]) * u; + } + display.setPixel(x, y, min(255, c0[0]), min(255, c0[1]), min(255, c0[2])); + } +} + +// Lerp o toward the colour c at brightness b by w; past full brightness it +// runs toward a hot pale core. +function tint(o, c, b, w) { + const k = min(b, 1), h = max(0, b - 1) * 0.9; + o[0] += (c[0] * k + 255 * h - o[0]) * w; + o[1] += (c[1] * k + 190 * h - o[1]) * w; + o[2] += (c[2] * k + 170 * h - o[2]) * w; +} + +// The anatomical chart: a pale ghost of a body on a slate ground. +function anat(f, o) { + const top = 1 - f.y / H, t = f.rim, bl = f.bl; + [o[0], o[1], o[2]] = [2 + 3 * top, 5 + 5 * top, 12 + 9 * top]; + if (f.m > 0) tint(o, [18 + 34 * t + 8 * f.dep + 80 * bl, 26 + 44 * t + 10 * f.dep + 14 * bl, 42 + 50 * t + 16 * f.dep + 40 * bl], 1, f.m); + if (f.lw > 0) { + const ox = f.ox; + tint(o, [70 + 170 * ox, 60 + 30 * ox, 160 - 40 * ox], f.lt * (0.55 + 0.35 * ox) + 0.35 * f.lrim, f.lw); + } + const vb = () => f.wb > 0 && tint(o, BLUE[f.lb], (f.bm ? 0.36 + 0.8 * f.bp : 0.4 + 1.05 * f.bp) + 0.04 * f.lb, f.wb); + const vr = () => f.wr > 0 && tint(o, RED[f.lr], (f.rm ? 0.36 + 0.8 * f.rp : 0.32 + 1.05 * f.rp) + 0.05 * f.lr, f.wr); + // The vessel that owns the LED is drawn last. + if (f.wb > f.wr) vr(), vb(); + else vb(), vr(); + for (let c = 0; c < 3; c++) o[c] *= 1 - 0.55 * f.ho; + if (f.hw > 0) { + const hs = f.hs, b = f.hb, k = 0.45 * f.hr; + tint(o, [(70 + 170 * hs) * b + 255 * k, (80 - 45 * hs) * b + 170 * k, (230 - 185 * hs) * b + 180 * k], 1, f.hw); + } + [40, 235, 140].forEach((c, i) => (o[i] += c * f.hud)); +} + +// The angiogram and the thermal chart: one value, then its colour table. +function pal(f, c, o, E) { + const [lt, bg, t0, rim, dep, core, bl, mus, heat, bone, lung, ox, a0, a1, v0, v1, h0, h1, hud] = c; + let v = bg * (1.2 - (0.6 * f.y) / H); + if (f.m > 0) v += (t0 + rim * f.rim + dep * f.dep + core * f.core + bl * f.bl + (heat + mus * f.mus) * E - v) * f.m; + if (f.lw > 0) v += (lung + ox * f.ox * f.lt - v) * f.lw; + v += (bone - v) * f.bone * f.m * (bone > 0); + if (f.wb > 0) v += ((f.bm ? v0 + v1 * f.bp : a0 * 0.9 + a1 * f.bp) * (1 - 0.08 * f.lb) - v) * f.wb; + if (f.wr > 0) v += ((f.rm ? v0 + v1 * f.rp : a0 + a1 * f.rp) * (1 - 0.08 * f.lr) - v) * f.wr; + v *= 1 - 0.35 * f.ho; + if (f.hw > 0) v += (h0 + h1 * (f.hb - 0.62) + 0.15 * f.hr - v) * f.hw; + const i = clamp(round(max(v, f.hud * hud) * 255), 0, 255) * 3; + [o[0], o[1], o[2]] = [lt[i], lt[i + 1], lt[i + 2]]; +} diff --git a/incoming/pattern-study/patterns/flame-test.js b/incoming/pattern-study/patterns/flame-test.js new file mode 100644 index 000000000..0e7ef719a --- /dev/null +++ b/incoming/pattern-study/patterns/flame-test.js @@ -0,0 +1,539 @@ +// ===== Patternflow pattern ===== +// Title: Flame Test +// Family: chemistry +// Idea: On a dark lab bench a hand dips a nichrome wire loop into a row of labelled watch glasses and holds each salt in the pale blue flame of a Bunsen burner, which bursts into the element's colour, sputters with grains of salt and fades back to blue before the loop is cleaned in acid and goes for the next. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after the flame test of the school chemistry lab +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Salt=0..8, Speed=0.2..3, Room=0..1, Air=-1..1 +// +// Knob 1: Salt. Which watch glass the loop goes to next: at 0 it works along the row from copper (blue-green) through sodium (yellow-orange), lithium (crimson), barium (apple green), potassium (lilac), calcium (brick red), boric acid (green) and strontium (red); 1 to 8 keeps to one glass, copper at 1 to strontium at 8. +// Knob 2: Speed. How fast the hand works and the flame moves: at 2 a salt burns about 4.5 s and the next one goes into the flame 12 s after it; at 0.2 the hand works ten times slower and the flame slows to a lazy flicker. +// Knob 3: Room. The lab lights, on at 0 and off at 1, where only the flame lights the tiles, the bench and the hand. +// Knob 4: Air. The burner's collar: shut at -1 for a tall, wavering yellow flame that hides the salt's colour, half open near 0 for the quiet blue flame of the test, wide open at 1 for a short roaring flame with a sharp inner cone. +// Buttons: K1 tap sends the loop to the next salt now. + +const W = 64; +const H = 128; +const N = W * H; +// The burner's axis, the first row of flame above its barrel, the bench top. +const FX = 21; +const MOUTH = 79; +const BENCH = 94; +const { min, max, floor, ceil, round, sin, cos, exp, abs, sqrt, hypot, PI } = Math; +const rnd = () => Math.random(); +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, v) => { + const t = cl((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; +const mix = (a, b, t) => a + (b - a) * t; +const mix3 = (a, b, t) => a.map((v, i) => mix(v, b[i], t)); + +// The row of watch glasses, left to right: symbol, flame colour, the salt's +// own colour, how long its colour lasts in the flame (s), how strong it is. +const WHITE = [0.92, 0.91, 0.87]; +const SALTS = [ + ["Cu", [0, 240, 185], [0.35, 0.75, 0.7], 2, 1], + ["Na", [255, 175, 10], WHITE, 3.5, 1.25], + ["Li", [255, 10, 85], WHITE, 2, 1], + ["Ba", [170, 255, 35], WHITE, 2, 0.95], + ["K", [190, 100, 255], WHITE, 1.3, 0.8], + ["Ca", [255, 85, 10], WHITE, 2.2, 1], + ["B", [35, 255, 60], WHITE, 1.8, 1], + ["Sr", [255, 5, 25], WHITE, 2.2, 1.1], +]; +// A heap of salt in a watch glass: x offset, row, shade. +const HEAP = [[0, 107, 1], [-1, 108, 0.95], [0, 108, 1], [1, 108, 0.8], [-2, 109, 0.85], [-1, 109, 0.9], [0, 109, 0.85], [1, 109, 0.75], [2, 109, 0.65]]; +// 3x5 letters for the labels. +const FONT = { + C: "011/100/100/100/011", + u: "000/000/101/101/011", + N: "110/101/101/101/101", + a: "000/011/101/101/011", + L: "100/100/100/100/111", + i: "1/0/1/1/1", + B: "110/101/110/101/110", + K: "101/101/110/101/101", + S: "011/100/010/001/110", + r: "000/000/101/110/100", + H: "101/101/111/101/101", + l: "1/1/1/1/1", +}; + +// The hand's round: in the flame with a salt, to the acid, dipped, back, +// in the flame to burn the loop clean, to the next glass, dipped, back. +const DUR = [4.5, 1.1, 0.8, 1, 1.4, 1.2, 0.7, 1]; +// Where the loop goes, and the holder's angle there. +const FP = [FX + 4, 62, 0.45]; +const AT = [51, 75, 1.22]; +const AD = [51, 90, 1.22]; +const ST = (i) => [4 + 8 * i, 100, 0.52]; +const SD = (i) => [4 + 8 * i, 108, 0.52]; + +// Smooth value noise, wrapping both ways. +function noise(w, h, cell, oct) { + const out = new Float32Array(w * h); + let amp = 1; + let tot = 0; + for (let o = 0; o < oct; o++, cell /= 2, amp *= 0.55) { + const gw = w / cell; + const gh = h / cell; + const g = Float32Array.from({ length: gw * gh }, rnd); + const at = (i, j) => g[(j % gh) * gw + (i % gw)]; + for (let y = 0; y < h; y++) { + const j = floor(y / cell); + const ty = sm(0, 1, y / cell - j); + for (let x = 0; x < w; x++) { + const i = floor(x / cell); + const tx = sm(0, 1, x / cell - i); + const a = at(i, j) + (at(i + 1, j) - at(i, j)) * tx; + const b = at(i, j + 1) + (at(i + 1, j + 1) - at(i, j + 1)) * tx; + out[y * w + x] += amp * (a + (b - a) * ty); + } + } + tot += amp; + } + return out.map((v) => v / tot); +} + +// Bilinear lookup in the 64x128 noise tile. +function nz(T, x, y) { + const xi = floor(x); + const yi = floor(y); + const fx = x - xi; + const r0 = (yi & 127) * 64; + const r1 = ((yi + 1) & 127) * 64; + const a = T[r0 + (xi & 63)]; + const b = T[r0 + ((xi + 1) & 63)]; + const c = T[r1 + (xi & 63)]; + const d = T[r1 + ((xi + 1) & 63)]; + return a + (b - a) * fx + (c - a + (a - b - c + d) * fx) * (y - yi); +} + +// Everything that stands still, as albedo: the tiled wall and its periodic +// table, the gas tap and hose, the burner, the acid, the glasses, the labels. +function build(P) { + const { alb, spec, G, benchM } = P; + const put = (x, y, c, s) => { + if (x < 0 || x >= W || y < 0 || y >= H) return; + const p = y * W + x; + alb.set(c, p * 3); + benchM[p] = 0; + if (s !== undefined) spec[p] = s; + }; + const at = (x, y) => alb.slice((y * W + x) * 3, (y * W + x) * 3 + 3); + const text = (str, cx, top, ink) => { + const gl = [...str].map((c) => FONT[c].split("/")); + let x0 = cx - (gl.reduce((s, g) => s + g[0].length + 1, -1) >> 1); + for (const g of gl) { + for (let j = 0; j < 5; j++) for (let i = 0; i < g[j].length; i++) if (g[j][i] === "1") put(x0 + i, top + j, ink); + x0 += g[0].length + 1; + } + }; + const shade = Float32Array.from({ length: 256 }, rnd); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + let v; + if (y < BENCH - 1) { + const gx = (x + 5) % 11; + const gy = (y + 7) % 11; + v = gx === 10 || gy === 10 ? 0.36 : 0.45 + 0.05 * shade[(((x + 5) / 11) | 0) * 19 + (((y + 7) / 11) | 0) * 7 & 255] + 0.05 * (1 - (gx + gy) / 18) + (gx === 0 || gy === 0 ? 0.03 : 0); + put(x, y, [v, v * 1.01, v * 1.03]); + } else if (y === BENCH - 1) put(x, y, [0.06, 0.06, 0.07]); + else if (y === BENCH) put(x, y, [0.3, 0.3, 0.32], 0.3); + else if (y < 125) { + v = 0.17 + 0.014 * rnd(); + put(x, y, [v, v, v * 1.08]); + benchM[y * W + x] = 1; + } else put(x, y, y === 125 ? [0.42, 0.42, 0.44] : [0.1, 0.1, 0.11], y === 125 ? 0.4 : 0); + // The flame lights the room like a lamp along its height. + const dx = x - FX; + const dy = y < 22 ? 22 - y : y > 78 ? y - 78 : 0; + G[y * W + x] = 1 / (1 + (dx * dx + dy * dy) / 480); + } + // A periodic table pinned to the tiles. + for (let y = 3; y <= 27; y++) for (let x = 39; x <= 62; x++) put(x, y, [0.85, 0.84, 0.78]); + for (let x = 42; x <= 59; x++) put(x, 5, [0.2, 0.2, 0.28]); + const cell = (c, y, f) => + put(42 + c, y, f ? [0.55, 0.85, 0.8] : c === 0 ? (y === 8 ? [0.7, 0.7, 0.75] : [0.95, 0.35, 0.35]) : c === 1 ? [0.98, 0.62, 0.3] : c < 12 ? [0.95, 0.85, 0.35] : c < 17 ? (c - 12 > (y - 8) / 2 - 1 ? [0.45, 0.8, 0.5] : [0.45, 0.65, 0.95]) : [0.7, 0.5, 0.95]); + for (let n = 1; n <= 7; n++) for (let c = 0; c < 18; c++) if (n > 3 || (n === 1 ? c === 0 || c === 17 : c < 2 || c > 11)) cell(c, 6 + 2 * n, false); + for (let c = 3; c < 17; c++) cell(c, 23, true), cell(c, 25, true); + // The heatproof mat the burner stands on. + for (let y = 96; y <= 104; y++) + for (let x = 7 - ((y - 96) >> 1); x <= 35 + ((y - 96) >> 1); x++) { + const v = y === 104 ? 0.22 : y === 96 ? 0.42 : 0.5 + 0.04 * rnd(); + put(x, y, [v, v * 0.97, v * 0.92]); + } + // The gas tap on the wall, its lever, and the orange rubber hose to the burner. + for (let y = 82; y <= 91; y++) for (let x = 0; x <= 3; x++) put(x, y, [0.4, 0.4, 0.42], x === 0 ? 0.3 : 0); + for (let y = 86; y <= 89; y++) for (let x = 1; x <= 6; x++) put(x, y, y === 86 ? [0.9, 0.72, 0.2] : [0.62, 0.48, 0.2], y === 86 ? 0.5 : 0); + for (let x = 7; x <= 9; x++) put(x, 88, [0.72, 0.56, 0.24]); + for (let j = 0; j < 4; j++) put(4 + (j >> 1), 85 - j, [0.12, 0.12, 0.13]); + for (let k = 0; k <= 80; k++) { + const t = k / 80; + const u = 1 - t; + const bx = round(u * u * u * 9 + 3 * u * u * t * 17 + 3 * u * t * t * 2 + t * t * t * 10); + const by = round(u * u * u * 88 + 3 * u * u * t * 94 + 3 * u * t * t * 106 + t * t * t * 101); + put(bx, by, [0.88, 0.38, 0.14], 0.25); + put(bx, by + 1, [0.6, 0.24, 0.08]); + } + // The burner: a heavy base, the gas inlet, the chrome barrel and its collar. + const BW = [5, 6, 7, 7, 7]; + for (let j = 0; j < 5; j++) + for (let x = FX - BW[j]; x <= FX + BW[j]; x++) { + const v = j === 0 ? 0.42 : j === 4 ? 0.1 : 0.22 - 0.03 * j; + put(x, 99 + j, [v, v, v * 1.15], j === 0 ? 0.5 : 0); + } + for (let x = FX - 7; x <= FX + 8; x++) put(x, 104, at(x, 104).map((v) => v * 0.55)); + for (let x = FX - 11; x < FX - 7; x++) put(x, 100, [0.78, 0.62, 0.3], 0.4), put(x, 101, [0.5, 0.38, 0.18]); + const BC = [0.28, 0.75, 0.56, 0.38, 0.2]; + for (let y = 80; y <= 98; y++) + for (let i = 0; i < 5; i++) { + const v = BC[i] + (y === 80 ? 0.15 : 0); + put(FX - 2 + i, y, [v, v, v * 1.06], i === 1 ? 0.6 : 0); + } + const CC = [0.25, 0.5, 0.8, 0.64, 0.46, 0.3, 0.18]; + for (let y = 89; y <= 95; y++) + for (let i = 0; i < 7; i++) { + const v = CC[i] * (y === 89 || y === 95 ? 0.75 : 1); + put(FX - 3 + i, y, [v, v * 0.8, v * 0.42], i === 2 ? 0.5 : 0); + } + // A beaker of dilute hydrochloric acid to clean the loop in. + for (let y = 75; y <= 101; y++) + for (let x = 43; x <= 58; x++) { + if (x === 43 && y > 76) continue; + if (y === 75 || y === 101 || x === 44 || x === 58 || x === 43) { + put(x, y, [0.5, 0.55, 0.6], x === 44 || y === 75 ? 0.8 : 0.4); + continue; + } + let c = at(x, y).map((v, i) => v * [0.9, 0.94, 0.98][i]); + if (y >= 85) c = c.map((v, i) => v * [0.62, 0.68, 0.7][i] + [0.08, 0.11, 0.14][i]); + put(x, y, c, y === 85 ? 0.6 : x === 45 ? 0.3 : 0); + } + P.front = []; + for (let y = 93; y <= 99; y++) for (let x = 46; x <= 56; x++) put(x, y, [0.88, 0.86, 0.78]), P.front.push(y * W + x); + text("HCl", 51, 94, [0.07, 0.07, 0.1]); + // The watch glasses, each with its heap of salt, and the tape with their names. + for (let i = 0; i < 8; i++) { + const c = 4 + 8 * i; + for (let x = c - 3; x <= c + 3; x++) put(x, 112, at(x, 112).map((v) => v * 0.6)); + for (let dy = -2; dy <= 1; dy++) + for (let dx = -3; dx <= 3; dx++) { + const e = (dx / 3.7) ** 2 + ((dy + 0.5) / 2.1) ** 2; + if (e < 1) put(c + dx, 110 + dy, e > 0.45 ? [0.5, 0.56, 0.6] : at(c + dx, 110 + dy).map((v) => v * 1.3 + 0.07), e > 0.45 && dy < 0 ? 0.9 : 0.3); + } + const k = SALTS[i][2]; + for (const [dx, y, s] of HEAP) put(c + dx, y, k.map((v) => v * s)); + } + for (let y = 115; y <= 121; y++) + for (let x = 0; x < W; x++) { + const v = y === 115 || y === 121 ? 0.62 : 0.82; + if (x % 8) put(x, y, [v, v * 0.97, v * 0.82]); + } + for (let i = 0; i < 8; i++) text(SALTS[i][0], 4 + 8 * i, 116, [0.06, 0.06, 0.09]); +} + +export function setup(params) { + const P = params; + const F = (n) => new Float32Array(n); + Object.assign(P, { alb: F(N * 3), spec: F(N), G: F(N), buf: F(N * 3), benchM: new Uint8Array(N), NT: noise(64, 128, 16, 3) }); + build(P); + Object.assign(P, { started: false, t: 0, tf: 0, ph: 0, pt: 0, cur: 0, salt: 0, pick: 0, ks: 0, kd: 0, kc: SALTS[0][1], heat: 0, bead: 0, sput: 0, flare: 0, sp: [], hot: [], sAcc: 0, bAcc: 0, air: 0.5, room: 0, from: FP, pos: FP, m: 1 }); +} + +// Kind 0 is a grain carried up the flame in the salt's colour, kind 1 a grain +// spat out of the loop that glows as it falls, kind 2 a bubble in the acid. +function spark(P, k) { + if (P.sp.length >= 160) return; + P.sp.push({ + k, + x: P.pos[0] + (rnd() - 0.5) * 2.5, + y: P.pos[1] + (rnd() - 0.5) * 2, + vx: (rnd() - 0.5) * [14, 60, 2][k], + vy: -[25 + 45 * rnd(), 10 + 35 * rnd(), 7 + 6 * rnd()][k], + a: 0, + l: [0.25 + 0.55 * rnd(), 0.4 + 0.6 * rnd(), 2][k], + c: P.kc.map((v) => v * 0.6 + 100), + }); +} + +function enter(P) { + const ph = P.ph; + P.from = P.pos; + if (ph === 5) P.cur = P.pick ? P.pick - 1 : (P.cur + 1) % 8; + if (ph === 7) P.bead = 1; + if (ph === 0) { + P.salt = P.cur; + P.kc = SALTS[P.cur][1]; + for (let i = 0; i < 16; i++) spark(P, i & 1); + } + // Even a clean loop shows a flash of sodium from the acid and the fingers. + if (ph === 4) P.kc = SALTS[1][1]; +} + +function step(P, h) { + P.t += h; + P.pt += h; + while (P.pt >= DUR[P.ph]) { + P.pt -= DUR[P.ph]; + P.ph = (P.ph + 1) % 8; + enter(P); + } + const ph = P.ph; + const s = P.pt / DUR[ph]; + const e = sm(0, 1, s); + const dip = sm(0, 0.35, s) * sm(1, 0.62, s); + let pos; + if (ph === 0 || ph === 4) pos = [FP[0] + 0.5 * sin(P.t * 1.3), FP[1] + 0.5 * sin(P.t * 1.9 + 1), FP[2]]; + else if (ph === 2 || ph === 6) { + pos = ph === 2 ? mix3(AT, AD, dip) : mix3(ST(P.cur), SD(P.cur), dip); + pos[0] += sin(s * 19) * 0.7 * dip; + } else { + pos = mix3(P.from, ph === 1 ? AT : ph === 5 ? ST(P.cur) : FP, e); + pos[1] -= 5 * sin(PI * e); + } + P.pos = pos; + const inF = ph === 0 || ph === 4; + const wet = ph === 2 && dip > 0.6; + P.heat += ((inF ? 1 : 0) - P.heat) * (1 - exp(-h / (inF ? 0.35 : wet ? 0.07 : 0.9))); + if (ph === 0) P.bead = max(0, 1 - P.pt / 0.9); + + // The salt's colour: a burst as the loop goes in, a while at full, then a fade. + const S = SALTS[P.salt]; + let tgt = 0; + P.sput = 0; + if (ph === 0) { + tgt = S[4] * (P.pt < 1.3 ? sm(0, 0.18, P.pt) : exp(-(P.pt - 1.3) / S[3])); + P.sput = S[4] * (exp(-P.pt / 0.9) + 0.3 * exp(-P.pt / 2.5)); + } else if (ph === 4) { + tgt = 0.85 * sm(0, 0.15, P.pt) * exp(-P.pt / 0.5); + P.sput = 0.5 * exp(-P.pt / 0.35); + } + P.ks += (tgt - P.ks) * (1 - exp(-h / (tgt > P.ks ? 0.06 : 0.22))); + P.flare *= exp(-h / 0.15); + P.kd = P.ks * (0.78 + 0.3 * nz(P.NT, P.t * 9, 40.5)) + P.flare; + // Grains crackle off the loop, now and then a whole crust at once. + if (P.sput > 0.02) { + P.sAcc += 40 * P.sput * h; + for (; P.sAcc >= 1; P.sAcc--) spark(P, rnd() < 0.7 ? 0 : 1); + if (rnd() < 3 * P.sput * h) { + P.flare += 0.3 * P.ks; + for (let i = 0; i < 8; i++) spark(P, i % 3 ? 0 : 1); + } + } + if (wet) for (P.bAcc += 22 * h; P.bAcc >= 1; P.bAcc--) spark(P, 2); + P.sp = P.sp.filter((p) => { + p.a += h; + if (p.k === 0) { + p.vy -= 30 * h; + p.vx *= exp(-2 * h); + } else if (p.k === 1) p.vy += 90 * h; + p.x += p.vx * h; + p.y += p.vy * h; + return p.a < p.l && !(p.k === 2 && p.y < 85.5) && p.x >= 0 && p.x <= 63 && p.y >= 0 && p.y <= 124; + }); +} + +export function update(dt, input, params) { + const P = params; + const k = input.knobValues || [0, 2, 0, 0.06]; + dt = cl(dt, 0, 0.1); + P.pick = cl(round(k[0]), 0, 8); + P.m = cl(k[1], 0.2, 3) / 2; + const room = cl(k[2], 0, 1); + const air = (cl(k[3], -1, 1) + 1) / 2; + if (!P.started) { + P.started = true; + P.air = air; + P.room = room; + P.cur = P.pick ? P.pick - 1 : 0; + enter(P); + // Open a moment after the first salt went into the flame. + for (let i = 0; i < 8; i++) step(P, 1 / 30); + } + if (input.btnPressed && input.btnPressed[0] && P.ph < 5) { + P.ph = 5; + P.pt = 0; + enter(P); + } + P.air += (air - P.air) * (1 - exp(-dt / 0.25)); + P.room += (room - P.room) * (1 - exp(-dt / 0.12)); + P.tf += dt * (0.3 + 0.7 * P.m); + step(P, dt * P.m); +} + +// The holder: the wire loop, the nichrome wire, the aluminium handle, the +// hand round it and the white sleeve of a lab coat. The loop and the first +// few LEDs of wire are kept to glow when hot. +function holder(P, light) { + const [lx, ly, th] = P.pos; + const ux = cos(th); + const uy = -sin(th); + P.hot = []; + for (let y = max(0, floor(ly + 100 * uy - 7)); y <= min(H - 1, ceil(ly + 7)); y++) + for (let x = max(0, floor(lx - 3)); x < W; x++) { + const dx = x - lx; + const dy = y - ly; + const s = dx * ux + dy * uy; + const q = dx * uy - dy * ux; + if (s < -2.5 || abs(q) > 6.2) continue; + let c = null; + const hand = ((s - 31) / 4.3) ** 2 + ((q + 0.3) / 3.7) ** 2; + if (((s - 27.5) / 2.6) ** 2 + ((q - 2.5) / 1.3) ** 2 < 1) c = [1, 0.78, 0.64]; + else if (hand < 1) { + // Curled fingers under the handle, the knuckles above it. + const k = 0.84 + 0.07 * q - (q < -0.8 && abs(((s - 29.5) % 2) - 1) < 0.45 ? 0.22 : 0); + c = [k, 0.72 * k, 0.58 * k]; + } else if (s > 34 && s < 37.5 && abs(q + 0.5) < 3) c = [0.88, 0.62, 0.5]; + else if (s >= 37 && abs(q + 0.6) < 5.4) { + const k = (s < 39 ? 0.74 : 0.9) + 0.03 * q; + c = [0.88 * k, 0.89 * k, 0.92 * k]; + } else if (s >= 9 && s < 30 && q > -1 && q < 0.6) c = q > -0.2 ? [0.66, 0.68, 0.74] : [0.36, 0.37, 0.42]; + else if ((s >= 1 && s < 9 && abs(q) < 0.6) || (s < 1.8 && abs(hypot(dx, dy) - 1.25) < 0.55)) { + c = [0.55, 0.55, 0.52]; + if (s < 5) P.hot.push(y * W + x, 1 - max(0, s) / 6); + } + if (c) light(y * W + x, c); + } +} + +function flame(P) { + const { buf, NT, kd, kc } = P; + const a = P.air; + const t = P.tf; + const k1 = min(1, kd); + const Yf = sm(0.5, 0.06, a); + const Rf = sm(0.6, 1, a); + const Cf = sm(0.12, 0.6, a); + const bl = sm(0.06, 0.5, a); + const fl = 1 + (0.06 * sin(t * 7.3) + 0.05 * sin(t * 12.9 + 1)) * (1 + Yf); + const Hf = mix(mix(76, 58, bl), 47, Rf) * (1 + 0.2 * k1) * fl; + const Wm = mix(8.5, 6.4, bl) - Rf + 1.6 * k1; + const Hi = mix(7, 15, sm(0.15, 1, a)); + const A = mix(5, 2, bl) * (1 - 0.6 * Rf); + const ws = mix(4.5, 6.5, a) + 7 * Rf; + const vs = mix(30, 46, a) + 40 * Rf; + const tn = mix(0.75, 0.5, a); + const [lx, ly] = P.pos; + const inF = P.heat > 0.5; + const salt = 1 - 0.8 * Yf; + for (let y = max(0, floor(MOUTH - Hf * 1.3)); y <= MOUTH; y++) { + const hg = MOUTH + 1 - y; + const v = hg / Hf; + const cx = FX + A * v ** 1.4 * sin(0.11 * hg - ws * t) + 0.45 * A * v * sin(0.23 * hg - 1.7 * ws * t + 2); + const hw = max(0.7, (2.6 + (Wm - 2.6) * sm(0, 0.3, v)) * sqrt(max(0, 1.05 - v))); + const yw = Yf * sm(2, 9, hg); + const ri = 2.5 * max(0, 1 - hg / Hi) ** 0.8 + 0.25; + const cxi = FX + 0.35 * sin(t * 9 + hg * 0.3) * (1 - Rf); + const above = sm(ly + 5, ly - 2, y); + for (let x = max(0, floor(cx - hw * 1.7 - 3)); x <= min(W - 1, ceil(cx + hw * 1.7 + 3)); x++) { + const n = nz(NT, x * 1.1 + 7, y * 0.55 + t * vs * 0.55); + const d2 = abs(x - cx) / hw + (n - 0.5) * tn * (0.25 + v); + const O = sm(1.12, 0.55, d2) * sm(1, 0.72, v + (n - 0.5) * 0.45 * v); + let r = 0; + let g = 0; + let b = 0; + // The outer flame: pale blue, or yellow with soot when the collar is shut. + if (O > 0.002) { + const ob = O * (0.62 + 0.38 * (1 - v)) * (0.85 + 0.25 * n) * (0.8 + 0.25 * Rf); + const core = sm(0.85, 0.2, d2) * (1 - 0.5 * v); + const lum = O * (0.75 + 0.35 * n); + r = mix(58 * ob, 255 * lum, yw); + g = mix(98 * ob, lum * (95 + 150 * core - 60 * v), yw); + b = mix(225 * ob, O * (10 + 120 * core * core), yw); + } + // The inner cone, brightest along its surface. + if (hg < Hi + 1) { + const di = abs(x - cxi); + const C = sm(ri + 0.75, ri - 0.35, di) * Cf * (0.92 + 0.08 * sin(t * 61 + hg)); + if (C > 0) { + const rim = sm(ri - 1.3, ri - 0.3, di); + r += (60 + 100 * rim) * C; + g += (140 + 100 * rim) * C; + b += 255 * C; + } + } + // The salt's colour rises from the loop and fills the flame above it in tongues. + let ps = 0; + if (kd > 0.01) { + const n2 = nz(NT, x * 1.6 + 31, y * 0.8 + t * vs * 0.9 + 53); + ps = kd * salt * (O * above * (0.4 + 0.95 * n2) + (inF ? 0.9 * exp(-((x - lx) ** 2 + (y - ly) ** 2) / 10) : 0)); + const q = max(0, 1 - 1.4 * ps); + r = r * q + kc[0] * ps; + g = g * q + kc[1] * ps; + b = b * q + kc[2] * ps; + } + if (r + g + b < 1) continue; + const o = (y * W + x) * 3; + const dim = 1 - 0.4 * min(1, O + 0.5 * ps); + buf[o] = buf[o] * dim + r; + buf[o + 1] = buf[o + 1] * dim + g; + buf[o + 2] = buf[o + 2] * dim + b; + } + } +} + +export function draw(display, params) { + const P = params; + if (!P.started) return; + const { buf, alb, spec, G, benchM, kc } = P; + const a = P.air; + const Yf = sm(0.5, 0.06, a); + const k1 = min(1.3, P.kd) * (1 - 0.8 * Yf); + const fl = 0.9 + 0.1 * nz(P.NT, P.tf * 11, 70.5) + 0.12 * Yf * sin(P.tf * 9); + // The flame's own colour, which lights the room. + const FC = [0, 1, 2].map((i) => (mix([58, 98, 225][i] * 0.85, [255, 165, 40][i], Yf) * (1 - 0.8 * min(1, k1)) + kc[i] * k1) * fl); + const [gr, gg, gb] = FC.map((v) => v * (0.52 + 0.3 * Yf)); + const A = 150 * (1 - P.room) ** 1.3 + 1.5; + for (let p = 0, o = 0; p < N; p++, o += 3) { + const g = G[p]; + const s = spec[p]; + buf[o] = alb[o] * (A + gr * g) + s * (0.3 * A + 1.4 * gr * g); + buf[o + 1] = alb[o + 1] * (A + gg * g) + s * (0.3 * A + 1.4 * gg * g); + buf[o + 2] = alb[o + 2] * (A * 0.95 + gb * g) + s * (0.3 * A + 1.4 * gb * g); + } + const add = (p, c, f) => { + for (let i = 0; i < 3; i++) buf[p * 3 + i] += c[i] * f; + }; + // The glossy bench: the ceiling lights along its back and the flame under the burner. + for (let x = 0; x < W; x++) for (const [y, k] of [[96, 0.22], [97, 0.12]]) if (benchM[y * W + x]) add(y * W + x, [A, A, A], k); + const sway = 1.5 * sin(P.tf * 4.1); + for (let y = 105; y <= 124; y++) { + const w = 2 + (y - 105) * 0.22; + const k = 0.34 * exp(-(y - 105) / 7); + for (let x = max(0, floor(FX - 3 * w)); x <= min(W - 1, ceil(FX + 3 * w)); x++) + if (benchM[y * W + x]) add(y * W + x, FC, k * exp(-(((x - FX - (sway * (y - 105)) / 20) / w) ** 2))); + } + const light = (p, c) => { + const g = G[p]; + buf[p * 3] = c[0] * (A + gr * g); + buf[p * 3 + 1] = c[1] * (A + gg * g); + buf[p * 3 + 2] = c[2] * (A * 0.95 + gb * g); + }; + // The air hole in the collar opens as the collar turns. + const hole = round(4 * a); + for (let y = 91; y <= 93; y++) for (let x = FX - (hole >> 1); x < FX - (hole >> 1) + hole; x++) light(y * W + x, [0.03, 0.03, 0.03]); + for (const p of P.sp) if (p.k === 2) light(round(p.y) * W + round(p.x), [0.7, 0.75, 0.8]); + holder(P, light); + // The label on the beaker hides the loop behind it. + if (P.ph >= 1 && P.ph <= 3) for (const p of P.front) light(p, alb.subarray(p * 3, p * 3 + 3)); + flame(P); + for (const p of P.sp) { + const f = 1 - p.a / p.l; + if (p.k < 2) add(round(p.y) * W + round(p.x), p.k ? [255 * sqrt(f), 210 * f * f, 120 * f * f * f] : p.c, p.k ? 1 : f); + } + // The loop glows red hot in the flame; the salt on it shows until it melts. + const ht = P.heat; + if (ht > 0.02) for (let i = 0; i < P.hot.length; i += 2) add(P.hot[i], [255, 70 + 90 * ht, 25 * ht], ht * P.hot[i + 1]); + if (P.bead > 0.05) { + const c = SALTS[P.cur][2]; + const p = round(P.pos[1]) * W + round(P.pos[0]); + for (let i = 0; i < 3; i++) buf[p * 3 + i] = mix(buf[p * 3 + i], c[i] * (A + 60) + [180, 140, 90][i] * ht, P.bead); + } + for (let p = 0, o = 0; p < N; p++, o += 3) display.setPixel(p % W, (p / W) | 0, min(255, buf[o]), min(255, buf[o + 1]), min(255, buf[o + 2])); +} diff --git a/incoming/pattern-study/patterns/flipdot.js b/incoming/pattern-study/patterns/flipdot.js new file mode 100644 index 000000000..cbb49d2d8 --- /dev/null +++ b/incoming/pattern-study/patterns/flipdot.js @@ -0,0 +1,333 @@ +// ===== Patternflow pattern ===== +// Title: Flipdot +// Family: type +// Idea: A bus destination sign of fluorescent flip discs stands on end, the route number in its own box above the destination, and between pages a wave of discs flips the whole board yellow and back, each disc showing a thin edge as it turns. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after the electromagnetic flip-disc destination signs on buses and trams from the 1970s on +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Disc=0..1, Speed=0.2..3, Route=0..3, Wipe=0..3 +// +// Knob 1: Disc. The colour painted on the bright side of the discs: fluorescent yellow at 0, then lime green, white, amber and red at 1. +// Knob 2: Speed. How fast the sign runs its pages and its waves: at 2 the destination holds three seconds, the stops a little over two, and a wave crosses the board in under a second. +// Knob 3: Route. Which bus the sign is set for, each with its own number, destination and stops: the 17 to Pier Head, the 7 to Dock Yard, the N9 night bus and the X3 express to Sea Wall. +// Knob 4: Wipe. How the controller drives the discs between pages: column by column at 0, a diagonal at 1, a scatter of single discs at 2, row by row at 3. +// Buttons: K1 tap ends the page now and the next wave starts. + +const W = 64; +const H = 128; +const N = W * H; +const { min, max, floor, round } = Math; +const rnd = () => Math.random(); +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); + +// The board: 20 discs across and 40 down on a 3 LED pitch, each disc 2 by 2 +// LEDs with one dark LED between. The top box holds 16 rows for the route +// number; a housing bar; then 24 rows for the destination. +const COLS = 20; +const R1 = 16; +const R2 = 24; +const ROWS = R1 + R2; +const ND = COLS * ROWS; +const discX = (c) => 3 + 3 * c; +const discY = (r) => (r < R1 ? 2 + 3 * r : 55 + 3 * (r - R1)); + +// One disc's turn, in sign seconds. The first quarter shows the old face, the +// second a thin edge of it on the top row, the third a thin edge of the new +// face on the bottom row, the last the new face. +const FD = 0.14; +const FIRST = 2.15; +const HOLD = 3.0; +const HOLD_INV = 2.2; +const WAVE = [0.8, 1.05, 0.9, 1.0]; + +// A condensed 7 row dot face. Each glyph is its rows left to right, top to +// bottom; the width is the length over 7. Most letters are 4 discs wide, the +// I and the 1 are 3, M T V W X Y are 5. +const FONT = { + A: ".##.#..##..######..##..##..#", B: "###.#..##..####.#..##..####.", C: ".##.#..##...#...#...#..#.##.", + D: "###.#..##..##..##..##..####.", E: "#####...#...###.#...#...####", F: "#####...#...###.#...#...#...", + G: ".##.#..##...#.###..##..#.###", H: "#..##..##..######..##..##..#", I: "###.#..#..#..#..#.###", + J: "...#...#...#...##..##..#.##.", K: "#..##..##.#.##..#.#.#..##..#", L: "#...#...#...#...#...#...####", + M: "#...###.###.#.##.#.##...##...##...#", N: "#..###.###.##.###.###..##..#", O: ".##.#..##..##..##..##..#.##.", + P: "###.#..##..####.#...#...#...", R: "###.#..##..####.#.#.#..##..#", S: ".##.#..##....##....##..#.##.", + T: "#####..#....#....#....#....#....#..", U: "#..##..##..##..##..##..#.##.", V: "#...##...##...#.#.#..#.#..#.#...#..", + W: "#...##...##...##.#.##.#.###.###...#", X: "#...##...#.#.#...#...#.#.#...##...#", Y: "#...##...#.#.#...#....#....#....#..", + Z: "####...#..#...#..#..#...####", + 0: ".##.#..##.###..###.##..#.##.", 1: ".#.##..#..#..#..#.###", 2: ".##.#..#...#..#..#..#...####", + 3: ".##.#..#...#.##....##..#.##.", 4: "..#..##.#.#.#.#.####..#...#.", 5: "#####...###....#...##..#.##.", + 6: ".##.#...#...###.#..##..#.##.", 7: "####...#..#...#..#...#...#..", 8: ".##.#..##..#.##.#..##..#.##.", + 9: ".##.#..##..#.###...#...#.##.", "-": ".........###.........", ":": "..#..#.", ".": "......#", +}; + +// Each route runs four pages: the destination, a page of stops shown +// inverse, the destination again, and a second inverse page. +const ROUTES = [ + { no: "17", pages: [["PIER", "HEAD"], ["VIA", "OLD", "MILL"], ["PIER", "HEAD"], ["VIA", "FISH", "DOCK"]] }, + { no: "7", pages: [["DOCK", "YARD"], ["VIA", "FAIR", "LANE"], ["DOCK", "YARD"], ["VIA", "ROPE", "WALK"]] }, + { no: "N9", pages: [["ZOO", "GATE"], ["LAST", "BUS", "0115"], ["ZOO", "GATE"], ["VIA", "ELM", "PARK"]] }, + { no: "X3", pages: [["SEA", "WALL"], ["VIA", "HIGH", "ST"], ["SEA", "WALL"], ["VIA", "LIDO"]] }, +]; + +// Disc paint, from the yellow most of these signs carried to the red a few did. +const PAINT = [ + [255, 226, 20], + [110, 255, 40], + [236, 240, 226], + [255, 150, 12], + [255, 58, 34], +]; +const BLACK = [13, 13, 12]; +const GAP = [3, 3, 4]; + +const textWidth = (s, k) => { + let w = 0; + for (const ch of s) w += (ch === " " ? 2 : FONT[ch].length / 7) * k + k; + return max(0, w - k); +}; + +// Sets the discs of one line of text, k times size, its top left at disc +// (c0, r0). +function stamp(bits, s, c0, r0, k) { + let c = c0; + for (const ch of s) { + if (ch === " ") { + c += 3 * k; + continue; + } + const g = FONT[ch]; + const gw = g.length / 7; + for (let y = 0; y < 7; y++) + for (let x = 0; x < gw; x++) { + if (g[y * gw + x] !== "#") continue; + for (let a = 0; a < k; a++) + for (let b = 0; b < k; b++) { + const cc = c + x * k + b; + const rr = r0 + y * k + a; + if (cc >= 0 && cc < COLS && rr >= 0 && rr < ROWS) bits[rr * COLS + cc] = 1; + } + } + c += (gw + 1) * k; + } +} + +// A page as the discs it wants yellow. Odd pages are inverse: yellow ground, +// black letters, the route box with them. +function pageBits(route, n) { + const R = ROUTES[route]; + const lines = R.pages[n % R.pages.length]; + const bits = new Uint8Array(ND); + stamp(bits, R.no, floor((COLS - textWidth(R.no, 2)) / 2), 1, 2); + const h = lines.length * 8 - 1; + const top = R1 + floor((R2 - h) / 2); + lines.forEach((s, j) => stamp(bits, s, floor((COLS - textWidth(s, 1) + 1) / 2), top + j * 8, 1)); + if (n & 1) for (let i = 0; i < ND; i++) bits[i] ^= 1; + return bits; +} + +const ALL = [new Uint8Array(ND), new Uint8Array(ND).fill(1)]; + +function palette(P, u) { + P.hue = u; + const t = u * 4; + const j = min(3, floor(t)); + const f = t - j; + for (let c = 0; c < 3; c++) P.col[c] = PAINT[j][c] + (PAINT[j + 1][c] - PAINT[j][c]) * f; +} + +// The housing: a grey bezel round the edge, the dark backplate the discs sit +// on, and the bar between the two boxes with its four screws. +function housing(P) { + const b = P.base; + for (let p = 0; p < N; p++) [b[p * 3], b[p * 3 + 1], b[p * 3 + 2]] = GAP; + const put = (x, y, v, t) => { + const o = (y * W + x) * 3; + b[o] = v; + b[o + 1] = v; + b[o + 2] = v + t; + }; + for (let x = 0; x < W; x++) { + put(x, 0, 40, 4); + put(x, H - 1, 34, 4); + put(x, 50, 40, 4); + for (let y = 51; y < 53; y++) put(x, y, 26, 3); + put(x, 53, 32, 3); + } + for (let y = 0; y < H; y++) { + put(0, y, 38, 4); + put(W - 1, y, 34, 4); + } + for (const sx of [5, 57]) for (let y = 51; y < 53; y++) for (let x = sx; x < sx + 2; x++) put(x, y, x === sx && y === 51 ? 86 : 58, 2); +} + +function init(P, route) { + P.route = route; + P.base = new Float32Array(N * 3); + P.out = new Float32Array(N * 3); + P.col = [0, 0, 0]; + P.fa = new Uint8Array(ND); + P.fb = new Uint8Array(ND); + P.ts = new Float32Array(ND).fill(-9); + // How bright each disc's paint still is, and how much light falls on its + // row from the strip under the top of each box. + P.lit = new Float32Array(ND); + P.dim = new Float32Array(ND); + P.q = new Int32Array(ND); + for (let i = 0; i < ND; i++) { + const r = floor(i / COLS); + const c = i % COLS; + const shade = r < R1 ? 1 - 0.04 * (r / (R1 - 1)) : 1 - 0.06 * ((r - R1) / (R2 - 1)); + P.lit[i] = (0.96 + 0.04 * rnd()) * shade; + P.dim[i] = 0.85 + 0.3 * rnd(); + P.q[i] = discY(r) * W + discX(c); + } + // One disc jammed on its edge, and a few loose ones that flutter as the bus + // idles. + P.stuck = (R1 + 2 + floor(rnd() * (R2 - 4))) * COLS + 1 + floor(rnd() * (COLS - 2)); + P.loose = [0, 1, 2, 3].map(() => floor(rnd() * ND)); + P.fli = -1; + P.flt = 0; + housing(P); + P.st = 0; + P.pg = 0; + const bits = pageBits(route, 0); + P.fa.set(bits); + P.fb.set(bits); + P.k = 0; + P.kt = 0; + P.kd = FIRST; +} + +const face = (P, i) => (P.st >= P.ts[i] ? P.fb[i] : P.fa[i]); + +// Starts a wave towards the target at sign time t0. Discs already showing +// the right face do not move; two random ones in each wave are sticky and +// turn late. +function wave(P, target, t0, dir) { + const mode = P.mode; + const wd = WAVE[mode]; + const sticky = [floor(rnd() * ND), floor(rnd() * ND)]; + P.fli = -1; + for (let i = 0; i < ND; i++) { + const cur = face(P, i); + const tgt = target[i]; + P.fa[i] = cur; + P.fb[i] = tgt; + if (cur === tgt) { + P.ts[i] = -9; + continue; + } + const r = floor(i / COLS); + const c = dir > 0 ? i % COLS : COLS - 1 - (i % COLS); + const rr = dir > 0 ? r : ROWS - 1 - r; + let u; + if (mode === 0) u = c / (COLS - 1) + r * 0.0012; + else if (mode === 1) u = (c + rr) / (COLS + ROWS - 2); + else if (mode === 2) u = rnd(); + else u = rr / (ROWS - 1) + c * 0.0015; + P.ts[i] = t0 + u * wd + (i === sticky[0] || i === sticky[1] ? 0.12 + 0.15 * rnd() : 0); + } + return wd + FD + 0.03; +} + +// The sign's cycle: page, fill to yellow, hold, inverse page, clear to +// black, hold, next page. +function enter(P, k, t0) { + P.k = k; + P.kt = t0; + if (k === 0) P.kd = HOLD; + else if (k === 1) P.kd = wave(P, ALL[1], t0, 1); + else if (k === 2) P.kd = 0.4; + else if (k === 3) P.kd = wave(P, pageBits(P.route, ++P.pg), t0, 1); + else if (k === 4) P.kd = HOLD_INV; + else if (k === 5) P.kd = wave(P, ALL[0], t0, -1); + else if (k === 6) P.kd = 0.25; + else P.kd = wave(P, pageBits(P.route, ++P.pg), t0, -1); +} + +export function setup(params) { + params.base = null; +} + +export function update(dt, input, params) { + const P = params; + const k = input.knobValues || [0, 2, 0, 0.06]; + dt = cl(dt, 0, 0.1); + const hue = cl(k[0], 0, 1); + const sp = cl(k[1], 0.2, 3) / 2; + const route = cl(round(k[2]), 0, 3); + P.mode = cl(round(k[3]), 0, 3); + if (!P.base) init(P, route); + else if (route !== P.route) { + P.route = route; + P.pg = -1; + enter(P, 7, P.st); + } + if (hue !== P.hue) palette(P, hue); + P.st += dt * sp; + const hold = P.k % 2 === 0; + if (input.btnPressed && input.btnPressed[0] && hold) P.kd = P.st - P.kt; + while (P.st - P.kt >= P.kd) enter(P, (P.k + 1) % 8, P.kt + P.kd); + if (P.fli >= 0 && P.st - P.flt > 3 * FD) P.fli = -1; + if (P.k % 2 === 0 && P.fli < 0 && rnd() < dt * sp * 1.1) { + P.fli = P.loose[floor(rnd() * P.loose.length)]; + P.flt = P.st; + } +} + +// Paints one disc part way through a turn from face a to face b. +function paint(P, i, a, b, p) { + const o = P.out; + const q = P.q[i]; + let f = p < 0.5 ? a : b; + let rows = 3; + let s = 1; + if (p >= 0.25 && p < 0.5) [rows, s] = [1, 0.6]; + else if (p >= 0.5 && p < 0.75) [rows, s] = [2, 0.6]; + if (i === P.stuck) [f, rows, s] = [1, 1, 0.5]; + const c = P.col; + const L = P.lit[i] * s; + const D = P.dim[i] * s; + const cr = f ? c[0] * L : BLACK[0] * D; + const cg = f ? c[1] * L : BLACK[1] * D; + const cb = f ? c[2] * L : BLACK[2] * D; + for (let y = 0; y < 2; y++) { + if (!(rows & (1 << y))) continue; + for (let x = 0; x < 2; x++) { + const j = (q + y * W + x) * 3; + // The face is not quite flat: its top left catches a little more of + // the light than its bottom right. + const g = 1.03 - 0.03 * (x + y); + o[j] = cr * g; + o[j + 1] = cg * g; + o[j + 2] = cb * g; + } + } +} + +export function draw(display, params) { + const P = params; + if (!P.base) return; + const o = P.out; + o.set(P.base); + const st = P.st; + for (let i = 0; i < ND; i++) { + const a = P.fa[i]; + const b = P.fb[i]; + let p = a === b ? 1 : (st - P.ts[i]) / FD; + if (i === P.fli) { + const e = (st - P.flt) / FD; + const cur = face(P, i); + if (e < 1) paint(P, i, cur, 1 - cur, e); + else if (e < 2) paint(P, i, 1 - cur, 1 - cur, 1); + else paint(P, i, 1 - cur, cur, e - 2); + continue; + } + paint(P, i, a, b, p < 0 ? 0 : p > 1 ? 1 : p); + } + for (let y = 0, j = 0; y < H; y++) + for (let x = 0; x < W; x++, j += 3) display.setPixel(x, y, min(255, o[j]) | 0, min(255, o[j + 1]) | 0, min(255, o[j + 2]) | 0); +} diff --git a/incoming/pattern-study/patterns/galilean-moons.js b/incoming/pattern-study/patterns/galilean-moons.js new file mode 100644 index 000000000..26a0c2c3c --- /dev/null +++ b/incoming/pattern-study/patterns/galilean-moons.js @@ -0,0 +1,523 @@ +// ===== Patternflow pattern ===== +// Title: Galilean Moons +// Family: celestial +// Idea: Jupiter's four big moons as an observer's chart: the eyepiece at the top shows the belted planet turning, its moons in a line and their shadows crossing the disc, and below it each moon traces its own sine round Jupiter's band, ten rows a day, as the days to come scroll up to the eyepiece. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after the Jupiter's-moons chart Sky & Telescope prints each month, computed with Jean Meeus's low-accuracy theory of the four satellites +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Sky=0..1, Speed=0.5..24, Power=0..1, Seeing=0..1 +// +// Knob 1: Sky. The light the chart is read by: a night chart in navy at 0, red-light observing mode at the middle, the printed paper chart at 1. +// Knob 2: Speed. Hours of Jupiter's time a second: at 2 the Great Red Spot comes round every 5 seconds and the chart climbs a row every 1.2 seconds; 0.5 to 24, a day a second. +// Knob 3: Power. The eyepiece's magnification, which also sets the chart's scale: at 0 the field holds Callisto's whole orbit; turned up, Callisto, then Ganymede, then Europa run off the sides at elongation until only Io's orbit fits. +// Knob 4: Seeing. Turbulence in the air over the telescope: steady near 0, and toward 1 the disc wobbles, boils and blurs into a glow until the belts are gone. +// Buttons: K1 tap jumps ahead to the next shadow transit, an hour before the shadow touches Jupiter's limb. + +const W = 64; +const H = 128; +const { sin, cos, asin, atan2, sqrt, floor, round, min, max, abs, exp, pow, PI } = Math; +const RAD = PI / 180; +const CX = 31.5; +// The eyepiece fills rows 0 to 37: the close-up round row 16 and the +// low-power line on row 35. The chart starts at row 38 with row 35 as now. +const EY = 16; +const LY = 35; +const C0 = 38; +const EYE = C0; +// 2026 October 23, 18:00 UT, three months before the February 2027 +// opposition, when Earth crosses Jupiter's equator and the moons pass right +// over each other; the panel opens at the first shadow transit after it. +const JD0 = 2461337.25; +// The Great Red Spot's System II longitude, about where it drifts to by late 2026. +const GRS = 78; +// Meeus: each moon's longitude at J2000 and daily motion in degrees, its +// radius in Jupiter radii. +const U0 = [163.8069, 358.414, 5.7176, 224.8092]; +const UN = [203.4058646, 101.2916335, 50.234518, 21.48798]; +const RM = [0.0255, 0.0219, 0.0368, 0.0337]; +// Dot and shadow radii in the close-up, in LEDs. Io is 0.7 LEDs across at +// the lowest power, so the moons are drawn two and a half times too big. +const DOT = [0.85, 0.75, 1.15, 1.05]; +// Io yellow-orange, Europa white, Ganymede grey-brown, Callisto grey-blue, +// as they look in the eyepiece, and the albedo they show against the disc. +const EYEC = [ + [255, 196, 84], + [240, 242, 248], + [206, 178, 146], + [128, 150, 200], +]; +const ALB = [1, 1, 0.78, 0.62]; +// Chart palettes: night, red light, paper. bg0/bg1 alternate by day. +const PAL = [ + { + bg0: [8, 14, 40], bg1: [16, 28, 72], band: [196, 170, 128], shadow: [26, 18, 20], label: [104, 124, 178], + moon: [[255, 170, 40], [236, 240, 250], [190, 156, 118], [118, 146, 210]], + }, + { + bg0: [14, 0, 0], bg1: [42, 2, 0], band: [128, 12, 4], shadow: [4, 0, 0], label: [120, 8, 0], + moon: [[230, 44, 10], [255, 96, 66], [176, 26, 8], [128, 14, 4]], + }, + { + bg0: [232, 224, 204], bg1: [212, 204, 184], band: [190, 162, 118], shadow: [36, 26, 20], label: [112, 100, 88], + moon: [[222, 100, 8], [28, 32, 52], [126, 80, 42], [34, 76, 152]], + }, +]; +// 3x5 digits for the day labels. +const DIG = [0x7b6f, 0x2c97, 0x73e7, 0x73cf, 0x5bc9, 0x79cf, 0x79ef, 0x7249, 0x7bef, 0x7bcf]; + +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const sm = (a, b, x) => { + const t = cl((x - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; +const mix = (a, b, t) => a + (b - a) * t; +// Signed difference of two angles in degrees, -180..180. +const wrap = (a) => a - 360 * floor((a + 180) / 360); + +// Jupiter seen from Earth at Julian date jd: distance, phase angle, the +// declinations of the Earth and the Sun over its equator, the shadow's +// direction and the System I and II central meridians (Meeus, chapter 44). +function sky(jd, S) { + const d = jd - 2451545; + const V = (172.74 + 0.00111588 * d) * RAD; + const M = (357.529 + 0.9856003 * d) * RAD; + const N = (20.02 + 0.0830853 * d + 0.329 * sin(V)) * RAD; + const J = (66.115 + 0.9025179 * d - 0.329 * sin(V)) * RAD; + const A = (1.915 * sin(M) + 0.02 * sin(2 * M)) * RAD; + const B = (5.555 * sin(N) + 0.168 * sin(2 * N)) * RAD; + const K = J + A - B; + const R = 1.00014 - 0.01671 * cos(M) - 0.00014 * cos(2 * M); + const r = 5.20872 - 0.25208 * cos(N) - 0.00611 * cos(2 * N); + const dist = sqrt(r * r + R * R - 2 * r * R * cos(K)); + const psi = asin((R / dist) * sin(K)); + const lam = (34.35 + 0.083091 * d + 0.329 * sin(V)) * RAD + B; + S.Ds = 3.12 * RAD * sin(lam + 42.8 * RAD); + S.De = S.Ds - 2.22 * RAD * sin(psi) * cos(lam + 22 * RAD) - 1.3 * RAD * ((r - dist) / dist) * sin(lam - 100.5 * RAD); + S.lt = dist / 173; + S.ph = psi - B; + // The shadow points away from the Sun: west (right) before opposition. + S.sx = -sin(psi); + S.sz = cos(psi); + const t = d - S.lt; + S.w1 = 210.98 + 877.8169088 * t + S.ph / RAD; + S.w2 = 187.23 + 870.1869088 * t + S.ph / RAD; +} + +function makeMoons() { + const F = () => new Float64Array(4); + return { X: F(), Y: F(), Z: F(), vis: F(), sx: F(), sy: F(), son: F(), u: F() }; +} + +// The four moons at jd using the slow terms in S. X is west (right) and Y +// north in Jupiter radii, Z away from Earth. vis is how much of the moon is +// neither behind Jupiter nor in its shadow; son is how much of its shadow +// lies on the side of the disc we see, at (sx, sy). +function moons(jd, S, Mo) { + const t = jd - 2451545 - S.lt; + const u = Mo.u; + for (let i = 0; i < 4; i++) u[i] = (U0[i] + UN[i] * t) * RAD + S.ph; + const G = (331.18 + 50.310482 * t) * RAD; + const Hh = (87.45 + 21.569231 * t) * RAD; + const a = u[0], b = u[1], c = u[2]; + u[0] += 0.473 * RAD * sin(2 * (a - b)); + u[1] += 1.065 * RAD * sin(2 * (b - c)); + u[2] += 0.165 * RAD * sin(G); + u[3] += 0.843 * RAD * sin(Hh); + const rr0 = 5.9057 - 0.0244 * cos(2 * (a - b)); + const rr1 = 9.3966 - 0.0882 * cos(2 * (b - c)); + const rr2 = 14.9883 - 0.0216 * cos(G); + const rr3 = 26.3627 - 0.1939 * cos(Hh); + const sDe = sin(S.De); + for (let i = 0; i < 4; i++) { + const r = i === 0 ? rr0 : i === 1 ? rr1 : i === 2 ? rr2 : rr3; + const X = r * sin(u[i]); + const Z = -r * cos(u[i]); + const Y = Z * sDe; + Mo.X[i] = X; + Mo.Y[i] = Y; + Mo.Z[i] = Z; + let vis = 1; + if (Z > 0) vis = sm(1 - RM[i], 1 + RM[i], sqrt(X * X + (Y / 0.935) ** 2)); + const al = X * S.sx + Z * S.sz; + if (al > 0) { + const pe = X * S.sz - Z * S.sx; + const v = (al * S.Ds) / 0.935; + const w = RM[i] + al * 0.0012; + vis *= sm(1 - w, 1 + w, sqrt(pe * pe + v * v)); + } + Mo.vis[i] = vis; + Mo.son[i] = 0; + if (Z < 0) { + const cc = X * X + Z * Z - 1; + const disc = al * al - cc; + if (disc > 0) { + const tt = -al - sqrt(disc); + const zh = Z + tt * S.sz; + if (tt > 0 && zh < 0) { + const xh = X + tt * S.sx; + const yh = Y - tt * S.Ds; + Mo.sx[i] = xh; + Mo.sy[i] = yh; + Mo.son[i] = 1 - sm(0.93, 1, sqrt(xh * xh + (yh / 0.935) ** 2)); + } + } + } + } +} + +// Day of the month of the civil day that starts at jd (Meeus, chapter 7). +function dayOf(jd) { + const Z = floor(jd + 0.5); + const al = floor((Z - 1867216.25) / 36524.25); + const A = Z + 1 + al - floor(al / 4); + const B = A + 1524; + const C = floor((B - 122.1) / 365.25); + const D = floor(365.25 * C); + const E = floor((B - D) / 30.6001); + return B - D - floor(30.6001 * E); +} + +// Cloud colours at planetographic latitude lat (degrees) and System I and II +// longitudes L1, L2 (degrees, west). Writes albedo 0..1 into o. +function cloud(lat, L1, L2, o) { + const al = abs(lat); + let r = 0.94, g = 0.89, b = 0.77; + const put = (cr, cg, cb, a) => { + r += (cr - r) * a; + g += (cg - g) * a; + b += (cb - b) * a; + }; + const l1 = L1 * RAD, l2 = L2 * RAD; + // Polar hoods. + put(0.6, 0.57, 0.54, 0.85 * sm(28, 58, al)); + // North Equatorial Belt, festoons trailing from its south edge. + const ns = 6.8 + 1.1 * sin(6 * l1 + 1) + 0.6 * sin(13 * l1); + const nn = 17.5 + 1.2 * sin(2 * l2 + 0.4) + 0.7 * sin(5 * l2 + 2); + let a = sm(ns - 1, ns + 1, lat) * (1 - sm(nn - 1, nn + 1, lat)); + put(0.6, 0.37, 0.23, 0.88 * a); + // Festoons: dark plumes, unevenly spaced, slanting down into the EZ. + const fe = sin(10 * l1 + 0.12 * lat + 0.9 * sin(3 * l1 + 2)); + put(0.4, 0.43, 0.54, 0.7 * sm(0.5, 0.9, fe) * sm(1.5 + 2.5 * sin(7 * l1) ** 2, 6.5, lat) * (1 - sm(6.5, 8, lat))); + // Barges: dark red ovals in the NEB's north half. + for (const bl of [40, 165, 290]) { + const e = (wrap(L2 - bl) / 5) ** 2 + ((lat - 15) / 1.8) ** 2; + put(0.42, 0.2, 0.14, 0.9 * (1 - sm(0.5, 1.1, e))); + } + // The Great Red Spot and the hollow it presses into the SEB. + const eg = (wrap(L2 - GRS) / 8) ** 2 + ((lat + 22.5) / 5) ** 2; + // South Equatorial Belt, paler down its middle. + const ss = -19.5 + 0.9 * sin(3 * l2 + 2) + 0.5 * sin(8 * l2); + const sn = -7.5 + 0.8 * sin(4 * l2) + 0.5 * sin(11 * l1 + 1); + a = sm(ss - 1, ss + 1, lat) * (1 - sm(sn - 1, sn + 1, lat)); + a *= 1 - 0.4 * exp(-(((lat + 13.5) / 2.6) ** 2)) * (0.6 + 0.4 * sin(5 * l2 + 1)); + a *= sm(1.1, 2.4, eg); + put(0.66, 0.44, 0.28, 0.8 * a); + put(0.88, 0.45, 0.31, 1 - sm(0.6, 1, eg)); + // Temperate belts, white ovals in the south one. + const st = -29 + 0.8 * sin(3 * l2 + 4); + put(0.7, 0.55, 0.41, 0.62 * sm(st - 2.5, st - 1, lat) * (1 - sm(st + 1, st + 2.5, lat))); + for (const wl of [100, 215, 335]) { + const e = (wrap(L2 - wl) / 4.5) ** 2 + ((lat + 33) / 2.4) ** 2; + put(1, 0.99, 0.95, 0.85 * (1 - sm(0.4, 1, e))); + } + const nt = 26 + 0.9 * sin(4 * l2 + 1); + put(0.68, 0.51, 0.37, 0.6 * sm(nt - 2.5, nt - 1, lat) * (1 - sm(nt + 1, nt + 2.5, lat))); + put(0.72, 0.62, 0.52, 0.4 * exp(-(((al - 37) / 1.6) ** 2))); + o[0] = r; + o[1] = g; + o[2] = b; +} + +// A soft dot of colour c and strength a added into buffer f (stride W). +function dot(f, rows, x, y, rad, c, a) { + const x0 = max(0, floor(x - rad - 1)), x1 = min(W - 1, floor(x + rad + 1)); + const y0 = max(0, floor(y - rad - 1)), y1 = min(rows - 1, floor(y + rad + 1)); + for (let j = y0; j <= y1; j++) + for (let i = x0; i <= x1; i++) { + const k = cl(rad + 0.5 - sqrt((i - x) ** 2 + (j - y) ** 2), 0, 1) * a; + if (k <= 0) continue; + const o = (j * W + i) * 3; + f[o] += (c[0] - f[o]) * k; + f[o + 1] += (c[1] - f[o + 1]) * k; + f[o + 2] += (c[2] - f[o + 2]) * k; + } +} + +export function setup(params) { + const P = params; + P.S = {}; + P.Mo = makeMoons(); + P.eye = new Float32Array(W * C0 * 3); + P.tmp = new Float32Array(W * EYE * 3); + P.row = new Float32Array(W * 3); + P.al = new Float32Array(W * 4); + P.sh = new Float32Array(W); + P.xp = new Float64Array(4); + P.vp = new Float64Array(4); + P.cs = new Float32Array(3); + P.clock = 0; + Object.assign(P, { skyk: 0, speed: 2, power: 0, seeing: 0.06 }); + // Open five hours before the first shadow touches the limb, which at the + // opening speed brings it on at 2.5 seconds. + P.jd = nextShadow(JD0) - 5 / 24; +} + +// The next time after jd that a moon's shadow reaches the disc. +function nextShadow(jd) { + const S = {}, Mo = makeMoons(); + let was = 0; + for (let k = 0; k < 2000; k++) { + const t = jd + k * 0.01; + if (k % 10 === 0) sky(t, S); + moons(t, S, Mo); + let on = 0; + for (let i = 0; i < 4; i++) if (Mo.son[i] > 0.5) on |= 1 << i; + if (k > 0 && on & ~was) return t; + was = on; + } + return jd; +} + +export function update(dt, input, params) { + const P = params; + const k = input.knobValues || [0, 2, 0, 0.06]; + dt = cl(dt || 0, 0, 0.1); + P.skyk = cl(k[0], 0, 1); + P.speed = cl(k[1], 0.5, 24); + P.power = cl(k[2], 0, 1); + P.seeing = cl(k[3], 0, 1); + if (input.btnPressed && input.btnPressed[0]) P.jd = nextShadow(P.jd + 0.02) - 1 / 24; + P.jd += (dt * P.speed) / 24; + P.clock += dt; +} + +// The close-up eyepiece: Jupiter at Rc LEDs a radius, with seeing. +function eyepiece(P, S, Mo, Rc) { + const f = P.eye; + const see = P.seeing; + const tm = P.clock; + const cs = P.cs; + // Image motion: the whole disc wanders, and each row ripples on its own. + const gx = see * (2.6 * sin(tm * 3.1) * sin(tm * 1.27 + 1) + 1.3 * sin(tm * 7.3 + 2)); + const gy = see * (1.5 * sin(tm * 2.3 + 4) * sin(tm * 0.9) + 0.8 * sin(tm * 6.1 + 1)); + const Rp = Rc * 0.935; + const cy = EY + gy; + const w1 = S.w1, w2 = S.w2; + for (let y = 0; y < EYE; y++) { + const rip = see * 1.3 * sin(y * 0.9 + tm * 11) * sin(y * 0.37 - tm * 5.3 + 1.3); + const cx = CX + gx + rip; + const ny = (y - cy) / Rp; + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3; + const nx = (x - cx) / Rc; + const q = nx * nx + ny * ny; + const rho = sqrt(q); + // Glare round the disc: the seeing disc's wings. + const out = max(0, rho - 1) * Rc; + const halo = (4 + 110 * see) / pow(1 + (out / (2 + 10 * see)) ** 2, 1.5) * (rho > 1 ? 1 : 0); + f[o] = 3 + halo * 0.95; + f[o + 1] = 4 + halo * 0.9; + f[o + 2] = 8 + halo * 0.8; + if (rho >= 1 + 1 / Rc) continue; + const edge = cl((1 - rho) * Rc + 0.6, 0, 1); + const mu = sqrt(max(0, 1 - min(q, 1))); + const lat = -asin(cl(ny, -1, 1)) / RAD; + const lon = atan2(nx, mu) / RAD; + cloud(lat, w1 - lon, w2 - lon, cs); + let lum = 255 * (0.18 + 0.82 * pow(mu, 0.55)); + // The phase: a thin shade on the limb away from the Sun. + lum *= 1 - 0.35 * sm(0.75, 1, nx * (S.sx > 0 ? 1 : -1)) * abs(S.sx) * 5; + for (let i = 0; i < 4; i++) { + if (Mo.son[i] <= 0) continue; + const dx = (x - cx - Mo.sx[i] * Rc), dy = (y - cy + Mo.sy[i] * Rc); + lum *= 1 - 0.9 * Mo.son[i] * cl(DOT[i] + 0.55 - sqrt(dx * dx + dy * dy), 0, 1); + } + f[o] += (cs[0] * lum - f[o]) * edge; + f[o + 1] += (cs[1] * lum - f[o + 1]) * edge; + f[o + 2] += (cs[2] * lum - f[o + 2]) * edge; + } + } + // Moons in the field, in front of the disc or beside it. + for (let i = 0; i < 4; i++) { + const x = CX + gx + Mo.X[i] * Rc, y = cy - Mo.Y[i] * Rc; + if (x < -3 || x > W + 3 || Mo.vis[i] <= 0.01) continue; + const onDisc = Mo.Z[i] < 0 && Mo.X[i] ** 2 + (Mo.Y[i] / 0.935) ** 2 < 1; + const tw = 1 + see * 0.35 * sin(tm * (17 + i * 3) + i); + const c = EYEC[i].map((v) => v * (onDisc ? ALB[i] : 1) * min(1.1, tw)); + dot(f, EYE, x, y, DOT[i] * (1 + see * 0.6), c, Mo.vis[i]); + } + // Seeing blurs the image: a separable Gaussian. + const sg = 0.2 + 1.7 * see; + if (sg > 0.35) { + const rad = min(5, Math.ceil(sg * 2.5)); + const ker = []; + let ks = 0; + for (let i = -rad; i <= rad; i++) ks += ker[i + rad] = exp(-(i * i) / (2 * sg * sg)); + const t = P.tmp; + for (let y = 0; y < EYE; y++) + for (let x = 0; x < W; x++) + for (let c = 0; c < 3; c++) { + let s = 0; + for (let i = -rad; i <= rad; i++) s += ker[i + rad] * f[(y * W + cl(x + i, 0, W - 1)) * 3 + c]; + t[(y * W + x) * 3 + c] = s / ks; + } + for (let y = 0; y < EYE; y++) + for (let x = 0; x < W; x++) + for (let c = 0; c < 3; c++) { + let s = 0; + for (let i = -rad; i <= rad; i++) s += ker[i + rad] * t[(cl(y + i, 0, EYE - 1) * W + x) * 3 + c]; + f[(y * W + x) * 3 + c] = s / ks; + } + } +} + +// The low-power line under the close-up: Jupiter and its moons at the chart's scale. +function line(P, Mo, Rj, Rc) { + const f = P.eye; + // Two faint lines from under the close-up down to the planet it magnifies. + for (const s of [-1, 1]) { + const xa = CX + s * Rc * 0.45, ya = EY + Rc * 0.935 + 1; + const xb = CX + s * (Rj + 0.8), yb = LY - 1.5; + const n = max(2, round(yb - ya)); + for (let k = 0; k <= n; k++) { + const x = round(mix(xa, xb, k / n)), y = round(mix(ya, yb, k / n)); + const o = (y * W + x) * 3; + if (y < C0 && x >= 0 && x < W) for (let c = 0; c < 3; c++) f[o + c] = max(f[o + c], [34, 40, 62][c]); + } + } + const hj = min(Rj * 0.935, 2.2); + for (let y = LY - 3; y <= LY + 3; y++) + for (let x = 0; x < W; x++) { + const nx = (x - CX) / Rj, ny = (y - LY) / hj; + const rho = sqrt(nx * nx + ny * ny); + const k = cl((1 - rho) * max(1.2, Rj) + 0.7, 0, 1); + if (k <= 0) continue; + const lum = 0.55 + 0.45 * sqrt(max(0, 1 - rho * rho)); + const o = (y * W + x) * 3; + f[o] += (236 * lum - f[o]) * k; + f[o + 1] += (220 * lum - f[o + 1]) * k; + f[o + 2] += (186 * lum - f[o + 2]) * k; + } + for (let i = 0; i < 4; i++) { + if (Mo.vis[i] <= 0.01) continue; + dot(f, C0, CX + Mo.X[i] * Rj, LY - Mo.Y[i] * Rj, 0.7, EYEC[i], Mo.vis[i]); + } +} + +function blend(k1) { + const s = k1 * 2; + const i = min(1, floor(s)), t = sm(0, 1, s - i); + const a = PAL[i], b = PAL[i + 1]; + const m = (u, v) => u.map((x, j) => mix(x, v[j], t)); + const o = {}; + for (const key of ["bg0", "bg1", "band", "shadow", "label"]) o[key] = m(a[key], b[key]); + o.moon = a.moon.map((c, j) => m(c, b.moon[j])); + return o; +} + +// The chart: ten rows a day at the lowest power, now at row LY, the day +// numbers down the left edge under the line each day starts on. +function chart(P, display, pal, Rj) { + const rpd = 10 * pow(Rj / 1.15, 0.6); + const S = P.S2 || (P.S2 = {}); + const Mo = P.Mo2 || (P.Mo2 = makeMoons()); + const { row, al, sh } = P; + const hw = 0.55; + const marks = new Set(); + for (let d = floor(P.jd + (C0 - 6 - LY) / rpd - 0.5) + 1.5; d < P.jd + (H - LY) / rpd; d++) { + const n = dayOf(d + 0.01), yb = round(LY + (d - P.jd) * rpd); + const s = n < 10 ? [n] : [floor(n / 10), n % 10]; + s.forEach((dg, j) => { + for (let b = 0; b < 15; b++) if ((DIG[dg] >> (14 - b)) & 1) marks.add((yb + 1 + floor(b / 3)) * W + 1 + j * 4 + (b % 3)); + }); + } + for (let y = C0; y < H; y++) { + const tc = P.jd + (y - LY) / rpd; + const ta = tc - 0.5 / rpd, tb = tc + 0.5 / rpd; + // Alternate days light and dark, split where a day starts inside the row. + const da = floor(ta - 0.5); + let odd = da & 1; + const nb = da + 1.5; + if (nb < tb) { + const fr = (tb - nb) * rpd; + odd = odd * (1 - fr) + (1 - (da & 1)) * fr; + } + for (let x = 0; x < W; x++) { + const lab = marks.has(y * W + x); + for (let c = 0; c < 3; c++) row[x * 3 + c] = lab ? pal.label[c] : mix(pal.bg0[c], pal.bg1[c], odd); + } + // Jupiter's band, rounded off by its limb. + for (let x = floor(CX - Rj - 1); x <= CX + Rj + 1; x++) { + const k = cl(min(x + 0.5, CX + Rj) - max(x - 0.5, CX - Rj), 0, 1); + if (k <= 0) continue; + const dx = (x - CX) / Rj; + const lum = 0.62 + 0.38 * sqrt(max(0, 1 - dx * dx)); + for (let c = 0; c < 3; c++) row[x * 3 + c] = mix(row[x * 3 + c], pal.band[c] * lum, k); + } + sky(tc, S); + al.fill(0); + sh.fill(0); + const xp = P.xp, vp = P.vp; + for (let s = 0; s <= 4; s++) { + moons(ta + (s / 4) * (tb - ta), S, Mo); + for (let i = 0; i < 4; i++) { + const x = CX + Mo.X[i] * Rj, v = Mo.vis[i]; + if (Mo.son[i] > 0) { + const xs = CX + Mo.sx[i] * Rj; + for (let xx = max(0, floor(xs - 1)); xx <= min(W - 1, xs + 1); xx++) + sh[xx] = max(sh[xx], Mo.son[i] * cl(1 - abs(xx - xs) * 0.8, 0, 1)); + } + if (s > 0) { + const lo = min(x, xp[i]) - hw, hi = max(x, xp[i]) + hw, vv = min(v, vp[i]); + if (vv > 0.02 && hi > -1 && lo < W) + for (let xx = max(0, floor(lo)); xx <= min(W - 1, floor(hi + 1)); xx++) { + const k = cl(min(xx + 0.5, hi) - max(xx - 0.5, lo), 0, 1) * vv; + if (k > al[xx * 4 + i]) al[xx * 4 + i] = k; + } + } + xp[i] = x; + vp[i] = v; + } + } + for (let x = 0; x < W; x++) { + if (sh[x] > 0) for (let c = 0; c < 3; c++) row[x * 3 + c] = mix(row[x * 3 + c], pal.shadow[c], sh[x] * 0.9); + for (let i = 3; i >= 0; i--) { + const k = al[x * 4 + i]; + if (k > 0) for (let c = 0; c < 3; c++) row[x * 3 + c] = mix(row[x * 3 + c], pal.moon[i][c], k); + } + display.setPixel(x, y, min(255, row[x * 3]), min(255, row[x * 3 + 1]), min(255, row[x * 3 + 2])); + } + } +} + +export function draw(display, params) { + const P = params; + if (!P.eye) setup(P); + const S = P.S, Mo = P.Mo; + sky(P.jd, S); + moons(P.jd, S, Mo); + const p = P.power; + const Rj = 1.15 * pow(5.1 / 1.15, p); + const Rc = 14 + 2.5 * p; + eyepiece(P, S, Mo, Rc); + line(P, Mo, Rj, Rc); + // Red light turns the eyepiece's colours to shades of red. + const red = sm(0, 1, 1 - abs(P.skyk * 2 - 1)); + const f = P.eye; + for (let i = 0, o = 0; i < W * C0; i++, o += 3) { + let r = f[o], g = f[o + 1], b = f[o + 2]; + if (red > 0) { + const l = 0.5 * r + 0.4 * g + 0.1 * b; + r = mix(r, l, red); + g = mix(g, l * 0.06, red); + b = mix(b, 0, red); + } + display.setPixel(i % W, (i / W) | 0, min(255, r), min(255, g), min(255, b)); + } + const pal = blend(P.skyk); + chart(P, display, pal, Rj); +} diff --git a/incoming/pattern-study/patterns/liftoff.js b/incoming/pattern-study/patterns/liftoff.js new file mode 100644 index 000000000..1ac231a59 --- /dev/null +++ b/incoming/pattern-study/patterns/liftoff.js @@ -0,0 +1,413 @@ +// ===== Patternflow pattern ===== +// Title: Liftoff +// Family: flight +// Idea: A rocket vents on its pad across the water, lights up in rolling steam and climbs while the camera tilts after it from blue sky into black and stars, through Max-Q and staging, until it is a bright dot and the next rocket stands on the pad. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after long-lens tracking shots of launches filmed across the water from the press site +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Rocket=0..3, Speed=0.2..3, Stages=1..3, Time=0..1 +// +// Knob 1: Rocket. A white moon rocket by its tower at 0, a chequered V-2 at 1, a grey-green cluster whose boosters fall away in a cross at 2, a sounding rocket on a rail at 3. +// Knob 2: Speed. At 2 the countdown, the flight and the next rocket take 35 s; at 0.2 ten times longer. +// Knob 3: Stages. 1 to 3 stages. A taller stack gets a taller tower and more steam, and each extra stage drops away on the climb. +// Knob 4: Time. Dawn at 0, then day, dusk and a floodlit night with searchlights. At dawn and dusk the high plume catches the sun. +// Buttons: K1 tap skips the countdown to ignition, or after liftoff rolls out the next rocket. + +const W = 64; +const H = 128; +const N = W * H; +// Shoreline row with the camera level, pad column, deck height. +const GY = 112; +const X0 = 36; +const DECK = 4; +const MAXP = 900; +// A puff: x, height, vx, vz, radius, growth, age, life, opacity, heat, +// whiteness, drag, sprite. +const PF = 13; +const SP = 24; +const { min, max, abs, sin, cos, exp, sqrt, floor, round, ceil, atan2, hypot } = Math; +const rnd = () => Math.random(); +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); +const ss = (a, b, v) => { + const t = clamp((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; +// Dawn, day, dusk, night: four sky colours, ambient, sun colour; sun on the +// rocket, on the high plume, floodlights, stars, sun column, height, radius. +const PAL = [ + [255, 150, 90, 200, 135, 150, 80, 105, 190, 22, 36, 110, 80, 88, 125, 255, 175, 120, 0.3, 1, 0.75, 0.15, 56, 0, 3.5], + [200, 222, 245, 140, 190, 240, 55, 135, 230, 15, 55, 165, 175, 185, 200, 255, 250, 235, 1, 0, 0, 0, 12, 190, 3.5], + [255, 115, 55, 185, 85, 115, 70, 55, 140, 18, 18, 70, 95, 72, 100, 255, 135, 75, 0.35, 1, 0.6, 0.3, 8, 3, 4], + [22, 34, 62, 14, 22, 50, 7, 12, 34, 3, 5, 16, 30, 34, 55, 0, 0, 0, 0, 0.1, 1, 1, 30, -40, 0], +]; +// Kerosene, hydrogen, solid: flame core, middle, edge, glow; trail +// whiteness and thickness. +const FUEL = [ + [[255, 246, 215], [255, 196, 80], [235, 105, 30], [255, 140, 55], 0.62, 0.75], + [[235, 240, 255], [170, 180, 255], [120, 110, 230], [130, 150, 255], 0.95, 0.22], + [[255, 252, 240], [255, 225, 150], [250, 160, 70], [255, 190, 120], 0.95, 0.95], +]; +const ACC = [1, 1.15, 1.05, 1.7]; +const LEAN = [0, 0, 0, 0.12]; +const STEAM = [1, 0.55, 0.9, 0.35]; +const IGN = [-2, -1.6, -2.2, -0.12]; +// White, black, engine bells, bare metal, grey-green, orange. +const PAINT = [[0.9, 0.9, 0.88], [0.08, 0.08, 0.08], [0.24, 0.21, 0.2], [0.66, 0.69, 0.74], [0.62, 0.67, 0.58], [0.95, 0.42, 0.12]]; +// 3x5 letters: 0-9, T, minus, plus. +const FONT = [31599, 11415, 29671, 29647, 23497, 31183, 31215, 29330, 31727, 31695, 29842, 448, 1488]; +const BEAMS = [[3, 0.32], [62, -0.3], [22, 0.06]].map(([b, a]) => [b, sin(a), cos(a)]); +const BOOST = [[-1.4, 0, 1.9, 1.4, 2, 0], [0, 1, 2.1, 2.05, 1, 0], [1, 13, 2.05, 1.1, 10, 0], [13, 18, 1.1, 0, 10, 0]]; + +// Stages from the bottom: length, segments [u0, u1, half widths, paint], +// bell [length, half widths], nozzle, fuel, fins [u0, u1, span, paint]. +function build(sty, n) { + const S = (len, segs, bell, wn, fuel, fins = []) => ({ len, segs, bell, wn, fuel, fins }); + const a = 20 + (3 - n) * 2.5, b = n > 1 ? 26 : 29; + const st = [ + [S(a, [[0, a, 4.5, 4.5, 6]], [2.5, 4.4, 3.4], 4.3, 0, [[0, 5, 2.4, 1]]), S(13, [[0, 1, 4.5, 4.5, 1], [1, 13, 4.5, 4.5, 0]], [1.5, 3, 2.2], 3, 1), S(10, [[0, 3, 4.5, 3.3, 0], [3, 10, 3.3, 3.3, 7]], [1.2, 1.8, 1.2], 1.6, 1)], + [S(b, [[0, 20, 3.2, 3.4, 8], [20, b, 3.4, n > 1 ? 1.4 : 0, 8]], [1.5, 2.4, 1.8], 2.2, 0, [[0, 7, 3.4, 8]]), S(12, [[0, 12, 1.4, 1.4, 0]], [0.8, 1.1, 0.8], 1, 2, [[0, 3, 1.7, 1]]), S(8, [[0, 8, 1.1, 1.1, 9]], [0.7, 0.9, 0.6], 0.8, 2, [[0, 2.5, 1.2, 5]])], + [S(28, [[0, 26, 2.2, 2.8, 10], [26, 28, 2.8, 2.5, 10]], [1.5, 2.2, 1.6], 2.2, 0), S(10, [[0, 2, 2.4, 2.4, 11], [2, 10, 2.6, 2.6, 10]], [1.2, 1.8, 1.2], 1.6, 0), S(4, [[0, 4, 2.6, 2.6, 3]], [0.8, 1.2, 0.8], 1, 1)], + [S(15, [[0, 15, 1.7, 1.7, 9]], [1, 1.4, 1.1], 1.4, 2, [[0, 4.5, 2.6, 5]]), S(12, [[0, 1.5, 1.7, 1.3, 1], [1.5, 12, 1.3, 1.3, 9]], [0.8, 1.1, 0.8], 1.1, 2, [[1.5, 4, 1.7, 5]]), S(9, [[0, 9, 1.1, 1.1, 9]], [0.6, 0.9, 0.6], 0.8, 2, [[0, 2.5, 1.2, 5]])], + ][sty].slice(0, n); + const last = st[n - 1], top = last.segs.at(-1)[3]; + last.nose = sty === 0 ? [[0, 4, top, 2.6, 3], [4, 7, 2.6, 0.7, 0], [7, 11, 0.45, 0.45, 5], [11, 13, 0.9, 0.9, 5], [13, 14, 0.9, 0.1, 5]] : sty === 2 ? [[0, 5, 3.2, 3.2, 0], [5, 9, 3.2, 1.4, 0], [9, 10, 1.4, 0.5, 0], [10, 14, 0.4, 0.4, 5], [14, 16, 0.8, 0.8, 5], [16, 17, 0.8, 0, 5]] : top > 0 ? [[0, sty === 3 ? 6 : 4, top, 0, 0]] : []; + let u = 0; + for (const s of st) [s.u, u] = [u, u + s.len]; + return { st, n, L: u + (last.nose.length && last.nose.at(-1)[1]), sep: st.slice(1).map((_, i) => (sty === 2 ? 11.5 + i * 4.5 : 9.5 + i * 5)), boost: sty === 2 }; +} + +// Stages a..b-1 end to end from 0, with stage a's bell below. +function stack(V, a, b) { + const segs = [], fins = [], u0 = V.st[a].u, bl = V.st[a].bell; + segs.push([-bl[0], 0, bl[1], bl[2], 2, 0]); + for (let i = a; i < b; i++) { + const s = V.st[i], o = s.u - u0, t = o + s.len; + for (const g of s.segs) segs.push([g[0] + o, g[1] + o, g[2], g[3], g[4], o]); + for (const f of s.fins) fins.push([f[0] + o, f[1] + o, f[2], f[3], o]); + if (s.nose) for (const g of s.nose) segs.push([g[0] + t, g[1] + t, g[2], g[3], g[4], t]); + } + return { segs, fins }; +} + +const vehicle = (P) => P.vc[P.style * 4 + P.n] || (P.vc[P.style * 4 + P.n] = build(P.style, P.n)); + +// The stack's foot at flight time T, and its engines. +function fly(P, V, T) { + const sty = P.style, t = max(0, T), g = ACC[sty], E = g * 512 * (1 - exp((-t * t) / 128)); + let ai = 0, f = ss(IGN[sty], IGN[sty] + 0.6, T); + for (const ts of V.sep) { + if (T >= ts) ai++; + f *= 1 - ss(ts - 0.3, ts, T) + ss(ts + 0.7, ts + 1.1, T); + } + const bo = V.boost && T < 7.5, S = V.st[ai], wn = bo ? 6.2 : S.wn; + return { E, ai, f, wn, s: 1 / (1 + 0.001 * g * t * t * t), a: LEAN[sty] + 0.2 * ss(3, 16, t), x: X0 + 6 * ss(3, 20, t) + (sty === 3 ? 0.12 * min(E, 70) : 0), e: DECK + V.st[0].bell[0] + E, Lf: wn * (S.fuel === 2 ? 4.2 : 3.2) * (1 + E / 260) * f, k: 0.12 + min(0.9, E / 500) * (S.fuel === 1 ? 1.4 : 1), fuel: bo ? 0 : S.fuel }; +} + +// The camera's tilt in rows keeps the nose near the top, then lets the +// shrinking rocket climb. +function camera(V, T, F) { + const d = F.E - (GY - DECK - V.st[0].bell[0] - V.L * F.s - 15 + 9 * ss(12, 23, T)), h = max(14 - abs(d), 0) / 14; + return max(0, d) + h * h * 3.5; +} + +export function setup(params) { + const P = params, F = (n) => new Float32Array(n); + Object.assign(P, { buf: F(N * 3), G: F(N), D: F(N * 5), pa: F(MAXP * PF), spD: F(4 * SP * SP), spS: F(4 * SP * SP), vc: {}, started: false, T: -4.3, np: 0, clk: 0, pt: null }); + // Lumpy puffs lit from above. + for (let q = 0; q < 4 * SP * SP; q++) { + const k = floor(q / (SP * SP)), dx = (((q % SP) + 0.5) / SP) * 2 - 1, dy = (((floor(q / SP) % SP) + 0.5) / SP) * 2 - 1; + const d = hypot(dx, dy), an = atan2(dy, dx), edge = 0.8 + 0.1 * sin(5 * an + k * 1.7) + 0.07 * sin(9 * an + k * 2.9) + 0.04 * sin(13 * an + k); + P.spD[q] = ss(edge, edge - 0.45, d) * (0.78 + 0.22 * sin(dx * 7 + k) * sin(dy * 6 + k * 2)); + P.spS[q] = clamp(0.55 - 0.55 * dy + 0.12 * dx + (d > edge - 0.2 && dy < 0 ? 0.25 : 0), 0, 1); + } + P.stars = Array.from({ length: 240 }, () => [floor(rnd() * W), 30 + rnd() * 620, 0.25 + 0.75 * rnd() ** 3]); + P.shore = Array.from({ length: W }, (_, x) => 3 + 1.2 * sin(x * 0.7) * sin(x * 0.23 + 1) + (x % 11 < 3 ? 1.8 : 0) + (x > 52 ? 1.2 : 0)); +} + +function newRocket(P, T) { + [P.T, P.np, P.pt] = [T, 0, null]; +} + +function emit(P, ...v) { + if (P.np < MAXP) P.pa.set([...v.slice(0, 6), 0, ...v.slice(6), floor(rnd() * 4)], P.np++ * PF); +} + +function times(n, fn) { + for (let c = n; c > 0 && (c >= 1 || rnd() < c); c--) fn(); +} + +function step(P, h) { + P.T += h; + if (P.T > 25) return newRocket(P, -10); + const T = P.T, V = vehicle(P), sty = P.style, F = fly(P, V, T), C = camera(V, T, F), s = F.s, sa = sin(F.a), ca = cos(F.a); + // Before ignition liquid oxygen boils off and falls down the tanks. + if (T < IGN[sty] + 0.4 && sty !== 3) + for (const S of V.st) + times(S.fuel === 2 ? 0 : h * 34, () => { + const sd = rnd() < 0.6 ? 1 : -1; + emit(P, F.x + sd * (S.segs.at(-1)[3] - 0.3), F.e + S.u + S.len * (0.35 + 0.6 * rnd()), sd * (0.8 + 2.5 * rnd()), -3 - 5 * rnd(), 0.6, 1.5, 1.4 + rnd(), 0.2, 0, 0.95, 0.4); + }); + if (F.f > 0.02) { + const uN = V.st[F.ai].u - V.st[F.ai].bell[0], tl = F.Lf * s * 0.85, tx = F.x + sa * (uN * s - tl), tz = F.e + ca * (uN * s - tl), fu = FUEL[F.fuel]; + // Steam rolls out of the trench both ways and up round the rocket. + if (tz < 24 && F.E < 40) + times(55 * STEAM[sty] * (0.25 + 0.35 * P.n) * F.f * (1 - F.E / 40) * h, () => { + const sd = rnd() < 0.5 ? -1 : 1; + if (rnd() < 0.72) emit(P, X0 + sd * (8 + 2 * rnd()), 1 + rnd() * 2, sd * (18 + 34 * rnd()) * (0.6 + 0.4 * F.f), 3 + 9 * rnd(), 2.2, 3.6 + 2 * rnd(), 16, 0.5, 0.7, 0.95, 0.55); + else emit(P, X0 + sd * rnd() * 7, DECK + rnd() * 3, sd * 4 * rnd(), 10 + 16 * rnd(), 2, 2.8, 12, 0.45, 0.9, 0.95, 0.9); + }); + // The trail, laid along the path since the last step. + if (T > 0 && tz > DECK + 6) { + const [px, pz] = P.pt || [tx, tz], r0 = max(0.6, F.wn * s * 1.05), hi = P.tw * ss(140, 300, tz); + const cnt = min(14, ceil(max(hypot(tx - px, tz - pz) / max(0.6, r0 * 0.6), h * 24))); + for (let j = 1; j <= cnt; j++) emit(P, px + ((tx - px) * j) / cnt + (rnd() - 0.5) * r0, pz + ((tz - pz) * j) / cnt, (rnd() - 0.5) * 2 * s, -1.5 * s, r0, (1.1 + 1.8 * hi) * sqrt(s + 0.08) * 2, 30, fu[5] * (0.75 + 0.25 * rnd()) * (1 - 0.4 * hi), 1, fu[4], 0.05); + P.pt = [tx, tz]; + } + } else P.pt = null; + // Puffs spread, rise and thin, and go once below the panel. + const pa = P.pa; + for (let i = 0; i < P.np; i++) { + const o = i * PF, dr = exp(-pa[o + 11] * h); + pa[o + 6] += h; + pa[o + 2] *= dr; + pa[o + 3] = pa[o + 3] * dr + (pa[o + 11] > 0.3 ? 2 * h : 0); + pa[o] += (pa[o + 2] + 1.2 + pa[o + 1] * 0.012) * h; + pa[o + 1] += pa[o + 3] * h; + if (pa[o + 6] > pa[o + 7] || GY + C - pa[o + 1] - pa[o + 4] - pa[o + 5] * sqrt(pa[o + 6]) > 130 || abs(pa[o] - 32) > 62) { + pa.copyWithin(o, --P.np * PF, P.np * PF + PF); + i--; + } + } +} + +export function update(dt, input, params) { + const P = params, k = input.knobValues || [0, 2, 1, 0.06]; + dt = clamp(dt, 0, 0.1); + P.style = clamp(round(k[0]), 0, 3); + P.n = clamp(round(k[2]), 1, 3); + P.tod = clamp(k[3], 0, 1); + // The hour's colours; the sun lingers low at dawn and dusk. + const f = P.tod * 3, i = min(2, floor(f)), q = f - i, A = PAL[i], B = PAL[i + 1]; + P.pal = A.map((v, j) => v + (B[j] - v) * q); + P.se = A[23] + (B[23] - A[23]) * (i === 0 ? q * q : i === 1 ? 1 - (1 - q) ** 2 : q); + [P.sunI, P.tw, P.lights, P.night] = [P.pal[18], P.pal[19], P.pal[20], ss(0.67, 1, P.tod)]; + if (!P.started) { + P.started = true; + // Open 2.3 s before liftoff, with the tanks already venting. + for (let j = 0; j < 20; j++) step(P, 0.1); + } + if (input.btnPressed && input.btnPressed[0]) { + if (P.T < IGN[P.style] - 0.3) P.T = IGN[P.style] - 0.3; + else if (P.T > 2) newRocket(P, -3); + } + P.clk += dt; + step(P, (dt * clamp(k[1], 0.2, 3)) / 2); +} + +// Lights a surface facing vn across a cylinder, laid over the LED. +function shade(P, p, vn, fl, r, g, b, a) { + const c = P.pal, o = p * 3, buf = P.buf, sn = P.sunK * max(0, 0.25 + 0.75 * vn * P.side), am = 1 - 0.28 * vn * vn, fr = 235 * fl * (1 - 0.35 * vn * vn); + [r, g, b].forEach((k, j) => (buf[o + j] += (k * (c[12 + j] * am + c[15 + j] * sn + fr + P.G[p] * P.gc[j]) - buf[o + j]) * a)); +} + +// Patterned paints: moon rocket stages 1 and 3, V-2 quarters, bands, +// grey-green, truss. +function albedo(c, u, vn) { + if (c === 6) c = (u > 0.5 && u < 7 && vn > -0.1 && vn < 0.5) || (u > 15.5 && u < 17) ? 1 : 0; + else if (c === 7) c = u > 5 && u < 8.5 && vn > 0.05 ? 1 : 0; + else if (c === 8) c = (floor(u / 6.5) + (vn > 0)) & 1; + else if (c === 9) c = u % 7 < 1.4 ? 5 : 0; + else if (c > 9) c = c === 10 ? 4 : (floor(u * 1.5) + floor((vn + 1) * 2.5)) & 1 ? 1 : 3; + return PAINT[c]; +} + +// One body from its foot at (b.x, b.y), leaning b.ang, at scale b.sc. +function body(P, b) { + const sa = sin(b.ang), ca = cos(b.ang), sc = b.sc, fl = b.flame, buf = P.buf; + let u0 = 0, u1 = 0, hm = 0; + for (const g of b.segs) [u0, u1, hm] = [min(u0, g[0]), max(u1, g[1]), max(hm, g[2], g[3])]; + hm += b.fins.length ? 3.5 : 0; + const fu = u0; + if (fl) [u0, hm] = [u0 - fl.Lf * 3.2, max(hm + 5, fl.wn + (fl.k + 0.12) * fl.Lf * 3.2 + 1)]; + const m = (u0 + u1) / 2, cx = b.x + m * sa * sc, cy = b.y - m * ca * sc, R = hypot((u1 - u0) / 2, hm) * sc + 2; + const fc = fl && FUEL[fl.fuel], flood = P.lights * 0.9 * exp(-b.E / 45); + for (let y = max(0, floor(cy - R)); y <= min(H - 1, ceil(cy + R)); y++) + for (let x = max(0, floor(cx - R)); x <= min(W - 1, ceil(cx + R)); x++) { + const dx = x + 0.5 - b.x, dy = b.y - (y + 0.5), p = y * W + x; + const u = (dx * sa + dy * ca) / sc, v = (dx * ca - dy * sa) / sc, av = abs(v); + let seg = null; + for (const g of b.segs) if (u >= g[0] && u < g[1]) seg = g; + if (seg) { + const hw = seg[2] + ((seg[3] - seg[2]) * (u - seg[0])) / (seg[1] - seg[0]); + let cov = clamp((hw - av) * sc + 0.5, 0, 1), vn = clamp(v / max(0.3, hw), -1, 1), c = seg[4], uu = u - seg[5]; + if (cov <= 0) + for (const f of b.fins) + if (u >= f[0] && u < f[1]) { + const cf = clamp((hw + f[2] * (1 - (u - f[0]) / (f[1] - f[0])) - av) * sc + 0.5, 0, 1); + if (cf > cov) [cov, vn, c, uu] = [cf, v > 0 ? 0.6 : -0.6, f[3], u - f[4]]; + } + const A = albedo(c, uu, vn), hc = hw + 0.6 + (b.uq + 0.5 - u) * 0.6; + if (cov > 0) shade(P, p, vn, flood, A[0], A[1], A[2], cov * b.al); + // Max-Q: a collar of condensation round the body. + else if (b.q > 0 && u > b.uq - 7 && u < b.uq + 0.5 && av < hc) shade(P, p, v / hc, 0.4, 1, 1, 1, b.q * 0.95 * ss(0, 1.2, b.uq + 0.5 - u) * (1 - (av / hc) ** 3) * (0.75 + 0.25 * sin(u * 3 + P.clk * 40))); + continue; + } + // The flame, clipped where it meets the deck. + if (!fl || u >= fu || GY + P.Ci - y - 0.5 < DECK + 0.5) continue; + const ue = fu - u, q = av / (fl.wn * 0.85 + (fl.k + 0.12) * ue); + if (q >= 1.2) continue; + const I = (exp(-ue / fl.Lf) * max(0, 1 - q * q) * (fl.fuel === 0 && b.E < 140 ? 0.8 + 0.3 * cos((ue * 2.4) / fl.wn) : 1) * fl.f) / (1 + fl.k * 1.2); + const core = exp(-ue / (fl.Lf * 0.35)) * max(0, 1 - q * 1.7) * fl.f; + const tt = min(1, I * 0.9 + core) * 2, br = min(1.2, I * 1.7 + core), A = fc[tt < 1 ? 2 : 1], B = fc[tt < 1 ? 1 : 0], w = tt < 1 ? tt : tt - 1; + for (let j = 0; j < 3; j++) buf[p * 3 + j] += (A[j] + (B[j] - A[j]) * w) * br; + } +} + +// The cluster's boosters; at 7.5 s the side ones pivot out and fall. +function boosters(P, V, T, F, sides) { + const dt = T - 7.5, sep = dt > 0, S0 = sep ? fly(P, V, 7.5) : F; + if (dt > 5) return; + const drop = sep ? ((fly(P, V, 7.55).E - S0.E) / 0.05) * 1.1 * (1 - exp(-dt / 1.1)) : 0; + for (const sd of sides) { + const off = sd * 3.7 * S0.s; + body(P, { segs: BOOST, fins: [], x: S0.x + cos(S0.a) * off + (sep ? sd * 2 * dt : 0), y: GY + P.Ci - S0.e - drop + sin(S0.a) * off, ang: S0.a + sd * (sep ? 0.35 + 0.9 * dt : -0.05), sc: S0.s / (1 + (sep ? 0.06 * dt : 0)), al: 1 - ss(3, 5, max(0, dt)), E: sep ? 300 : F.E }); + } +} + +function sky(P) { + const c = P.pal, buf = P.buf, Ci = P.Ci, sx = c[22], se = P.se, sr = c[24], sunA = 1 - P.night, sk = [0, 0, 0], bi = P.lights * (0.25 + 0.75 * P.night) * 55; + for (let y = 0; y < H; y++) { + const a = GY + Ci - y - 6, e = a + 6, gl = sunA * 0.55 * exp(-abs(e - se) / 26); + const [A, B, q] = a < 22 ? [0, 3, max(0, a) / 22] : a < 100 ? [3, 6, (a - 22) / 78] : a < 240 ? [6, 9, (a - 100) / 140] : [9, -1, min(1, (a - 240) / 180)]; + for (let j = 0; j < 3; j++) sk[j] = c[A + j] + ((B < 0 ? 0 : c[B + j]) - c[A + j]) * q; + // The sun's glow and disc, and searchlights from the pad. + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3, dx = x + 0.5 - sx, g = gl * exp(-(dx * dx) / 420), disc = sr > 0 ? sunA * clamp(sr - hypot(dx, e - se) + 0.5, 0, 1) : 0; + let v = 0; + if (bi > 1 && e > 0) + for (const [bx, sn, cs] of BEAMS) { + const d = x + 0.5 - bx, al = d * sn + e * cs, pp = d * cs - e * sn, w = 1.2 + al * 0.035; + v += (bi * exp(-(pp * pp) / (w * w))) / (1 + al / 160); + } + for (let j = 0; j < 3; j++) buf[o + j] = sk[j] + c[15 + j] * g + (c[15 + j] * 0.8 + 50) * disc + v * [0.75, 0.82, 1][j]; + } + } + // Stars come out as the sky darkens. + P.stars.forEach(([x, e, m], i) => { + const y = GY + Ci - floor(e), v = m * (c[21] + (1 - c[21]) * ss(250, 420, e)) * (0.8 + 0.2 * sin(P.clk * 3 + i)) * 235, o = (y * W + x) * 3; + if (y >= 0 && y < H) for (let j = 0; j < 3; j++) buf[o + j] += v * (j > 1 ? 1 : 0.9); + }); +} + +// A lit beam between two points, in rows above the shoreline. +function line(P, x0, e0, x1, e1, w, r, g, b, fl) { + const Ci = P.Ci, dx = x1 - x0, de = e1 - e0, l2 = dx * dx + de * de || 1; + for (let y = max(0, floor(GY + Ci - max(e0, e1) - w)); y <= min(H - 1, ceil(GY + Ci - min(e0, e1) + w)); y++) + for (let x = max(0, floor(min(x0, x1) - w)); x <= min(W - 1, ceil(max(x0, x1) + w)); x++) { + const px = x + 0.5 - x0, pe = GY + Ci - y - 0.5 - e0, t = clamp((px * dx + pe * de) / l2, 0, 1), a = clamp(w * 0.5 - hypot(px - dx * t, pe - de * t) + 0.5, 0, 1); + if (a > 0) shade(P, y * W + x, 0, fl, r, g, b, a); + } +} + +// Shore, pad, the moon rocket's tower and arms, the sounding rocket's rail. +function pad(P, V, T) { + const Ci = P.Ci, sty = P.style, tl = P.lights * 0.5, th = DECK + V.L + 6, tx0 = X0 - 19, tx1 = X0 - 10, buf = P.buf; + for (let y = max(0, GY + Ci - 90); y <= min(H - 1, GY + Ci); y++) { + const e = GY + Ci - y; + for (let x = 0; x < W; x++) { + const p = y * W + x, k = e === 4 ? 0.62 : 0.45, q = 0.2 + ((x * 7 + e * 3) % 5) * 0.012, i = x - tx0, j = e % 9; + if (e <= 4 && abs(x + 0.5 - X0) < 11 - e * 1.2) shade(P, p, (x + 0.5 - X0) / 14, tl, k, k, k, 1); + else if (e <= P.shore[x]) shade(P, p, 0, 0, q * 0.9, q, q * 0.8, 1); + else if (e <= 6) for (let j = 0; j < 3; j++) buf[p * 3 + j] = P.pal[j] * 0.55 + buf[p * 3 + j] * 0.2; + // The tower: an X-braced lattice with a hammerhead crane on top. + if (sty || e <= 0) continue; + if ((i >= 0 && x <= tx1 && e <= th && (i === 0 || i === 9 || j === 0 || i === j || i === 9 - j)) || (e >= th && e <= th + 1 && i >= -2 && x <= X0 + 2) || (e > th + 1 && e <= th + 3 && i >= 2 && i <= 5)) shade(P, p, i / 9 - 0.5, tl, 0.52, 0.54, 0.6, 1); + if (e === th + 4 && (i === 3 || x === X0 + 2) && sin(P.clk * 5) > 0) buf.set([255, 40, 30], p * 3); + } + } + // Swing arms to the stage joints and the capsule; the crew arm leaves first. + if (sty === 0) + V.st.map((s) => s.u + s.len - 1.5).concat([V.L - 11]).forEach((u, i, lv) => { + const t0 = i === lv.length - 1 ? -6 : 0.1 + i * 0.25, reach = (X0 - 4.3 - tx1) * (1 - ss(t0, t0 + 1.4, T)); + if (reach > 0.3) line(P, tx1 + 0.5, DECK + 2.5 + u, tx1 + 0.5 + reach, DECK + 2.5 + u, 1.8, 0.5, 0.52, 0.58, tl); + }); + if (sty === 3) line(P, X0 - 2.2, DECK, X0 - 2.2 + sin(LEAN[3]) * V.L * 0.8, DECK + cos(LEAN[3]) * V.L * 0.8, 1.8, 0.55, 0.55, 0.6, tl); +} + +// Puffs add density, shading, heat, sunlight and whiteness; then the smoke +// is lit. +function smoke(P) { + const { D, spD, spS, buf, G, gc, pa, Ci, pal: c } = P; + D.fill(0); + for (let i = 0; i < P.np; i++) { + const o = i * PF, ag = pa[o + 6], r = max(0.9, min(16, pa[o + 4] + pa[o + 5] * sqrt(ag))), cx = pa[o], cy = GY + Ci - pa[o + 1], life = pa[o + 7]; + const a = (pa[o + 8] * min(1, ag * 12) * (1 - ss(life * 0.4, life, ag)) * 2) / (1 + r * 0.08); + const L = [0, pa[o + 9] * exp(-ag / 0.3), min(1, P.sunI + P.tw * ss(140, 300, pa[o + 1])), pa[o + 10]], k = pa[o + 12] * SP * SP; + for (let y = max(0, ceil(cy - r - 0.5)); y <= min(H - 1, floor(cy + r - 0.5)); y++) + for (let x = max(0, ceil(cx - r - 0.5)); x <= min(W - 1, floor(cx + r - 0.5)); x++) { + const sx = floor(((x + 0.5 - cx) / r) * 0.5 * SP + SP / 2), sy = floor(((y + 0.5 - cy) / r) * 0.5 * SP + SP / 2), q = k + sy * SP + sx, d = spD[q] * a; + if (sx < 0 || sx >= SP || sy < 0 || sy >= SP || d <= 0.001) continue; + const p = (y * W + x) * 5; + D[p] += d; + D[p + 1] += d * spS[q]; + for (let j = 1; j < 4; j++) D[p + j + 1] += d * L[j]; + } + } + for (let p = 0; p < N; p++) { + const d = D[p * 5]; + if (d < 0.004) continue; + const sh = D[p * 5 + 1] / d, he = min(1.4, D[p * 5 + 2] / d), sn = (D[p * 5 + 3] / d) * (0.3 + 0.8 * sh) * (0.6 + 0.4 * P.sunI), ab = D[p * 5 + 4] / d; + const al = 1 - exp(-1.7 * d), am = 0.5 + 0.55 * sh, fl = P.lights * 75 * exp(-max(0, GY + Ci - ((p / W) | 0)) / 60), g = G[p] * 1.25; + for (let j = 0; j < 3; j++) buf[p * 3 + j] += (ab * (c[12 + j] * am + c[15 + j] * sn + fl + g * gc[j]) + he * gc[j] * 0.9 - buf[p * 3 + j]) * al; + } +} + +export function draw(display, params) { + const P = params, V = vehicle(P), T = P.T, buf = P.buf, c = P.pal, G = P.G; + const F = fly(P, V, T), Ci = (P.Ci = round(camera(V, T, F))), s = F.s, sa = sin(F.a), ca = cos(F.a), fu = FUEL[F.fuel]; + P.side = clamp((c[22] - X0) / 25, -1, 1); + P.sunK = P.sunI + P.tw * 0.7 * ss(60, 260, F.E); + // The engines' glow. + const uo = V.st[F.ai].u, uN = uo - V.st[F.ai].bell[0], nx = F.x + sa * uN * s, ny = GY + Ci - F.e - ca * uN * s, gx = nx - sa * F.Lf * s * 0.4, gy = ny + ca * F.Lf * s * 0.4; + const flick = 0.85 + 0.15 * sin(P.clk * 31) * sin(P.clk * 17.3 + 1), near = 1 - ss(10, 60, F.E); + const gr = ((6 + 8 * F.wn * s) * (1 + 1.6 * near)) ** 2, gi = F.f * flick * (F.fuel === 1 ? 0.35 : 1) * (0.55 + 0.6 * near) * (F.ai ? 1 : 0.5 + 0.25 * P.n) * (0.7 + 0.5 * P.night); + P.gc = fu[3]; + for (let p = 0; p < N; p++) G[p] = gi > 0.01 ? (gi * gr) / (gr + ((p % W) + 0.5 - gx) ** 2 + (((p / W) | 0) + 0.5 - gy) ** 2) : 0; + sky(P); + pad(P, V, T); + // Spent stages fall behind and tumble. + V.sep.forEach((ts, i) => { + const dt = T - ts, S0 = fly(P, V, ts), u = V.st[i].u * S0.s; + if (dt > 0 && dt < 5) body(P, { ...stack(V, i, i + 1), x: S0.x + sin(S0.a) * u + 2 * dt, y: GY + Ci - S0.e - cos(S0.a) * u - ((fly(P, V, ts + 0.05).E - S0.E) / 0.05) * 1.1 * (1 - exp(-dt / 1.1)), ang: S0.a + 0.45 * dt, sc: S0.s / (1 + 0.06 * dt), al: 1 - ss(3, 5, dt), E: 300 }); + }); + if (V.boost) boosters(P, V, T, F, [-1, 1]); + // The rocket from its lowest burning stage up. + body(P, { ...stack(V, F.ai, V.n), x: F.x + sa * uo * s, y: GY + Ci - F.e - ca * uo * s, ang: F.a, sc: s, al: 1, E: F.E, q: ss(6.2, 6.7, T) * (1 - ss(7.6, 8.3, T)), uq: (V.L - uo) * 0.5, flame: { wn: F.wn, Lf: max(0.01, F.Lf * flick), k: F.k, f: F.f, fuel: F.fuel } }); + if (V.boost) boosters(P, V, T, F, [0]); + // Far off, the flame is a bright point. + if (s * F.wn < 1.4 && F.f > 0.05) + for (let p = 0; p < N; p++) { + const a = 1.1 * F.f * exp(-(((p % W) + 0.5 - nx) ** 2 + (((p / W) | 0) + 0.5 - ny) ** 2) / (0.9 + 0.5 * s * F.wn) ** 2); + for (let j = 0; j < 3; j++) buf[p * 3 + j] += fu[1][j] * a; + } + smoke(P); + // The water in front mirrors everything above the shoreline. + for (let y = max(0, GY + Ci); y < H; y++) { + const sy = clamp(2 * (GY + Ci) - 1 - y, 0, H - 1); + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3, so = (sy * W + x) * 3, rp = 0.5 + 0.1 * sin(y * 2.1 + x * 0.3 - P.clk * 3); + for (let k = 0; k < 3; k++) buf[o + k] = buf[so + k] * rp + c[k] * 0.12 + G[y * W + x] * P.gc[k] * 0.25; + } + } + // The clock. + const tc = T < 0 ? ceil(-T) : floor(T); + [10, T < 0 ? 11 : 12, floor(tc / 10) % 10, tc % 10].forEach((ch, i) => { + for (let r = 0; r < 15; r++) if ((FONT[ch] >> (14 - r)) & 1) buf.set([255, 196, 70], ((2 + floor(r / 3)) * W + 2 + i * 4 + (r % 3)) * 3); + }); + const fade = ss(-10, -9.3, T) * (1 - ss(23.8, 25, T)); + for (let p = 0, o = 0; p < N; p++, o += 3) display.setPixel(p % W, (p / W) | 0, min(255, buf[o] * fade), min(255, buf[o + 1] * fade), min(255, buf[o + 2] * fade)); +} diff --git a/incoming/pattern-study/patterns/night-flight.js b/incoming/pattern-study/patterns/night-flight.js new file mode 100644 index 000000000..a5213ebea --- /dev/null +++ b/incoming/pattern-study/patterns/night-flight.js @@ -0,0 +1,478 @@ +// ===== Patternflow pattern ===== +// Title: Night Flight +// Family: city +// Idea: A city at night seen straight down from an airliner window: a lace of single-LED street lights traces the grid, the boulevards, a highway full of car lights and the bridges over a dark river, all on the sodium glow of the town, sliding slowly past the wingtip and its blinking lights while wisps of cloud drift under the plane. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after night photographs of cities taken from airliner windows +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Lamps=0..1, Speed=0.2..3, Grid=0..1, Haze=0..1 +// +// Knob 1: Lamps. The street lights: an old sodium city at 0, orange with the odd green mercury lamp; turned up, the lamps are swapped for white LEDs one by one until the whole town is white at 1. +// Knob 2: Speed. Ground speed: at 2 the city slides about 3.4 LEDs a second toward the lower left, at 0.2 it barely creeps; the cars on the highway keep their own pace on top of it. +// Knob 3: Grid. Street spacing inside the boulevards: blocks eight or nine LEDs apart at 0, twelve or thirteen at 1, with more house lights inside the wider ones. +// Knob 4: Haze. Cloud under the plane: a clear night with the odd wisp at the low end, a broken layer lit from below by the town in the middle, and at 1 an overcast the city only glows through. +// Buttons: K3 tap flies over another city. + +const W = 64; +const H = 128; +const N = W * H; +// Boulevards about every 44 LEDs, rivers about every 380 LEDs along the track. +const SB = 44; +const RV = 380; +const HW = 3.3; +// The city carpet: every LED leans this far away from its neighbours. +const AMP = 18.5; +const [RES, DOWN, DIAG, YARD, SUB] = [0, 1, 2, 3, 4]; +const [STREET, BLVD, WHITE, HOUSE, FLOOD, HWY, PATH] = [1, 2, 3, 4, 5, 6, 7]; +const { min, max, floor, round, sin, cos, abs, sqrt, exp } = Math; +const rnd = () => Math.random(); +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const md = (a, m) => ((a % m) + m) % m; +const R2 = 0.70710678; + +function hash(i, j) { + let h = (Math.imul(i, 374761393) + Math.imul(j, 668265263)) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} +const h3 = (i, j, k) => hash(i + Math.imul(k, 1013904223), j ^ Math.imul(k, 1664525)); +function vn(x, y) { + const xi = floor(x), yi = floor(y); + let u = x - xi, v = y - yi; + u = u * u * (3 - 2 * u); + v = v * v * (3 - 2 * v); + const a = hash(xi, yi), b = hash(xi + 1, yi), c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1); + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; +} + +// Boulevard lines, jittered a little each. +const AX = (S, i) => i * SB + floor(h3(i, 0, S + 101) * 9) - 4; +const AY = (S, j) => j * SB + floor(h3(j, 0, S + 202) * 9) - 4; + +// River k's centre line across the track (u) and its slope, in LEDs. +function riverAt(S, k, u) { + const p1 = u / 19 + h3(k, 7, S) * 6.283; + const p2 = u / 7.3 + h3(k, 8, S) * 6.283; + const v = k * RV + (h3(k, 9, S) - 0.5) * 50 + 7 * sin(p1) + 2.2 * sin(p2); + return [v, (7 / 19) * cos(p1) + (2.2 / 7.3) * cos(p2)]; +} +function riverDist(S, p, q) { + const u = p * R2; + const v = q * R2; + const [vr, dv] = riverAt(S, round(v / RV), u); + return abs(v - vr) / sqrt(1 + dv * dv); +} + +// One cell of the city, kept in a window-sized ring buffer by world position. +function gen(P, wx, wy) { + const S = P.seed; + const p = wx + wy; + const q = wx - wy; + const plus = (p & 1) === 0; + let i = floor(wx / SB); + if (wx < AX(S, i)) i--; + else if (wx >= AX(S, i + 1)) i++; + let j = floor(wy / SB); + if (wy < AY(S, j)) j--; + else if (wy >= AY(S, j + 1)) j++; + const x0 = AX(S, i); + const y0 = AY(S, j); + const bw = AX(S, i + 1) - x0; + const bh = AY(S, j + 1) - y0; + const dx = wx - x0; + const dy = wy - y0; + const ed = cl(min(dx - 1, bw - dx, dy - 1, bh - dy) / 9, 0, 1); + const hk = h3(i, j, S + 3); + const kind = hk < 0.1 ? DOWN : hk < 0.2 ? DIAG : hk < 0.28 ? YARD : hk < 0.42 ? SUB : RES; + const sp = P.sp + (h3(i, j, S + 4) < 0.4 ? 1 : 0); + const r = h3(wx, wy, S + 5); + const r2 = h3(wy, wx, S + 6); + let M = 26 + 10 * vn(wx / 23 + 0.37, wy / 23 + (S & 255)); + if (kind === DOWN) M += 12 * ed; + else if (kind === YARD) M -= 5 * ed; + let k = 0; + let b = 0; + const blvd = dx < 2 || dy < 2; + const house = 0.035 + P.grid * 0.05; + if (plus) { + if (blvd) [k, b] = [BLVD, 1]; + else if (kind === DOWN) { + const s = sp - 1; + if ((dx - 1) % s === 0 || (dy - 1) % s === 0) [k, b] = [WHITE, 0.95]; + else if (r < 0.1) [k, b] = [WHITE, 0.9 + 0.1 * r2]; + } else if (kind === DIAG) { + const D = 2 * sp + 2; + if (md(q, D) === 0 || md(p, D) === 0) [k, b] = [STREET, 0.96 + 0.04 * r2]; + else if (r < house) [k, b] = [HOUSE, 0.94]; + } else if (kind === YARD) { + const s = sp + 3; + const lx = (dx - 1) % s; + const ly = (dy - 1) % s; + const lot = h3(i * 31 + floor((dx - 1) / s), j * 31 + floor((dy - 1) / s), S + 11); + if ((lx === 0 || ly === 0) && r > 0.25) [k, b] = [FLOOD, 0.92]; + else if (lot < 0.4 && lx > 1 && ly > 1 && lx < s - 1 && ly < s - 1 && (wy & 1) === 0) [k, b] = [FLOOD, 0.92 + 0.08 * r2]; + } else if (kind === SUB) { + // Curving suburban streets: contour lines of a slow noise. + const u0 = i * 3.1 + 0.5; + const v0 = j * 1.7 + 0.5; + const lv = floor(vn(wx / 15 + u0, wy / 15 + v0) * 3); + if (lv !== floor(vn((wx + 1) / 15 + u0, wy / 15 + v0) * 3) || lv !== floor(vn(wx / 15 + u0, (wy + 1) / 15 + v0) * 3)) + [k, b] = [STREET, 0.96 + 0.04 * r2]; + else if (r < house * 1.8) [k, b] = [HOUSE, 0.94]; + } else { + const onX = (dx - 1) % sp === 0 && dx < bw - 2; + const onY = (dy - 1) % sp === 0 && dy < bh - 2; + if (onX || onY) { + if (r > 0.08) [k, b] = [STREET, 0.96 + 0.04 * r2]; + } else if (r < house) [k, b] = [HOUSE, 0.94]; + } + } + // A park over a few blocks, dark, with one lit path across it. + if (kind === RES && h3(i, j, S + 7) < 0.45) { + const nx = max(1, floor((bw - 3) / sp)); + const ny = max(1, floor((bh - 3) / sp)); + const pw = min(nx, 1 + (h3(i, j, S + 8) < 0.6 ? 1 : 0)); + const ph = min(ny, 2); + const px0 = 1 + floor(h3(i, j, S + 9) * (nx - pw + 1)) * sp; + const py0 = 1 + floor(h3(i, j, S + 10) * (ny - ph + 1)) * sp; + const pd = min(dx - px0, px0 + pw * sp - dx, dy - py0, py0 + ph * sp - dy); + if (pd > 0) { + if (!blvd) k = 0; + M += (21 - M) * cl(pd / 8, 0, 1); + if (plus && dx - px0 === dy - py0) [k, b] = [PATH, 0.75]; + } + } + // The river, its embankment roads and bridges where the boulevards cross. + const d = riverDist(S, p, q); + M += (20 - M) * cl((HW + 6 - d) / 9, 0, 1); + const wt = cl((HW + 0.6 - d) / 1.6, 0, 1); + if (d < HW + 2.8 && !blvd) k = 0; + if (plus && d >= HW + 0.9 && d < HW + 2.3) [k, b] = [STREET, 0.92]; + // The highway corridor: boulevards bridge it, the median carries lamps, + // the two carriageways (dp 0 and 4) carry the cars drawn each frame. + const dp = p - P.ph; + if (dp >= -4 && dp <= 8) { + if (!blvd || abs(dp - 2) <= 3) k = 0; + M += (21 - M) * cl((7 - abs(dp - 2)) / 7, 0, 1); + if ((dp === -2 || dp === 6) && md(q + dp * 2, 8) === 0) [k, b] = [HWY, 1]; + } + const slot = (wx & 63) + md(wy, H) * W; + P.KD[slot] = k; + P.LB[slot] = b; + P.LH[slot] = h3(wx, wy, S + 12); + P.MS[slot] = M; + P.WT[slot] = wt; +} + +function build(P) { + for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) gen(P, P.ox + x, P.oy + y); +} + +// One step along the track: the ground moves one LED left and one down. +function step(P) { + P.ox += 1; + P.oy -= 1; + for (let y = 0; y < H; y++) gen(P, P.ox + W - 1, P.oy + y); + for (let x = 0; x < W; x++) gen(P, P.ox + x, P.oy); +} + +function place(P) { + P.seed = floor(rnd() * 4096); + let ox = 20000 + floor(rnd() * 4000); + let oy = 20000 + floor(rnd() * 4000); + // Put a river across the middle of the window. + const u = (ox + oy + 96) * R2; + const qc = ox - oy - 32; + const [vr] = riverAt(P.seed, round((qc * R2) / RV), u); + const dl = round((vr / R2 - qc + (rnd() - 0.5) * 30) / 2); + ox += dl; + oy -= dl; + P.ox = ox; + P.oy = oy; + P.p0 = ox + oy; + const ph = P.p0 + 80 + floor(rnd() * 12); + P.ph = ph + (ph & 1); +} + +// The wingtip, fixed in the window: a swept wing in from the left edge. +const WING = [ + [-1, 92], + [30, 110.5], + [31, 113], + [27.5, 116.5], + [-1, 131], +]; +function inPoly(px, py, poly) { + let c = false; + for (let a = 0, b = poly.length - 1; a < poly.length; b = a++) { + const [xa, ya] = poly[a]; + const [xb, yb] = poly[b]; + if (ya > py !== yb > py && px < ((xb - xa) * (py - ya)) / (yb - ya) + xa) c = !c; + } + return c; +} + +export function setup(params) { + const P = params; + Object.assign(P, { + KD: new Uint8Array(N), + LB: new Float32Array(N), + LH: new Float32Array(N), + MS: new Float32Array(N), + WT: new Float32Array(N), + wing: new Float32Array(N), + CL: new Float32Array(33 * 65), + GA: new Float32Array(N), + GB: new Float32Array(N), + glowDirty: true, + acc: 0, + cs: 0, + t: 0, + sp: 0, + built: false, + }); + place(P); + // Wing shading: darker toward the root, a faint sheen along the leading edge. + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + if (!inPoly(x + 0.5, y + 0.5, WING)) continue; + const lead = abs((y + 0.5 - 92) * 31 - (x + 1.5) * 18.5) / 36.1; + P.wing[x + y * W] = 26 + 8 * (x / 30) + 40 * cl(1 - lead / 1.6, 0, 1) ** 1.5; + } + // The lights at the tip sit on cells the carpet pushes up. + P.nav = (P.p0 + 29 + 111) & 1 ? [30, 111] : [29, 111]; + P.strobe = (P.p0 + 27 + 115) & 1 ? [28, 115] : [27, 115]; +} + +export function update(dt, input, params) { + const P = params; + const k = input.knobValues || [0, 2, 0, 0.06]; + dt = cl(dt, 0, 0.1); + P.k1 = cl(k[0], 0, 1); + P.spd = cl(k[1], 0.2, 3); + P.grid = cl(k[2], 0, 1); + P.haze = cl(k[3], 0, 1); + const sp = 8 + round(P.grid * 4); + if (input.btnPressed && input.btnPressed[2]) { + place(P); + P.built = false; + } + if (!P.built || sp !== P.sp) { + P.sp = sp; + P.built = true; + P.glowDirty = true; + build(P); + } + P.acc += dt * P.spd * 1.2; + let n = 0; + while (P.acc >= 1) { + P.acc -= 1; + n++; + } + if (n > 24) { + P.ox += n; + P.oy -= n; + build(P); + } else for (let s = 0; s < n; s++) step(P); + // Cloud is nearer than the ground, so it slides past faster. + P.cs += dt * P.spd * 1.2 * 1.414 * 1.7; + P.t += dt; + if (n || P.glowDirty) glow(P); + // Cloud cover on a 2-LED lattice. + const th = 0.8 - 0.78 * P.haze; + const veil = 0.25 * P.haze * P.haze; + const thick = 0.45 + 0.4 * P.haze; + for (let gy = 0; gy < 65; gy++) + for (let gx = 0; gx < 33; gx++) { + const x = gx * 2; + const y = gy * 2; + const a = (y - x) * R2 - P.cs; + const bb = (x + y) * R2; + const nz = 0.65 * vn(a / 30 + 40, bb / 16 + 5.3 + P.t * 0.02) + 0.35 * vn(a / 11 + 70, bb / 7 + 11 - P.t * 0.03); + let dn = cl((nz - th) / 0.28, 0, 1); + dn = dn * dn * (3 - 2 * dn); + P.CL[gx + gy * 33] = min(0.88, dn * thick + veil); + } +} + +// The town's light blurred, for cloud to glow with: two box passes each way. +function glow(P) { + P.glowDirty = false; + const A = P.GA; + const B = P.GB; + for (let y = 0; y < H; y++) { + const row = md(P.oy + y, H) * W; + for (let x = 0; x < W; x++) { + const slot = ((P.ox + x) & 63) + row; + A[x + y * W] = P.KD[slot] ? P.LB[slot] : 0; + } + } + for (let pass = 0; pass < 4; pass++) { + const [src, dst] = pass & 1 ? [B, A] : [A, B]; + const horiz = pass < 2; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + let sum = 0; + for (let d = -2; d <= 2; d++) { + const u = horiz ? cl(x + d, 0, W - 1) : x; + const v = horiz ? y : cl(y + d, 0, H - 1); + sum += src[u + v * W]; + } + dst[x + y * W] = sum / 5; + } + } +} + +const SOD = [255, 132, 26]; +const LEDW = [[255, 214, 168], [226, 236, 255]]; +const MERC = [150, 255, 196]; +const HOUSEC = [255, 176, 92]; +const FLOODC = [214, 230, 255]; +const HEAD = [255, 250, 232]; +const TAIL = [255, 30, 20]; +const C = [0, 0, 0]; +const NB = [0.5, 0.62, 1]; +const NIGHT = 0.7; + +// A lamp's colour into C, from its kind, its dice and the Lamps knob. +function lampColour(kind, h, k1) { + const h2 = (h * 7.31) % 1; + const h3c = (h * 13.7) % 1; + if (kind === STREET || kind === BLVD || kind === HWY || kind === PATH) { + const base = kind === BLVD ? 0.3 : kind === PATH ? 0.6 : 0.06; + if (h < base + k1 * (1 - base)) { + for (let c = 0; c < 3; c++) C[c] = LEDW[0][c] + (LEDW[1][c] - LEDW[0][c]) * h3c; + } else if (h2 > 0.94 && kind === STREET) { + for (let c = 0; c < 3; c++) C[c] = MERC[c]; + } else { + C[0] = SOD[0]; + C[1] = SOD[1] + 24 * h3c; + C[2] = SOD[2] + 14 * h3c; + } + } else if (kind === WHITE) { + for (let c = 0; c < 3; c++) C[c] = LEDW[0][c] + (LEDW[1][c] - LEDW[0][c]) * h2; + } else if (kind === HOUSE) { + for (let c = 0; c < 3; c++) C[c] = HOUSEC[c]; + } else { + for (let c = 0; c < 3; c++) C[c] = FLOODC[c]; + } + // Every lamp burns at full strength in its brightest channel. + const m = 255 / max(C[0], C[1], C[2]); + for (let c = 0; c < 3; c++) C[c] *= m; +} + +export function draw(display, params) { + const P = params; + if (!P.built) return; + const t = P.t; + const k1 = P.k1; + const haze = [1 - 0.18 * k1, 0.64 + 0.24 * k1, 0.36 + 0.64 * k1]; + const cloudT = [1 - 0.12 * k1, 0.8 + 0.12 * k1, 0.64 + 0.36 * k1]; + const water = [0.32, 0.5, 1]; + const wingT = [0.78, 0.84, 1]; + // Strobe: a double flash every 1.25 s. + const ph = t % 1.25; + const flash = ph < 0.05 || (ph > 0.15 && ph < 0.2) ? 1 : 0; + // Cars: lane A runs with the ground, lane B against it, in cells along the lane. + const vA = 0.9 * t; + const vB = -0.9 * t; + for (let y = 0; y < H; y++) { + const wy = P.oy + y; + const row = md(wy, H) * W; + const gy = y >> 1; + const fy = (y & 1) * 0.5; + for (let x = 0; x < W; x++) { + const wx = P.ox + x; + const slot = (wx & 63) + row; + const plus = ((x + y + P.p0) & 1) === 0; + const gx = x >> 1; + const fx = (x & 1) * 0.5; + const ci = gx + gy * 33; + const c0 = P.CL[ci] + (P.CL[ci + 1] - P.CL[ci]) * fx; + const c1 = P.CL[ci + 33] + (P.CL[ci + 34] - P.CL[ci + 33]) * fx; + const al = c0 + (c1 - c0) * fy; + const wg = P.wing[x + y * W]; + let M; + let tr; + let tg; + let tb; + if (wg > 0) { + const nd = (x - P.nav[0]) ** 2 + (y - P.nav[1]) ** 2; + const sd = (x - P.strobe[0]) ** 2 + (y - P.strobe[1]) ** 2; + M = wg + 80 * flash * exp(-sd / 60); + const gl = 0.5 * exp(-nd / 10); + tr = wingT[0] * (1 - gl); + tg = wingT[1]; + tb = wingT[2] * (1 - gl * 0.6); + let v = max(0, M + (plus ? 10 : -10)); + let r = tr * v; + let g = tg * v; + let b = tb * v; + if (x === P.nav[0] && y === P.nav[1]) [r, g, b] = [40, 255, 90]; + else if (x === P.strobe[0] && y === P.strobe[1]) { + if (flash) [r, g, b] = [255, 255, 255]; + else [r, g, b] = [70, 74, 86]; + } + display.setPixel(x, y, min(255, r), min(255, g), min(255, b)); + continue; + } + const wt = P.WT[slot]; + M = P.MS[slot]; + // Ground, water and cloud tints mix, and the brightest channel is put + // back to 1 so the mix never dims a step. + tr = haze[0] + (water[0] - haze[0]) * wt; + tg = haze[1] + (water[1] - haze[1]) * wt; + tb = haze[2] + (water[2] - haze[2]) * wt; + tr += (cloudT[0] - tr) * al; + tg += (cloudT[1] - tg) * al; + tb += (cloudT[2] - tb) * al; + // The cells between the carpet's dots fall toward blue-black. + if (!plus) { + const nb = NIGHT * (1 - al); + tr += (NB[0] - tr) * nb; + tg += (NB[1] - tg) * nb; + tb += (NB[2] - tb) * nb; + } + // Cloud lit from below by the town. + const Lc = 0.55 * M + 14 + 210 * P.GA[x + y * W] + 24 * al; + const G = M + (Lc - M) * al; + const v = max(0, G + (plus ? AMP : -AMP)) / max(tr, tg, tb); + let r = v * tr; + let g = v * tg; + let b = v * tb; + if (plus) { + let kind = P.KD[slot]; + let lb = P.LB[slot]; + let col = null; + const dp = x + y + P.p0 - P.ph; + if (dp === 0 || dp === 4) { + // Carriageways: m counts cells along the lane. + const m = (x - y + P.ox - P.oy) >> 1; + const lane = dp === 0 ? 0 : 1; + const slotK = floor(m + (lane ? vB : vA) + 0.5); + if (h3(slotK, lane, P.seed + 21) < 0.45) { + col = lane ? TAIL : HEAD; + lb = 1; + } + } + if (col || kind) { + if (!col) { + lampColour(kind, P.LH[slot], k1); + col = C; + } + const h = P.LH[slot]; + const tw = lb * (0.97 + 0.03 * sin(t * (2.5 + 4 * h) + h * 40)); + const keep = 1 - 0.85 * al; + r = col[0] * tw * keep + r * (1 - keep); + g = col[1] * tw * keep + g * (1 - keep); + b = col[2] * tw * keep + b * (1 - keep); + } + } + display.setPixel(x, y, min(255, r), min(255, g), min(255, b)); + } + } +} diff --git a/incoming/pattern-study/patterns/pin-art.js b/incoming/pattern-study/patterns/pin-art.js new file mode 100644 index 000000000..d62d985a4 --- /dev/null +++ b/incoming/pattern-study/patterns/pin-art.js @@ -0,0 +1,328 @@ +// ===== Patternflow pattern ===== +// Title: Pin Art +// Family: toy +// Idea: A pin-art desk toy seen from the front fills the panel with four thousand steel pins in a staggered grid, and a spread hand, a key, a seashell, a toy car and a face press in from behind one after another, each relief rising out of the dim pins into a raking lamp until the toy is tilted and the pins slide flat again in a ripple. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after Ward Fleming's pinscreen desk toy (1985) +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Metal=0..1, Speed=0.2..3, Object=0..4, Light=0..1 +// +// Knob 1: Metal. The finish of the pins and the colour the lamp gives them: blue steel at 0, turning through violet and pink to brass in the middle, then through green back to blue steel at 1. +// Knob 2: Speed. How fast things press in, hold and are tilted flat again: at 2 an object takes 3 s to press in and a round is 8 s; at 0.2 it all runs ten times slower. +// Knob 3: Object. What presses in next: a spread hand, a key, a seashell, a toy car or a face, the rest following in that order. Turning it tilts the toy flat and brings the new one. +// Knob 4: Light. Where the desk lamp stands, going once round the toy from its left side, upper left at the start. The relief shines on the lamp's side and throws its shadow away from it, and the flat pins are brighter nearer the lamp. +// Buttons: K1 tap tilts the toy flat now and presses the next object in. + +const W = 64; +const H = 128; +const N = W * H; +const NOBJ = 5; +// The round at Speed 2, in seconds: press in, hold, tilt flat, rest. +const TP = 3.0; +const TH = 3.3; +const TW = 1.25; +const TR = 0.5; +const TC = TP + TH + TW + TR; +const { min, max, sqrt, sin, cos, exp, abs, round, hypot, atan2, PI } = Math; +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); +const sstep = (a, b, v) => { + const t = clamp((v - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; +// A fixed hash, so the pins are the same uneven pins on every run. +const hash = (i) => { + let h = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +}; + +// Distances to shapes, negative inside. seg() leaves how far along the +// segment the nearest point lies in T. +let T = 0; +function seg(px, py, ax, ay, bx, by) { + const dx = bx - ax; + const dy = by - ay; + T = clamp(((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy), 0, 1); + return hypot(px - ax - dx * T, py - ay - dy * T); +} +function box(px, py, cx, cy, hx, hy, r) { + const qx = abs(px - cx) - hx + r; + const qy = abs(py - cy) - hy + r; + return hypot(max(qx, 0), max(qy, 0)) + min(max(qx, qy), 0) - r; +} +function ell(px, py, cx, cy, rx, ry) { + return (hypot((px - cx) / rx, (py - cy) / ry) - 1) * min(rx, ry); +} +function smin(a, b, k) { + const h = clamp(0.5 + (0.5 * (b - a)) / k, 0, 1); + return b + (a - b) * h - k * h * (1 - h); +} +// A soft round bump, near enough a Gaussian, that ends 2 radii out. +const bump = (px, py, cx, cy, rx, ry) => { + const q = ((px - cx) / rx) ** 2 + ((py - cy) / ry) ** 2; + return q < 4 ? (1 - q / 4) ** 3 : 0; +}; +// A rounded edge: 0 at the outline, 1 once r LEDs inside it. +const prof = (d, r) => { + const t = clamp(-d / r, 0, 1); + return sqrt(t * (2 - t)); +}; +// Pins next to the outline are dragged a little way out with it. +const halo = (d) => (d < 5 ? 0.17 * exp(-d / 1.6) : 0); + +// Each object is a height above the pins' rest plane, 0..1, at an LED. +// The fingers: base and tip of each, and the radius. +const FING = [ + [15, 84, 3, 56, 6], + [20, 61, 10, 22, 5.8], + [30, 58, 29, 9, 6], + [39, 59, 47, 15, 5.8], + [47, 65, 59, 35, 5.2], +]; +function hand(x, y) { + let d = box(x, y, 32, 77, 19, 17, 9); + let crease = 0; + for (let f = 0; f < 5; f++) { + const [ax, ay, bx, by, r] = FING[f]; + const e = seg(x, y, ax, ay, bx, by) - r; + if (e < 0) { + const len = hypot(bx - ax, by - ay); + const j = f ? [0.4, 0.68] : [0.55, 0.55]; + crease = max(crease, exp(-(((T - j[0]) * len) ** 2) / 0.8), exp(-(((T - j[1]) * len) ** 2) / 0.8)); + } + d = smin(d, e, 2.5); + } + d = smin(d, seg(x, y, 32, 92, 32, 150) - 15.5, 8); + if (d > 0) return halo(d); + let r = 0.5 + 0.38 * prof(d, 8); + // The pads below the fingers, the ball of the thumb, and the lines of the palm. + r += 0.08 * bump(x, y, 22, 92, 8, 10) + 0.05 * bump(x, y, 34, 66, 14, 4) + 0.05 * bump(x, y, 43, 86, 6, 9); + r -= 0.1 * crease; + const heart = abs(y - (68 + 0.012 * (x - 30) ** 2 - 0.1 * (x - 30))); + const life = abs(hypot(x - 14, y - 92) - 15); + if (x > 18 && x < 48 && y < 80) r -= 0.1 * exp(-heart * heart); + if (y > 70 && y < 104 && x > 18) r -= 0.1 * exp(-life * life); + return clamp(r, 0, 1); +} + +function face(x, y) { + let d = ell(x, y, 32, 54, 24 - max(0, y - 60) * 0.24, 35); + d = smin(d, min(ell(x, y, 7, 56, 3.5, 8), ell(x, y, 57, 56, 3.5, 8)), 1.5); + const head = d; + d = smin(d, seg(x, y, 32, 88, 32, 140) - 10.5, 4); + d = smin(d, ell(x, y, 32, 142, 54, 28), 10); + if (d > 0) return halo(d); + let r = 0.42 + 0.24 * prof(d, 12); + // The neck and shoulders sit back under the jaw. + r -= 0.2 * sstep(-2, 2, head); + // Hair, combed back in strands from the hairline. + const hl = 33 + 0.012 * (x - 32) ** 2; + if (y < hl) r += 0.04 * sstep(hl, hl - 3, y) + 0.06 * sin(x * 1.25 + (y - 20) * (x - 32) * 0.012); + // Brows, eye sockets and eyes, the nose, cheeks, lips and chin. + r += 0.06 * bump(x, y, 32, 36, 16, 9); + r += 0.12 * (bump(x, y, 22, 44, 7, 1.8) + bump(x, y, 42, 44, 7, 1.8)); + r -= 0.3 * (bump(x, y, 22, 51, 6, 3.4) + bump(x, y, 42, 51, 6, 3.4)); + r += 0.16 * (bump(x, y, 22, 51.5, 3, 1.8) + bump(x, y, 42, 51.5, 3, 1.8)); + r += 0.3 * bump(x, y, 32, 59, 2.4, 9) + 0.18 * bump(x, y, 32, 67.5, 4.5, 2.6); + r -= 0.14 * (bump(x, y, 29, 70, 1.4, 1.1) + bump(x, y, 35, 70, 1.4, 1.1)); + r += 0.12 * (bump(x, y, 19, 64, 6, 6) + bump(x, y, 45, 64, 6, 6)); + r += 0.14 * bump(x, y, 32, 76.5, 6.5, 1.5) + 0.16 * bump(x, y, 32, 80.5, 5.5, 1.9) - 0.2 * bump(x, y, 32, 78.5, 7.5, 0.7); + r += 0.12 * bump(x, y, 32, 87, 6, 3); + return clamp(r, 0, 1); +} + +function key(x, y) { + const rr = hypot(x - 32, y - 25); + let d = max(rr - 21, 9 - rr); + d = min(d, box(x, y, 32, 47, 9, 3, 1), box(x, y, 32, 55, 7, 2.5, 1), box(x, y, 32, 83, 4.5, 38, 2)); + let bit = box(x, y, 44, 107, 8, 11, 1); + bit = max(bit, -box(x, y, 51, 101, 2.5, 2, 0), -box(x, y, 51, 111, 2.5, 2.5, 0), -box(x, y, 44, 106, 1.2, 5, 0)); + d = min(d, bit); + if (d > 0) return halo(d); + let r = 0.72 + 0.2 * prof(d, 3); + // A raised rim round the bow's hole and a groove down the shank. + r += 0.08 * exp(-((rr - 12) ** 2) / 2); + if (y > 60 && y < 118) r -= 0.12 * exp(-((x - 32) ** 2) / 1.2); + return clamp(r, 0, 1); +} + +// A scallop, hinge down: ribs fan out from the hinge to a wavy edge. +function shell(x, y) { + const dx = x - 32; + const dy = 92 - y; + const rho = hypot(dx, dy); + const th = atan2(dx, dy); + const rib = cos(th * 48); + let d = max(rho - 54 * (1 - 0.3 * th * th) - 1.2 * rib, (abs(th) - 0.85) * rho); + d = smin(d, box(x, y, 32, 94, 15, 5, 2), 3); + if (d > 0) return halo(d); + let r = 0.5 + 0.32 * prof(d, 14); + r += 0.13 * rib * sstep(5, 22, rho) * (dy > 0) + 0.025 * sin(rho * 1.4); + r -= 0.12 * exp(-((y - 89) ** 2) / 1.5) * (abs(dx) < 14); + return clamp(r, 0, 1); +} + +// A toy car seen from above, its nose at the top. +function car(x, y) { + let body = box(x, y, 32, 65, 14 + min(3, max(0, y - 14) * 0.12), 52, 11); + body = smin(body, min(ell(x, y, 18, 36, 5, 12), ell(x, y, 46, 36, 5, 12), ell(x, y, 17, 96, 5, 12), ell(x, y, 47, 96, 5, 12)), 3); + const tyre = min(box(x, y, 11, 36, 3, 8, 1.5), box(x, y, 53, 36, 3, 8, 1.5), box(x, y, 10, 96, 3, 8, 1.5), box(x, y, 54, 96, 3, 8, 1.5)); + const d = min(body, tyre); + if (d > 0) return halo(d); + if (body > 0) return 0.36 + 0.1 * prof(tyre, 2.5); + let r = 0.5 + 0.16 * prof(body, 4); + // The cabin: windscreen and rear window sunk between bonnet, roof and boot. + const xw = abs(x - 32); + const scr = xw < 12 - (58 - y) * 0.25 && y > 45 && y < 58; + const rear = xw < 11 && y > 91 && y < 99; + const roof = box(x, y, 32, 74.5, 11, 16, 3); + r += 0.14 * sstep(1, -1, roof) - 0.2 * (scr || rear); + // Bonnet creases, headlights, wing mirrors, the spoiler at the back. + r -= 0.14 * (exp(-((x - 23) ** 2) / 0.6) + exp(-((x - 41) ** 2) / 0.6)) * (y > 17 && y < 42); + r += 0.16 * (bump(x, y, 22, 16, 3.5, 2.2) + bump(x, y, 42, 16, 3.5, 2.2)) + 0.18 * (bump(x, y, 15, 56, 3, 2) + bump(x, y, 49, 56, 3, 2)); + r += 0.18 * sstep(1, -1, box(x, y, 32, 112, 16, 2, 1)) - 0.12 * sstep(1, -1, box(x, y, 32, 108, 14, 1, 0.5)); + return clamp(r, 0, 1); +} + +const OBJECTS = [hand, key, shell, car, face]; + +export function setup(params) { + const F = (n) => new Float32Array(n); + params.pin = { r: F(N), gx: F(N), gy: F(N), ht: F(N), jit: F(N), started: false }; + const P = params.pin; + for (let i = 0; i < N; i++) P.jit[i] = 0.88 + 0.24 * hash(i); +} + +// The object's heights, and their slopes for the lamp. +function load(P, o) { + const { r, gx, gy } = P; + const fn = OBJECTS[o]; + for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) r[y * W + x] = fn(x + 0.5, y + 0.5); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x; + gx[i] = (r[y * W + min(W - 1, x + 1)] - r[y * W + max(0, x - 1)]) / 2; + gy[i] = (r[min(H - 1, y + 1) * W + x] - r[max(0, y - 1) * W + x]) / 2; + } +} + +function tilt(P) { + if (P.c < TP + TH) { + P.sw = P.s; + P.c = TP + TH; + } +} + +export function update(dt, input, params) { + if (!params.pin) setup(params); + const P = params.pin; + const k = input.knobValues || [0, 2, 0, 0.06]; + dt = clamp(dt || 0, 0, 0.1); + P.hue = clamp(k[0], 0, 1); + P.rate = clamp(k[1], 0.2, 3) / 2; + const base = clamp(round(k[2]), 0, NOBJ - 1); + P.lamp = clamp(k[3], 0, 1); + if (!P.started) { + P.started = true; + P.base = base; + P.count = -1; + // Open on the last object of the round, just as the toy is tilted. + load(P, (base + NOBJ - 1) % NOBJ); + P.c = TP + TH - 0.05; + P.s = 1; + P.sw = 1; + } + if (base !== P.base) { + P.base = base; + P.count = -1; + tilt(P); + } + if (input.btnPressed && input.btnPressed[0]) tilt(P); + P.c += dt * P.rate; + if (P.c >= TC) { + P.c -= TC; + P.count++; + load(P, (P.base + P.count + NOBJ * 8) % NOBJ); + } + if (P.c < TP) { + const u = P.c / TP; + P.s = u * (1.3 - 0.3 * u); + } else if (P.c < TP + TH) P.s = 1; +} + +function hsv(h, s, v) { + const f = (n) => { + const k = (n + h * 6) % 6; + return v - v * s * max(0, min(k, 4 - k, 1)); + }; + return [f(5), f(3), f(1)]; +} + +export function draw(display, params) { + const P = params.pin; + if (!P || !P.started) return; + const { r, gx, gy, ht, jit } = P; + const lift = 1 - P.s; + // The tilt: a front runs down the panel, the pins behind it slide back and + // rattle a little as they go. + const wt = P.c - TP - TH; + const wiping = wt >= 0; + const yf = -16 + (wt / TW) * 170; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x; + if (!wiping) { + const h = r[i] - lift; + ht[i] = h > 0 ? h : 0; + continue; + } + const dd = yf - y - 5 * sin(x * 0.19 + 1.3) - 3 * sin(x * 0.47); + let h = r[i] - (1 - P.sw); + h = h > 0 ? h * (1 - sstep(0, 12, dd)) : 0; + if (dd > 0) h += 0.16 * max(0, sin(dd * 0.6)) * exp(-dd / 22); + ht[i] = h; + } + + // The lamp, low and to one side. + const ang = PI * (0.87 - 2 * P.lamp); + const lx = cos(ang); + const ly = -sin(ang); + const tint = hsv((0.6 + P.hue) % 1, 0.85, 1); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const i = y * W + x; + const h = ht[i]; + // Shadow: does a taller pin stand between this one and the lamp? + let ex = 0; + for (let j = 1; j <= 7; j++) { + const sx = round(x + j * 1.5 * lx); + const sy = round(y + j * 1.5 * ly); + if (sx < 0 || sx >= W || sy < 0 || sy >= H) break; + const e = ht[sy * W + sx] - h - j * 0.12; + if (e > ex) ex = e; + } + const shadow = 1 - 0.34 * sstep(0, 0.12, ex); + const q = clamp(0.5 + ((x - 31.5) * lx + (y - 63.5) * ly) / 140, 0, 1); + let v; + let wh; + if (((x + y) & 1) === 0) { + const flat = 106 * (0.72 + 0.56 * q) * jit[i]; + const up = sstep(0, 0.1, h); + const shade = clamp(1.02 - 4.5 * (gx[i] * lx + gy[i] * ly), 0.5, 1.3); + const raised = min(255, 255 * (0.66 + 0.4 * min(1, h)) * shade); + v = (flat + (raised - flat) * up) * shadow; + wh = 0.3 + 0.4 * clamp((v - 90) / 165, 0, 1); + } else { + // A gap: dark, unless the pins round it stand out and show their shafts. + const n = (ht[i - (x > 0 ? 1 : 0)] + ht[i + (x < W - 1 ? 1 : 0)] + ht[i - (y > 0 ? W : 0)] + ht[i + (y < H - 1 ? W : 0)]) / 4; + const shade = clamp(0.92 - 3 * (gx[i] * lx + gy[i] * ly), 0.35, 1.15); + v = 175 * sstep(0.02, 0.3, n) * shade * shadow; + wh = 0.15; + } + display.setPixel(x, y, v * (tint[0] + (1 - tint[0]) * wh), v * (tint[1] + (1 - tint[1]) * wh), v * (tint[2] + (1 - tint[2]) * wh)); + } +} diff --git a/incoming/pattern-study/patterns/senbazuru.js b/incoming/pattern-study/patterns/senbazuru.js new file mode 100644 index 000000000..d9a170156 --- /dev/null +++ b/incoming/pattern-study/patterns/senbazuru.js @@ -0,0 +1,540 @@ +// ===== Patternflow pattern ===== +// Title: Senbazuru +// Family: paper +// Idea: Strings of folded paper cranes hang from a red lacquered ring in a dim temple room, in bands of colour from the ring down to the silk tassels, and a soft draught sways the strands and turns the cranes so their folds catch the lamp one facet at a time. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after the senbazuru strings of a thousand origami cranes hung in Japanese temples and at the Children's Peace Monument in Hiroshima +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Paper=0..1, Breeze=0.2..3, Strands=8..16, Light=0..1 +// +// Knob 1: Paper. Which colours the cranes are folded from: under a row of gold foil cranes at the ring, the bands run from orange through green and blue to violet and pink at the tassels at 0, and turning it moves every band along the rainbow. +// Knob 2: Breeze. How hard the draught blows: at 0.2 the strands hang almost still, at 2 they lean and sway and the cranes turn, at 3 they swing wide and spin. +// Knob 3: Strands. How many strands hang from the ring, from 8 to 16; with more strands the cranes are folded smaller and each strand carries more of them. +// Knob 4: Light. Where the light comes from: a paper lamp low on the left at night at 0, with the round window blue with moonlight, through dusk to daylight from the upper right at 1. The lit folds and the cranes' shadows on the wall swing with it. +// Buttons: K1 tap sends a gust through the room, which spins the cranes and shakes one loose to glide to the floor. + +const W = 64; +const H = 128; +const N = W * H; +// The cranes, threads and ring are drawn at twice the resolution and averaged +// down, so a neck one LED wide still reads. +const SW = 128; +const SH = 256; +const SN = SW * SH; +// The ring: its centre column, its row, its radius and how far its front +// hangs below its back. Then the round window behind it and the first row +// of the floor. +const CX = 31.5; +const RY = 12; +const RR = 22; +const RE = 3.6; +const WY = 60; +const WR = 26; +const FLOOR = 121; +const MAXS = 16; +const { sin, cos, min, max, floor, round, sqrt, exp, hypot, PI } = Math; +const TAU = 2 * PI; +const rnd = () => Math.random(); +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); +const mix = (a, b, t) => a + (b - a) * t; +const mix3 = (a, b, t) => [mix(a[0], b[0], t), mix(a[1], b[1], t), mix(a[2], b[2], t)]; + +function hsv(h, s, v) { + h = (h - floor(h)) * 6; + const i = floor(h) % 6; + const f = h - floor(h); + const p = v * (1 - s); + const q = v * (1 - s * f); + const t = v * (1 - s * (1 - f)); + return [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i]; +} + +// A folded crane, head forward along -x, up along y, wings out along z. Each +// face is one flat facet of the paper; the far side mirrors the near one. +const PT = { + F: [-2.3, 0.3, 0], B: [2.3, 0.3, 0], D: [0, -1.9, 0], S: [0, 0, 1.3], + W0: [-2.4, 0.5, 0.3], W1: [2.4, 0.5, 0.3], Wm: [0.3, -0.4, 1.3], Wt: [1.5, 2.4, 4.8], + N0: [-1, -1.6, 0.6], N1: [-3.4, 0.9, 0], Nt: [-4.9, 5.4, 0], H0: [-4.1, 5.8, 0.4], Hp: [-6.6, 4.1, 0], + T0: [1, -1.6, 0.6], T1: [3.4, 0.9, 0], Tt: [5.3, 5.8, 0], +}; +const MESH = Float32Array.from( + [1, -1].flatMap((s) => + "F S D,S B D,W0 Wm Wt,Wm W1 Wt,N0 N1 Nt,Nt H0 Hp,T0 T1 Tt" + .split(",") + .flatMap((f) => f.split(" ").flatMap((k) => [PT[k][0], PT[k][1], PT[k][2] * s])), + ), +); +const TV = new Float32Array(9); +const CE = cos(0.38); +const SE = sin(0.38); +const GOLD = [1, 0.74, 0.3]; + +export function setup(params) { + const P = params; + Object.assign(P, { + cb: new Float32Array(SN * 3), + zb: new Float32Array(SN), + bg: new Float32Array(N * 3), + rec: new Float32Array(N), + grain: Float32Array.from({ length: N }, () => rnd() - 0.5), + key: "", + started: false, + ph: 0, + psi: 0, + vpsi: 0, + gust: 0, + gx: -30, + next: 7, + fall: [], + paper: 0, + B: 2, + n: 8, + light: 0.06, + }); + P.S = Array.from({ length: MAXS }, () => ({ + ax: 0, vx: 0, az: 0, vz: 0, tw: 0, vt: 0, + p: rnd() * TAU, q: rnd() * TAU, cut: floor(rnd() * 3.6), dir: rnd() < 0.5 ? 0 : PI, hj: (rnd() - 0.5) * 0.05, lost: 0, + })); +} + +// Crane size, spacing and count for the strand count. +function layout(n) { + const k = 1.45 - 0.04 * n; + const dy = 6.6 * k + 1; + return { k, dy, nmax: floor((104 - RY - 7) / dy) + 1 }; +} + +function gust(P, g, loosen) { + P.gust = max(P.gust, g); + P.gx = -12; + P.vpsi += 0.35 * g * (rnd() < 0.5 ? -1 : 1); + for (let s = 0; s < MAXS; s++) P.S[s].vt += g * (2 + 3 * rnd()) * (rnd() < 0.5 ? -1 : 1); + if (!loosen) return; + // The bottom crane of the nearest strand that still has a few slips off. + const { nmax, dy } = layout(P.n); + let best = -1; + let bz = -2; + for (let s = 0; s < P.n; s++) { + const st = P.S[s]; + const sa = sin(P.psi + ((s + 0.25) / P.n) * TAU); + if (st.lost < 2 && nmax - st.cut - st.lost > 5 && sa > bz) { + bz = sa; + best = s; + } + } + if (best < 0) return; + const st = P.S[best]; + const i = nmax - st.cut - st.lost - 1; + st.lost++; + const phi = P.psi + ((best + 0.25) / P.n) * TAU; + const u = (7 + i * dy) / 100; + const z = RR * sin(phi); + const x = CX + (RR * cos(phi) + st.ax * u) * (1 + z * 0.004); + P.fall.push({ x, x0: x, y: RY + RE * sin(phi) + 7 + i * dy, z: z + 4, yaw: -phi, t: 0, i, s: best, down: false }); + if (P.fall.length > 5) P.fall.shift(); +} + +function step(P, h) { + const B = P.B; + P.ph += h * (0.35 + 0.65 * sqrt(B / 2)); + const T = P.ph; + if ((P.next -= h) <= 0) { + gust(P, 0.35 + 0.3 * rnd(), false); + P.next = 6 + 7 * rnd(); + } + P.gust *= exp(-h / 1.8); + P.gx += h * 36; + const n = P.n; + for (let s = 0; s < n; s++) { + const st = P.S[s]; + const phi = P.psi + ((s + 0.25) / n) * TAU; + const x = CX + RR * cos(phi); + // The draught: a lean to the right that breathes, a flutter that runs + // across the bundle, and the gust front passing left to right. + const front = 1 / (1 + exp((x - P.gx) / 6)); + const wind = 3 * B * (0.4 + 0.6 * sin(0.61 * T + st.p) + 0.35 * sin(1.37 * T - 0.06 * x + st.q)) + (3 + 2.5 * B) * P.gust * front; + st.vx += h * (5.5 * (wind - st.ax) - 1.1 * st.vx); + st.ax += h * st.vx; + const wz = 2 * B * sin(0.43 * T + st.q) + 3 * B * P.gust * front * sin(st.p); + st.vz += h * (5.5 * (wz - st.az) - 1.1 * st.vz); + st.az += h * st.vz; + const tq = 0.3 * B * sin(0.53 * T + 2 * st.p); + st.vt += h * (3 * (tq - st.tw) - 0.45 * st.vt); + st.tw += h * st.vt; + } + // The whole hanging twists on its one cord. + const tr = 0.16 * B * sin(0.23 * T) + 0.06 * B * sin(0.67 * T + 1); + P.vpsi += h * (1.4 * (tr - P.psi) - 0.35 * P.vpsi); + P.psi += h * P.vpsi; + // A loose crane glides down like a leaf, rocking and turning. + for (const f of P.fall) { + if (f.down) continue; + f.t += h; + const sw = sin(2.1 * f.t); + f.y += h * (8 + 10 * sw * sw); + f.x = f.x0 + 7 * sin(2.1 * f.t - 0.3) * min(1, f.t); + f.yaw += h * 1.6; + f.roll = 0.55 * cos(2.1 * f.t) * min(1, f.t * 2); + if (f.y >= FLOOR + 2.6) { + f.y = FLOOR + 2.6; + f.roll = 0; + f.down = true; + } + } +} + +export function update(dt, input, params) { + const P = params; + const k = input.knobValues || [0, 2, 8, 0.06]; + dt = clamp(dt || 0, 0, 0.1); + P.paper = clamp(k[0], 0, 1); + P.B = clamp(k[1], 0.2, 3); + P.n = clamp(round(k[2]), 8, MAXS); + P.light = clamp(k[3], 0, 1); + if (!P.started) { + P.started = true; + for (let i = 0; i < 60; i++) step(P, 1 / 15); + } + if (input.btnPressed && input.btnPressed[0]) gust(P, 1, true); + for (let i = 0; i < 2; i++) step(P, dt / 2); +} + +// The room: dark wood boards, a round shoji window behind the cranes, the +// lamp, a polished floor. Kept until the light changes. +function room(P) { + const k = P.light; + const night = 1 - k; + const pap = k < 0.5 ? mix3([0.1, 0.15, 0.38], [0.66, 0.38, 0.4], k * 2) : mix3([0.66, 0.38, 0.4], [0.94, 0.88, 0.74], k * 2 - 1); + const { bg, rec, grain } = P; + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const p = y * W + x; + const o = p * 3; + const g = grain[p]; + const dx = x - 31.5; + const dy = y - WY; + const lamp = night * exp(-((x - 7) * (x - 7)) / 700 - ((y - 106) * (y - 106)) / 2500); + const lit = 0.3 + 1.4 * lamp + 0.9 * k * (0.4 + (0.6 * x) / 63) * (1.1 - (0.5 * y) / 127); + const r = sqrt(dx * dx + dy * dy); + // Each LED is wood taking w of the light, plus paper glowing pp, and + // the cranes' shadows fall on it rc dark. + let w = 0; + let pp = 0; + let rc = 0.7; + if (y < 3) w = y === 2 ? 0.35 : 0.7; + else if (y >= FLOOR) { + // Boards, and the window seen dimly in the polish. + const b = (y - FLOOR) % 3 === 0 ? 0.6 : 1; + w = 0.75 * b; + pp = exp(-(dx * dx) / 500) * 0.22 * b; + } else if (x < 3 || x > 60) w = x === 2 || x === 61 ? 0.8 : 0.53; + else if (r < WR) { + // Paper between the kumiko bars, brightest where the moon or sun is. + const spot = exp(-((dx - 10) * (dx - 10) + (dy + 12) * (dy + 12)) / 500); + const bar = (x + 1) % 7 === 0 || (y - WY + 99) % 9 === 0; + pp = (0.62 + 0.55 * spot + 0.04 * g) * (bar ? 0.28 : 1); + rc = 0.2; + } else if (r < WR + 2.2) w = r < WR + 1 ? 0.62 : 0.35; + else { + const tone = 0.8 + 0.25 * sin(floor((x + 3) / 7) * 12.9898) ** 2 + 0.12 * g; + w = 0.55 * tone * ((x + 3) % 7 === 0 ? 0.55 : 1); + } + w *= lit; + let cr = 0.42 * w + pap[0] * pp; + let cg = 0.26 * w + pap[1] * pp; + let cb = 0.15 * w + pap[2] * pp; + // The andon: a paper lamp on short legs in the corner, lit at night. + if (x >= 3 && x <= 12 && y >= 98 && y < FLOOR && (y < 117 || x === 4 || x === 11)) { + rc = 0; + if (y >= 117 || x === 3 || x === 12 || y === 98 || y === 107 || y === 116) { + const f = 0.3 * (0.4 + lit); + cr = 0.42 * f; + cg = 0.26 * f; + cb = 0.15 * f; + } else { + const glow = night * (0.55 + 0.3 * exp(-((x - 7.5) * (x - 7.5)) / 6 - ((y - 106) * (y - 106)) / 40)); + cr = 0.14 * lit + glow; + cg = 0.13 * lit + 0.62 * glow; + cb = 0.12 * lit + 0.26 * glow; + } + } + rec[p] = rc; + bg[o] = cr * 255; + bg[o + 1] = cg * 255; + bg[o + 2] = cb * 255; + } +} + +function lights(P) { + const k = P.light; + const lx = mix(-0.8, 0.65, k); + const ly = mix(0.35, 0.6, k); + const lz = mix(0.6, 0.45, k); + const l = hypot(lx, ly, lz); + P.L = [lx / l, ly / l, lz / l]; + const hl = hypot(P.L[0], P.L[1], P.L[2] + 1); + P.Hv = [P.L[0] / hl, P.L[1] / hl, (P.L[2] + 1) / hl]; + P.lc = k < 0.5 ? mix3([1, 0.8, 0.56], [1, 0.68, 0.6], k * 2) : mix3([1, 0.68, 0.6], [0.9, 0.95, 1], k * 2 - 1); + P.amb = mix3([0.14, 0.16, 0.3], [0.3, 0.29, 0.27], k); +} + +function put(P, x, y, z, r, g, b) { + x = floor(x); + y = floor(y); + if (x < 0 || x >= SW || y < 0 || y >= SH) return; + const i = y * SW + x; + if (z <= P.zb[i]) return; + P.zb[i] = z; + P.cb[i * 3] = r; + P.cb[i * 3 + 1] = g; + P.cb[i * 3 + 2] = b; +} + +function line(P, x0, y0, z0, x1, y1, z1, c) { + const n = max(1, Math.ceil(max(Math.abs(x1 - x0), Math.abs(y1 - y0)))); + for (let j = 0; j <= n; j++) { + const t = j / n; + put(P, mix(x0, x1, t), mix(y0, y1, t), mix(z0, z1, t), c[0], c[1], c[2]); + } +} + +// A lit sphere: a bead or the knot over the ring. +function bead(P, x, y, z, rad, col, occl) { + const { L, Hv, lc, amb } = P; + for (let j = floor(y - rad); j <= y + rad; j++) + for (let i = floor(x - rad); i <= x + rad; i++) { + const nx = (i + 0.5 - x) / rad; + const ny = -(j + 0.5 - y) / rad; + const q = 1 - nx * nx - ny * ny; + if (q < 0) continue; + const nz = sqrt(q); + const d = max(0, nx * L[0] + ny * L[1] + nz * L[2]); + const sp = 0.9 * max(0, nx * Hv[0] + ny * Hv[1] + nz * Hv[2]) ** 10; + const f = 255 * occl; + put(P, i, j, z + nz * rad * 0.5, f * (col[0] * (amb[0] + 1.2 * d * lc[0]) + sp * lc[0]), f * (col[1] * (amb[1] + 1.2 * d * lc[1]) + sp * lc[1]), f * (col[2] * (amb[2] + 1.2 * d * lc[2]) + sp * lc[2])); + } +} + +function tri(P, x0, y0, z0, x1, y1, z1, x2, y2, z2, r, g, b) { + const A = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0); + if (A > -0.02 && A < 0.02) return; + const ia = 1 / A; + const xa = max(0, floor(min(x0, x1, x2))); + const xb = min(SW - 1, floor(max(x0, x1, x2))); + const ya = max(0, floor(min(y0, y1, y2))); + const yb = min(SH - 1, floor(max(y0, y1, y2))); + const { zb, cb } = P; + for (let y = ya; y <= yb; y++) { + const py = y + 0.5; + for (let x = xa; x <= xb; x++) { + const px = x + 0.5; + const w0 = ((x1 - px) * (y2 - py) - (x2 - px) * (y1 - py)) * ia; + const w1 = ((x2 - px) * (y0 - py) - (x0 - px) * (y2 - py)) * ia; + const w2 = 1 - w0 - w1; + if (w0 < 0 || w1 < 0 || w2 < 0) continue; + const z = w0 * z0 + w1 * z1 + w2 * z2; + const i = y * SW + x; + if (z <= zb[i]) continue; + zb[i] = z; + cb[i * 3] = r; + cb[i * 3 + 1] = g; + cb[i * 3 + 2] = b; + } + } +} + +// One crane at screen point (cx, cy) in LEDs, depth cz, size k, turned yaw +// about its thread and tilted roll in the picture plane. Each facet is lit +// on its own: the side facing the lamp is bright, a facet that turns to +// mirror the lamp into the room flashes. +function crane(P, cx, cy, cz, k, yaw, roll, col, occl, gold) { + const cw = cos(yaw); + const sw = sin(yaw); + const cr = cos(roll); + const sr = sin(roll); + const { L, Hv, lc, amb } = P; + const shine = gold ? 1.1 : 0.35; + const pw = gold ? 6 : 14; + for (let t = 0; t < MESH.length; t += 9) { + for (let v = 0; v < 9; v += 3) { + const x = MESH[t + v]; + const y = MESH[t + v + 1]; + const z = MESH[t + v + 2]; + const x1 = x * cw + z * sw; + const z1 = z * cw - x * sw; + const y1 = x1 * sr + y * cr; + // Seen from a little above. + TV[v] = x1 * cr - y * sr; + TV[v + 1] = y1 * CE - z1 * SE; + TV[v + 2] = y1 * SE + z1 * CE; + } + const ax = TV[3] - TV[0]; + const ay = TV[4] - TV[1]; + const az = TV[5] - TV[2]; + const bx = TV[6] - TV[0]; + const by = TV[7] - TV[1]; + const bz = TV[8] - TV[2]; + let nx = ay * bz - az * by; + let ny = az * bx - ax * bz; + let nz = ax * by - ay * bx; + let nl = sqrt(nx * nx + ny * ny + nz * nz); + if (nl < 1e-6) continue; + // Paper has two sides: light the one that faces us. + if (nz < 0) nl = -nl; + nx /= nl; + ny /= nl; + nz /= nl; + // Paper lets a little light round the fold, so no facet goes black. + const d = max(0, nx * L[0] + ny * L[1] + nz * L[2] + 0.35) / 1.35; + const sp = shine * max(0, nx * Hv[0] + ny * Hv[1] + nz * Hv[2]) ** pw; + const r = 255 * occl * (col[0] * (amb[0] + 1.15 * d * lc[0]) + sp * lc[0]); + const g = 255 * occl * (col[1] * (amb[1] + 1.15 * d * lc[1]) + sp * lc[1]); + const b = 255 * occl * (col[2] * (amb[2] + 1.15 * d * lc[2]) + sp * lc[2]); + tri(P, 2 * (cx + k * TV[0]), 2 * (cy - k * TV[1]), cz + k * TV[2], 2 * (cx + k * TV[3]), 2 * (cy - k * TV[4]), cz + k * TV[5], 2 * (cx + k * TV[6]), 2 * (cy - k * TV[7]), cz + k * TV[8], r, g, b); + } +} + +// The colour of crane i from the top: bands from orange through the +// rainbow, under a first crane of gold foil on every strand. +function paperOf(P, i, nmax, st) { + if (i === 0) return GOLD; + const c = hsv(P.paper + 0.12 + (0.76 * (i - 1)) / max(1, nmax - 2) + st.hj, 0.82, 1); + return [c[0] * 0.92 + 0.08, c[1] * 0.92 + 0.08, c[2] * 0.92 + 0.08]; +} + +function hanging(P) { + const { nmax, k, dy } = layout(P.n); + const n = P.n; + const { lc, amb } = P; + const thread = [0.86, 0.8, 0.7]; + const silk = [0.95, 0.2, 0.14]; + for (let s = 0; s < n; s++) { + const st = P.S[s]; + const phi = P.psi + ((s + 0.25) / n) * TAU; + const ca = cos(phi); + const sa = sin(phi); + const ya = RY + RE * sa; + // The inside of the bundle is in shade. + const occl = 0.4 + 0.6 * (0.5 + 0.5 * sa); + const cnt = nmax - st.cut - st.lost; + const tc = thread.map((v, m) => 255 * occl * v * (amb[m] + 0.55 * lc[m])); + let px = 2 * (CX + RR * ca * (1 + RR * sa * 0.004)); + let py = 2 * ya; + let pz = RR * sa - 2; + const pts = []; + for (let i = 0; i <= cnt; i++) { + const yy = 7 + i * dy - (i === cnt ? dy * 0.35 : 0); + const u = yy / 100; + const dx = st.ax * u + 0.7 * P.B * u * sin(2.1 * P.ph - 3.2 * u + st.p); + const z = RR * sa + st.az * u; + const pp = 1 + z * 0.004; + const x = CX + (RR * ca + dx) * pp; + const y = ya + yy; + line(P, px, py, pz, 2 * x, 2 * y, z - 2 * k, tc); + px = 2 * x; + py = 2 * y; + pz = z - 2 * k; + pts.push([x, y, z, pp, u]); + } + for (let i = 0; i < cnt; i++) { + const [x, y, z, pp, u] = pts[i]; + const slope = (pts[i + 1][0] - x) / dy; + const yaw = -phi - PI / 2 + st.dir + st.tw * (0.3 + u) + (0.25 + 0.2 * P.B) * sin(1.3 * P.ph - 0.6 * i + st.q); + crane(P, x, y, z, k * pp, yaw, -slope, paperOf(P, i, nmax, st), occl, i === 0); + } + // A gold bead and a silk tassel that trails the swing. + const [x, y, z] = pts[cnt]; + bead(P, 2 * x, 2 * y, z, 2 * k + 0.2, GOLD, occl); + const lag = -0.25 * st.vx; + const sc = silk.map((v, m) => 255 * occl * v * (amb[m] + 0.9 * lc[m])); + const len = round(15 * k); + for (let j = 0; j < len; j++) { + const hw = (1.1 + j * 0.13) * k; + const xc = 2 * x + lag * j * 0.1; + for (let xx = floor(xc - hw); xx <= xc + hw; xx++) { + const f = (xx & 1 ? 0.7 : 1) * (j > len - 3 ? 0.75 : 1); + put(P, xx, 2 * y + 2 + j, z, sc[0] * f, sc[1] * f, sc[2] * f); + } + } + } +} + +function ring(P) { + const { L, Hv, lc, amb } = P; + for (let a = 0; a < TAU; a += 0.01) { + const ca = cos(a); + const sa = sin(a); + const z = RR * sa; + const x = 2 * (CX + RR * ca * (1 + z * 0.004)); + const y = 2 * (RY + RE * sa); + const occl = 0.45 + 0.55 * (0.5 + 0.5 * sa); + // Lacquered wood, round in section: lit on top and toward the lamp. + for (let q = -2; q <= 1; q++) { + const ny = -(q + 0.5) * 0.7; + const nl = sqrt(1 + ny * ny); + const d = max(0, (ca * L[0] + ny * L[1] + sa * L[2]) / nl); + const sp = 0.8 * max(0, (ca * Hv[0] + ny * Hv[1] + sa * Hv[2]) / nl) ** 12; + const f = 255 * occl; + put(P, x, y + q, z + 1, f * (0.85 * (amb[0] + 1.1 * d * lc[0]) + sp * lc[0]), f * (0.16 * (amb[1] + 1.1 * d * lc[1]) + sp * lc[1]), f * (0.09 * (amb[2] + 1.1 * d * lc[2]) + sp * lc[2])); + } + } + // Four cords up to a knot under the beam. + const cc = [0.9, 0.75, 0.4].map((v, m) => 255 * v * (amb[m] + 0.6 * lc[m])); + for (let j = 0; j < 4; j++) { + const a = P.psi + PI / 4 + (j * PI) / 2; + const z = RR * sin(a); + line(P, 2 * (CX + RR * cos(a) * (1 + z * 0.004)), 2 * (RY + RE * sin(a)), z, 2 * CX, 10, 0, cc); + } + line(P, 2 * CX, 0, 0, 2 * CX, 10, 0, cc); + bead(P, 2 * CX, 10, 1, 3.2, [0.9, 0.16, 0.12], 1); +} + +export function draw(display, params, time) { + const P = params; + if (!P.S) return; + const key = `${P.light}`; + if (key !== P.key) { + P.key = key; + room(P); + } + lights(P); + P.zb.fill(-1e9); + ring(P); + hanging(P); + const { nmax, k } = layout(P.n); + for (const f of P.fall) crane(P, f.x, f.y, f.z, k, f.yaw, f.roll || 0, paperOf(P, f.i, nmax, P.S[f.s]), 1, false); + const { zb, cb, bg, rec, L } = P; + // The cranes' shadows on the wall, thrown away from the light. + const ox = round(clamp((-L[0] / L[2]) * 16, -24, 24)); + const oy = round(clamp((L[1] / L[2]) * 16, -16, 16)); + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + let r = 0; + let g = 0; + let b = 0; + let c = 0; + for (let q = 0; q < 4; q++) { + const i = (2 * y + (q >> 1)) * SW + 2 * x + (q & 1); + if (zb[i] > -1e8) { + r += cb[i * 3]; + g += cb[i * 3 + 1]; + b += cb[i * 3 + 2]; + c++; + } + } + const o = (y * W + x) * 3; + // Thin paper edges count a little more than their share, so a neck + // half an LED wide still shows. + const a = min(1, c * 0.36); + const f = 1 - a; + const ic = c ? a / c : 0; + const sx = 2 * x - ox; + const sy = 2 * y - oy; + const sh = f * (sx >= 0 && sx < SW && sy >= 0 && sy < SH && zb[sy * SW + sx] > -1e8 ? 1 - 0.6 * rec[o / 3] : 1); + display.setPixel(x, y, min(255, r * ic + sh * bg[o]), min(255, g * ic + sh * bg[o + 1]), min(255, b * ic + sh * bg[o + 2])); + } +} diff --git a/incoming/pattern-study/patterns/weaver.js b/incoming/pattern-study/patterns/weaver.js new file mode 100644 index 000000000..590d6fb27 --- /dev/null +++ b/incoming/pattern-study/patterns/weaver.js @@ -0,0 +1,438 @@ +// ===== Patternflow pattern ===== +// Title: Weaver +// Family: bird +// Idea: A male village weaver builds his nest at the tip of an acacia twig over a river: he tears green strips off the reeds and threads them in, and the nest grows strand by strand from a ring to a round chamber with a downward entrance while old strips fade to straw. +// Author: patternflow-lab +// Date: 2026-10-23 +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Lineage: original, pattern round 22, after field photographs of village weaver (Ploceus cucullatus) colonies +// Round: 22 +// =============================== +// @matrix 64x128 +// @knobs Plumage=0..2, Speed=0.2..3, Nests=3..9, Sky=0..1 +// +// Knob 1: Plumage. A village weaver at 0, yellow with a black mask and a red eye, over a river; a golden weaver at 1 over a reed bed; a brown-masked baya weaver at 2 over a paddy field, its nests hung from a woven neck with a long tube. +// Knob 2: Speed. How fast the birds, the wind and the water go: at 2 a strand takes him about eight seconds, there, back and woven in. +// Knob 3: Nests. Three to nine nests in the tree, his among them. The bigger the colony, the further along his nest is at the start, from a ring with its roof begun to a chamber that wants only its entrance. +// Knob 4: Sky. Morning at 0 with the sun low on the right, noon at 0.5, evening at 1 with the sun going down on the left. +// Buttons: K1 tap calls a female: he hangs under his nest and flutters while she looks it over, and a finished nest she turns down he cuts loose into the river. + +const [W, H] = [64, 128]; +const N = W * H; +// The far bank, the water, the near bank; his twig's tip and root. +const [HOR, WTOP, NEAR, AX, AY, RX, RY] = [89, 95, 121, 37, 38, 40, 19]; +const { min, max, floor, ceil, round, sin, cos, abs, hypot, pow, atan2, sign, PI } = Math; +const rnd = () => Math.random(); +const cl = (v, a, b) => (v < a ? a : v > b ? b : v); +const mix = (a, b, t) => a + (b - a) * t; +const mixc = (a, b, t) => a.map((v, i) => mix(v, b[i], t)); +const sm = (x) => ((x = cl(x, 0, 1)), x * x * (3 - 2 * x)); +function hash(i, j) { + let h = (Math.imul(i, 374761393) + Math.imul(j, 668265263)) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} +// Value noise that wraps every `per` cells. +function vn(x, y, per = 4096) { + const xi = floor(x), yi = floor(y), u = x - xi, v = y - yi, su = u * u * (3 - 2 * u), sv = v * v * (3 - 2 * v); + const x0 = ((xi % per) + per) % per, x1 = (x0 + 1) % per, a = hash(x0, yi), b = hash(x1, yi), c = hash(x0, yi + 1); + return a + (b - a) * su + (c - a) * sv + (a - b - c + hash(x1, yi + 1)) * su * sv; +} +const hx = (s) => s.split(" ").map((h) => [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16))); + +// Village, golden, baya: plumage in KEYS order (crown, mask, eye, bill, nape, breast, back, streak, +// wing, wing edge, tail), the fresh strip, and the nest's tube, neck and chamber radii. +const KEYS = "CKEBNYMmWyT"; +const SP = [ + [hx("18140e 100c0a f02418 221c18 b25c1a fcd61c f0ca20 28220c 2e2816 e8ca38 706426 5cb234"), [5, 0, 9, 8.5]], + [hx("ffcc18 fa8c12 faf0ba 1e1812 ffc816 ffc610 e4ac10 c49216 a48c1c fcd02c a48a20 82bc38"), [2, 0, 9.5, 9]], + [hx("ffd828 482e1a 402616 30241c fad22c f6d040 98703a 5c4226 684e2c d2b476 7c623c 9cc458"), [15, 5, 7.5, 8.5]], +]; +const pal = (a) => Object.fromEntries([...KEYS].map((k, i) => [k, a[i]]).concat([["F", [204, 150, 128]]])); +const FPAL = pal(hx("b4a04a d8c670 7c4a28 ccae86 b4a04a e8da92 a08a4a 64522e 725e34 c8b472 867240")); +const [STRAW, OLD] = hx("ceb874 96805c"); + +// The bird facing right: rows, anchor, bill tip. +const pose = (s, ax, ay, tx, ty) => ({ rows: s.split(" "), ax, ay, tx, ty }); +const PERCH = pose("......CCC .....NCCCC .....NKEKKB ....MMKKKBBB ...MmMYKK ..WWWMYYY .yWWWYYYY TyWWyYYY TT..YYY T...F.F", 6, 5, 11, 3); +const POKE = pose(". ...MMM ..WWMNCCC .yWWMNCCCC TyWWYMKEKKB TT.YYYKKKBBB T...YYYKK ....F.F", 5, 4, 11, 5); +const FA = pose("...yW ...yWW ....yWW..CC TTMMMWWNCCKB ..TYYYYYKEKBB .....YYYYKK", 6, 4, 12, 4); +const flap = (t) => (floor(t / 0.065) & 1 ? FB : FA); +const FB = pose(".........CC TTMMMMMNCCKB ..TYYWWWYKEKBB .....yWWWKK ......yWW .......yW", 6, 2, 12, 2); + +// Morning, noon, evening: sky overhead, at the horizon, the light, the sun. +const SKY = [ + [...hx("5480c6 fcc28e"), [1.08, 0.9, 0.74], 50, 79], + [...hx("2c72e0 acd6f6"), [1, 1, 1], 40, -40], + [...hx("3e3686 ff8442"), [1.12, 0.74, 0.56], 16, 84], +]; +// The acacia: limbs as x0, y0, x1, y1, w0, w1, and the flat plates of its crown. +const LIMBS = [[6, 25, 24, 18, 3.6, 2.6], [24, 18, 44, 19, 2.6, 2], [44, 19, 63, 14, 2, 1.3], [40, 19, 57, 26, 1.7, 1.2], [57, 26, 64, 31, 1.2, 1], [7, 50, 20, 45, 2.8, 1.7], [20, 45, 28, 49, 1.7, 1]]; +const PLATES = [[32, 3, 42, 6.5], [6, 11, 13, 5], [48, 10, 20, 4.5], [22, 12, 10, 3], [62, 16, 8, 3]]; +// The rest of the colony: twig tip, twig root, size, which way it faces. +const COLONY = [[13, 31, 14, 21, 0.72, -1], [55, 56, 57, 26, 0.8, 1], [19, 64, 19, 47, 0.72, 1], [52, 31, 50, 19, 0.62, -1], [29, 82, 26, 48, 0.62, 1], [61, 84, 62, 30, 0.6, -1], [26, 27, 27, 19, 0.52, 1], [14, 85, 12, 51, 0.58, -1]]; +// The two tall blades he strips: foot, height, lean. +const PICK = [[15, 123, 20, 3], [50, 122, 21, -2.5]]; +const [WEAVE, OUT, TEAR, BACK, SHOW, CUT] = [0, 1, 2, 3, 4, 5]; +const DUR = [3.2, 1.5, 1.4, 1.7, 1, 1.6]; + +// A nest as strands in the order they go in, in LEDs from the twig tip, facing right: the neck +// round the twig, the ring he stands in, the egg chamber from the roof round the back, the front +// wall down to the entrance, the entrance tube. F says which strand closes each LED of the wall. +function gen([tube, nk, rx, ry], seed, s) { + let c = 0; + const r = () => hash(seed, c++), S = [], cy = nk + 8, bx = -3, by = cy + 1.5, F = []; + const add = (p) => S.push({ p, sh: 0.85 + 0.3 * r() }); + const arcs = (n, cx, cy, ax, ay, th, f0, keep) => { + for (let j = 0; j < n; j++) { + const p = [], f = f0 + (1 - f0) * r(), t = th(j) + (r() - 0.5) * 0.7, sw = 1.6 + r(), ox = 2 * r() - 1, oy = 2 * r() - 1; + for (let a = -sw / 2; a < sw / 2; a += 0.5 / (ax * f)) { + const x = cx + ox + cos(t + a) * ax * f, y = cy + oy + sin(t + a) * ay * f; + if (keep(x, y)) p.push(x, y); + } + add(p); + } + }; + const ring = (x, y) => (x / 5) ** 2 + ((y - cy) / 7) ** 2, ball = (x, y) => ((x - bx) / rx) ** 2 + ((y - by) / ry) ** 2; + const door = (x, y) => y > cy + 4.5 && abs(x - 2) < 2.5; + for (let y = 0; y < nk - 0.5; y += 1.5) { + const p = [], w = 1 + y * 0.22; + for (let i = 0; i <= 8; i++) p.push(-w + (w * i) / 4, y + i * 0.3); + add(p); + } + for (let i = 0; i < 6; i++) { + const p = [], f = 0.9 + 0.2 * r(), a0 = r() * 6.3, sw = 4 + 2 * r(); + for (let a = 0; a < sw; a += 0.12) p.push(cos(a0 + a) * 5 * f, cy + sin(a0 + a) * 7 * f); + add(p); + } + const pre = S.length; + arcs(16, bx, by, rx, ry, (j) => -PI / 2 - (j / 15) * PI * 1.1, 0.45, (x, y) => ball(x, y) < 1); + const ch = S.length; + arcs(7, 0, cy, 6, 8, (k) => -1.2 + k * 0.42, 0.35, (x, y) => ring(x, y) < 1 && !door(x, y)); + const fr = S.length, t0 = cy + 5, nt = 2 + round(tube / 2), dy = (tube + 3) / nt; + for (let k = 0; k < nt; k++) { + const p = [], y = t0 + k * dy, hw = k < 2 ? 4.4 - k : 2.6, c = 2.6 + (y - t0) * 0.08; + for (let i = 0; i <= 8; i++) p.push(c - hw + (hw * i) / 4, y + i * 0.28); + add(p); + } + for (let y = 0; y < t0 + tube + 4; y += 1 / s) + for (let x = -13; x < 8; x += 1 / s) { + const g = ring(x, y), tk = floor((y - t0) / dy), w = (1.5 * PI - atan2((y - by) / ry, (x - bx) / rx)) % (2 * PI); + let k = -1; + if (y < nk) k = abs(x) < 1 + y * 0.22 ? floor(y / 1.5) : -1; + else if (g < 1 && !door(x, y)) k = g > 0.6 ? pre - 6 : ch + cl(round((atan2((y - cy) / 8, x / 6) + 1.2) / 0.42), 0, 6); + else if (ball(x, y) < 1 && !door(x, y)) k = pre + min(15, round(w / (PI * 0.0733))); + else if (y >= t0 && tk < nt && abs(x - 2.6 - (y - t0) * 0.08) < (tk < 2 ? 4.4 - tk : 2.6)) k = fr + tk; + if (k >= 0) F.push(x, y, k + (y > t0 + tube + 1.8) * 1000); + } + return Object.assign(S, { pre, fr, by, F }); +} + +// A nest's point on the panel, swaying on its twig and dipping when a bird lands. +const nxy = (n, lx, ly) => [n.x + n.sw * (1 + (ly * n.s) / 20) + lx * n.s * n.face, n.y + n.dip + n.fall + ly * n.s]; + +function build(P) { + const { alb, mat } = P; + const set = (x, y, m, r, g, b) => x >= 0 && x < W && y >= 0 && y < H && ((mat[y * W + x] = m), alb.set([r, g, b], (y * W + x) * 3)); + for (let p = 0; p < N; p++) { + const x = p & 63, y = p >> 6, k = 0.8 + 0.3 * vn(x * 0.4, y * 0.8) + 0.1 * hash(x + 7, y); + if (y >= NEAR + round(2 * vn(x * 0.2, 4))) set(x, y, 5, 118 * k, 96 * k, 58 * k); + else if (y >= WTOP) set(x, y, 4, 0, 0, 0); + else if (y >= HOR) set(x, y, 3, 200 * k, 166 * k, 96 * k); + } + // Flat-topped acacias on the far bank. + for (let i = 0; i < 6; i++) { + const cx = 12 + i * 10.5 + rnd() * 5, top = HOR - 4 - floor(rnd() * 4), w = 2 + rnd() * 4; + for (let y = top; y < top + 2; y++) for (let x = round(cx - w - (y - top) * 1.5); x <= cx + w + (y - top) * 1.5; x++) set(x, y, 6, 44, 60, 50); + for (let y = top + 2; y < HOR; y++) set(round(cx), y, 6, 52, 56, 46); + } + const bark = (x, y, e, k = (0.72 + 0.4 * vn(x * 0.8, y * 0.16)) * (1.1 - 0.45 * e * e) * (e > 0.3 ? 1.12 : 1)) => set(x, y, 1, 112 * k, 94 * k, 76 * k); + for (let y = 10; y < H; y++) for (let c = 5.5 + 1.8 * sin(y * 0.03 + 0.5), w = 2 + (y / H) * 2.2, x = floor(c - w); x <= c + w; x++) bark(x, y, (x - c) / w); + for (const [x0, y0, x1, y1, w0, w1] of LIMBS) + for (let i = 0, n = ceil(hypot(x1 - x0, y1 - y0) * 2); i <= n; i++) { + const f = i / n, x = mix(x0, x1, f), y = mix(y0, y1, f), w = mix(w0, w1, f); + for (let dy = -w / 2; dy <= w / 2; dy += 0.5) bark(round(x), round(y + dy), (-2 * dy) / w); + } + for (const [cx, cy, rx, ry] of PLATES) + for (let y = floor(cy - ry); y <= cy + ry; y++) + for (let x = floor(cx - rx); x <= cx + rx; x++) { + const d = ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2, n = vn(x * 0.35, y * 0.5 + 20) * 0.7 + vn(x * 0.9, y * 1.1) * 0.3; + const k = 0.55 + 0.6 * n + (y < cy - ry * 0.3 ? 0.28 : 0) - (y > cy + ry * 0.35 ? 0.2 : 0); + if (d < 1 && n > 0.25 + 0.4 * d) set(x, y, 2, 80 * k, 118 * k, 44 * k); + } + // Reeds at the water's edge, and more out in a reed bed. + P.reeds = Array.from({ length: 40 }, (_, i) => + i < 12 ? [(i & 1 ? 44 : 10) + (i >> 1) * 2.4 + rnd() * 2, NEAR + 1, 12 + floor(rnd() * 9), rnd() * 7, i % 3 === 0] : [3 + rnd() * 60, WTOP + 4 + floor(rnd() * 20), 7 + floor(rnd() * 9), rnd() * 7, i % 2 === 0]); + P.shad = Float32Array.from({ length: 128 }, (_, i) => sm(1 - abs(i - 34) / 26) + 0.7 * sm(1 - abs(i - 96) / 16)); + P.cloud = Float32Array.from({ length: 128 * 36 }, (_, i, x = i & 127, y = i >> 7) => sm((vn(x / 16, y / 4, 8) * 0.65 + vn(x / 7, y / 2.5 + 9, 128 / 7) * 0.35 - 0.5) * 3) * sm(1 - abs(y - 16) / 18)); +} + +// The sky for the hour, and the water and paddy that reflect it. +function paint(P) { + const f = P.sk * 2, i = min(1, floor(f)), [top, hor, tint, sx, sy] = SKY[i].map((v, c) => (v.map ? mixc(v, SKY[i + 1][c], f - i) : mix(v, SKY[i + 1][c], f - i))); + const { alb, mat, bg, wet, rice } = P, G = [70, 160, 44]; + Object.assign(P, { hor, tint, sx, sun: cl(1 - abs(sy - 70) / 60, 0, 1) }); + for (let p = 0; p < N; p++) { + const x = p & 63, y = p >> 6, m = mat[p], o = p * 3, ds = hypot(x - sx, (y - sy) * 1.2), z = (y - WTOP) / (NEAR - WTOP), row = (y - WTOP + 2) ** 0.7 * 1.6; + const band = m === 4 && row % 1 < 0.42 && hash(x, floor(row)) > 0.15 ? rice : 0; + wet[p] = m === 4 ? 1 - band : 0; + for (let c = 0, a; c < 3; c++) + (a = alb[o + c] * tint[c]), + (bg[o + c] = + m === 0 ? mix(top[c], hor[c], pow(y / HOR, 1.5)) + (sy > 0 ? (ds < 3.2 ? 255 : 120 * Math.exp(-ds / 9)) : 0) + : m === 4 ? mix(mix(hor[c], top[c], 0.3 + 0.5 * z) * (0.8 - 0.2 * z), G[c] * tint[c] * (0.8 + 0.4 * hash(x, y)), band) + : m === 3 || m === 6 ? mix(a, hor[c], m === 6 ? 0.4 : 0.22) + : a); + } +} + +function species(P, s) { + const i = min(1, floor(s)), f = s - i, [c0, q0] = SP[i], [c1, q1] = SP[i + 1], v = c0.map((c, j) => mixc(c, c1[j], f)), M = P.main; + Object.assign(P, { pal: pal(v), fresh: v[11], rice: cl(s - 1, 0, 1), reedN: 12 + round(28 * (1 - abs(s - 1))) }); + for (const n of [M, ...P.col]) n.S = gen(mixc(q0, q1, f), n.seed, n.s); + M.nb = min(M.nb, M.S.length); +} + +const nest = (x, y, rx, ry, s, face, seed) => ({ x, y, rx, ry, s, face, seed, nb: 0, age: [], sw: 0, dip: 0, dv: 0, fall: 0, ph: rnd() * 7 }); + +export function setup(params) { + const P = params, F = (n) => new Float32Array(n); + Object.assign(P, { alb: F(N * 3), mat: new Uint8Array(N), bg: F(N * 3), buf: F(N * 3), wet: F(N), sp: -1, sk: -1, t: 0, fs: 0, side: 0, spt: 9 }); + P.main = nest(AX, AY, RX, RY, 1.3, 1, 7); + P.col = COLONY.map((c, i) => Object.assign(nest(...c, 101 + i * 37), { old: (i * 0.37) % 1, fresh: i === 2 })); + build(P); +} + +// Where he clings to weave strand j at reveal u: outside the wall, facing it. +function wpos(P, j, u) { + const M = P.main, p = M.S[min(j, M.S.length - 1)].p, i = min(p.length - 2, floor((u * p.length) / 2) * 2); + const [x, y] = nxy(M, p[i], p[i + 1]), [cx, cy] = nxy(M, -2, M.S.by), l = hypot(x - cx, y - cy) || 1, dx = (x - cx) / l, dy = (y - cy) / l; + return [x + dx * 4.5, y + dy * 4.5 + (dy > 0.55 ? 1 : -1), dx > 0 ? -1 : 1, dy > 0.55]; +} +function bladeTip(P, k) { + const [bx, by, h, lean] = PICK[k], b = P.bend[k]; + return [bx + lean + sin(P.t * 1.3 + k * 2) * 0.8 + b * sign(lean) * 0.8, by - h + b]; +} + +function stepMale(P, d) { + const M = P.main, held = P.fs && P.fs < 4; + let u = (P.bt += d) / DUR[P.bs]; + const go = (s) => ((P.bs = s), (P.bt = 0), (P.x0 = P.bx), (P.y0 = P.by)); + const fly = ([tx, ty], arc) => ([P.face, P.bx, P.by, P.pose, P.hang] = [tx >= P.x0 ? 1 : -1, mix(P.x0, tx, sm(u)), mix(P.y0, ty, sm(u)) - arc * sin(PI * u), flap(P.bt), false]); + P.show = false; + if (held && !P.bs) (P.bt -= d), (P.show = true), (u = -1); + switch (P.bs) { + case WEAVE: { + if (u < 0) break; + // Five passes, each a push through the wall and a tug that pulls it tight and shakes the twig. + const k = cl(u, 0, 1) * 5, q = floor(k), f = k - q, rv = min(1, (q + sm(f * 2.5)) / 5); + [P.bx, P.by, P.face, P.hang] = wpos(P, M.nb, rv); + [P.rev, P.pose, P.strip] = [rv, f < 0.42 ? POKE : PERCH, (1 - rv) * 13]; + if (f >= 0.42 && !P.tug) M.dv += 5; + P.tug = f >= 0.42; + if (u >= 1) (M.age[M.nb++] = P.t), (P.rev = P.strip = 0), go(M.nb >= M.S.length ? SHOW : OUT); + break; + } + case OUT: + P.tip = bladeTip(P, P.side); + fly([P.tip[0], P.tip[1] - 4], 5); + if (u >= 1) go(TEAR); + break; + case TEAR: { + // Bite low on the blade and back off up it, tearing a strip. + const t = bladeTip(P, P.side), e = sm((u - 0.25) / 0.6), fc = PICK[P.side][3] > 0 ? -1 : 1; + P.bend[P.side] = min(3, P.bend[P.side] + d * 12); + [P.face, P.pose, P.hang, P.bx, P.by] = [fc, u < 0.25 || (u > 0.5 && u < 0.6) ? POKE : PERCH, false, t[0] - fc * 3 * e, t[1] - 4 - 2 * e]; + P.tear = u > 0.2 && u < 0.88 ? [t[0] + fc * 3, t[1] + 6] : null; + if (u >= 1) (P.tear = null), (P.strip = 13), go(BACK), (P.side ^= 1); + break; + } + case BACK: + fly(wpos(P, M.nb, 0), 6); + if (u >= 1) (M.dv += 16), go(WEAVE); + break; + case SHOW: + P.show = true; + if (!P.fs && P.bt > 0.8) callFemale(P); + if (P.reject) (P.reject = false), go(CUT); + break; + case CUT: { + const [x, y] = nxy(M, 0, 0); + [P.bx, P.by, P.face, P.hang, P.pose] = [x - 3, y - 3, 1, false, floor(P.bt / 0.2) & 1 ? POKE : PERCH]; + if (u >= 1) (M.fv = -10), (M.fall = 0.01), (M.dv -= 20), go(OUT); + } + } + if (P.bs !== TEAR) P.bend = P.bend.map((b) => max(0, b - d * 8)); +} + +function callFemale(P) { + if (!P.fs) Object.assign(P, { fs: 1, ft: 0, fx0: 70, fy0: 30 + rnd() * 20 }); +} +function stepFemale(P, d) { + const M = P.main, done = M.nb >= M.S.length, [rx, ry] = nxy(M, -3, M.S.by - 8.5).map((v, i) => v - 1 - 4 * i), u = (P.ft += d) / [1, 1.8, 3.4, 1, 1.8][P.fs]; + const go = (s) => ((P.fs = s), (P.ft = 0), (P.fx0 = P.fx), (P.fy0 = P.fy)); + if (P.fs === 1 || P.fs === 4) { + const [tx, ty] = P.fs === 1 ? [rx, ry] : [-12, 24]; + [P.fx, P.fy, P.ff, P.fp] = [mix(P.fx0, tx, sm(u)), mix(P.fy0, ty, sm(u)) - 5 * sin(PI * u), tx > P.fx0 ? 1 : -1, flap(P.ft)]; + if (P.fs === 1 && u >= 1) (M.dv += 10), go(2); + if (P.fs === 4 && u >= 1) (P.fs = 0), (P.reject = done); + } else { + [P.fx, P.fy, P.ff, P.fp] = [rx, ry, floor(P.ft / 0.9) & 1 ? 1 : -1, floor(P.ft / 0.45) % 3 === 1 ? POKE : PERCH]; + if (u >= 1) go(4); + } +} + +export function update(dt, input, params) { + const P = params, k = input.knobValues || [0, 2, 3, 0.06], sp = cl(k[0], 0, 2), sk = cl(k[3], 0, 1), nn = cl(k[2], 3, 9), M = P.main; + if (abs(sp - P.sp) > 0.004) (P.sp = sp), species(P, sp), (P.sk = -1); + if (abs(sk - P.sk) > 0.002) (P.sk = sk), paint(P); + P.nn = round(nn) - 1; + if (!P.bend) { + // Start halfway through a strand. + M.nb = min(M.S.length - 1, M.S.pre + 3 + round(((nn - 3) / 6) * (M.S.fr - M.S.pre - 3))); + for (let j = 0; j < M.nb; j++) M.age[j] = -(M.nb - j) * 7.8; + Object.assign(P, { bs: WEAVE, bt: 1.4, rev: 0, strip: 0, bend: [0, 0] }); + } + if (input.btnPressed?.[0]) callFemale(P); + const d = cl(dt, 0, 0.1) * (cl(k[1], 0.2, 3) / 2); + P.t += d; + for (const n of [M, ...P.col]) { + n.sw = (0.55 * sin(P.t * 0.9 + n.ph) + 0.3 * sin(P.t * 2.3 + n.ph * 2)) * (1.4 - n.s * 0.4); + n.dv += (-70 * n.dip - 5 * n.dv) * d; + n.dip = cl(n.dip + n.dv * d, -3, 4); + } + if (M.fall) { + M.fall += (M.fv += 90 * d) * d; + if (M.y + M.fall > WTOP + 2) (P.spx = M.x - 2), (P.spt = 0), (M.fall = M.nb = 0), (M.seed += 11), species(P, P.sp); + } + stepMale(P, d); + if (P.fs) stepFemale(P, d); + P.spt += d; +} + +let BUF, TT; +function Ao(o, r, g, b, a = 1) { + BUF[o] += (r - BUF[o]) * a; + BUF[o + 1] += (g - BUF[o + 1]) * a; + BUF[o + 2] += (b - BUF[o + 2]) * a; +} +const A = (x, y, r, g, b, a) => x >= 0 && x < W && y >= 0 && y < H && Ao((y * W + x) * 3, r, g, b, a); +const AL = (x, y, c, k = 1) => A(x, y, c[0] * k * TT[0], c[1] * k * TT[1], c[2] * k * TT[2]); + +// Flipped, he hangs upside down; turned, he hangs head down with his wings going. +function drawBird(S, pl, x0, y0, face, flip, turn) { + const ox = round(x0), oy = round(y0); + S.rows.forEach((row, r) => + [...row].forEach((ch, c, _, col = ch === "M" && (c + r) & 1 ? pl.m : pl[ch]) => col && (turn ? AL(ox + (r - S.ay) * face, oy + c - S.ax, col) : AL(ox + (c - S.ax) * face, oy + (flip ? S.ay - r : r - S.ay), col))), + ); + return [ox + (S.tx - S.ax) * face, oy + (flip ? S.ay - S.ty : S.ty - S.ay)]; +} + +// A strip of grass from the bill: trailing in flight, hanging while he weaves. +function strip(P, [x, y], len, face, flying) { + let a = flying ? (face > 0 ? PI * 0.9 : PI * 0.1) : PI / 2 - face * 0.6; + const tg = flying ? a : PI / 2; + for (let i = 0; i < len; i++) { + a += (tg - a) * 0.15 + 0.12 * sin(P.t * 9 + i * 0.8); + AL(round((x += cos(a))), round((y += sin(a))), P.fresh, 1.1 + 0.15 * sin(i)); + } +} + +function drawNest(P, n, nb, rev, light) { + const S = n.S, F = S.F, sd = (P.sx > 32 ? 1 : -1) * n.face, { x: nx, s, face, sw } = n, ny = n.y + n.dip + n.fall, lim = min(nb, S.length - 1), C = []; + for (let j = 0; j <= lim; j++) { + C[j] = (n.fresh ? mixc(P.fresh, STRAW, (0.7 * (nb - j)) / 12) : n.old >= 0 ? mixc(STRAW, OLD, n.old) : mixc(P.fresh, STRAW, sm((P.t - (n.age[j] ?? P.t)) / 45))).map((v, c) => v * TT[c]); + } + const lit = (lx, ly) => 0.84 + 0.16 * cl((lx * sd) / 9 - (ly - S.by) / 20, -1, 1), at = (lx, ly) => [round(nx + sw * (1 + (ly * s) / 20) + lx * s * face), round(ny + ly * s)]; + n.low = -99; + for (let i = 0; i < F.length; i += 3) { + const k = F[i + 2] % 1000, lx = F[i], ly = F[i + 1]; + if (k > lim || (k === nb && rev < 0.7)) continue; + // The wall behind the strands: straw in slanting runs, the entrance mouth dark. + const [xi, yi] = at(lx, ly), c = C[k], q = F[i + 2] > 999 ? 0.15 : light * lit(lx, ly) * (0.68 + 0.1 * hash(i, 5) + 0.12 * sin((lx * 0.8 + ly) * 2.2 + 1.5 * sin(lx * 0.5 - ly * 0.3))); + A(xi, yi, c[0] * q, c[1] * q, c[2] * q); + if (yi > n.low) [n.low, n.lowx] = [yi, xi]; + } + for (let j = 0; j <= lim; j++) { + const p = S[j].p, m = j < nb ? p.length : floor((rev * p.length) / 2) * 2, k0 = light * S[j].sh, [cr, cg, cb] = C[j]; + for (let i = 0; i < m; i += 2) { + const [xi, yi] = at(p[i], p[i + 1]), k = k0 * (lit(p[i], p[i + 1]) + ((i >> 2) & 1 ? 0.1 : -0.08)); + A(xi, yi + 1, cr * 0.3, cg * 0.3, cb * 0.3, 0.3); + A(xi, yi, cr * k, cg * k, cb * k); + } + } +} + +function twig(n) { + const [tx, ty] = nxy(n, 0, 0); + for (let i = 0, L = ceil(hypot(tx - n.rx, ty - n.ry) * 1.3); i <= L; i++) AL(round(mix(n.rx, tx, i / L) + 1.5 * sin((PI * i) / L) * n.face), round(mix(n.ry, ty, i / L)), [96, 80, 64]); +} + +function sky(P) { + // Clouds drift behind the tree; the crown flickers in the wind. + const { buf, mat, wet, t, sun } = P, off = t * 3, CT = P.cloud, cc = P.hor.map((v) => mix(v, 255, 0.55)); + for (let p = 15 * W; p < 86 * W; p++) + if (!mat[p]) { + const x = (p & 63) + off, x0 = floor(x), f = x - x0, r = ((p >> 7) - 7) * 128; + Ao(p * 3, cc[0], cc[1], cc[2], (CT[r + (x0 & 127)] * (1 - f) + CT[r + ((x0 + 1) & 127)] * f) * 0.8); + } + for (let p = 0; p < 22 * W; p++) if (mat[p] === 2) for (let c = 0, s = 0.8 + 0.45 * CT[((p >> 6) + 8) * 128 + (((p & 63) + floor(t * 6)) & 127)]; c < 3; c++) buf[p * 3 + c] *= s; + // Ripples on the water, and the sun's road across it. + for (let p = WTOP * W; p < (NEAR + 2) * W; p++) { + if (!wet[p]) continue; + const x = p & 63, y = p >> 6, z = (y - WTOP) / 26, rp = sin(y * 1.9 + 2.4 * sin(x * 0.2 + t * 0.7 + y * 0.3) - t * 1.6), o = p * 3; + const g = rp > 0.75 ? cl((0.4 + 1.6 * sun * cl(1 - abs(x - P.sx) / (3 + 9 * z), 0, 1)) * wet[p], 0, 1) * 0.7 : 0; + for (let c = 0; c < 3; c++) buf[o + c] = mix(buf[o + c] * (1 + 0.1 * rp * wet[p]), [255, 236, 190][c], g); + } + for (let r = P.spt * 9, x = floor(P.spx - r); P.spt < 2.5 && x <= P.spx + r; x++) A(x, round(WTOP + 2 + 0.2 * r * (1 - ((x - P.spx) / r) ** 2)), 240, 244, 240, 0.8 - P.spt * 0.3); +} + +function reeds(P) { + for (let i = 0; i < P.reedN; i++) { + const [rx, ry, h, ph, plume] = P.reeds[i], lean = sin(P.t * 1.4 + ph) * 0.8 + (i & 1 ? 1 : -1); + for (let j = 0; j < h; j++) { + const x = round(rx + lean * (j / h) ** 2), k = 0.7 + (0.5 * j) / h; + AL(x, ry - j, [84, 140, 50], k); + if (j === round(h * 0.4) || j === round(h * 0.7)) for (let e = 1, sd = (j + i) & 1 ? 1 : -1; e < 4; e++) AL(x + sd * e, ry - j - (e >> 1), [96, 156, 56], k); + } + if (plume) for (let j = 0; j < 4; j++) AL(round(rx + lean) + (j & 1), ry - h - j, [160, 112, 74]); + } + for (let k = 0; k < 2; k++) { + const [bx, by, h, lean] = PICK[k], [tx, ty] = bladeTip(P, k); + for (let j = 0; j <= h; j++) { + const f = j / h, x = round(mix(bx, tx, f * f)), y = round(mix(by, ty, f)); + AL(x, y, [96, 168, 56], 0.7 + 0.5 * f); + if (j % 4 === 2) AL(x - sign(lean) * (1 + (j & 4 ? 1 : 0)), y + 1, [80, 150, 50], 0.8); + } + } +} + +export function draw(display, params) { + const P = params, { buf, t, pal: pl, mat, shad } = P, M = P.main; + [BUF, TT] = [buf, P.tint]; + buf.set(P.bg); + sky(P); + reeds(P); + // The colony; its males hang under their nests and flutter, sit on top, or are away. + for (let i = 0; i < P.nn; i++) { + const n = P.col[i], c = (t * 0.06 + n.ph * 0.3) % 1, [x, y] = nxy(n, 2 * n.face, n.S.by + 3); + twig(n); + drawNest(P, n, n.fresh ? 10 : n.S.length, 0, 0.85); + if (n.fresh) drawBird(floor(t / 0.5) & 1 ? POKE : PERCH, pl, x + 5 * n.face, y - 3, -n.face); + else if (i % 3 !== 2) c < 0.35 ? drawBird(flap(t), pl, n.lowx, n.low + 7, n.face, false, true) : c < 0.55 && drawBird(PERCH, pl, ...nxy(n, -3, -3 / n.s), -n.face); + } + twig(M); + drawNest(P, M, M.nb, P.bs === WEAVE ? P.rev : 0, 1); + if (P.fs) drawBird(P.fp || FA, FPAL, P.fx, P.fy, P.ff); + if (P.show) drawBird(flap(t), pl, M.lowx, M.low + 7, 1, false, true); + else { + const tip = drawBird(P.pose, pl, P.bx, P.by, P.face, P.hang); + if (P.strip > 0.5) strip(P, tip, round(P.strip), P.face, P.bs === OUT || P.bs === BACK); + for (let i = 0; P.tear && i <= 8; i++) AL(round(mix(tip[0], P.tear[0], i / 8)), round(mix(tip[1], P.tear[1], i / 8)), P.fresh, 1.1); + } + // Out to the panel, under the shadows of the passing clouds. + for (let p = 0, o = 0, so = floor(t * 3); p < N; p++, o += 3) { + const s = mat[p] ? 1 - 0.4 * shad[((p & 63) + so - (p >> 7)) & 127] : 1; + display.setPixel(p & 63, p >> 6, cl(buf[o] * s, 0, 255), cl(buf[o + 1] * s, 0, 255), cl(buf[o + 2] * s, 0, 255)); + } +}