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 { 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'; export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited>, buildingData: BuildingAssets | null, onMove: (location: { latitude: number; longitude: number; elevation: number }) => void, coast?: LoadedCoast, structureAssets?: StructureAssets, reconstructSummit = false, summitGeometry?: BufferGeometry, sheep?: Awaited>) { 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; 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 | 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(); const controls = createFpsCameraController(camera, canvas, (x, y) => { 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)); 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; }); if (sheep) scene.add(sheep.group); const emotes = createEmoteState(); const emoteEvents = new AbortController(); const waveButton = document.querySelector('#emote-wave')!; const prayButton = document.querySelector('#emote-pray')!; const emoteStatus = document.getElementById('emote-status')!; const rainGear = createRainGearState(); const umbrellaButton = document.querySelector('#gear-umbrella')!; const raincoatButton = document.querySelector('#gear-raincoat')!; const hoodButton = document.querySelector('#gear-hood')!; const gearStatus = document.getElementById('gear-status')!; const refreshGear = () => { 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); 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 prayerAvailable = () => Boolean(solarState && canPray(solarState.date, solarState.sunrise)); const refreshEmotes = () => { refreshGear(); waveButton.disabled = !sheep || overview; prayButton.disabled = !sheep || overview; prayButton.setAttribute('aria-disabled', String(prayButton.disabled || !prayerAvailable())); prayButton.dataset.unavailableReason = prayerAvailable() ? '' : PRAYER_NOTICE; waveButton.setAttribute('aria-pressed', String(emotes.current === 'wave')); prayButton.setAttribute('aria-pressed', String(emotes.current === 'pray')); emoteStatus.textContent = overview ? '조감 보기에서는 감정표현을 사용할 수 없습니다' : emotes.current === 'wave' ? '손 흔드는 중 · 이동하면 종료' : emotes.current === 'pray' ? '일출을 보며 소원 비는 중 · 이동하면 종료' : prayerAvailable() ? '소원빌기 가능 · 1 인사 / 2 소원빌기 · 이동하면 종료' : '소원빌기는 선택한 시각이 일출 전후 5분일 때 활성화됩니다'; }; const startEmote = (emote: Exclude) => { 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(); }; waveButton.addEventListener('click', () => startEmote('wave'), { 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'].includes(event.code)) return; event.preventDefault(); if (['Digit3', 'Digit4', 'Digit5'].includes(event.code)) { toggleGear(event.code === 'Digit3' ? 'umbrella' : event.code === 'Digit4' ? 'raincoat' : 'hood'); return; } startEmote(event.code === 'Digit1' ? 'wave' : '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, camera, travel, delta, !controls.looking); 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 && ((!moving && !turning) || Math.hypot(destination.x-camera.position.x, destination.z-camera.position.z) <= MOVEMENT.arrivalDistance)) 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, camera, travel, delta, !controls.looking); } 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)); } const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera; 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 (composer && !profile?.disablePost) { renderPass.camera = activeCamera; composer.render(delta); } else renderer.render(scene, activeCamera); 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, 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 } }; }, setConnection(blocked: boolean, clock: (() => number) | null) { networkBlocked = blocked; sharedNow = clock; if (blocked) { controls.clear(); playerMoving = false; emotes.cancel(); } 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); sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood); placeObserver(); refreshEmotes(); rendering.invalidate(); }, synchronizePlayers(players: PlayerState[], selfId: string) { remotePlayers?.synchronize(players, selfId); rendering.invalidate(); }, clearPlayers() { remotePlayers?.clear(); rendering.invalidate(); }, setWeather(weather: WeatherSample | null) { weatherTarget = weatherAppearance(weather); rainGear.updateWeather(weatherTarget.rain, weatherTarget.snowfall); sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood); 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(); 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(); 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))); }, setEyeHeight(meters: number) { eyeHeight = meters; placeObserver(); }, update(state: SunState) { solarState = state; 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); if (bloom) { bloom.enabled = state.altitude < BUILDINGS.lighting.fadeEndAltitude; bloom.strength = BUILDINGS.lighting.bloomStrength * (1 - MathUtils.smoothstep(state.altitude, BUILDINGS.lighting.fadeStartAltitude, BUILDINGS.lighting.fadeEndAltitude)); } 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(); 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; }