Improve character controls and add idle breathing
This commit is contained in:
@@ -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
@@ -87,6 +87,16 @@
|
||||
</fieldset>
|
||||
</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="캐릭터 액션">
|
||||
<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 +163,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>
|
||||
|
||||
@@ -13,8 +13,17 @@ 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,
|
||||
idle: {
|
||||
periodSeconds: 3.8,
|
||||
transitionSeconds: 0.3,
|
||||
torsoRise: 0.01,
|
||||
torsoDepth: 0.014,
|
||||
headLift: 0.018,
|
||||
shoulderLift: 0.008,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export const MOVEMENT = { speed: 4, maxDelta: 0.05, dragSensitivity: 0.003, dragThreshold: 6, arrivalDistance: 0.01 } as const;
|
||||
export const MOVEMENT = {
|
||||
speed: 4, maxDelta: 0.05, dragSensitivity: 0.003, dragThreshold: 6, arrivalDistance: 0.01,
|
||||
turnSensitivity: 0.008, wheelSensitivity: 0.002, companionSpacing: 1.2,
|
||||
} as const;
|
||||
|
||||
@@ -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,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++) {
|
||||
|
||||
@@ -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 { PlayerState } from './protocol.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) {
|
||||
const players = new Map<string, { sheep: Sheep; state: PlayerState; from: Vector3; yaw: number; elapsed: number }>();
|
||||
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); }
|
||||
|
||||
@@ -59,8 +59,8 @@ 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;
|
||||
let progress = reducedMotion ? 1 : 0;
|
||||
let frame = 0;
|
||||
let elapsed = 0;
|
||||
let lastFrame = 0;
|
||||
@@ -73,7 +73,7 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
||||
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);
|
||||
sheep.pose('idle', 0, false, elapsed / 1000);
|
||||
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));
|
||||
@@ -104,7 +104,7 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
||||
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 (!reducedMotion) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
const visible = () => { lastFrame = 0; if (!document.hidden) render(); };
|
||||
cleanup = () => {
|
||||
|
||||
+30
-1
@@ -104,6 +104,11 @@ 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() }));
|
||||
const animateIdle = typeof matchMedia === 'undefined' || !matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const originals: Object3D[] = [];
|
||||
group.traverse(object => originals.push(object));
|
||||
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 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 breathPhase = Math.random() * Math.PI * 2;
|
||||
let idleWeight = 0;
|
||||
let flashlightOn = false;
|
||||
let flashlightPose = 0;
|
||||
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) {
|
||||
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;
|
||||
previousPoseTime = time;
|
||||
const previousFlashlightPose = flashlightPose;
|
||||
@@ -161,7 +189,8 @@ 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
|
||||
|| moving || emote !== 'idle' || previousMoving !== moving || previousEmote !== emote
|
||||
|| previousFlashlightPose !== flashlightPose || flashlightPose !== Number(flashlightOn)
|
||||
|| previousUmbrellaPose !== umbrellaPose || umbrellaPose !== Number(umbrellaOpen);
|
||||
previousMoving = moving;
|
||||
|
||||
+92
-12
@@ -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';
|
||||
@@ -168,20 +169,82 @@ 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;
|
||||
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();
|
||||
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;
|
||||
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;
|
||||
companionName.textContent = remotePlayers!.get(id)!.name;
|
||||
companionPanel.hidden = false;
|
||||
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 });
|
||||
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 primaryActionLabel = document.getElementById('primary-action-label')!;
|
||||
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 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 ? '끄기' : '켜기') : '인사';
|
||||
@@ -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))) {
|
||||
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,7 +416,16 @@ 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;
|
||||
companionStatus.textContent = arrived ? '나란히 섰어요. 드래그로 구도를 맞춰 보세요.' : '이동이 막혔어요. 조금 더 가까이 이동해 주세요.';
|
||||
}
|
||||
controls.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
playerMoving = moving;
|
||||
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));
|
||||
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));
|
||||
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
|
||||
}
|
||||
const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera;
|
||||
flashlight?.update(flashlightOn && !overview);
|
||||
@@ -446,7 +519,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 +534,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 +552,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 +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); },
|
||||
showCityOverview() {
|
||||
controls.clear();
|
||||
closeCompanion();
|
||||
emotes.cancel();
|
||||
overview = true;
|
||||
refreshEmotes();
|
||||
@@ -505,14 +585,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));
|
||||
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
|
||||
}
|
||||
},
|
||||
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) updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
|
||||
},
|
||||
setEyeHeight(meters: number) {
|
||||
eyeHeight = meters;
|
||||
|
||||
@@ -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); } }
|
||||
@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, #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[open] { display: flex; flex-direction: column; }
|
||||
|
||||
+37
-10
@@ -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.equal(sheep.pose('idle', 0, false, 1), true, 'stopping must invalidate one frame');
|
||||
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 +121,36 @@ 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.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)]) {
|
||||
const character = new Group();
|
||||
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');
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
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 { createRemotePlayers } from '../src/multiplayer/remotePlayers.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);
|
||||
});
|
||||
|
||||
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[][] = [];
|
||||
|
||||
+22
-2
@@ -9,6 +9,26 @@ 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));
|
||||
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 () => {
|
||||
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 +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.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 +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.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();
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user