Improve character controls and add idle breathing
This commit is contained in:
+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;
|
||||
|
||||
Reference in New Issue
Block a user