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
+92 -12
View File
@@ -9,6 +9,7 @@ import { observer } from '../config/observer.ts';
import { SCENE } from '../constants/scene.ts';
import { EQUIPMENT_NOTICE, PRAYER_NOTICE } from '../constants/ui.ts';
import { MOVEMENT } from '../constants/movement.ts';
import { CHARACTER } from '../constants/character.ts';
import { createFpsCameraController } from '../controls/fpsCameraController.ts';
import { createSky } from './sky.ts';
import { createSun } from './sun.ts';
@@ -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;