661 lines
37 KiB
TypeScript
661 lines
37 KiB
TypeScript
import { type BufferGeometry, ACESFilmicToneMapping, Color, Fog, HemisphereLight, MathUtils, PerspectiveCamera, Raycaster, Scene, Vector2, Vector3, WebGLRenderer } from 'three';
|
|
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
|
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
|
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
|
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
|
|
import type { SunState } from '../astronomy/sunCalculator.ts';
|
|
import { directionFromAngles } from '../astronomy/direction.ts';
|
|
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';
|
|
import { createNightSky } from './nightSky.ts';
|
|
import { createHorizon } from './horizon.ts';
|
|
import { createCompass } from '../ui/compass.ts';
|
|
import { createTerrain } from './terrain.ts';
|
|
import type { loadTerrain } from './terrain.ts';
|
|
import { TERRAIN } from '../constants/terrain.ts';
|
|
import { createStructures, structurePosition } from './structures.ts';
|
|
import type { StructureAssets } from './structures.ts';
|
|
import { createBakedBuildings } from './buildingAssets.ts';
|
|
import type { BuildingAssets } from './buildingAssets.ts';
|
|
import { BUILDINGS } from '../constants/buildings.ts';
|
|
import { walkingCoordinates, walkStep } from '../controls/walking.ts';
|
|
import { createOcean, isCoastalLand, seaHeight } from './coast.ts';
|
|
import type { LoadedCoast } from './coast.ts';
|
|
import { COAST } from '../constants/coast.ts';
|
|
import { GISAMUN } from '../constants/gisamun.ts';
|
|
import { createHarbour, isGisamun } from './harbour.ts';
|
|
import { createRenderInvalidation } from './renderInvalidation.ts';
|
|
import { createTerrainMinimap } from '../ui/terrainMinimap.ts';
|
|
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
|
|
import { createSummitWalking } from '../controls/summitWalking.ts';
|
|
import { createSummitTrails } from './summitTrails.ts';
|
|
import { createSummitRelief } from './summitRelief.ts';
|
|
import { SUMMIT_RELIEF } from '../constants/summitRelief.ts';
|
|
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
|
|
import { createSummitMarker } from './summitMarker.ts';
|
|
import { WEATHER } from '../constants/weather.ts';
|
|
import { weatherAppearance } from '../weather/weather.ts';
|
|
import type { WeatherSample } from '../weather/weather.ts';
|
|
import { createPrecipitation } from './precipitation.ts';
|
|
import type { createRenderProfile } from '../dev/renderProfile.ts';
|
|
import type { loadSheep } from './sheep.ts';
|
|
import { canPray, createEmoteState, type Emote } from '../controls/emotes.ts';
|
|
import { createRainGearState, type RainGearItem } from '../controls/rainGear.ts';
|
|
import { faceTravel, updateThirdPersonCamera } from '../controls/thirdPersonCamera.ts';
|
|
import { createRemotePlayers } from '../multiplayer/remotePlayers.ts';
|
|
import type { PlayerState, PlayerUpdate } from '../multiplayer/protocol.ts';
|
|
import { createSelfBadge } from '../ui/selfBadge.ts';
|
|
import { createFlashlight } from './flashlight.ts';
|
|
import { FLASHLIGHT } from '../constants/flashlight.ts';
|
|
import { flashlightAfterSunUpdate } from '../controls/flashlight.ts';
|
|
|
|
export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<ReturnType<typeof loadTerrain>>, buildingData: BuildingAssets | null,
|
|
onMove: (location: { latitude: number; longitude: number; elevation: number }) => void, coast?: LoadedCoast, structureAssets?: StructureAssets, reconstructSummit = false, summitGeometry?: BufferGeometry,
|
|
sheep?: Awaited<ReturnType<typeof loadSheep>>) {
|
|
const scene = new Scene();
|
|
const rendering = createRenderInvalidation();
|
|
const camera = new PerspectiveCamera(SCENE.fieldOfView, innerWidth / innerHeight, SCENE.near, SCENE.far);
|
|
camera.position.set(0, observer.eyeHeight, 0);
|
|
camera.lookAt(1, observer.eyeHeight, 0);
|
|
const renderer = new WebGLRenderer({ canvas, antialias: true });
|
|
renderer.toneMapping = ACESFilmicToneMapping;
|
|
const sky = createSky();
|
|
const sun = createSun(camera.position);
|
|
const nightSky = createNightSky(camera.position);
|
|
const horizon = createHorizon(camera.position, terrainData.metadata.observerSurfaceElevation);
|
|
const terrain = createTerrain(terrainData, coast, reconstructSummit);
|
|
const remotePlayers = sheep ? createRemotePlayers(sheep, scene, terrain.heightAt) : undefined;
|
|
const selfBadge = sheep ? createSelfBadge(sheep.group, canvas) : undefined;
|
|
const flashlight = sheep ? createFlashlight(sheep.group) : undefined;
|
|
if (flashlight) scene.add(flashlight.light, flashlight.light.target);
|
|
let flashlightOn = false;
|
|
let networkBlocked = false;
|
|
let sharedNow: (() => number) | null = null;
|
|
let playerMoving = false;
|
|
const profiling = import.meta.env.DEV && new URLSearchParams(location.search).has('profile');
|
|
let profile: ReturnType<typeof createRenderProfile> | undefined;
|
|
let disposed = false;
|
|
const summitStarted = performance.now();
|
|
const summitWalking = reconstructSummit && !coast ? createSummitWalking(terrainData.metadata.origin) : undefined;
|
|
const summitTrails = summitWalking && summitGeometry ? createSummitTrails(summitGeometry,()=>{
|
|
rendering.invalidate(); profile?.transition();
|
|
}, summitWalking.start) : undefined;
|
|
if(profiling) console.info('summit-construction-ms',performance.now()-summitStarted);
|
|
const minimap = createTerrainMinimap(terrainData, terrain.mesh.geometry, summitWalking);
|
|
const compass = createCompass();
|
|
const ocean = coast ? createOcean(coast, terrainData.metadata.observerSurfaceElevation) : undefined;
|
|
const harbour = coast && isGisamun(coast.data.origin) ? createHarbour(terrainData.metadata.observerSurfaceElevation) : undefined;
|
|
const precipitation = createPrecipitation((x, z) => {
|
|
const radius = terrainData.metadata.radiusMeters;
|
|
const ground = harbour?.heightAt(x, z) ?? terrain.heightAt(MathUtils.clamp(x, -radius, radius), MathUtils.clamp(z, -radius, radius));
|
|
return coast ? Math.max(ground, seaHeight(x, z, terrainData.metadata.observerSurfaceElevation)) : ground;
|
|
});
|
|
scene.add(precipitation.group);
|
|
const buildings = buildingData ? createBakedBuildings(buildingData, terrainData.metadata) : undefined;
|
|
buildings?.spill.applyTo(terrain.mesh.material);
|
|
const composer = buildings ? new EffectComposer(renderer) : undefined;
|
|
const renderPass = new RenderPass(scene, camera);
|
|
const bloom = buildings ? new UnrealBloomPass(new Vector2(innerWidth, innerHeight),
|
|
BUILDINGS.lighting.bloomStrength, BUILDINGS.lighting.bloomRadius, BUILDINGS.lighting.bloomThreshold) : undefined;
|
|
const outputPass = composer ? new OutputPass() : undefined;
|
|
if (composer && bloom && outputPass) {
|
|
composer.renderTarget1.samples = composer.renderTarget2.samples = 4;
|
|
composer.addPass(renderPass);
|
|
composer.addPass(bloom);
|
|
composer.addPass(outputPass);
|
|
}
|
|
const start = harbour ? GISAMUN.viewpoint : coast ? { x: 0, z: 0 }
|
|
: structurePosition(SUMMIT_TERRAIN.start.latitude, SUMMIT_TERRAIN.start.longitude, terrainData.metadata.origin);
|
|
const target = harbour ? new Vector3(GISAMUN.jodo.x, terrain.heightAt(GISAMUN.jodo.x, GISAMUN.jodo.z), GISAMUN.jodo.z)
|
|
: buildings?.target ?? structurePosition(coast!.lookTarget.latitude, coast!.lookTarget.longitude, terrainData.metadata.origin);
|
|
const overviewCamera = camera.clone();
|
|
const followCamera = camera.clone();
|
|
if (coast) overviewCamera.position.set(0, COAST.overviewHeight, 1200);
|
|
else overviewCamera.position.y = BUILDINGS.overviewHeight;
|
|
overviewCamera.lookAt(coast ? new Vector3(300, 0, 0) : target);
|
|
let overview = false;
|
|
const viewNote = document.getElementById('view-note')!;
|
|
const restoreView = () => { overview = false; viewNote.textContent = ''; refreshEmotes(); };
|
|
canvas.addEventListener('pointerdown', restoreView);
|
|
terrain.mesh.updateMatrixWorld(true);
|
|
const groundRay = new Raycaster(new Vector3(), new Vector3(0, -1, 0));
|
|
const structures = coast ? undefined : createStructures(terrainData.metadata.origin, (x, z) => {
|
|
groundRay.ray.origin.set(x, 10000, z);
|
|
const hit = groundRay.intersectObject(terrain.mesh, false)[0];
|
|
if (!hit) throw new Error('구조물 위치의 지형 높이를 찾지 못했습니다.');
|
|
return hit.point.y;
|
|
}, structureAssets!);
|
|
const summitRelief = summitTrails && structures ? createSummitRelief(
|
|
structures.group.getObjectByName('weather-radar')!.position, terrain.heightAt, summitTrails.rockMaterial,
|
|
structureAssets!.get(SUMMIT_RELIEF.assetId)!) : undefined;
|
|
const summitMarker = summitTrails ? createSummitMarker(structureAssets!.get(SUMMIT_PLATEAU.marker.assetId)!,
|
|
start, terrain.heightAt, summitTrails.rockMaterial) : undefined;
|
|
const ambient = new HemisphereLight('#c7def0', '#3e4235', 1.5);
|
|
const fog = new Fog('#a6b6bc', TERRAIN.fogNear, TERRAIN.fogFar);
|
|
scene.fog = fog;
|
|
const nightFog = new Color('#060b15'), dawnFog = new Color('#383e38'), dayFog = new Color('#a6b6bc');
|
|
let solarState: SunState | undefined;
|
|
let weatherTarget = weatherAppearance(null);
|
|
const atmosphere = { ...weatherTarget };
|
|
const sunDirection = new Vector3();
|
|
function applyAtmosphere() {
|
|
if (!solarState) return;
|
|
const altitude = solarState.altitude;
|
|
sunDirection.set(...directionFromAngles(solarState.azimuth, altitude));
|
|
sky.update(sunDirection, altitude, atmosphere.cloud);
|
|
sun.update(sunDirection, altitude, atmosphere.transmission, atmosphere.cloud);
|
|
nightSky.update(solarState.date, {
|
|
...observer, ...walkingCoordinates(camera.position.x, camera.position.z, terrainData.metadata.origin),
|
|
}, altitude, atmosphere.cloud);
|
|
fog.near = atmosphere.fogNear;
|
|
fog.far = atmosphere.fogFar;
|
|
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(altitude, -8, 15)) * atmosphere.ambient;
|
|
sheep?.updateLighting(altitude, atmosphere.ambient);
|
|
}
|
|
scene.add(sky.mesh, sun.group, sun.light, sun.light.target, horizon.group, terrain.mesh, ambient);
|
|
scene.add(nightSky.group);
|
|
if (structures) scene.add(structures.group);
|
|
if (summitTrails) scene.add(summitTrails.group);
|
|
if (summitRelief) scene.add(summitRelief.group);
|
|
if (summitMarker) scene.add(summitMarker.group);
|
|
if (buildings) scene.add(buildings.group);
|
|
if (ocean) scene.add(ocean.mesh);
|
|
if (harbour) scene.add(harbour.group);
|
|
const destinationRay = new Raycaster();
|
|
const pointer = new Vector2();
|
|
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), cameraDistance);
|
|
const view = sheep ? followCamera : camera;
|
|
view.updateMatrixWorld(true);
|
|
destinationRay.setFromCamera(pointer, view);
|
|
const surfaces = harbour ? [terrain.mesh, harbour.group] : [terrain.mesh];
|
|
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')!;
|
|
const prayButton = document.querySelector<HTMLButtonElement>('#emote-pray')!;
|
|
const emoteStatus = document.getElementById('emote-status')!;
|
|
const rainGear = createRainGearState();
|
|
const umbrellaButton = document.querySelector<HTMLButtonElement>('#gear-umbrella')!;
|
|
const raincoatButton = document.querySelector<HTMLButtonElement>('#gear-raincoat')!;
|
|
const hoodButton = document.querySelector<HTMLButtonElement>('#gear-hood')!;
|
|
const flashlightButton = document.querySelector<HTMLButtonElement>('#gear-flashlight')!;
|
|
document.getElementById('flashlight-lumens')!.textContent = `${FLASHLIGHT.lumens} lm`;
|
|
flashlightButton.title = `손전등 · ${FLASHLIGHT.lumens} lm · 단축키 6`;
|
|
const gearStatus = document.getElementById('gear-status')!;
|
|
const refreshGear = () => {
|
|
flashlightButton.disabled = !sheep || overview || networkBlocked;
|
|
const dark = Boolean(solarState && solarState.altitude <= FLASHLIGHT.nightAltitude);
|
|
flashlightButton.setAttribute('aria-disabled', String(flashlightButton.disabled || !dark));
|
|
flashlightButton.dataset.unavailableReason = dark ? '' : '손전등은 해가 진 뒤 사용할 수 있습니다';
|
|
flashlightButton.setAttribute('aria-pressed', String(flashlightOn));
|
|
flashlightButton.setAttribute('aria-label', `손전등 ${flashlightOn ? '끄기' : '켜기'} · ${FLASHLIGHT.lumens} lm`);
|
|
umbrellaButton.disabled = raincoatButton.disabled = hoodButton.disabled = !sheep || overview || networkBlocked;
|
|
for (const button of [umbrellaButton, raincoatButton, hoodButton]) {
|
|
const reason = !rainGear.available ? EQUIPMENT_NOTICE.weather
|
|
: button === hoodButton && !rainGear.raincoat ? EQUIPMENT_NOTICE.raincoat : '';
|
|
button.setAttribute('aria-disabled', String(Boolean(reason) || button.disabled));
|
|
button.dataset.unavailableReason = reason;
|
|
}
|
|
umbrellaButton.setAttribute('aria-pressed', String(rainGear.umbrella));
|
|
raincoatButton.setAttribute('aria-pressed', String(rainGear.raincoat));
|
|
hoodButton.setAttribute('aria-pressed', String(rainGear.hood));
|
|
umbrellaButton.setAttribute('aria-label', rainGear.umbrella ? '우산 접기' : '우산 쓰기');
|
|
raincoatButton.setAttribute('aria-label', rainGear.raincoat ? '우비 벗기' : '우비 입기');
|
|
hoodButton.setAttribute('aria-label', rainGear.hood ? '모자 벗기' : '모자 쓰기');
|
|
[umbrellaButton, raincoatButton, hoodButton].forEach((button, index) => {
|
|
button.title = `${button.getAttribute('aria-label')} · 단축키 ${index + 3}`;
|
|
});
|
|
const worn = [rainGear.umbrella ? '우산' : '', rainGear.raincoat ? '우비' : '', rainGear.hood ? '후드' : ''].filter(Boolean).join(' · ');
|
|
gearStatus.textContent = overview ? '관측 시점으로 돌아오면 장비를 바꿀 수 있습니다'
|
|
: !rainGear.available ? EQUIPMENT_NOTICE.weather
|
|
: worn ? `${worn} 착용 중 · 이동과 감정표현 중에도 유지`
|
|
: '비·눈이 오는 중 · 3 우산 / 4 우비';
|
|
};
|
|
const toggleGear = (item: RainGearItem) => {
|
|
if (!sheep || overview || networkBlocked || !rainGear.toggle(item)) return;
|
|
sheep.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
|
refreshGear(); rendering.invalidate();
|
|
};
|
|
umbrellaButton.addEventListener('click', () => toggleGear('umbrella'), { signal: emoteEvents.signal });
|
|
raincoatButton.addEventListener('click', () => toggleGear('raincoat'), { signal: emoteEvents.signal });
|
|
hoodButton.addEventListener('click', () => toggleGear('hood'), { signal: emoteEvents.signal });
|
|
const toggleFlashlight = () => {
|
|
if (!sheep || overview || networkBlocked || !solarState || solarState.altitude > FLASHLIGHT.nightAltitude) return;
|
|
flashlightOn = !flashlightOn;
|
|
sheep.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
|
refreshEmotes(); rendering.invalidate();
|
|
};
|
|
flashlightButton.addEventListener('click', toggleFlashlight, { signal: emoteEvents.signal });
|
|
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 ? '끄기' : '켜기') : '인사';
|
|
primaryActionIcon.setAttribute('href', dark ? '#icon-flashlight' : '#icon-wave');
|
|
const primaryActionName = dark ? `손전등 ${flashlightOn ? '끄기' : '켜기'}` : '손 흔들기';
|
|
waveButton.setAttribute('aria-label', primaryActionName);
|
|
waveButton.title = `${primaryActionName} · 단축키 1`;
|
|
prayButton.disabled = !sheep || overview;
|
|
prayButton.setAttribute('aria-disabled', String(prayButton.disabled || !prayerAvailable()));
|
|
prayButton.dataset.unavailableReason = prayerAvailable() ? '' : PRAYER_NOTICE;
|
|
waveButton.setAttribute('aria-pressed', String(dark ? flashlightOn : emotes.current === 'wave'));
|
|
prayButton.setAttribute('aria-pressed', String(emotes.current === 'pray'));
|
|
emoteStatus.textContent = overview ? '조감 보기에서는 감정표현을 사용할 수 없습니다'
|
|
: dark ? '1 손전등 켜기·끄기 / 2 소원빌기'
|
|
: emotes.current === 'wave' ? '손 흔드는 중 · 이동하면 종료'
|
|
: emotes.current === 'pray' ? '일출을 보며 소원 비는 중 · 이동하면 종료'
|
|
: prayerAvailable() ? '소원빌기 가능 · 1 인사 / 2 소원빌기 · 이동하면 종료'
|
|
: '소원빌기는 선택한 시각이 일출 전후 5분일 때 활성화됩니다';
|
|
};
|
|
const startEmote = (emote: Exclude<Emote, 'idle'>) => {
|
|
if (!sheep || overview || networkBlocked || !emotes.start(emote, prayerAvailable())) return;
|
|
controls.clear();
|
|
if (emote === 'pray' && solarState) {
|
|
camera.lookAt(camera.position.clone().add(new Vector3(...directionFromAngles(solarState.azimuth, 0))));
|
|
}
|
|
refreshEmotes();
|
|
rendering.invalidate();
|
|
};
|
|
const primaryAction = () => {
|
|
if (solarState && solarState.altitude <= FLASHLIGHT.nightAltitude) toggleFlashlight();
|
|
else startEmote('wave');
|
|
};
|
|
waveButton.addEventListener('click', primaryAction, { signal: emoteEvents.signal });
|
|
prayButton.addEventListener('click', () => startEmote('pray'), { signal: emoteEvents.signal });
|
|
window.addEventListener('keydown', event => {
|
|
if (!controls.active() || event.repeat || !['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6'].includes(event.code)) return;
|
|
event.preventDefault();
|
|
if (event.code === 'Digit6') { toggleFlashlight(); return; }
|
|
if (['Digit3', 'Digit4', 'Digit5'].includes(event.code)) {
|
|
toggleGear(event.code === 'Digit3' ? 'umbrella' : event.code === 'Digit4' ? 'raincoat' : 'hood'); return;
|
|
}
|
|
if (event.code === 'Digit1') primaryAction();
|
|
else startEmote('pray');
|
|
}, { signal: emoteEvents.signal });
|
|
let eyeHeight = observer.eyeHeight;
|
|
let walkedDistance = 0;
|
|
const placeObserver = () => {
|
|
const { x, z } = camera.position;
|
|
const sampled = harbour?.heightAt(x,z) ?? terrain.heightAt(x, z);
|
|
const ground = coast ? Math.max(sampled, seaHeight(x, z, terrainData.metadata.observerSurfaceElevation) + 0.05) : sampled;
|
|
camera.position.y = ground + eyeHeight;
|
|
const elevation = ground + terrainData.metadata.observerSurfaceElevation + (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
|
|
horizon.move(camera.position, elevation, eyeHeight);
|
|
sun.light.target.position.copy(camera.position);
|
|
sky.mesh.position.copy(camera.position);
|
|
const coordinates = walkingCoordinates(x, z, terrainData.metadata.origin);
|
|
onMove({ ...coordinates, elevation });
|
|
document.getElementById('movement-status')!.textContent = `시작점에서 ${Math.hypot(x-start.x, z-start.z).toFixed(0)}m · ${summitWalking ? `걸은 거리 ${walkedDistance.toFixed(0)}m · 반경 200m 길/정상 암반 내 이동 · 구역 경계 추정` : harbour ? '북쪽 방파제 진입부 · 임시 관측점' : coast ? '해안 육지 안에서 이동' : '지형 따라 이동 · 구조물 충돌 미적용'}`;
|
|
};
|
|
const resize = () => {
|
|
profile?.transition();
|
|
camera.aspect = innerWidth / innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
overviewCamera.aspect = camera.aspect;
|
|
overviewCamera.updateProjectionMatrix();
|
|
followCamera.aspect = camera.aspect;
|
|
followCamera.updateProjectionMatrix();
|
|
renderer.setPixelRatio(profile?.forcedDpr ?? Math.min(devicePixelRatio, SCENE.maxPixelRatio));
|
|
renderer.setSize(innerWidth, innerHeight);
|
|
composer?.setPixelRatio(renderer.getPixelRatio());
|
|
composer?.setSize(innerWidth, innerHeight);
|
|
rendering.invalidate();
|
|
};
|
|
window.addEventListener('resize', resize);
|
|
resize();
|
|
let previousTime = performance.now();
|
|
const forward = new Vector3();
|
|
const travel = new Vector3();
|
|
renderer.setAnimationLoop((now) => {
|
|
const cpuStart = profile?.active() ? performance.now() : 0;
|
|
const delta = (now - previousTime) / 1000;
|
|
previousTime = now;
|
|
const replayPosition = profile?.step(now);
|
|
let moving = false;
|
|
let turning = false;
|
|
const destination = controls.destination;
|
|
const movementRequested = !networkBlocked && !overview && destination !== null;
|
|
if (replayPosition || movementRequested) {
|
|
const next = replayPosition ?? walkStep(camera.position, destination!, delta, terrainData.metadata.radiusMeters - 1);
|
|
if(summitWalking && !summitWalking.canTraverse(camera.position,next)) {
|
|
profile?.rejected();
|
|
const alongX=next.clone();alongX.z=camera.position.z;
|
|
const alongZ=next.clone();alongZ.x=camera.position.x;
|
|
if(summitWalking.canTraverse(camera.position,alongX)) next.copy(alongX);
|
|
else if(summitWalking.canTraverse(camera.position,alongZ)) next.copy(alongZ);
|
|
else next.copy(camera.position);
|
|
}
|
|
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, travel, delta);
|
|
turning = alignment < .999;
|
|
next.copy(camera.position).addScaledVector(travel, alignment);
|
|
rendering.invalidate();
|
|
}
|
|
walkedDistance += Math.hypot(next.x-camera.position.x,next.z-camera.position.z);
|
|
moving = next.distanceToSquared(camera.position) > 1e-10;
|
|
camera.position.copy(next);
|
|
placeObserver();
|
|
}
|
|
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();
|
|
ocean?.animate(now / 1000);
|
|
let weatherChanged = false;
|
|
const blend = 1 - Math.exp(-Math.min(delta, 0.1) / WEATHER.transitionSeconds);
|
|
for (const key of Object.keys(atmosphere) as (keyof typeof atmosphere)[]) {
|
|
const difference = weatherTarget[key] - atmosphere[key];
|
|
if (difference === 0) continue;
|
|
atmosphere[key] = Math.abs(difference) < Math.max(1, Math.abs(weatherTarget[key])) * 0.0001
|
|
? weatherTarget[key] : atmosphere[key] + difference * blend;
|
|
weatherChanged = true;
|
|
}
|
|
if (weatherChanged) { applyAtmosphere(); rendering.invalidate(); }
|
|
const previousEmote = emotes.current;
|
|
emotes.update(delta, movementRequested, prayerAvailable());
|
|
if (previousEmote !== emotes.current) { refreshEmotes(); rendering.invalidate(); }
|
|
if (sheep) {
|
|
// Keep facing the sunrise throughout prayer, even if the user moves the mouse.
|
|
sheep.group.position.set(camera.position.x, camera.position.y - eyeHeight, camera.position.z);
|
|
if (emotes.current === 'pray' && solarState) {
|
|
forward.set(...directionFromAngles(solarState.azimuth, 0));
|
|
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
|
|
} else if (moving && replayPosition) {
|
|
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), cameraDistance);
|
|
}
|
|
const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera;
|
|
flashlight?.update(flashlightOn && !overview);
|
|
const precipitating = precipitation.update(delta, activeCamera.position, atmosphere.rain, atmosphere.snowfall,
|
|
MathUtils.smoothstep(solarState?.altitude ?? -12, -8, 15), canvas.height);
|
|
if(profile?.active()) rendering.invalidate();
|
|
if (!rendering.shouldRender(activeCamera, Boolean(ocean) || precipitating)) { profile?.observe(now, false); return; }
|
|
const updateEnd = cpuStart ? performance.now() : 0;
|
|
minimap.update(overview ? overviewCamera : camera);
|
|
compass.update(activeCamera);
|
|
const minimapEnd = cpuStart ? performance.now() : 0;
|
|
if (profile) renderer.info.reset();
|
|
if (bloom) {
|
|
const altitude = solarState?.altitude ?? 90;
|
|
const torchActive = flashlightOn && !overview;
|
|
bloom.enabled = altitude < BUILDINGS.lighting.fadeEndAltitude;
|
|
bloom.strength = BUILDINGS.lighting.bloomStrength * (1 - MathUtils.smoothstep(altitude,
|
|
BUILDINGS.lighting.fadeStartAltitude, BUILDINGS.lighting.fadeEndAltitude))
|
|
* (torchActive ? FLASHLIGHT.bloomStrengthScale : 1);
|
|
bloom.threshold = torchActive ? FLASHLIGHT.bloomThreshold : BUILDINGS.lighting.bloomThreshold;
|
|
bloom.radius = torchActive ? FLASHLIGHT.bloomRadius : BUILDINGS.lighting.bloomRadius;
|
|
}
|
|
if (composer && !profile?.disablePost) {
|
|
renderPass.camera = activeCamera;
|
|
composer.render(delta);
|
|
} else renderer.render(scene, activeCamera);
|
|
selfBadge?.update(activeCamera, sharedNow !== null && !overview);
|
|
if (cpuStart) profile?.end(now, { update: updateEnd - cpuStart, minimap: minimapEnd - updateEnd,
|
|
submit: performance.now() - minimapEnd });
|
|
if (profile?.observe(now, true)) resize();
|
|
});
|
|
const world = {
|
|
buildingCounts: buildings?.counts,
|
|
captureFrame(view?: PerspectiveCamera) {
|
|
const activeCamera = view ?? (overview ? overviewCamera : sheep ? followCamera : camera);
|
|
if (composer && !profile?.disablePost) {
|
|
renderPass.camera = activeCamera;
|
|
composer.render(0);
|
|
} else renderer.render(scene, activeCamera);
|
|
// Copy synchronously before WebGL clears the drawing buffer.
|
|
const frame = document.createElement('canvas');
|
|
frame.width = canvas.width;
|
|
frame.height = canvas.height;
|
|
frame.getContext('2d')!.drawImage(canvas, 0, 0);
|
|
return frame;
|
|
},
|
|
captureAngle(azimuth: number, pitch: number) {
|
|
// Orbit only the capture camera; keep the character facing the sunrise.
|
|
const view = camera.clone();
|
|
view.lookAt(view.position.clone().add(new Vector3(...directionFromAngles(azimuth, pitch))));
|
|
if (sheep) {
|
|
const orbit = view.clone();
|
|
updateThirdPersonCamera(orbit, view, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
|
|
return world.captureFrame(orbit);
|
|
}
|
|
return world.captureFrame(view);
|
|
},
|
|
readPlayer(): PlayerUpdate {
|
|
const yaw = sheep?.group.rotation.y ?? camera.rotation.y;
|
|
return { x: camera.position.x, z: camera.position.z, yaw: Math.atan2(Math.sin(yaw), Math.cos(yaw)),
|
|
moving: playerMoving, emote: emotes.current,
|
|
equipment: { umbrella: rainGear.umbrella, raincoat: rainGear.raincoat, hood: rainGear.hood, flashlight: flashlightOn } };
|
|
},
|
|
setConnection(blocked: boolean, clock: (() => number) | null) {
|
|
networkBlocked = blocked; sharedNow = clock;
|
|
if (blocked) { controls.clear(); closeCompanion(); playerMoving = false; emotes.cancel(); }
|
|
refreshEmotes();
|
|
rendering.invalidate();
|
|
},
|
|
applyPlayer(state: PlayerState) {
|
|
controls.clear(); emotes.cancel(); playerMoving = false;
|
|
camera.position.x = state.x; camera.position.z = state.z;
|
|
if (sheep) sheep.group.rotation.y = state.yaw;
|
|
rainGear.apply(state.equipment);
|
|
flashlightOn = Boolean((state.equipment.flashlight ?? flashlightOn) && solarState && solarState.altitude <= FLASHLIGHT.nightAltitude);
|
|
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
|
placeObserver(); refreshEmotes(); rendering.invalidate();
|
|
},
|
|
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(); closeCompanion(); if (controls.arrivalYaw !== undefined) controls.stop(); rendering.invalidate(); },
|
|
setWeather(weather: WeatherSample | null) {
|
|
weatherTarget = weatherAppearance(weather);
|
|
rainGear.updateWeather(weather?.rain ?? null, weather?.snowfall ?? null);
|
|
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
|
refreshGear();
|
|
// Do not blend an old precipitation type into the newly selected weather.
|
|
if (weatherTarget.rain === 0) atmosphere.rain = 0;
|
|
if (weatherTarget.snowfall === 0) atmosphere.snowfall = 0;
|
|
rendering.invalidate();
|
|
},
|
|
resetPosition() {
|
|
emotes.cancel();
|
|
closeCompanion();
|
|
walkedDistance = 0;
|
|
restoreView(); controls.clear(); camera.position.set(start.x, eyeHeight, start.z);
|
|
placeObserver();
|
|
camera.lookAt(harbour ? target : coast ? new Vector3(target.x, camera.position.y, target.z) : camera.position.clone().add(new Vector3(1,0,0)));
|
|
if (!coast) camera.lookAt(camera.position.clone().add(new Vector3(...directionFromAngles(SUMMIT_TERRAIN.heading, SUMMIT_TERRAIN.pitch))));
|
|
if (sheep) {
|
|
camera.getWorldDirection(forward);
|
|
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
|
|
}
|
|
refreshEmotes();
|
|
},
|
|
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();
|
|
viewNote.textContent = coast ? '해안 조감 · 실제 눈높이 아님 · 하늘 클릭 시 관측 복귀' : `건물 조감 미리보기 · 기준 지면보다 ${BUILDINGS.overviewHeight}m 위 · 실제 관측 눈높이 아님 · 하늘 클릭 시 관측 복귀`;
|
|
},
|
|
lookAtStructure(id: string) {
|
|
restoreView();
|
|
const target = id === SUMMIT_PLATEAU.marker.assetId ? summitMarker?.group : structures?.group.getObjectByName(id);
|
|
if (target) camera.lookAt(target.position.clone().add(new Vector3(0, id.includes('mast') ? 14 : id === 'weather-radar' ? 10 : 1, 0)));
|
|
},
|
|
lookEast() { restoreView(); camera.lookAt(camera.position.clone().add(new Vector3(1, 0, 0))); },
|
|
lookAtSunrise(azimuth: number) {
|
|
restoreView();
|
|
forward.set(...directionFromAngles(azimuth, 0));
|
|
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), 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), cameraDistance);
|
|
},
|
|
setEyeHeight(meters: number) {
|
|
eyeHeight = meters;
|
|
placeObserver();
|
|
},
|
|
update(state: SunState) {
|
|
const nextFlashlightOn = flashlightAfterSunUpdate(flashlightOn, solarState?.altitude, state.altitude);
|
|
solarState = state;
|
|
if (flashlightOn !== nextFlashlightOn) {
|
|
flashlightOn = nextFlashlightOn;
|
|
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
|
}
|
|
emotes.update(0, false, prayerAvailable());
|
|
refreshEmotes();
|
|
// LIVE calls this every second; never skip a new solar/time state.
|
|
rendering.invalidate();
|
|
const direction = new Vector3(...directionFromAngles(state.azimuth, state.altitude));
|
|
applyAtmosphere();
|
|
ocean?.update(direction, state.altitude);
|
|
buildings?.update(state.altitude);
|
|
horizon.update(state.altitude);
|
|
fog.color.copy(nightFog).lerp(dawnFog, MathUtils.smoothstep(state.altitude, -8, 0));
|
|
fog.color.lerp(dayFog, MathUtils.smoothstep(state.altitude, 0, 15));
|
|
renderer.toneMappingExposure = MathUtils.lerp(0.12, 0.65, MathUtils.smoothstep(state.altitude, -12, 5));
|
|
},
|
|
dispose() {
|
|
disposed = true;
|
|
profile?.dispose();
|
|
renderer.setAnimationLoop(null);
|
|
minimap.dispose();
|
|
compass.dispose();
|
|
selfBadge?.dispose();
|
|
flashlight?.dispose();
|
|
bloom?.dispose();
|
|
outputPass?.dispose();
|
|
renderPass.dispose();
|
|
composer?.dispose();
|
|
controls.dispose();
|
|
emoteEvents.abort();
|
|
remotePlayers?.clear();
|
|
sheep?.dispose();
|
|
precipitation.dispose();
|
|
nightSky.dispose();
|
|
terrain.dispose();
|
|
summitRelief?.dispose();
|
|
summitMarker?.dispose();
|
|
summitTrails?.dispose();
|
|
structures?.dispose();
|
|
buildings?.dispose();
|
|
ocean?.dispose();
|
|
harbour?.dispose();
|
|
canvas.removeEventListener('pointerdown', restoreView);
|
|
window.removeEventListener('resize', resize);
|
|
renderer.dispose();
|
|
},
|
|
};
|
|
if (import.meta.env.DEV && profiling) void import('../dev/renderProfile.ts').then(({ createRenderProfile }) => {
|
|
if (disposed) return;
|
|
profile = createRenderProfile(renderer, scene, camera, () => world.resetPosition(), target, () =>
|
|
composer && !profile?.disablePost ? composer.passes.filter(pass => pass.enabled).map(pass => pass.constructor.name) : ['WebGLRenderer'], resize);
|
|
renderer.info.autoReset = false;
|
|
resize();
|
|
});
|
|
return world;
|
|
}
|