Compare commits
21
Commits
28a45ff8e3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e234baa4aa | ||
|
|
4b27a8c209 | ||
|
|
0a05c94ce1 | ||
|
|
ad5a5b0cfb | ||
|
|
60437790a8 | ||
|
|
1581b7a64a | ||
|
|
db945a41cb | ||
|
|
8b6d407507 | ||
|
|
21b0bb7e98 | ||
|
|
0d58170211 | ||
|
|
924f2e0506 | ||
|
|
62afa4b272 | ||
|
|
586e59608e | ||
|
|
39374cea05 | ||
|
|
6ce32d33fe | ||
|
|
8c2113a323 | ||
|
|
850ac84ef7 | ||
|
|
ff86bdb5b5 | ||
|
|
eb49ea4b84 | ||
|
|
18dfb63350 | ||
|
|
1b8698565a |
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 3000,
|
||||
"port": 2027,
|
||||
"mongoUri": "mongodb://172.16.0.7:27017",
|
||||
"mongoDatabase": "sunrise",
|
||||
"visitStore": "mongo",
|
||||
"allowedOrigins": [
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:2027",
|
||||
"http://localhost:2027",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:5173"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"accessToken": "",
|
||||
"siteUrl": "https://sunrise.horoli.kr",
|
||||
"stateDir": "data/threads",
|
||||
"imageDir": "dist/threads-captures",
|
||||
"imageBaseUrl": "https://sunrise.horoli.kr/threads-captures/"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build.sh text eol=lf
|
||||
@@ -18,4 +18,7 @@ __pycache__/
|
||||
|
||||
# Environment secrets (keep the documented template)
|
||||
.env.*
|
||||
!/.env.threads.sample.json
|
||||
/.config.json
|
||||
/data/threads/
|
||||
/.threads-captions.json
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"template": ["오늘의 관악산 일출이에요", "{dateDots} · 일출 {sunrise}", "날씨: {message}"],
|
||||
"replyTemplate": ["관악산 일출 보러 가기", "{url}"],
|
||||
"testPrefix": "[테스트] ",
|
||||
"thresholds": { "fogVisibilityMeters": 1000, "cloudyPercent": 80, "partlyCloudyPercent": 30 },
|
||||
"messages": {
|
||||
"clear": ["맑음"],
|
||||
"partlyCloudy": ["구름 조금"],
|
||||
"cloudy": ["흐림"],
|
||||
"fog": ["낮은 시정"],
|
||||
"rain": ["비"],
|
||||
"snow": ["눈"],
|
||||
"unknown": ["정보 없음"]
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
# 해보러가기
|
||||
|
||||
매일 일출 시각에 화면을 촬영하고 Meta Threads에 게시하는 Linux 작업은 [자동 게시 설정 가이드](docs/threads-automation.md)를 참고하세요.
|
||||
|
||||
## 현재 과천 건물: VWorld 단독 (2026-10-30)
|
||||
|
||||
OSM 건물의 외곽선·높이·층수와 OSM 대조 기반 선별을 제거했다. VWorld 원본에서 독립적으로 재생성한 **6,743개**를 표시한다(높이 자료 1,756개 / 층수 환산 953개 / 임시 높이 4,034개). VWorld 내부 겹침 183개를 제외했다. 아래 OSM·VWorld 통합 설명은 이전 작업 기록이며 현재 구성에 적용되지 않는다. 해안선·정상 구조물 위치의 OSM 사용은 유지한다.
|
||||
@@ -13,7 +15,7 @@ OSM 건물의 외곽선·높이·층수와 OSM 대조 기반 선별을 제거했
|
||||
|
||||
## 공동 관측 서버
|
||||
|
||||
관악산 채널 1은 최대 100명을 수용합니다. Node.js + Socket.IO로 이동·감정표현·우천 장비를 공유하고 MongoDB에는 접속 기록만 저장합니다. `npm run build` 후 `npm start`로 실행합니다. 기본 주소는 `http://127.0.0.1:3000`입니다.
|
||||
관악산 채널 1은 최대 100명을 수용합니다. Node.js + Socket.IO로 이동·감정표현·우천 장비를 공유하고 MongoDB에는 접속 기록만 저장합니다. `npm run build` 후 `npm start`로 실행합니다. 기본 주소는 `http://127.0.0.1:2027`입니다.
|
||||
|
||||
환경설정·개발 실행·검증 방법과 운영 한계는 [서버 설명](docs/multiplayer-server.md)을 참고하세요. 기본 MongoDB는 `172.16.0.7:27017`의 `sunrise.visitSessions`이며 `.config.example.json`을 `.config.json`으로 복사한 뒤 변경합니다.
|
||||
|
||||
@@ -37,7 +39,7 @@ OSM 건물의 외곽선·높이·층수와 OSM 대조 기반 선별을 제거했
|
||||
|
||||
- 현재 관악산 공동 방을 `채널 1`로 사용한다.
|
||||
- `/api/lobby`에서 서버 목록, 채널 정원·인원·입장 가능 여부를 조회한다. 선택 화면에서는 방문 기록이나 3D 장면을 생성하지 않는다.
|
||||
- 개발 시 프런트엔드(`npm run dev`)와 백엔드(`npm run dev:server`)를 함께 실행한다. Vite가 `/api`와 `/socket.io`를 백엔드 3000번 포트로 전달한다.
|
||||
- 개발 시 프런트엔드(`npm run dev`)와 백엔드(`npm run dev:server`)를 함께 실행한다. Vite가 `/api`와 `/socket.io`를 백엔드 2027번 포트로 전달한다.
|
||||
- 서버 조회 실패·입장 실패는 재시도할 수 있다. 명시적인 `?mode=solo` 비교 화면과 개발용 `?profile` 경로는 기존 직접 진입을 유지한다.
|
||||
|
||||
아래 내용은 각 기능의 개발 기록이다.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo 'Run this deployment build as root: sudo ./build.sh' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check the service account before replacing dist.
|
||||
sunrise_group=$(id -gn sunrise)
|
||||
git pull --ff-only
|
||||
npm run build
|
||||
install -d -o sunrise -g "$sunrise_group" -m 755 dist/threads-captures
|
||||
pm2 restart sunrise
|
||||
systemctl restart sunrise-threads.service
|
||||
|
||||
echo 'Build complete; capture permissions restored, PM2 sunrise and sunrise-threads.service restarted.'
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Sunrise Threads daily capture and publishing worker
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=sunrise
|
||||
WorkingDirectory=/opt/sunrise
|
||||
Environment=NODE_ENV=production
|
||||
Environment=TZ=Asia/Seoul
|
||||
Environment=HOME=/home/sunrise
|
||||
ExecStart=/usr/bin/node /opt/sunrise/server/threads/worker.ts
|
||||
Restart=on-failure
|
||||
RestartSec=60
|
||||
TimeoutStopSec=15
|
||||
KillMode=control-group
|
||||
UMask=0022
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -12,13 +12,13 @@ npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
예제 설정의 접속 주소: `http://127.0.0.1:3000`. DB: `mongodb://172.16.0.7:27017`, DB 이름 `sunrise`, 컬렉션 `visitSessions`. 인증이 필요하면 `mongoUri`에 계정과 `authSource` 등 해당 서버 설정을 지정한다. URI를 브라우저용 `VITE_*` 변수에 넣지 않는다. `.config.json`은 버전 관리에서 제외한다.
|
||||
예제 설정의 접속 주소: `http://127.0.0.1:2027`. DB: `mongodb://172.16.0.7:27017`, DB 이름 `sunrise`, 컬렉션 `visitSessions`. 인증이 필요하면 `mongoUri`에 계정과 `authSource` 등 해당 서버 설정을 지정한다. URI를 브라우저용 `VITE_*` 변수에 넣지 않는다. `.config.json`은 버전 관리에서 제외한다.
|
||||
|
||||
Linux에서는 `cp .config.example.json .config.json`으로 복사한다. 설정 파일은 작업 디렉터리와 관계없이 프로젝트 루트에서 읽으며 모든 예제 항목을 유지한다. 파일이 없거나 JSON 또는 항목 형식이 잘못되면 서버 시작을 중단한다. 서버 설정은 `.env`나 환경변수에서 읽지 않는다. 수정 후 서버를 재시작한다. `npm run verify:mongo`도 같은 파일의 `mongoUri`를 사용한다.
|
||||
|
||||
운영 환경에서는 `allowedOrigins`를 실제 접속 주소의 배열(예: `["https://sunrise.example.com"]`)로 바꾸고 `visitStore`는 `"mongo"`로 유지한다. JSON에는 주석을 넣지 않는다.
|
||||
|
||||
개발 시 별도 터미널에서 `npm run dev:server`와 `npm run dev`를 실행한다. Vite의 `/socket.io`, `/health`, `/api` 요청은 3000 포트 서버로 전달된다. `npm run preview`는 프런트 미리보기이며 공동 방 서버를 대신하지 않는다.
|
||||
개발 시 별도 터미널에서 `npm run dev:server`와 `npm run dev`를 실행한다. Vite의 `/socket.io`, `/health`, `/api` 요청은 2027 포트 서버로 전달된다. `npm run preview`는 프런트 미리보기이며 공동 방 서버를 대신하지 않는다.
|
||||
|
||||
`host` 기본값은 `127.0.0.1`이라 현재 컴퓨터에서만 접속할 수 있다. LAN/운영 배포 시 실제 바인딩 주소, `port`, `allowedOrigins`를 지정하고 TLS를 제공하는 프록시와 방화벽을 구성한다. 이번 구현은 외부 호스팅 배포까지 수행하지 않는다. 동일 방/정원을 여러 Node 프로세스에서 따로 실행하지 않는다. 프로세스별 메모리는 공유되지 않으며 현재는 단일 인스턴스 구성이다.
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Character controls and breathing implementation plan
|
||||
|
||||
**Goal:** Keep tap-to-move without a joystick, independently orbit the camera and turn the character, select companions and stand beside them, and add subtle idle breathing.
|
||||
|
||||
**Architecture:** Extend the existing pointer controller, third-person camera, sheep rig and remote-player collection. Keep movement constraints and the multiplayer position/yaw protocol. No dependencies or new input framework.
|
||||
|
||||
**Spec:** User-approved conversation: tap ground to move, tap characters to select, drag to orbit without turning the character, pinch to zoom, explicit direction mode with Done, no automatic camera rotation during walking, companion side placement. Additional request: natural breathing while idle.
|
||||
|
||||
## Constraints and review focus
|
||||
- Feature settings belong in existing domain constants. Preserve PC right-click movement and left-click selection.
|
||||
- Entering direction mode stops walking and cancels prayer; leaving restores orbit behavior.
|
||||
- Pinch/cancel/blur must not become a tap or leave captured input active.
|
||||
- Companion placement uses the nearest accessible side, existing travel checks and final facing; departure cancels selection.
|
||||
- Breathing uses original transforms, keeps legs/root fixed, carries coat/head accessories, blends out for other actions, and invalidates rendering.
|
||||
|
||||
## Tasks
|
||||
- [x] Extend controller tests for direction mode, selection and multi-touch suppression; update camera test to require preserved orbit. Run focused tests to establish failures.
|
||||
- [x] Implement input mode and zoom callbacks in `src/controls/fpsCameraController.ts`, camera distance and decoupled movement in `src/controls/thirdPersonCamera.ts`, settings in `src/constants/movement.ts` and `character.ts`.
|
||||
- [x] Add companion picking/side destination with behavioral tests to `src/multiplayer/remotePlayers.ts`; connect compact controls and accessible state in `src/scene/world.ts`, `index.html`, `src/style.css`.
|
||||
- [x] Test idle rig transforms, coat and clone independence in `tests/character.test.ts`; implement breathing in `src/scene/sheep.ts` and reuse in the lobby while preserving its gaze.
|
||||
- [x] Run `npm test`, `npm run build`; inspect mobile UI and gesture behavior in a browser if available. Review diff and document limitations.
|
||||
|
||||
Implementation proceeds in the current clean workspace under the user's explicit request. No commit, publication or dependency installation is needed.
|
||||
|
||||
## Verification
|
||||
- Focused input, rig, gear and companion tests passed. Tests first demonstrated missing direction mode, pinch/selection and breathing behavior.
|
||||
- Review caught canvas blur no longer stopping travel; a failing behavior test was added and the existing stop-on-menu behavior restored.
|
||||
- Production build passed (existing large-chunk warning remains).
|
||||
- Headless Edge at 390px and 320px verified real touch orbit/body separation, pinch without movement, companion selection/arrival, breathing and layout; no page errors.
|
||||
- Browser validation uses an injected companion in a solo scene; remote-player reconciliation and server behavior are covered separately by the test suite.
|
||||
- Sandbox temp rename and localhost restrictions required running the full suite and browser with local permissions. No application workaround was introduced.
|
||||
- Final full suite: 142 tests, 141 passed, 1 skipped, 0 failed.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Sheep walk implementation plan
|
||||
|
||||
**Goal:** Apply the approved comparison-preview gait with strength 0.4 (the supplied slider position), then commit.
|
||||
**Architecture:** Extend the existing procedural pose in `src/scene/sheep.ts`; keep tuning in `src/constants/character.ts`. Preserve gear grips and independent cloned rigs. No model or movement API changes.
|
||||
**Tech stack:** TypeScript, Three.js, existing Node tests.
|
||||
|
||||
- [x] Update existing character checks for gradual start/stop, torso/head motion, and authored rest restoration.
|
||||
- [x] Add preview gait constants, damp walking weight, restore animated transforms per frame, and carry the coat around the torso pivot.
|
||||
- [x] Run character/gear tests and production build; inspect visual output and review the diff.
|
||||
- [x] Commit only the related production changes, tests, and plan.
|
||||
|
||||
Validation: 142 tests passed, 1 skipped; production build passed. Browser rendering checked with and without rain gear and flashlight; no page errors. Review found the shared original torso position could influence cloned coats; a failing independence assertion reproduced it, and using the authored torso position fixed it.
|
||||
@@ -0,0 +1,118 @@
|
||||
# 매일 일출 촬영 및 Threads 게시
|
||||
|
||||
systemd가 `sunrise` 계정으로 예약 워커를 실행합니다. 워커는 매일 03:00 KST에 준비하고 SunCalc와 실제 페이지의 관측 위치로 일출을 계산합니다. 일출 5분 전에 브라우저를 열고 **일출 5분 뒤** 두 앵글을 촬영합니다. 사진 두 장을 한 캐러셀로 게시하고 원본을 즉시 삭제한 뒤 링크 댓글을 게시합니다. 본문에는 실제 일출 시각을 표시합니다.
|
||||
|
||||
## 설정과 저장 위치
|
||||
|
||||
- `.env.threads.json`: accessToken, siteUrl, stateDir, imageDir, imageBaseUrl 설정. 최초에만 샘플을 복사합니다.
|
||||
- `.threads-captions.json`: 사용자 본문/댓글 문구. 최초에만 샘플을 복사합니다.
|
||||
- `data/threads`: 갱신 토큰, 게시 기록, 댓글 기록, 캡처 메타데이터. 공개하면 안 됩니다.
|
||||
- `dist/threads-captures`: 공개용 임시 JPEG. 게시 확인 후 삭제합니다.
|
||||
- `public/og-sunrise.jpg`: 버튼 없는 메인 화면 일출 이미지. 빌드 때 dist로 복사되는 고정 링크 미리보기입니다.
|
||||
|
||||
빌드 시 임시 촬영 이미지는 삭제되어도 됩니다. 토큰·게시 기록은 data/threads, 설정·문구는 프로젝트 루트에 있으므로 빌드로 초기화되지 않습니다. 촬영·게시 도중에는 빌드하지 마세요.
|
||||
|
||||
## PM2에서 systemd로 전환
|
||||
|
||||
아래는 root에서 실행합니다. 경로 `/opt/sunrise`, 계정 `sunrise` 기준입니다.
|
||||
|
||||
### 1. 중복 실행 중지
|
||||
|
||||
root와 sunrise의 `pm2 list`를 확인하고 sunrise-threads가 등록된 계정에서만 delete/save를 실행하세요. pm2가 없는 계정에서는 생략합니다. 다른 웹 서버 프로세스는 삭제하지 않습니다.
|
||||
|
||||
```bash
|
||||
pm2 list
|
||||
pm2 delete sunrise-threads
|
||||
pm2 save --force
|
||||
sudo -iu sunrise pm2 list
|
||||
sudo -iu sunrise pm2 delete sunrise-threads
|
||||
sudo -iu sunrise pm2 save --force
|
||||
systemctl disable --now sunrise-threads.timer
|
||||
systemctl stop sunrise-threads.service
|
||||
```
|
||||
|
||||
없는 프로세스/유닛 오류는 그 항목에 한해서 무시해도 됩니다. 새 서비스는 상시 워커라 별도 timer를 켜지 않습니다.
|
||||
|
||||
### 2. 코드 반영, 빌드, 권한 수정
|
||||
|
||||
root에서 실행합니다. 캡처 저장 위치는 기존 dist/threads-captures를 유지하며 저장소 이전은 필요 없습니다.
|
||||
|
||||
```bash
|
||||
cd /opt/sunrise
|
||||
git pull --ff-only
|
||||
npm ci
|
||||
# 최초 서비스 등록 전에는 직접 빌드합니다.
|
||||
npm run build
|
||||
install -d -o sunrise -g "$(id -gn sunrise)" -m 755 dist/threads-captures
|
||||
install -d -o sunrise -g "$(id -gn sunrise)" -m 700 data/threads
|
||||
chown -R sunrise:"$(id -gn sunrise)" data/threads dist/threads-captures
|
||||
chmod -R u+rwX,go-rwx data/threads
|
||||
chown sunrise:"$(id -gn sunrise)" .env.threads.json
|
||||
chmod 600 .env.threads.json
|
||||
if [ -f .threads-captions.json ]; then
|
||||
chown sunrise:"$(id -gn sunrise)" .threads-captions.json
|
||||
chmod 600 .threads-captions.json
|
||||
fi
|
||||
```
|
||||
|
||||
기존 Node 정적 서버의 /threads-captures/ 경로를 그대로 사용합니다. Nginx 경로 변경도 필요 없습니다. 앞선 저장소 이전 명령을 이미 서버에서 실행했다면 .env.threads.json의 imageDir를 dist/threads-captures로 되돌리세요. 토큰·문구·게시 기록은 지우지 않습니다.
|
||||
|
||||
### 3. sunrise 계정으로 systemd 실행
|
||||
|
||||
Node 22.18 이상이 필요합니다. 기존 계정의 Node 경로와 HOME을 사용합니다.
|
||||
|
||||
```bash
|
||||
NODE_BIN=$(sudo -iu sunrise sh -lc 'command -v node')
|
||||
SUNRISE_HOME=$(getent passwd sunrise | cut -d: -f6)
|
||||
test -n "$NODE_BIN" && test -n "$SUNRISE_HOME" || exit 1
|
||||
install -m 644 deploy/sunrise-threads.service /etc/systemd/system/sunrise-threads.service
|
||||
mkdir -p /etc/systemd/system/sunrise-threads.service.d
|
||||
printf '[Service]\nExecStart=\nExecStart=%s /opt/sunrise/server/threads/worker.ts\nEnvironment=HOME=%s\n' "$NODE_BIN" "$SUNRISE_HOME" > /etc/systemd/system/sunrise-threads.service.d/runtime.conf
|
||||
systemctl daemon-reload
|
||||
systemctl cat sunrise-threads.service
|
||||
systemd-analyze verify /etc/systemd/system/sunrise-threads.service
|
||||
systemctl enable --now sunrise-threads.service
|
||||
systemctl status sunrise-threads.service --no-pager
|
||||
journalctl -u sunrise-threads.service -n 50 --no-pager
|
||||
```
|
||||
|
||||
기존 drop-in이 있으면 User/ExecStart/쓰기 제한 설정과 충돌하지 않는지 확인하세요. 기존 `PLAYWRIGHT_BROWSERS_PATH`가 있었다면 같은 Environment 설정을 유지하세요. 기존 sunrise 계정의 Chromium을 사용하며, 버전 업데이트로 브라우저가 없을 때만 같은 계정에서 `npx playwright install chromium`을 실행합니다. OS 라이브러리는 root에서 `npx playwright install-deps chromium`으로 설치할 수 있습니다.
|
||||
|
||||
### 4. 테스트와 이후 배포
|
||||
|
||||
```bash
|
||||
# 실제 게시 없이 현재 시각 촬영
|
||||
sudo -iu sunrise sh -lc 'cd /opt/sunrise && npm run threads:preview'
|
||||
# 실제 테스트 게시 + 댓글
|
||||
sudo -iu sunrise sh -lc 'cd /opt/sunrise && npm run threads:post-now'
|
||||
```
|
||||
|
||||
별도 브라우저 경로를 사용하면 테스트 터미널에도 동일한 환경변수를 적용하세요. post-now는 실행할 때마다 새 테스트 게시물을 만듭니다. preview 이미지는 확인용으로 보존합니다.
|
||||
|
||||
이후 배포는 git pull, 필요 시 npm ci, ./build.sh, 웹 서버 재시작 순서입니다. ./build.sh가 빌드와 권한 설정에 성공한 뒤 sunrise-threads.service까지 재시작합니다. 설정/문구는 다시 복사하지 않습니다. root에서 ./build.sh를 실행하면 빌드 후 캡처 폴더 생성·권한 설정·서비스 재시작까지 처리합니다. 서비스가 아직 등록되지 않았다면 최초 등록 절차를 먼저 진행하세요. npm run build를 직접 실행했다면 아래 명령이 필요합니다.
|
||||
|
||||
```bash
|
||||
install -d -o sunrise -g "$(id -gn sunrise)" -m 755 /opt/sunrise/dist/threads-captures
|
||||
```
|
||||
정지는 `systemctl stop sunrise-threads.service`입니다.
|
||||
|
||||
## 문구 편집
|
||||
|
||||
기존 `.threads-captions.json`을 직접 편집하세요. 파일이 없을 때만 `cp -n .threads-captions.sample.json .threads-captions.json`을 사용합니다.
|
||||
|
||||
- template: 본문 줄 배열. 기본은 소개, 날짜·일출, 날씨입니다.
|
||||
- replyTemplate: 링크 댓글 줄 배열. 기존 파일에 이 항목이 없으면 댓글을 보내지 않습니다.
|
||||
- messages: 날씨별 문구. 우선순위는 눈 → 비 → 낮은 시정 → 흐림 → 구름 조금 → 맑음입니다. 데이터가 없으면 unknown입니다.
|
||||
- thresholds: 낮은 시정(m), 흐림과 구름 조금의 운량(%) 기준.
|
||||
- testPrefix: 즉시 테스트 본문에 붙이는 표시.
|
||||
- 치환값: {message}, {date}, {dateDots}, {time}, {sunrise}, {url}, {hostname}.
|
||||
|
||||
`npm run threads:captions`로 미리 확인합니다. 문구만 바꾸면 빌드/재시작이 필요 없습니다. 본문과 댓글 각각 500자 제한입니다. 댓글 link_attachment는 siteUrl의 origin을 사용하며 사이트 Open Graph 정보를 요청합니다. 최종 미리보기 표시와 캐시는 Threads가 결정합니다.
|
||||
|
||||
## 게시 기록과 재시도
|
||||
|
||||
토큰은 Meta 사용자 토큰 생성기의 장기 액세스 토큰을 accessToken에 넣습니다. 갱신 토큰은 `data/threads/token.json`에 저장되며 설정 토큰보다 우선합니다. 토큰 교체 시 워커를 멈추고 기존 token.json을 안전하게 백업한 뒤 제거하고 설정 토큰을 바꾸세요. 토큰 철회/만료는 재인증이 필요합니다.
|
||||
|
||||
작업 실패 시 최대 5회, 60초 간격으로 재시도합니다. 저장된 사진은 같은 날짜 재실행에 재사용하며 일출+5분 시각을 놓치고 저장 사진도 없으면 건너뜁니다. 본문 게시 성공 직후 원본을 삭제하고 댓글을 게시합니다. 삭제 실패는 다음 실행에 재시도합니다.
|
||||
|
||||
본문 기록은 날짜.json, 캡처 기록은 날짜.capture.json, 댓글은 날짜.json.reply.json입니다. 즉시 테스트는 test-날짜-UUID 형식입니다. published 기록과 게시 ID는 중복 방지용이므로 지우지 마세요. publishing 상태는 응답 유실 가능성이 있어 자동 재게시하지 않습니다. 실제 계정과 로그를 확인한 뒤 복구하세요. 과거 댓글 문구가 없는 기록에는 소급 댓글을 달지 않습니다.
|
||||
+28
-3
@@ -6,6 +6,14 @@
|
||||
<meta name="theme-color" content="#101823" />
|
||||
<meta name="description" content="함께 떠나는 온라인 해돋이, 해보러가기" />
|
||||
<title>해보러가기</title>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://sunrise.horoli.kr/" />
|
||||
<meta property="og:title" content="해보러가기 · 관악산 일출" />
|
||||
<meta property="og:description" content="함께 떠나는 온라인 해돋이. 관악산의 일출을 3D 풍경으로 만나보세요." />
|
||||
<meta property="og:image" content="https://sunrise.horoli.kr/og-sunrise.jpg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="능선 위로 해가 떠오르기 시작하는 해보러가기 메인 화면" />
|
||||
<link rel="preload" href="/fonts/GowunDodum-Title.ttf" as="font" type="font/ttf" crossorigin />
|
||||
</head>
|
||||
<body class="in-lobby">
|
||||
@@ -31,6 +39,7 @@
|
||||
<symbol id="icon-umbrella" viewBox="0 0 24 24"><path d="M2 12a10 10 0 0 1 20 0H2Zm10 0v7a2 2 0 0 0 4 0M12 2V1"/></symbol>
|
||||
<symbol id="icon-raincoat" viewBox="0 0 24 24"><path d="m8 3 4 3 4-3 5 5-3 4-2-2v11H8V10l-2 2-3-4 5-5ZM12 6v15M9 16h1M14 16h1"/></symbol>
|
||||
<symbol id="icon-hood" viewBox="0 0 24 24"><path d="M4 15V10a8 8 0 0 1 16 0v5l-3 6H7l-3-6Z"/><path d="M8 15V10a4 4 0 0 1 8 0v5l-4 3-4-3ZM10 18v4M14 18v4"/></symbol>
|
||||
<symbol id="icon-flashlight" viewBox="0 0 24 24"><path d="M8 10h8l-1 11H9L8 10ZM6 5h12v5H6V5ZM9 14h6M8 1l-1 2M12 0v3M16 1l1 2"/></symbol>
|
||||
</svg>
|
||||
<canvas id="world" aria-label="태양 관측 3D 풍경"></canvas>
|
||||
<main class="hud" aria-label="관측 조작">
|
||||
@@ -50,6 +59,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-end">
|
||||
<button type="button" id="screenshot-open" data-panel="screenshot" aria-label="촬영" title="화면 촬영" aria-haspopup="dialog" aria-controls="ui-dialog" disabled><svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h4l2-3h6l2 3h4v15H3Z"/><circle cx="12" cy="13" r="4"/></svg></button>
|
||||
<button type="button" data-panel="menu" aria-label="메뉴" aria-haspopup="dialog" aria-controls="ui-dialog"><svg class="icon" aria-hidden="true"><use href="#icon-menu"/></svg></button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -64,6 +74,7 @@
|
||||
</section>
|
||||
</aside>
|
||||
<div id="solo-time-controls" aria-label="관측 시간">
|
||||
<button id="time-toggle" type="button" aria-expanded="false" aria-controls="time-settings" aria-label="관측 시간 설정"><svg class="icon" aria-hidden="true"><use href="#icon-clock"/></svg><span id="compact-time" aria-hidden="true">00:00</span><span class="time-toggle-label">설정</span></button>
|
||||
<div id="time-settings">
|
||||
<div class="date-row"><span id="mode" class="mode">LIVE</span><time id="selected-time"></time><span class="unit">KST</span></div>
|
||||
<fieldset id="time-controls" class="time-panel" aria-label="관측 시각 조절" disabled>
|
||||
@@ -76,13 +87,19 @@
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="stand-beside" hidden>나란히 서기</button>
|
||||
<div class="movement-controls" aria-label="위치와 방향 조절">
|
||||
<p id="direction-hint" role="status" hidden>좌우로 드래그하면 내 캐릭터만 돌아갑니다</p>
|
||||
<button type="button" id="direction-toggle" aria-pressed="false" aria-describedby="direction-hint" disabled>방향 맞추기</button>
|
||||
</div>
|
||||
<div class="emote-controls" aria-label="캐릭터 액션">
|
||||
<button type="button" id="emote-wave" disabled aria-pressed="false" aria-describedby="emote-status" title="손 흔들기 · 단축키 1"><svg class="icon" aria-hidden="true"><use href="#icon-wave"/></svg>인사</button>
|
||||
<button type="button" id="emote-wave" disabled aria-pressed="false" aria-describedby="emote-status" title="손 흔들기 · 단축키 1"><svg class="icon" aria-hidden="true"><use href="#icon-wave"/></svg><span id="primary-action-label">인사</span></button>
|
||||
<button type="button" id="emote-pray" disabled aria-pressed="false" aria-describedby="emote-status" title="소원빌기 · 일출 전후 5분 · 단축키 2"><svg class="icon" aria-hidden="true"><use href="#icon-sunrise"/></svg>소원빌기</button>
|
||||
<div class="equipment-menu">
|
||||
<button type="button" id="equipment-toggle" aria-expanded="false" aria-controls="equipment-options"><svg class="icon" aria-hidden="true"><use href="#icon-umbrella"/></svg>장비</button>
|
||||
<div id="equipment-options" hidden>
|
||||
<div class="equipment-controls" role="group" aria-label="우천 장비">
|
||||
<div class="equipment-controls" role="group" aria-label="장비">
|
||||
<button type="button" id="gear-flashlight" disabled aria-pressed="false" aria-label="손전등 켜기" title="손전등 · 단축키 6"><svg class="icon" aria-hidden="true"><use href="#icon-flashlight"/></svg><span id="flashlight-lumens"></span></button>
|
||||
<button type="button" id="gear-umbrella" disabled aria-pressed="false" aria-label="우산 쓰기" title="우산 쓰기 · 단축키 3" aria-describedby="gear-status"><svg class="icon" aria-hidden="true"><use href="#icon-umbrella"/></svg></button>
|
||||
<button type="button" id="gear-raincoat" disabled aria-pressed="false" aria-label="우비 입기" title="우비 입기 · 단축키 4" aria-describedby="gear-status"><svg class="icon" aria-hidden="true"><use href="#icon-raincoat"/></svg></button>
|
||||
<button type="button" id="gear-hood" disabled aria-pressed="false" aria-label="모자 쓰기" title="모자 쓰기 · 단축키 5" aria-describedby="gear-status"><svg class="icon" aria-hidden="true"><use href="#icon-hood"/></svg></button>
|
||||
@@ -98,6 +115,13 @@
|
||||
<dialog id="ui-dialog" aria-labelledby="dialog-title">
|
||||
<header class="dialog-header"><button type="button" id="dialog-back" aria-label="뒤로" hidden>←</button><h1 id="dialog-title">메뉴</h1><button type="button" data-close aria-label="닫기" autofocus>×</button></header>
|
||||
<div class="dialog-body">
|
||||
<section data-dialog-panel="screenshot" hidden>
|
||||
<p class="muted">현재 보고 있는 장면을 PNG 이미지로 저장합니다.</p>
|
||||
<div class="menu-list">
|
||||
<button type="button" data-screenshot="ui">UI 포함 · 화면 그대로</button>
|
||||
<button type="button" data-screenshot="scene">UI 제외 · 풍경만</button>
|
||||
</div>
|
||||
</section>
|
||||
<section data-dialog-panel="menu" hidden>
|
||||
<p id="menu-current-mode" class="muted menu-current-mode">현재 함께 보기 중</p>
|
||||
<nav class="menu-list menu-primary" aria-label="주요 메뉴">
|
||||
@@ -134,7 +158,7 @@
|
||||
</section>
|
||||
<section data-dialog-panel="settings" hidden>
|
||||
<div class="height-field"><div class="slider-label"><label for="eye-height">관측 기준 높이</label><output id="eye-height-output" for="eye-height"></output></div><input id="eye-height" type="range" aria-describedby="eye-height-note" /><p id="eye-height-note" class="muted">태양 계산 기준 · 카메라는 3인칭</p></div>
|
||||
<details open><summary>조작 방법</summary><p id="control-hint">터치 또는 우클릭한 곳으로 이동 · 드래그로 둘러보기</p><p class="muted">1 인사 · 2 소원빌기 · 3 우산 · 4 우비 · 5 모자</p><p class="muted">소원빌기는 선택 시각이 일출 전후 5분일 때 사용할 수 있습니다. 이동하면 감정표현이 종료됩니다.</p></details>
|
||||
<details open><summary>조작 방법</summary><p id="control-hint">바닥 탭 또는 우클릭으로 이동 · 캐릭터 탭 또는 클릭으로 선택 · 드래그로 시점만 회전 · 두 손가락 핀치 또는 마우스 휠로 확대·축소 · 방향 맞추기를 누른 뒤 좌우 드래그로 내 캐릭터 회전, 완료로 종료</p><p class="muted">1 인사 · 2 소원빌기 · 3 우산 · 4 우비 · 5 모자</p><p class="muted">소원빌기는 선택 시각이 일출 전후 5분일 때 사용할 수 있습니다. 이동하면 감정표현이 종료됩니다.</p></details>
|
||||
<a id="terrain-comparison" class="terrain-source" href="?terrain=original">원본 지형 비교</a>
|
||||
</section>
|
||||
<section data-dialog-panel="sources" hidden>
|
||||
@@ -146,6 +170,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
<p id="screenshot-status" role="status" aria-live="polite" hidden></p>
|
||||
<p id="render-error" role="alert" hidden></p>
|
||||
<script type="module" src="/src/entry.ts"></script>
|
||||
</body>
|
||||
|
||||
Generated
+35
@@ -8,7 +8,9 @@
|
||||
"name": "gwanaksan-sunrise",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"html-to-image": "^1.11.13",
|
||||
"mongodb": "^7.7.0",
|
||||
"playwright": "^1.64.1",
|
||||
"socket.io": "^4.8.4",
|
||||
"socket.io-client": "^4.8.4",
|
||||
"suncalc": "2.0.1",
|
||||
@@ -1179,6 +1181,12 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-image": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/memory-pager": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
|
||||
@@ -1335,6 +1343,33 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.32",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
"node": ">=22.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"threads:run": "node server/threads/run.ts",
|
||||
"threads:preview": "node server/threads/run.ts --dry-run --now",
|
||||
"threads:post-now": "node server/threads/run.ts --post-now",
|
||||
"threads:captions": "node server/threads/previewCaptions.ts",
|
||||
"threads:worker": "node server/threads/worker.ts",
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"prepare:buildings": "node scripts/prepare-building-assets.ts",
|
||||
"check:buildings": "node scripts/prepare-building-assets.ts --check",
|
||||
@@ -25,7 +30,9 @@
|
||||
"verify:solar": "node tests/solar-report.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"html-to-image": "^1.11.13",
|
||||
"mongodb": "^7.7.0",
|
||||
"playwright": "^1.64.1",
|
||||
"socket.io": "^4.8.4",
|
||||
"socket.io-client": "^4.8.4",
|
||||
"suncalc": "2.0.1",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -255,3 +255,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
========================================================================
|
||||
|
||||
html-to-image 1.11.13
|
||||
License: MIT
|
||||
Source: https://github.com/bubkoo/html-to-image
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017-2025 W.Y.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -10,7 +10,7 @@
|
||||
{
|
||||
"id": "way/337498789",
|
||||
"label": "정상 암반길",
|
||||
"width": 3.2,
|
||||
"width": 5.2,
|
||||
"points": [
|
||||
[
|
||||
126.9642427,
|
||||
@@ -57,7 +57,7 @@
|
||||
{
|
||||
"id": "way/37751526",
|
||||
"label": "남쪽 하산 계단길",
|
||||
"width": 2.4,
|
||||
"width": 4.4,
|
||||
"points": [
|
||||
[
|
||||
126.9652191,
|
||||
@@ -148,7 +148,7 @@
|
||||
{
|
||||
"id": "way/337498787",
|
||||
"label": "동쪽 계단길",
|
||||
"width": 2.2,
|
||||
"width": 4.2,
|
||||
"points": [
|
||||
[
|
||||
126.9646956,
|
||||
@@ -167,7 +167,7 @@
|
||||
{
|
||||
"id": "way/337498788",
|
||||
"label": "북쪽 능선길",
|
||||
"width": 2.4,
|
||||
"width": 4.4,
|
||||
"points": [
|
||||
[
|
||||
126.9652188,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 3.1 MiB |
Binary file not shown.
Binary file not shown.
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sourceHash": "8f7306b64c88b4d706c8bd8ecce0385fe1f5f64502123735b13d7affc2cb8ba7",
|
||||
"bufferHash": "59f836d4ff44c1f047c4165c99672fc9e7a04f9981f15561f4277754577d6e5a",
|
||||
"byteLength": 15601904,
|
||||
"gzipBytes": 6138340,
|
||||
"originalVertices": 1180560,
|
||||
"vertices": 209224,
|
||||
"triangles": 393520,
|
||||
"originalAttributeBytes": 61389120,
|
||||
"generateMs": 3773.8809,
|
||||
"decodeMs": 13.8812999999991
|
||||
"sourceHash": "407cca469fa95b26fe4f8da3cbc515620903ebb723c1aea08e59c4e2ca591acc",
|
||||
"bufferHash": "8fa1ab6646cacc15fc1e84ace034a71826a8dc50f9d5dc2c15aab39e290166d6",
|
||||
"byteLength": 16099324,
|
||||
"gzipBytes": 6369382,
|
||||
"originalVertices": 1219830,
|
||||
"vertices": 215769,
|
||||
"triangles": 406610,
|
||||
"originalAttributeBytes": 63431160,
|
||||
"generateMs": 4067.9287999999997,
|
||||
"decodeMs": 17.9390999999996
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"resolution": 2048,
|
||||
"grainScale": 145,
|
||||
"crystalScale": 75,
|
||||
"fineScale": 600,
|
||||
"roughness": 0.91,
|
||||
"relief": 0.0018,
|
||||
"faceTint": 1.0,
|
||||
"rimTint": 0.76,
|
||||
"terrainBlend": 0.82,
|
||||
"terrainSaturation": 0.65,
|
||||
"terrainBrightness": 1.15,
|
||||
"groundPaletteBlend": 0.55,
|
||||
"grainColors": [
|
||||
[0.26, [0.045, 0.049, 0.052, 1]],
|
||||
[0.39, [0.18, 0.19, 0.19, 1]],
|
||||
[0.46, [0.49, 0.50, 0.49, 1]],
|
||||
[0.56, [0.72, 0.73, 0.71, 1]],
|
||||
[0.72, [0.88, 0.89, 0.86, 1]]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"description": "Hand-traced polygon silhouettes from the user supplied 191x377 reference. Coordinates follow the photograph; depth is an artistic estimate. Rings after the first are holes.",
|
||||
"layout": {"scale": 0.65, "anchor": [0.18, 1.1], "glyphVerticalOffsets": [0, -0.12, -0.24], "captionVerticalOffset": -0.24},
|
||||
"caption": {
|
||||
"text": "해발 629 m",
|
||||
"font": "C:/Windows/Fonts/HANBatangB.ttf",
|
||||
"left": -0.125,
|
||||
"baseline": 0.20,
|
||||
"runs": [
|
||||
{"text":"해발", "width":0.20, "height":0.080, "gap":0.04},
|
||||
{"text":"629", "width":0.24, "height":0.105, "gap":0.05},
|
||||
{"text":"m", "width":0.10, "height":0.075, "gap":0}
|
||||
]
|
||||
},
|
||||
"glyphs": [
|
||||
{"character":"冠", "bounds":[60,20,142,99], "placement":[0.18,1.78,0.53,0.45], "shapes":[
|
||||
[[[67,36],[75,31],[88,29],[104,25],[117,24],[126,20],[132,25],[140,29],[142,34],[134,37],[125,34],[117,31],[102,33],[89,37],[82,44],[76,49],[68,50],[65,45]]],
|
||||
[[[86,42],[94,38],[102,38],[105,43],[100,49],[97,54],[104,56],[105,61],[98,65],[100,71],[95,76],[98,82],[106,86],[118,87],[127,82],[129,72],[129,60],[133,66],[135,77],[138,84],[136,90],[124,94],[108,97],[96,93],[87,87],[81,94],[72,94],[66,91],[63,86],[69,81],[73,75],[70,71],[73,66],[80,64],[85,60],[82,55],[79,54],[81,48]], [[86,71],[82,76],[82,81],[87,77],[90,72]]],
|
||||
[[[112,35],[118,34],[125,40],[124,44],[119,48],[120,53],[127,51],[129,56],[125,60],[120,61],[119,74],[116,82],[110,84],[105,78],[101,75],[103,71],[110,74],[112,68],[111,62],[105,65],[101,61],[104,56],[112,54],[112,46],[108,44]]]
|
||||
]},
|
||||
{"character":"岳", "bounds":[60,129,142,214], "placement":[0.15,1.29,0.55,0.46], "shapes":[
|
||||
[[[77,153],[86,145],[99,138],[109,131],[117,130],[122,135],[119,141],[110,145],[98,147],[91,152],[90,158],[97,157],[108,151],[116,149],[124,153],[125,159],[119,162],[116,171],[128,170],[134,173],[139,174],[142,181],[136,183],[128,180],[117,181],[105,184],[91,186],[78,190],[68,189],[62,186],[60,181],[67,180],[76,179],[80,175],[79,169],[75,164]], [[92,164],[98,160],[105,161],[103,171],[97,175],[91,175]]],
|
||||
[[[83,191],[88,188],[93,188],[95,194],[92,201],[100,199],[102,190],[108,185],[115,187],[116,192],[111,199],[119,197],[121,190],[127,187],[132,190],[130,197],[126,203],[122,208],[115,211],[107,209],[103,214],[97,211],[95,207],[86,211],[80,212],[77,207],[76,201],[73,198],[75,194]]]
|
||||
]},
|
||||
{"character":"山", "bounds":[57,250,145,313], "placement":[0.20,0.83,0.57,0.38], "shapes":[
|
||||
[[[58,305],[64,298],[68,287],[72,277],[78,277],[81,282],[80,289],[78,296],[87,294],[91,282],[95,270],[99,257],[105,255],[110,251],[115,255],[118,262],[113,268],[109,272],[108,281],[105,289],[107,293],[114,292],[115,281],[119,275],[125,277],[129,281],[129,287],[125,293],[131,294],[135,291],[140,294],[143,302],[145,307],[138,307],[127,306],[117,307],[106,308],[94,308],[83,311],[71,313],[62,311],[57,309]]]
|
||||
]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"source": "User-supplied Naver place 11491310 coordinate and annotated north-up satellite screenshot, 2026-10-31",
|
||||
"pin": { "latitude": 37.445208, "longitude": 126.96413 },
|
||||
"pinTipPixels": [206, 175],
|
||||
"markerCenterPixels": [231, 230],
|
||||
"metersPerPixel": 0.2,
|
||||
"scaleNote": "Approximately 50px per 10m in the same 18.30 zoom satellite view; screenshot registration is approximate.",
|
||||
"estimatedUncertaintyMeters": 3,
|
||||
"offsetFromPinMeters": [5, 11],
|
||||
"offsetFromStartMeters": [-2.9642, -43.1612]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"scale": [1.30, 1.30, 1.20],
|
||||
"leftBowlDepth": 0.42,
|
||||
"cleftDepth": 0.19,
|
||||
"leftShoulderLift": 0.20,
|
||||
"rightFaceOffset": 0.10,
|
||||
"bedrockRadius": 2.15,
|
||||
"bedrockBlend": 0.8,
|
||||
"bedrockOverlap": 0.12
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const SUNRISE_WISH = {
|
||||
width: 1080, height: 1920, fps: 30, seconds: 10,
|
||||
from: Date.parse('2026-11-06T06:55:00+09:00'),
|
||||
to: Date.parse('2026-11-06T07:28:00+09:00'),
|
||||
heading: 109, pitch: 5, fieldOfView: 30,
|
||||
} as const;
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Blender-authored pocket torch; local -Z exports as the grip's -Y axis."""
|
||||
import bpy, math
|
||||
from pathlib import Path
|
||||
ROOT = Path('D:/project/sunrise')
|
||||
previous = bpy.context.window.scene
|
||||
scene = bpy.data.scenes.new('Sheep pocket flashlight')
|
||||
bpy.context.window.scene = scene
|
||||
root = bpy.data.objects.new('Flashlight', None)
|
||||
scene.collection.objects.link(root)
|
||||
def material(name, color, metal=0):
|
||||
m=bpy.data.materials.new(name);m.use_nodes=True
|
||||
s=next(n for n in m.node_tree.nodes if n.type=='BSDF_PRINCIPLED')
|
||||
s.inputs['Base Color'].default_value=(*color,1)
|
||||
s.inputs['Metallic'].default_value=metal;s.inputs['Roughness'].default_value=.45
|
||||
return m
|
||||
body=material('Torch charcoal',(.065,.085,.10))
|
||||
trim=material('Torch warm brass',(.55,.35,.12),.5)
|
||||
lens=material('Torch lens',(.72,.83,.88))
|
||||
def cylinder(name, radius, depth, z, mat):
|
||||
bpy.ops.mesh.primitive_cylinder_add(vertices=24,radius=radius,depth=depth,location=(0,0,z))
|
||||
o=bpy.context.object;o.name=name;o.parent=root;o.data.materials.append(mat)
|
||||
bevel=o.modifiers.new('Soft edges','BEVEL');bevel.width=.007;bevel.segments=2
|
||||
return o
|
||||
cylinder('Torch grip',.065,.22,-.035,body)
|
||||
for z in [-.10,-.06,-.02,.02]: cylinder('Grip ring',.068,.012,z,trim)
|
||||
cylinder('Torch cap',.07,.035,.09,body)
|
||||
cylinder('Torch bezel',.10,.09,-.19,body)
|
||||
cylinder('Lens rim',.105,.018,-.24,trim)
|
||||
cylinder('FlashlightLens',.088,.009,-.253,lens)
|
||||
for o in scene.objects:o.select_set(True)
|
||||
bpy.context.view_layer.objects.active=root
|
||||
import io_scene_gltf2
|
||||
fmt=next(i[0] for i in io_scene_gltf2.get_format_items(None,bpy.context) if i[0]=='GLB')
|
||||
bpy.ops.export_scene.gltf(filepath=str(ROOT/'public/characters/flashlight.glb'),export_format=fmt,use_selection=True,use_active_scene=True,export_yup=True,export_apply=True)
|
||||
(ROOT/'assets/characters').mkdir(parents=True,exist_ok=True)
|
||||
bpy.data.libraries.write(str(ROOT/'assets/characters/flashlight.blend'),{scene})
|
||||
bpy.context.window.scene=previous
|
||||
print('Exported flashlight.glb; charcoal body, brass grip rings, inset lens')
|
||||
+18
-11
@@ -1,5 +1,5 @@
|
||||
"""Run with Blender MCP. Separate cartoon rain gear for the rounded v10 sheep."""
|
||||
import bpy, bmesh, math, json, struct
|
||||
import bpy, bmesh, math, json, struct, sys
|
||||
from mathutils import Vector, Matrix
|
||||
ROOT='D:/project/sunrise'
|
||||
with bpy.data.libraries.load(ROOT+'/assets/characters/ivory-lamb-v10.blend',link=False) as (src,dst):
|
||||
@@ -54,27 +54,34 @@ def sphere(name,location,scale,mat,parent):
|
||||
|
||||
umbrella=empty('Umbrella')
|
||||
# The grip is in the raised right hoof; the tilted shaft centers the canopy above the head.
|
||||
grip=Vector((.58,-.40,.82)); top=Vector((0,-.05,3.03))
|
||||
grip=Vector((.58,-.40,.82)); top=Vector((0,-.05,3.18))
|
||||
stroke('Umbrella shaft',[grip,top],.022,ink,umbrella)
|
||||
def canopy_point(radius,sector,across):
|
||||
# Taut fabric curves inward between the eight projecting rib tips.
|
||||
scallop=math.sin(math.pi*across)
|
||||
r=1.20*radius*(1-.10*scallop*radius**3)
|
||||
a=(sector+across)*math.pi/4
|
||||
return (r*math.cos(a),-.05+r*math.sin(a),3.18-.65*radius**1.4+.10*scallop*radius**3)
|
||||
for sector in range(8):
|
||||
vertices=[];faces=[]
|
||||
for row in range(9):
|
||||
theta=(math.pi/2)*row/8
|
||||
for col in range(5):
|
||||
a=(sector+col/4)*math.pi/4
|
||||
vertices.append((1.28*math.sin(theta)*math.cos(a),-.05+1.28*math.sin(theta)*math.sin(a),2.53+.50*math.cos(theta)))
|
||||
vertices.append(canopy_point(row/8,sector,col/4))
|
||||
for row in range(8):
|
||||
for col in range(4):
|
||||
i=row*5+col;faces.append((i,i+1,i+6,i+5))
|
||||
panel=mesh('Canopy panel',vertices,faces,red if sector%2==0 else cream,umbrella)
|
||||
panel=mesh('Canopy panel',vertices,faces,red,umbrella)
|
||||
bpy.context.view_layer.objects.active=panel
|
||||
solid=panel.modifiers.new('Canopy thickness','SOLIDIFY');solid.thickness=.018
|
||||
bpy.ops.object.modifier_apply(modifier=solid.name)
|
||||
a=sector*math.pi/4
|
||||
stroke('Canopy rib',[(1.28*math.sin(t)*math.cos(a),-.05+1.28*math.sin(t)*math.sin(a),2.54+.50*math.cos(t)) for t in [i*math.pi/32 for i in range(17)]],.009,ink,umbrella)
|
||||
stroke('Canopy rim',[(1.28*math.cos(a),-.05+1.28*math.sin(a),2.53) for a in [i*math.tau/96 for i in range(97)]],.016,ink,umbrella)
|
||||
sphere('Umbrella tip',(0,-.05,3.055),(.04,.04,.07),ink,umbrella)
|
||||
stroke('Canopy rib',[canopy_point(i/16,sector,0) for i in range(17)],.009,ink,umbrella)
|
||||
stroke('Canopy rim',[canopy_point(1,sector,i/12) for i in range(13)],.012,ink,umbrella)
|
||||
sphere('Rib tip',canopy_point(1,sector,0),(.025,.025,.025),ink,umbrella)
|
||||
stroke('Umbrella tip',[(0,-.05,3.17),(0,-.05,3.33)],.022,ink,umbrella)
|
||||
sphere('Grip',grip,(.052,.052,.12),red,umbrella)
|
||||
stroke('Hook handle',[grip,(grip.x,grip.y,grip.z-.16)]+[
|
||||
(grip.x-.10+.10*math.cos(a),grip.y,grip.z-.16-.10*math.sin(a))
|
||||
for a in [i*math.pi/16 for i in range(17)]],.033,red,umbrella)
|
||||
|
||||
coat=empty('Raincoat')
|
||||
for joint_name in ('Body','ArmLeft','ArmRight'):
|
||||
@@ -174,6 +181,6 @@ def export(root,path):
|
||||
f.write(struct.pack('<III',0x46546C67,2,20+len(encoded)+len(binary)))
|
||||
f.write(struct.pack('<II',len(encoded),0x4E4F534A));f.write(encoded);f.write(binary)
|
||||
export(umbrella,ROOT+'/public/characters/umbrella.glb')
|
||||
export(coat,ROOT+'/public/characters/raincoat.glb')
|
||||
if '--umbrella-only' not in sys.argv:export(coat,ROOT+'/public/characters/raincoat.glb')
|
||||
bpy.data.libraries.write(ROOT+'/assets/characters/rain-gear.blend',{scene})
|
||||
print('Umbrella and articulated raincoat exported')
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Bake photo-guided vector outlines into the existing marker; run inside Blender.
|
||||
|
||||
Edits authored vector shapes, not the reference photograph. Existing stone and UVs
|
||||
are reused. Color and tangent normal are baked for the same glTF material.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path('D:/project/sunrise')
|
||||
scene = bpy.data.scenes['Summit slab and marker']
|
||||
bpy.context.window.scene = scene
|
||||
body = bpy.data.objects['Photo matched summit boulder']
|
||||
mat = body.data.materials[0]
|
||||
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
||||
bs = next(n for n in nodes if n.type == 'BSDF_PRINCIPLED')
|
||||
atlas = next(n for n in nodes if n.type == 'UVMAP' and n.uv_map == 'Painted monument atlas')
|
||||
spec = json.loads((ROOT / 'scripts/constants/marker-inscription.json').read_text(encoding='utf-8'))
|
||||
N = 2048
|
||||
mask = np.zeros((N, N), dtype=np.float32)
|
||||
|
||||
def atlas_point(x, z, vertical_offset):
|
||||
ax, az = spec['layout']['anchor']
|
||||
scale = spec['layout']['scale']
|
||||
x = ax+(x-ax)*scale
|
||||
z = az+(z-az)*scale+vertical_offset
|
||||
return ((.02+.46*(x+1.3)/2.6)*N, (.02+.96*(z+.2)/2.3)*N)
|
||||
|
||||
def polygon(points, value):
|
||||
p = np.array(points)
|
||||
lo = np.maximum(0, np.floor(p.min(axis=0)).astype(int))
|
||||
hi = np.minimum(N-1, np.ceil(p.max(axis=0)).astype(int))
|
||||
X, Y = np.meshgrid(np.arange(lo[0],hi[0]+1)+.5, np.arange(lo[1],hi[1]+1)+.5)
|
||||
inside = np.zeros(X.shape, dtype=bool)
|
||||
for a, b in zip(p, np.roll(p, -1, axis=0)):
|
||||
if abs(b[1]-a[1]) < 1e-9:
|
||||
continue
|
||||
inside ^= ((a[1]>Y) != (b[1]>Y)) & (X < (b[0]-a[0])*(Y-a[1])/(b[1]-a[1])+a[0])
|
||||
patch = mask[lo[1]:hi[1]+1,lo[0]:hi[0]+1]
|
||||
patch[inside] = value
|
||||
|
||||
for glyph_index, glyph in enumerate(spec['glyphs']):
|
||||
x0,y0,x1,y1 = glyph['bounds']
|
||||
cx,top,w,h = glyph['placement']
|
||||
for shape in glyph['shapes']:
|
||||
for i, ring in enumerate(shape):
|
||||
points = []
|
||||
for x,y in ring:
|
||||
px = cx+((x-x0)/(x1-x0)-.5)*w
|
||||
pz = top-(y-y0)/(y1-y0)*h
|
||||
points.append(atlas_point(px,pz,spec['layout']['glyphVerticalOffsets'][glyph_index]))
|
||||
polygon(points, 1 if i == 0 else 0)
|
||||
|
||||
# Rasterize small serif lettering into the same atlas; no font or text mesh is
|
||||
# exported. Separate runs match the photo's smaller Korean label and unit.
|
||||
caption = spec['caption']
|
||||
font = bpy.data.fonts.load(caption['font'], check_existing=True)
|
||||
left = caption['left']
|
||||
for run in caption['runs']:
|
||||
curve = bpy.data.curves.new('Temporary elevation lettering', type='FONT')
|
||||
curve.body = run['text']
|
||||
curve.font = font
|
||||
curve.resolution_u = 16
|
||||
obj = bpy.data.objects.new('Temporary elevation lettering', curve)
|
||||
scene.collection.objects.link(obj)
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = evaluated.to_mesh()
|
||||
try:
|
||||
mesh.calc_loop_triangles()
|
||||
coordinates = np.array([(v.co.x,v.co.y) for v in mesh.vertices])
|
||||
low, high = coordinates.min(axis=0), coordinates.max(axis=0)
|
||||
for tri in mesh.loop_triangles:
|
||||
points = []
|
||||
for vertex in tri.vertices:
|
||||
x,y = (coordinates[vertex]-low)/(high-low)
|
||||
px = left+x*run['width']
|
||||
pz = caption['baseline']+y*run['height']
|
||||
points.append(atlas_point(px,pz,spec['layout']['captionVerticalOffset']))
|
||||
polygon(points,1)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
bpy.data.objects.remove(obj,do_unlink=True)
|
||||
bpy.data.curves.remove(curve)
|
||||
left += run['width']+run['gap']
|
||||
if font.users == 0:
|
||||
bpy.data.fonts.remove(font)
|
||||
|
||||
# A sub-texel soft edge preserves the chisel silhouette without stair steps.
|
||||
soft = (mask*4 + np.roll(mask,1,0)+np.roll(mask,-1,0)+np.roll(mask,1,1)+np.roll(mask,-1,1))/8
|
||||
rng = np.random.default_rng(194)
|
||||
wear = rng.uniform(.94,1,mask.shape).astype(np.float32)
|
||||
wear[rng.random(mask.shape)<.007] = .55
|
||||
|
||||
def write_image(name, values):
|
||||
image = bpy.data.images.get(name) or bpy.data.images.new(name,width=N,height=N,alpha=False)
|
||||
if tuple(image.size) != (N,N):
|
||||
image.scale(N,N)
|
||||
rgba = np.ones((N,N,4),dtype=np.float32)
|
||||
rgba[:,:,:3] = values[:,:,None]
|
||||
image.pixels.foreach_set(rgba.ravel())
|
||||
image.update()
|
||||
image.pack()
|
||||
return image
|
||||
|
||||
write_image('Hanja pigment mask', soft*wear)
|
||||
paint = next(n for n in nodes if n.type == 'MIX_RGB' and n.inputs[0].is_linked)
|
||||
paint.inputs[2].default_value = (.008,.012,.014,1)
|
||||
target = next(n for n in nodes if n.type == 'TEX_IMAGE' and n.image and n.image.name == 'Painted Hanja on granite')
|
||||
links.new(paint.outputs[0], bs.inputs['Base Color'])
|
||||
nodes.active = target
|
||||
for obj in scene.objects:
|
||||
obj.select_set(False)
|
||||
body.select_set(True)
|
||||
bpy.context.view_layer.objects.active = body
|
||||
scene.render.bake.use_pass_direct = False
|
||||
scene.render.bake.use_pass_indirect = False
|
||||
scene.render.bake.use_pass_color = True
|
||||
scene.render.bake.margin = 8
|
||||
bake_types = bpy.ops.object.bake.get_rna_type().properties['type'].enum_items
|
||||
bpy.ops.object.bake(type=next(i.identifier for i in bake_types if i.identifier == 'DIFFUSE'))
|
||||
target.image.save()
|
||||
target.image.pack()
|
||||
links.new(target.outputs['Color'],bs.inputs['Base Color'])
|
||||
|
||||
# Bake the shallow engraving plus the original granite normal into the atlas.
|
||||
# Reuse nodes on rerun; always start from the original granite normal.
|
||||
def node(kind, label):
|
||||
found = next((n for n in nodes if n.label == label),None)
|
||||
if found is None:
|
||||
found = nodes.new(kind)
|
||||
found.label = label
|
||||
return found
|
||||
|
||||
depth = node('ShaderNodeTexImage','Inscription depth source')
|
||||
depth.image = write_image('Inscription recessed height',1-soft)
|
||||
depth.image.colorspace_settings.name = 'Non-Color'
|
||||
links.new(atlas.outputs['UV'],depth.inputs['Vector'])
|
||||
original_normal = next(n for n in nodes if n.type == 'NORMAL_MAP' and n.label != 'Baked inscription normal')
|
||||
# The old material used a default tangent basis; explicitly retain its grain UV.
|
||||
original_normal.uv_map = 'Granite grain UV'
|
||||
bump = node('ShaderNodeBump','Shallow inscription bevel')
|
||||
bump.inputs['Strength'].default_value = .65
|
||||
bump.inputs['Distance'].default_value = .0025
|
||||
links.new(depth.outputs['Color'],bump.inputs['Height'])
|
||||
links.new(original_normal.outputs['Normal'],bump.inputs['Normal'])
|
||||
links.new(bump.outputs['Normal'],bs.inputs['Normal'])
|
||||
normal_target = node('ShaderNodeTexImage','Baked inscription normal texture')
|
||||
normal_image = bpy.data.images.get('Marker inscription normal') or bpy.data.images.new('Marker inscription normal',width=N,height=N,alpha=False)
|
||||
normal_image.colorspace_settings.name = 'Non-Color'
|
||||
normal_target.image = normal_image
|
||||
links.new(atlas.outputs['UV'],normal_target.inputs['Vector'])
|
||||
nodes.active = normal_target
|
||||
# The edge material needs its own temporary bake target to keep its maps intact.
|
||||
temporary = []
|
||||
for side in list(body.data.materials)[1:]:
|
||||
previous = side.node_tree.nodes.active
|
||||
t = side.node_tree.nodes.new('ShaderNodeTexImage')
|
||||
t.image = normal_image
|
||||
side.node_tree.nodes.active = t
|
||||
temporary.append((side,t,previous))
|
||||
try:
|
||||
bpy.ops.object.bake(type=next(i.identifier for i in bake_types if i.identifier == 'NORMAL'))
|
||||
finally:
|
||||
for side,t,previous in temporary:
|
||||
side.node_tree.nodes.remove(t)
|
||||
side.node_tree.nodes.active = previous
|
||||
normal_image.filepath_raw = str(ROOT / 'public/terrain/materials/marker-inscription-normal.png')
|
||||
normal_image.file_format = next(i.identifier for i in normal_image.bl_rna.properties['file_format'].enum_items if i.identifier == 'PNG')
|
||||
normal_image.save()
|
||||
normal_image.pack()
|
||||
normal = node('ShaderNodeNormalMap','Baked inscription normal')
|
||||
normal.uv_map = 'Painted monument atlas'
|
||||
links.new(normal_target.outputs['Color'],normal.inputs['Color'])
|
||||
links.new(normal.outputs['Normal'],bs.inputs['Normal'])
|
||||
body['inscriptionStyle'] = 'Photo-guided silhouette polygons with negative spaces, dense charcoal pigment and shallow baked engraving'
|
||||
scene.render.filepath = str(ROOT / 'assets/summit/blender-plateau-study/marker-detailed-inscription.png')
|
||||
bpy.ops.render.render(write_still=True)
|
||||
print('Detailed inscription baked:',int(np.count_nonzero(mask)),'texels, 3 glyphs +',caption['text'],'; color + tangent normal.')
|
||||
@@ -15,6 +15,16 @@ for obj in [marker,*marker.children_recursive]:obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active=marker
|
||||
location=marker.location.copy();marker.location=(0,0,0)
|
||||
photo_body=bpy.data.objects.get('Photo matched summit boulder')
|
||||
# Repacking an already-packed image after baking can retain its old PNG bytes.
|
||||
# Refresh from the saved bake before glTF copies those bytes into the GLB.
|
||||
if photo_body and photo_body.get('paintedInscription'):
|
||||
images={n.image for mat in photo_body.data.materials for n in mat.node_tree.nodes
|
||||
if n.type=='TEX_IMAGE' and n.image and n.image.filepath}
|
||||
for image in images:
|
||||
path=Path(bpy.path.abspath(image.filepath))
|
||||
data=path.read_bytes()
|
||||
image.pack(data=data,data_len=len(data))
|
||||
image.reload()
|
||||
restore=None
|
||||
if photo_body and not photo_body.get('paintedInscription'):
|
||||
mat=photo_body.data.materials[0]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Apply the satellite-estimated marker location in the authored plateau scene."""
|
||||
import bpy, json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path('D:/project/sunrise')
|
||||
spec = json.loads((ROOT/'scripts/constants/marker-location.json').read_text())
|
||||
root = bpy.data.objects['summit-marker']
|
||||
baseline = bpy.data.meshes['Slab before marker seating']
|
||||
x, south = spec['offsetFromStartMeters']
|
||||
def ground(x, y):
|
||||
return min(baseline.vertices, key=lambda v:(v.co.x-x)**2+(v.co.y-y)**2).co.z
|
||||
root.location.z += ground(x, -south)-ground(root.location.x, root.location.y)
|
||||
root.location.x, root.location.y = x, -south
|
||||
root['locationSource'] = spec['source']
|
||||
root['locationUncertaintyMeters'] = spec['estimatedUncertaintyMeters']
|
||||
# Restore the original ground, then seat the existing stone at its new location.
|
||||
exec(compile((ROOT/'scripts/sculpt-asymmetric-marker.py').read_text(), 'sculpt-asymmetric-marker.py', 'exec'))
|
||||
exec(compile((ROOT/'scripts/export-summit-plateau.py').read_text(), 'export-summit-plateau.py', 'exec'))
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Author and bake a dedicated light-grey granite material in Blender.
|
||||
|
||||
The procedural material is retained for editing; the temporary bake plane is
|
||||
removed. Other terrain textures and the monument's silhouette stay unchanged.
|
||||
Run detail-marker-inscription.py afterwards to rebake the preserved lettering.
|
||||
"""
|
||||
import bpy, json, math
|
||||
from pathlib import Path
|
||||
from mathutils import Vector
|
||||
from mathutils.noise import noise_vector
|
||||
|
||||
ROOT = Path('D:/project/sunrise')
|
||||
spec = json.loads((ROOT/'scripts/constants/marker-granite.json').read_text())
|
||||
scene = bpy.data.scenes['Summit slab and marker']
|
||||
bpy.context.window.scene = scene
|
||||
body = bpy.data.objects['Photo matched summit boulder']
|
||||
mat = bpy.data.materials.get('Marker grey granite authoring') or bpy.data.materials.new('Marker grey granite authoring')
|
||||
mat.use_nodes = True
|
||||
mat.use_fake_user = True
|
||||
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
||||
nodes.clear()
|
||||
bs = nodes.new('ShaderNodeBsdfPrincipled')
|
||||
out = nodes.new('ShaderNodeOutputMaterial')
|
||||
links.new(bs.outputs['BSDF'],out.inputs['Surface'])
|
||||
uv = nodes.new('ShaderNodeTexCoord')
|
||||
|
||||
def noise(scale):
|
||||
n = nodes.new('ShaderNodeTexNoise')
|
||||
n.inputs['Scale'].default_value = scale
|
||||
n.inputs['Detail'].default_value = 2
|
||||
links.new(uv.outputs['UV'],n.inputs['Vector'])
|
||||
return n
|
||||
|
||||
grain = noise(spec['grainScale'])
|
||||
fine = noise(spec['fineScale'])
|
||||
cloud = noise(18)
|
||||
ramp = nodes.new('ShaderNodeValToRGB')
|
||||
for i,(position,color) in enumerate(spec['grainColors']):
|
||||
element = ramp.color_ramp.elements[i] if i < 2 else ramp.color_ramp.elements.new(position)
|
||||
element.position = position
|
||||
element.color = color
|
||||
links.new(grain.outputs['Fac'],ramp.inputs['Fac'])
|
||||
crystal = nodes.new('ShaderNodeTexVoronoi')
|
||||
crystal.inputs['Scale'].default_value = spec['crystalScale']
|
||||
links.new(uv.outputs['UV'],crystal.inputs['Vector'])
|
||||
mica = nodes.new('ShaderNodeValToRGB')
|
||||
mica.color_ramp.elements[0].position = .12
|
||||
mica.color_ramp.elements[0].color = (.11,.12,.13,1)
|
||||
mica.color_ramp.elements[1].position = .26
|
||||
mica.color_ramp.elements[1].color = (1,1,1,1)
|
||||
links.new(crystal.outputs['Distance'],mica.inputs['Fac'])
|
||||
mix = nodes.new('ShaderNodeMixRGB')
|
||||
mix.blend_type = next(i.identifier for i in mix.bl_rna.properties['blend_type'].enum_items if i.identifier == 'MULTIPLY')
|
||||
mix.inputs[0].default_value = .8
|
||||
links.new(ramp.outputs['Color'],mix.inputs[1])
|
||||
links.new(mica.outputs['Color'],mix.inputs[2])
|
||||
cloud_ramp = nodes.new('ShaderNodeValToRGB')
|
||||
cloud_ramp.color_ramp.elements[0].position = .3
|
||||
cloud_ramp.color_ramp.elements[0].color = (.36,.38,.37,1)
|
||||
cloud_ramp.color_ramp.elements[1].position = .65
|
||||
cloud_ramp.color_ramp.elements[1].color = (1,1,1,1)
|
||||
links.new(cloud.outputs['Fac'],cloud_ramp.inputs['Fac'])
|
||||
weather = nodes.new('ShaderNodeMixRGB')
|
||||
weather.blend_type = mix.blend_type
|
||||
weather.inputs[0].default_value = .5
|
||||
links.new(mix.outputs[0],weather.inputs[1])
|
||||
links.new(cloud_ramp.outputs[0],weather.inputs[2])
|
||||
# The same source as the underlying summit rock anchors the monument's palette.
|
||||
terrain = nodes.new('ShaderNodeTexImage')
|
||||
terrain.image = bpy.data.images.load(str(ROOT/'public/terrain/materials/rock-color.jpg'),check_existing=True)
|
||||
links.new(uv.outputs['UV'],terrain.inputs['Vector'])
|
||||
tone = nodes.new('ShaderNodeHueSaturation')
|
||||
tone.inputs['Saturation'].default_value = spec['terrainSaturation']
|
||||
tone.inputs['Value'].default_value = spec['terrainBrightness']
|
||||
links.new(terrain.outputs['Color'],tone.inputs['Color'])
|
||||
slab = bpy.data.objects['SLAB_SURFACE']
|
||||
root = bpy.data.objects['summit-marker']
|
||||
nearest = min(slab.data.vertices,key=lambda v:(v.co.x-root.location.x)**2+(v.co.y-root.location.y)**2)
|
||||
loop = next(l for l in slab.data.loops if l.vertex_index == nearest.index)
|
||||
ground_uv = slab.data.uv_layers.active.data[loop.index].uv
|
||||
ground = nodes.new('ShaderNodeTexImage')
|
||||
ground.image = bpy.data.images['Summit unique weathered granite']
|
||||
ground.inputs['Vector'].default_value = (ground_uv.x,ground_uv.y,0)
|
||||
palette = nodes.new('ShaderNodeMixRGB')
|
||||
palette.inputs[0].default_value = spec['groundPaletteBlend']
|
||||
links.new(tone.outputs['Color'],palette.inputs[1])
|
||||
links.new(ground.outputs['Color'],palette.inputs[2])
|
||||
surface = nodes.new('ShaderNodeMixRGB')
|
||||
surface.inputs[0].default_value = spec['terrainBlend']
|
||||
links.new(weather.outputs[0],surface.inputs[1])
|
||||
links.new(palette.outputs[0],surface.inputs[2])
|
||||
links.new(surface.outputs[0],bs.inputs['Base Color'])
|
||||
bs.inputs['Roughness'].default_value = spec['roughness']
|
||||
bump = nodes.new('ShaderNodeBump')
|
||||
bump.inputs['Distance'].default_value = spec['relief']
|
||||
bump.inputs['Strength'].default_value = .65
|
||||
links.new(grain.outputs['Fac'],bump.inputs['Height'])
|
||||
micro = nodes.new('ShaderNodeBump')
|
||||
micro.inputs['Distance'].default_value = spec['relief']*.18
|
||||
micro.inputs['Strength'].default_value = .4
|
||||
terrain_normal = nodes.new('ShaderNodeTexImage')
|
||||
terrain_normal.image = bpy.data.images.load(str(ROOT/'public/terrain/materials/rock-normal.jpg'),check_existing=True)
|
||||
terrain_normal.image.colorspace_settings.name = 'Non-Color'
|
||||
links.new(uv.outputs['UV'],terrain_normal.inputs['Vector'])
|
||||
normal = nodes.new('ShaderNodeNormalMap')
|
||||
normal.inputs['Strength'].default_value = .65
|
||||
links.new(terrain_normal.outputs['Color'],normal.inputs['Color'])
|
||||
links.new(normal.outputs['Normal'],bump.inputs['Normal'])
|
||||
links.new(fine.outputs['Fac'],micro.inputs['Height'])
|
||||
links.new(bump.outputs['Normal'],micro.inputs['Normal'])
|
||||
links.new(micro.outputs['Normal'],bs.inputs['Normal'])
|
||||
|
||||
mesh = bpy.data.meshes.new('Temporary granite bake plane')
|
||||
mesh.from_pydata([(0,0,0),(1,0,0),(1,1,0),(0,1,0)],[],[(0,1,2,3)])
|
||||
mesh.uv_layers.new(name='UVMap')
|
||||
for loop in mesh.loops:
|
||||
mesh.uv_layers.active.data[loop.index].uv = mesh.vertices[loop.vertex_index].co.xy
|
||||
plane = bpy.data.objects.new(mesh.name,mesh)
|
||||
scene.collection.objects.link(plane)
|
||||
mesh.materials.append(mat)
|
||||
for obj in scene.objects:
|
||||
obj.select_set(False)
|
||||
plane.select_set(True)
|
||||
bpy.context.view_layer.objects.active = plane
|
||||
target = nodes.new('ShaderNodeTexImage')
|
||||
nodes.active = target
|
||||
images = {}
|
||||
try:
|
||||
for suffix,bake_type in [('color','DIFFUSE'),('normal','NORMAL')]:
|
||||
name = 'marker-grey-granite-'+suffix
|
||||
image = bpy.data.images.get(name) or bpy.data.images.new(name,width=spec['resolution'],height=spec['resolution'],alpha=False)
|
||||
if suffix == 'normal':
|
||||
image.colorspace_settings.name = 'Non-Color'
|
||||
target.image = image
|
||||
scene.render.bake.use_pass_direct = False
|
||||
scene.render.bake.use_pass_indirect = False
|
||||
scene.render.bake.use_pass_color = True
|
||||
bpy.ops.object.bake(type=next(i.identifier for i in bpy.ops.object.bake.get_rna_type().properties['type'].enum_items if i.identifier == bake_type))
|
||||
image.filepath_raw = str(ROOT/'public/terrain/materials'/(name+'.png'))
|
||||
image.file_format = next(i.identifier for i in image.bl_rna.properties['file_format'].enum_items if i.identifier == 'PNG')
|
||||
image.save()
|
||||
image.pack()
|
||||
images[suffix] = image
|
||||
finally:
|
||||
bpy.data.objects.remove(plane,do_unlink=True)
|
||||
bpy.data.meshes.remove(mesh)
|
||||
|
||||
# Replace only this monument's source textures; surrounding rocks keep theirs.
|
||||
for i,material in enumerate(list(body.data.materials)):
|
||||
if material.users > 1:
|
||||
material = material.copy()
|
||||
body.data.materials[i] = material
|
||||
for n in material.node_tree.nodes:
|
||||
if n.type != 'TEX_IMAGE' or not n.image:
|
||||
continue
|
||||
if n.image.name.startswith(('rock-color','marker-grey-granite-color')):
|
||||
n.image = images['color']
|
||||
elif n.image.name.startswith(('rock-normal','marker-grey-granite-normal')):
|
||||
n.image = images['normal']
|
||||
shader = next(n for n in material.node_tree.nodes if n.type == 'BSDF_PRINCIPLED')
|
||||
shader.inputs['Roughness'].default_value = spec['roughness']
|
||||
|
||||
# Recompute neutral weathering, rather than repeatedly brightening the old tint.
|
||||
colors = body.data.color_attributes['Granite weathering']
|
||||
for v in body.data.vertices:
|
||||
x,y,z = v.co
|
||||
panel = abs((x-.18)/max(.16,.57-.18*z))
|
||||
rim = min(1,max(0,(panel-.8)/.4))
|
||||
rim = rim*rim*(3-2*rim)
|
||||
weathering = .045*noise_vector(Vector((x*9,z*9,2.7)))[0]
|
||||
seam = .05*math.exp(-((x+.32-.18*z)/.07)**2)
|
||||
flecks = noise_vector(Vector((x*31,z*31,y*31)))[0]
|
||||
patina = .20*max(0,min(1,(flecks-.05)*3))*rim
|
||||
cleft = .12*math.exp(-((x+.18-.12*z)/.085)**2)
|
||||
grey = spec['faceTint']*(1-rim)+spec['rimTint']*rim+weathering-seam-patina-cleft
|
||||
colors.data[v.index].color = (grey,grey,grey,1)
|
||||
body['graniteFinish'] = 'Light neutral grey quartz/feldspar, dark mica specks, granular microrelief; photo-guided'
|
||||
print('Dedicated grey granite baked; marker source nodes updated, geometry and inscription unchanged.')
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Shape the left hollow and deep cleft from the supplied oblique photos.
|
||||
|
||||
Restores a saved mesh baseline before every run so edits never accumulate.
|
||||
The front UV atlas and inscription remain attached to their original vertices.
|
||||
"""
|
||||
import bpy, math, json
|
||||
from mathutils import Vector
|
||||
from pathlib import Path
|
||||
ROOT = Path('D:/project/sunrise')
|
||||
spec = json.loads((ROOT/'scripts/constants/marker-shape.json').read_text())
|
||||
scene = bpy.data.scenes['Summit slab and marker']
|
||||
bpy.context.window.scene = scene
|
||||
body = bpy.data.objects['Photo matched summit boulder']
|
||||
root = bpy.data.objects['summit-marker']
|
||||
baseline = bpy.data.meshes.get('Marker before asymmetric hollow')
|
||||
if baseline is None:
|
||||
baseline = body.data.copy()
|
||||
baseline.name = 'Marker before asymmetric hollow'
|
||||
baseline.use_fake_user = True
|
||||
mesh = body.data
|
||||
for vertex, source in zip(mesh.vertices,baseline.vertices):
|
||||
x,y,z = source.co
|
||||
front = max(0,min(1,(.15-y)/.5))
|
||||
bowl = math.exp(-((x+.55)/.34)**2-((z-.86)/.67)**4)
|
||||
# A narrow, slanting cleft between the sunken left bowl and inscribed slab.
|
||||
cleft = math.exp(-((x+.18-.12*z)/.065)**2)*math.exp(-((z-.86)/.88)**4)
|
||||
shoulder = math.exp(-((x+.44)/.48)**2)*max(0,min(1,(z-.8)/.7))
|
||||
vertex.co = (x-.13*shoulder+spec['rightFaceOffset']*max(0,min(1,(x+.12)/.6)),
|
||||
y+front*(spec['leftBowlDepth']*bowl+spec['cleftDepth']*cleft),
|
||||
z+spec['leftShoulderLift']*shoulder)
|
||||
mesh.update()
|
||||
root.scale = spec['scale']
|
||||
root['blockedRadius'] = round(max(math.hypot(v.co.x*root.scale.x,v.co.y*root.scale.y) for v in mesh.vertices)+.12,2)
|
||||
body['asymmetricHollow'] = True
|
||||
# Close the local fissure under the enlarged stone using the shared terrain mesh.
|
||||
# This surface is exported for both rendering and walking; no separate pedestal.
|
||||
bpy.context.view_layer.update()
|
||||
slab = bpy.data.objects['SLAB_SURFACE']
|
||||
ground_baseline = bpy.data.meshes.get('Slab before marker seating')
|
||||
if ground_baseline is None:
|
||||
ground_baseline = slab.data.copy()
|
||||
ground_baseline.name = 'Slab before marker seating'
|
||||
ground_baseline.use_fake_user = True
|
||||
base = min((body.matrix_world@v.co).z for v in mesh.vertices)
|
||||
for vertex,source in zip(slab.data.vertices,ground_baseline.vertices):
|
||||
vertex.co = source.co
|
||||
d = math.hypot(vertex.co.x-root.location.x,vertex.co.y-root.location.y)
|
||||
if d >= spec['bedrockRadius']+spec['bedrockBlend']:
|
||||
continue
|
||||
t = max(0,min(1,(d-spec['bedrockRadius'])/spec['bedrockBlend']))
|
||||
weight = 1-t*t*(3-2*t)
|
||||
target = base+spec['bedrockOverlap']+.025*math.sin(vertex.co.x*3+vertex.co.y*2)
|
||||
if vertex.co.z < target:
|
||||
vertex.co.z += (target-vertex.co.z)*weight
|
||||
slab.data.update()
|
||||
scene.camera.location = root.location+Vector((1.7,-10,3.5))
|
||||
scene.camera.rotation_euler = (root.location+Vector((0,0,1.5))-scene.camera.location).to_track_quat('-Z','Y').to_euler()
|
||||
scene.camera.data.lens = 55
|
||||
print('Asymmetric left hollow sculpted; scale',spec['scale'],'blocked radius',root['blockedRadius'])
|
||||
@@ -3,5 +3,5 @@ export const SERVER_LIMITS = {
|
||||
movementBudgetSeconds: 0.5,
|
||||
updateIntervalMs: 100,
|
||||
weatherSampleMs: 60000,
|
||||
defaultOrigins: ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:5173', 'http://127.0.0.1:5173'],
|
||||
defaultOrigins: ['http://localhost:2027', 'http://127.0.0.1:2027', 'http://localhost:5173', 'http://127.0.0.1:5173'],
|
||||
} as const;
|
||||
|
||||
+4
-2
@@ -43,7 +43,8 @@ function validUpdate(value: unknown): value is PlayerUpdate {
|
||||
&& Math.abs(v.yaw) <= Math.PI * 2 && typeof v.moving === 'boolean'
|
||||
&& ['idle', 'wave', 'pray'].includes(v.emote) && !!gear
|
||||
&& typeof gear.umbrella === 'boolean' && typeof gear.raincoat === 'boolean'
|
||||
&& typeof gear.hood === 'boolean' && (!gear.hood || gear.raincoat);
|
||||
&& typeof gear.hood === 'boolean' && (!gear.hood || gear.raincoat)
|
||||
&& (gear.flashlight === undefined || typeof gear.flashlight === 'boolean');
|
||||
}
|
||||
|
||||
export class SummitRoom {
|
||||
@@ -191,7 +192,8 @@ export class SummitRoom {
|
||||
|| Object.keys(update.equipment).some(key => update.equipment[key as keyof typeof update.equipment] !== s.player!.equipment[key as keyof typeof update.equipment]);
|
||||
if (!changed) return;
|
||||
s.player = { ...s.player, x: update.x, z: update.z, yaw: update.yaw, moving: update.moving, emote,
|
||||
equipment: { umbrella: update.equipment.umbrella, raincoat: update.equipment.raincoat, hood: update.equipment.hood },
|
||||
equipment: { umbrella: update.equipment.umbrella, raincoat: update.equipment.raincoat, hood: update.equipment.hood,
|
||||
...(update.equipment.flashlight === undefined ? {} : { flashlight: update.equipment.flashlight }) },
|
||||
updatedAt: at, emoteStartedAt: emote === s.player.emote ? s.player.emoteStartedAt : at };
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { WeatherSample } from '../../src/weather/weather.ts';
|
||||
import { kstClock, kstFields } from '../../src/ui/time.ts';
|
||||
import { THREADS } from '../../src/constants/threads.ts';
|
||||
|
||||
type Condition = typeof THREADS.captionConditions[number];
|
||||
export interface Captions {
|
||||
template: string[];
|
||||
replyTemplate?: string[];
|
||||
testPrefix: string;
|
||||
thresholds: { fogVisibilityMeters: number; cloudyPercent: number; partlyCloudyPercent: number };
|
||||
messages: Record<Condition, string[]>;
|
||||
}
|
||||
|
||||
export function loadCaptions(path: string | URL = new URL('../../.threads-captions.json', import.meta.url)): Captions {
|
||||
let text: string;
|
||||
try { text = readFileSync(path, 'utf8'); }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
text = readFileSync(new URL('../../.threads-captions.sample.json', import.meta.url), 'utf8');
|
||||
}
|
||||
let config: Captions;
|
||||
try { config = JSON.parse(text); } catch { throw new Error('Invalid JSON in .threads-captions.json'); }
|
||||
const lines = (value: unknown): value is string[] => Array.isArray(value) && value.length > 0 && value.every(line => typeof line === 'string');
|
||||
if (!config || !lines(config.template) || typeof config.testPrefix !== 'string'
|
||||
|| (config.replyTemplate !== undefined && !lines(config.replyTemplate))
|
||||
|| !THREADS.captionConditions.every(key => lines(config.messages?.[key]))) throw new Error('Invalid caption template or weather messages');
|
||||
const t = config.thresholds;
|
||||
if (!t || !Number.isFinite(t.fogVisibilityMeters) || t.fogVisibilityMeters < 0
|
||||
|| !Number.isFinite(t.partlyCloudyPercent) || !Number.isFinite(t.cloudyPercent)
|
||||
|| t.partlyCloudyPercent < 0 || t.cloudyPercent > 100 || t.partlyCloudyPercent >= t.cloudyPercent) {
|
||||
throw new Error('Invalid caption weather thresholds');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function weatherKey(weather: WeatherSample | null, config: Captions): Condition {
|
||||
if (!weather) return 'unknown';
|
||||
if ((weather.snowfall ?? 0) > 0) return 'snow';
|
||||
if ((weather.rain ?? 0) > 0) return 'rain';
|
||||
if (weather.visibility !== null && weather.visibility < config.thresholds.fogVisibilityMeters) return 'fog';
|
||||
if (weather.rain === null || weather.snowfall === null || !Number.isFinite(weather.cloudCover)) return 'unknown';
|
||||
if (weather.cloudCover >= config.thresholds.cloudyPercent) return 'cloudy';
|
||||
if (weather.cloudCover >= config.thresholds.partlyCloudyPercent) return 'partlyCloudy';
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
export function renderCaption(config: Captions, capture: {
|
||||
weather: WeatherSample | null; capturedAt: string; sunrise: string; url: string; test: boolean;
|
||||
}) {
|
||||
const condition = weatherKey(capture.weather, config);
|
||||
const date = new Date(capture.capturedAt);
|
||||
const values: Record<string, string> = {
|
||||
message: config.messages[condition].join('\n'), date: kstFields(date).date,
|
||||
dateDots: kstFields(date).date.replaceAll('-', '.'), hostname: new URL(capture.url).hostname,
|
||||
time: kstClock(date).slice(0, 5), sunrise: kstClock(new Date(capture.sunrise)).slice(0, 5), url: capture.url,
|
||||
};
|
||||
const expand = (lines: string[]) => lines.join('\n').replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
if (!(key in values)) throw new Error(`Unknown caption placeholder: ${key}`);
|
||||
return values[key];
|
||||
});
|
||||
const text = (capture.test ? config.testPrefix : '') + expand(config.template);
|
||||
const replyText = config.replyTemplate ? expand(config.replyTemplate) : undefined;
|
||||
if (!text.trim() || [...text].length > THREADS.captionMaxLength) throw new Error('Caption must contain 1–500 characters');
|
||||
if (replyText !== undefined && (!replyText.trim() || [...replyText].length > THREADS.captionMaxLength)) {
|
||||
throw new Error('Reply must contain 1–500 characters');
|
||||
}
|
||||
return { text, condition, replyText };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFile, realpath, unlink, writeFile, rename } from 'node:fs/promises';
|
||||
import { dirname, relative, isAbsolute, resolve } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/** Only confirmed publications may release their public source images. */
|
||||
export async function cleanupPublished(recordPath: string, imageDir: string) {
|
||||
const record = JSON.parse(await readFile(recordPath, 'utf8'));
|
||||
if (record.status !== 'published' || !record.postId || record.imagesDeletedAt) return false;
|
||||
const root = await realpath(imageDir).catch(error => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return resolve(imageDir);
|
||||
throw error;
|
||||
});
|
||||
const paths: string[] = [];
|
||||
for (const image of record.images) {
|
||||
let path: string;
|
||||
try { path = await realpath(image.imagePath); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; throw error; }
|
||||
const child = relative(root, path);
|
||||
if (!child || child.startsWith('..') || isAbsolute(child) || dirname(path) !== root) {
|
||||
throw new Error('Refusing image cleanup outside the capture directory');
|
||||
}
|
||||
paths.push(path);
|
||||
}
|
||||
for (const path of paths) {
|
||||
try { await unlink(path); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
||||
}
|
||||
const temporary = `${recordPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporary, JSON.stringify({ ...record, imagesDeletedAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
||||
await rename(temporary, recordPath);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { THREADS } from '../../src/constants/threads.ts';
|
||||
|
||||
export interface ThreadsConfig {
|
||||
accessToken: string;
|
||||
siteUrl: string;
|
||||
stateDir: string;
|
||||
imageDir: string;
|
||||
imageBaseUrl: string;
|
||||
}
|
||||
|
||||
export function loadThreadsConfig(path: string | URL = new URL('../../.env.threads.json', import.meta.url), optional = false): ThreadsConfig {
|
||||
const defaults: ThreadsConfig = {
|
||||
accessToken: '', siteUrl: THREADS.siteUrl, stateDir: 'data/threads',
|
||||
imageDir: 'dist/threads-captures', imageBaseUrl: `${THREADS.siteUrl}/threads-captures/`,
|
||||
};
|
||||
let contents: string;
|
||||
try { contents = readFileSync(path, 'utf8'); }
|
||||
catch (error) {
|
||||
if (optional && (error as NodeJS.ErrnoException).code === 'ENOENT') return defaults;
|
||||
throw new Error('Cannot read .env.threads.json; copy .env.threads.sample.json and configure it.');
|
||||
}
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(contents); }
|
||||
catch { throw new Error('Invalid JSON in .env.threads.json'); }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid .env.threads.json: expected an object');
|
||||
const config = { ...defaults, ...value } as ThreadsConfig;
|
||||
for (const field of Object.keys(defaults) as (keyof ThreadsConfig)[]) {
|
||||
if (typeof config[field] !== 'string' || (field !== 'accessToken' && !config[field].trim())) {
|
||||
throw new Error(`Invalid .env.threads.json field: ${field}`);
|
||||
}
|
||||
}
|
||||
for (const field of ['siteUrl', 'imageBaseUrl'] as const) {
|
||||
try {
|
||||
if (!['http:', 'https:'].includes(new URL(config[field]).protocol)) throw new Error();
|
||||
} catch { throw new Error(`Invalid .env.threads.json field: ${field}`); }
|
||||
}
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { loadCaptions, renderCaption } from './captions.ts';
|
||||
import { dailySchedule } from './publisher.ts';
|
||||
import { loadThreadsConfig } from './config.ts';
|
||||
import type { WeatherSample } from '../../src/weather/weather.ts';
|
||||
|
||||
const config = loadCaptions();
|
||||
const site = loadThreadsConfig(undefined, true);
|
||||
const base = { cloudCover: 0, rain: 0, snowfall: 0, visibility: 20000 };
|
||||
const samples: Record<string, WeatherSample | null> = {
|
||||
clear: base, partlyCloudy: { ...base, cloudCover: config.thresholds.partlyCloudyPercent },
|
||||
cloudy: { ...base, cloudCover: config.thresholds.cloudyPercent }, fog: { ...base, visibility: 0 },
|
||||
rain: { ...base, rain: 1 }, snow: { ...base, snowfall: 1 }, unknown: null,
|
||||
};
|
||||
for (const [condition, weather] of Object.entries(samples)) {
|
||||
const { text, replyText } = renderCaption(config, { weather, capturedAt: new Date().toISOString(),
|
||||
sunrise: dailySchedule(new Date()).sunrise.toISOString(), url: new URL(site.siteUrl).origin, test: false });
|
||||
console.log(`\n[${condition}]\n${text}`);
|
||||
if (replyText) console.log(`\n[댓글]\n${replyText}`);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { calculateSun } from '../../src/astronomy/sunCalculator.ts';
|
||||
import { observer } from '../../src/config/observer.ts';
|
||||
import { kstFields } from '../../src/ui/time.ts';
|
||||
import { THREADS } from '../../src/constants/threads.ts';
|
||||
|
||||
export function dailySchedule(now: Date) {
|
||||
const sunrise = calculateSun(now, observer).sunrise;
|
||||
if (!sunrise || !Number.isFinite(+sunrise)) throw new Error('No sunrise for this date');
|
||||
return { date: kstFields(now).date, sunrise, captureAt: sunriseCaptureTime(sunrise), prepareAt: new Date(+sunrise - THREADS.prepareMs) };
|
||||
}
|
||||
|
||||
export function sunriseCaptureTime(sunrise: Date): Date {
|
||||
return new Date(+sunrise + THREADS.captureDelayMs);
|
||||
}
|
||||
|
||||
export async function threadsRequest(token: string, path: string, params: Record<string, string>, method = 'GET', request: typeof fetch = fetch) {
|
||||
const url = new URL(path, THREADS.apiUrl);
|
||||
if (method === 'GET') url.search = new URLSearchParams(params).toString();
|
||||
const response = await request(url, {
|
||||
method, headers: { Authorization: `Bearer ${token}` },
|
||||
body: method === 'POST' ? new URLSearchParams(params) : undefined,
|
||||
signal: AbortSignal.timeout(THREADS.requestTimeoutMs),
|
||||
});
|
||||
const data = await response.json();
|
||||
// Do not log response bodies: authentication errors may echo credentials.
|
||||
if (!response.ok || data.error) throw new Error(`Threads API failed (HTTP ${response.status}, code ${data.error?.code ?? 'unknown'})`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function publishImage(token: string, imageUrl: string, text: string,
|
||||
beforePublish: (containerId: string) => Promise<void>, request: typeof fetch = fetch,
|
||||
wait: (ms: number) => Promise<unknown> = delay) {
|
||||
const container = await threadsRequest(token, '/me/threads', {
|
||||
media_type: 'IMAGE', image_url: imageUrl, text,
|
||||
alt_text: '관악산 정상에서 바라본 일출 시각의 3D 시뮬레이션',
|
||||
}, 'POST', request);
|
||||
if (!container.id) throw new Error('Threads returned no container ID');
|
||||
return publishContainer(token, container.id, beforePublish, request, wait);
|
||||
}
|
||||
|
||||
export async function publishCarousel(token: string, imageUrls: string[], text: string,
|
||||
beforePublish: (containerId: string) => Promise<void>, request: typeof fetch = fetch,
|
||||
wait: (ms: number) => Promise<unknown> = delay) {
|
||||
if (imageUrls.length !== THREADS.angles.length) throw new Error('Both sunrise angles are required');
|
||||
const children: string[] = [];
|
||||
for (const [index, imageUrl] of imageUrls.entries()) {
|
||||
const child = await threadsRequest(token, '/me/threads', {
|
||||
media_type: 'IMAGE', image_url: imageUrl, is_carousel_item: 'true',
|
||||
alt_text: `${THREADS.angles[index].alt} · 3D 시뮬레이션`,
|
||||
}, 'POST', request);
|
||||
if (!child.id) throw new Error('Threads returned no child container ID');
|
||||
await waitForMedia(token, child.id, request, wait);
|
||||
children.push(child.id);
|
||||
}
|
||||
const container = await threadsRequest(token, '/me/threads', {
|
||||
media_type: 'CAROUSEL', children: children.join(','), text,
|
||||
}, 'POST', request);
|
||||
if (!container.id) throw new Error('Threads returned no carousel ID');
|
||||
return publishContainer(token, container.id, beforePublish, request, wait);
|
||||
}
|
||||
|
||||
export async function publishReply(token: string, postId: string, text: string, linkUrl: string,
|
||||
beforePublish: (id: string) => Promise<void>, request: typeof fetch = fetch,
|
||||
wait: (ms: number) => Promise<unknown> = delay) {
|
||||
const container = await threadsRequest(token, '/me/threads', {
|
||||
media_type: 'TEXT', text, reply_to_id: postId, link_attachment: linkUrl,
|
||||
}, 'POST', request);
|
||||
if (!container.id) throw new Error('Threads returned no reply container ID');
|
||||
return publishContainer(token, container.id, beforePublish, request, wait);
|
||||
}
|
||||
|
||||
async function publishContainer(token: string, id: string, beforePublish: (id: string) => Promise<void>,
|
||||
request: typeof fetch, wait: (ms: number) => Promise<unknown>) {
|
||||
await waitForMedia(token, id, request, wait);
|
||||
await beforePublish(id);
|
||||
const post = await threadsRequest(token, '/me/threads_publish', { creation_id: id }, 'POST', request);
|
||||
if (!post.id) throw new Error('Threads returned no post ID; check account before retrying');
|
||||
return String(post.id);
|
||||
}
|
||||
|
||||
async function waitForMedia(token: string, id: string, request: typeof fetch, wait: (ms: number) => Promise<unknown>) {
|
||||
for (let i = 0; i < THREADS.mediaPollAttempts; i++) {
|
||||
const media = await threadsRequest(token, `/${id}`, { fields: 'status,error_message' }, 'GET', request);
|
||||
if (media.status === 'FINISHED') return;
|
||||
if (['ERROR', 'EXPIRED', 'PUBLISHED'].includes(media.status)) throw new Error(`Threads container status: ${media.status}`);
|
||||
await wait(THREADS.mediaPollMs);
|
||||
}
|
||||
throw new Error('Threads media processing timed out');
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { publishReply } from './publisher.ts';
|
||||
|
||||
/** A separate exclusive claim prevents concurrent or ambiguous reply retries. */
|
||||
export async function replyToPublished(recordPath: string, getToken: () => Promise<string>,
|
||||
publish: typeof publishReply = publishReply) {
|
||||
const record = JSON.parse(await readFile(recordPath, 'utf8'));
|
||||
if (record.status !== 'published' || !record.postId || !record.imagesDeletedAt || !record.replyText) return;
|
||||
const replyPath = `${recordPath}.reply.json`;
|
||||
try {
|
||||
const existing = JSON.parse(await readFile(replyPath, 'utf8'));
|
||||
if (existing.status !== 'published') console.warn(`Reply outcome unknown; inspect ${replyPath} before retrying`);
|
||||
return;
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
||||
const token = await getToken();
|
||||
const replyId = await publish(token, record.postId, record.replyText, record.replyUrl, async containerId => {
|
||||
await writeFile(replyPath, JSON.stringify({ status: 'publishing', postId: record.postId, containerId }, null, 2),
|
||||
{ flag: 'wx', mode: 0o600 });
|
||||
});
|
||||
const temporary = `${replyPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporary, JSON.stringify({ status: 'published', postId: record.postId, replyId }, null, 2), { mode: 0o600 });
|
||||
await rename(temporary, replyPath);
|
||||
console.log(`Threads reply published: ${replyId}`);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { chromium } from 'playwright';
|
||||
import { THREADS } from '../../src/constants/threads.ts';
|
||||
import { kstClock, kstFields } from '../../src/ui/time.ts';
|
||||
import { dailySchedule, publishCarousel, threadsRequest, sunriseCaptureTime } from './publisher.ts';
|
||||
import { loadThreadsConfig } from './config.ts';
|
||||
import { cleanupPublished } from './cleanup.ts';
|
||||
import { replyToPublished } from './reply.ts';
|
||||
import { loadCaptions, renderCaption } from './captions.ts';
|
||||
import { WEATHER } from '../../src/constants/weather.ts';
|
||||
import type { WeatherSample } from '../../src/weather/weather.ts';
|
||||
import type {} from '../../src/ui/threadsCapture.ts';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const config = loadThreadsConfig(undefined, dryRun);
|
||||
const stateDir = resolve(config.stateDir);
|
||||
const imageDir = resolve(config.imageDir);
|
||||
const siteUrl = new URL(config.siteUrl);
|
||||
const publicUrl = new URL(config.imageBaseUrl);
|
||||
|
||||
async function readJson(path: string) {
|
||||
try { return JSON.parse(await readFile(path, 'utf8')); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; }
|
||||
}
|
||||
|
||||
async function saveJson(path: string, data: unknown) {
|
||||
const temporary = `${path}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporary, JSON.stringify(data, null, 2), { mode: 0o600 });
|
||||
await rename(temporary, path);
|
||||
}
|
||||
|
||||
async function accessToken() {
|
||||
const path = join(stateDir, 'token.json');
|
||||
let saved = await readJson(path);
|
||||
if (!saved) {
|
||||
const token = config.accessToken.trim();
|
||||
if (!token) throw new Error('Set accessToken in .env.threads.json to a long-lived Threads token');
|
||||
saved = { token, refreshedAt: Date.now() };
|
||||
await saveJson(path, saved);
|
||||
}
|
||||
if (Date.now() - saved.refreshedAt >= THREADS.tokenRefreshMs) {
|
||||
const refreshed = await threadsRequest(saved.token, '/refresh_access_token', { grant_type: 'th_refresh_token' });
|
||||
if (!refreshed.access_token) throw new Error('Token refresh returned no access token');
|
||||
saved = { token: refreshed.access_token, refreshedAt: Date.now() };
|
||||
await saveJson(path, saved);
|
||||
}
|
||||
return saved.token as string;
|
||||
}
|
||||
|
||||
async function waitUntil(date: Date) {
|
||||
while (+date > Date.now()) await delay(Math.min(+date - Date.now(), 60_000));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const postNow = args.includes('--post-now');
|
||||
if (args.some(arg => !['--dry-run', '--now', '--post-now'].includes(arg))
|
||||
|| (args.includes('--now') && !dryRun) || (postNow && (dryRun || args.includes('--now')))) {
|
||||
throw new Error('Usage: run.ts [--dry-run [--now] | --post-now]. --post-now publishes a real test post.');
|
||||
}
|
||||
const schedule = dailySchedule(new Date());
|
||||
const runId = postNow ? `test-${schedule.date}-${randomUUID()}` : schedule.date;
|
||||
const recordPath = join(stateDir, `${runId}.json`);
|
||||
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
||||
if (!dryRun) {
|
||||
// Retry interrupted deletions, including earlier manual test publications.
|
||||
for (const name of await readdir(stateDir)) {
|
||||
if (/^(?:\d{4}-\d{2}-\d{2}|test-\d{4}-\d{2}-\d{2}-[\da-f-]+)\.json$/.test(name)) {
|
||||
await cleanupPublished(join(stateDir, name), imageDir);
|
||||
// An older failed reply must not prevent today's time-sensitive capture.
|
||||
try { await replyToPublished(join(stateDir, name), accessToken); }
|
||||
catch (error) { console.warn(`Pending reply ${name}: ${error instanceof Error ? error.message : 'failed'}`); }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dryRun && await readJson(recordPath)) {
|
||||
await replyToPublished(recordPath, accessToken);
|
||||
console.log(`${schedule.date}: publication already recorded; inspect the record if status is publishing`);
|
||||
return;
|
||||
}
|
||||
const immediate = postNow || (dryRun && args.includes('--now'));
|
||||
const capturePath = join(stateDir, `${runId}.capture.json`);
|
||||
const savedCapture = dryRun ? null : await readJson(capturePath);
|
||||
if (!savedCapture && !immediate && Date.now() > +schedule.captureAt + THREADS.maxLatenessMs) {
|
||||
console.log(`${schedule.date}: sunrise +5 minute capture window has passed; skipping late capture`);
|
||||
return;
|
||||
}
|
||||
const captured = savedCapture ?? await captureSunrise(schedule, immediate);
|
||||
const { images, capturedAt } = captured;
|
||||
if (!Array.isArray(images) || images.length !== THREADS.angles.length) {
|
||||
throw new Error('Saved capture is not a two-angle capture; archive the old capture record before running again');
|
||||
}
|
||||
if (!dryRun && !savedCapture) await saveJson(capturePath, captured);
|
||||
const { text, condition, replyText } = renderCaption(loadCaptions(), { weather: captured.weather ?? null,
|
||||
capturedAt, sunrise: captured.sunrise, url: siteUrl.origin, test: postNow });
|
||||
console.log(`Caption (${condition}):\n${text}`);
|
||||
if (replyText) console.log(`Reply:\n${replyText}`);
|
||||
if (dryRun) { console.log('Dry run: no Threads API calls or publication records'); return; }
|
||||
// Authentication/network outages must not lose the time-sensitive capture.
|
||||
const token = await accessToken();
|
||||
for (const { imageUrl, imagePath } of images) {
|
||||
if (new URL(imageUrl).protocol !== 'https:') throw new Error('Public image URL must use HTTPS');
|
||||
const check = await fetch(imageUrl, { signal: AbortSignal.timeout(THREADS.requestTimeoutMs) });
|
||||
const remoteImage = Buffer.from(await check.arrayBuffer());
|
||||
if (!check.ok || !remoteImage.equals(await readFile(imagePath))) throw new Error('Public image URL does not serve the captured image');
|
||||
}
|
||||
const record = { date: schedule.date, mode: postNow ? 'test' : 'scheduled', sunrise: captured.sunrise,
|
||||
capturedAt, images, weather: captured.weather ?? null, condition, text, replyText,
|
||||
replyUrl: siteUrl.origin, status: 'publishing' };
|
||||
const postId = await publishCarousel(token, images.map((image: { imageUrl: string }) => image.imageUrl), text, async containerId => {
|
||||
// Atomic claim survives restarts and blocks concurrent/ambiguous publication retries.
|
||||
await writeFile(recordPath, JSON.stringify({ ...record, containerId }, null, 2), { flag: 'wx', mode: 0o600 });
|
||||
});
|
||||
const pending = await readJson(recordPath);
|
||||
await saveJson(recordPath, { ...pending, status: 'published', postId });
|
||||
console.log(`Threads published: ${postId}`);
|
||||
await cleanupPublished(recordPath, imageDir);
|
||||
console.log('Deleted both source images after publication');
|
||||
await replyToPublished(recordPath, accessToken);
|
||||
}
|
||||
|
||||
async function captureSunrise(schedule: ReturnType<typeof dailySchedule>, immediate: boolean) {
|
||||
console.log(`Sunrise estimate: ${schedule.sunrise.toISOString()} (${kstClock(schedule.sunrise)} KST)`);
|
||||
if (!immediate) await waitUntil(schedule.prepareAt);
|
||||
await mkdir(imageDir, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true, chromiumSandbox: true,
|
||||
args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||||
const images: { name: string; imagePath: string; imageUrl: string }[] = [];
|
||||
let sunrise: Date;
|
||||
let capturedAt: string;
|
||||
let weather: WeatherSample | null;
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: THREADS.viewport, deviceScaleFactor: 1, timezoneId: 'Asia/Seoul' });
|
||||
const url = new URL(siteUrl);
|
||||
url.searchParams.set('mode', 'solo'); url.searchParams.set('site', 'gwanaksan');
|
||||
url.searchParams.set('capture', 'threads');
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 120_000 });
|
||||
await page.waitForFunction(() => Boolean(window.sunriseThreads), undefined, { timeout: 180_000 });
|
||||
await page.waitForFunction(() => !document.getElementById('lobby') || document.getElementById('lobby')!.hidden);
|
||||
// An unavailable forecast gets a neutral caption, never a fabricated clear sky.
|
||||
try {
|
||||
await page.waitForFunction(() => window.sunriseThreads!.weatherStatus?.() !== 'loading', undefined, { timeout: WEATHER.timeoutMs });
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.name !== 'TimeoutError') throw error;
|
||||
console.warn('Weather is still loading; capture may use the unknown-weather caption');
|
||||
}
|
||||
sunrise = new Date(await page.evaluate(() => window.sunriseThreads!.sunrise()));
|
||||
if (!Number.isFinite(+sunrise) || kstFields(sunrise).date !== schedule.date) throw new Error('Unexpected page sunrise date');
|
||||
console.log(`Page sunrise: ${sunrise.toISOString()} (${kstClock(sunrise)} KST)`);
|
||||
const captureAt = sunriseCaptureTime(sunrise);
|
||||
console.log(`Capture target: ${captureAt.toISOString()} (${kstClock(captureAt)} KST)`);
|
||||
if (!immediate) {
|
||||
if (Date.now() > +captureAt + THREADS.maxLatenessMs) throw new Error('Page loaded after capture deadline');
|
||||
await waitUntil(captureAt);
|
||||
}
|
||||
const capture = await page.evaluate(() => window.sunriseThreads!.capture());
|
||||
capturedAt = capture.capturedAt;
|
||||
weather = capture.weather ?? null;
|
||||
if (!immediate && Math.abs(Date.parse(capturedAt) - +captureAt) > THREADS.maxLatenessMs) throw new Error('Capture missed sunrise +5 minute deadline');
|
||||
if (capture.images?.length !== THREADS.angles.length) throw new Error('Deploy the two-angle capture frontend first');
|
||||
for (const [index, shot] of capture.images.entries()) {
|
||||
if (shot.name !== THREADS.angles[index].name) throw new Error('Unexpected capture angle order');
|
||||
const filename = `${schedule.date}-${randomUUID()}-${shot.name}.jpg`;
|
||||
const imagePath = join(imageDir, filename);
|
||||
const imageUrl = new URL(filename, publicUrl.href.replace(/\/?$/, '/')).href;
|
||||
const image = Buffer.from(shot.image.split(',')[1], 'base64');
|
||||
if (image.length < 1000) throw new Error('Capture is empty');
|
||||
await writeFile(imagePath, image);
|
||||
images.push({ name: shot.name, imagePath, imageUrl });
|
||||
console.log(`Saved ${shot.name} capture: ${imagePath}`);
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
return { images, capturedAt, sunrise: sunrise.toISOString(), weather };
|
||||
}
|
||||
|
||||
void main().catch(error => {
|
||||
// Avoid logging fetch error causes/URLs that may contain credentials.
|
||||
console.error(error instanceof Error ? error.message : 'Threads job failed');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { fromKstInput, kstFields } from '../../src/ui/time.ts';
|
||||
import { THREADS } from '../../src/constants/threads.ts';
|
||||
|
||||
export function nextRunAt(now: Date, completedDate?: string): Date {
|
||||
const date = kstFields(now).date;
|
||||
const today = fromKstInput(date, THREADS.scheduleHourKst * 60);
|
||||
if (completedDate === date) {
|
||||
const tomorrow = new Date(+fromKstInput(date, 12 * 60) + 86_400_000);
|
||||
return fromKstInput(kstFields(tomorrow).date, THREADS.scheduleHourKst * 60);
|
||||
}
|
||||
return +now < +today ? today : now;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { nextRunAt } from './schedule.ts';
|
||||
import { kstFields } from '../../src/ui/time.ts';
|
||||
import { THREADS } from '../../src/constants/threads.ts';
|
||||
|
||||
const stop = new AbortController();
|
||||
let child: ChildProcess | undefined;
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.once(signal, () => { stop.abort(); child?.kill('SIGTERM'); });
|
||||
}
|
||||
|
||||
async function runJob() {
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
const job = spawn(process.execPath, [fileURLToPath(new URL('./run.ts', import.meta.url))], { stdio: 'inherit' });
|
||||
child = job;
|
||||
let timedOut = false;
|
||||
let forceStop: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = setTimeout(() => {
|
||||
timedOut = true;
|
||||
console.error('Daily job exceeded eight hours; terminating before retry');
|
||||
job.kill('SIGTERM');
|
||||
forceStop = setTimeout(() => job.kill('SIGKILL'), THREADS.jobStopGraceMs);
|
||||
}, THREADS.jobTimeoutMs);
|
||||
const clear = () => { clearTimeout(deadline); clearTimeout(forceStop); child = undefined; };
|
||||
job.once('error', error => { clear(); reject(error); });
|
||||
job.once('exit', code => { clear(); resolve(code === 0 && !timedOut); });
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let completedDate: string | undefined;
|
||||
while (!stop.signal.aborted) {
|
||||
const next = nextRunAt(new Date(), completedDate);
|
||||
console.log(`Next daily preparation: ${next.toISOString()} (schedule: 03:00 Asia/Seoul)`);
|
||||
while (+next > Date.now()) await delay(Math.min(+next - Date.now(), 60_000), undefined, { signal: stop.signal });
|
||||
if (stop.signal.aborted) break;
|
||||
const date = kstFields(new Date()).date;
|
||||
for (let attempt = 1; attempt <= THREADS.retryAttempts && !stop.signal.aborted; attempt++) {
|
||||
if (await runJob()) break;
|
||||
console.error(`Daily job failed (${attempt}/${THREADS.retryAttempts})`);
|
||||
if (attempt < THREADS.retryAttempts) await delay(THREADS.retryMs, undefined, { signal: stop.signal });
|
||||
}
|
||||
completedDate = date;
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch(error => {
|
||||
if (stop.signal.aborted) return;
|
||||
console.error(error instanceof Error ? error.message : 'Threads worker failed');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -25,3 +25,9 @@ export function calculateSun(date: Date, observer: ObserverConfig): SunState {
|
||||
standardSunrise: standard.sunrise, standardSunset: standard.sunset,
|
||||
};
|
||||
}
|
||||
|
||||
export function sunriseAzimuth(date: Date, observer: ObserverConfig): number {
|
||||
const sunrise = calculateSun(date, observer).sunrise;
|
||||
return sunrise && Number.isFinite(+sunrise)
|
||||
? getPosition(sunrise, observer.latitude, observer.longitude).azimuth : 90;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export const BUILDINGS = {
|
||||
assetManifestUrl: '/buildings/gwacheon.baked.json',
|
||||
assetBufferUrl: '/buildings/gwacheon.baked.bin',
|
||||
assetVersion: 1,
|
||||
tileSize: 1000,
|
||||
metersPerLevel: 3,
|
||||
// Visual placeholders only: not a claim about actual floors.
|
||||
fallbackHeight: { apartments: 15, residential: 9, house: 6, commercial: 12, greenhouse: 3, roof: 3, school: 12, default: 9 } as Record<string, number>,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const CHARACTER = {
|
||||
url: '/characters/red-sheep.glb?v=rounded-v10',
|
||||
scale: 0.62,
|
||||
visibilityRadius: 3, // Includes raised arms, umbrella and idle hops around the ground anchor.
|
||||
selfBadgeGap: 0.12,
|
||||
outline: 0.018,
|
||||
ink: '#352724',
|
||||
lighting: {
|
||||
@@ -12,8 +14,41 @@ export const CHARACTER = {
|
||||
waveSeconds: 3,
|
||||
prayerWindowMs: 5 * 60 * 1000,
|
||||
cameraDistance: 5,
|
||||
cameraMinDistance: 2,
|
||||
cameraMaxDistance: 12,
|
||||
cameraLift: 1.15,
|
||||
cameraClearance: 0.35,
|
||||
cameraTurnRate: 1.8,
|
||||
turnSpeed: 4,
|
||||
walk: {
|
||||
strength: 0.4,
|
||||
rate: 9,
|
||||
stride: 0.36,
|
||||
transitionSeconds: 0.25,
|
||||
sway: 0.026,
|
||||
rise: 0.018,
|
||||
roll: 0.035,
|
||||
twist: 0.055,
|
||||
headLag: 0.45,
|
||||
headFollow: 0.65,
|
||||
headRoll: 0.4,
|
||||
headNod: 0.018,
|
||||
armLag: 0.18,
|
||||
armSwing: 0.65,
|
||||
footLift: 0.035,
|
||||
},
|
||||
idle: {
|
||||
periodSeconds: 3.8,
|
||||
transitionSeconds: 0.3,
|
||||
torsoRise: 0.01,
|
||||
torsoDepth: 0.014,
|
||||
headLift: 0.018,
|
||||
shoulderLift: 0.008,
|
||||
fidget: {
|
||||
intervalSeconds: 30,
|
||||
scratchSeconds: 2.4,
|
||||
hopSeconds: 1,
|
||||
hopHeight: 0.085,
|
||||
hopCrouch: 0.025,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const FLASHLIGHT = {
|
||||
url: '/characters/flashlight.glb?v=1',
|
||||
lumens: 600,
|
||||
poseTransitionSeconds: 0.35,
|
||||
bloomStrengthScale: 0.08,
|
||||
bloomThreshold: 4,
|
||||
bloomRadius: 0.03,
|
||||
distance: 18,
|
||||
halfAngle: 28 * Math.PI / 180,
|
||||
penumbra: 0.6,
|
||||
color: '#fff2dc',
|
||||
// Apparent solar centre below the horizon by roughly one solar radius.
|
||||
nightAltitude: -0.3,
|
||||
} as const;
|
||||
@@ -1 +1,4 @@
|
||||
export const MOVEMENT = { speed: 4, maxDelta: 0.05, dragSensitivity: 0.003, dragThreshold: 6, arrivalDistance: 0.01 } as const;
|
||||
export const MOVEMENT = {
|
||||
speed: 4, maxDelta: 0.05, dragSensitivity: 0.003, dragThreshold: 6, arrivalDistance: 0.01,
|
||||
turnSensitivity: 0.008, wheelSensitivity: 0.002, companionSpacing: 1.2,
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const RAIN_GEAR = {
|
||||
umbrellaUrl: '/characters/umbrella.glb?v=1',
|
||||
umbrellaUrl: '/characters/umbrella.glb?v=2',
|
||||
umbrellaTransitionSeconds: 0.45,
|
||||
umbrellaGripDirection: [.05, -.45, .7],
|
||||
raincoatUrl: '/characters/raincoat.glb?v=5',
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const RENDER_QUALITY = {
|
||||
mobileFps: 30,
|
||||
mobileCap: 1.5,
|
||||
mobilePixelBudget: 1_500_000,
|
||||
levels: [0.75, 1, 1.25, 1.5, 1.75, 2] as readonly number[],
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const SCREENSHOT = {
|
||||
objectUrlLifetimeMs: 60000,
|
||||
} as const;
|
||||
@@ -0,0 +1,5 @@
|
||||
export const SUMMIT_MARKER = {
|
||||
assetRevision: 'e8ea25042407',
|
||||
buriedDepth: 0.12,
|
||||
footHeight: 0.18,
|
||||
} as const;
|
||||
@@ -2,6 +2,7 @@
|
||||
export const SUMMIT_MATERIALS = {
|
||||
baseUrl: '/terrain/materials',
|
||||
anisotropy: 8,
|
||||
minimumRoughness: 0.85,
|
||||
edgeBlend: { innerMeters: -0.5, outerMeters: 0.4, irregularityMeters: 0.16 },
|
||||
rockBlend: { innerMeters: -0.7, outerMeters: 0.5, irregularityMeters: 0.25 },
|
||||
path: { tileMeters: 2, normalStrength: 0.65, tint: '#ffffff' },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ export const SUMMIT_TERRAIN = {
|
||||
start: { latitude: 37.44472, longitude: 126.96422 },
|
||||
coreRadius: 100,
|
||||
outerRadius: 200,
|
||||
plateauBlendMeters: 10,
|
||||
// Looking south leaves the radar to the right, the eastern valley to the left.
|
||||
heading: 190,
|
||||
pitch: -3,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** OSM centerlines; widths and surface boundaries estimated from Naver Oct 2013 panoramas.
|
||||
/** OSM centerlines; photo-estimated widths include 1m of movement clearance on each side.
|
||||
* No public access inferred for the radar facility spur (way/337498785).
|
||||
*/
|
||||
import { SUMMIT_PLATEAU } from './summitPlateau.ts';
|
||||
@@ -18,7 +18,7 @@ export const SUMMIT_TRAILS = {
|
||||
{
|
||||
"id": "way/337498789",
|
||||
"label": "정상 암반길",
|
||||
"width": 3.2,
|
||||
"width": 5.2,
|
||||
"points": [
|
||||
[
|
||||
126.9642427,
|
||||
@@ -65,7 +65,7 @@ export const SUMMIT_TRAILS = {
|
||||
{
|
||||
"id": "way/37751526",
|
||||
"label": "남쪽 하산 계단길",
|
||||
"width": 2.4,
|
||||
"width": 4.4,
|
||||
"points": [
|
||||
[
|
||||
126.9652191,
|
||||
@@ -156,7 +156,7 @@ export const SUMMIT_TRAILS = {
|
||||
{
|
||||
"id": "way/337498787",
|
||||
"label": "동쪽 계단길",
|
||||
"width": 2.2,
|
||||
"width": 4.2,
|
||||
"points": [
|
||||
[
|
||||
126.9646956,
|
||||
@@ -175,7 +175,7 @@ export const SUMMIT_TRAILS = {
|
||||
{
|
||||
"id": "way/337498788",
|
||||
"label": "북쪽 능선길",
|
||||
"width": 2.4,
|
||||
"width": 4.4,
|
||||
"points": [
|
||||
[
|
||||
126.9652188,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export const THREADS = {
|
||||
siteUrl: 'https://sunrise.horoli.kr',
|
||||
apiUrl: 'https://graph.threads.net',
|
||||
prepareMs: 5 * 60_000,
|
||||
captureDelayMs: 5 * 60_000,
|
||||
maxLatenessMs: 30_000,
|
||||
requestTimeoutMs: 30_000,
|
||||
mediaPollMs: 5_000,
|
||||
mediaPollAttempts: 24,
|
||||
tokenRefreshMs: 7 * 86_400_000,
|
||||
captionMaxLength: 500,
|
||||
captionConditions: ['clear', 'partlyCloudy', 'cloudy', 'fog', 'rain', 'snow', 'unknown'],
|
||||
scheduleHourKst: 3,
|
||||
retryAttempts: 5,
|
||||
retryMs: 60_000,
|
||||
jobTimeoutMs: 8 * 60 * 60_000,
|
||||
jobStopGraceMs: 10_000,
|
||||
viewport: { width: 1080, height: 1728 },
|
||||
angles: [
|
||||
{ name: 'sunrise', offset: 0, pitch: 0, alt: '일출을 바라보는 캐릭터의 뒷모습과 관악산 풍경' },
|
||||
{ name: 'portrait', offset: 200, pitch: 20, alt: '일출 시각 관악산 정상의 캐릭터 정면과 하늘' },
|
||||
],
|
||||
} as const;
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
export const DIALOG_TITLES = {
|
||||
menu: '메뉴', location: '장소', time: '날짜·시간', information: '관측 정보',
|
||||
view: '지도·시점', session: '닉네임·접속 상태',
|
||||
settings: '설정·조작 방법', sources: '출처·오픈소스',
|
||||
settings: '설정·조작 방법', sources: '출처·오픈소스', screenshot: '화면 촬영',
|
||||
} as const;
|
||||
|
||||
export const EQUIPMENT_NOTICE = {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { FLASHLIGHT } from '../constants/flashlight.ts';
|
||||
|
||||
/** Auto-enable only on night entry; preserve manual choices during the night. */
|
||||
export function flashlightAfterSunUpdate(on: boolean, previousAltitude: number | undefined, altitude: number) {
|
||||
if (altitude > FLASHLIGHT.nightAltitude) return false;
|
||||
return previousAltitude === undefined || previousAltitude > FLASHLIGHT.nightAltitude ? true : on;
|
||||
}
|
||||
@@ -3,46 +3,94 @@ import { SCENE } from '../constants/scene.ts';
|
||||
import { MOVEMENT } from '../constants/movement.ts';
|
||||
|
||||
export function createFpsCameraController(camera: PerspectiveCamera, canvas: HTMLCanvasElement,
|
||||
pickDestination: (x: number, y: number) => Vector3 | null) {
|
||||
pickDestination: (x: number, y: number) => Vector3 | null,
|
||||
options: { onTurn?: (radians: number) => void; onZoom?: (factor: number) => void;
|
||||
onSelect?: (x: number, y: number) => boolean; onModeChange?: (turning: boolean) => void } = {}) {
|
||||
let destination: Vector3 | null = null;
|
||||
let arrivalYaw: number | undefined;
|
||||
let turning = false;
|
||||
const pointers = new Map<number, { x: number; y: number }>();
|
||||
let pinched = false;
|
||||
const pinchDistance = () => {
|
||||
const [a, b] = [...pointers.values()];
|
||||
return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
|
||||
};
|
||||
let gesture: { id: number; startX: number; startY: number; x: number; y: number; dragged: boolean; canMove: boolean } | null = null;
|
||||
canvas.tabIndex = 0;
|
||||
const cancelGesture = () => {
|
||||
const id = gesture?.id;
|
||||
const ids = [...pointers.keys()];
|
||||
gesture = null;
|
||||
if (id !== undefined && canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
|
||||
pointers.clear(); pinched = false;
|
||||
for (const id of ids) if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
|
||||
};
|
||||
const clear = () => { destination = null; cancelGesture(); };
|
||||
const stop = () => { destination = null; arrivalYaw = undefined; };
|
||||
const blur = () => { stop(); cancelGesture(); };
|
||||
const setTurning = (value: boolean) => {
|
||||
stop(); cancelGesture(); turning = value; options.onModeChange?.(value);
|
||||
};
|
||||
const clear = () => setTurning(false);
|
||||
const down = (event: PointerEvent) => {
|
||||
if (!event.isPrimary || gesture || (event.button !== 0 && event.button !== 2)) return;
|
||||
if (pointers.size >= 2 || (event.pointerType !== 'touch' && (!event.isPrimary || pointers.size))
|
||||
|| (event.button !== 0 && event.button !== 2)) return;
|
||||
event.preventDefault();
|
||||
canvas.focus({ preventScroll: true });
|
||||
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
if (pointers.size > 1) { pinched = true; return; }
|
||||
gesture = { id: event.pointerId, startX: event.clientX, startY: event.clientY,
|
||||
x: event.clientX, y: event.clientY, dragged: false, canMove: event.pointerType !== 'mouse' || event.button === 2 };
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
};
|
||||
const move = (event: PointerEvent) => {
|
||||
if (!pointers.has(event.pointerId)) return;
|
||||
const before = pinchDistance();
|
||||
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
if (pinched) {
|
||||
const after = pinchDistance();
|
||||
if (before > 0 && after > 0) options.onZoom?.(before / after);
|
||||
return;
|
||||
}
|
||||
if (!gesture || gesture.id !== event.pointerId) return;
|
||||
if (Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) > MOVEMENT.dragThreshold) gesture.dragged = true;
|
||||
if (gesture.dragged) {
|
||||
camera.rotation.reorder('YXZ');
|
||||
camera.rotation.y -= (event.clientX - gesture.x) * MOVEMENT.dragSensitivity;
|
||||
camera.rotation.x = MathUtils.clamp(camera.rotation.x - (event.clientY - gesture.y) * MOVEMENT.dragSensitivity,
|
||||
-MathUtils.degToRad(SCENE.pitchLimitDegrees), MathUtils.degToRad(SCENE.pitchLimitDegrees));
|
||||
if (turning) options.onTurn?.((event.clientX - gesture.x) * MOVEMENT.turnSensitivity);
|
||||
else {
|
||||
camera.rotation.reorder('YXZ');
|
||||
camera.rotation.y -= (event.clientX - gesture.x) * MOVEMENT.dragSensitivity;
|
||||
camera.rotation.x = MathUtils.clamp(camera.rotation.x - (event.clientY - gesture.y) * MOVEMENT.dragSensitivity,
|
||||
-MathUtils.degToRad(SCENE.pitchLimitDegrees), MathUtils.degToRad(SCENE.pitchLimitDegrees));
|
||||
}
|
||||
}
|
||||
gesture.x = event.clientX;
|
||||
gesture.y = event.clientY;
|
||||
};
|
||||
const up = (event: PointerEvent) => {
|
||||
if (!pointers.has(event.pointerId)) return;
|
||||
if (pinched) {
|
||||
pointers.delete(event.pointerId);
|
||||
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
|
||||
if (!pointers.size) cancelGesture();
|
||||
return;
|
||||
}
|
||||
if (!gesture || gesture.id !== event.pointerId) return;
|
||||
const tap = !gesture.dragged && Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) <= MOVEMENT.dragThreshold;
|
||||
if (tap && gesture.canMove) destination = pickDestination(event.clientX, event.clientY);
|
||||
if (tap && !turning) {
|
||||
if (options.onSelect?.(event.clientX, event.clientY)) stop();
|
||||
else if (gesture.canMove) { destination = pickDestination(event.clientX, event.clientY); arrivalYaw = undefined; }
|
||||
}
|
||||
cancelGesture();
|
||||
};
|
||||
const cancel = (event: PointerEvent) => { if (gesture?.id === event.pointerId) cancelGesture(); };
|
||||
const cancel = (event: PointerEvent) => { if (pointers.has(event.pointerId)) cancelGesture(); };
|
||||
const wheel = (event: WheelEvent) => {
|
||||
if (!options.onZoom) return;
|
||||
event.preventDefault();
|
||||
options.onZoom(Math.exp(MathUtils.clamp(event.deltaY, -100, 100) * MOVEMENT.wheelSensitivity));
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => { if (event.code === 'Escape') clear(); };
|
||||
const contextMenu = (event: Event) => event.preventDefault();
|
||||
window.addEventListener('blur', clear);
|
||||
canvas.addEventListener('blur', clear);
|
||||
window.addEventListener('keydown', escape);
|
||||
canvas.addEventListener('blur', blur);
|
||||
canvas.addEventListener('wheel', wheel, { passive: false });
|
||||
document.addEventListener('visibilitychange', clear);
|
||||
canvas.addEventListener('pointerdown', down);
|
||||
canvas.addEventListener('pointermove', move);
|
||||
@@ -52,14 +100,19 @@ export function createFpsCameraController(camera: PerspectiveCamera, canvas: HTM
|
||||
canvas.addEventListener('contextmenu', contextMenu);
|
||||
return {
|
||||
get destination() { return destination; },
|
||||
get looking() { return Boolean(gesture?.dragged); },
|
||||
stop() { destination = null; },
|
||||
get arrivalYaw() { return arrivalYaw; },
|
||||
get turning() { return turning; },
|
||||
setTurning,
|
||||
moveTo(point: Vector3, yaw?: number) { setTurning(false); destination = point.clone(); arrivalYaw = yaw; },
|
||||
stop,
|
||||
active() { return document.activeElement === canvas; },
|
||||
clear,
|
||||
dispose() {
|
||||
clear();
|
||||
window.removeEventListener('blur', clear);
|
||||
canvas.removeEventListener('blur', clear);
|
||||
window.removeEventListener('keydown', escape);
|
||||
canvas.removeEventListener('blur', blur);
|
||||
canvas.removeEventListener('wheel', wheel);
|
||||
document.removeEventListener('visibilitychange', clear);
|
||||
canvas.removeEventListener('pointerdown', down);
|
||||
canvas.removeEventListener('pointermove', move);
|
||||
|
||||
@@ -4,6 +4,7 @@ export type RainGearItem = 'umbrella' | 'raincoat' | 'hood';
|
||||
export function createRainGearState() {
|
||||
const worn = { umbrella: false, raincoat: false, hood: false };
|
||||
let available = false;
|
||||
let awaitingArrivalWeather = true;
|
||||
return {
|
||||
get available() { return available; },
|
||||
get umbrella() { return worn.umbrella; },
|
||||
@@ -14,8 +15,12 @@ export function createRainGearState() {
|
||||
worn.raincoat = state.raincoat;
|
||||
worn.hood = state.raincoat && state.hood;
|
||||
},
|
||||
updateWeather(rain: number, snowfall: number) {
|
||||
available = rain > 0 || snowfall > 0;
|
||||
updateWeather(rain: number | null, snowfall: number | null) {
|
||||
available = (rain ?? 0) > 0 || (snowfall ?? 0) > 0;
|
||||
if (awaitingArrivalWeather && rain !== null) {
|
||||
if (rain > 0) { worn.raincoat = true; worn.hood = true; }
|
||||
awaitingArrivalWeather = false;
|
||||
}
|
||||
if (!available) { worn.umbrella = false; worn.raincoat = false; worn.hood = false; }
|
||||
},
|
||||
toggle(item: RainGearItem) {
|
||||
|
||||
@@ -4,9 +4,8 @@ import { CHARACTER } from '../constants/character.ts';
|
||||
const direction = new Vector3();
|
||||
const target = new Vector3();
|
||||
|
||||
/** Face the accepted ground step, while the camera catches up along the shortest yaw arc. */
|
||||
export function faceTravel(character: Object3D, observer: PerspectiveCamera, step: Vector3,
|
||||
delta: number, follow = true) {
|
||||
/** Face the accepted ground step without changing the user's camera orbit. */
|
||||
export function faceTravel(character: Object3D, step: Vector3, delta: number) {
|
||||
if (step.x === 0 && step.z === 0) return 0;
|
||||
const heading = Math.atan2(step.x, step.z);
|
||||
const turn = Math.atan2(Math.sin(heading - character.rotation.y), Math.cos(heading - character.rotation.y));
|
||||
@@ -14,22 +13,17 @@ export function faceTravel(character: Object3D, observer: PerspectiveCamera, ste
|
||||
character.rotation.y += Math.max(-limit, Math.min(limit, turn));
|
||||
// Turn in place for rear destinations; accelerate as the body faces its path.
|
||||
const alignment = Math.max(0, Math.cos(heading - character.rotation.y)) ** 2;
|
||||
if (!follow) return alignment;
|
||||
observer.rotation.reorder('YXZ');
|
||||
const targetYaw = character.rotation.y + Math.PI;
|
||||
const difference = Math.atan2(Math.sin(targetYaw - observer.rotation.y), Math.cos(targetYaw - observer.rotation.y));
|
||||
observer.rotation.y += difference * (1 - Math.exp(-CHARACTER.cameraTurnRate * Math.min(delta, .1)));
|
||||
return alignment;
|
||||
}
|
||||
|
||||
/** Observer stays on the walking surface; only the render camera is offset. */
|
||||
export function updateThirdPersonCamera(camera: PerspectiveCamera, observer: PerspectiveCamera,
|
||||
heightAt: (x: number, z: number) => number) {
|
||||
heightAt: (x: number, z: number) => number, distance: number = CHARACTER.cameraDistance) {
|
||||
observer.getWorldDirection(direction);
|
||||
// Limit vertical orbit so the character remains readable at extreme look angles.
|
||||
direction.y = Math.max(-0.45, Math.min(0.45, direction.y));
|
||||
direction.normalize();
|
||||
camera.position.copy(observer.position).addScaledVector(direction, -CHARACTER.cameraDistance);
|
||||
camera.position.copy(observer.position).addScaledVector(direction, -distance);
|
||||
camera.position.y += CHARACTER.cameraLift;
|
||||
// Sample the boom as well as its endpoint to avoid crossing a rising slope.
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
|
||||
+17
-3
@@ -1,7 +1,8 @@
|
||||
import './style.css';
|
||||
import { createDialogs } from './ui/dialogs.ts';
|
||||
import { createScreenshot } from './ui/screenshot.ts';
|
||||
import { loadSummitAssets } from './scene/summitAssets.ts';
|
||||
import { calculateSun } from './astronomy/sunCalculator.ts';
|
||||
import { calculateSun, sunriseAzimuth } from './astronomy/sunCalculator.ts';
|
||||
import { createWorld } from './scene/world.ts';
|
||||
import { createInformationPanel } from './ui/informationPanel.ts';
|
||||
import { TimeController } from './ui/timeController.ts';
|
||||
@@ -104,6 +105,7 @@ export async function start(options: StartOptions = {}) {
|
||||
update();
|
||||
}, coast, structures, reconstructSummit, summitGeometry, sheep);
|
||||
const viewEvents = new AbortController();
|
||||
const screenshot = createScreenshot(() => world.captureFrame());
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-look]').forEach(button => {
|
||||
button.addEventListener('click', () => world.lookAtStructure(button.dataset.look!), { signal: viewEvents.signal });
|
||||
});
|
||||
@@ -126,8 +128,10 @@ export async function start(options: StartOptions = {}) {
|
||||
world.update(state);
|
||||
};
|
||||
const weatherStatus = document.getElementById('weather-status')!;
|
||||
let captureWeather: WeatherStatus = { status: 'loading', weather: null };
|
||||
const applyWeather = ({ status, weather, source }: WeatherStatus) => {
|
||||
if (import.meta.env.DEV && new URLSearchParams(location.search).has('profile')) return;
|
||||
captureWeather = { status, weather, source };
|
||||
world.setWeather(weather);
|
||||
weatherStatus.textContent = status === 'ready' && weather
|
||||
? `${source} · 운량 ${Math.round(weather.cloudCover)}% · ${weather.visibility === null
|
||||
@@ -151,6 +155,9 @@ export async function start(options: StartOptions = {}) {
|
||||
update();
|
||||
});
|
||||
world.resetPosition();
|
||||
const faceSunrise = () => world.lookAtSunrise(sunriseAzimuth(selectedDate, effectiveObserver));
|
||||
faceSunrise();
|
||||
let firstJoin = true;
|
||||
const connectionStatus = document.getElementById('connection-status')!;
|
||||
const modeButton = document.getElementById('session-mode') as HTMLButtonElement;
|
||||
const retryButton = document.getElementById('retry-connection') as HTMLButtonElement;
|
||||
@@ -166,7 +173,10 @@ export async function start(options: StartOptions = {}) {
|
||||
nickname.value = `방문자-${visitorId.slice(-4)}`;
|
||||
const multiplayer = createMultiplayerClient({
|
||||
readPlayer: () => world.readPlayer(),
|
||||
joined: player => world.applyPlayer(player),
|
||||
joined: player => {
|
||||
world.applyPlayer(player);
|
||||
if (firstJoin) { faceSunrise(); firstJoin = false; }
|
||||
},
|
||||
correction: player => world.applyPlayer(player),
|
||||
snapshot: (state, selfId) => world.synchronizePlayers(state.players, selfId),
|
||||
weather: state => { if (sharedMode) applyWeather(state); },
|
||||
@@ -230,9 +240,13 @@ export async function start(options: StartOptions = {}) {
|
||||
document.querySelector<HTMLFieldSetElement>('#time-controls')!.disabled = false;
|
||||
window.addEventListener('pagehide', (event) => {
|
||||
multiplayer.disconnect();
|
||||
if (!event.persisted) { dialogs.dispose(); viewEvents.abort(); height.dispose(); time.dispose(); weather.dispose(); multiplayer.dispose(); world.dispose(); }
|
||||
if (!event.persisted) { screenshot.dispose(); dialogs.dispose(); viewEvents.abort(); height.dispose(); time.dispose(); weather.dispose(); multiplayer.dispose(); world.dispose(); }
|
||||
});
|
||||
window.addEventListener('pageshow', event => { if (event.persisted && sharedMode) join(); });
|
||||
if (query.get('capture') === 'threads' && !sharedMode) {
|
||||
const { installThreadsCapture } = await import('./ui/threadsCapture.ts');
|
||||
installThreadsCapture(effectiveObserver, world, () => captureWeather);
|
||||
}
|
||||
options.ready?.();
|
||||
} catch (error) {
|
||||
status.textContent = '지형을 불러오지 못했습니다';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Emote } from '../controls/emotes.ts';
|
||||
import type { WeatherStatus } from '../weather/weather.ts';
|
||||
|
||||
export interface Equipment { umbrella: boolean; raincoat: boolean; hood: boolean }
|
||||
export interface Equipment { umbrella: boolean; raincoat: boolean; hood: boolean; flashlight?: boolean }
|
||||
export interface PlayerUpdate {
|
||||
x: number;
|
||||
z: number;
|
||||
|
||||
@@ -1,11 +1,39 @@
|
||||
import { type Object3D, MathUtils, Vector3 } from 'three';
|
||||
import { type Camera, type Object3D, type Raycaster, Frustum, Matrix4, MathUtils, Sphere, Vector3 } from 'three';
|
||||
import type { Sheep } from '../scene/sheep.ts';
|
||||
import type { PlayerState } from './protocol.ts';
|
||||
import { MULTIPLAYER } from '../constants/multiplayer.ts';
|
||||
import { MOVEMENT } from '../constants/movement.ts';
|
||||
import { CHARACTER } from '../constants/character.ts';
|
||||
|
||||
export function createRemotePlayers(template: Sheep, scene: Object3D, heightAt: (x: number, z: number) => number) {
|
||||
const players = new Map<string, { sheep: Sheep; state: PlayerState; from: Vector3; yaw: number; elapsed: number }>();
|
||||
const frustum = new Frustum(), projection = new Matrix4();
|
||||
const bounds = new Sphere(new Vector3(), CHARACTER.visibilityRadius);
|
||||
return {
|
||||
get(id: string) { return players.get(id)?.sheep.group; },
|
||||
pick(ray: Raycaster, maxDistance: number) {
|
||||
let selected: string | undefined;
|
||||
for (const [id, { sheep }] of players) {
|
||||
sheep.group.updateWorldMatrix(true, true);
|
||||
const hit = ray.intersectObject(sheep.group, true).find(hit => {
|
||||
for (let node: Object3D | null = hit.object; node; node = node.parent) if (!node.visible) return false;
|
||||
return true;
|
||||
});
|
||||
if (hit && hit.distance < maxDistance) { maxDistance = hit.distance; selected = id; }
|
||||
}
|
||||
return selected;
|
||||
},
|
||||
beside(id: string, from: Vector3, reachable: (point: Vector3) => boolean) {
|
||||
const target = players.get(id)?.sheep.group;
|
||||
if (!target) return;
|
||||
const yaw = target.rotation.y;
|
||||
const side = new Vector3(Math.cos(yaw), 0, -Math.sin(yaw)).multiplyScalar(MOVEMENT.companionSpacing);
|
||||
const candidates = [target.position.clone().add(side), target.position.clone().sub(side)]
|
||||
.sort((a, b) => a.distanceToSquared(from) - b.distanceToSquared(from));
|
||||
const position = candidates.find(point => reachable(point) && [...players.values()].every(({ sheep }) =>
|
||||
Math.hypot(sheep.group.position.x - point.x, sheep.group.position.z - point.z) >= MOVEMENT.companionSpacing * .9));
|
||||
return position ? { position, yaw } : undefined;
|
||||
},
|
||||
synchronize(states: PlayerState[], selfId: string) {
|
||||
const present = new Set(states.filter(p => p.id !== selfId).map(p => p.id));
|
||||
for (const [id, entry] of players) if (!present.has(id)) { entry.sheep.dispose(); players.delete(id); }
|
||||
@@ -26,21 +54,34 @@ export function createRemotePlayers(template: Sheep, scene: Object3D, heightAt:
|
||||
entry.elapsed = 0;
|
||||
}
|
||||
entry.state = state;
|
||||
entry.sheep.setEquipment(state.equipment.umbrella, state.equipment.raincoat, state.equipment.hood);
|
||||
entry.sheep.setEquipment(state.equipment.umbrella, state.equipment.raincoat, state.equipment.hood, state.equipment.flashlight);
|
||||
}
|
||||
},
|
||||
update(delta: number, serverTime: number) {
|
||||
update(delta: number, serverTime: number, camera?: Camera) {
|
||||
if (camera) {
|
||||
camera.updateMatrixWorld(true);
|
||||
frustum.setFromProjectionMatrix(projection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse));
|
||||
}
|
||||
let changed = false;
|
||||
for (const entry of players.values()) {
|
||||
const { sheep, state, from } = entry;
|
||||
const duration = MULTIPLAYER.interpolationMs / 1000;
|
||||
const interpolating = entry.elapsed < duration;
|
||||
entry.elapsed += Math.max(0, delta);
|
||||
const fraction = Math.min(1, entry.elapsed / duration);
|
||||
const x = MathUtils.lerp(from.x, state.x, fraction), z = MathUtils.lerp(from.z, state.z, fraction);
|
||||
sheep.group.position.set(x, heightAt(x, z), z);
|
||||
const angle = Math.atan2(Math.sin(state.yaw - entry.yaw), Math.cos(state.yaw - entry.yaw));
|
||||
sheep.group.rotation.y = entry.yaw + angle * fraction;
|
||||
if (interpolating) {
|
||||
entry.elapsed = Math.min(duration, entry.elapsed + Math.max(0, delta));
|
||||
const fraction = Math.min(1, entry.elapsed / duration);
|
||||
const x = MathUtils.lerp(from.x, state.x, fraction), z = MathUtils.lerp(from.z, state.z, fraction);
|
||||
if (x !== sheep.group.position.x || z !== sheep.group.position.z) {
|
||||
sheep.group.position.set(x, heightAt(x, z), z);
|
||||
}
|
||||
const angle = Math.atan2(Math.sin(state.yaw - entry.yaw), Math.cos(state.yaw - entry.yaw));
|
||||
sheep.group.rotation.y = entry.yaw + angle * fraction;
|
||||
}
|
||||
bounds.center.copy(sheep.group.position);
|
||||
const visible = !camera || frustum.intersectsSphere(bounds);
|
||||
changed = changed || sheep.group.visible !== visible;
|
||||
sheep.group.visible = visible;
|
||||
if (!visible) continue;
|
||||
const active = sheep.pose(state.emote, Math.max(0, serverTime - state.emoteStartedAt) / 1000,
|
||||
state.moving && serverTime - state.updatedAt < MULTIPLAYER.staleMovementMs, serverTime / 1000);
|
||||
changed = changed || interpolating || active;
|
||||
|
||||
+15
-11
@@ -69,7 +69,7 @@ export async function loadBuildings(): Promise<BuildingData> {
|
||||
|
||||
export function createBuildings(data: BuildingData, terrain: Awaited<ReturnType<typeof loadTerrain>>) {
|
||||
const group = new Group();
|
||||
const geometries: Record<'tagged' | 'levels' | 'fallback', ExtrudeGeometry[]> = { tagged: [], levels: [], fallback: [] };
|
||||
const geometries = { tagged: new Map<string, ExtrudeGeometry[]>(), levels: new Map<string, ExtrudeGeometry[]>(), fallback: new Map<string, ExtrudeGeometry[]>() };
|
||||
const counts = { tagged: 0, levels: 0, fallback: 0 };
|
||||
const lightSources: BuildingLightSource[] = [];
|
||||
const surface = (x: number, z: number) => terrainHeightAt(terrain.metadata, terrain.heights, x, z) - terrain.metadata.observerSurfaceElevation - (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
|
||||
@@ -107,19 +107,23 @@ export function createBuildings(data: BuildingData, terrain: Awaited<ReturnType<
|
||||
geometry.setAttribute('buildingLightHeight', new BufferAttribute(lightHeights, 1));
|
||||
geometry.rotateX(-Math.PI / 2);
|
||||
geometry.translate(x, base, z);
|
||||
geometries[source].push(geometry);
|
||||
const tile = `${Math.floor(x / BUILDINGS.tileSize)},${Math.floor(z / BUILDINGS.tileSize)}`;
|
||||
const bucket = geometries[source].get(tile) ?? [];
|
||||
bucket.push(geometry);
|
||||
geometries[source].set(tile, bucket);
|
||||
counts[source]++;
|
||||
}
|
||||
for (const source of ['tagged', 'levels', 'fallback'] as const) {
|
||||
if (!geometries[source].length) continue;
|
||||
const merged = mergeGeometries(geometries[source], false)!;
|
||||
geometries[source].forEach(g => g.dispose());
|
||||
// Lighting uses world XZ and local height, not UVs. Retain normals and
|
||||
// lighting heights when sharing vertices so facade/roof seams stay sharp.
|
||||
merged.deleteAttribute('uv');
|
||||
const indexed = indexBuildingGeometry(merged);
|
||||
merged.dispose();
|
||||
group.add(createBuildingMesh(source, indexed));
|
||||
for (const bucket of geometries[source].values()) {
|
||||
const merged = mergeGeometries(bucket, false)!;
|
||||
bucket.forEach(g => g.dispose());
|
||||
// Lighting uses world XZ and local height, not UVs. Retain normals and
|
||||
// lighting heights when sharing vertices so facade/roof seams stay sharp.
|
||||
merged.deleteAttribute('uv');
|
||||
const indexed = indexBuildingGeometry(merged);
|
||||
merged.dispose();
|
||||
group.add(createBuildingMesh(source, indexed));
|
||||
}
|
||||
}
|
||||
const target = structurePosition(BUILDINGS.target.latitude, BUILDINGS.target.longitude, terrain.metadata.origin);
|
||||
target.y = surface(target.x, target.z) + 20;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { SpotLight, Vector3, type Object3D } from 'three';
|
||||
import { FLASHLIGHT } from '../constants/flashlight.ts';
|
||||
|
||||
/** Convert nominal lumens into candela over the beam cone. Distance is a render cutoff. */
|
||||
export function createFlashlight(player: Object3D) {
|
||||
const grip = player.getObjectByName('HeldFlashlight')!;
|
||||
const light = new SpotLight(FLASHLIGHT.color, 0, FLASHLIGHT.distance, FLASHLIGHT.halfAngle, FLASHLIGHT.penumbra, 2);
|
||||
const solidAngle = 2 * Math.PI * (1 - Math.cos(FLASHLIGHT.halfAngle));
|
||||
light.intensity = FLASHLIGHT.lumens / solidAngle;
|
||||
light.visible = false;
|
||||
const tip = new Vector3(), aim = new Vector3();
|
||||
return {
|
||||
light,
|
||||
update(on: boolean) {
|
||||
light.visible = on;
|
||||
if (!on) return;
|
||||
grip.updateWorldMatrix(true, false);
|
||||
light.position.copy(tip.set(0, -.27, 0).applyMatrix4(grip.matrixWorld));
|
||||
light.target.position.copy(aim.set(0, -1.27, 0).applyMatrix4(grip.matrixWorld));
|
||||
},
|
||||
dispose() { light.removeFromParent(); light.target.removeFromParent(); light.dispose(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Pace the whole update loop while retaining real elapsed time between accepted frames. */
|
||||
export function createFrameClock(fps: number) {
|
||||
const interval = 1000 / fps;
|
||||
let previous: number | undefined;
|
||||
let next = 0;
|
||||
return {
|
||||
reset() { previous = undefined; next = 0; },
|
||||
tick(now: number): number | undefined {
|
||||
if (previous !== undefined && now + .001 < next) return;
|
||||
const delta = previous === undefined ? 0 : Math.max(0, now - previous) / 1000;
|
||||
next = previous === undefined || !interval ? now + interval
|
||||
: next + (Math.floor(Math.max(0, now + .001 - next) / interval) + 1) * interval;
|
||||
previous = now;
|
||||
return delta;
|
||||
},
|
||||
};
|
||||
}
|
||||
+46
-27
@@ -13,6 +13,9 @@ import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
|
||||
import { SCENE } from '../constants/scene.ts';
|
||||
import { TERRAIN } from '../constants/terrain.ts';
|
||||
import { LOBBY_CHARACTER, LOBBY_SUNRISE } from '../constants/lobby.ts';
|
||||
import { RENDER_QUALITY } from '../constants/renderQuality.ts';
|
||||
import { createFrameClock } from './frameClock.ts';
|
||||
import { renderPixelRatio } from './adaptiveResolution.ts';
|
||||
|
||||
/** Same Gwanaksan DSM, summit correction, sky and solar disc as the observation scene. */
|
||||
export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
||||
@@ -59,27 +62,34 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
||||
characterCamera.position.z = 5;
|
||||
foreground.add(sheep.group);
|
||||
const head = sheep.group.getObjectByName('Head')!;
|
||||
const headRest = head.quaternion.clone();
|
||||
let progress = matchMedia('(prefers-reduced-motion: reduce)').matches ? 1 : 0;
|
||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const mobile = matchMedia('(pointer: coarse)').matches;
|
||||
const frameClock = createFrameClock(mobile ? RENDER_QUALITY.mobileFps : Infinity);
|
||||
let progress = reducedMotion ? 1 : 0;
|
||||
let frame = 0;
|
||||
let elapsed = 0;
|
||||
let lastFrame = 0;
|
||||
let previousProgress = -1;
|
||||
let gazeAngle = 0;
|
||||
const render = () => {
|
||||
const date = new Date(LOBBY_SUNRISE.from + (LOBBY_SUNRISE.to - LOBBY_SUNRISE.from) * progress);
|
||||
const state = calculateSun(date, observer);
|
||||
direction.set(...directionFromAngles(state.azimuth, state.altitude));
|
||||
sky.update(direction, state.altitude);
|
||||
sun.update(direction, state.altitude);
|
||||
nightSky.update(date, observer, state.altitude);
|
||||
const moonAltitude = getMoonPosition(date, observer.latitude, observer.longitude).altitude;
|
||||
const gazeAltitude = MathUtils.lerp(moonAltitude, state.altitude, MathUtils.smoothstep(state.altitude, -8, 0));
|
||||
head.quaternion.copy(headRest);
|
||||
head.rotateX(-MathUtils.degToRad(MathUtils.clamp(gazeAltitude, 8, 28)));
|
||||
sheep.updateLighting(Math.max(LOBBY_CHARACTER.minimumLightingAltitude, state.altitude), 1);
|
||||
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(state.altitude, -8, 15));
|
||||
fog.color.copy(night).lerp(dawn, MathUtils.smoothstep(state.altitude, -8, 0));
|
||||
fog.color.lerp(day, MathUtils.smoothstep(state.altitude, 0, 15));
|
||||
renderer.toneMappingExposure = MathUtils.lerp(0.12, LOBBY_SUNRISE.maxExposure, MathUtils.smoothstep(state.altitude, -12, 5));
|
||||
if (progress !== previousProgress) {
|
||||
previousProgress = progress;
|
||||
const date = new Date(LOBBY_SUNRISE.from + (LOBBY_SUNRISE.to - LOBBY_SUNRISE.from) * progress);
|
||||
const state = calculateSun(date, observer);
|
||||
direction.set(...directionFromAngles(state.azimuth, state.altitude));
|
||||
sky.update(direction, state.altitude);
|
||||
sun.update(direction, state.altitude);
|
||||
nightSky.update(date, observer, state.altitude);
|
||||
const moonAltitude = getMoonPosition(date, observer.latitude, observer.longitude).altitude;
|
||||
const gazeAltitude = MathUtils.lerp(moonAltitude, state.altitude, MathUtils.smoothstep(state.altitude, -8, 0));
|
||||
gazeAngle = -MathUtils.degToRad(MathUtils.clamp(gazeAltitude, 8, 28));
|
||||
sheep.updateLighting(Math.max(LOBBY_CHARACTER.minimumLightingAltitude, state.altitude), 1);
|
||||
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(state.altitude, -8, 15));
|
||||
fog.color.copy(night).lerp(dawn, MathUtils.smoothstep(state.altitude, -8, 0));
|
||||
fog.color.lerp(day, MathUtils.smoothstep(state.altitude, 0, 15));
|
||||
renderer.toneMappingExposure = MathUtils.lerp(0.12, LOBBY_SUNRISE.maxExposure, MathUtils.smoothstep(state.altitude, -12, 5));
|
||||
}
|
||||
sheep.pose('idle', 0, false, elapsed / 1000);
|
||||
head.rotateX(gazeAngle);
|
||||
renderer.render(scene, camera);
|
||||
const { clientWidth: width, clientHeight: height } = canvas;
|
||||
const size = Math.min(LOBBY_CHARACTER.maxSize, width * LOBBY_CHARACTER.widthFraction, height * LOBBY_CHARACTER.heightFraction);
|
||||
@@ -94,19 +104,28 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
||||
const { clientWidth: width, clientHeight: height } = canvas;
|
||||
camera.aspect = width / Math.max(1, height);
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, LOBBY_SUNRISE.maxPixelRatio));
|
||||
renderer.setPixelRatio(renderPixelRatio(devicePixelRatio, LOBBY_SUNRISE.maxPixelRatio, width, height, mobile));
|
||||
renderer.setSize(width, height, false);
|
||||
render();
|
||||
};
|
||||
const tick = (now: number) => {
|
||||
if (disposed) return;
|
||||
if (!document.hidden && lastFrame) elapsed += now - lastFrame;
|
||||
lastFrame = now;
|
||||
progress = MathUtils.clamp((elapsed - LOBBY_SUNRISE.nightHoldMs) / LOBBY_SUNRISE.durationMs, 0, 1);
|
||||
if (!document.hidden) render();
|
||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||
if (disposed || document.hidden) return;
|
||||
const delta = frameClock.tick(now);
|
||||
if (delta !== undefined) {
|
||||
elapsed += delta * 1000;
|
||||
progress = MathUtils.clamp((elapsed - LOBBY_SUNRISE.nightHoldMs) / LOBBY_SUNRISE.durationMs, 0, 1);
|
||||
render();
|
||||
}
|
||||
if (!reducedMotion) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
const visible = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frameClock.reset();
|
||||
if (!document.hidden) {
|
||||
render();
|
||||
if (!reducedMotion) frame = requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
const visible = () => { lastFrame = 0; if (!document.hidden) render(); };
|
||||
cleanup = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', resize);
|
||||
@@ -126,7 +145,7 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
||||
window.addEventListener('resize', resize);
|
||||
document.addEventListener('visibilitychange', visible);
|
||||
resize();
|
||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||
if (!document.hidden && progress < 1) frame = requestAnimationFrame(tick);
|
||||
})();
|
||||
return { ready, dispose() { disposed = true; cleanup?.(); cleanup = undefined; } };
|
||||
}
|
||||
|
||||
+190
-18
@@ -2,12 +2,13 @@ import { BackSide, Group, MathUtils, Mesh, MeshBasicMaterial, Quaternion, Vector
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
import { CHARACTER } from '../constants/character.ts';
|
||||
import { RAIN_GEAR } from '../constants/rainGear.ts';
|
||||
import { FLASHLIGHT } from '../constants/flashlight.ts';
|
||||
import type { Emote } from '../controls/emotes.ts';
|
||||
|
||||
export interface Sheep {
|
||||
group: Group;
|
||||
clone(): Sheep;
|
||||
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp?: boolean): void;
|
||||
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp?: boolean, flashlight?: boolean): void;
|
||||
updateLighting(altitude: number, ambient: number): void;
|
||||
pose(emote: Emote, elapsed: number, moving: boolean, time: number): boolean;
|
||||
dispose(): void;
|
||||
@@ -15,12 +16,12 @@ export interface Sheep {
|
||||
|
||||
export async function loadSheep() {
|
||||
const loader = new GLTFLoader();
|
||||
const [gltf, umbrella, raincoat] = await Promise.all(
|
||||
[CHARACTER.url, RAIN_GEAR.umbrellaUrl, RAIN_GEAR.raincoatUrl].map(url => loader.loadAsync(url)));
|
||||
return createSheep(gltf.scene, { umbrella: umbrella.scene, raincoat: raincoat.scene });
|
||||
const [gltf, umbrella, raincoat, flashlight] = await Promise.all(
|
||||
[CHARACTER.url, RAIN_GEAR.umbrellaUrl, RAIN_GEAR.raincoatUrl, FLASHLIGHT.url].map(url => loader.loadAsync(url)));
|
||||
return createSheep(gltf.scene, { umbrella: umbrella.scene, raincoat: raincoat.scene, flashlight: flashlight.scene });
|
||||
}
|
||||
|
||||
export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Group }): Sheep {
|
||||
export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Group; flashlight?: Group }): Sheep {
|
||||
const group = new Group();
|
||||
group.name = 'PlayerSheep';
|
||||
group.add(model);
|
||||
@@ -32,11 +33,22 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
||||
return [name, joint];
|
||||
})) as Record<string, Object3D>;
|
||||
const rest = Object.fromEntries(Object.entries(joints).map(([name, joint]) => [name, joint.quaternion.clone()]));
|
||||
if (gear?.flashlight) {
|
||||
gear.flashlight.name = 'HeldFlashlight';
|
||||
gear.flashlight.position.set(0, -.52, 0);
|
||||
joints.ArmLeft.add(gear.flashlight);
|
||||
gear.flashlight.visible = false;
|
||||
}
|
||||
const coatParts: Object3D[] = [];
|
||||
let hood: Object3D | undefined;
|
||||
let foldedHood: Object3D | undefined;
|
||||
if (gear) {
|
||||
model.add(gear.umbrella, gear.raincoat);
|
||||
// Preserve the authored overhead position in the held pose, then let the hand carry it.
|
||||
joints.ArmRight.quaternion.setFromUnitVectors(new Vector3(0, -1, 0),
|
||||
new Vector3(...RAIN_GEAR.umbrellaGripDirection).normalize()).multiply(rest.ArmRight);
|
||||
joints.ArmRight.attach(gear.umbrella);
|
||||
joints.ArmRight.quaternion.copy(rest.ArmRight);
|
||||
hood = gear.raincoat.getObjectByName('CoatHood');
|
||||
foldedHood = gear.raincoat.getObjectByName('CoatFoldedHood');
|
||||
if (!hood || !foldedHood) throw new Error('우비 후드 모델 누락');
|
||||
@@ -92,6 +104,14 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
||||
}
|
||||
for (const source of converted.keys()) source.dispose();
|
||||
const baseColors = new Map([...materials].map(material => [material, material.color.clone()]));
|
||||
const body = model.getObjectByName('Body');
|
||||
const breathingParts = [body, gear?.raincoat, joints.Head, joints.ArmLeft, joints.ArmRight]
|
||||
.filter((part): part is Object3D => Boolean(part))
|
||||
.map(part => ({ part, position: part.position.clone(), scale: part.scale.clone(), quaternion: part.quaternion.clone() }));
|
||||
const legPositions = [joints.LegLeft.position.clone(), joints.LegRight.position.clone()];
|
||||
const bodyRestPosition = body?.position.clone();
|
||||
const animateIdle = typeof matchMedia === 'undefined' || !matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const modelRestPosition = model.position.clone();
|
||||
const originals: Object3D[] = [];
|
||||
group.traverse(object => originals.push(object));
|
||||
function bindRig(group: Group, ownsAssets: boolean): Sheep {
|
||||
@@ -101,9 +121,28 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
||||
const rigJoints = Object.fromEntries(['Head', 'ArmLeft', 'ArmRight', 'LegLeft', 'LegRight']
|
||||
.map(name => [name, group.getObjectByName(name)!]));
|
||||
const joints = rigJoints;
|
||||
const jointEntries = Object.entries(joints);
|
||||
const rigCoatParts = coatParts.map(part => copy(part)!);
|
||||
const rigHood = copy(hood), rigFoldedHood = copy(foldedHood), rigUmbrella = copy(gear?.umbrella);
|
||||
const rigFlashlight = copy(gear?.flashlight);
|
||||
const rigBreathingParts = breathingParts.map(rest => ({ ...rest, part: copy(rest.part)! }));
|
||||
const rigBody = copy(body), rigCoat = copy(gear?.raincoat);
|
||||
const rigModel = copy(model)!;
|
||||
const breathPhase = Math.random() * Math.PI * 2;
|
||||
const fidget = CHARACTER.idle.fidget;
|
||||
let fidgetWait: number = fidget.intervalSeconds;
|
||||
let fidgetTime = 0;
|
||||
let fidgetAction: 'scratch' | 'hop' | undefined;
|
||||
let idleWeight = 0;
|
||||
let walkWeight = 0;
|
||||
const walk = CHARACTER.walk;
|
||||
const torsoRotation = new Quaternion();
|
||||
const coatOffset = new Vector3();
|
||||
let flashlightOn = false;
|
||||
let flashlightPose = 0;
|
||||
let previousPoseTime: number | undefined;
|
||||
let umbrellaOpen = false;
|
||||
let umbrellaPose = 0;
|
||||
const rotation = new Quaternion();
|
||||
const down = new Vector3(0, -1, 0);
|
||||
const aim = new Vector3();
|
||||
@@ -117,9 +156,11 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
||||
return {
|
||||
group,
|
||||
clone() { return bindRig(group.clone(true), false); },
|
||||
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp = false) {
|
||||
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp = false, flashlight = false) {
|
||||
flashlightOn = flashlight;
|
||||
if (rigFlashlight) rigFlashlight.visible = flashlight || flashlightPose > 0;
|
||||
umbrellaOpen = umbrella;
|
||||
if (rigUmbrella) rigUmbrella.visible = umbrella;
|
||||
if (rigUmbrella) rigUmbrella.visible = umbrella || umbrellaPose > 0;
|
||||
rigCoatParts.forEach(part => { part.visible = raincoat; });
|
||||
if (rigHood) rigHood.visible = raincoat && hoodUp;
|
||||
if (rigFoldedHood) rigFoldedHood.visible = raincoat && !hoodUp;
|
||||
@@ -133,26 +174,157 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
||||
for (const [material, color] of baseColors) material.color.copy(color).multiplyScalar(brightness);
|
||||
},
|
||||
pose(emote: Emote, elapsed: number, moving: boolean, time: number) {
|
||||
const changed = moving || emote !== 'idle' || previousMoving !== moving || previousEmote !== emote;
|
||||
const delta = Math.max(0, time - (previousPoseTime ?? time));
|
||||
const previousWalkWeight = walkWeight;
|
||||
walkWeight = emote === 'idle' ? MathUtils.damp(walkWeight, Number(moving), 1 / walk.transitionSeconds, delta) : 0;
|
||||
if (!moving && walkWeight < .001) walkWeight = 0;
|
||||
const idle = animateIdle && !moving && emote === 'idle';
|
||||
rigModel.position.copy(modelRestPosition);
|
||||
if (!idle) {
|
||||
fidgetWait = fidget.intervalSeconds;
|
||||
fidgetTime = 0;
|
||||
fidgetAction = undefined;
|
||||
} else {
|
||||
// Do not fast-forward gestures after a background tab resumes.
|
||||
const idleDelta = Math.min(delta, .1);
|
||||
fidgetWait -= idleDelta;
|
||||
if (fidgetWait <= 0) {
|
||||
fidgetAction = Math.random() < .5 ? 'scratch' : 'hop';
|
||||
fidgetTime = 0;
|
||||
fidgetWait += fidget.intervalSeconds;
|
||||
} else if (fidgetAction) {
|
||||
fidgetTime += idleDelta;
|
||||
const duration = fidgetAction === 'scratch' ? fidget.scratchSeconds : fidget.hopSeconds;
|
||||
if (fidgetTime >= duration) {
|
||||
fidgetAction = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
const previousIdleWeight = idleWeight;
|
||||
const idleStep = delta / CHARACTER.idle.transitionSeconds;
|
||||
idleWeight = idle ? Math.min(1, idleWeight + idleStep) : Math.max(0, idleWeight - idleStep);
|
||||
const breath = (1 - Math.cos(time * Math.PI * 2 / CHARACTER.idle.periodSeconds + breathPhase)) * .5
|
||||
* MathUtils.smoothstep(idleWeight, 0, 1);
|
||||
for (const rest of rigBreathingParts) {
|
||||
const { part } = rest;
|
||||
part.position.copy(rest.position);
|
||||
part.scale.copy(rest.scale);
|
||||
if (part === rigBody || part === rigCoat) {
|
||||
part.quaternion.copy(rest.quaternion);
|
||||
part.scale.y *= 1 + breath * CHARACTER.idle.torsoRise;
|
||||
part.scale.z *= 1 + breath * CHARACTER.idle.torsoDepth;
|
||||
// The coat is authored in model coordinates; expand about the torso's pivot.
|
||||
if (part === rigCoat && bodyRestPosition) part.position.y += -bodyRestPosition.y * breath * CHARACTER.idle.torsoRise;
|
||||
} else {
|
||||
part.position.y += breath * (part === joints.Head ? CHARACTER.idle.headLift : CHARACTER.idle.shoulderLift);
|
||||
}
|
||||
}
|
||||
const step = delta / FLASHLIGHT.poseTransitionSeconds;
|
||||
previousPoseTime = time;
|
||||
const previousFlashlightPose = flashlightPose;
|
||||
flashlightPose = flashlightOn ? Math.min(1, flashlightPose + step) : Math.max(0, flashlightPose - step);
|
||||
const previousUmbrellaPose = umbrellaPose;
|
||||
const umbrellaStep = delta / RAIN_GEAR.umbrellaTransitionSeconds;
|
||||
umbrellaPose = umbrellaOpen ? Math.min(1, umbrellaPose + umbrellaStep) : Math.max(0, umbrellaPose - umbrellaStep);
|
||||
const carryingUmbrella = umbrellaOpen || umbrellaPose > 0;
|
||||
const changed = idle || idleWeight > 0 || previousIdleWeight > 0
|
||||
|| walkWeight > 0 || previousWalkWeight > 0
|
||||
|| moving || emote !== 'idle' || previousMoving !== moving || previousEmote !== emote
|
||||
|| previousFlashlightPose !== flashlightPose || flashlightPose !== Number(flashlightOn)
|
||||
|| previousUmbrellaPose !== umbrellaPose || umbrellaPose !== Number(umbrellaOpen);
|
||||
previousMoving = moving;
|
||||
previousEmote = emote;
|
||||
for (const [name, joint] of Object.entries(joints)) joint.quaternion.copy(rest[name]);
|
||||
for (const [name, joint] of jointEntries) joint.quaternion.copy(rest[name]);
|
||||
joints.LegLeft.position.copy(legPositions[0]);
|
||||
joints.LegRight.position.copy(legPositions[1]);
|
||||
if (emote === 'wave') {
|
||||
pointArm(umbrellaOpen ? 'ArmLeft' : 'ArmRight',
|
||||
aim.set(umbrellaOpen ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15));
|
||||
pointArm(carryingUmbrella ? 'ArmLeft' : 'ArmRight',
|
||||
aim.set(carryingUmbrella ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15));
|
||||
} else if (emote === 'pray') {
|
||||
pointArm('ArmLeft', aim.set(.68, -.22, .64));
|
||||
pointArm('ArmRight', aim.set(-.68, -.22, .64));
|
||||
joints.Head.rotateX(.16);
|
||||
} else if (moving) {
|
||||
const stride = Math.sin(time * 9) * .4;
|
||||
joints.LegLeft.rotateX(stride);
|
||||
joints.LegRight.rotateX(-stride);
|
||||
joints.ArmLeft.rotateX(-stride * .6);
|
||||
joints.ArmRight.rotateX(stride * .6);
|
||||
} else if (moving || walkWeight > 0) {
|
||||
const phase = time * walk.rate;
|
||||
const swing = Math.sin(phase);
|
||||
const amplitude = walkWeight * walk.strength;
|
||||
const sway = swing * walk.sway * amplitude;
|
||||
const rise = (1 - Math.cos(phase * 2)) * walk.rise * amplitude;
|
||||
if (rigBody) {
|
||||
rigBody.position.x += sway;
|
||||
rigBody.position.y += rise;
|
||||
const bodyRest = rigBreathingParts.find(({ part }) => part === rigBody)!;
|
||||
torsoRotation.copy(bodyRest.quaternion).invert();
|
||||
rigBody.rotateZ(-swing * walk.roll * amplitude);
|
||||
rigBody.rotateY(swing * walk.twist * amplitude);
|
||||
torsoRotation.premultiply(rigBody.quaternion);
|
||||
if (rigCoat && bodyRestPosition) {
|
||||
// The coat is authored at the model origin; rotate it about the torso pivot.
|
||||
coatOffset.copy(rigCoat.position).sub(bodyRestPosition).applyQuaternion(torsoRotation);
|
||||
rigCoat.position.copy(coatOffset).add(bodyRestPosition);
|
||||
rigCoat.position.x += sway;
|
||||
rigCoat.position.y += rise;
|
||||
rigCoat.quaternion.premultiply(torsoRotation);
|
||||
}
|
||||
}
|
||||
for (const [side, sign] of [['Left', 1], ['Right', -1]] as const) {
|
||||
const leg = joints[`Leg${side}`], arm = joints[`Arm${side}`];
|
||||
leg.rotateX(swing * sign * walk.stride * walkWeight);
|
||||
leg.position.y += Math.max(0, -Math.cos(phase) * sign) ** 2 * walk.footLift * amplitude;
|
||||
arm.position.x += sway;
|
||||
arm.position.y += rise;
|
||||
arm.rotateX(-Math.sin(phase - walk.armLag) * sign * walk.stride * walk.armSwing * walkWeight);
|
||||
arm.rotateZ(-swing * walk.roll * amplitude);
|
||||
}
|
||||
joints.Head.position.x += Math.sin(phase - walk.headLag) * walk.sway * walk.headFollow * amplitude;
|
||||
joints.Head.position.y += (1 - Math.cos(phase * 2 - walk.headLag)) * walk.rise * walk.headFollow * amplitude;
|
||||
joints.Head.rotateZ(Math.sin(phase - walk.headLag) * walk.roll * walk.headRoll * amplitude);
|
||||
joints.Head.rotateX(Math.sin(phase * 2 - walk.headLag) * walk.headNod * amplitude);
|
||||
} else if (fidgetAction === 'scratch') {
|
||||
const blend = MathUtils.smoothstep(fidgetTime, 0, .4)
|
||||
* (1 - MathUtils.smoothstep(fidgetTime, fidget.scratchSeconds - .4, fidget.scratchSeconds));
|
||||
const left = carryingUmbrella;
|
||||
const arm = joints[left ? 'ArmLeft' : 'ArmRight'];
|
||||
// Only scratch with an empty hand, including while equipment blends out.
|
||||
if (!left || (!flashlightOn && flashlightPose === 0)) {
|
||||
pointArm(left ? 'ArmLeft' : 'ArmRight', aim.set(left ? -.3 : .3, .95, .12 + Math.sin(fidgetTime * 22) * .12));
|
||||
arm.quaternion.slerp(rest[left ? 'ArmLeft' : 'ArmRight'], 1 - blend);
|
||||
joints.Head.rotateZ((left ? -.1 : .1) * blend);
|
||||
}
|
||||
} else if (fidgetAction === 'hop') {
|
||||
const progress = fidgetTime / fidget.hopSeconds * 2;
|
||||
const phase = progress % 1;
|
||||
const strength = progress < 1 ? 1 : .7;
|
||||
// Each small hop has a planted preparation, a short flight and a soft landing.
|
||||
const flight = MathUtils.clamp((phase - .24) / .48, 0, 1);
|
||||
const bounce = 4 * flight * (1 - flight) * strength;
|
||||
const crouch = (phase < .24 ? Math.sin(phase / .24 * Math.PI)
|
||||
: phase > .72 ? Math.sin((phase - .72) / .28 * Math.PI) : 0) * strength;
|
||||
rigModel.position.y += bounce * fidget.hopHeight;
|
||||
for (const { part } of rigBreathingParts) {
|
||||
if (part === rigBody || part === rigCoat) {
|
||||
part.scale.y *= 1 - crouch * fidget.hopCrouch;
|
||||
if (part === rigCoat && bodyRestPosition) part.position.y += bodyRestPosition.y * crouch * fidget.hopCrouch;
|
||||
} else {
|
||||
part.position.y -= crouch * fidget.hopCrouch;
|
||||
}
|
||||
}
|
||||
joints.LegLeft.rotateX(-bounce * .1);
|
||||
joints.LegRight.rotateX(-bounce * .1);
|
||||
joints.ArmLeft.rotateZ(-bounce * .08);
|
||||
joints.ArmRight.rotateZ(bounce * .08);
|
||||
}
|
||||
// Keep the grip stable while the free arm and legs perform their own animations.
|
||||
if (umbrellaOpen) pointArm('ArmRight', aim.set(.05, -.45, .7));
|
||||
if (umbrellaPose > 0) {
|
||||
rotation.setFromUnitVectors(down, aim.set(...RAIN_GEAR.umbrellaGripDirection).normalize()).multiply(rest.ArmRight);
|
||||
joints.ArmRight.quaternion.slerp(rotation, MathUtils.smoothstep(umbrellaPose, 0, 1));
|
||||
}
|
||||
if (rigUmbrella) rigUmbrella.visible = carryingUmbrella;
|
||||
if (flashlightPose > 0) {
|
||||
rotation.setFromUnitVectors(down, aim.set(-.08, -.22, 1).normalize()).multiply(rest.ArmLeft);
|
||||
joints.ArmLeft.quaternion.slerp(rotation, MathUtils.smoothstep(flashlightPose, 0, 1));
|
||||
}
|
||||
if (rigFlashlight) rigFlashlight.visible = flashlightOn || flashlightPose > 0;
|
||||
return changed;
|
||||
},
|
||||
dispose() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
import { STRUCTURES, STRUCTURE_ASSETS } from '../constants/structures.ts';
|
||||
import { SUMMIT_RELIEF } from '../constants/summitRelief.ts';
|
||||
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
|
||||
import { SUMMIT_MARKER } from '../constants/summitMarker.ts';
|
||||
import { summitPosition } from '../controls/summitCoordinates.ts';
|
||||
|
||||
export type StructureAssets = Map<string, Group>;
|
||||
@@ -14,7 +15,8 @@ export async function loadStructures(includeRelief = false): Promise<StructureAs
|
||||
const ids = [...STRUCTURES.towers.map(s => s.id), 'weather-radar', ...STRUCTURES.decks.map(s => s.id),
|
||||
...(includeRelief ? [SUMMIT_RELIEF.assetId, SUMMIT_PLATEAU.marker.assetId] : [])];
|
||||
return new Map(await Promise.all(ids.map(async id => {
|
||||
const model = await loader.loadAsync(STRUCTURE_ASSETS.baseUrl + '/' + id + '.glb');
|
||||
const revision = id === SUMMIT_PLATEAU.marker.assetId ? `?v=${SUMMIT_MARKER.assetRevision}` : '';
|
||||
const model = await loader.loadAsync(STRUCTURE_ASSETS.baseUrl + '/' + id + '.glb' + revision);
|
||||
return [id, model.scene] as const;
|
||||
})));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Group, Mesh, type Material, type BufferGeometry, type MeshStandardMaterial } from 'three';
|
||||
import { Box3, Group, Mesh, Vector3, type Material, type BufferGeometry, type MeshStandardMaterial } from 'three';
|
||||
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
|
||||
import { SUMMIT_MARKER } from '../constants/summitMarker.ts';
|
||||
|
||||
export function createSummitMarker(asset: Group, start: {x: number; z: number},
|
||||
groundAt: (x: number, z: number) => number, rockMaterial: MeshStandardMaterial) {
|
||||
@@ -7,6 +8,23 @@ export function createSummitMarker(asset: Group, start: {x: number; z: number},
|
||||
group.name = SUMMIT_PLATEAU.marker.assetId;
|
||||
const [x,z] = SUMMIT_PLATEAU.marker.offset;
|
||||
group.position.set(start.x+x, groundAt(start.x+x,start.z+z), start.z+z);
|
||||
group.add(asset);
|
||||
group.updateMatrixWorld(true);
|
||||
const base = new Box3().setFromObject(asset).min.y;
|
||||
const point = new Vector3();
|
||||
let seatingOffset = Infinity;
|
||||
asset.traverse(object => {
|
||||
if (!(object instanceof Mesh)) return;
|
||||
const positions = object.geometry.getAttribute('position');
|
||||
for (let i=0; i<positions.count; i++) {
|
||||
point.fromBufferAttribute(positions,i).applyMatrix4(object.matrixWorld);
|
||||
if (point.y-base > SUMMIT_MARKER.footHeight) continue;
|
||||
seatingOffset = Math.min(seatingOffset,
|
||||
groundAt(point.x,point.z)-SUMMIT_MARKER.buriedDepth-point.y);
|
||||
}
|
||||
});
|
||||
if (Number.isFinite(seatingOffset)) group.position.y += seatingOffset;
|
||||
group.updateMatrixWorld(true);
|
||||
const granite = rockMaterial.clone();
|
||||
granite.vertexColors = false;
|
||||
const materials = new Set<Material>([granite]);
|
||||
@@ -17,7 +35,6 @@ export function createSummitMarker(asset: Group, start: {x: number; z: number},
|
||||
(Array.isArray(object.material) ? object.material : [object.material]).forEach(m => materials.add(m));
|
||||
if (object.name.replaceAll('_',' ') === 'Marker granite body') object.material = granite;
|
||||
});
|
||||
group.add(asset);
|
||||
return { group, dispose() {
|
||||
geometries.forEach(g => g.dispose());
|
||||
materials.forEach(m => m.dispose());
|
||||
|
||||
@@ -45,8 +45,20 @@ export function applySummitTerrain(geometry: BufferGeometry, meta: TerrainMetada
|
||||
const fade = 1 - t * t * (3 - 2 * t);
|
||||
positions.setY(i, positions.getY(i) + weighted / weights * fade);
|
||||
const authored = authoredHeight(x - start.x, z - start.z);
|
||||
if (authored !== undefined) positions.setY(i, authored - meta.observerSurfaceElevation
|
||||
- (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters));
|
||||
if (authored !== undefined) {
|
||||
// Fade the sculpt into the corrected terrain before its rectangular edge.
|
||||
// This also suppresses discontinuities in the exported outermost samples.
|
||||
const localX = x - start.x, localZ = z - start.z;
|
||||
const smooth = (distance: number) => {
|
||||
const t = Math.min(1, Math.max(0, distance / SUMMIT_TERRAIN.plateauBlendMeters));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
const blend = smooth(Math.min(localX - xs[0], xs.at(-1)! - localX))
|
||||
* smooth(Math.min(localZ - zs[0], zs.at(-1)! - localZ));
|
||||
const target = authored - meta.observerSurfaceElevation
|
||||
- (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
|
||||
positions.setY(i, positions.getY(i) + (target - positions.getY(i)) * blend);
|
||||
}
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
@@ -43,6 +43,8 @@ export function createSummitTrails(geometry: BufferGeometry, onTextureLoaded: ()
|
||||
}\n`+shader.fragmentShader;
|
||||
for(const [chunk,map,uv] of [['map_fragment','map','vMapUv'],['normal_fragment_maps','normalMap','vNormalMapUv'],['roughnessmap_fragment','roughnessMap','vRoughnessMapUv']] as const)
|
||||
shader.fragmentShader=shader.fragmentShader.replace(`#include <${chunk}>`,ShaderChunk[chunk].replaceAll(`texture2D( ${map}, ${uv} )`,`rockDetail(${map},${uv})`));
|
||||
shader.fragmentShader=shader.fragmentShader.replace('#include <metalnessmap_fragment>',
|
||||
`roughnessFactor=max(roughnessFactor,${SUMMIT_MATERIALS.minimumRoughness});\n#include <metalnessmap_fragment>`);
|
||||
};
|
||||
rockMaterial.customProgramCacheKey=()=>`route-rock-detail-${detail.secondaryScale}-${secondaryBlend}`;
|
||||
// Keep leaf detail near the observer; avoid obvious repeated bands across distant slopes.
|
||||
@@ -114,7 +116,8 @@ export function createSummitTrails(geometry: BufferGeometry, onTextureLoaded: ()
|
||||
shader.fragmentShader=shader.fragmentShader.replace('#include <normal_fragment_maps>',
|
||||
ShaderChunk.normal_fragment_maps.replaceAll('texture2D( normalMap, vNormalMapUv )','summitSample(normalMap,trailNormal,rockNormal,vNormalMapUv,1)'));
|
||||
shader.fragmentShader=shader.fragmentShader.replace('#include <roughnessmap_fragment>',
|
||||
ShaderChunk.roughnessmap_fragment.replace('texture2D( roughnessMap, vRoughnessMapUv )','summitSample(roughnessMap,trailRoughness,rockRoughness,vRoughnessMapUv,2)'));
|
||||
ShaderChunk.roughnessmap_fragment.replace('texture2D( roughnessMap, vRoughnessMapUv )','summitSample(roughnessMap,trailRoughness,rockRoughness,vRoughnessMapUv,2)')
|
||||
+ `\nroughnessFactor=max(roughnessFactor,${SUMMIT_MATERIALS.minimumRoughness});`);
|
||||
};
|
||||
}
|
||||
const mesh=new Mesh(geometry,material);mesh.name=`Summit ${name} PBR surface`;group.add(mesh);
|
||||
|
||||
+234
-34
@@ -9,6 +9,7 @@ import { observer } from '../config/observer.ts';
|
||||
import { SCENE } from '../constants/scene.ts';
|
||||
import { EQUIPMENT_NOTICE, PRAYER_NOTICE } from '../constants/ui.ts';
|
||||
import { MOVEMENT } from '../constants/movement.ts';
|
||||
import { CHARACTER } from '../constants/character.ts';
|
||||
import { createFpsCameraController } from '../controls/fpsCameraController.ts';
|
||||
import { createSky } from './sky.ts';
|
||||
import { createSun } from './sun.ts';
|
||||
@@ -30,6 +31,9 @@ import { COAST } from '../constants/coast.ts';
|
||||
import { GISAMUN } from '../constants/gisamun.ts';
|
||||
import { createHarbour, isGisamun } from './harbour.ts';
|
||||
import { createRenderInvalidation } from './renderInvalidation.ts';
|
||||
import { createFrameClock } from './frameClock.ts';
|
||||
import { renderPixelRatio } from './adaptiveResolution.ts';
|
||||
import { RENDER_QUALITY } from '../constants/renderQuality.ts';
|
||||
import { createTerrainMinimap } from '../ui/terrainMinimap.ts';
|
||||
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
|
||||
import { createSummitWalking } from '../controls/summitWalking.ts';
|
||||
@@ -49,6 +53,11 @@ import { createRainGearState, type RainGearItem } from '../controls/rainGear.ts'
|
||||
import { faceTravel, updateThirdPersonCamera } from '../controls/thirdPersonCamera.ts';
|
||||
import { createRemotePlayers } from '../multiplayer/remotePlayers.ts';
|
||||
import type { PlayerState, PlayerUpdate } from '../multiplayer/protocol.ts';
|
||||
import { createSelfBadge } from '../ui/selfBadge.ts';
|
||||
import { createCharacterOverlay } from '../ui/characterOverlay.ts';
|
||||
import { createFlashlight } from './flashlight.ts';
|
||||
import { FLASHLIGHT } from '../constants/flashlight.ts';
|
||||
import { flashlightAfterSunUpdate } from '../controls/flashlight.ts';
|
||||
|
||||
export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<ReturnType<typeof loadTerrain>>, buildingData: BuildingAssets | null,
|
||||
onMove: (location: { latitude: number; longitude: number; elevation: number }) => void, coast?: LoadedCoast, structureAssets?: StructureAssets, reconstructSummit = false, summitGeometry?: BufferGeometry,
|
||||
@@ -66,10 +75,16 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
const horizon = createHorizon(camera.position, terrainData.metadata.observerSurfaceElevation);
|
||||
const terrain = createTerrain(terrainData, coast, reconstructSummit);
|
||||
const remotePlayers = sheep ? createRemotePlayers(sheep, scene, terrain.heightAt) : undefined;
|
||||
const selfBadge = sheep ? createSelfBadge(sheep.group, canvas) : undefined;
|
||||
const flashlight = sheep ? createFlashlight(sheep.group) : undefined;
|
||||
if (flashlight) scene.add(flashlight.light, flashlight.light.target);
|
||||
let flashlightOn = false;
|
||||
let networkBlocked = false;
|
||||
let sharedNow: (() => number) | null = null;
|
||||
let playerMoving = false;
|
||||
const profiling = import.meta.env.DEV && new URLSearchParams(location.search).has('profile');
|
||||
const mobile = matchMedia('(pointer: coarse)').matches;
|
||||
const frameClock = createFrameClock(mobile && !profiling ? RENDER_QUALITY.mobileFps : Infinity);
|
||||
let profile: ReturnType<typeof createRenderProfile> | undefined;
|
||||
let disposed = false;
|
||||
const summitStarted = performance.now();
|
||||
@@ -160,29 +175,111 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
if (harbour) scene.add(harbour.group);
|
||||
const destinationRay = new Raycaster();
|
||||
const pointer = new Vector2();
|
||||
const controls = createFpsCameraController(camera, canvas, (x, y) => {
|
||||
let cameraDistance: number = CHARACTER.cameraDistance;
|
||||
const followPosition = new Vector3();
|
||||
const followRotation = camera.quaternion.clone();
|
||||
let previousCameraDistance = -1;
|
||||
const updateFollowCamera = () => {
|
||||
if (previousCameraDistance === cameraDistance && followPosition.equals(camera.position)
|
||||
&& followRotation.equals(camera.quaternion)) return;
|
||||
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
|
||||
followPosition.copy(camera.position);
|
||||
followRotation.copy(camera.quaternion);
|
||||
previousCameraDistance = cameraDistance;
|
||||
};
|
||||
let selectedCompanion: string | undefined;
|
||||
let companionAnchor: ReturnType<typeof createCharacterOverlay> | undefined;
|
||||
const directionButton = document.querySelector<HTMLButtonElement>('#direction-toggle')!;
|
||||
const directionHint = document.getElementById('direction-hint')!;
|
||||
const besideButton = document.querySelector<HTMLButtonElement>('#stand-beside')!;
|
||||
const closeCompanion = () => { selectedCompanion = undefined; companionAnchor = undefined; besideButton.hidden = true; };
|
||||
const pickGround = (x: number, y: number) => {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
pointer.set((x - bounds.left) / bounds.width * 2 - 1, -(y - bounds.top) / bounds.height * 2 + 1);
|
||||
if (sheep) updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
|
||||
if (sheep) updateFollowCamera();
|
||||
const view = sheep ? followCamera : camera;
|
||||
view.updateMatrixWorld(true);
|
||||
destinationRay.setFromCamera(pointer, view);
|
||||
const surfaces = harbour ? [terrain.mesh, harbour.group] : [terrain.mesh];
|
||||
const hit = destinationRay.intersectObjects(surfaces, true)[0];
|
||||
return hit?.point ?? null;
|
||||
return destinationRay.intersectObjects(surfaces, true)[0];
|
||||
};
|
||||
const controls = createFpsCameraController(camera, canvas, (x, y) => networkBlocked ? null : pickGround(x, y)?.point ?? null, {
|
||||
onTurn: radians => {
|
||||
if (!sheep || networkBlocked || overview) return;
|
||||
sheep.group.rotation.y += radians;
|
||||
rendering.invalidate();
|
||||
},
|
||||
onZoom: factor => {
|
||||
cameraDistance = MathUtils.clamp(cameraDistance * factor, CHARACTER.cameraMinDistance, CHARACTER.cameraMaxDistance);
|
||||
rendering.invalidate();
|
||||
},
|
||||
onModeChange: turning => {
|
||||
directionButton.setAttribute('aria-pressed', String(turning));
|
||||
directionButton.textContent = turning ? '완료' : '방향 맞추기';
|
||||
directionHint.hidden = !turning;
|
||||
if (turning) { closeCompanion(); emotes.cancel(); playerMoving = false; refreshEmotes(); }
|
||||
rendering.invalidate();
|
||||
},
|
||||
onSelect: (x, y) => {
|
||||
if (networkBlocked) return true;
|
||||
const ground = pickGround(x, y);
|
||||
const id = remotePlayers?.pick(destinationRay, ground?.distance ?? Infinity);
|
||||
closeCompanion();
|
||||
if (!id) return false;
|
||||
selectedCompanion = id;
|
||||
const player = remotePlayers!.get(id)!;
|
||||
companionAnchor = createCharacterOverlay(player, canvas, besideButton);
|
||||
rendering.invalidate();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
if (sheep) scene.add(sheep.group);
|
||||
const emotes = createEmoteState();
|
||||
const emoteEvents = new AbortController();
|
||||
directionButton.addEventListener('click', () => {
|
||||
if (sheep && !networkBlocked && !overview) controls.setTurning(!controls.turning);
|
||||
}, { signal: emoteEvents.signal });
|
||||
besideButton.addEventListener('click', () => {
|
||||
if (!selectedCompanion || networkBlocked || overview) return;
|
||||
const spot = remotePlayers?.beside(selectedCompanion, camera.position, point => {
|
||||
const radius = terrainData.metadata.radiusMeters - 1;
|
||||
if (Math.abs(point.x) > radius || Math.abs(point.z) > radius) return false;
|
||||
if (summitWalking && !summitWalking.canTraverse(camera.position, point)) return false;
|
||||
if (coast) {
|
||||
const steps = Math.max(1, Math.ceil(camera.position.distanceTo(point) / (MOVEMENT.speed * MOVEMENT.maxDelta)));
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const x = MathUtils.lerp(camera.position.x, point.x, i / steps);
|
||||
const z = MathUtils.lerp(camera.position.z, point.z, i / steps);
|
||||
if (!isCoastalLand(coast.data, x, z)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
closeCompanion();
|
||||
if (!spot) return;
|
||||
emotes.cancel(); refreshEmotes();
|
||||
controls.moveTo(spot.position, spot.yaw);
|
||||
}, { signal: emoteEvents.signal });
|
||||
const waveButton = document.querySelector<HTMLButtonElement>('#emote-wave')!;
|
||||
const primaryActionLabel = document.getElementById('primary-action-label')!;
|
||||
const primaryActionIcon = waveButton.querySelector('use')!;
|
||||
const prayButton = document.querySelector<HTMLButtonElement>('#emote-pray')!;
|
||||
const emoteStatus = document.getElementById('emote-status')!;
|
||||
const rainGear = createRainGearState();
|
||||
const umbrellaButton = document.querySelector<HTMLButtonElement>('#gear-umbrella')!;
|
||||
const raincoatButton = document.querySelector<HTMLButtonElement>('#gear-raincoat')!;
|
||||
const hoodButton = document.querySelector<HTMLButtonElement>('#gear-hood')!;
|
||||
const flashlightButton = document.querySelector<HTMLButtonElement>('#gear-flashlight')!;
|
||||
document.getElementById('flashlight-lumens')!.textContent = `${FLASHLIGHT.lumens} lm`;
|
||||
flashlightButton.title = `손전등 · ${FLASHLIGHT.lumens} lm · 단축키 6`;
|
||||
const gearStatus = document.getElementById('gear-status')!;
|
||||
const refreshGear = () => {
|
||||
flashlightButton.disabled = !sheep || overview || networkBlocked;
|
||||
const dark = Boolean(solarState && solarState.altitude <= FLASHLIGHT.nightAltitude);
|
||||
flashlightButton.setAttribute('aria-disabled', String(flashlightButton.disabled || !dark));
|
||||
flashlightButton.dataset.unavailableReason = dark ? '' : '손전등은 해가 진 뒤 사용할 수 있습니다';
|
||||
flashlightButton.setAttribute('aria-pressed', String(flashlightOn));
|
||||
flashlightButton.setAttribute('aria-label', `손전등 ${flashlightOn ? '끄기' : '켜기'} · ${FLASHLIGHT.lumens} lm`);
|
||||
umbrellaButton.disabled = raincoatButton.disabled = hoodButton.disabled = !sheep || overview || networkBlocked;
|
||||
for (const button of [umbrellaButton, raincoatButton, hoodButton]) {
|
||||
const reason = !rainGear.available ? EQUIPMENT_NOTICE.weather
|
||||
@@ -207,22 +304,37 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
};
|
||||
const toggleGear = (item: RainGearItem) => {
|
||||
if (!sheep || overview || networkBlocked || !rainGear.toggle(item)) return;
|
||||
sheep.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood);
|
||||
sheep.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
||||
refreshGear(); rendering.invalidate();
|
||||
};
|
||||
umbrellaButton.addEventListener('click', () => toggleGear('umbrella'), { signal: emoteEvents.signal });
|
||||
raincoatButton.addEventListener('click', () => toggleGear('raincoat'), { signal: emoteEvents.signal });
|
||||
hoodButton.addEventListener('click', () => toggleGear('hood'), { signal: emoteEvents.signal });
|
||||
const toggleFlashlight = () => {
|
||||
if (!sheep || overview || networkBlocked || !solarState || solarState.altitude > FLASHLIGHT.nightAltitude) return;
|
||||
flashlightOn = !flashlightOn;
|
||||
sheep.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
||||
refreshEmotes(); rendering.invalidate();
|
||||
};
|
||||
flashlightButton.addEventListener('click', toggleFlashlight, { signal: emoteEvents.signal });
|
||||
const prayerAvailable = () => Boolean(solarState && canPray(solarState.date, solarState.sunrise));
|
||||
const refreshEmotes = () => {
|
||||
refreshGear();
|
||||
waveButton.disabled = !sheep || overview;
|
||||
directionButton.disabled = besideButton.disabled = !sheep || overview || networkBlocked;
|
||||
const dark = Boolean(solarState && solarState.altitude <= FLASHLIGHT.nightAltitude);
|
||||
waveButton.disabled = !sheep || overview || networkBlocked;
|
||||
primaryActionLabel.textContent = dark ? (flashlightOn ? '끄기' : '켜기') : '인사';
|
||||
primaryActionIcon.setAttribute('href', dark ? '#icon-flashlight' : '#icon-wave');
|
||||
const primaryActionName = dark ? `손전등 ${flashlightOn ? '끄기' : '켜기'}` : '손 흔들기';
|
||||
waveButton.setAttribute('aria-label', primaryActionName);
|
||||
waveButton.title = `${primaryActionName} · 단축키 1`;
|
||||
prayButton.disabled = !sheep || overview;
|
||||
prayButton.setAttribute('aria-disabled', String(prayButton.disabled || !prayerAvailable()));
|
||||
prayButton.dataset.unavailableReason = prayerAvailable() ? '' : PRAYER_NOTICE;
|
||||
waveButton.setAttribute('aria-pressed', String(emotes.current === 'wave'));
|
||||
waveButton.setAttribute('aria-pressed', String(dark ? flashlightOn : emotes.current === 'wave'));
|
||||
prayButton.setAttribute('aria-pressed', String(emotes.current === 'pray'));
|
||||
emoteStatus.textContent = overview ? '조감 보기에서는 감정표현을 사용할 수 없습니다'
|
||||
: dark ? '1 손전등 켜기·끄기 / 2 소원빌기'
|
||||
: emotes.current === 'wave' ? '손 흔드는 중 · 이동하면 종료'
|
||||
: emotes.current === 'pray' ? '일출을 보며 소원 비는 중 · 이동하면 종료'
|
||||
: prayerAvailable() ? '소원빌기 가능 · 1 인사 / 2 소원빌기 · 이동하면 종료'
|
||||
@@ -237,15 +349,21 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
refreshEmotes();
|
||||
rendering.invalidate();
|
||||
};
|
||||
waveButton.addEventListener('click', () => startEmote('wave'), { signal: emoteEvents.signal });
|
||||
const primaryAction = () => {
|
||||
if (solarState && solarState.altitude <= FLASHLIGHT.nightAltitude) toggleFlashlight();
|
||||
else startEmote('wave');
|
||||
};
|
||||
waveButton.addEventListener('click', primaryAction, { signal: emoteEvents.signal });
|
||||
prayButton.addEventListener('click', () => startEmote('pray'), { signal: emoteEvents.signal });
|
||||
window.addEventListener('keydown', event => {
|
||||
if (!controls.active() || event.repeat || !['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5'].includes(event.code)) return;
|
||||
if (!controls.active() || event.repeat || !['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6'].includes(event.code)) return;
|
||||
event.preventDefault();
|
||||
if (event.code === 'Digit6') { toggleFlashlight(); return; }
|
||||
if (['Digit3', 'Digit4', 'Digit5'].includes(event.code)) {
|
||||
toggleGear(event.code === 'Digit3' ? 'umbrella' : event.code === 'Digit4' ? 'raincoat' : 'hood'); return;
|
||||
}
|
||||
startEmote(event.code === 'Digit1' ? 'wave' : 'pray');
|
||||
if (event.code === 'Digit1') primaryAction();
|
||||
else startEmote('pray');
|
||||
}, { signal: emoteEvents.signal });
|
||||
let eyeHeight = observer.eyeHeight;
|
||||
let walkedDistance = 0;
|
||||
@@ -270,7 +388,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
overviewCamera.updateProjectionMatrix();
|
||||
followCamera.aspect = camera.aspect;
|
||||
followCamera.updateProjectionMatrix();
|
||||
renderer.setPixelRatio(profile?.forcedDpr ?? Math.min(devicePixelRatio, SCENE.maxPixelRatio));
|
||||
renderer.setPixelRatio(profile?.forcedDpr ?? renderPixelRatio(devicePixelRatio,
|
||||
mobile ? RENDER_QUALITY.mobileCap : SCENE.maxPixelRatio, innerWidth, innerHeight, mobile));
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
composer?.setPixelRatio(renderer.getPixelRatio());
|
||||
composer?.setSize(innerWidth, innerHeight);
|
||||
@@ -278,13 +397,13 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
};
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
let previousTime = performance.now();
|
||||
const forward = new Vector3();
|
||||
const travel = new Vector3();
|
||||
renderer.setAnimationLoop((now) => {
|
||||
const renderFrame = (now: number) => {
|
||||
if (document.hidden) return;
|
||||
const delta = frameClock.tick(now);
|
||||
if (delta === undefined) return;
|
||||
const cpuStart = profile?.active() ? performance.now() : 0;
|
||||
const delta = (now - previousTime) / 1000;
|
||||
previousTime = now;
|
||||
const replayPosition = profile?.step(now);
|
||||
let moving = false;
|
||||
let turning = false;
|
||||
@@ -303,7 +422,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
if ((next.x !== camera.position.x || next.z !== camera.position.z) && (!coast || isCoastalLand(coast.data, next.x, next.z))) {
|
||||
travel.subVectors(next, camera.position);
|
||||
if (sheep && !replayPosition) {
|
||||
const alignment = faceTravel(sheep.group, camera, travel, delta, !controls.looking);
|
||||
const alignment = faceTravel(sheep.group, travel, delta);
|
||||
turning = alignment < .999;
|
||||
next.copy(camera.position).addScaledVector(travel, alignment);
|
||||
rendering.invalidate();
|
||||
@@ -313,10 +432,17 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
camera.position.copy(next);
|
||||
placeObserver();
|
||||
}
|
||||
if (destination && ((!moving && !turning) || Math.hypot(destination.x-camera.position.x, destination.z-camera.position.z) <= MOVEMENT.arrivalDistance)) controls.stop();
|
||||
if (destination) {
|
||||
const arrived = Math.hypot(destination.x-camera.position.x, destination.z-camera.position.z) <= MOVEMENT.arrivalDistance;
|
||||
if (arrived || (!moving && !turning)) {
|
||||
if (controls.arrivalYaw !== undefined) {
|
||||
if (arrived && sheep) sheep.group.rotation.y = controls.arrivalYaw;
|
||||
}
|
||||
controls.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
playerMoving = moving;
|
||||
if (remotePlayers?.update(delta, sharedNow ? sharedNow() : Date.now())) rendering.invalidate();
|
||||
ocean?.animate(now / 1000);
|
||||
let weatherChanged = false;
|
||||
const blend = 1 - Math.exp(-Math.min(delta, 0.1) / WEATHER.transitionSeconds);
|
||||
@@ -338,12 +464,15 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
forward.set(...directionFromAngles(solarState.azimuth, 0));
|
||||
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
|
||||
} else if (moving && replayPosition) {
|
||||
faceTravel(sheep.group, camera, travel, delta, !controls.looking);
|
||||
faceTravel(sheep.group, travel, delta);
|
||||
}
|
||||
if (sheep.pose(emotes.current, emotes.elapsed, moving, now / 1000)) rendering.invalidate();
|
||||
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
|
||||
updateFollowCamera();
|
||||
}
|
||||
const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera;
|
||||
if (remotePlayers?.update(delta, sharedNow ? sharedNow() : Date.now(), activeCamera)) rendering.invalidate();
|
||||
companionAnchor?.update(activeCamera, !overview);
|
||||
flashlight?.update(flashlightOn && !overview);
|
||||
const precipitating = precipitation.update(delta, activeCamera.position, atmosphere.rain, atmosphere.snowfall,
|
||||
MathUtils.smoothstep(solarState?.altitude ?? -12, -8, 15), canvas.height);
|
||||
if(profile?.active()) rendering.invalidate();
|
||||
@@ -353,25 +482,70 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
compass.update(activeCamera);
|
||||
const minimapEnd = cpuStart ? performance.now() : 0;
|
||||
if (profile) renderer.info.reset();
|
||||
if (composer && !profile?.disablePost) {
|
||||
if (bloom) {
|
||||
const altitude = solarState?.altitude ?? 90;
|
||||
const torchActive = flashlightOn && !overview;
|
||||
bloom.enabled = altitude < BUILDINGS.lighting.fadeEndAltitude;
|
||||
bloom.strength = BUILDINGS.lighting.bloomStrength * (1 - MathUtils.smoothstep(altitude,
|
||||
BUILDINGS.lighting.fadeStartAltitude, BUILDINGS.lighting.fadeEndAltitude))
|
||||
* (torchActive ? FLASHLIGHT.bloomStrengthScale : 1);
|
||||
bloom.threshold = torchActive ? FLASHLIGHT.bloomThreshold : BUILDINGS.lighting.bloomThreshold;
|
||||
bloom.radius = torchActive ? FLASHLIGHT.bloomRadius : BUILDINGS.lighting.bloomRadius;
|
||||
}
|
||||
if (composer && bloom?.enabled && !profile?.disablePost) {
|
||||
renderPass.camera = activeCamera;
|
||||
composer.render(delta);
|
||||
} else renderer.render(scene, activeCamera);
|
||||
selfBadge?.update(activeCamera, sharedNow !== null && !overview);
|
||||
if (cpuStart) profile?.end(now, { update: updateEnd - cpuStart, minimap: minimapEnd - updateEnd,
|
||||
submit: performance.now() - minimapEnd });
|
||||
if (profile?.observe(now, true)) resize();
|
||||
});
|
||||
};
|
||||
const onVisibility = () => {
|
||||
frameClock.reset();
|
||||
rendering.invalidate();
|
||||
renderer.setAnimationLoop(document.hidden ? null : renderFrame);
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
onVisibility();
|
||||
const world = {
|
||||
buildingCounts: buildings?.counts,
|
||||
captureFrame(view?: PerspectiveCamera) {
|
||||
const activeCamera = view ?? (overview ? overviewCamera : sheep ? followCamera : camera);
|
||||
remotePlayers?.update(0, sharedNow ? sharedNow() : Date.now(), activeCamera);
|
||||
rendering.invalidate();
|
||||
if (composer && bloom?.enabled && !profile?.disablePost) {
|
||||
renderPass.camera = activeCamera;
|
||||
composer.render(0);
|
||||
} else renderer.render(scene, activeCamera);
|
||||
// Copy synchronously before WebGL clears the drawing buffer.
|
||||
const frame = document.createElement('canvas');
|
||||
frame.width = canvas.width;
|
||||
frame.height = canvas.height;
|
||||
frame.getContext('2d')!.drawImage(canvas, 0, 0);
|
||||
return frame;
|
||||
},
|
||||
captureAngle(azimuth: number, pitch: number) {
|
||||
// Orbit only the capture camera; keep the character facing the sunrise.
|
||||
const view = camera.clone();
|
||||
view.lookAt(view.position.clone().add(new Vector3(...directionFromAngles(azimuth, pitch))));
|
||||
if (sheep) {
|
||||
const orbit = view.clone();
|
||||
updateThirdPersonCamera(orbit, view, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
|
||||
return world.captureFrame(orbit);
|
||||
}
|
||||
return world.captureFrame(view);
|
||||
},
|
||||
readPlayer(): PlayerUpdate {
|
||||
const yaw = sheep?.group.rotation.y ?? camera.rotation.y;
|
||||
return { x: camera.position.x, z: camera.position.z, yaw: Math.atan2(Math.sin(yaw), Math.cos(yaw)),
|
||||
moving: playerMoving, emote: emotes.current,
|
||||
equipment: { umbrella: rainGear.umbrella, raincoat: rainGear.raincoat, hood: rainGear.hood } };
|
||||
equipment: { umbrella: rainGear.umbrella, raincoat: rainGear.raincoat, hood: rainGear.hood, flashlight: flashlightOn } };
|
||||
},
|
||||
setConnection(blocked: boolean, clock: (() => number) | null) {
|
||||
networkBlocked = blocked; sharedNow = clock;
|
||||
if (blocked) { controls.clear(); playerMoving = false; emotes.cancel(); }
|
||||
if (blocked) { controls.clear(); closeCompanion(); playerMoving = false; emotes.cancel(); }
|
||||
refreshEmotes();
|
||||
rendering.invalidate();
|
||||
},
|
||||
applyPlayer(state: PlayerState) {
|
||||
@@ -379,17 +553,22 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
camera.position.x = state.x; camera.position.z = state.z;
|
||||
if (sheep) sheep.group.rotation.y = state.yaw;
|
||||
rainGear.apply(state.equipment);
|
||||
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood);
|
||||
flashlightOn = Boolean((state.equipment.flashlight ?? flashlightOn) && solarState && solarState.altitude <= FLASHLIGHT.nightAltitude);
|
||||
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
||||
placeObserver(); refreshEmotes(); rendering.invalidate();
|
||||
},
|
||||
synchronizePlayers(players: PlayerState[], selfId: string) {
|
||||
remotePlayers?.synchronize(players, selfId); rendering.invalidate();
|
||||
if (selectedCompanion && !remotePlayers?.get(selectedCompanion)) {
|
||||
if (controls.arrivalYaw !== undefined) controls.stop();
|
||||
closeCompanion();
|
||||
}
|
||||
},
|
||||
clearPlayers() { remotePlayers?.clear(); rendering.invalidate(); },
|
||||
clearPlayers() { remotePlayers?.clear(); closeCompanion(); if (controls.arrivalYaw !== undefined) controls.stop(); rendering.invalidate(); },
|
||||
setWeather(weather: WeatherSample | null) {
|
||||
weatherTarget = weatherAppearance(weather);
|
||||
rainGear.updateWeather(weatherTarget.rain, weatherTarget.snowfall);
|
||||
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood);
|
||||
rainGear.updateWeather(weather?.rain ?? null, weather?.snowfall ?? null);
|
||||
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
||||
refreshGear();
|
||||
// Do not blend an old precipitation type into the newly selected weather.
|
||||
if (weatherTarget.rain === 0) atmosphere.rain = 0;
|
||||
@@ -398,6 +577,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
},
|
||||
resetPosition() {
|
||||
emotes.cancel();
|
||||
closeCompanion();
|
||||
walkedDistance = 0;
|
||||
restoreView(); controls.clear(); camera.position.set(start.x, eyeHeight, start.z);
|
||||
placeObserver();
|
||||
@@ -412,6 +592,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
lookAtCity() { restoreView(); camera.lookAt(coast && !harbour ? new Vector3(target.x, camera.position.y, target.z) : target); },
|
||||
showCityOverview() {
|
||||
controls.clear();
|
||||
closeCompanion();
|
||||
emotes.cancel();
|
||||
overview = true;
|
||||
refreshEmotes();
|
||||
@@ -423,12 +604,32 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
if (target) camera.lookAt(target.position.clone().add(new Vector3(0, id.includes('mast') ? 14 : id === 'weather-radar' ? 10 : 1, 0)));
|
||||
},
|
||||
lookEast() { restoreView(); camera.lookAt(camera.position.clone().add(new Vector3(1, 0, 0))); },
|
||||
lookAtSunrise(azimuth: number) {
|
||||
restoreView();
|
||||
forward.set(...directionFromAngles(azimuth, 0));
|
||||
camera.lookAt(camera.position.clone().add(forward));
|
||||
if (sheep) {
|
||||
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
|
||||
updateFollowCamera();
|
||||
}
|
||||
},
|
||||
lookAtSun() {
|
||||
if (!solarState) return;
|
||||
restoreView();
|
||||
camera.lookAt(camera.position.clone().add(new Vector3(...directionFromAngles(solarState.azimuth, 0))));
|
||||
if (sheep) updateFollowCamera();
|
||||
},
|
||||
setEyeHeight(meters: number) {
|
||||
eyeHeight = meters;
|
||||
placeObserver();
|
||||
},
|
||||
update(state: SunState) {
|
||||
const nextFlashlightOn = flashlightAfterSunUpdate(flashlightOn, solarState?.altitude, state.altitude);
|
||||
solarState = state;
|
||||
if (flashlightOn !== nextFlashlightOn) {
|
||||
flashlightOn = nextFlashlightOn;
|
||||
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
||||
}
|
||||
emotes.update(0, false, prayerAvailable());
|
||||
refreshEmotes();
|
||||
// LIVE calls this every second; never skip a new solar/time state.
|
||||
@@ -437,11 +638,6 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
applyAtmosphere();
|
||||
ocean?.update(direction, state.altitude);
|
||||
buildings?.update(state.altitude);
|
||||
if (bloom) {
|
||||
bloom.enabled = state.altitude < BUILDINGS.lighting.fadeEndAltitude;
|
||||
bloom.strength = BUILDINGS.lighting.bloomStrength * (1 - MathUtils.smoothstep(state.altitude,
|
||||
BUILDINGS.lighting.fadeStartAltitude, BUILDINGS.lighting.fadeEndAltitude));
|
||||
}
|
||||
horizon.update(state.altitude);
|
||||
fog.color.copy(nightFog).lerp(dawnFog, MathUtils.smoothstep(state.altitude, -8, 0));
|
||||
fog.color.lerp(dayFog, MathUtils.smoothstep(state.altitude, 0, 15));
|
||||
@@ -449,10 +645,14 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
closeCompanion();
|
||||
profile?.dispose();
|
||||
renderer.setAnimationLoop(null);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
minimap.dispose();
|
||||
compass.dispose();
|
||||
selfBadge?.dispose();
|
||||
flashlight?.dispose();
|
||||
bloom?.dispose();
|
||||
outputPass?.dispose();
|
||||
renderPass.dispose();
|
||||
@@ -479,7 +679,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
if (import.meta.env.DEV && profiling) void import('../dev/renderProfile.ts').then(({ createRenderProfile }) => {
|
||||
if (disposed) return;
|
||||
profile = createRenderProfile(renderer, scene, camera, () => world.resetPosition(), target, () =>
|
||||
composer && !profile?.disablePost ? composer.passes.filter(pass => pass.enabled).map(pass => pass.constructor.name) : ['WebGLRenderer'], resize);
|
||||
composer && bloom?.enabled && !profile?.disablePost ? composer.passes.filter(pass => pass.enabled).map(pass => pass.constructor.name) : ['WebGLRenderer'], resize);
|
||||
renderer.info.autoReset = false;
|
||||
resize();
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ button[aria-pressed=true], button.primary { background: #e3ba86; color: #28251f;
|
||||
button[aria-pressed=true]:hover, button.primary:hover { background: #f0cca0; }
|
||||
:focus-visible { outline: 2px solid #f0c48e; outline-offset: 3px; }
|
||||
.hud { position: fixed; inset: 0; pointer-events: none; }
|
||||
.self-badge, #stand-beside { position: fixed; transform: translate(-50%, -100%); padding: 3px 9px; border: 1px solid #ffffff80; border-radius: 10px; background: #172731e8; color: #fff; font-size: 12px; font-weight: 700; line-height: 18px; box-shadow: 0 2px 6px #0004; pointer-events: none; user-select: none; }
|
||||
#compass { position: absolute; top: max(18px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); width: min(460px, calc(100vw - 360px)); height: 66px; color: #f4f2e9; text-shadow: 0 1px 3px #000, 0 0 6px #0009; pointer-events: none; }
|
||||
.compass-scale { position: relative; height: 43px; overflow: hidden; mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent); }
|
||||
.compass-tick { position: absolute; top: 0; transform: translateX(-50%); height: 39px; padding-top: 18px; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
@@ -28,6 +29,8 @@ button[aria-pressed=true]:hover, button.primary:hover { background: #f0cca0; }
|
||||
.scene-toolbar { position: absolute; top: max(18px, env(safe-area-inset-top)); left: max(18px, env(safe-area-inset-left)); right: max(18px, env(safe-area-inset-right)); display: flex; justify-content: space-between; gap: 12px; }
|
||||
.scene-toolbar button, .emote-controls button, #minimap-overlay { pointer-events: auto; box-shadow: 0 3px 14px #0003; }
|
||||
.toolbar-end, .button-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
#screenshot-status { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); max-width: calc(100vw - 32px); margin: 0; padding: 12px 18px; border-radius: 14px; background: #18232ef2; color: #eef1f4; font-size: 13px; pointer-events: none; animation: screenshot-status 4s forwards; }
|
||||
@keyframes screenshot-status { 0%, 80% { opacity: 1; } 100% { opacity: 0; } }
|
||||
.emote-controls { position: absolute; right: max(18px, env(safe-area-inset-right)); bottom: max(24px, env(safe-area-inset-bottom)); display: flex; gap: 10px; }
|
||||
.emote-controls button { flex-direction: column; gap: 6px; min-width: 70px; padding: 12px 14px; background: #eee5d6; border-color: #fff3dd80; color: #3a382f; font-size: 12px; }
|
||||
.emote-controls button:hover:not(:disabled) { background: #fff0d8; }
|
||||
@@ -52,6 +55,15 @@ button[aria-pressed=true]:hover, button.primary:hover { background: #f0cca0; }
|
||||
@keyframes equipment-expand { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@media (prefers-reduced-motion: reduce) { #equipment-options, #location-options, #equipment-notice { animation: none; } }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
.movement-controls { position: absolute; right: max(18px, env(safe-area-inset-right)); bottom: calc(max(24px, env(safe-area-inset-bottom)) + 96px); display: flex; flex-direction: column; align-items: flex-end; gap: 8px; max-width: calc(100vw - 36px); }
|
||||
.movement-controls button { pointer-events: auto; }
|
||||
#direction-hint { margin: 0; padding: 10px 12px; border: 1px solid #ffffff26; border-radius: 14px; background: #18232ef2; font-size: 12px; line-height: 1.5; }
|
||||
#stand-beside { min-width: 0; min-height: 0; width: max-content; pointer-events: auto; }
|
||||
@media (max-width: 600px) {
|
||||
.movement-controls { right: max(10px, env(safe-area-inset-right)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 62px); max-width: calc(100vw - 20px); }
|
||||
#direction-toggle { padding: 8px 12px; font-size: 12px; background: #18232ee6; }
|
||||
#direction-toggle[aria-pressed=true] { background: #e3ba86; }
|
||||
}
|
||||
|
||||
#ui-dialog { width: min(440px, calc(100vw - 32px)); max-width: none; max-height: calc(100vh - 32px); max-height: calc(100dvh - max(16px, env(safe-area-inset-top)) - max(16px, env(safe-area-inset-bottom))); padding: 0; border: 1px solid #52617070; border-radius: 22px; color: #e8edf2; background: #18232e; box-shadow: 0 24px 80px #0006; overflow: hidden; }
|
||||
#ui-dialog[open] { display: flex; flex-direction: column; }
|
||||
@@ -117,6 +129,7 @@ input[type=range] { display: block; width: 100%; min-height: 36px; margin: 8px 0
|
||||
#solo-time-controls { left: max(14px, env(safe-area-inset-left)); right: max(14px, env(safe-area-inset-right)); bottom: calc(max(20px, env(safe-area-inset-bottom)) + 90px); width: auto; }
|
||||
}
|
||||
.text-button { border: 0; padding-inline: 0; background: transparent; color: #e3ba86; }
|
||||
#time-toggle { display: none; }
|
||||
.full-width { width: 100%; }
|
||||
.angles { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.angles > div { display: flex; flex-direction: column; gap: 8px; }
|
||||
@@ -162,3 +175,29 @@ summary { padding: 12px 0; cursor: pointer; font-size: 14px; }
|
||||
#minimap-overlay { width: 132px; }
|
||||
.emote-controls { bottom: max(12px, env(safe-area-inset-bottom)); }
|
||||
}
|
||||
|
||||
/* Compact phone HUD: keep the view clear and expand time editing on demand. */
|
||||
@media (max-width: 600px) {
|
||||
.scene-toolbar { top: max(8px, env(safe-area-inset-top)); left: max(10px, env(safe-area-inset-left)); right: max(10px, env(safe-area-inset-right)); }
|
||||
.scene-toolbar button { width: 44px; height: 44px; min-height: 44px; padding: 10px; border-radius: 12px; }
|
||||
#current-location { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
#compass { top: calc(max(8px, env(safe-area-inset-top)) + 3px); left: calc(50% - 26px); width: calc(100vw - 192px); height: 52px; }
|
||||
.compass-scale { height: 34px; }
|
||||
.compass-tick { padding-top: 15px; font-size: 10px; }
|
||||
.compass-tick.cardinal { font-size: 12px; }
|
||||
.compass-tick:not(.cardinal) { font-size: 0; color: transparent; text-shadow: none; }
|
||||
.compass-bearing { font-size: 10px; }
|
||||
.emote-controls { right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); gap: 6px; }
|
||||
.emote-controls button { min-width: 44px; min-height: 48px; padding: 5px 6px; gap: 3px; border-radius: 11px; font-size: 10px; }
|
||||
.emote-controls .icon { width: 18px; height: 18px; }
|
||||
#solo-time-controls { left: max(10px, env(safe-area-inset-left)); right: auto; bottom: max(10px, env(safe-area-inset-bottom)); width: auto; }
|
||||
#solo-time-controls #time-toggle { display: inline-flex; min-height: 48px; padding: 8px 10px; gap: 6px; font-size: 15px; font-variant-numeric: tabular-nums; }
|
||||
.time-toggle-label { font-size: 10px; color: #adbdca; }
|
||||
#solo-time-controls:not(.shared-time) #time-settings { display: none; position: absolute; bottom: 60px; left: 0; width: min(360px, calc(100vw - 20px - env(safe-area-inset-left) - env(safe-area-inset-right))); max-height: calc(100dvh - 140px); overflow-y: auto; padding: 16px; border: 1px solid #ffffff26; border-radius: 16px; background: #18232ef5; pointer-events: auto; }
|
||||
#solo-time-controls:not(.shared-time):has(#time-toggle[aria-expanded=true]) #time-settings { display: block; }
|
||||
#solo-time-controls.shared-time #time-toggle { display: none; }
|
||||
#solo-time-controls.shared-time .time-panel { gap: 6px; min-height: 48px; align-items: center; }
|
||||
#solo-time-controls.shared-time output { font-size: 13px; }
|
||||
#solo-time-controls.shared-time #live-button { min-width: 48px; min-height: 44px; padding: 6px; gap: 4px; font-size: 10px; letter-spacing: 0; }
|
||||
#solo-time-controls.shared-time .live-dot { width: 5px; height: 5px; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Box3, Vector3, type Camera, type Object3D } from 'three';
|
||||
import { CHARACTER } from '../constants/character.ts';
|
||||
|
||||
/** Position a screen-sized element above a character's head. */
|
||||
export function createCharacterOverlay(player: Object3D, canvas: HTMLCanvasElement, element: HTMLElement, margin = 0) {
|
||||
const head = player.getObjectByName('Head')!;
|
||||
const point = new Vector3();
|
||||
player.updateWorldMatrix(true, true);
|
||||
const headTop = new Box3().setFromObject(head).max.y - head.getWorldPosition(point).y;
|
||||
return {
|
||||
update(camera: Camera, visible: boolean) {
|
||||
if (!visible) { element.hidden = true; return; }
|
||||
head.getWorldPosition(point);
|
||||
point.y += headTop + CHARACTER.selfBadgeGap;
|
||||
camera.updateMatrixWorld(true);
|
||||
point.project(camera);
|
||||
element.hidden = point.z < -1 || point.z > 1 || Math.abs(point.x) > 1 || Math.abs(point.y) > 1;
|
||||
if (element.hidden) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
let x = rect.left + (point.x + 1) * rect.width / 2;
|
||||
let y = rect.top + (1 - point.y) * rect.height / 2;
|
||||
if (margin > 0) {
|
||||
const halfWidth = element.offsetWidth / 2;
|
||||
x = Math.max(rect.left + margin + halfWidth, Math.min(x, rect.right - margin - halfWidth));
|
||||
y = Math.max(rect.top + margin + element.offsetHeight, Math.min(y, rect.bottom - margin));
|
||||
}
|
||||
element.style.left = `${x}px`;
|
||||
element.style.top = `${y}px`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export function createCompass() {
|
||||
const scale = root.querySelector<HTMLElement>('.compass-scale')!;
|
||||
const bearing = root.querySelector<HTMLElement>('.compass-bearing')!;
|
||||
const direction = new Vector3();
|
||||
let previousHeading: number | undefined;
|
||||
const ticks = Array.from({ length: 360 / COMPASS.tickDegrees }, (_, index) => {
|
||||
const degrees = index * COMPASS.tickDegrees;
|
||||
const tick = document.createElement('span');
|
||||
@@ -19,6 +20,8 @@ export function createCompass() {
|
||||
update(camera: Camera) {
|
||||
camera.getWorldDirection(direction);
|
||||
const heading = (Math.atan2(direction.x, -direction.z) * 180 / Math.PI + 360) % 360;
|
||||
if (heading === previousHeading) return;
|
||||
previousHeading = heading;
|
||||
for (const { tick, degrees } of ticks) {
|
||||
const offset = (degrees - heading + 540) % 360 - 180;
|
||||
tick.hidden = Math.abs(offset) > COMPASS.visibleDegrees / 2;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { SCREENSHOT } from '../constants/screenshot.ts';
|
||||
|
||||
export function createScreenshot(captureFrame: () => HTMLCanvasElement) {
|
||||
const opener = document.querySelector<HTMLButtonElement>('#screenshot-open')!;
|
||||
const dialog = document.querySelector<HTMLDialogElement>('#ui-dialog')!;
|
||||
const status = document.getElementById('screenshot-status')!;
|
||||
const events = new AbortController();
|
||||
let busy = false;
|
||||
opener.disabled = false;
|
||||
opener.addEventListener('click', event => {
|
||||
if (busy) event.stopPropagation();
|
||||
}, { signal: events.signal });
|
||||
|
||||
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-screenshot]')) {
|
||||
button.addEventListener('click', async () => {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
dialog.close();
|
||||
status.hidden = true;
|
||||
try {
|
||||
const includeUi = button.dataset.screenshot === 'ui';
|
||||
const toCanvas = includeUi ? (await import('html-to-image')).toCanvas : null;
|
||||
if (toCanvas) await document.fonts.ready;
|
||||
const frame = captureFrame();
|
||||
if (toCanvas) {
|
||||
const overlay = await toCanvas(document.body, {
|
||||
width: innerWidth, height: innerHeight,
|
||||
canvasWidth: frame.width, canvasHeight: frame.height, pixelRatio: 1,
|
||||
filter: node => !node.matches?.('#world, #lobby, #ui-dialog, #screenshot-status, script'),
|
||||
});
|
||||
frame.getContext('2d')!.drawImage(overlay, 0, 0, frame.width, frame.height);
|
||||
}
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
frame.toBlob(value => value ? resolve(value) : reject(new Error('PNG encoding failed')), 'image/png');
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `sunrise-${new Date().toISOString().replace(/[:.]/g, '-')}-${includeUi ? 'ui' : 'scene'}.png`;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), SCREENSHOT.objectUrlLifetimeMs);
|
||||
status.textContent = 'PNG 이미지 다운로드를 시작했습니다.';
|
||||
} catch (error) {
|
||||
console.error('Screenshot failed', error);
|
||||
status.textContent = '사진을 저장하지 못했습니다. 다시 시도해 주세요.';
|
||||
} finally {
|
||||
busy = false;
|
||||
status.hidden = false;
|
||||
}
|
||||
}, { signal: events.signal });
|
||||
}
|
||||
return { dispose() { events.abort(); opener.disabled = true; } };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { type Object3D } from 'three';
|
||||
import { createCharacterOverlay } from './characterOverlay.ts';
|
||||
|
||||
/** A screen-sized label attached only to the local player's head. */
|
||||
export function createSelfBadge(player: Object3D, canvas: HTMLCanvasElement) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'self-badge';
|
||||
badge.textContent = '나';
|
||||
badge.setAttribute('aria-label', '내 캐릭터');
|
||||
badge.hidden = true;
|
||||
document.querySelector('.hud')!.append(badge);
|
||||
return {
|
||||
...createCharacterOverlay(player, canvas, badge),
|
||||
dispose() { badge.remove(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ObserverConfig } from '../config/observer.ts';
|
||||
import { calculateSun, sunriseAzimuth } from '../astronomy/sunCalculator.ts';
|
||||
import { THREADS } from '../constants/threads.ts';
|
||||
import type { WeatherSample, WeatherStatus } from '../weather/weather.ts';
|
||||
|
||||
export interface ThreadsCapture {
|
||||
sunrise(): string;
|
||||
weatherStatus(): WeatherStatus['status'];
|
||||
capture(): { images: { name: string; image: string }[]; capturedAt: string; weather: WeatherSample | null };
|
||||
}
|
||||
|
||||
declare global { interface Window { sunriseThreads?: ThreadsCapture } }
|
||||
|
||||
export function installThreadsCapture(observer: ObserverConfig,
|
||||
world: { update: (state: ReturnType<typeof calculateSun>) => void; lookAtSunrise: (azimuth: number) => void;
|
||||
captureAngle: (azimuth: number, pitch: number) => HTMLCanvasElement }, getWeather: () => WeatherStatus) {
|
||||
window.sunriseThreads = {
|
||||
weatherStatus: () => getWeather().status,
|
||||
sunrise() {
|
||||
const sunrise = calculateSun(new Date(), observer).sunrise;
|
||||
if (!sunrise || !Number.isFinite(+sunrise)) throw new Error('No sunrise');
|
||||
return sunrise.toISOString();
|
||||
},
|
||||
capture() {
|
||||
const now = new Date();
|
||||
world.update(calculateSun(now, observer));
|
||||
const azimuth = sunriseAzimuth(now, observer);
|
||||
world.lookAtSunrise(azimuth);
|
||||
const images = THREADS.angles.map(angle => ({ name: angle.name,
|
||||
image: world.captureAngle((azimuth + angle.offset) % 360, angle.pitch).toDataURL('image/jpeg', 0.95) }));
|
||||
const weather = getWeather();
|
||||
return { images, capturedAt: now.toISOString(), weather: weather.status === 'ready' ? weather.weather : null };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,8 @@ export class TimeController {
|
||||
private slider = document.querySelector<HTMLInputElement>('#time-slider')!;
|
||||
private output = document.querySelector<HTMLOutputElement>('#time-output')!;
|
||||
private liveButton = document.querySelector<HTMLButtonElement>('#live-button')!;
|
||||
private timeToggle = document.querySelector<HTMLButtonElement>('#time-toggle')!;
|
||||
private compactTime = document.querySelector<HTMLElement>('#compact-time')!;
|
||||
private onChange: (date: Date, mode: TimeMode) => void;
|
||||
private sharedClock: (() => Date) | null = null;
|
||||
|
||||
@@ -27,6 +29,9 @@ export class TimeController {
|
||||
this.dateInput.addEventListener('change', this.custom);
|
||||
this.slider.addEventListener('input', this.custom);
|
||||
this.liveButton.addEventListener('click', this.live);
|
||||
this.timeToggle.addEventListener('click', this.toggleSettings);
|
||||
document.addEventListener('pointerdown', this.dismissSettings);
|
||||
document.addEventListener('keydown', this.closeSettings);
|
||||
document.addEventListener('visibilitychange', this.sync);
|
||||
window.addEventListener('focus', this.sync);
|
||||
window.addEventListener('keydown', this.adjustTime);
|
||||
@@ -34,6 +39,23 @@ export class TimeController {
|
||||
this.sync();
|
||||
}
|
||||
|
||||
private toggleSettings = () => {
|
||||
this.timeToggle.setAttribute('aria-expanded', String(this.timeToggle.getAttribute('aria-expanded') !== 'true'));
|
||||
};
|
||||
|
||||
private dismissSettings = (event: PointerEvent) => {
|
||||
if (event.target instanceof Element && !event.target.closest('#solo-time-controls')) {
|
||||
this.timeToggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
};
|
||||
|
||||
private closeSettings = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && this.timeToggle.getAttribute('aria-expanded') === 'true') {
|
||||
this.timeToggle.setAttribute('aria-expanded', 'false');
|
||||
this.timeToggle.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private adjustTime = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey
|
||||
|| this.sharedClock || this.slider.matches(':disabled')
|
||||
@@ -63,7 +85,7 @@ export class TimeController {
|
||||
};
|
||||
|
||||
private sync = () => {
|
||||
if (this.mode === 'LIVE') this.render(this.sharedClock ? this.sharedClock() : new Date());
|
||||
if (!document.hidden && this.mode === 'LIVE') this.render(this.sharedClock ? this.sharedClock() : new Date());
|
||||
};
|
||||
|
||||
private render(date: Date) {
|
||||
@@ -74,6 +96,8 @@ export class TimeController {
|
||||
const [year, month, day] = fields.date.split('-');
|
||||
this.dateDisplay.textContent = `${year}년 ${month}월 ${day}일`;
|
||||
const clock = kstClock(date);
|
||||
this.compactTime.textContent = clock.slice(0, 5);
|
||||
this.timeToggle.setAttribute('aria-label', `관측 시간 ${clock} KST · 설정`);
|
||||
const seconds = fields.minutes * 60 + Number(clock.slice(-2));
|
||||
this.slider.value = String(Math.floor(seconds / TIME_STEP_SECONDS) * TIME_STEP_SECONDS);
|
||||
this.output.value = clock;
|
||||
@@ -87,6 +111,9 @@ export class TimeController {
|
||||
this.dateInput.removeEventListener('change', this.custom);
|
||||
this.slider.removeEventListener('input', this.custom);
|
||||
this.liveButton.removeEventListener('click', this.live);
|
||||
this.timeToggle.removeEventListener('click', this.toggleSettings);
|
||||
document.removeEventListener('pointerdown', this.dismissSettings);
|
||||
document.removeEventListener('keydown', this.closeSettings);
|
||||
document.removeEventListener('visibilitychange', this.sync);
|
||||
window.removeEventListener('focus', this.sync);
|
||||
window.removeEventListener('keydown', this.adjustTime);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { calculateSun, sunriseAzimuth } from '../src/astronomy/sunCalculator.ts';
|
||||
import { observer } from '../src/config/observer.ts';
|
||||
|
||||
test('arrival faces the selected day’s sunrise, even when entering in the afternoon', () => {
|
||||
for (const month of ['01', '06', '11']) {
|
||||
const date = new Date(`2026-${month}-02T15:00:00+09:00`);
|
||||
const state = calculateSun(date, observer);
|
||||
const heading = sunriseAzimuth(date, observer);
|
||||
assert.equal(heading, calculateSun(state.sunrise!, observer).azimuth);
|
||||
assert.ok(heading > 45 && heading < 135);
|
||||
assert.ok(Math.abs(heading - state.azimuth) > 60);
|
||||
}
|
||||
});
|
||||
@@ -51,7 +51,8 @@ test('downloaded footprints are deduplicated, closed, and generate finite merged
|
||||
const city=createBuildings(data,terrain);
|
||||
assert.equal(Object.values(city.counts).reduce((a,b)=>a+b,0),data.features.length);
|
||||
assert.ok(city.counts.tagged>0 && city.counts.levels>0 && city.counts.fallback>0);
|
||||
assert.ok(city.group.children.length<=3);
|
||||
assert.ok(city.group.children.length > 3, 'separate city districts can be culled independently');
|
||||
assert.ok(city.group.children.length < 100, 'batch buildings instead of one draw call per building');
|
||||
for(const mesh of city.group.children as Mesh[]) assert.ok(Array.from(mesh.geometry.attributes.position.array).every(Number.isFinite));
|
||||
city.dispose();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BoxGeometry, Group, Mesh, MeshBasicMaterial, PerspectiveCamera } from 'three';
|
||||
import { createCharacterOverlay } from '../src/ui/characterOverlay.ts';
|
||||
|
||||
test('character menu follows its subject, stays inside the viewport and hides behind the camera', () => {
|
||||
const player = new Group(), head = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial());
|
||||
head.name = 'Head'; head.position.y = 1; player.add(head);
|
||||
const camera = new PerspectiveCamera(60, 1, .1, 100);
|
||||
camera.position.set(0, 2, 5); camera.lookAt(0, 1, 0); camera.updateMatrixWorld(true);
|
||||
const canvas = { getBoundingClientRect: () => ({ left: 0, top: 0, right: 320, bottom: 320, width: 320, height: 320 }) } as unknown as HTMLCanvasElement;
|
||||
const panel = { hidden: true, style: { left: '', top: '' }, offsetWidth: 220, offsetHeight: 80 } as unknown as HTMLElement;
|
||||
const overlay = createCharacterOverlay(player, canvas, panel, 12);
|
||||
overlay.update(camera, true);
|
||||
assert.equal(panel.hidden, false);
|
||||
assert.equal(parseFloat(panel.style.left), 160);
|
||||
const firstTop = parseFloat(panel.style.top);
|
||||
player.position.x = 1;
|
||||
overlay.update(camera, true);
|
||||
assert.ok(parseFloat(panel.style.left) > 160);
|
||||
assert.ok(parseFloat(panel.style.left) + panel.offsetWidth / 2 <= 308);
|
||||
player.position.y = 1;
|
||||
overlay.update(camera, true);
|
||||
assert.ok(parseFloat(panel.style.top) < firstTop);
|
||||
assert.ok(parseFloat(panel.style.top) - panel.offsetHeight >= 12);
|
||||
player.position.z = 10;
|
||||
overlay.update(camera, true);
|
||||
assert.equal(panel.hidden, true);
|
||||
player.position.set(0, 0, 0);
|
||||
overlay.update(camera, false);
|
||||
assert.equal(panel.hidden, true);
|
||||
overlay.update(camera, true);
|
||||
assert.equal(panel.hidden, false);
|
||||
head.geometry.dispose(); (head.material as MeshBasicMaterial).dispose();
|
||||
});
|
||||
+48
-11
@@ -108,10 +108,14 @@ test('exported sheep has working joints and renders the final idle pose after wa
|
||||
const leg = sheep.group.getObjectByName('LegLeft')!;
|
||||
const rest = leg.quaternion.clone();
|
||||
assert.equal(sheep.pose('idle', 0, true, 1), true);
|
||||
sheep.pose('idle', 0, true, 1.2);
|
||||
assert.ok(leg.quaternion.angleTo(rest) > .01);
|
||||
assert.equal(sheep.pose('idle', 0, false, 1), true, 'stopping must invalidate one frame');
|
||||
const walking = leg.quaternion.clone();
|
||||
assert.equal(sheep.pose('idle', 0, false, 1.2), true, 'stopping must invalidate one frame');
|
||||
assert.ok(leg.quaternion.angleTo(walking) < 1e-6, 'stopping blends instead of snapping');
|
||||
sheep.pose('idle', 0, false, 4);
|
||||
assert.ok(leg.quaternion.angleTo(rest) < 1e-6);
|
||||
assert.equal(sheep.pose('idle', 0, false, 1), false);
|
||||
assert.equal(sheep.pose('idle', 0, false, 1), true, 'idle breathing keeps rendering');
|
||||
const arm = sheep.group.getObjectByName('ArmRight')!;
|
||||
const armRest = arm.quaternion.clone();
|
||||
sheep.pose('wave', 1, false, 1);
|
||||
@@ -121,7 +125,42 @@ test('exported sheep has working joints and renders the final idle pose after wa
|
||||
sheep.dispose();
|
||||
});
|
||||
|
||||
test('sideways and rear destinations turn the character forward and ease the camera behind it', () => {
|
||||
test('idle breathing moves torso and head while feet stay planted and clones retain authored rest transforms', async () => {
|
||||
const bytes = await readFile(new URL('../public/characters/red-sheep.glb', import.meta.url));
|
||||
const model = await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), '');
|
||||
const sheep = createSheep(model.scene);
|
||||
const body = sheep.group.getObjectByName('Body')!, head = sheep.group.getObjectByName('Head')!;
|
||||
const bodyRest = body.scale.clone(), headRest = head.position.clone();
|
||||
const leg = sheep.group.getObjectByName('LegLeft')!;
|
||||
const feet = leg.getWorldPosition(new Vector3());
|
||||
sheep.pose('idle', 0, false, 0);
|
||||
for (let i = 1; i <= 8; i++) sheep.pose('idle', 0, false, i / 10);
|
||||
assert.ok(!body.scale.equals(bodyRest));
|
||||
assert.ok(!head.position.equals(headRest));
|
||||
assert.ok(leg.getWorldPosition(new Vector3()).distanceTo(feet) < 1e-8);
|
||||
const once = body.scale.clone();
|
||||
sheep.pose('idle', 0, false, .8);
|
||||
assert.deepEqual(body.scale, once, 'same timestamp cannot accumulate scaling');
|
||||
const remote = sheep.clone();
|
||||
remote.pose('wave', 0, false, 1);
|
||||
assert.deepEqual(remote.group.getObjectByName('Body')!.scale, bodyRest, 'clone does not inherit inflated rest scale');
|
||||
assert.deepEqual(remote.group.getObjectByName('Head')!.position, headRest);
|
||||
assert.deepEqual(body.scale, once, 'remote pose is independent');
|
||||
sheep.pose('idle', 0, true, .81);
|
||||
assert.ok(!body.scale.equals(bodyRest), 'breathing blends out instead of snapping');
|
||||
sheep.pose('idle', 0, true, 1.2);
|
||||
assert.deepEqual(body.scale, bodyRest);
|
||||
assert.ok(head.position.distanceTo(headRest) > .001, 'walking carries the head');
|
||||
assert.ok(body.position.x !== 0, 'walking shifts the torso weight');
|
||||
const walkingHead = head.position.clone();
|
||||
sheep.pose('idle', 0, true, 1.2);
|
||||
assert.deepEqual(head.position, walkingHead, 'walking offsets cannot accumulate');
|
||||
sheep.pose('pray', 0, false, 1.5);
|
||||
assert.deepEqual(head.position, headRest, 'emotes clear walking offsets');
|
||||
remote.dispose(); sheep.dispose();
|
||||
});
|
||||
|
||||
test('sideways and rear destinations turn the character without changing the chosen camera angle', () => {
|
||||
for (const step of [new Vector3(1, 0, 0), new Vector3(0, 0, 1), new Vector3(-1, 0, -.01)]) {
|
||||
const character = new Group();
|
||||
character.rotation.y = Math.PI;
|
||||
@@ -129,21 +168,19 @@ test('sideways and rear destinations turn the character forward and ease the cam
|
||||
camera.rotation.set(-.2, 0, 0, 'YXZ');
|
||||
const original = camera.quaternion.clone();
|
||||
const initialFacing = character.quaternion.clone();
|
||||
const speed = faceTravel(character, camera, step, 1 / 60);
|
||||
const speed = faceTravel(character, step, 1 / 60);
|
||||
assert.ok(character.quaternion.angleTo(initialFacing) <= 4 / 60 + 1e-6, 'body must turn gradually');
|
||||
if (step.z === 1) assert.equal(speed, 0, 'rear destinations turn before translating');
|
||||
assert.ok(camera.quaternion.angleTo(original) < .22, 'camera must ease, not snap');
|
||||
for (let i = 0; i < 180; i++) faceTravel(character, camera, step, 1 / 60);
|
||||
assert.ok(camera.quaternion.equals(original), 'walking preserves the chosen camera angle');
|
||||
for (let i = 0; i < 180; i++) faceTravel(character, step, 1 / 60);
|
||||
assert.ok(character.getWorldDirection(new Vector3()).dot(step.clone().normalize()) > .999);
|
||||
const view = camera.getWorldDirection(new Vector3());
|
||||
view.y = 0;
|
||||
assert.ok(view.normalize().dot(step.clone().normalize()) > .999);
|
||||
assert.ok(camera.quaternion.equals(original));
|
||||
assert.ok(Math.abs(camera.rotation.x + .2) < 1e-6, 'preserve viewing pitch');
|
||||
const settled = character.quaternion.clone();
|
||||
faceTravel(character, camera, new Vector3(), 1 / 60);
|
||||
faceTravel(character, new Vector3(), 1 / 60);
|
||||
assert.ok(character.quaternion.angleTo(settled) < 1e-6, 'idle keeps the last facing');
|
||||
const manual = camera.quaternion.clone();
|
||||
faceTravel(character, camera, step.clone().negate(), 1 / 60, false);
|
||||
faceTravel(character, step.clone().negate(), 1 / 60);
|
||||
assert.ok(camera.quaternion.angleTo(manual) < 1e-6, 'manual looking takes priority');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { flashlightAfterSunUpdate } from '../src/controls/flashlight.ts';
|
||||
|
||||
test('sunset enables the flashlight once, preserves manual off, and resets at dawn', () => {
|
||||
assert.equal(flashlightAfterSunUpdate(false, undefined, 20), false);
|
||||
assert.equal(flashlightAfterSunUpdate(false, 1, -0.1), false, 'wait until the solar disc has set');
|
||||
assert.equal(flashlightAfterSunUpdate(false, -0.1, -1), true);
|
||||
assert.equal(flashlightAfterSunUpdate(false, -1, -2), false, 'manual off survives time updates');
|
||||
assert.equal(flashlightAfterSunUpdate(true, -2, -3), true);
|
||||
assert.equal(flashlightAfterSunUpdate(true, -3, 1), false);
|
||||
assert.equal(flashlightAfterSunUpdate(false, 1, -1), true, 'next night enables again');
|
||||
assert.equal(flashlightAfterSunUpdate(false, undefined, -10), true, 'nighttime arrival enables the torch');
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFrameClock } from '../src/scene/frameClock.ts';
|
||||
|
||||
test('30 fps pacing retains elapsed simulation time on 60 and 120 Hz displays', () => {
|
||||
for (const hz of [60, 120]) {
|
||||
const clock = createFrameClock(30);
|
||||
let frames = 0, elapsed = 0;
|
||||
for (let i = 0; i <= hz * 10; i++) {
|
||||
const delta = clock.tick(i * 1000 / hz);
|
||||
if (delta !== undefined) { frames++; elapsed += delta; }
|
||||
}
|
||||
assert.equal(frames, 301);
|
||||
assert.ok(Math.abs(elapsed - 10) < 1e-8);
|
||||
}
|
||||
});
|
||||
|
||||
test('resume resets elapsed time instead of simulating the hidden interval', () => {
|
||||
const clock = createFrameClock(30);
|
||||
clock.tick(0); clock.tick(100);
|
||||
clock.reset();
|
||||
assert.equal(clock.tick(60000), 0);
|
||||
assert.equal(clock.tick(60001), undefined);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Group } from 'three';
|
||||
import { BoxGeometry, Group, Mesh, MeshBasicMaterial, PerspectiveCamera, Raycaster, Vector3 } from 'three';
|
||||
import { createServerClock } from '../src/multiplayer/clock.ts';
|
||||
import { createRemotePlayers } from '../src/multiplayer/remotePlayers.ts';
|
||||
import type { Sheep } from '../src/scene/sheep.ts';
|
||||
@@ -16,24 +16,78 @@ test('server clock uses round trip midpoint and monotonic elapsed time', () => {
|
||||
assert.equal(clock.now(), 1001020);
|
||||
});
|
||||
|
||||
test('offscreen players skip posing but keep movement current and reappear when the camera turns', () => {
|
||||
const scene = new Group(), camera = new PerspectiveCamera(60, 1, .1, 100);
|
||||
let poses = 0;
|
||||
const template = { group: new Group(), clone() { return { group: new Group(), setEquipment() {},
|
||||
pose() { poses++; return true; }, dispose() {} } as unknown as Sheep; } } as Sheep;
|
||||
const remote = createRemotePlayers(template, scene, () => 0);
|
||||
const player: PlayerState = { id: 'other', nickname: '친구', x: 0, z: 10, yaw: 0, moving: false,
|
||||
emote: 'idle', equipment: { umbrella: false, raincoat: false, hood: false }, updatedAt: 1000, emoteStartedAt: 1000 };
|
||||
remote.synchronize([player], 'self');
|
||||
for (let i = 0; i < 300; i++) remote.update(1 / 30, 1000 + i * 1000 / 30, camera);
|
||||
assert.equal(poses, 0);
|
||||
assert.equal(remote.get('other')!.visible, false);
|
||||
remote.synchronize([{ ...player, x: 2, updatedAt: 11000 }], 'self');
|
||||
remote.update(.2, 11200, camera);
|
||||
assert.equal(remote.get('other')!.position.x, 2);
|
||||
camera.lookAt(2, 0, 10);
|
||||
assert.equal(remote.update(.03, 11230, camera), true);
|
||||
assert.equal(poses, 1);
|
||||
assert.equal(remote.get('other')!.visible, true);
|
||||
});
|
||||
|
||||
test('companion selection picks visible players and chooses the nearest reachable unoccupied side', () => {
|
||||
const scene = new Group();
|
||||
const geometry = new BoxGeometry(1, 2, 1), material = new MeshBasicMaterial();
|
||||
const template = { clone() {
|
||||
const group = new Group(); group.add(new Mesh(geometry, material));
|
||||
return { group, setEquipment() {}, pose: () => false, dispose() { group.removeFromParent(); } } as unknown as Sheep;
|
||||
} } as Sheep;
|
||||
const remote = createRemotePlayers(template, scene, () => 0);
|
||||
const player: PlayerState = { id: 'friend', nickname: '친구', x: 0, z: 0, yaw: 0, moving: false,
|
||||
emote: 'idle', equipment: { umbrella: false, raincoat: false, hood: false }, updatedAt: 1000, emoteStartedAt: 1000 };
|
||||
remote.synchronize([player], 'self');
|
||||
const ray = new Raycaster(new Vector3(0, 0, 5), new Vector3(0, 0, -1));
|
||||
assert.equal(remote.pick(ray, 10), 'friend');
|
||||
assert.equal(remote.pick(ray, 2), undefined, 'terrain in front prevents selection');
|
||||
const from = new Vector3(3, 0, 0);
|
||||
const right = remote.beside('friend', from, () => true)!;
|
||||
assert.ok(right.position.x > 0 && right.position.z === 0);
|
||||
assert.equal(right.yaw, 0);
|
||||
const left = remote.beside('friend', from, point => point.x < 0)!;
|
||||
assert.ok(left.position.x < 0);
|
||||
assert.equal(remote.beside('friend', from, () => false), undefined);
|
||||
remote.synchronize([player, { ...player, id: 'occupied', x: right.position.x }], 'self');
|
||||
assert.ok(remote.beside('friend', from, () => true)!.position.x < 0);
|
||||
remote.synchronize([], 'self');
|
||||
assert.equal(remote.get('friend'), undefined);
|
||||
assert.equal(remote.beside('friend', from, () => true), undefined);
|
||||
geometry.dispose(); material.dispose();
|
||||
});
|
||||
|
||||
test('remote snapshot reconciles membership, equipment and interpolated motion without local duplicate', () => {
|
||||
const scene = new Group();
|
||||
const equipment: boolean[][] = [];
|
||||
let disposed = 0;
|
||||
const template = { clone() { return { group: new Group(), setEquipment: (...v: boolean[]) => equipment.push(v),
|
||||
pose: () => false, dispose() { this.group.removeFromParent(); disposed++; } } as unknown as Sheep; } } as Sheep;
|
||||
const remote = createRemotePlayers(template, scene, () => 3);
|
||||
let heightSamples = 0;
|
||||
const remote = createRemotePlayers(template, scene, () => { heightSamples++; return 3; });
|
||||
const player: PlayerState = { id: 'other', nickname: '방문자', x: 0, z: 0, yaw: 0, moving: false,
|
||||
emote: 'idle', equipment: { umbrella: true, raincoat: true, hood: false }, updatedAt: 1000, emoteStartedAt: 1000 };
|
||||
emote: 'idle', equipment: { umbrella: true, raincoat: true, hood: false, flashlight: true }, updatedAt: 1000, emoteStartedAt: 1000 };
|
||||
remote.synchronize([player, { ...player, id: 'self' }], 'self');
|
||||
assert.equal(scene.children.length, 1);
|
||||
assert.deepEqual(equipment.at(-1), [true, true, false]);
|
||||
assert.deepEqual(equipment.at(-1), [true, true, false, true]);
|
||||
remote.synchronize([{ ...player, x: 1, updatedAt: 1100 }], 'self');
|
||||
remote.update(.075, 1100);
|
||||
assert.ok(scene.children[0].position.x > 0 && scene.children[0].position.x < 1);
|
||||
remote.update(.2, 1300);
|
||||
assert.equal(scene.children[0].position.x, 1);
|
||||
assert.equal(scene.children[0].position.y, 3);
|
||||
const samplesAfterMoving = heightSamples;
|
||||
for (let i = 0; i < 300; i++) remote.update(1 / 30, 1300 + i * 1000 / 30);
|
||||
assert.equal(heightSamples, samplesAfterMoving, 'stationary players reuse their terrain height');
|
||||
remote.synchronize([], 'self');
|
||||
assert.equal(scene.children.length, 0);
|
||||
assert.equal(disposed, 1);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user