Improve character controls and add idle breathing

This commit is contained in:
2026-11-02 11:56:28 +09:00
parent 0d58170211
commit 21b0bb7e98
15 changed files with 458 additions and 65 deletions
@@ -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.
+11 -1
View File
@@ -87,6 +87,16 @@
</fieldset> </fieldset>
</div> </div>
</div> </div>
<div class="movement-controls" aria-label="위치와 방향 조절">
<div id="companion-selection" hidden>
<span id="companion-name"></span>
<button type="button" id="stand-beside">옆에 서기</button>
<button type="button" id="companion-close" aria-label="선택 닫기">×</button>
<p id="companion-status" role="status"></p>
</div>
<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 +163,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>
+10 -1
View File
@@ -13,8 +13,17 @@ 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,
idle: {
periodSeconds: 3.8,
transitionSeconds: 0.3,
torsoRise: 0.01,
torsoDepth: 0.014,
headLift: 0.018,
shoulderLift: 0.008,
},
} as const; } 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;
+69 -16
View File
@@ -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 -10
View File
@@ -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++) {
+26 -1
View File
@@ -1,11 +1,36 @@
import { type Object3D, MathUtils, Vector3 } from 'three'; import { type Object3D, type Raycaster, MathUtils, 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';
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 }>();
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); }
+4 -4
View File
@@ -59,8 +59,8 @@ 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; let progress = reducedMotion ? 1 : 0;
let frame = 0; let frame = 0;
let elapsed = 0; let elapsed = 0;
let lastFrame = 0; let lastFrame = 0;
@@ -73,7 +73,7 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
nightSky.update(date, observer, state.altitude); nightSky.update(date, observer, state.altitude);
const moonAltitude = getMoonPosition(date, observer.latitude, observer.longitude).altitude; const moonAltitude = getMoonPosition(date, observer.latitude, observer.longitude).altitude;
const gazeAltitude = MathUtils.lerp(moonAltitude, state.altitude, MathUtils.smoothstep(state.altitude, -8, 0)); const gazeAltitude = MathUtils.lerp(moonAltitude, state.altitude, MathUtils.smoothstep(state.altitude, -8, 0));
head.quaternion.copy(headRest); sheep.pose('idle', 0, false, elapsed / 1000);
head.rotateX(-MathUtils.degToRad(MathUtils.clamp(gazeAltitude, 8, 28))); head.rotateX(-MathUtils.degToRad(MathUtils.clamp(gazeAltitude, 8, 28)));
sheep.updateLighting(Math.max(LOBBY_CHARACTER.minimumLightingAltitude, state.altitude), 1); sheep.updateLighting(Math.max(LOBBY_CHARACTER.minimumLightingAltitude, state.altitude), 1);
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(state.altitude, -8, 15)); ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(state.altitude, -8, 15));
@@ -104,7 +104,7 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
lastFrame = now; lastFrame = now;
progress = MathUtils.clamp((elapsed - LOBBY_SUNRISE.nightHoldMs) / LOBBY_SUNRISE.durationMs, 0, 1); progress = MathUtils.clamp((elapsed - LOBBY_SUNRISE.nightHoldMs) / LOBBY_SUNRISE.durationMs, 0, 1);
if (!document.hidden) render(); if (!document.hidden) render();
if (progress < 1) frame = requestAnimationFrame(tick); if (!reducedMotion) frame = requestAnimationFrame(tick);
}; };
const visible = () => { lastFrame = 0; if (!document.hidden) render(); }; const visible = () => { lastFrame = 0; if (!document.hidden) render(); };
cleanup = () => { cleanup = () => {
+30 -1
View File
@@ -104,6 +104,11 @@ 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() }));
const animateIdle = typeof matchMedia === 'undefined' || !matchMedia('(prefers-reduced-motion: reduce)').matches;
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 {
@@ -116,6 +121,10 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
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 breathPhase = Math.random() * Math.PI * 2;
let idleWeight = 0;
let flashlightOn = false; let flashlightOn = false;
let flashlightPose = 0; let flashlightPose = 0;
let previousPoseTime: number | undefined; let previousPoseTime: number | undefined;
@@ -153,6 +162,25 @@ 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 idle = animateIdle && !moving && emote === 'idle';
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.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 && body) part.position.y += -body.position.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,7 +189,8 @@ 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
|| 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;
+92 -12
View File
@@ -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';
@@ -168,20 +169,82 @@ 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;
let selectedCompanion: string | undefined;
const directionButton = document.querySelector<HTMLButtonElement>('#direction-toggle')!;
const directionHint = document.getElementById('direction-hint')!;
const companionPanel = document.getElementById('companion-selection')!;
const companionName = document.getElementById('companion-name')!;
const companionStatus = document.getElementById('companion-status')!;
const besideButton = document.querySelector<HTMLButtonElement>('#stand-beside')!;
const closeCompanion = () => { selectedCompanion = undefined; companionPanel.hidden = true; companionStatus.textContent = ''; };
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) updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
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;
companionName.textContent = remotePlayers!.get(id)!.name;
companionPanel.hidden = false;
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 });
document.getElementById('companion-close')!.addEventListener('click', closeCompanion, { 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;
});
if (!spot) { companionStatus.textContent = '바로 걸어갈 수 있는 빈자리가 없어요. 조금 더 가까이 이동해 주세요.'; return; }
emotes.cancel(); refreshEmotes();
controls.moveTo(spot.position, spot.yaw);
companionStatus.textContent = '옆자리로 이동 중';
}, { 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 +305,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 ? '끄기' : '켜기') : '인사';
@@ -342,7 +406,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,7 +416,16 @@ 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;
companionStatus.textContent = arrived ? '나란히 섰어요. 드래그로 구도를 맞춰 보세요.' : '이동이 막혔어요. 조금 더 가까이 이동해 주세요.';
}
controls.stop();
}
}
} }
playerMoving = moving; playerMoving = moving;
if (remotePlayers?.update(delta, sharedNow ? sharedNow() : Date.now())) rendering.invalidate(); if (remotePlayers?.update(delta, sharedNow ? sharedNow() : Date.now())) rendering.invalidate();
@@ -377,10 +450,10 @@ 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)); updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
} }
const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera; const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera;
flashlight?.update(flashlightOn && !overview); flashlight?.update(flashlightOn && !overview);
@@ -446,7 +519,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 +534,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 +552,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 +567,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 +585,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)); updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
} }
}, },
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) updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
}, },
setEyeHeight(meters: number) { setEyeHeight(meters: number) {
eyeHeight = meters; eyeHeight = meters;
+12
View File
@@ -55,6 +55,18 @@ 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, #companion-selection { margin: 0; padding: 10px 12px; border: 1px solid #ffffff26; border-radius: 14px; background: #18232ef2; font-size: 12px; line-height: 1.5; }
#companion-selection { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; pointer-events: auto; }
#companion-name { max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#companion-status { flex-basis: 100%; margin: 0; }
#companion-status:empty { display: none; }
@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; }
+37 -10
View File
@@ -111,7 +111,7 @@ test('exported sheep has working joints and renders the final idle pose after wa
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'); assert.equal(sheep.pose('idle', 0, false, 1), true, 'stopping must invalidate one frame');
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 +121,36 @@ 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.deepEqual(head.position, headRest);
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 +158,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');
} }
}); });
+30 -1
View File
@@ -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, 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,6 +16,35 @@ test('server clock uses round trip midpoint and monotonic elapsed time', () => {
assert.equal(clock.now(), 1001020); assert.equal(clock.now(), 1001020);
}); });
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[][] = [];
+22 -2
View File
@@ -9,6 +9,26 @@ 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));
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);
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 +80,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 +191,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();
}); });
+75 -5
View File
@@ -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) {