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>, 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; 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 | 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('#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('#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('#emote-wave')!; const primaryActionLabel = document.getElementById('primary-action-label')!; const primaryActionIcon = waveButton.querySelector('use')!; 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 flashlightButton = document.querySelector('#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) => { 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; }