Compare commits
11
Commits
62afa4b272
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e234baa4aa | ||
|
|
4b27a8c209 | ||
|
|
0a05c94ce1 | ||
|
|
ad5a5b0cfb | ||
|
|
60437790a8 | ||
|
|
1581b7a64a | ||
|
|
db945a41cb | ||
|
|
8b6d407507 | ||
|
|
21b0bb7e98 | ||
|
|
0d58170211 | ||
|
|
924f2e0506 |
@@ -0,0 +1 @@
|
|||||||
|
/build.sh text eol=lf
|
||||||
@@ -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,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.
|
||||||
@@ -41,6 +41,7 @@ root에서 실행합니다. 캡처 저장 위치는 기존 dist/threads-captures
|
|||||||
cd /opt/sunrise
|
cd /opt/sunrise
|
||||||
git pull --ff-only
|
git pull --ff-only
|
||||||
npm ci
|
npm ci
|
||||||
|
# 최초 서비스 등록 전에는 직접 빌드합니다.
|
||||||
npm run build
|
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 755 dist/threads-captures
|
||||||
install -d -o sunrise -g "$(id -gn sunrise)" -m 700 data/threads
|
install -d -o sunrise -g "$(id -gn sunrise)" -m 700 data/threads
|
||||||
@@ -88,7 +89,7 @@ sudo -iu sunrise sh -lc 'cd /opt/sunrise && npm run threads:post-now'
|
|||||||
|
|
||||||
별도 브라우저 경로를 사용하면 테스트 터미널에도 동일한 환경변수를 적용하세요. post-now는 실행할 때마다 새 테스트 게시물을 만듭니다. preview 이미지는 확인용으로 보존합니다.
|
별도 브라우저 경로를 사용하면 테스트 터미널에도 동일한 환경변수를 적용하세요. post-now는 실행할 때마다 새 테스트 게시물을 만듭니다. preview 이미지는 확인용으로 보존합니다.
|
||||||
|
|
||||||
이후 배포는 git pull, 필요 시 npm ci, npm run build, 웹 서버 재시작, `systemctl restart sunrise-threads.service` 순서입니다. 설정/문구는 다시 복사하지 않습니다. root로 빌드한 뒤에는 아래 명령으로 캡처 폴더를 다시 만들고 권한을 맞추세요.
|
이후 배포는 git pull, 필요 시 npm ci, ./build.sh, 웹 서버 재시작 순서입니다. ./build.sh가 빌드와 권한 설정에 성공한 뒤 sunrise-threads.service까지 재시작합니다. 설정/문구는 다시 복사하지 않습니다. root에서 ./build.sh를 실행하면 빌드 후 캡처 폴더 생성·권한 설정·서비스 재시작까지 처리합니다. 서비스가 아직 등록되지 않았다면 최초 등록 절차를 먼저 진행하세요. npm run build를 직접 실행했다면 아래 명령이 필요합니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
install -d -o sunrise -g "$(id -gn sunrise)" -m 755 /opt/sunrise/dist/threads-captures
|
install -d -o sunrise -g "$(id -gn sunrise)" -m 755 /opt/sunrise/dist/threads-captures
|
||||||
|
|||||||
+6
-1
@@ -87,6 +87,11 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</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="캐릭터 액션">
|
<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><span id="primary-action-label">인사</span></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>
|
<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>
|
||||||
@@ -153,7 +158,7 @@
|
|||||||
</section>
|
</section>
|
||||||
<section data-dialog-panel="settings" hidden>
|
<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>
|
<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>
|
<a id="terrain-comparison" class="terrain-source" href="?terrain=original">원본 지형 비교</a>
|
||||||
</section>
|
</section>
|
||||||
<section data-dialog-panel="sources" hidden>
|
<section data-dialog-panel="sources" hidden>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ export const BUILDINGS = {
|
|||||||
assetManifestUrl: '/buildings/gwacheon.baked.json',
|
assetManifestUrl: '/buildings/gwacheon.baked.json',
|
||||||
assetBufferUrl: '/buildings/gwacheon.baked.bin',
|
assetBufferUrl: '/buildings/gwacheon.baked.bin',
|
||||||
assetVersion: 1,
|
assetVersion: 1,
|
||||||
|
tileSize: 1000,
|
||||||
metersPerLevel: 3,
|
metersPerLevel: 3,
|
||||||
// Visual placeholders only: not a claim about actual floors.
|
// 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>,
|
fallbackHeight: { apartments: 15, residential: 9, house: 6, commercial: 12, greenhouse: 3, roof: 3, school: 12, default: 9 } as Record<string, number>,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const CHARACTER = {
|
export const CHARACTER = {
|
||||||
url: '/characters/red-sheep.glb?v=rounded-v10',
|
url: '/characters/red-sheep.glb?v=rounded-v10',
|
||||||
scale: 0.62,
|
scale: 0.62,
|
||||||
|
visibilityRadius: 3, // Includes raised arms, umbrella and idle hops around the ground anchor.
|
||||||
selfBadgeGap: 0.12,
|
selfBadgeGap: 0.12,
|
||||||
outline: 0.018,
|
outline: 0.018,
|
||||||
ink: '#352724',
|
ink: '#352724',
|
||||||
@@ -13,8 +14,41 @@ export const CHARACTER = {
|
|||||||
waveSeconds: 3,
|
waveSeconds: 3,
|
||||||
prayerWindowMs: 5 * 60 * 1000,
|
prayerWindowMs: 5 * 60 * 1000,
|
||||||
cameraDistance: 5,
|
cameraDistance: 5,
|
||||||
|
cameraMinDistance: 2,
|
||||||
|
cameraMaxDistance: 12,
|
||||||
cameraLift: 1.15,
|
cameraLift: 1.15,
|
||||||
cameraClearance: 0.35,
|
cameraClearance: 0.35,
|
||||||
cameraTurnRate: 1.8,
|
|
||||||
turnSpeed: 4,
|
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;
|
} 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,5 @@
|
|||||||
export const RENDER_QUALITY = {
|
export const RENDER_QUALITY = {
|
||||||
|
mobileFps: 30,
|
||||||
mobileCap: 1.5,
|
mobileCap: 1.5,
|
||||||
mobilePixelBudget: 1_500_000,
|
mobilePixelBudget: 1_500_000,
|
||||||
levels: [0.75, 1, 1.25, 1.5, 1.75, 2] as readonly number[],
|
levels: [0.75, 1, 1.25, 1.5, 1.75, 2] as readonly number[],
|
||||||
|
|||||||
@@ -3,46 +3,94 @@ import { SCENE } from '../constants/scene.ts';
|
|||||||
import { MOVEMENT } from '../constants/movement.ts';
|
import { MOVEMENT } from '../constants/movement.ts';
|
||||||
|
|
||||||
export function createFpsCameraController(camera: PerspectiveCamera, canvas: HTMLCanvasElement,
|
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 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;
|
let gesture: { id: number; startX: number; startY: number; x: number; y: number; dragged: boolean; canMove: boolean } | null = null;
|
||||||
canvas.tabIndex = 0;
|
canvas.tabIndex = 0;
|
||||||
const cancelGesture = () => {
|
const cancelGesture = () => {
|
||||||
const id = gesture?.id;
|
const ids = [...pointers.keys()];
|
||||||
gesture = null;
|
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) => {
|
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();
|
event.preventDefault();
|
||||||
canvas.focus({ preventScroll: true });
|
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,
|
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 };
|
x: event.clientX, y: event.clientY, dragged: false, canMove: event.pointerType !== 'mouse' || event.button === 2 };
|
||||||
canvas.setPointerCapture(event.pointerId);
|
|
||||||
};
|
};
|
||||||
const move = (event: PointerEvent) => {
|
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 (!gesture || gesture.id !== event.pointerId) return;
|
||||||
if (Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) > MOVEMENT.dragThreshold) gesture.dragged = true;
|
if (Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) > MOVEMENT.dragThreshold) gesture.dragged = true;
|
||||||
if (gesture.dragged) {
|
if (gesture.dragged) {
|
||||||
camera.rotation.reorder('YXZ');
|
if (turning) options.onTurn?.((event.clientX - gesture.x) * MOVEMENT.turnSensitivity);
|
||||||
camera.rotation.y -= (event.clientX - gesture.x) * MOVEMENT.dragSensitivity;
|
else {
|
||||||
camera.rotation.x = MathUtils.clamp(camera.rotation.x - (event.clientY - gesture.y) * MOVEMENT.dragSensitivity,
|
camera.rotation.reorder('YXZ');
|
||||||
-MathUtils.degToRad(SCENE.pitchLimitDegrees), MathUtils.degToRad(SCENE.pitchLimitDegrees));
|
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.x = event.clientX;
|
||||||
gesture.y = event.clientY;
|
gesture.y = event.clientY;
|
||||||
};
|
};
|
||||||
const up = (event: PointerEvent) => {
|
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;
|
if (!gesture || gesture.id !== event.pointerId) return;
|
||||||
const tap = !gesture.dragged && Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) <= MOVEMENT.dragThreshold;
|
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();
|
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();
|
const contextMenu = (event: Event) => event.preventDefault();
|
||||||
window.addEventListener('blur', clear);
|
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);
|
document.addEventListener('visibilitychange', clear);
|
||||||
canvas.addEventListener('pointerdown', down);
|
canvas.addEventListener('pointerdown', down);
|
||||||
canvas.addEventListener('pointermove', move);
|
canvas.addEventListener('pointermove', move);
|
||||||
@@ -52,14 +100,19 @@ export function createFpsCameraController(camera: PerspectiveCamera, canvas: HTM
|
|||||||
canvas.addEventListener('contextmenu', contextMenu);
|
canvas.addEventListener('contextmenu', contextMenu);
|
||||||
return {
|
return {
|
||||||
get destination() { return destination; },
|
get destination() { return destination; },
|
||||||
get looking() { return Boolean(gesture?.dragged); },
|
get arrivalYaw() { return arrivalYaw; },
|
||||||
stop() { destination = null; },
|
get turning() { return turning; },
|
||||||
|
setTurning,
|
||||||
|
moveTo(point: Vector3, yaw?: number) { setTurning(false); destination = point.clone(); arrivalYaw = yaw; },
|
||||||
|
stop,
|
||||||
active() { return document.activeElement === canvas; },
|
active() { return document.activeElement === canvas; },
|
||||||
clear,
|
clear,
|
||||||
dispose() {
|
dispose() {
|
||||||
clear();
|
clear();
|
||||||
window.removeEventListener('blur', 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);
|
document.removeEventListener('visibilitychange', clear);
|
||||||
canvas.removeEventListener('pointerdown', down);
|
canvas.removeEventListener('pointerdown', down);
|
||||||
canvas.removeEventListener('pointermove', move);
|
canvas.removeEventListener('pointermove', move);
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { CHARACTER } from '../constants/character.ts';
|
|||||||
const direction = new Vector3();
|
const direction = new Vector3();
|
||||||
const target = new Vector3();
|
const target = new Vector3();
|
||||||
|
|
||||||
/** Face the accepted ground step, while the camera catches up along the shortest yaw arc. */
|
/** Face the accepted ground step without changing the user's camera orbit. */
|
||||||
export function faceTravel(character: Object3D, observer: PerspectiveCamera, step: Vector3,
|
export function faceTravel(character: Object3D, step: Vector3, delta: number) {
|
||||||
delta: number, follow = true) {
|
|
||||||
if (step.x === 0 && step.z === 0) return 0;
|
if (step.x === 0 && step.z === 0) return 0;
|
||||||
const heading = Math.atan2(step.x, step.z);
|
const heading = Math.atan2(step.x, step.z);
|
||||||
const turn = Math.atan2(Math.sin(heading - character.rotation.y), Math.cos(heading - character.rotation.y));
|
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));
|
character.rotation.y += Math.max(-limit, Math.min(limit, turn));
|
||||||
// Turn in place for rear destinations; accelerate as the body faces its path.
|
// 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;
|
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;
|
return alignment;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Observer stays on the walking surface; only the render camera is offset. */
|
/** Observer stays on the walking surface; only the render camera is offset. */
|
||||||
export function updateThirdPersonCamera(camera: PerspectiveCamera, observer: PerspectiveCamera,
|
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);
|
observer.getWorldDirection(direction);
|
||||||
// Limit vertical orbit so the character remains readable at extreme look angles.
|
// 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.y = Math.max(-0.45, Math.min(0.45, direction.y));
|
||||||
direction.normalize();
|
direction.normalize();
|
||||||
camera.position.copy(observer.position).addScaledVector(direction, -CHARACTER.cameraDistance);
|
camera.position.copy(observer.position).addScaledVector(direction, -distance);
|
||||||
camera.position.y += CHARACTER.cameraLift;
|
camera.position.y += CHARACTER.cameraLift;
|
||||||
// Sample the boom as well as its endpoint to avoid crossing a rising slope.
|
// Sample the boom as well as its endpoint to avoid crossing a rising slope.
|
||||||
for (let i = 1; i <= 12; i++) {
|
for (let i = 1; i <= 12; i++) {
|
||||||
|
|||||||
@@ -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 { Sheep } from '../scene/sheep.ts';
|
||||||
import type { PlayerState } from './protocol.ts';
|
import type { PlayerState } from './protocol.ts';
|
||||||
import { MULTIPLAYER } from '../constants/multiplayer.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) {
|
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 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 {
|
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) {
|
synchronize(states: PlayerState[], selfId: string) {
|
||||||
const present = new Set(states.filter(p => p.id !== selfId).map(p => p.id));
|
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); }
|
for (const [id, entry] of players) if (!present.has(id)) { entry.sheep.dispose(); players.delete(id); }
|
||||||
@@ -29,18 +57,31 @@ export function createRemotePlayers(template: Sheep, scene: Object3D, heightAt:
|
|||||||
entry.sheep.setEquipment(state.equipment.umbrella, state.equipment.raincoat, state.equipment.hood, state.equipment.flashlight);
|
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;
|
let changed = false;
|
||||||
for (const entry of players.values()) {
|
for (const entry of players.values()) {
|
||||||
const { sheep, state, from } = entry;
|
const { sheep, state, from } = entry;
|
||||||
const duration = MULTIPLAYER.interpolationMs / 1000;
|
const duration = MULTIPLAYER.interpolationMs / 1000;
|
||||||
const interpolating = entry.elapsed < duration;
|
const interpolating = entry.elapsed < duration;
|
||||||
entry.elapsed += Math.max(0, delta);
|
if (interpolating) {
|
||||||
const fraction = Math.min(1, entry.elapsed / duration);
|
entry.elapsed = Math.min(duration, entry.elapsed + Math.max(0, delta));
|
||||||
const x = MathUtils.lerp(from.x, state.x, fraction), z = MathUtils.lerp(from.z, state.z, fraction);
|
const fraction = Math.min(1, entry.elapsed / duration);
|
||||||
sheep.group.position.set(x, heightAt(x, z), z);
|
const x = MathUtils.lerp(from.x, state.x, fraction), z = MathUtils.lerp(from.z, state.z, fraction);
|
||||||
const angle = Math.atan2(Math.sin(state.yaw - entry.yaw), Math.cos(state.yaw - entry.yaw));
|
if (x !== sheep.group.position.x || z !== sheep.group.position.z) {
|
||||||
sheep.group.rotation.y = entry.yaw + angle * 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;
|
||||||
|
}
|
||||||
|
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,
|
const active = sheep.pose(state.emote, Math.max(0, serverTime - state.emoteStartedAt) / 1000,
|
||||||
state.moving && serverTime - state.updatedAt < MULTIPLAYER.staleMovementMs, serverTime / 1000);
|
state.moving && serverTime - state.updatedAt < MULTIPLAYER.staleMovementMs, serverTime / 1000);
|
||||||
changed = changed || interpolating || active;
|
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>>) {
|
export function createBuildings(data: BuildingData, terrain: Awaited<ReturnType<typeof loadTerrain>>) {
|
||||||
const group = new Group();
|
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 counts = { tagged: 0, levels: 0, fallback: 0 };
|
||||||
const lightSources: BuildingLightSource[] = [];
|
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);
|
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.setAttribute('buildingLightHeight', new BufferAttribute(lightHeights, 1));
|
||||||
geometry.rotateX(-Math.PI / 2);
|
geometry.rotateX(-Math.PI / 2);
|
||||||
geometry.translate(x, base, z);
|
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]++;
|
counts[source]++;
|
||||||
}
|
}
|
||||||
for (const source of ['tagged', 'levels', 'fallback'] as const) {
|
for (const source of ['tagged', 'levels', 'fallback'] as const) {
|
||||||
if (!geometries[source].length) continue;
|
for (const bucket of geometries[source].values()) {
|
||||||
const merged = mergeGeometries(geometries[source], false)!;
|
const merged = mergeGeometries(bucket, false)!;
|
||||||
geometries[source].forEach(g => g.dispose());
|
bucket.forEach(g => g.dispose());
|
||||||
// Lighting uses world XZ and local height, not UVs. Retain normals and
|
// Lighting uses world XZ and local height, not UVs. Retain normals and
|
||||||
// lighting heights when sharing vertices so facade/roof seams stay sharp.
|
// lighting heights when sharing vertices so facade/roof seams stay sharp.
|
||||||
merged.deleteAttribute('uv');
|
merged.deleteAttribute('uv');
|
||||||
const indexed = indexBuildingGeometry(merged);
|
const indexed = indexBuildingGeometry(merged);
|
||||||
merged.dispose();
|
merged.dispose();
|
||||||
group.add(createBuildingMesh(source, indexed));
|
group.add(createBuildingMesh(source, indexed));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const target = structurePosition(BUILDINGS.target.latitude, BUILDINGS.target.longitude, terrain.metadata.origin);
|
const target = structurePosition(BUILDINGS.target.latitude, BUILDINGS.target.longitude, terrain.metadata.origin);
|
||||||
target.y = surface(target.x, target.z) + 20;
|
target.y = surface(target.x, target.z) + 20;
|
||||||
|
|||||||
@@ -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 { SCENE } from '../constants/scene.ts';
|
||||||
import { TERRAIN } from '../constants/terrain.ts';
|
import { TERRAIN } from '../constants/terrain.ts';
|
||||||
import { LOBBY_CHARACTER, LOBBY_SUNRISE } from '../constants/lobby.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. */
|
/** Same Gwanaksan DSM, summit correction, sky and solar disc as the observation scene. */
|
||||||
export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
||||||
@@ -59,27 +62,34 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
|||||||
characterCamera.position.z = 5;
|
characterCamera.position.z = 5;
|
||||||
foreground.add(sheep.group);
|
foreground.add(sheep.group);
|
||||||
const head = sheep.group.getObjectByName('Head')!;
|
const head = sheep.group.getObjectByName('Head')!;
|
||||||
const headRest = head.quaternion.clone();
|
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
let progress = matchMedia('(prefers-reduced-motion: reduce)').matches ? 1 : 0;
|
const mobile = matchMedia('(pointer: coarse)').matches;
|
||||||
|
const frameClock = createFrameClock(mobile ? RENDER_QUALITY.mobileFps : Infinity);
|
||||||
|
let progress = reducedMotion ? 1 : 0;
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
let lastFrame = 0;
|
let previousProgress = -1;
|
||||||
|
let gazeAngle = 0;
|
||||||
const render = () => {
|
const render = () => {
|
||||||
const date = new Date(LOBBY_SUNRISE.from + (LOBBY_SUNRISE.to - LOBBY_SUNRISE.from) * progress);
|
if (progress !== previousProgress) {
|
||||||
const state = calculateSun(date, observer);
|
previousProgress = progress;
|
||||||
direction.set(...directionFromAngles(state.azimuth, state.altitude));
|
const date = new Date(LOBBY_SUNRISE.from + (LOBBY_SUNRISE.to - LOBBY_SUNRISE.from) * progress);
|
||||||
sky.update(direction, state.altitude);
|
const state = calculateSun(date, observer);
|
||||||
sun.update(direction, state.altitude);
|
direction.set(...directionFromAngles(state.azimuth, state.altitude));
|
||||||
nightSky.update(date, observer, state.altitude);
|
sky.update(direction, state.altitude);
|
||||||
const moonAltitude = getMoonPosition(date, observer.latitude, observer.longitude).altitude;
|
sun.update(direction, state.altitude);
|
||||||
const gazeAltitude = MathUtils.lerp(moonAltitude, state.altitude, MathUtils.smoothstep(state.altitude, -8, 0));
|
nightSky.update(date, observer, state.altitude);
|
||||||
head.quaternion.copy(headRest);
|
const moonAltitude = getMoonPosition(date, observer.latitude, observer.longitude).altitude;
|
||||||
head.rotateX(-MathUtils.degToRad(MathUtils.clamp(gazeAltitude, 8, 28)));
|
const gazeAltitude = MathUtils.lerp(moonAltitude, state.altitude, MathUtils.smoothstep(state.altitude, -8, 0));
|
||||||
sheep.updateLighting(Math.max(LOBBY_CHARACTER.minimumLightingAltitude, state.altitude), 1);
|
gazeAngle = -MathUtils.degToRad(MathUtils.clamp(gazeAltitude, 8, 28));
|
||||||
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(state.altitude, -8, 15));
|
sheep.updateLighting(Math.max(LOBBY_CHARACTER.minimumLightingAltitude, state.altitude), 1);
|
||||||
fog.color.copy(night).lerp(dawn, MathUtils.smoothstep(state.altitude, -8, 0));
|
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(state.altitude, -8, 15));
|
||||||
fog.color.lerp(day, MathUtils.smoothstep(state.altitude, 0, 15));
|
fog.color.copy(night).lerp(dawn, MathUtils.smoothstep(state.altitude, -8, 0));
|
||||||
renderer.toneMappingExposure = MathUtils.lerp(0.12, LOBBY_SUNRISE.maxExposure, MathUtils.smoothstep(state.altitude, -12, 5));
|
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);
|
renderer.render(scene, camera);
|
||||||
const { clientWidth: width, clientHeight: height } = canvas;
|
const { clientWidth: width, clientHeight: height } = canvas;
|
||||||
const size = Math.min(LOBBY_CHARACTER.maxSize, width * LOBBY_CHARACTER.widthFraction, height * LOBBY_CHARACTER.heightFraction);
|
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;
|
const { clientWidth: width, clientHeight: height } = canvas;
|
||||||
camera.aspect = width / Math.max(1, height);
|
camera.aspect = width / Math.max(1, height);
|
||||||
camera.updateProjectionMatrix();
|
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);
|
renderer.setSize(width, height, false);
|
||||||
render();
|
render();
|
||||||
};
|
};
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
if (disposed) return;
|
if (disposed || document.hidden) return;
|
||||||
if (!document.hidden && lastFrame) elapsed += now - lastFrame;
|
const delta = frameClock.tick(now);
|
||||||
lastFrame = now;
|
if (delta !== undefined) {
|
||||||
progress = MathUtils.clamp((elapsed - LOBBY_SUNRISE.nightHoldMs) / LOBBY_SUNRISE.durationMs, 0, 1);
|
elapsed += delta * 1000;
|
||||||
if (!document.hidden) render();
|
progress = MathUtils.clamp((elapsed - LOBBY_SUNRISE.nightHoldMs) / LOBBY_SUNRISE.durationMs, 0, 1);
|
||||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
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 = () => {
|
cleanup = () => {
|
||||||
cancelAnimationFrame(frame);
|
cancelAnimationFrame(frame);
|
||||||
window.removeEventListener('resize', resize);
|
window.removeEventListener('resize', resize);
|
||||||
@@ -126,7 +145,7 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
|||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
document.addEventListener('visibilitychange', visible);
|
document.addEventListener('visibilitychange', visible);
|
||||||
resize();
|
resize();
|
||||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
if (!document.hidden && progress < 1) frame = requestAnimationFrame(tick);
|
||||||
})();
|
})();
|
||||||
return { ready, dispose() { disposed = true; cleanup?.(); cleanup = undefined; } };
|
return { ready, dispose() { disposed = true; cleanup?.(); cleanup = undefined; } };
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-8
@@ -104,6 +104,14 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
}
|
}
|
||||||
for (const source of converted.keys()) source.dispose();
|
for (const source of converted.keys()) source.dispose();
|
||||||
const baseColors = new Map([...materials].map(material => [material, material.color.clone()]));
|
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[] = [];
|
const originals: Object3D[] = [];
|
||||||
group.traverse(object => originals.push(object));
|
group.traverse(object => originals.push(object));
|
||||||
function bindRig(group: Group, ownsAssets: boolean): Sheep {
|
function bindRig(group: Group, ownsAssets: boolean): Sheep {
|
||||||
@@ -113,9 +121,23 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
const rigJoints = Object.fromEntries(['Head', 'ArmLeft', 'ArmRight', 'LegLeft', 'LegRight']
|
const rigJoints = Object.fromEntries(['Head', 'ArmLeft', 'ArmRight', 'LegLeft', 'LegRight']
|
||||||
.map(name => [name, group.getObjectByName(name)!]));
|
.map(name => [name, group.getObjectByName(name)!]));
|
||||||
const joints = rigJoints;
|
const joints = rigJoints;
|
||||||
|
const jointEntries = Object.entries(joints);
|
||||||
const rigCoatParts = coatParts.map(part => copy(part)!);
|
const rigCoatParts = coatParts.map(part => copy(part)!);
|
||||||
const rigHood = copy(hood), rigFoldedHood = copy(foldedHood), rigUmbrella = copy(gear?.umbrella);
|
const rigHood = copy(hood), rigFoldedHood = copy(foldedHood), rigUmbrella = copy(gear?.umbrella);
|
||||||
const rigFlashlight = copy(gear?.flashlight);
|
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 flashlightOn = false;
|
||||||
let flashlightPose = 0;
|
let flashlightPose = 0;
|
||||||
let previousPoseTime: number | undefined;
|
let previousPoseTime: number | undefined;
|
||||||
@@ -153,6 +175,50 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
},
|
},
|
||||||
pose(emote: Emote, elapsed: number, moving: boolean, time: number) {
|
pose(emote: Emote, elapsed: number, moving: boolean, time: number) {
|
||||||
const delta = Math.max(0, time - (previousPoseTime ?? time));
|
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;
|
const step = delta / FLASHLIGHT.poseTransitionSeconds;
|
||||||
previousPoseTime = time;
|
previousPoseTime = time;
|
||||||
const previousFlashlightPose = flashlightPose;
|
const previousFlashlightPose = flashlightPose;
|
||||||
@@ -161,12 +227,16 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
const umbrellaStep = delta / RAIN_GEAR.umbrellaTransitionSeconds;
|
const umbrellaStep = delta / RAIN_GEAR.umbrellaTransitionSeconds;
|
||||||
umbrellaPose = umbrellaOpen ? Math.min(1, umbrellaPose + umbrellaStep) : Math.max(0, umbrellaPose - umbrellaStep);
|
umbrellaPose = umbrellaOpen ? Math.min(1, umbrellaPose + umbrellaStep) : Math.max(0, umbrellaPose - umbrellaStep);
|
||||||
const carryingUmbrella = umbrellaOpen || umbrellaPose > 0;
|
const carryingUmbrella = umbrellaOpen || umbrellaPose > 0;
|
||||||
const changed = moving || emote !== 'idle' || previousMoving !== moving || previousEmote !== emote
|
const changed = idle || idleWeight > 0 || previousIdleWeight > 0
|
||||||
|
|| walkWeight > 0 || previousWalkWeight > 0
|
||||||
|
|| moving || emote !== 'idle' || previousMoving !== moving || previousEmote !== emote
|
||||||
|| previousFlashlightPose !== flashlightPose || flashlightPose !== Number(flashlightOn)
|
|| previousFlashlightPose !== flashlightPose || flashlightPose !== Number(flashlightOn)
|
||||||
|| previousUmbrellaPose !== umbrellaPose || umbrellaPose !== Number(umbrellaOpen);
|
|| previousUmbrellaPose !== umbrellaPose || umbrellaPose !== Number(umbrellaOpen);
|
||||||
previousMoving = moving;
|
previousMoving = moving;
|
||||||
previousEmote = emote;
|
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') {
|
if (emote === 'wave') {
|
||||||
pointArm(carryingUmbrella ? 'ArmLeft' : 'ArmRight',
|
pointArm(carryingUmbrella ? 'ArmLeft' : 'ArmRight',
|
||||||
aim.set(carryingUmbrella ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15));
|
aim.set(carryingUmbrella ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15));
|
||||||
@@ -174,12 +244,75 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
pointArm('ArmLeft', aim.set(.68, -.22, .64));
|
pointArm('ArmLeft', aim.set(.68, -.22, .64));
|
||||||
pointArm('ArmRight', aim.set(-.68, -.22, .64));
|
pointArm('ArmRight', aim.set(-.68, -.22, .64));
|
||||||
joints.Head.rotateX(.16);
|
joints.Head.rotateX(.16);
|
||||||
} else if (moving) {
|
} else if (moving || walkWeight > 0) {
|
||||||
const stride = Math.sin(time * 9) * .4;
|
const phase = time * walk.rate;
|
||||||
joints.LegLeft.rotateX(stride);
|
const swing = Math.sin(phase);
|
||||||
joints.LegRight.rotateX(-stride);
|
const amplitude = walkWeight * walk.strength;
|
||||||
joints.ArmLeft.rotateX(-stride * .6);
|
const sway = swing * walk.sway * amplitude;
|
||||||
joints.ArmRight.rotateX(stride * .6);
|
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.
|
// Keep the grip stable while the free arm and legs perform their own animations.
|
||||||
if (umbrellaPose > 0) {
|
if (umbrellaPose > 0) {
|
||||||
|
|||||||
+129
-22
@@ -9,6 +9,7 @@ import { observer } from '../config/observer.ts';
|
|||||||
import { SCENE } from '../constants/scene.ts';
|
import { SCENE } from '../constants/scene.ts';
|
||||||
import { EQUIPMENT_NOTICE, PRAYER_NOTICE } from '../constants/ui.ts';
|
import { EQUIPMENT_NOTICE, PRAYER_NOTICE } from '../constants/ui.ts';
|
||||||
import { MOVEMENT } from '../constants/movement.ts';
|
import { MOVEMENT } from '../constants/movement.ts';
|
||||||
|
import { CHARACTER } from '../constants/character.ts';
|
||||||
import { createFpsCameraController } from '../controls/fpsCameraController.ts';
|
import { createFpsCameraController } from '../controls/fpsCameraController.ts';
|
||||||
import { createSky } from './sky.ts';
|
import { createSky } from './sky.ts';
|
||||||
import { createSun } from './sun.ts';
|
import { createSun } from './sun.ts';
|
||||||
@@ -30,6 +31,9 @@ import { COAST } from '../constants/coast.ts';
|
|||||||
import { GISAMUN } from '../constants/gisamun.ts';
|
import { GISAMUN } from '../constants/gisamun.ts';
|
||||||
import { createHarbour, isGisamun } from './harbour.ts';
|
import { createHarbour, isGisamun } from './harbour.ts';
|
||||||
import { createRenderInvalidation } from './renderInvalidation.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 { createTerrainMinimap } from '../ui/terrainMinimap.ts';
|
||||||
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
|
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
|
||||||
import { createSummitWalking } from '../controls/summitWalking.ts';
|
import { createSummitWalking } from '../controls/summitWalking.ts';
|
||||||
@@ -50,6 +54,7 @@ import { faceTravel, updateThirdPersonCamera } from '../controls/thirdPersonCame
|
|||||||
import { createRemotePlayers } from '../multiplayer/remotePlayers.ts';
|
import { createRemotePlayers } from '../multiplayer/remotePlayers.ts';
|
||||||
import type { PlayerState, PlayerUpdate } from '../multiplayer/protocol.ts';
|
import type { PlayerState, PlayerUpdate } from '../multiplayer/protocol.ts';
|
||||||
import { createSelfBadge } from '../ui/selfBadge.ts';
|
import { createSelfBadge } from '../ui/selfBadge.ts';
|
||||||
|
import { createCharacterOverlay } from '../ui/characterOverlay.ts';
|
||||||
import { createFlashlight } from './flashlight.ts';
|
import { createFlashlight } from './flashlight.ts';
|
||||||
import { FLASHLIGHT } from '../constants/flashlight.ts';
|
import { FLASHLIGHT } from '../constants/flashlight.ts';
|
||||||
import { flashlightAfterSunUpdate } from '../controls/flashlight.ts';
|
import { flashlightAfterSunUpdate } from '../controls/flashlight.ts';
|
||||||
@@ -78,6 +83,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
let sharedNow: (() => number) | null = null;
|
let sharedNow: (() => number) | null = null;
|
||||||
let playerMoving = false;
|
let playerMoving = false;
|
||||||
const profiling = import.meta.env.DEV && new URLSearchParams(location.search).has('profile');
|
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 profile: ReturnType<typeof createRenderProfile> | undefined;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
const summitStarted = performance.now();
|
const summitStarted = performance.now();
|
||||||
@@ -168,20 +175,91 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
if (harbour) scene.add(harbour.group);
|
if (harbour) scene.add(harbour.group);
|
||||||
const destinationRay = new Raycaster();
|
const destinationRay = new Raycaster();
|
||||||
const pointer = new Vector2();
|
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();
|
const bounds = canvas.getBoundingClientRect();
|
||||||
pointer.set((x - bounds.left) / bounds.width * 2 - 1, -(y - bounds.top) / bounds.height * 2 + 1);
|
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;
|
const view = sheep ? followCamera : camera;
|
||||||
view.updateMatrixWorld(true);
|
view.updateMatrixWorld(true);
|
||||||
destinationRay.setFromCamera(pointer, view);
|
destinationRay.setFromCamera(pointer, view);
|
||||||
const surfaces = harbour ? [terrain.mesh, harbour.group] : [terrain.mesh];
|
const surfaces = harbour ? [terrain.mesh, harbour.group] : [terrain.mesh];
|
||||||
const hit = destinationRay.intersectObjects(surfaces, true)[0];
|
return destinationRay.intersectObjects(surfaces, true)[0];
|
||||||
return hit?.point ?? null;
|
};
|
||||||
|
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);
|
if (sheep) scene.add(sheep.group);
|
||||||
const emotes = createEmoteState();
|
const emotes = createEmoteState();
|
||||||
const emoteEvents = new AbortController();
|
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 waveButton = document.querySelector<HTMLButtonElement>('#emote-wave')!;
|
||||||
const primaryActionLabel = document.getElementById('primary-action-label')!;
|
const primaryActionLabel = document.getElementById('primary-action-label')!;
|
||||||
const primaryActionIcon = waveButton.querySelector('use')!;
|
const primaryActionIcon = waveButton.querySelector('use')!;
|
||||||
@@ -242,6 +320,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
const prayerAvailable = () => Boolean(solarState && canPray(solarState.date, solarState.sunrise));
|
const prayerAvailable = () => Boolean(solarState && canPray(solarState.date, solarState.sunrise));
|
||||||
const refreshEmotes = () => {
|
const refreshEmotes = () => {
|
||||||
refreshGear();
|
refreshGear();
|
||||||
|
directionButton.disabled = besideButton.disabled = !sheep || overview || networkBlocked;
|
||||||
const dark = Boolean(solarState && solarState.altitude <= FLASHLIGHT.nightAltitude);
|
const dark = Boolean(solarState && solarState.altitude <= FLASHLIGHT.nightAltitude);
|
||||||
waveButton.disabled = !sheep || overview || networkBlocked;
|
waveButton.disabled = !sheep || overview || networkBlocked;
|
||||||
primaryActionLabel.textContent = dark ? (flashlightOn ? '끄기' : '켜기') : '인사';
|
primaryActionLabel.textContent = dark ? (flashlightOn ? '끄기' : '켜기') : '인사';
|
||||||
@@ -309,7 +388,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
overviewCamera.updateProjectionMatrix();
|
overviewCamera.updateProjectionMatrix();
|
||||||
followCamera.aspect = camera.aspect;
|
followCamera.aspect = camera.aspect;
|
||||||
followCamera.updateProjectionMatrix();
|
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);
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
composer?.setPixelRatio(renderer.getPixelRatio());
|
composer?.setPixelRatio(renderer.getPixelRatio());
|
||||||
composer?.setSize(innerWidth, innerHeight);
|
composer?.setSize(innerWidth, innerHeight);
|
||||||
@@ -317,13 +397,13 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
};
|
};
|
||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
resize();
|
resize();
|
||||||
let previousTime = performance.now();
|
|
||||||
const forward = new Vector3();
|
const forward = new Vector3();
|
||||||
const travel = 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 cpuStart = profile?.active() ? performance.now() : 0;
|
||||||
const delta = (now - previousTime) / 1000;
|
|
||||||
previousTime = now;
|
|
||||||
const replayPosition = profile?.step(now);
|
const replayPosition = profile?.step(now);
|
||||||
let moving = false;
|
let moving = false;
|
||||||
let turning = false;
|
let turning = false;
|
||||||
@@ -342,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))) {
|
if ((next.x !== camera.position.x || next.z !== camera.position.z) && (!coast || isCoastalLand(coast.data, next.x, next.z))) {
|
||||||
travel.subVectors(next, camera.position);
|
travel.subVectors(next, camera.position);
|
||||||
if (sheep && !replayPosition) {
|
if (sheep && !replayPosition) {
|
||||||
const alignment = faceTravel(sheep.group, camera, travel, delta, !controls.looking);
|
const alignment = faceTravel(sheep.group, travel, delta);
|
||||||
turning = alignment < .999;
|
turning = alignment < .999;
|
||||||
next.copy(camera.position).addScaledVector(travel, alignment);
|
next.copy(camera.position).addScaledVector(travel, alignment);
|
||||||
rendering.invalidate();
|
rendering.invalidate();
|
||||||
@@ -352,10 +432,17 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
camera.position.copy(next);
|
camera.position.copy(next);
|
||||||
placeObserver();
|
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;
|
playerMoving = moving;
|
||||||
if (remotePlayers?.update(delta, sharedNow ? sharedNow() : Date.now())) rendering.invalidate();
|
|
||||||
ocean?.animate(now / 1000);
|
ocean?.animate(now / 1000);
|
||||||
let weatherChanged = false;
|
let weatherChanged = false;
|
||||||
const blend = 1 - Math.exp(-Math.min(delta, 0.1) / WEATHER.transitionSeconds);
|
const blend = 1 - Math.exp(-Math.min(delta, 0.1) / WEATHER.transitionSeconds);
|
||||||
@@ -377,12 +464,14 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
forward.set(...directionFromAngles(solarState.azimuth, 0));
|
forward.set(...directionFromAngles(solarState.azimuth, 0));
|
||||||
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
|
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
|
||||||
} else if (moving && replayPosition) {
|
} 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();
|
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;
|
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);
|
flashlight?.update(flashlightOn && !overview);
|
||||||
const precipitating = precipitation.update(delta, activeCamera.position, atmosphere.rain, atmosphere.snowfall,
|
const precipitating = precipitation.update(delta, activeCamera.position, atmosphere.rain, atmosphere.snowfall,
|
||||||
MathUtils.smoothstep(solarState?.altitude ?? -12, -8, 15), canvas.height);
|
MathUtils.smoothstep(solarState?.altitude ?? -12, -8, 15), canvas.height);
|
||||||
@@ -403,7 +492,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
bloom.threshold = torchActive ? FLASHLIGHT.bloomThreshold : BUILDINGS.lighting.bloomThreshold;
|
bloom.threshold = torchActive ? FLASHLIGHT.bloomThreshold : BUILDINGS.lighting.bloomThreshold;
|
||||||
bloom.radius = torchActive ? FLASHLIGHT.bloomRadius : BUILDINGS.lighting.bloomRadius;
|
bloom.radius = torchActive ? FLASHLIGHT.bloomRadius : BUILDINGS.lighting.bloomRadius;
|
||||||
}
|
}
|
||||||
if (composer && !profile?.disablePost) {
|
if (composer && bloom?.enabled && !profile?.disablePost) {
|
||||||
renderPass.camera = activeCamera;
|
renderPass.camera = activeCamera;
|
||||||
composer.render(delta);
|
composer.render(delta);
|
||||||
} else renderer.render(scene, activeCamera);
|
} else renderer.render(scene, activeCamera);
|
||||||
@@ -411,12 +500,21 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
if (cpuStart) profile?.end(now, { update: updateEnd - cpuStart, minimap: minimapEnd - updateEnd,
|
if (cpuStart) profile?.end(now, { update: updateEnd - cpuStart, minimap: minimapEnd - updateEnd,
|
||||||
submit: performance.now() - minimapEnd });
|
submit: performance.now() - minimapEnd });
|
||||||
if (profile?.observe(now, true)) resize();
|
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 = {
|
const world = {
|
||||||
buildingCounts: buildings?.counts,
|
buildingCounts: buildings?.counts,
|
||||||
captureFrame(view?: PerspectiveCamera) {
|
captureFrame(view?: PerspectiveCamera) {
|
||||||
const activeCamera = view ?? (overview ? overviewCamera : sheep ? followCamera : camera);
|
const activeCamera = view ?? (overview ? overviewCamera : sheep ? followCamera : camera);
|
||||||
if (composer && !profile?.disablePost) {
|
remotePlayers?.update(0, sharedNow ? sharedNow() : Date.now(), activeCamera);
|
||||||
|
rendering.invalidate();
|
||||||
|
if (composer && bloom?.enabled && !profile?.disablePost) {
|
||||||
renderPass.camera = activeCamera;
|
renderPass.camera = activeCamera;
|
||||||
composer.render(0);
|
composer.render(0);
|
||||||
} else renderer.render(scene, activeCamera);
|
} else renderer.render(scene, activeCamera);
|
||||||
@@ -446,7 +544,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
},
|
},
|
||||||
setConnection(blocked: boolean, clock: (() => number) | null) {
|
setConnection(blocked: boolean, clock: (() => number) | null) {
|
||||||
networkBlocked = blocked; sharedNow = clock;
|
networkBlocked = blocked; sharedNow = clock;
|
||||||
if (blocked) { controls.clear(); playerMoving = false; emotes.cancel(); }
|
if (blocked) { controls.clear(); closeCompanion(); playerMoving = false; emotes.cancel(); }
|
||||||
|
refreshEmotes();
|
||||||
rendering.invalidate();
|
rendering.invalidate();
|
||||||
},
|
},
|
||||||
applyPlayer(state: PlayerState) {
|
applyPlayer(state: PlayerState) {
|
||||||
@@ -460,8 +559,12 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
},
|
},
|
||||||
synchronizePlayers(players: PlayerState[], selfId: string) {
|
synchronizePlayers(players: PlayerState[], selfId: string) {
|
||||||
remotePlayers?.synchronize(players, selfId); rendering.invalidate();
|
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) {
|
setWeather(weather: WeatherSample | null) {
|
||||||
weatherTarget = weatherAppearance(weather);
|
weatherTarget = weatherAppearance(weather);
|
||||||
rainGear.updateWeather(weather?.rain ?? null, weather?.snowfall ?? null);
|
rainGear.updateWeather(weather?.rain ?? null, weather?.snowfall ?? null);
|
||||||
@@ -474,6 +577,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
},
|
},
|
||||||
resetPosition() {
|
resetPosition() {
|
||||||
emotes.cancel();
|
emotes.cancel();
|
||||||
|
closeCompanion();
|
||||||
walkedDistance = 0;
|
walkedDistance = 0;
|
||||||
restoreView(); controls.clear(); camera.position.set(start.x, eyeHeight, start.z);
|
restoreView(); controls.clear(); camera.position.set(start.x, eyeHeight, start.z);
|
||||||
placeObserver();
|
placeObserver();
|
||||||
@@ -488,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); },
|
lookAtCity() { restoreView(); camera.lookAt(coast && !harbour ? new Vector3(target.x, camera.position.y, target.z) : target); },
|
||||||
showCityOverview() {
|
showCityOverview() {
|
||||||
controls.clear();
|
controls.clear();
|
||||||
|
closeCompanion();
|
||||||
emotes.cancel();
|
emotes.cancel();
|
||||||
overview = true;
|
overview = true;
|
||||||
refreshEmotes();
|
refreshEmotes();
|
||||||
@@ -505,14 +610,14 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
camera.lookAt(camera.position.clone().add(forward));
|
camera.lookAt(camera.position.clone().add(forward));
|
||||||
if (sheep) {
|
if (sheep) {
|
||||||
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
|
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
|
||||||
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
|
updateFollowCamera();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
lookAtSun() {
|
lookAtSun() {
|
||||||
if (!solarState) return;
|
if (!solarState) return;
|
||||||
restoreView();
|
restoreView();
|
||||||
camera.lookAt(camera.position.clone().add(new Vector3(...directionFromAngles(solarState.azimuth, 0))));
|
camera.lookAt(camera.position.clone().add(new Vector3(...directionFromAngles(solarState.azimuth, 0))));
|
||||||
if (sheep) updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
|
if (sheep) updateFollowCamera();
|
||||||
},
|
},
|
||||||
setEyeHeight(meters: number) {
|
setEyeHeight(meters: number) {
|
||||||
eyeHeight = meters;
|
eyeHeight = meters;
|
||||||
@@ -540,8 +645,10 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
closeCompanion();
|
||||||
profile?.dispose();
|
profile?.dispose();
|
||||||
renderer.setAnimationLoop(null);
|
renderer.setAnimationLoop(null);
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
minimap.dispose();
|
minimap.dispose();
|
||||||
compass.dispose();
|
compass.dispose();
|
||||||
selfBadge?.dispose();
|
selfBadge?.dispose();
|
||||||
@@ -572,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 (import.meta.env.DEV && profiling) void import('../dev/renderProfile.ts').then(({ createRenderProfile }) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
profile = createRenderProfile(renderer, scene, camera, () => world.resetPosition(), target, () =>
|
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;
|
renderer.info.autoReset = false;
|
||||||
resize();
|
resize();
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-1
@@ -16,7 +16,7 @@ button[aria-pressed=true], button.primary { background: #e3ba86; color: #28251f;
|
|||||||
button[aria-pressed=true]:hover, button.primary:hover { background: #f0cca0; }
|
button[aria-pressed=true]:hover, button.primary:hover { background: #f0cca0; }
|
||||||
:focus-visible { outline: 2px solid #f0c48e; outline-offset: 3px; }
|
:focus-visible { outline: 2px solid #f0c48e; outline-offset: 3px; }
|
||||||
.hud { position: fixed; inset: 0; pointer-events: none; }
|
.hud { position: fixed; inset: 0; pointer-events: none; }
|
||||||
.self-badge { 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; }
|
.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 { 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-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; }
|
.compass-tick { position: absolute; top: 0; transform: translateX(-50%); height: 39px; padding-top: 18px; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||||
@@ -55,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); } }
|
@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; } }
|
@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; }
|
.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 { 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; }
|
#ui-dialog[open] { display: flex; flex-direction: column; }
|
||||||
|
|||||||
@@ -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 scale = root.querySelector<HTMLElement>('.compass-scale')!;
|
||||||
const bearing = root.querySelector<HTMLElement>('.compass-bearing')!;
|
const bearing = root.querySelector<HTMLElement>('.compass-bearing')!;
|
||||||
const direction = new Vector3();
|
const direction = new Vector3();
|
||||||
|
let previousHeading: number | undefined;
|
||||||
const ticks = Array.from({ length: 360 / COMPASS.tickDegrees }, (_, index) => {
|
const ticks = Array.from({ length: 360 / COMPASS.tickDegrees }, (_, index) => {
|
||||||
const degrees = index * COMPASS.tickDegrees;
|
const degrees = index * COMPASS.tickDegrees;
|
||||||
const tick = document.createElement('span');
|
const tick = document.createElement('span');
|
||||||
@@ -19,6 +20,8 @@ export function createCompass() {
|
|||||||
update(camera: Camera) {
|
update(camera: Camera) {
|
||||||
camera.getWorldDirection(direction);
|
camera.getWorldDirection(direction);
|
||||||
const heading = (Math.atan2(direction.x, -direction.z) * 180 / Math.PI + 360) % 360;
|
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) {
|
for (const { tick, degrees } of ticks) {
|
||||||
const offset = (degrees - heading + 540) % 360 - 180;
|
const offset = (degrees - heading + 540) % 360 - 180;
|
||||||
tick.hidden = Math.abs(offset) > COMPASS.visibleDegrees / 2;
|
tick.hidden = Math.abs(offset) > COMPASS.visibleDegrees / 2;
|
||||||
|
|||||||
+3
-17
@@ -1,12 +1,8 @@
|
|||||||
import { Box3, Vector3, type Camera, type Object3D } from 'three';
|
import { type Object3D } from 'three';
|
||||||
import { CHARACTER } from '../constants/character.ts';
|
import { createCharacterOverlay } from './characterOverlay.ts';
|
||||||
|
|
||||||
/** A screen-sized label attached only to the local player's head. */
|
/** A screen-sized label attached only to the local player's head. */
|
||||||
export function createSelfBadge(player: Object3D, canvas: HTMLCanvasElement) {
|
export function createSelfBadge(player: Object3D, canvas: HTMLCanvasElement) {
|
||||||
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;
|
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
badge.className = 'self-badge';
|
badge.className = 'self-badge';
|
||||||
badge.textContent = '나';
|
badge.textContent = '나';
|
||||||
@@ -14,17 +10,7 @@ export function createSelfBadge(player: Object3D, canvas: HTMLCanvasElement) {
|
|||||||
badge.hidden = true;
|
badge.hidden = true;
|
||||||
document.querySelector('.hud')!.append(badge);
|
document.querySelector('.hud')!.append(badge);
|
||||||
return {
|
return {
|
||||||
update(camera: Camera, visible: boolean) {
|
...createCharacterOverlay(player, canvas, badge),
|
||||||
if (!visible) { badge.hidden = true; return; }
|
|
||||||
head.getWorldPosition(point);
|
|
||||||
point.y += headTop + CHARACTER.selfBadgeGap;
|
|
||||||
point.project(camera);
|
|
||||||
badge.hidden = point.z < -1 || point.z > 1 || Math.abs(point.x) > 1 || Math.abs(point.y) > 1;
|
|
||||||
if (badge.hidden) return;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
badge.style.left = `${rect.left + (point.x + 1) * rect.width / 2}px`;
|
|
||||||
badge.style.top = `${rect.top + (1 - point.y) * rect.height / 2}px`;
|
|
||||||
},
|
|
||||||
dispose() { badge.remove(); },
|
dispose() { badge.remove(); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export class TimeController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private sync = () => {
|
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) {
|
private render(date: Date) {
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ test('downloaded footprints are deduplicated, closed, and generate finite merged
|
|||||||
const city=createBuildings(data,terrain);
|
const city=createBuildings(data,terrain);
|
||||||
assert.equal(Object.values(city.counts).reduce((a,b)=>a+b,0),data.features.length);
|
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.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));
|
for(const mesh of city.group.children as Mesh[]) assert.ok(Array.from(mesh.geometry.attributes.position.array).every(Number.isFinite));
|
||||||
city.dispose();
|
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 leg = sheep.group.getObjectByName('LegLeft')!;
|
||||||
const rest = leg.quaternion.clone();
|
const rest = leg.quaternion.clone();
|
||||||
assert.equal(sheep.pose('idle', 0, true, 1), true);
|
assert.equal(sheep.pose('idle', 0, true, 1), true);
|
||||||
|
sheep.pose('idle', 0, true, 1.2);
|
||||||
assert.ok(leg.quaternion.angleTo(rest) > .01);
|
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.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 arm = sheep.group.getObjectByName('ArmRight')!;
|
||||||
const armRest = arm.quaternion.clone();
|
const armRest = arm.quaternion.clone();
|
||||||
sheep.pose('wave', 1, false, 1);
|
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();
|
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)]) {
|
for (const step of [new Vector3(1, 0, 0), new Vector3(0, 0, 1), new Vector3(-1, 0, -.01)]) {
|
||||||
const character = new Group();
|
const character = new Group();
|
||||||
character.rotation.y = Math.PI;
|
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');
|
camera.rotation.set(-.2, 0, 0, 'YXZ');
|
||||||
const original = camera.quaternion.clone();
|
const original = camera.quaternion.clone();
|
||||||
const initialFacing = character.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');
|
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');
|
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');
|
assert.ok(camera.quaternion.equals(original), 'walking preserves the chosen camera angle');
|
||||||
for (let i = 0; i < 180; i++) faceTravel(character, camera, step, 1 / 60);
|
for (let i = 0; i < 180; i++) faceTravel(character, step, 1 / 60);
|
||||||
assert.ok(character.getWorldDirection(new Vector3()).dot(step.clone().normalize()) > .999);
|
assert.ok(character.getWorldDirection(new Vector3()).dot(step.clone().normalize()) > .999);
|
||||||
const view = camera.getWorldDirection(new Vector3());
|
assert.ok(camera.quaternion.equals(original));
|
||||||
view.y = 0;
|
|
||||||
assert.ok(view.normalize().dot(step.clone().normalize()) > .999);
|
|
||||||
assert.ok(Math.abs(camera.rotation.x + .2) < 1e-6, 'preserve viewing pitch');
|
assert.ok(Math.abs(camera.rotation.x + .2) < 1e-6, 'preserve viewing pitch');
|
||||||
const settled = character.quaternion.clone();
|
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');
|
assert.ok(character.quaternion.angleTo(settled) < 1e-6, 'idle keeps the last facing');
|
||||||
const manual = camera.quaternion.clone();
|
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');
|
assert.ok(camera.quaternion.angleTo(manual) < 1e-6, 'manual looking takes priority');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 { createServerClock } from '../src/multiplayer/clock.ts';
|
||||||
import { createRemotePlayers } from '../src/multiplayer/remotePlayers.ts';
|
import { createRemotePlayers } from '../src/multiplayer/remotePlayers.ts';
|
||||||
import type { Sheep } from '../src/scene/sheep.ts';
|
import type { Sheep } from '../src/scene/sheep.ts';
|
||||||
@@ -16,13 +16,64 @@ test('server clock uses round trip midpoint and monotonic elapsed time', () => {
|
|||||||
assert.equal(clock.now(), 1001020);
|
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', () => {
|
test('remote snapshot reconciles membership, equipment and interpolated motion without local duplicate', () => {
|
||||||
const scene = new Group();
|
const scene = new Group();
|
||||||
const equipment: boolean[][] = [];
|
const equipment: boolean[][] = [];
|
||||||
let disposed = 0;
|
let disposed = 0;
|
||||||
const template = { clone() { return { group: new Group(), setEquipment: (...v: boolean[]) => equipment.push(v),
|
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;
|
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,
|
const player: PlayerState = { id: 'other', nickname: '방문자', x: 0, z: 0, yaw: 0, moving: false,
|
||||||
emote: 'idle', equipment: { umbrella: true, raincoat: true, hood: false, flashlight: true }, 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');
|
remote.synchronize([player, { ...player, id: 'self' }], 'self');
|
||||||
@@ -34,6 +85,9 @@ test('remote snapshot reconciles membership, equipment and interpolated motion w
|
|||||||
remote.update(.2, 1300);
|
remote.update(.2, 1300);
|
||||||
assert.equal(scene.children[0].position.x, 1);
|
assert.equal(scene.children[0].position.x, 1);
|
||||||
assert.equal(scene.children[0].position.y, 3);
|
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');
|
remote.synchronize([], 'self');
|
||||||
assert.equal(scene.children.length, 0);
|
assert.equal(scene.children.length, 0);
|
||||||
assert.equal(disposed, 1);
|
assert.equal(disposed, 1);
|
||||||
|
|||||||
+32
-2
@@ -9,6 +9,36 @@ import { createFlashlight } from '../src/scene/flashlight.ts';
|
|||||||
import { FLASHLIGHT } from '../src/constants/flashlight.ts';
|
import { FLASHLIGHT } from '../src/constants/flashlight.ts';
|
||||||
import { Vector3 } from 'three';
|
import { Vector3 } from 'three';
|
||||||
|
|
||||||
|
test('idle breathing carries the coat and hood without lifting the feet or changing the umbrella grip', async () => {
|
||||||
|
const [model, umbrella, raincoat] = await Promise.all(['red-sheep', 'umbrella', 'raincoat'].map(asset));
|
||||||
|
raincoat.name = 'TestRaincoat';
|
||||||
|
const sheep = createSheep(model, { umbrella, raincoat });
|
||||||
|
sheep.setEquipment(true, true, true);
|
||||||
|
const body = model.getObjectByName('Body')!, head = model.getObjectByName('Head')!;
|
||||||
|
const hood = model.getObjectByName('CoatHood')!, arm = model.getObjectByName('ArmRight')!;
|
||||||
|
const bodyScale = body.scale.clone(), coatScale = raincoat.scale.clone();
|
||||||
|
const foot = model.getObjectByName('LegRight')!, footPosition = foot.getWorldPosition(new Vector3());
|
||||||
|
sheep.pose('idle', 0, false, 0);
|
||||||
|
sheep.pose('idle', 0, false, 1);
|
||||||
|
const grip = arm.quaternion.clone();
|
||||||
|
assert.ok(Math.abs(body.scale.y / bodyScale.y - raincoat.scale.y / coatScale.y) < 1e-8);
|
||||||
|
assert.ok(Math.abs(body.scale.z / bodyScale.z - raincoat.scale.z / coatScale.z) < 1e-8);
|
||||||
|
assert.ok(hood.getWorldPosition(new Vector3()).distanceTo(head.getWorldPosition(new Vector3())) < 1e-8);
|
||||||
|
sheep.pose('idle', 0, false, 2);
|
||||||
|
assert.ok(arm.quaternion.angleTo(grip) < 1e-8);
|
||||||
|
assert.ok(foot.getWorldPosition(new Vector3()).distanceTo(footPosition) < 1e-8);
|
||||||
|
const remote = sheep.clone();
|
||||||
|
remote.pose('idle', 0, false, 0);
|
||||||
|
remote.pose('idle', 0, false, 1);
|
||||||
|
const remoteCoat = remote.group.getObjectByName('TestRaincoat')!;
|
||||||
|
const remoteCoatPosition = remoteCoat.position.clone();
|
||||||
|
sheep.pose('idle', 0, true, 3);
|
||||||
|
remote.pose('idle', 0, false, 1);
|
||||||
|
assert.deepEqual(remoteCoat.position, remoteCoatPosition, 'local walking cannot change a remote coat pivot');
|
||||||
|
remote.dispose();
|
||||||
|
sheep.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('authored flashlight stays in the left grip and uses lumen power only when switched on', async () => {
|
test('authored flashlight stays in the left grip and uses lumen power only when switched on', async () => {
|
||||||
const [model, umbrella, raincoat, torch] = await Promise.all(['red-sheep', 'umbrella', 'raincoat', 'flashlight'].map(asset));
|
const [model, umbrella, raincoat, torch] = await Promise.all(['red-sheep', 'umbrella', 'raincoat', 'flashlight'].map(asset));
|
||||||
const sheep = createSheep(model, { umbrella, raincoat, flashlight: torch });
|
const sheep = createSheep(model, { umbrella, raincoat, flashlight: torch });
|
||||||
@@ -60,7 +90,7 @@ test('flashlight raises and lowers smoothly, retaining the prop until the arm is
|
|||||||
assert.equal(sheep.pose('idle', 0, false, 2), true, 'the final lowered frame is rendered');
|
assert.equal(sheep.pose('idle', 0, false, 2), true, 'the final lowered frame is rendered');
|
||||||
assert.ok(arm.quaternion.angleTo(rest) < 1e-6);
|
assert.ok(arm.quaternion.angleTo(rest) < 1e-6);
|
||||||
assert.equal(torch.visible, false);
|
assert.equal(torch.visible, false);
|
||||||
assert.equal(sheep.pose('idle', 0, false, 2.1), false, 'idle rendering can stop after the transition');
|
assert.equal(sheep.pose('idle', 0, false, 2.1), true, 'breathing continues after the equipment transition');
|
||||||
sheep.dispose();
|
sheep.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,6 +201,6 @@ test('umbrella follows the right hand through raising, reversal and lowering bef
|
|||||||
assert.equal(sheep.pose('idle', 0, false, 1.2), true);
|
assert.equal(sheep.pose('idle', 0, false, 1.2), true);
|
||||||
assert.ok(arm.quaternion.angleTo(rest) < 1e-6);
|
assert.ok(arm.quaternion.angleTo(rest) < 1e-6);
|
||||||
assert.equal(umbrella.visible, false);
|
assert.equal(umbrella.visible, false);
|
||||||
assert.equal(sheep.pose('idle', 0, false, 1.3), false);
|
assert.equal(sheep.pose('idle', 0, false, 1.3), true);
|
||||||
sheep.dispose();
|
sheep.dispose();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { createFpsCameraController } from '../src/controls/fpsCameraController.t
|
|||||||
import { directionFromAngles } from '../src/astronomy/direction.ts';
|
import { directionFromAngles } from '../src/astronomy/direction.ts';
|
||||||
import { SUMMIT_TERRAIN } from '../src/constants/summitTerrain.ts';
|
import { SUMMIT_TERRAIN } from '../src/constants/summitTerrain.ts';
|
||||||
|
|
||||||
function withController(run: (camera: PerspectiveCamera, canvas: EventTarget, picks: number[][]) => void) {
|
function withController(run: (camera: PerspectiveCamera, canvas: EventTarget, picks: number[][],
|
||||||
|
controller: ReturnType<typeof createFpsCameraController>) => void,
|
||||||
|
options: Parameters<typeof createFpsCameraController>[3] = {}) {
|
||||||
const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||||
const oldDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
const oldDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||||
const doc = Object.assign(new EventTarget(), { querySelector: () => ({ textContent: '' }) });
|
const doc = Object.assign(new EventTarget(), { querySelector: () => ({ textContent: '' }) });
|
||||||
@@ -16,8 +18,8 @@ function withController(run: (camera: PerspectiveCamera, canvas: EventTarget, pi
|
|||||||
const camera = new PerspectiveCamera();
|
const camera = new PerspectiveCamera();
|
||||||
const picks: number[][] = [];
|
const picks: number[][] = [];
|
||||||
const controller = createFpsCameraController(camera, canvas as unknown as HTMLCanvasElement,
|
const controller = createFpsCameraController(camera, canvas as unknown as HTMLCanvasElement,
|
||||||
(x, y) => { picks.push([x, y]); return new Vector3(x, 0, y); });
|
(x, y) => { picks.push([x, y]); return new Vector3(x, 0, y); }, options);
|
||||||
try { run(camera, canvas, picks); }
|
try { run(camera, canvas, picks, controller); }
|
||||||
finally {
|
finally {
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
for (const [key, value] of [['window', oldWindow], ['document', oldDocument]] as const) {
|
for (const [key, value] of [['window', oldWindow], ['document', oldDocument]] as const) {
|
||||||
@@ -26,11 +28,79 @@ function withController(run: (camera: PerspectiveCamera, canvas: EventTarget, pi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function pointer(canvas: EventTarget, type: string, x: number, y: number, pointerType = 'touch', button = 0) {
|
function pointer(canvas: EventTarget, type: string, x: number, y: number, pointerType = 'touch', button = 0, pointerId = 1) {
|
||||||
canvas.dispatchEvent(Object.assign(new Event(type, { cancelable: true }),
|
canvas.dispatchEvent(Object.assign(new Event(type, { cancelable: true }),
|
||||||
{ pointerId: 1, isPrimary: true, pointerType, button, clientX: x, clientY: y }));
|
{ pointerId, isPrimary: pointerId === 1, pointerType, button, clientX: x, clientY: y }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('direction mode stops travel and rotates only the character until done', () => {
|
||||||
|
let yaw = 0;
|
||||||
|
withController((camera, canvas, picks, controller) => {
|
||||||
|
pointer(canvas, 'pointerdown', 20, 20);
|
||||||
|
pointer(canvas, 'pointerup', 20, 20);
|
||||||
|
assert.ok(controller.destination);
|
||||||
|
controller.setTurning(true);
|
||||||
|
assert.equal(controller.destination, null);
|
||||||
|
const view = camera.quaternion.clone();
|
||||||
|
pointer(canvas, 'pointerdown', 100, 100);
|
||||||
|
pointer(canvas, 'pointermove', 150, 120);
|
||||||
|
pointer(canvas, 'pointerup', 150, 120);
|
||||||
|
assert.notEqual(yaw, 0);
|
||||||
|
assert.ok(camera.quaternion.equals(view));
|
||||||
|
assert.equal(picks.length, 1);
|
||||||
|
controller.setTurning(false);
|
||||||
|
const facing = yaw;
|
||||||
|
pointer(canvas, 'pointerdown', 100, 100);
|
||||||
|
pointer(canvas, 'pointermove', 150, 100);
|
||||||
|
pointer(canvas, 'pointerup', 150, 100);
|
||||||
|
assert.equal(yaw, facing);
|
||||||
|
assert.ok(!camera.quaternion.equals(view));
|
||||||
|
}, { onTurn: delta => { yaw += delta; } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pinch zoom never rotates or becomes a destination when either finger lifts', () => {
|
||||||
|
let zoom = 1;
|
||||||
|
withController((camera, canvas, picks) => {
|
||||||
|
const view = camera.quaternion.clone();
|
||||||
|
pointer(canvas, 'pointerdown', 100, 100);
|
||||||
|
pointer(canvas, 'pointerdown', 200, 100, 'touch', 0, 2);
|
||||||
|
pointer(canvas, 'pointermove', 250, 100, 'touch', 0, 2);
|
||||||
|
assert.ok(zoom < 1);
|
||||||
|
pointer(canvas, 'pointerup', 100, 100);
|
||||||
|
pointer(canvas, 'pointermove', 280, 100, 'touch', 0, 2);
|
||||||
|
pointer(canvas, 'pointerup', 280, 100, 'touch', 0, 2);
|
||||||
|
assert.deepEqual(picks, []);
|
||||||
|
assert.ok(camera.quaternion.equals(view));
|
||||||
|
pointer(canvas, 'pointerdown', 50, 50);
|
||||||
|
pointer(canvas, 'pointerup', 50, 50);
|
||||||
|
assert.equal(picks.length, 1);
|
||||||
|
}, { onZoom: factor => { zoom *= factor; } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting a character takes priority over tap movement and blur exits turning', () => {
|
||||||
|
withController((_camera, canvas, picks, controller) => {
|
||||||
|
pointer(canvas, 'pointerdown', 100, 100);
|
||||||
|
pointer(canvas, 'pointerup', 100, 100);
|
||||||
|
assert.deepEqual(picks, []);
|
||||||
|
controller.setTurning(true);
|
||||||
|
window.dispatchEvent(new Event('blur'));
|
||||||
|
assert.equal(controller.turning, false);
|
||||||
|
}, { onSelect: () => true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening a menu stops walking without losing the explicit direction mode', () => {
|
||||||
|
withController((_camera, canvas, _picks, controller) => {
|
||||||
|
pointer(canvas, 'pointerdown', 100, 100);
|
||||||
|
pointer(canvas, 'pointerup', 100, 100);
|
||||||
|
assert.ok(controller.destination);
|
||||||
|
canvas.dispatchEvent(new Event('blur'));
|
||||||
|
assert.equal(controller.destination, null);
|
||||||
|
controller.setTurning(true);
|
||||||
|
canvas.dispatchEvent(new Event('blur'));
|
||||||
|
assert.equal(controller.turning, true, 'Done button can receive focus before exiting');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('touch tap and mouse right click select a destination, left click does not', () => {
|
test('touch tap and mouse right click select a destination, left click does not', () => {
|
||||||
withController((_camera, canvas, picks) => {
|
withController((_camera, canvas, picks) => {
|
||||||
for (const [type, button] of [['touch', 0], ['mouse', 2], ['mouse', 0]] as const) {
|
for (const [type, button] of [['touch', 0], ['mouse', 2], ['mouse', 0]] as const) {
|
||||||
|
|||||||
Reference in New Issue
Block a user