Compare commits

...
9 Commits
31 changed files with 2372 additions and 194 deletions
+3 -1
View File
@@ -10,8 +10,10 @@ 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 and sunrise-threads.service restarted.'
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.
+6 -1
View File
@@ -87,6 +87,11 @@
</fieldset>
</div>
</div>
<button type="button" id="stand-beside" hidden>나란히 서기</button>
<div class="movement-controls" aria-label="위치와 방향 조절">
<p id="direction-hint" role="status" hidden>좌우로 드래그하면 내 캐릭터만 돌아갑니다</p>
<button type="button" id="direction-toggle" aria-pressed="false" aria-describedby="direction-hint" disabled>방향 맞추기</button>
</div>
<div class="emote-controls" aria-label="캐릭터 액션">
<button type="button" id="emote-wave" disabled aria-pressed="false" aria-describedby="emote-status" title="손 흔들기 · 단축키 1"><svg class="icon" aria-hidden="true"><use href="#icon-wave"/></svg><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>
@@ -153,7 +158,7 @@
</section>
<section data-dialog-panel="settings" hidden>
<div class="height-field"><div class="slider-label"><label for="eye-height">관측 기준 높이</label><output id="eye-height-output" for="eye-height"></output></div><input id="eye-height" type="range" aria-describedby="eye-height-note" /><p id="eye-height-note" class="muted">태양 계산 기준 · 카메라는 3인칭</p></div>
<details open><summary>조작 방법</summary><p id="control-hint">터치 또는 우클릭한 곳으로 이동 · 드래그로 둘러보기</p><p class="muted">1 인사 · 2 소원빌기 · 3 우산 · 4 우비 · 5 모자</p><p class="muted">소원빌기는 선택 시각이 일출 전후 5분일 때 사용할 수 있습니다. 이동하면 감정표현이 종료됩니다.</p></details>
<details open><summary>조작 방법</summary><p id="control-hint">바닥 탭 또는 우클릭으로 이동 · 캐릭터 탭 또는 클릭으로 선택 · 드래그로 시점만 회전 · 두 손가락 핀치 또는 마우스 휠로 확대·축소 · 방향 맞추기를 누른 뒤 좌우 드래그로 내 캐릭터 회전, 완료로 종료</p><p class="muted">1 인사 · 2 소원빌기 · 3 우산 · 4 우비 · 5 모자</p><p class="muted">소원빌기는 선택 시각이 일출 전후 5분일 때 사용할 수 있습니다. 이동하면 감정표현이 종료됩니다.</p></details>
<a id="terrain-comparison" class="terrain-source" href="?terrain=original">원본 지형 비교</a>
</section>
<section data-dialog-panel="sources" hidden>
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,6 +3,7 @@ export const BUILDINGS = {
assetManifestUrl: '/buildings/gwacheon.baked.json',
assetBufferUrl: '/buildings/gwacheon.baked.bin',
assetVersion: 1,
tileSize: 1000,
metersPerLevel: 3,
// Visual placeholders only: not a claim about actual floors.
fallbackHeight: { apartments: 15, residential: 9, house: 6, commercial: 12, greenhouse: 3, roof: 3, school: 12, default: 9 } as Record<string, number>,
+35 -1
View File
@@ -1,6 +1,7 @@
export const CHARACTER = {
url: '/characters/red-sheep.glb?v=rounded-v10',
scale: 0.62,
visibilityRadius: 3, // Includes raised arms, umbrella and idle hops around the ground anchor.
selfBadgeGap: 0.12,
outline: 0.018,
ink: '#352724',
@@ -13,8 +14,41 @@ export const CHARACTER = {
waveSeconds: 3,
prayerWindowMs: 5 * 60 * 1000,
cameraDistance: 5,
cameraMinDistance: 2,
cameraMaxDistance: 12,
cameraLift: 1.15,
cameraClearance: 0.35,
cameraTurnRate: 1.8,
turnSpeed: 4,
walk: {
strength: 0.4,
rate: 9,
stride: 0.36,
transitionSeconds: 0.25,
sway: 0.026,
rise: 0.018,
roll: 0.035,
twist: 0.055,
headLag: 0.45,
headFollow: 0.65,
headRoll: 0.4,
headNod: 0.018,
armLag: 0.18,
armSwing: 0.65,
footLift: 0.035,
},
idle: {
periodSeconds: 3.8,
transitionSeconds: 0.3,
torsoRise: 0.01,
torsoDepth: 0.014,
headLift: 0.018,
shoulderLift: 0.008,
fidget: {
intervalSeconds: 30,
scratchSeconds: 2.4,
hopSeconds: 1,
hopHeight: 0.085,
hopCrouch: 0.025,
},
},
} as const;
+4 -1
View File
@@ -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
View File
@@ -1,4 +1,5 @@
export const RENDER_QUALITY = {
mobileFps: 30,
mobileCap: 1.5,
mobilePixelBudget: 1_500_000,
levels: [0.75, 1, 1.25, 1.5, 1.75, 2] as readonly number[],
+69 -16
View File
@@ -3,46 +3,94 @@ import { SCENE } from '../constants/scene.ts';
import { MOVEMENT } from '../constants/movement.ts';
export function createFpsCameraController(camera: PerspectiveCamera, canvas: HTMLCanvasElement,
pickDestination: (x: number, y: number) => Vector3 | null) {
pickDestination: (x: number, y: number) => Vector3 | null,
options: { onTurn?: (radians: number) => void; onZoom?: (factor: number) => void;
onSelect?: (x: number, y: number) => boolean; onModeChange?: (turning: boolean) => void } = {}) {
let destination: Vector3 | null = null;
let arrivalYaw: number | undefined;
let turning = false;
const pointers = new Map<number, { x: number; y: number }>();
let pinched = false;
const pinchDistance = () => {
const [a, b] = [...pointers.values()];
return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
};
let gesture: { id: number; startX: number; startY: number; x: number; y: number; dragged: boolean; canMove: boolean } | null = null;
canvas.tabIndex = 0;
const cancelGesture = () => {
const id = gesture?.id;
const ids = [...pointers.keys()];
gesture = null;
if (id !== undefined && canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
pointers.clear(); pinched = false;
for (const id of ids) if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
};
const clear = () => { destination = null; cancelGesture(); };
const stop = () => { destination = null; arrivalYaw = undefined; };
const blur = () => { stop(); cancelGesture(); };
const setTurning = (value: boolean) => {
stop(); cancelGesture(); turning = value; options.onModeChange?.(value);
};
const clear = () => setTurning(false);
const down = (event: PointerEvent) => {
if (!event.isPrimary || gesture || (event.button !== 0 && event.button !== 2)) return;
if (pointers.size >= 2 || (event.pointerType !== 'touch' && (!event.isPrimary || pointers.size))
|| (event.button !== 0 && event.button !== 2)) return;
event.preventDefault();
canvas.focus({ preventScroll: true });
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
canvas.setPointerCapture(event.pointerId);
if (pointers.size > 1) { pinched = true; return; }
gesture = { id: event.pointerId, startX: event.clientX, startY: event.clientY,
x: event.clientX, y: event.clientY, dragged: false, canMove: event.pointerType !== 'mouse' || event.button === 2 };
canvas.setPointerCapture(event.pointerId);
};
const move = (event: PointerEvent) => {
if (!pointers.has(event.pointerId)) return;
const before = pinchDistance();
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (pinched) {
const after = pinchDistance();
if (before > 0 && after > 0) options.onZoom?.(before / after);
return;
}
if (!gesture || gesture.id !== event.pointerId) return;
if (Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) > MOVEMENT.dragThreshold) gesture.dragged = true;
if (gesture.dragged) {
camera.rotation.reorder('YXZ');
camera.rotation.y -= (event.clientX - gesture.x) * MOVEMENT.dragSensitivity;
camera.rotation.x = MathUtils.clamp(camera.rotation.x - (event.clientY - gesture.y) * MOVEMENT.dragSensitivity,
-MathUtils.degToRad(SCENE.pitchLimitDegrees), MathUtils.degToRad(SCENE.pitchLimitDegrees));
if (turning) options.onTurn?.((event.clientX - gesture.x) * MOVEMENT.turnSensitivity);
else {
camera.rotation.reorder('YXZ');
camera.rotation.y -= (event.clientX - gesture.x) * MOVEMENT.dragSensitivity;
camera.rotation.x = MathUtils.clamp(camera.rotation.x - (event.clientY - gesture.y) * MOVEMENT.dragSensitivity,
-MathUtils.degToRad(SCENE.pitchLimitDegrees), MathUtils.degToRad(SCENE.pitchLimitDegrees));
}
}
gesture.x = event.clientX;
gesture.y = event.clientY;
};
const up = (event: PointerEvent) => {
if (!pointers.has(event.pointerId)) return;
if (pinched) {
pointers.delete(event.pointerId);
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
if (!pointers.size) cancelGesture();
return;
}
if (!gesture || gesture.id !== event.pointerId) return;
const tap = !gesture.dragged && Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) <= MOVEMENT.dragThreshold;
if (tap && gesture.canMove) destination = pickDestination(event.clientX, event.clientY);
if (tap && !turning) {
if (options.onSelect?.(event.clientX, event.clientY)) stop();
else if (gesture.canMove) { destination = pickDestination(event.clientX, event.clientY); arrivalYaw = undefined; }
}
cancelGesture();
};
const cancel = (event: PointerEvent) => { if (gesture?.id === event.pointerId) cancelGesture(); };
const cancel = (event: PointerEvent) => { if (pointers.has(event.pointerId)) cancelGesture(); };
const wheel = (event: WheelEvent) => {
if (!options.onZoom) return;
event.preventDefault();
options.onZoom(Math.exp(MathUtils.clamp(event.deltaY, -100, 100) * MOVEMENT.wheelSensitivity));
};
const escape = (event: KeyboardEvent) => { if (event.code === 'Escape') clear(); };
const contextMenu = (event: Event) => event.preventDefault();
window.addEventListener('blur', clear);
canvas.addEventListener('blur', clear);
window.addEventListener('keydown', escape);
canvas.addEventListener('blur', blur);
canvas.addEventListener('wheel', wheel, { passive: false });
document.addEventListener('visibilitychange', clear);
canvas.addEventListener('pointerdown', down);
canvas.addEventListener('pointermove', move);
@@ -52,14 +100,19 @@ export function createFpsCameraController(camera: PerspectiveCamera, canvas: HTM
canvas.addEventListener('contextmenu', contextMenu);
return {
get destination() { return destination; },
get looking() { return Boolean(gesture?.dragged); },
stop() { destination = null; },
get arrivalYaw() { return arrivalYaw; },
get turning() { return turning; },
setTurning,
moveTo(point: Vector3, yaw?: number) { setTurning(false); destination = point.clone(); arrivalYaw = yaw; },
stop,
active() { return document.activeElement === canvas; },
clear,
dispose() {
clear();
window.removeEventListener('blur', clear);
canvas.removeEventListener('blur', clear);
window.removeEventListener('keydown', escape);
canvas.removeEventListener('blur', blur);
canvas.removeEventListener('wheel', wheel);
document.removeEventListener('visibilitychange', clear);
canvas.removeEventListener('pointerdown', down);
canvas.removeEventListener('pointermove', move);
+4 -10
View File
@@ -4,9 +4,8 @@ import { CHARACTER } from '../constants/character.ts';
const direction = new Vector3();
const target = new Vector3();
/** Face the accepted ground step, while the camera catches up along the shortest yaw arc. */
export function faceTravel(character: Object3D, observer: PerspectiveCamera, step: Vector3,
delta: number, follow = true) {
/** Face the accepted ground step without changing the user's camera orbit. */
export function faceTravel(character: Object3D, step: Vector3, delta: number) {
if (step.x === 0 && step.z === 0) return 0;
const heading = Math.atan2(step.x, step.z);
const turn = Math.atan2(Math.sin(heading - character.rotation.y), Math.cos(heading - character.rotation.y));
@@ -14,22 +13,17 @@ export function faceTravel(character: Object3D, observer: PerspectiveCamera, ste
character.rotation.y += Math.max(-limit, Math.min(limit, turn));
// Turn in place for rear destinations; accelerate as the body faces its path.
const alignment = Math.max(0, Math.cos(heading - character.rotation.y)) ** 2;
if (!follow) return alignment;
observer.rotation.reorder('YXZ');
const targetYaw = character.rotation.y + Math.PI;
const difference = Math.atan2(Math.sin(targetYaw - observer.rotation.y), Math.cos(targetYaw - observer.rotation.y));
observer.rotation.y += difference * (1 - Math.exp(-CHARACTER.cameraTurnRate * Math.min(delta, .1)));
return alignment;
}
/** Observer stays on the walking surface; only the render camera is offset. */
export function updateThirdPersonCamera(camera: PerspectiveCamera, observer: PerspectiveCamera,
heightAt: (x: number, z: number) => number) {
heightAt: (x: number, z: number) => number, distance: number = CHARACTER.cameraDistance) {
observer.getWorldDirection(direction);
// Limit vertical orbit so the character remains readable at extreme look angles.
direction.y = Math.max(-0.45, Math.min(0.45, direction.y));
direction.normalize();
camera.position.copy(observer.position).addScaledVector(direction, -CHARACTER.cameraDistance);
camera.position.copy(observer.position).addScaledVector(direction, -distance);
camera.position.y += CHARACTER.cameraLift;
// Sample the boom as well as its endpoint to avoid crossing a rising slope.
for (let i = 1; i <= 12; i++) {
+49 -8
View File
@@ -1,11 +1,39 @@
import { type Object3D, MathUtils, Vector3 } from 'three';
import { type Camera, type Object3D, type Raycaster, Frustum, Matrix4, MathUtils, Sphere, Vector3 } from 'three';
import type { Sheep } from '../scene/sheep.ts';
import type { PlayerState } from './protocol.ts';
import { MULTIPLAYER } from '../constants/multiplayer.ts';
import { MOVEMENT } from '../constants/movement.ts';
import { CHARACTER } from '../constants/character.ts';
export function createRemotePlayers(template: Sheep, scene: Object3D, heightAt: (x: number, z: number) => number) {
const players = new Map<string, { sheep: Sheep; state: PlayerState; from: Vector3; yaw: number; elapsed: number }>();
const frustum = new Frustum(), projection = new Matrix4();
const bounds = new Sphere(new Vector3(), CHARACTER.visibilityRadius);
return {
get(id: string) { return players.get(id)?.sheep.group; },
pick(ray: Raycaster, maxDistance: number) {
let selected: string | undefined;
for (const [id, { sheep }] of players) {
sheep.group.updateWorldMatrix(true, true);
const hit = ray.intersectObject(sheep.group, true).find(hit => {
for (let node: Object3D | null = hit.object; node; node = node.parent) if (!node.visible) return false;
return true;
});
if (hit && hit.distance < maxDistance) { maxDistance = hit.distance; selected = id; }
}
return selected;
},
beside(id: string, from: Vector3, reachable: (point: Vector3) => boolean) {
const target = players.get(id)?.sheep.group;
if (!target) return;
const yaw = target.rotation.y;
const side = new Vector3(Math.cos(yaw), 0, -Math.sin(yaw)).multiplyScalar(MOVEMENT.companionSpacing);
const candidates = [target.position.clone().add(side), target.position.clone().sub(side)]
.sort((a, b) => a.distanceToSquared(from) - b.distanceToSquared(from));
const position = candidates.find(point => reachable(point) && [...players.values()].every(({ sheep }) =>
Math.hypot(sheep.group.position.x - point.x, sheep.group.position.z - point.z) >= MOVEMENT.companionSpacing * .9));
return position ? { position, yaw } : undefined;
},
synchronize(states: PlayerState[], selfId: string) {
const present = new Set(states.filter(p => p.id !== selfId).map(p => p.id));
for (const [id, entry] of players) if (!present.has(id)) { entry.sheep.dispose(); players.delete(id); }
@@ -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);
}
},
update(delta: number, serverTime: number) {
update(delta: number, serverTime: number, camera?: Camera) {
if (camera) {
camera.updateMatrixWorld(true);
frustum.setFromProjectionMatrix(projection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse));
}
let changed = false;
for (const entry of players.values()) {
const { sheep, state, from } = entry;
const duration = MULTIPLAYER.interpolationMs / 1000;
const interpolating = entry.elapsed < duration;
entry.elapsed += Math.max(0, delta);
const fraction = Math.min(1, entry.elapsed / duration);
const x = MathUtils.lerp(from.x, state.x, fraction), z = MathUtils.lerp(from.z, state.z, fraction);
sheep.group.position.set(x, heightAt(x, z), z);
const angle = Math.atan2(Math.sin(state.yaw - entry.yaw), Math.cos(state.yaw - entry.yaw));
sheep.group.rotation.y = entry.yaw + angle * fraction;
if (interpolating) {
entry.elapsed = Math.min(duration, entry.elapsed + Math.max(0, delta));
const fraction = Math.min(1, entry.elapsed / duration);
const x = MathUtils.lerp(from.x, state.x, fraction), z = MathUtils.lerp(from.z, state.z, fraction);
if (x !== sheep.group.position.x || z !== sheep.group.position.z) {
sheep.group.position.set(x, heightAt(x, z), z);
}
const angle = Math.atan2(Math.sin(state.yaw - entry.yaw), Math.cos(state.yaw - entry.yaw));
sheep.group.rotation.y = entry.yaw + angle * fraction;
}
bounds.center.copy(sheep.group.position);
const visible = !camera || frustum.intersectsSphere(bounds);
changed = changed || sheep.group.visible !== visible;
sheep.group.visible = visible;
if (!visible) continue;
const active = sheep.pose(state.emote, Math.max(0, serverTime - state.emoteStartedAt) / 1000,
state.moving && serverTime - state.updatedAt < MULTIPLAYER.staleMovementMs, serverTime / 1000);
changed = changed || interpolating || active;
+15 -11
View File
@@ -69,7 +69,7 @@ export async function loadBuildings(): Promise<BuildingData> {
export function createBuildings(data: BuildingData, terrain: Awaited<ReturnType<typeof loadTerrain>>) {
const group = new Group();
const geometries: Record<'tagged' | 'levels' | 'fallback', ExtrudeGeometry[]> = { tagged: [], levels: [], fallback: [] };
const geometries = { tagged: new Map<string, ExtrudeGeometry[]>(), levels: new Map<string, ExtrudeGeometry[]>(), fallback: new Map<string, ExtrudeGeometry[]>() };
const counts = { tagged: 0, levels: 0, fallback: 0 };
const lightSources: BuildingLightSource[] = [];
const surface = (x: number, z: number) => terrainHeightAt(terrain.metadata, terrain.heights, x, z) - terrain.metadata.observerSurfaceElevation - (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
@@ -107,19 +107,23 @@ export function createBuildings(data: BuildingData, terrain: Awaited<ReturnType<
geometry.setAttribute('buildingLightHeight', new BufferAttribute(lightHeights, 1));
geometry.rotateX(-Math.PI / 2);
geometry.translate(x, base, z);
geometries[source].push(geometry);
const tile = `${Math.floor(x / BUILDINGS.tileSize)},${Math.floor(z / BUILDINGS.tileSize)}`;
const bucket = geometries[source].get(tile) ?? [];
bucket.push(geometry);
geometries[source].set(tile, bucket);
counts[source]++;
}
for (const source of ['tagged', 'levels', 'fallback'] as const) {
if (!geometries[source].length) continue;
const merged = mergeGeometries(geometries[source], false)!;
geometries[source].forEach(g => g.dispose());
// Lighting uses world XZ and local height, not UVs. Retain normals and
// lighting heights when sharing vertices so facade/roof seams stay sharp.
merged.deleteAttribute('uv');
const indexed = indexBuildingGeometry(merged);
merged.dispose();
group.add(createBuildingMesh(source, indexed));
for (const bucket of geometries[source].values()) {
const merged = mergeGeometries(bucket, false)!;
bucket.forEach(g => g.dispose());
// Lighting uses world XZ and local height, not UVs. Retain normals and
// lighting heights when sharing vertices so facade/roof seams stay sharp.
merged.deleteAttribute('uv');
const indexed = indexBuildingGeometry(merged);
merged.dispose();
group.add(createBuildingMesh(source, indexed));
}
}
const target = structurePosition(BUILDINGS.target.latitude, BUILDINGS.target.longitude, terrain.metadata.origin);
target.y = surface(target.x, target.z) + 20;
+17
View File
@@ -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
View File
@@ -13,6 +13,9 @@ import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
import { SCENE } from '../constants/scene.ts';
import { TERRAIN } from '../constants/terrain.ts';
import { LOBBY_CHARACTER, LOBBY_SUNRISE } from '../constants/lobby.ts';
import { RENDER_QUALITY } from '../constants/renderQuality.ts';
import { createFrameClock } from './frameClock.ts';
import { renderPixelRatio } from './adaptiveResolution.ts';
/** Same Gwanaksan DSM, summit correction, sky and solar disc as the observation scene. */
export function createLobbySunrise(canvas: HTMLCanvasElement) {
@@ -59,27 +62,34 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
characterCamera.position.z = 5;
foreground.add(sheep.group);
const head = sheep.group.getObjectByName('Head')!;
const headRest = head.quaternion.clone();
let progress = matchMedia('(prefers-reduced-motion: reduce)').matches ? 1 : 0;
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
const mobile = matchMedia('(pointer: coarse)').matches;
const frameClock = createFrameClock(mobile ? RENDER_QUALITY.mobileFps : Infinity);
let progress = reducedMotion ? 1 : 0;
let frame = 0;
let elapsed = 0;
let lastFrame = 0;
let previousProgress = -1;
let gazeAngle = 0;
const render = () => {
const date = new Date(LOBBY_SUNRISE.from + (LOBBY_SUNRISE.to - LOBBY_SUNRISE.from) * progress);
const state = calculateSun(date, observer);
direction.set(...directionFromAngles(state.azimuth, state.altitude));
sky.update(direction, state.altitude);
sun.update(direction, state.altitude);
nightSky.update(date, observer, state.altitude);
const moonAltitude = getMoonPosition(date, observer.latitude, observer.longitude).altitude;
const gazeAltitude = MathUtils.lerp(moonAltitude, state.altitude, MathUtils.smoothstep(state.altitude, -8, 0));
head.quaternion.copy(headRest);
head.rotateX(-MathUtils.degToRad(MathUtils.clamp(gazeAltitude, 8, 28)));
sheep.updateLighting(Math.max(LOBBY_CHARACTER.minimumLightingAltitude, state.altitude), 1);
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(state.altitude, -8, 15));
fog.color.copy(night).lerp(dawn, MathUtils.smoothstep(state.altitude, -8, 0));
fog.color.lerp(day, MathUtils.smoothstep(state.altitude, 0, 15));
renderer.toneMappingExposure = MathUtils.lerp(0.12, LOBBY_SUNRISE.maxExposure, MathUtils.smoothstep(state.altitude, -12, 5));
if (progress !== previousProgress) {
previousProgress = progress;
const date = new Date(LOBBY_SUNRISE.from + (LOBBY_SUNRISE.to - LOBBY_SUNRISE.from) * progress);
const state = calculateSun(date, observer);
direction.set(...directionFromAngles(state.azimuth, state.altitude));
sky.update(direction, state.altitude);
sun.update(direction, state.altitude);
nightSky.update(date, observer, state.altitude);
const moonAltitude = getMoonPosition(date, observer.latitude, observer.longitude).altitude;
const gazeAltitude = MathUtils.lerp(moonAltitude, state.altitude, MathUtils.smoothstep(state.altitude, -8, 0));
gazeAngle = -MathUtils.degToRad(MathUtils.clamp(gazeAltitude, 8, 28));
sheep.updateLighting(Math.max(LOBBY_CHARACTER.minimumLightingAltitude, state.altitude), 1);
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(state.altitude, -8, 15));
fog.color.copy(night).lerp(dawn, MathUtils.smoothstep(state.altitude, -8, 0));
fog.color.lerp(day, MathUtils.smoothstep(state.altitude, 0, 15));
renderer.toneMappingExposure = MathUtils.lerp(0.12, LOBBY_SUNRISE.maxExposure, MathUtils.smoothstep(state.altitude, -12, 5));
}
sheep.pose('idle', 0, false, elapsed / 1000);
head.rotateX(gazeAngle);
renderer.render(scene, camera);
const { clientWidth: width, clientHeight: height } = canvas;
const size = Math.min(LOBBY_CHARACTER.maxSize, width * LOBBY_CHARACTER.widthFraction, height * LOBBY_CHARACTER.heightFraction);
@@ -94,19 +104,28 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
const { clientWidth: width, clientHeight: height } = canvas;
camera.aspect = width / Math.max(1, height);
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(devicePixelRatio, LOBBY_SUNRISE.maxPixelRatio));
renderer.setPixelRatio(renderPixelRatio(devicePixelRatio, LOBBY_SUNRISE.maxPixelRatio, width, height, mobile));
renderer.setSize(width, height, false);
render();
};
const tick = (now: number) => {
if (disposed) return;
if (!document.hidden && lastFrame) elapsed += now - lastFrame;
lastFrame = now;
progress = MathUtils.clamp((elapsed - LOBBY_SUNRISE.nightHoldMs) / LOBBY_SUNRISE.durationMs, 0, 1);
if (!document.hidden) render();
if (progress < 1) frame = requestAnimationFrame(tick);
if (disposed || document.hidden) return;
const delta = frameClock.tick(now);
if (delta !== undefined) {
elapsed += delta * 1000;
progress = MathUtils.clamp((elapsed - LOBBY_SUNRISE.nightHoldMs) / LOBBY_SUNRISE.durationMs, 0, 1);
render();
}
if (!reducedMotion) frame = requestAnimationFrame(tick);
};
const visible = () => {
cancelAnimationFrame(frame);
frameClock.reset();
if (!document.hidden) {
render();
if (!reducedMotion) frame = requestAnimationFrame(tick);
}
};
const visible = () => { lastFrame = 0; if (!document.hidden) render(); };
cleanup = () => {
cancelAnimationFrame(frame);
window.removeEventListener('resize', resize);
@@ -126,7 +145,7 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
window.addEventListener('resize', resize);
document.addEventListener('visibilitychange', visible);
resize();
if (progress < 1) frame = requestAnimationFrame(tick);
if (!document.hidden && progress < 1) frame = requestAnimationFrame(tick);
})();
return { ready, dispose() { disposed = true; cleanup?.(); cleanup = undefined; } };
}
+141 -8
View File
@@ -104,6 +104,14 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
}
for (const source of converted.keys()) source.dispose();
const baseColors = new Map([...materials].map(material => [material, material.color.clone()]));
const body = model.getObjectByName('Body');
const breathingParts = [body, gear?.raincoat, joints.Head, joints.ArmLeft, joints.ArmRight]
.filter((part): part is Object3D => Boolean(part))
.map(part => ({ part, position: part.position.clone(), scale: part.scale.clone(), quaternion: part.quaternion.clone() }));
const legPositions = [joints.LegLeft.position.clone(), joints.LegRight.position.clone()];
const bodyRestPosition = body?.position.clone();
const animateIdle = typeof matchMedia === 'undefined' || !matchMedia('(prefers-reduced-motion: reduce)').matches;
const modelRestPosition = model.position.clone();
const originals: Object3D[] = [];
group.traverse(object => originals.push(object));
function bindRig(group: Group, ownsAssets: boolean): Sheep {
@@ -113,9 +121,23 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
const rigJoints = Object.fromEntries(['Head', 'ArmLeft', 'ArmRight', 'LegLeft', 'LegRight']
.map(name => [name, group.getObjectByName(name)!]));
const joints = rigJoints;
const jointEntries = Object.entries(joints);
const rigCoatParts = coatParts.map(part => copy(part)!);
const rigHood = copy(hood), rigFoldedHood = copy(foldedHood), rigUmbrella = copy(gear?.umbrella);
const rigFlashlight = copy(gear?.flashlight);
const rigBreathingParts = breathingParts.map(rest => ({ ...rest, part: copy(rest.part)! }));
const rigBody = copy(body), rigCoat = copy(gear?.raincoat);
const rigModel = copy(model)!;
const breathPhase = Math.random() * Math.PI * 2;
const fidget = CHARACTER.idle.fidget;
let fidgetWait: number = fidget.intervalSeconds;
let fidgetTime = 0;
let fidgetAction: 'scratch' | 'hop' | undefined;
let idleWeight = 0;
let walkWeight = 0;
const walk = CHARACTER.walk;
const torsoRotation = new Quaternion();
const coatOffset = new Vector3();
let flashlightOn = false;
let flashlightPose = 0;
let previousPoseTime: number | undefined;
@@ -153,6 +175,50 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
},
pose(emote: Emote, elapsed: number, moving: boolean, time: number) {
const delta = Math.max(0, time - (previousPoseTime ?? time));
const previousWalkWeight = walkWeight;
walkWeight = emote === 'idle' ? MathUtils.damp(walkWeight, Number(moving), 1 / walk.transitionSeconds, delta) : 0;
if (!moving && walkWeight < .001) walkWeight = 0;
const idle = animateIdle && !moving && emote === 'idle';
rigModel.position.copy(modelRestPosition);
if (!idle) {
fidgetWait = fidget.intervalSeconds;
fidgetTime = 0;
fidgetAction = undefined;
} else {
// Do not fast-forward gestures after a background tab resumes.
const idleDelta = Math.min(delta, .1);
fidgetWait -= idleDelta;
if (fidgetWait <= 0) {
fidgetAction = Math.random() < .5 ? 'scratch' : 'hop';
fidgetTime = 0;
fidgetWait += fidget.intervalSeconds;
} else if (fidgetAction) {
fidgetTime += idleDelta;
const duration = fidgetAction === 'scratch' ? fidget.scratchSeconds : fidget.hopSeconds;
if (fidgetTime >= duration) {
fidgetAction = undefined;
}
}
}
const previousIdleWeight = idleWeight;
const idleStep = delta / CHARACTER.idle.transitionSeconds;
idleWeight = idle ? Math.min(1, idleWeight + idleStep) : Math.max(0, idleWeight - idleStep);
const breath = (1 - Math.cos(time * Math.PI * 2 / CHARACTER.idle.periodSeconds + breathPhase)) * .5
* MathUtils.smoothstep(idleWeight, 0, 1);
for (const rest of rigBreathingParts) {
const { part } = rest;
part.position.copy(rest.position);
part.scale.copy(rest.scale);
if (part === rigBody || part === rigCoat) {
part.quaternion.copy(rest.quaternion);
part.scale.y *= 1 + breath * CHARACTER.idle.torsoRise;
part.scale.z *= 1 + breath * CHARACTER.idle.torsoDepth;
// The coat is authored in model coordinates; expand about the torso's pivot.
if (part === rigCoat && bodyRestPosition) part.position.y += -bodyRestPosition.y * breath * CHARACTER.idle.torsoRise;
} else {
part.position.y += breath * (part === joints.Head ? CHARACTER.idle.headLift : CHARACTER.idle.shoulderLift);
}
}
const step = delta / FLASHLIGHT.poseTransitionSeconds;
previousPoseTime = time;
const previousFlashlightPose = flashlightPose;
@@ -161,12 +227,16 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
const umbrellaStep = delta / RAIN_GEAR.umbrellaTransitionSeconds;
umbrellaPose = umbrellaOpen ? Math.min(1, umbrellaPose + umbrellaStep) : Math.max(0, umbrellaPose - umbrellaStep);
const carryingUmbrella = umbrellaOpen || umbrellaPose > 0;
const changed = 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)
|| previousUmbrellaPose !== umbrellaPose || umbrellaPose !== Number(umbrellaOpen);
previousMoving = moving;
previousEmote = emote;
for (const [name, joint] of Object.entries(joints)) joint.quaternion.copy(rest[name]);
for (const [name, joint] of jointEntries) joint.quaternion.copy(rest[name]);
joints.LegLeft.position.copy(legPositions[0]);
joints.LegRight.position.copy(legPositions[1]);
if (emote === 'wave') {
pointArm(carryingUmbrella ? 'ArmLeft' : 'ArmRight',
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('ArmRight', aim.set(-.68, -.22, .64));
joints.Head.rotateX(.16);
} else if (moving) {
const stride = Math.sin(time * 9) * .4;
joints.LegLeft.rotateX(stride);
joints.LegRight.rotateX(-stride);
joints.ArmLeft.rotateX(-stride * .6);
joints.ArmRight.rotateX(stride * .6);
} else if (moving || walkWeight > 0) {
const phase = time * walk.rate;
const swing = Math.sin(phase);
const amplitude = walkWeight * walk.strength;
const sway = swing * walk.sway * amplitude;
const rise = (1 - Math.cos(phase * 2)) * walk.rise * amplitude;
if (rigBody) {
rigBody.position.x += sway;
rigBody.position.y += rise;
const bodyRest = rigBreathingParts.find(({ part }) => part === rigBody)!;
torsoRotation.copy(bodyRest.quaternion).invert();
rigBody.rotateZ(-swing * walk.roll * amplitude);
rigBody.rotateY(swing * walk.twist * amplitude);
torsoRotation.premultiply(rigBody.quaternion);
if (rigCoat && bodyRestPosition) {
// The coat is authored at the model origin; rotate it about the torso pivot.
coatOffset.copy(rigCoat.position).sub(bodyRestPosition).applyQuaternion(torsoRotation);
rigCoat.position.copy(coatOffset).add(bodyRestPosition);
rigCoat.position.x += sway;
rigCoat.position.y += rise;
rigCoat.quaternion.premultiply(torsoRotation);
}
}
for (const [side, sign] of [['Left', 1], ['Right', -1]] as const) {
const leg = joints[`Leg${side}`], arm = joints[`Arm${side}`];
leg.rotateX(swing * sign * walk.stride * walkWeight);
leg.position.y += Math.max(0, -Math.cos(phase) * sign) ** 2 * walk.footLift * amplitude;
arm.position.x += sway;
arm.position.y += rise;
arm.rotateX(-Math.sin(phase - walk.armLag) * sign * walk.stride * walk.armSwing * walkWeight);
arm.rotateZ(-swing * walk.roll * amplitude);
}
joints.Head.position.x += Math.sin(phase - walk.headLag) * walk.sway * walk.headFollow * amplitude;
joints.Head.position.y += (1 - Math.cos(phase * 2 - walk.headLag)) * walk.rise * walk.headFollow * amplitude;
joints.Head.rotateZ(Math.sin(phase - walk.headLag) * walk.roll * walk.headRoll * amplitude);
joints.Head.rotateX(Math.sin(phase * 2 - walk.headLag) * walk.headNod * amplitude);
} else if (fidgetAction === 'scratch') {
const blend = MathUtils.smoothstep(fidgetTime, 0, .4)
* (1 - MathUtils.smoothstep(fidgetTime, fidget.scratchSeconds - .4, fidget.scratchSeconds));
const left = carryingUmbrella;
const arm = joints[left ? 'ArmLeft' : 'ArmRight'];
// Only scratch with an empty hand, including while equipment blends out.
if (!left || (!flashlightOn && flashlightPose === 0)) {
pointArm(left ? 'ArmLeft' : 'ArmRight', aim.set(left ? -.3 : .3, .95, .12 + Math.sin(fidgetTime * 22) * .12));
arm.quaternion.slerp(rest[left ? 'ArmLeft' : 'ArmRight'], 1 - blend);
joints.Head.rotateZ((left ? -.1 : .1) * blend);
}
} else if (fidgetAction === 'hop') {
const progress = fidgetTime / fidget.hopSeconds * 2;
const phase = progress % 1;
const strength = progress < 1 ? 1 : .7;
// Each small hop has a planted preparation, a short flight and a soft landing.
const flight = MathUtils.clamp((phase - .24) / .48, 0, 1);
const bounce = 4 * flight * (1 - flight) * strength;
const crouch = (phase < .24 ? Math.sin(phase / .24 * Math.PI)
: phase > .72 ? Math.sin((phase - .72) / .28 * Math.PI) : 0) * strength;
rigModel.position.y += bounce * fidget.hopHeight;
for (const { part } of rigBreathingParts) {
if (part === rigBody || part === rigCoat) {
part.scale.y *= 1 - crouch * fidget.hopCrouch;
if (part === rigCoat && bodyRestPosition) part.position.y += bodyRestPosition.y * crouch * fidget.hopCrouch;
} else {
part.position.y -= crouch * fidget.hopCrouch;
}
}
joints.LegLeft.rotateX(-bounce * .1);
joints.LegRight.rotateX(-bounce * .1);
joints.ArmLeft.rotateZ(-bounce * .08);
joints.ArmRight.rotateZ(bounce * .08);
}
// Keep the grip stable while the free arm and legs perform their own animations.
if (umbrellaPose > 0) {
+129 -22
View File
@@ -9,6 +9,7 @@ import { observer } from '../config/observer.ts';
import { SCENE } from '../constants/scene.ts';
import { EQUIPMENT_NOTICE, PRAYER_NOTICE } from '../constants/ui.ts';
import { MOVEMENT } from '../constants/movement.ts';
import { CHARACTER } from '../constants/character.ts';
import { createFpsCameraController } from '../controls/fpsCameraController.ts';
import { createSky } from './sky.ts';
import { createSun } from './sun.ts';
@@ -30,6 +31,9 @@ import { COAST } from '../constants/coast.ts';
import { GISAMUN } from '../constants/gisamun.ts';
import { createHarbour, isGisamun } from './harbour.ts';
import { createRenderInvalidation } from './renderInvalidation.ts';
import { createFrameClock } from './frameClock.ts';
import { renderPixelRatio } from './adaptiveResolution.ts';
import { RENDER_QUALITY } from '../constants/renderQuality.ts';
import { createTerrainMinimap } from '../ui/terrainMinimap.ts';
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
import { createSummitWalking } from '../controls/summitWalking.ts';
@@ -50,6 +54,7 @@ import { faceTravel, updateThirdPersonCamera } from '../controls/thirdPersonCame
import { createRemotePlayers } from '../multiplayer/remotePlayers.ts';
import type { PlayerState, PlayerUpdate } from '../multiplayer/protocol.ts';
import { createSelfBadge } from '../ui/selfBadge.ts';
import { createCharacterOverlay } from '../ui/characterOverlay.ts';
import { createFlashlight } from './flashlight.ts';
import { FLASHLIGHT } from '../constants/flashlight.ts';
import { flashlightAfterSunUpdate } from '../controls/flashlight.ts';
@@ -78,6 +83,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
let sharedNow: (() => number) | null = null;
let playerMoving = false;
const profiling = import.meta.env.DEV && new URLSearchParams(location.search).has('profile');
const mobile = matchMedia('(pointer: coarse)').matches;
const frameClock = createFrameClock(mobile && !profiling ? RENDER_QUALITY.mobileFps : Infinity);
let profile: ReturnType<typeof createRenderProfile> | undefined;
let disposed = false;
const summitStarted = performance.now();
@@ -168,20 +175,91 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
if (harbour) scene.add(harbour.group);
const destinationRay = new Raycaster();
const pointer = new Vector2();
const controls = createFpsCameraController(camera, canvas, (x, y) => {
let cameraDistance: number = CHARACTER.cameraDistance;
const followPosition = new Vector3();
const followRotation = camera.quaternion.clone();
let previousCameraDistance = -1;
const updateFollowCamera = () => {
if (previousCameraDistance === cameraDistance && followPosition.equals(camera.position)
&& followRotation.equals(camera.quaternion)) return;
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
followPosition.copy(camera.position);
followRotation.copy(camera.quaternion);
previousCameraDistance = cameraDistance;
};
let selectedCompanion: string | undefined;
let companionAnchor: ReturnType<typeof createCharacterOverlay> | undefined;
const directionButton = document.querySelector<HTMLButtonElement>('#direction-toggle')!;
const directionHint = document.getElementById('direction-hint')!;
const besideButton = document.querySelector<HTMLButtonElement>('#stand-beside')!;
const closeCompanion = () => { selectedCompanion = undefined; companionAnchor = undefined; besideButton.hidden = true; };
const pickGround = (x: number, y: number) => {
const bounds = canvas.getBoundingClientRect();
pointer.set((x - bounds.left) / bounds.width * 2 - 1, -(y - bounds.top) / bounds.height * 2 + 1);
if (sheep) updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
if (sheep) updateFollowCamera();
const view = sheep ? followCamera : camera;
view.updateMatrixWorld(true);
destinationRay.setFromCamera(pointer, view);
const surfaces = harbour ? [terrain.mesh, harbour.group] : [terrain.mesh];
const hit = destinationRay.intersectObjects(surfaces, true)[0];
return hit?.point ?? null;
return destinationRay.intersectObjects(surfaces, true)[0];
};
const controls = createFpsCameraController(camera, canvas, (x, y) => networkBlocked ? null : pickGround(x, y)?.point ?? null, {
onTurn: radians => {
if (!sheep || networkBlocked || overview) return;
sheep.group.rotation.y += radians;
rendering.invalidate();
},
onZoom: factor => {
cameraDistance = MathUtils.clamp(cameraDistance * factor, CHARACTER.cameraMinDistance, CHARACTER.cameraMaxDistance);
rendering.invalidate();
},
onModeChange: turning => {
directionButton.setAttribute('aria-pressed', String(turning));
directionButton.textContent = turning ? '완료' : '방향 맞추기';
directionHint.hidden = !turning;
if (turning) { closeCompanion(); emotes.cancel(); playerMoving = false; refreshEmotes(); }
rendering.invalidate();
},
onSelect: (x, y) => {
if (networkBlocked) return true;
const ground = pickGround(x, y);
const id = remotePlayers?.pick(destinationRay, ground?.distance ?? Infinity);
closeCompanion();
if (!id) return false;
selectedCompanion = id;
const player = remotePlayers!.get(id)!;
companionAnchor = createCharacterOverlay(player, canvas, besideButton);
rendering.invalidate();
return true;
},
});
if (sheep) scene.add(sheep.group);
const emotes = createEmoteState();
const emoteEvents = new AbortController();
directionButton.addEventListener('click', () => {
if (sheep && !networkBlocked && !overview) controls.setTurning(!controls.turning);
}, { signal: emoteEvents.signal });
besideButton.addEventListener('click', () => {
if (!selectedCompanion || networkBlocked || overview) return;
const spot = remotePlayers?.beside(selectedCompanion, camera.position, point => {
const radius = terrainData.metadata.radiusMeters - 1;
if (Math.abs(point.x) > radius || Math.abs(point.z) > radius) return false;
if (summitWalking && !summitWalking.canTraverse(camera.position, point)) return false;
if (coast) {
const steps = Math.max(1, Math.ceil(camera.position.distanceTo(point) / (MOVEMENT.speed * MOVEMENT.maxDelta)));
for (let i = 0; i <= steps; i++) {
const x = MathUtils.lerp(camera.position.x, point.x, i / steps);
const z = MathUtils.lerp(camera.position.z, point.z, i / steps);
if (!isCoastalLand(coast.data, x, z)) return false;
}
}
return true;
});
closeCompanion();
if (!spot) return;
emotes.cancel(); refreshEmotes();
controls.moveTo(spot.position, spot.yaw);
}, { signal: emoteEvents.signal });
const waveButton = document.querySelector<HTMLButtonElement>('#emote-wave')!;
const primaryActionLabel = document.getElementById('primary-action-label')!;
const primaryActionIcon = waveButton.querySelector('use')!;
@@ -242,6 +320,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
const prayerAvailable = () => Boolean(solarState && canPray(solarState.date, solarState.sunrise));
const refreshEmotes = () => {
refreshGear();
directionButton.disabled = besideButton.disabled = !sheep || overview || networkBlocked;
const dark = Boolean(solarState && solarState.altitude <= FLASHLIGHT.nightAltitude);
waveButton.disabled = !sheep || overview || networkBlocked;
primaryActionLabel.textContent = dark ? (flashlightOn ? '끄기' : '켜기') : '인사';
@@ -309,7 +388,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
overviewCamera.updateProjectionMatrix();
followCamera.aspect = camera.aspect;
followCamera.updateProjectionMatrix();
renderer.setPixelRatio(profile?.forcedDpr ?? Math.min(devicePixelRatio, SCENE.maxPixelRatio));
renderer.setPixelRatio(profile?.forcedDpr ?? renderPixelRatio(devicePixelRatio,
mobile ? RENDER_QUALITY.mobileCap : SCENE.maxPixelRatio, innerWidth, innerHeight, mobile));
renderer.setSize(innerWidth, innerHeight);
composer?.setPixelRatio(renderer.getPixelRatio());
composer?.setSize(innerWidth, innerHeight);
@@ -317,13 +397,13 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
};
window.addEventListener('resize', resize);
resize();
let previousTime = performance.now();
const forward = new Vector3();
const travel = new Vector3();
renderer.setAnimationLoop((now) => {
const renderFrame = (now: number) => {
if (document.hidden) return;
const delta = frameClock.tick(now);
if (delta === undefined) return;
const cpuStart = profile?.active() ? performance.now() : 0;
const delta = (now - previousTime) / 1000;
previousTime = now;
const replayPosition = profile?.step(now);
let moving = false;
let turning = false;
@@ -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))) {
travel.subVectors(next, camera.position);
if (sheep && !replayPosition) {
const alignment = faceTravel(sheep.group, camera, travel, delta, !controls.looking);
const alignment = faceTravel(sheep.group, travel, delta);
turning = alignment < .999;
next.copy(camera.position).addScaledVector(travel, alignment);
rendering.invalidate();
@@ -352,10 +432,17 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
camera.position.copy(next);
placeObserver();
}
if (destination && ((!moving && !turning) || Math.hypot(destination.x-camera.position.x, destination.z-camera.position.z) <= MOVEMENT.arrivalDistance)) controls.stop();
if (destination) {
const arrived = Math.hypot(destination.x-camera.position.x, destination.z-camera.position.z) <= MOVEMENT.arrivalDistance;
if (arrived || (!moving && !turning)) {
if (controls.arrivalYaw !== undefined) {
if (arrived && sheep) sheep.group.rotation.y = controls.arrivalYaw;
}
controls.stop();
}
}
}
playerMoving = moving;
if (remotePlayers?.update(delta, sharedNow ? sharedNow() : Date.now())) rendering.invalidate();
ocean?.animate(now / 1000);
let weatherChanged = false;
const blend = 1 - Math.exp(-Math.min(delta, 0.1) / WEATHER.transitionSeconds);
@@ -377,12 +464,14 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
forward.set(...directionFromAngles(solarState.azimuth, 0));
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
} else if (moving && replayPosition) {
faceTravel(sheep.group, camera, travel, delta, !controls.looking);
faceTravel(sheep.group, travel, delta);
}
if (sheep.pose(emotes.current, emotes.elapsed, moving, now / 1000)) rendering.invalidate();
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
updateFollowCamera();
}
const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera;
if (remotePlayers?.update(delta, sharedNow ? sharedNow() : Date.now(), activeCamera)) rendering.invalidate();
companionAnchor?.update(activeCamera, !overview);
flashlight?.update(flashlightOn && !overview);
const precipitating = precipitation.update(delta, activeCamera.position, atmosphere.rain, atmosphere.snowfall,
MathUtils.smoothstep(solarState?.altitude ?? -12, -8, 15), canvas.height);
@@ -403,7 +492,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
bloom.threshold = torchActive ? FLASHLIGHT.bloomThreshold : BUILDINGS.lighting.bloomThreshold;
bloom.radius = torchActive ? FLASHLIGHT.bloomRadius : BUILDINGS.lighting.bloomRadius;
}
if (composer && !profile?.disablePost) {
if (composer && bloom?.enabled && !profile?.disablePost) {
renderPass.camera = activeCamera;
composer.render(delta);
} 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,
submit: performance.now() - minimapEnd });
if (profile?.observe(now, true)) resize();
});
};
const onVisibility = () => {
frameClock.reset();
rendering.invalidate();
renderer.setAnimationLoop(document.hidden ? null : renderFrame);
};
document.addEventListener('visibilitychange', onVisibility);
onVisibility();
const world = {
buildingCounts: buildings?.counts,
captureFrame(view?: PerspectiveCamera) {
const activeCamera = view ?? (overview ? overviewCamera : sheep ? followCamera : camera);
if (composer && !profile?.disablePost) {
remotePlayers?.update(0, sharedNow ? sharedNow() : Date.now(), activeCamera);
rendering.invalidate();
if (composer && bloom?.enabled && !profile?.disablePost) {
renderPass.camera = activeCamera;
composer.render(0);
} else renderer.render(scene, activeCamera);
@@ -446,7 +544,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
},
setConnection(blocked: boolean, clock: (() => number) | null) {
networkBlocked = blocked; sharedNow = clock;
if (blocked) { controls.clear(); playerMoving = false; emotes.cancel(); }
if (blocked) { controls.clear(); closeCompanion(); playerMoving = false; emotes.cancel(); }
refreshEmotes();
rendering.invalidate();
},
applyPlayer(state: PlayerState) {
@@ -460,8 +559,12 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
},
synchronizePlayers(players: PlayerState[], selfId: string) {
remotePlayers?.synchronize(players, selfId); rendering.invalidate();
if (selectedCompanion && !remotePlayers?.get(selectedCompanion)) {
if (controls.arrivalYaw !== undefined) controls.stop();
closeCompanion();
}
},
clearPlayers() { remotePlayers?.clear(); rendering.invalidate(); },
clearPlayers() { remotePlayers?.clear(); closeCompanion(); if (controls.arrivalYaw !== undefined) controls.stop(); rendering.invalidate(); },
setWeather(weather: WeatherSample | null) {
weatherTarget = weatherAppearance(weather);
rainGear.updateWeather(weather?.rain ?? null, weather?.snowfall ?? null);
@@ -474,6 +577,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
},
resetPosition() {
emotes.cancel();
closeCompanion();
walkedDistance = 0;
restoreView(); controls.clear(); camera.position.set(start.x, eyeHeight, start.z);
placeObserver();
@@ -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); },
showCityOverview() {
controls.clear();
closeCompanion();
emotes.cancel();
overview = true;
refreshEmotes();
@@ -505,14 +610,14 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
camera.lookAt(camera.position.clone().add(forward));
if (sheep) {
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() {
if (!solarState) return;
restoreView();
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) {
eyeHeight = meters;
@@ -540,8 +645,10 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
},
dispose() {
disposed = true;
closeCompanion();
profile?.dispose();
renderer.setAnimationLoop(null);
document.removeEventListener('visibilitychange', onVisibility);
minimap.dispose();
compass.dispose();
selfBadge?.dispose();
@@ -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 (disposed) return;
profile = createRenderProfile(renderer, scene, camera, () => world.resetPosition(), target, () =>
composer && !profile?.disablePost ? composer.passes.filter(pass => pass.enabled).map(pass => pass.constructor.name) : ['WebGLRenderer'], resize);
composer && bloom?.enabled && !profile?.disablePost ? composer.passes.filter(pass => pass.enabled).map(pass => pass.constructor.name) : ['WebGLRenderer'], resize);
renderer.info.autoReset = false;
resize();
});
+10 -1
View File
@@ -16,7 +16,7 @@ button[aria-pressed=true], button.primary { background: #e3ba86; color: #28251f;
button[aria-pressed=true]:hover, button.primary:hover { background: #f0cca0; }
:focus-visible { outline: 2px solid #f0c48e; outline-offset: 3px; }
.hud { position: fixed; inset: 0; pointer-events: none; }
.self-badge { 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-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; }
@@ -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); } }
@media (prefers-reduced-motion: reduce) { #equipment-options, #location-options, #equipment-notice { animation: none; } }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.movement-controls { position: absolute; right: max(18px, env(safe-area-inset-right)); bottom: calc(max(24px, env(safe-area-inset-bottom)) + 96px); display: flex; flex-direction: column; align-items: flex-end; gap: 8px; max-width: calc(100vw - 36px); }
.movement-controls button { pointer-events: auto; }
#direction-hint { margin: 0; padding: 10px 12px; border: 1px solid #ffffff26; border-radius: 14px; background: #18232ef2; font-size: 12px; line-height: 1.5; }
#stand-beside { min-width: 0; min-height: 0; width: max-content; pointer-events: auto; }
@media (max-width: 600px) {
.movement-controls { right: max(10px, env(safe-area-inset-right)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 62px); max-width: calc(100vw - 20px); }
#direction-toggle { padding: 8px 12px; font-size: 12px; background: #18232ee6; }
#direction-toggle[aria-pressed=true] { background: #e3ba86; }
}
#ui-dialog { width: min(440px, calc(100vw - 32px)); max-width: none; max-height: calc(100vh - 32px); max-height: calc(100dvh - max(16px, env(safe-area-inset-top)) - max(16px, env(safe-area-inset-bottom))); padding: 0; border: 1px solid #52617070; border-radius: 22px; color: #e8edf2; background: #18232e; box-shadow: 0 24px 80px #0006; overflow: hidden; }
#ui-dialog[open] { display: flex; flex-direction: column; }
+31
View File
@@ -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`;
},
};
}
+3
View File
@@ -6,6 +6,7 @@ export function createCompass() {
const scale = root.querySelector<HTMLElement>('.compass-scale')!;
const bearing = root.querySelector<HTMLElement>('.compass-bearing')!;
const direction = new Vector3();
let previousHeading: number | undefined;
const ticks = Array.from({ length: 360 / COMPASS.tickDegrees }, (_, index) => {
const degrees = index * COMPASS.tickDegrees;
const tick = document.createElement('span');
@@ -19,6 +20,8 @@ export function createCompass() {
update(camera: Camera) {
camera.getWorldDirection(direction);
const heading = (Math.atan2(direction.x, -direction.z) * 180 / Math.PI + 360) % 360;
if (heading === previousHeading) return;
previousHeading = heading;
for (const { tick, degrees } of ticks) {
const offset = (degrees - heading + 540) % 360 - 180;
tick.hidden = Math.abs(offset) > COMPASS.visibleDegrees / 2;
+3 -17
View File
@@ -1,12 +1,8 @@
import { Box3, Vector3, type Camera, type Object3D } from 'three';
import { CHARACTER } from '../constants/character.ts';
import { type Object3D } from 'three';
import { createCharacterOverlay } from './characterOverlay.ts';
/** A screen-sized label attached only to the local player's head. */
export function createSelfBadge(player: Object3D, canvas: HTMLCanvasElement) {
const 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');
badge.className = 'self-badge';
badge.textContent = '나';
@@ -14,17 +10,7 @@ export function createSelfBadge(player: Object3D, canvas: HTMLCanvasElement) {
badge.hidden = true;
document.querySelector('.hud')!.append(badge);
return {
update(camera: Camera, visible: boolean) {
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`;
},
...createCharacterOverlay(player, canvas, badge),
dispose() { badge.remove(); },
};
}
+1 -1
View File
@@ -85,7 +85,7 @@ export class TimeController {
};
private sync = () => {
if (this.mode === 'LIVE') this.render(this.sharedClock ? this.sharedClock() : new Date());
if (!document.hidden && this.mode === 'LIVE') this.render(this.sharedClock ? this.sharedClock() : new Date());
};
private render(date: Date) {
+2 -1
View File
@@ -51,7 +51,8 @@ test('downloaded footprints are deduplicated, closed, and generate finite merged
const city=createBuildings(data,terrain);
assert.equal(Object.values(city.counts).reduce((a,b)=>a+b,0),data.features.length);
assert.ok(city.counts.tagged>0 && city.counts.levels>0 && city.counts.fallback>0);
assert.ok(city.group.children.length<=3);
assert.ok(city.group.children.length > 3, 'separate city districts can be culled independently');
assert.ok(city.group.children.length < 100, 'batch buildings instead of one draw call per building');
for(const mesh of city.group.children as Mesh[]) assert.ok(Array.from(mesh.geometry.attributes.position.array).every(Number.isFinite));
city.dispose();
});
+35
View File
@@ -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
View File
@@ -108,10 +108,14 @@ test('exported sheep has working joints and renders the final idle pose after wa
const leg = sheep.group.getObjectByName('LegLeft')!;
const rest = leg.quaternion.clone();
assert.equal(sheep.pose('idle', 0, true, 1), true);
sheep.pose('idle', 0, true, 1.2);
assert.ok(leg.quaternion.angleTo(rest) > .01);
assert.equal(sheep.pose('idle', 0, false, 1), true, 'stopping must invalidate one frame');
const walking = leg.quaternion.clone();
assert.equal(sheep.pose('idle', 0, false, 1.2), true, 'stopping must invalidate one frame');
assert.ok(leg.quaternion.angleTo(walking) < 1e-6, 'stopping blends instead of snapping');
sheep.pose('idle', 0, false, 4);
assert.ok(leg.quaternion.angleTo(rest) < 1e-6);
assert.equal(sheep.pose('idle', 0, false, 1), false);
assert.equal(sheep.pose('idle', 0, false, 1), true, 'idle breathing keeps rendering');
const arm = sheep.group.getObjectByName('ArmRight')!;
const armRest = arm.quaternion.clone();
sheep.pose('wave', 1, false, 1);
@@ -121,7 +125,42 @@ test('exported sheep has working joints and renders the final idle pose after wa
sheep.dispose();
});
test('sideways and rear destinations turn the character forward and ease the camera behind it', () => {
test('idle breathing moves torso and head while feet stay planted and clones retain authored rest transforms', async () => {
const bytes = await readFile(new URL('../public/characters/red-sheep.glb', import.meta.url));
const model = await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), '');
const sheep = createSheep(model.scene);
const body = sheep.group.getObjectByName('Body')!, head = sheep.group.getObjectByName('Head')!;
const bodyRest = body.scale.clone(), headRest = head.position.clone();
const leg = sheep.group.getObjectByName('LegLeft')!;
const feet = leg.getWorldPosition(new Vector3());
sheep.pose('idle', 0, false, 0);
for (let i = 1; i <= 8; i++) sheep.pose('idle', 0, false, i / 10);
assert.ok(!body.scale.equals(bodyRest));
assert.ok(!head.position.equals(headRest));
assert.ok(leg.getWorldPosition(new Vector3()).distanceTo(feet) < 1e-8);
const once = body.scale.clone();
sheep.pose('idle', 0, false, .8);
assert.deepEqual(body.scale, once, 'same timestamp cannot accumulate scaling');
const remote = sheep.clone();
remote.pose('wave', 0, false, 1);
assert.deepEqual(remote.group.getObjectByName('Body')!.scale, bodyRest, 'clone does not inherit inflated rest scale');
assert.deepEqual(remote.group.getObjectByName('Head')!.position, headRest);
assert.deepEqual(body.scale, once, 'remote pose is independent');
sheep.pose('idle', 0, true, .81);
assert.ok(!body.scale.equals(bodyRest), 'breathing blends out instead of snapping');
sheep.pose('idle', 0, true, 1.2);
assert.deepEqual(body.scale, bodyRest);
assert.ok(head.position.distanceTo(headRest) > .001, 'walking carries the head');
assert.ok(body.position.x !== 0, 'walking shifts the torso weight');
const walkingHead = head.position.clone();
sheep.pose('idle', 0, true, 1.2);
assert.deepEqual(head.position, walkingHead, 'walking offsets cannot accumulate');
sheep.pose('pray', 0, false, 1.5);
assert.deepEqual(head.position, headRest, 'emotes clear walking offsets');
remote.dispose(); sheep.dispose();
});
test('sideways and rear destinations turn the character without changing the chosen camera angle', () => {
for (const step of [new Vector3(1, 0, 0), new Vector3(0, 0, 1), new Vector3(-1, 0, -.01)]) {
const character = new Group();
character.rotation.y = Math.PI;
@@ -129,21 +168,19 @@ test('sideways and rear destinations turn the character forward and ease the cam
camera.rotation.set(-.2, 0, 0, 'YXZ');
const original = camera.quaternion.clone();
const initialFacing = character.quaternion.clone();
const speed = faceTravel(character, camera, step, 1 / 60);
const speed = faceTravel(character, step, 1 / 60);
assert.ok(character.quaternion.angleTo(initialFacing) <= 4 / 60 + 1e-6, 'body must turn gradually');
if (step.z === 1) assert.equal(speed, 0, 'rear destinations turn before translating');
assert.ok(camera.quaternion.angleTo(original) < .22, 'camera must ease, not snap');
for (let i = 0; i < 180; i++) faceTravel(character, camera, step, 1 / 60);
assert.ok(camera.quaternion.equals(original), 'walking preserves the chosen camera angle');
for (let i = 0; i < 180; i++) faceTravel(character, step, 1 / 60);
assert.ok(character.getWorldDirection(new Vector3()).dot(step.clone().normalize()) > .999);
const view = camera.getWorldDirection(new Vector3());
view.y = 0;
assert.ok(view.normalize().dot(step.clone().normalize()) > .999);
assert.ok(camera.quaternion.equals(original));
assert.ok(Math.abs(camera.rotation.x + .2) < 1e-6, 'preserve viewing pitch');
const settled = character.quaternion.clone();
faceTravel(character, camera, new Vector3(), 1 / 60);
faceTravel(character, new Vector3(), 1 / 60);
assert.ok(character.quaternion.angleTo(settled) < 1e-6, 'idle keeps the last facing');
const manual = camera.quaternion.clone();
faceTravel(character, camera, step.clone().negate(), 1 / 60, false);
faceTravel(character, step.clone().negate(), 1 / 60);
assert.ok(camera.quaternion.angleTo(manual) < 1e-6, 'manual looking takes priority');
}
});
+24
View File
@@ -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);
});
+56 -2
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Group } from 'three';
import { BoxGeometry, Group, Mesh, MeshBasicMaterial, PerspectiveCamera, Raycaster, Vector3 } from 'three';
import { createServerClock } from '../src/multiplayer/clock.ts';
import { createRemotePlayers } from '../src/multiplayer/remotePlayers.ts';
import type { Sheep } from '../src/scene/sheep.ts';
@@ -16,13 +16,64 @@ test('server clock uses round trip midpoint and monotonic elapsed time', () => {
assert.equal(clock.now(), 1001020);
});
test('offscreen players skip posing but keep movement current and reappear when the camera turns', () => {
const scene = new Group(), camera = new PerspectiveCamera(60, 1, .1, 100);
let poses = 0;
const template = { group: new Group(), clone() { return { group: new Group(), setEquipment() {},
pose() { poses++; return true; }, dispose() {} } as unknown as Sheep; } } as Sheep;
const remote = createRemotePlayers(template, scene, () => 0);
const player: PlayerState = { id: 'other', nickname: '친구', x: 0, z: 10, yaw: 0, moving: false,
emote: 'idle', equipment: { umbrella: false, raincoat: false, hood: false }, updatedAt: 1000, emoteStartedAt: 1000 };
remote.synchronize([player], 'self');
for (let i = 0; i < 300; i++) remote.update(1 / 30, 1000 + i * 1000 / 30, camera);
assert.equal(poses, 0);
assert.equal(remote.get('other')!.visible, false);
remote.synchronize([{ ...player, x: 2, updatedAt: 11000 }], 'self');
remote.update(.2, 11200, camera);
assert.equal(remote.get('other')!.position.x, 2);
camera.lookAt(2, 0, 10);
assert.equal(remote.update(.03, 11230, camera), true);
assert.equal(poses, 1);
assert.equal(remote.get('other')!.visible, true);
});
test('companion selection picks visible players and chooses the nearest reachable unoccupied side', () => {
const scene = new Group();
const geometry = new BoxGeometry(1, 2, 1), material = new MeshBasicMaterial();
const template = { clone() {
const group = new Group(); group.add(new Mesh(geometry, material));
return { group, setEquipment() {}, pose: () => false, dispose() { group.removeFromParent(); } } as unknown as Sheep;
} } as Sheep;
const remote = createRemotePlayers(template, scene, () => 0);
const player: PlayerState = { id: 'friend', nickname: '친구', x: 0, z: 0, yaw: 0, moving: false,
emote: 'idle', equipment: { umbrella: false, raincoat: false, hood: false }, updatedAt: 1000, emoteStartedAt: 1000 };
remote.synchronize([player], 'self');
const ray = new Raycaster(new Vector3(0, 0, 5), new Vector3(0, 0, -1));
assert.equal(remote.pick(ray, 10), 'friend');
assert.equal(remote.pick(ray, 2), undefined, 'terrain in front prevents selection');
const from = new Vector3(3, 0, 0);
const right = remote.beside('friend', from, () => true)!;
assert.ok(right.position.x > 0 && right.position.z === 0);
assert.equal(right.yaw, 0);
const left = remote.beside('friend', from, point => point.x < 0)!;
assert.ok(left.position.x < 0);
assert.equal(remote.beside('friend', from, () => false), undefined);
remote.synchronize([player, { ...player, id: 'occupied', x: right.position.x }], 'self');
assert.ok(remote.beside('friend', from, () => true)!.position.x < 0);
remote.synchronize([], 'self');
assert.equal(remote.get('friend'), undefined);
assert.equal(remote.beside('friend', from, () => true), undefined);
geometry.dispose(); material.dispose();
});
test('remote snapshot reconciles membership, equipment and interpolated motion without local duplicate', () => {
const scene = new Group();
const equipment: boolean[][] = [];
let disposed = 0;
const template = { clone() { return { group: new Group(), setEquipment: (...v: boolean[]) => equipment.push(v),
pose: () => false, dispose() { this.group.removeFromParent(); disposed++; } } as unknown as Sheep; } } as Sheep;
const remote = createRemotePlayers(template, scene, () => 3);
let heightSamples = 0;
const remote = createRemotePlayers(template, scene, () => { heightSamples++; return 3; });
const player: PlayerState = { id: 'other', nickname: '방문자', x: 0, z: 0, yaw: 0, moving: false,
emote: 'idle', equipment: { umbrella: true, raincoat: true, hood: false, flashlight: true }, updatedAt: 1000, emoteStartedAt: 1000 };
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);
assert.equal(scene.children[0].position.x, 1);
assert.equal(scene.children[0].position.y, 3);
const samplesAfterMoving = heightSamples;
for (let i = 0; i < 300; i++) remote.update(1 / 30, 1300 + i * 1000 / 30);
assert.equal(heightSamples, samplesAfterMoving, 'stationary players reuse their terrain height');
remote.synchronize([], 'self');
assert.equal(scene.children.length, 0);
assert.equal(disposed, 1);
+32 -2
View File
@@ -9,6 +9,36 @@ import { createFlashlight } from '../src/scene/flashlight.ts';
import { FLASHLIGHT } from '../src/constants/flashlight.ts';
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 () => {
const [model, umbrella, raincoat, torch] = await Promise.all(['red-sheep', 'umbrella', 'raincoat', 'flashlight'].map(asset));
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.ok(arm.quaternion.angleTo(rest) < 1e-6);
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();
});
@@ -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.ok(arm.quaternion.angleTo(rest) < 1e-6);
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();
});
+75 -5
View File
@@ -5,7 +5,9 @@ import { createFpsCameraController } from '../src/controls/fpsCameraController.t
import { directionFromAngles } from '../src/astronomy/direction.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 oldDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
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 picks: number[][] = [];
const controller = createFpsCameraController(camera, canvas as unknown as HTMLCanvasElement,
(x, y) => { picks.push([x, y]); return new Vector3(x, 0, y); });
try { run(camera, canvas, picks); }
(x, y) => { picks.push([x, y]); return new Vector3(x, 0, y); }, options);
try { run(camera, canvas, picks, controller); }
finally {
controller.dispose();
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 }),
{ 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', () => {
withController((_camera, canvas, picks) => {
for (const [type, button] of [['touch', 0], ['mouse', 2], ['mouse', 0]] as const) {