Files
sunrise/src/scene/world.ts
T

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;
}