feat: initialize sunrise with 100-player shared rooms
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
/** +X east, +Y up, -Z north. All input angles are degrees. */
|
||||
export function directionFromAngles(azimuth: number, altitude: number): [number, number, number] {
|
||||
const az = azimuth * Math.PI / 180;
|
||||
const alt = altitude * Math.PI / 180;
|
||||
return [Math.cos(alt) * Math.sin(az), Math.sin(alt), -Math.cos(alt) * Math.cos(az)];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getPosition, getTimes } from 'suncalc';
|
||||
import type { ObserverConfig } from '../config/observer.ts';
|
||||
import { fromKstInput, kstFields } from '../ui/time.ts';
|
||||
|
||||
export interface SunState {
|
||||
date: Date;
|
||||
azimuth: number;
|
||||
altitude: number;
|
||||
sunrise: Date | null;
|
||||
sunset: Date | null;
|
||||
standardSunrise: Date | null;
|
||||
standardSunset: Date | null;
|
||||
}
|
||||
|
||||
export function calculateSun(date: Date, observer: ObserverConfig): SunState {
|
||||
// SunCalc 2.x already returns degrees, north-clockwise azimuth and apparent altitude.
|
||||
const position = getPosition(date, observer.latitude, observer.longitude);
|
||||
// getTimes uses a solar day; local noon anchors the selected KST calendar date.
|
||||
const noon = fromKstInput(kstFields(date).date, 720);
|
||||
const times = getTimes(noon, observer.latitude, observer.longitude, observer.elevation + observer.eyeHeight);
|
||||
const standard = getTimes(noon, observer.latitude, observer.longitude, 0);
|
||||
return {
|
||||
date, azimuth: position.azimuth, altitude: position.altitude,
|
||||
sunrise: times.sunrise, sunset: times.sunset,
|
||||
standardSunrise: standard.sunrise, standardSunset: standard.sunset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface ObserverConfig {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
elevation: number;
|
||||
eyeHeight: number;
|
||||
}
|
||||
|
||||
// 원본 지형·건물의 공통 원점. 카메라 시작점은 constants/summitTerrain.ts의 추정 촬영점.
|
||||
// elevation은 1단계 임시 기준값. 지형 앱은 main.ts에서 DSM 표면고도로 대체한다.
|
||||
export const observer: ObserverConfig = {
|
||||
latitude: 37.445,
|
||||
longitude: 126.964,
|
||||
elevation: 629,
|
||||
eyeHeight: 1.7,
|
||||
};
|
||||
export const observerName = '관악산 정상';
|
||||
export const observerIsProvisional = true;
|
||||
@@ -0,0 +1,19 @@
|
||||
export const BUILDINGS = {
|
||||
url: '/buildings/gwacheon.geojson',
|
||||
assetManifestUrl: '/buildings/gwacheon.baked.json',
|
||||
assetBufferUrl: '/buildings/gwacheon.baked.bin',
|
||||
assetVersion: 1,
|
||||
metersPerLevel: 3,
|
||||
// Visual placeholders only: not a claim about actual floors.
|
||||
fallbackHeight: { apartments: 15, residential: 9, house: 6, commercial: 12, greenhouse: 3, roof: 3, school: 12, default: 9 } as Record<string, number>,
|
||||
baseSampleRadius: 45,
|
||||
overviewHeight: 180,
|
||||
target: { latitude: 37.429, longitude: 126.991 },
|
||||
colors: { tagged: '#c5c7bf', levels: '#b4bdc1', fallback: '#a99b85' },
|
||||
// Illustrative exterior lighting, not surveyed fixtures or light transport.
|
||||
lighting: {
|
||||
fadeStartAltitude: -6, fadeEndAltitude: 5, facadeFadeHeight: 24,
|
||||
spillMapSize: 1024, spillDistance: 14, spillSpacing: 18, spillIntensity: 0.9,
|
||||
bloomStrength: 0.22, bloomRadius: 0.15, bloomThreshold: 1.2,
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,19 @@
|
||||
export const CHARACTER = {
|
||||
url: '/characters/red-sheep.glb?v=rounded-v10',
|
||||
scale: 0.62,
|
||||
outline: 0.018,
|
||||
ink: '#352724',
|
||||
lighting: {
|
||||
nightBrightness: 0.12,
|
||||
minimumBrightness: 0.08,
|
||||
nightAltitude: -8,
|
||||
dayAltitude: 15,
|
||||
},
|
||||
waveSeconds: 3,
|
||||
prayerWindowMs: 5 * 60 * 1000,
|
||||
cameraDistance: 5,
|
||||
cameraLift: 1.15,
|
||||
cameraClearance: 0.35,
|
||||
cameraTurnRate: 1.8,
|
||||
turnSpeed: 4,
|
||||
} as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
export const COAST = {
|
||||
// OSM node/12019797394: Hajodae Pavilion.
|
||||
latitude: 38.020813,
|
||||
longitude: 128.7352564,
|
||||
radiusMeters: 1500,
|
||||
spacingMeters: 10,
|
||||
maskSpacingMeters: 2,
|
||||
distanceRangeMeters: 32,
|
||||
oceanRadiusMeters: 30000,
|
||||
oceanSegments: 240,
|
||||
overviewHeight: 900,
|
||||
cape: { latitude: 38.021987, longitude: 128.7355911 },
|
||||
// Protected-tree reference coordinates; approximate viewing bearing only.
|
||||
pine: { latitude: 38 + 1.252 / 60, longitude: 128 + 44.134 / 60 },
|
||||
} as const;
|
||||
@@ -0,0 +1,5 @@
|
||||
export const COMPASS = {
|
||||
tickDegrees: 5,
|
||||
visibleDegrees: 150,
|
||||
labels: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'],
|
||||
} as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
export const GISAMUN = {
|
||||
latitude: 38.00555,
|
||||
longitude: 128.73195,
|
||||
// OSM coastline nodes spanning the beach south of Gisamun harbour.
|
||||
// Sand extent is an illustrative 35m inland strip, not surveyed geometry.
|
||||
sandLatitudeMin: 38.0043299,
|
||||
sandLatitudeMax: 38.0064958,
|
||||
sandWidthMeters: 35,
|
||||
harbour: { latitude: 38.0085, longitude: 128.7328 },
|
||||
// Local metres relative to the unchanged terrain-data origin. Provisional
|
||||
// north breakwater entrance, not a surveyed visitor/camera location.
|
||||
viewpoint: { x: -30, z: -350 },
|
||||
// Centre of the mapped offshore island's bounding box (Jodo).
|
||||
jodo: { x: 970.095, z: -171.101 },
|
||||
} as const;
|
||||
@@ -0,0 +1,78 @@
|
||||
// Footprint follows OSM way/583161999 in the published Gisamun local coordinates.
|
||||
// Inland closure, top elevation, wall height and shelter strength are illustrative.
|
||||
export const GISAMUN_HARBOUR = {
|
||||
topElevation: 3,
|
||||
bottomElevation: -2,
|
||||
shelterWaveScale: .25,
|
||||
entranceBlendMeters: 18,
|
||||
parapetHeight: .7,
|
||||
parapetWidth: .65,
|
||||
topColor: '#a2a198',
|
||||
wallColor: '#737a78',
|
||||
land: [
|
||||
[-28.051, -155.739],
|
||||
[-4.057, -150.511],
|
||||
[13.586, -144.984],
|
||||
[25.908, -151.71],
|
||||
[47.118, -140.822],
|
||||
[58.307, -147.348],
|
||||
[36.956, -163.498],
|
||||
[25.943, -167.849],
|
||||
[-12.129, -159.935],
|
||||
[-31.678, -173.088],
|
||||
[-37.272, -178.882],
|
||||
[-114.628, -175.93],
|
||||
[-129.038, -274.496],
|
||||
[-122.205, -279.746],
|
||||
[-121.933, -316.042],
|
||||
[-115.995, -349.851],
|
||||
[-100.196, -362.937],
|
||||
[-42.848, -363.359],
|
||||
[-35.024, -332.835],
|
||||
[-22.272, -302.31],
|
||||
[-15.94, -303.764],
|
||||
[-3.047, -271.731],
|
||||
[16.019, -247.7],
|
||||
[95.386, -163.543],
|
||||
[90.485, -155.029],
|
||||
[98.916, -150.723],
|
||||
[111.818, -152.21],
|
||||
[118.158, -165.841],
|
||||
[56.804, -238.465],
|
||||
[21.244, -279.578],
|
||||
[-4.857, -330.848],
|
||||
[-17.424, -401.508],
|
||||
[-24.81, -423.552],
|
||||
[-180, -423.552],
|
||||
[-180, -155.739],
|
||||
],
|
||||
basin: [
|
||||
[58.307, -147.348],
|
||||
[36.956, -163.498],
|
||||
[25.943, -167.849],
|
||||
[-12.129, -159.935],
|
||||
[-31.678, -173.088],
|
||||
[-37.272, -178.882],
|
||||
[-114.628, -175.93],
|
||||
[-129.038, -274.496],
|
||||
[-122.205, -279.746],
|
||||
[-121.933, -316.042],
|
||||
[-115.995, -349.851],
|
||||
[-100.196, -362.937],
|
||||
[-42.848, -363.359],
|
||||
[-35.024, -332.835],
|
||||
[-22.272, -302.31],
|
||||
[-15.94, -303.764],
|
||||
[-3.047, -271.731],
|
||||
[16.019, -247.7],
|
||||
[95.386, -163.543],
|
||||
],
|
||||
parapet: [
|
||||
[118.158, -165.841],
|
||||
[56.804, -238.465],
|
||||
[21.244, -279.578],
|
||||
[-4.857, -330.848],
|
||||
[-17.424, -401.508],
|
||||
[-24.81, -423.552],
|
||||
],
|
||||
} as const;
|
||||
@@ -0,0 +1,6 @@
|
||||
export const IMPOSTOR_EXPERIMENT = {
|
||||
width: 1280, height: 720, textureScale: 2, sideOffset: 300,
|
||||
minLongitude: 126.99, maxLongitude: 127.00,
|
||||
minLatitude: 37.425, maxLatitude: 37.435,
|
||||
warmupFrames: 30, sampleFrames: 90, rounds: 3,
|
||||
} as const;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { LOCATION_ENABLED } from './locations.ts';
|
||||
|
||||
export const APP_NAME = '해보러가기';
|
||||
export const LOBBY_CHARACTER = {
|
||||
maxSize: 360,
|
||||
widthFraction: 0.65,
|
||||
heightFraction: 0.55,
|
||||
marginFraction: 0,
|
||||
heading: 160,
|
||||
frameBottom: 0.75,
|
||||
frameTop: 1.5,
|
||||
minimumLightingAltitude: -3,
|
||||
} as const;
|
||||
export const LOBBY_SUNRISE = {
|
||||
durationMs: 4500,
|
||||
nightHoldMs: 300,
|
||||
from: Date.parse('2026-11-06T05:40:00+09:00'),
|
||||
to: Date.parse('2026-11-06T07:45:00+09:00'),
|
||||
heading: 110,
|
||||
pitch: 12,
|
||||
fieldOfView: 40,
|
||||
maxPixelRatio: 1.5,
|
||||
maxExposure: 0.32,
|
||||
} as const;
|
||||
export const LOBBY_SERVERS = [
|
||||
{ id: 'gwanaksan', name: '관악산 가기', description: '능선 위에서 함께 맞이하는 아침', enabled: LOCATION_ENABLED.gwanaksan },
|
||||
{ id: 'hajodae', name: '하조대 가기', description: '바다와 소나무 사이로 떠오르는 해', enabled: LOCATION_ENABLED.hajodae },
|
||||
{ id: 'gisamun', name: '기사문 해변 가기', description: '잔잔한 해변에서 기다리는 첫 햇살', enabled: LOCATION_ENABLED.gisamun },
|
||||
] as const;
|
||||
export const LOBBY = { endpoint: '/api/lobby', timeoutMs: 10000, channelName: '채널 1' } as const;
|
||||
|
||||
export interface LobbyData {
|
||||
servers: typeof LOBBY_SERVERS;
|
||||
channel: { id: string; name: string; capacity: number; players: number; available: boolean };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { observer, observerName } from '../config/observer.ts';
|
||||
import { COAST } from './coast.ts';
|
||||
import { TERRAIN } from './terrain.ts';
|
||||
import { GISAMUN } from './gisamun.ts';
|
||||
|
||||
export const LOCATIONS = {
|
||||
gwanaksan: {
|
||||
name: observerName, label: 'GWANAKSAN', observer,
|
||||
terrain: { metadataUrl: TERRAIN.metadataUrl, heightsUrl: TERRAIN.heightsUrl },
|
||||
coast: null,
|
||||
},
|
||||
hajodae: {
|
||||
name: '하조대 정자', label: 'HAJODAE',
|
||||
observer: { latitude: COAST.latitude, longitude: COAST.longitude, elevation: 0, eyeHeight: 1.7 },
|
||||
terrain: { metadataUrl: '/terrain/hajodae/metadata.json', heightsUrl: '/terrain/hajodae/heights.f32' },
|
||||
coast: { directory: '/terrain/hajodae', lookTarget: COAST.pine, lookLabel: '소나무 방향' },
|
||||
},
|
||||
gisamun: {
|
||||
name: '기사문 해변', label: 'GISAMUN',
|
||||
observer: { latitude: GISAMUN.latitude, longitude: GISAMUN.longitude, elevation: 0, eyeHeight: 1.7 },
|
||||
terrain: { metadataUrl: '/terrain/gisamun/metadata.json', heightsUrl: '/terrain/gisamun/heights.f32' },
|
||||
coast: { directory: '/terrain/gisamun', lookTarget: GISAMUN.harbour, lookLabel: '기사문항 방향' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_LOCATION: keyof typeof LOCATIONS = 'gwanaksan';
|
||||
export const LOCATION_ENABLED: Record<keyof typeof LOCATIONS, boolean> = {
|
||||
gwanaksan: true,
|
||||
hajodae: false,
|
||||
gisamun: false,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
export const MINIMAP = {
|
||||
size: 256,
|
||||
padding: 12,
|
||||
contourInterval: 50,
|
||||
majorInterval: 100,
|
||||
minZoom: 1,
|
||||
maxZoom: 16,
|
||||
zoomStep: 2,
|
||||
background: '#17272b',
|
||||
contourColor: '#80998b',
|
||||
majorColor: '#bed0ad',
|
||||
markerColor: '#ffd29a',
|
||||
} as const;
|
||||
@@ -0,0 +1 @@
|
||||
export const MOVEMENT = { speed: 4, maxDelta: 0.05, dragSensitivity: 0.003, dragThreshold: 6, arrivalDistance: 0.01 } as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
export const MULTIPLAYER = {
|
||||
locationId: 'gwanaksan',
|
||||
roomId: 'gwanaksan-001',
|
||||
capacity: 100,
|
||||
tickMs: 100,
|
||||
snapshotMs: 5000,
|
||||
clockSyncMs: 30000,
|
||||
visitHeartbeatMs: 60000,
|
||||
interpolationMs: 150,
|
||||
playerHeartbeatMs: 1000,
|
||||
staleMovementMs: 1500,
|
||||
maxPacketBytes: 4096,
|
||||
ackTimeoutMs: 10000,
|
||||
maxNicknameLength: 20,
|
||||
} as const;
|
||||
@@ -0,0 +1,13 @@
|
||||
export const NIGHT_SKY = {
|
||||
starCount: 1800,
|
||||
seed: 73021,
|
||||
distance: 60000,
|
||||
moonRadiusKm: 1737.4,
|
||||
fadeStartAltitude: -12,
|
||||
fadeEndAltitude: -4,
|
||||
backgroundRadiance: [0.08, 0.13, 0.22],
|
||||
starRadiance: 8,
|
||||
moonRadiance: 7,
|
||||
moonFadeStartAltitude: -4,
|
||||
moonFadeEndAltitude: 5,
|
||||
} as const;
|
||||
@@ -0,0 +1,5 @@
|
||||
export const EYE_HEIGHT = {
|
||||
minCentimeters: 160,
|
||||
maxCentimeters: 195,
|
||||
stepCentimeters: 1,
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Visual density limits, not meteorological severity thresholds. */
|
||||
export const PRECIPITATION = {
|
||||
radius: 22,
|
||||
height: 30,
|
||||
belowCamera: 10,
|
||||
maxDelta: 0.05,
|
||||
rain: { maxParticles: 5000, fullRate: 30, speed: 14, streak: 0.65, opacity: 0.28 },
|
||||
snow: { maxParticles: 2200, fullRate: 4, speed: 1.2, size: 0.09, opacity: 0.85, drift: 0.35 },
|
||||
} as const;
|
||||
@@ -0,0 +1,4 @@
|
||||
export const RAIN_GEAR = {
|
||||
umbrellaUrl: '/characters/umbrella.glb?v=1',
|
||||
raincoatUrl: '/characters/raincoat.glb?v=5',
|
||||
} as const;
|
||||
@@ -0,0 +1,12 @@
|
||||
export const RENDER_QUALITY = {
|
||||
mobileCap: 1.5,
|
||||
mobilePixelBudget: 1_500_000,
|
||||
levels: [0.75, 1, 1.25, 1.5, 1.75, 2] as readonly number[],
|
||||
settleMs: 10_000,
|
||||
windowMs: 5_000,
|
||||
minimumSamples: 30,
|
||||
lowerP95Ms: 34,
|
||||
raiseP95Ms: 22,
|
||||
raiseMeanMs: 19,
|
||||
healthyWindows: 4,
|
||||
} as const;
|
||||
@@ -0,0 +1,20 @@
|
||||
export const SCENE = {
|
||||
fieldOfView: 65,
|
||||
near: 0.1,
|
||||
far: 100000,
|
||||
skyRadius: 45000,
|
||||
sunDistance: 60000,
|
||||
sunDiameterDegrees: 0.53,
|
||||
haloDiameterDegrees: 7,
|
||||
haloOpacity: 0.32,
|
||||
sunRadianceAtHorizon: 6,
|
||||
sunRadianceAtDay: 40,
|
||||
sunRadianceDayAltitude: 15,
|
||||
mieCoefficient: 0.005,
|
||||
mieDirectionalG: 0.8,
|
||||
horizonRadius: 40000,
|
||||
horizonBlendDegrees: 2,
|
||||
markerRadius: 3000,
|
||||
pitchLimitDegrees: 85,
|
||||
maxPixelRatio: 2,
|
||||
} as const;
|
||||
@@ -0,0 +1,24 @@
|
||||
export const STRUCTURE_ASSETS = {
|
||||
baseUrl: '/models/summit',
|
||||
foundationColor: '#bbb9ac',
|
||||
supportColor: '#454b42',
|
||||
access: { deckIds: ['north-deck'] as readonly string[], treadDepth: .3, maxRiser: .18, maxSteps: 32, landingLength: .6, groundOverlap: .1 },
|
||||
} as const;
|
||||
|
||||
/** Preliminary dimensions in meters; NOT surveyed heights. Sources: data/references/summit. */
|
||||
export const STRUCTURES = {
|
||||
towers: [
|
||||
{ id: 'summit-mast', latitude: 37.4450675, longitude: 126.9638043, height: 24, width: 2.4, source: 'OSM node/3445797250', estimatedPosition: false },
|
||||
// OSM transmitter points identify facilities, not the centers of individual towers.
|
||||
{ id: 'south-tower-1', latitude: 37.4401643, longitude: 126.9642555, height: 48, width: 5, source: 'OSM node/4262612712 + recent-02.jpg', estimatedPosition: true },
|
||||
{ id: 'south-tower-2', latitude: 37.4400359, longitude: 126.9640016, height: 36, width: 3.2, source: 'OSM node/4736107159 + recent-02.jpg', estimatedPosition: true },
|
||||
{ id: 'south-tower-3', latitude: 37.44012, longitude: 126.96448, height: 40, width: 3, source: 'recent-02.jpg; approximate cluster offset', estimatedPosition: true },
|
||||
{ id: 'south-tower-4', latitude: 37.44003, longitude: 126.96472, height: 34, width: 2.4, source: 'recent-02.jpg; approximate cluster offset', estimatedPosition: true },
|
||||
],
|
||||
radar: { latitude: 37.444028, longitude: 126.963808, radius: 4, buildingHeight: 7, source: 'OSM way/337498781 (identity inferred), photo-01/09/10' },
|
||||
decks: [
|
||||
{ id: 'north-deck', enabled: false, latitude: 37.44516, longitude: 126.96405, width: 3, length: 7, heading: -20, stairLength: 7, stairDrop: 3, source: 'deck-19/20/22; position and dimensions estimated' },
|
||||
].filter(deck => deck.enabled),
|
||||
railing: { height: 1.15, postWidth: 0.1, maxSpan: 1.5 },
|
||||
deck: { thickness: 0.18, clearance: 0.25, plankWidth: 0.18 },
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
export const SUMMIT_ASSETS = {
|
||||
manifestUrl: '/terrain/summit-mesh.json',
|
||||
bufferUrl: '/terrain/summit-mesh.bin',
|
||||
version: 1,
|
||||
magic: 0x53554d54,
|
||||
interiorMaxEdge: 5.6,
|
||||
detailedStartRadius: 20,
|
||||
attributes: [['position',3],['normal',3],['uv',2],['color',3],['trailDistance',1],['rockDistance',1]] as const,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Illustrative CC0 surface scans; not photographs or measured materials of this site. */
|
||||
export const SUMMIT_MATERIALS = {
|
||||
baseUrl: '/terrain/materials',
|
||||
anisotropy: 8,
|
||||
edgeBlend: { innerMeters: -0.5, outerMeters: 0.4, irregularityMeters: 0.16 },
|
||||
rockBlend: { innerMeters: -0.7, outerMeters: 0.5, irregularityMeters: 0.25 },
|
||||
path: { tileMeters: 2, normalStrength: 0.65, tint: '#ffffff' },
|
||||
rock: { tileMeters: 1.8, normalStrength: 0.55, tint: '#ffffff', secondaryScale: 1.37, secondaryOffset: [0.31, 0.73], secondaryBlend: 0.5, secondaryVariation: 0.3 },
|
||||
forest: { tileMeters: 2, normalStrength: 0.75, tint: '#b8c6a3', farColor: '#697052', fadeNear: 15, fadeFar: 65 },
|
||||
} as const;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
/** User reference photos, 2026-10-30. Visual estimates, not surveyed rock geometry.
|
||||
* Offsets in metres east/south of the radar; tops relative to its terrain contact.
|
||||
* The adjacent mast in the first photo is not identified with the mapped north mast.
|
||||
*/
|
||||
export const SUMMIT_RELIEF = {
|
||||
rockTint: '#ffffff',
|
||||
assetId: 'radar-rocks',
|
||||
access: { bottom: [-10, -28], top: [-8, -4], landingEnd: [-3.5, -4], width: 1.25, treadDepth: .28, railHeight: 1.05, railRadius: .035, postEvery: 5, color: '#85999c' },
|
||||
} as const;
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Photo/contour interpretation, NOT surveyed GPS or elevations. See /terrain/summit-reconstruction.html. */
|
||||
export const SUMMIT_TERRAIN = {
|
||||
start: { latitude: 37.44472, longitude: 126.96422 },
|
||||
coreRadius: 100,
|
||||
outerRadius: 200,
|
||||
// Looking south leaves the radar to the right, the eastern valley to the left.
|
||||
heading: 190,
|
||||
pitch: -3,
|
||||
controls: [
|
||||
{ label: '시작 위치 암반', latitude: 37.44472, longitude: 126.96422, elevation: 620 },
|
||||
{ label: '북쪽 정상 암반', latitude: 37.44503, longitude: 126.96394, elevation: 629 },
|
||||
{ label: '정상 표지석 일대 암반', latitude: 37.4451398, longitude: 126.9642379, elevation: 629 },
|
||||
{ label: '정상에서 내려오는 넓은 석판', latitude: 37.44494, longitude: 126.96420, elevation: 626 },
|
||||
{ label: '중간 안부', latitude: 37.44440, longitude: 126.96402, elevation: 606 },
|
||||
{ label: '레이돔 암봉', latitude: 37.444028, longitude: 126.963808, elevation: 628 },
|
||||
// User photos: narrow radar crown, steep exposed faces, separate western rock fin.
|
||||
{ label: '레이돔 북쪽 받침', latitude: 37.444080, longitude: 126.963808, elevation: 628 },
|
||||
{ label: '레이돔 남쪽 받침', latitude: 37.443975, longitude: 126.963808, elevation: 627 },
|
||||
{ label: '레이돔 동쪽 받침', latitude: 37.444028, longitude: 126.963875, elevation: 627 },
|
||||
{ label: '레이돔 서쪽 받침', latitude: 37.444028, longitude: 126.963740, elevation: 627 },
|
||||
{ label: '북동 절벽 하단', latitude: 37.444145, longitude: 126.963945, elevation: 603 },
|
||||
{ label: '북쪽 절벽 하단', latitude: 37.444185, longitude: 126.963805, elevation: 608 },
|
||||
{ label: '서쪽 첨봉 받침', latitude: 37.444105, longitude: 126.963580, elevation: 617 },
|
||||
{ label: '첨봉 북쪽 암릉', latitude: 37.444240, longitude: 126.963570, elevation: 608 },
|
||||
{ label: '첨봉 아래 안부', latitude: 37.444380, longitude: 126.963680, elevation: 603 },
|
||||
{ label: '암봉 북사면', latitude: 37.44425, longitude: 126.96390, elevation: 610 },
|
||||
{ label: '암봉 동사면', latitude: 37.44403, longitude: 126.96408, elevation: 605 },
|
||||
{ label: '암봉 서사면', latitude: 37.44403, longitude: 126.96353, elevation: 604 },
|
||||
{ label: '암봉 남사면', latitude: 37.44382, longitude: 126.96382, elevation: 610 },
|
||||
{ label: '동쪽 골짜기', latitude: 37.44435, longitude: 126.96465, elevation: 582 },
|
||||
{ label: '서쪽 사면', latitude: 37.44435, longitude: 126.96335, elevation: 582 },
|
||||
],
|
||||
} as const;
|
||||
@@ -0,0 +1,343 @@
|
||||
/** OSM centerlines; widths and surface boundaries estimated from Naver Oct 2013 panoramas.
|
||||
* No public access inferred for the radar facility spur (way/337498785).
|
||||
*/
|
||||
import { SUMMIT_PLATEAU } from './summitPlateau.ts';
|
||||
export const SUMMIT_TRAILS = {
|
||||
radius: 200, surfaceStep: 0.7, surfaceLift: 0.06, sweepStep: 0.2,
|
||||
mapZoom: 64, mapMaxZoom: 128,
|
||||
colors: { path: '#b5a080', rock: '#a7a397' },
|
||||
// Photo-estimated slab from the lower forecourt to the OSM summit point ~47m north.
|
||||
// Shared by walking, rock material and minimap; excludes the western mast and eastern drop.
|
||||
rockPolygon: SUMMIT_PLATEAU.boundary,
|
||||
rockOutcrops: [
|
||||
{ latitude: 37.444028, longitude: 126.963808, radius: 14 },
|
||||
{ latitude: 37.444105, longitude: 126.963580, radius: 11 },
|
||||
{ latitude: 37.444240, longitude: 126.963570, radius: 9 },
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
"id": "way/337498789",
|
||||
"label": "정상 암반길",
|
||||
"width": 3.2,
|
||||
"points": [
|
||||
[
|
||||
126.9642427,
|
||||
37.4452121
|
||||
],
|
||||
[
|
||||
126.9642346,
|
||||
37.4450861
|
||||
],
|
||||
[
|
||||
126.9641628,
|
||||
37.4449609
|
||||
],
|
||||
[
|
||||
126.9641194,
|
||||
37.4449084
|
||||
],
|
||||
[
|
||||
126.9641086,
|
||||
37.4448542
|
||||
],
|
||||
[
|
||||
126.9641235,
|
||||
37.4447614
|
||||
],
|
||||
[
|
||||
126.9641299,
|
||||
37.4446834
|
||||
],
|
||||
[
|
||||
126.9641627,
|
||||
37.4446443
|
||||
],
|
||||
[
|
||||
126.9641937,
|
||||
37.4446176
|
||||
],
|
||||
[
|
||||
126.9641853,
|
||||
37.4445946
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "way/37751526",
|
||||
"label": "남쪽 하산 계단길",
|
||||
"width": 2.4,
|
||||
"points": [
|
||||
[
|
||||
126.9652191,
|
||||
37.4426477
|
||||
],
|
||||
[
|
||||
126.965157,
|
||||
37.4427111
|
||||
],
|
||||
[
|
||||
126.9650832,
|
||||
37.4428437
|
||||
],
|
||||
[
|
||||
126.9649758,
|
||||
37.4428823
|
||||
],
|
||||
[
|
||||
126.9646903,
|
||||
37.4431718
|
||||
],
|
||||
[
|
||||
126.9644942,
|
||||
37.4433977
|
||||
],
|
||||
[
|
||||
126.9645363,
|
||||
37.4435795
|
||||
],
|
||||
[
|
||||
126.9644974,
|
||||
37.4437541
|
||||
],
|
||||
[
|
||||
126.9645055,
|
||||
37.4438755
|
||||
],
|
||||
[
|
||||
126.9644849,
|
||||
37.4439916
|
||||
],
|
||||
[
|
||||
126.9644634,
|
||||
37.4441563
|
||||
],
|
||||
[
|
||||
126.9644231,
|
||||
37.4442014
|
||||
],
|
||||
[
|
||||
126.9643909,
|
||||
37.4441957
|
||||
],
|
||||
[
|
||||
126.9643509,
|
||||
37.4442247
|
||||
],
|
||||
[
|
||||
126.9642731,
|
||||
37.4442455
|
||||
],
|
||||
[
|
||||
126.9642271,
|
||||
37.4442888
|
||||
],
|
||||
[
|
||||
126.9641796,
|
||||
37.4443334
|
||||
],
|
||||
[
|
||||
126.9641677,
|
||||
37.4443904
|
||||
],
|
||||
[
|
||||
126.9641998,
|
||||
37.4444216
|
||||
],
|
||||
[
|
||||
126.9642101,
|
||||
37.444477
|
||||
],
|
||||
[
|
||||
126.9641853,
|
||||
37.4445946
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "way/337498787",
|
||||
"label": "동쪽 계단길",
|
||||
"width": 2.2,
|
||||
"points": [
|
||||
[
|
||||
126.9646956,
|
||||
37.4448264
|
||||
],
|
||||
[
|
||||
126.9644011,
|
||||
37.4449652
|
||||
],
|
||||
[
|
||||
126.9641628,
|
||||
37.4449609
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "way/337498788",
|
||||
"label": "북쪽 능선길",
|
||||
"width": 2.4,
|
||||
"points": [
|
||||
[
|
||||
126.9652188,
|
||||
37.445905
|
||||
],
|
||||
[
|
||||
126.9651599,
|
||||
37.4458421
|
||||
],
|
||||
[
|
||||
126.9651204,
|
||||
37.445817
|
||||
],
|
||||
[
|
||||
126.9650739,
|
||||
37.4458262
|
||||
],
|
||||
[
|
||||
126.965047,
|
||||
37.4458008
|
||||
],
|
||||
[
|
||||
126.9650012,
|
||||
37.4458049
|
||||
],
|
||||
[
|
||||
126.9649836,
|
||||
37.4457585
|
||||
],
|
||||
[
|
||||
126.9649235,
|
||||
37.4457452
|
||||
],
|
||||
[
|
||||
126.9649129,
|
||||
37.4458003
|
||||
],
|
||||
[
|
||||
126.9648793,
|
||||
37.445814
|
||||
],
|
||||
[
|
||||
126.9648108,
|
||||
37.4457987
|
||||
],
|
||||
[
|
||||
126.9648084,
|
||||
37.4457327
|
||||
],
|
||||
[
|
||||
126.9647692,
|
||||
37.4457201
|
||||
],
|
||||
[
|
||||
126.9647305,
|
||||
37.4457475
|
||||
],
|
||||
[
|
||||
126.9646677,
|
||||
37.4457742
|
||||
],
|
||||
[
|
||||
126.9646443,
|
||||
37.4457812
|
||||
],
|
||||
[
|
||||
126.9646393,
|
||||
37.4457277
|
||||
],
|
||||
[
|
||||
126.9646373,
|
||||
37.4456519
|
||||
],
|
||||
[
|
||||
126.9646231,
|
||||
37.4456153
|
||||
],
|
||||
[
|
||||
126.9645478,
|
||||
37.4456006
|
||||
],
|
||||
[
|
||||
126.9645194,
|
||||
37.4455309
|
||||
],
|
||||
[
|
||||
126.9645211,
|
||||
37.4455051
|
||||
],
|
||||
[
|
||||
126.9644963,
|
||||
37.4454888
|
||||
],
|
||||
[
|
||||
126.9645078,
|
||||
37.4454665
|
||||
],
|
||||
[
|
||||
126.9644384,
|
||||
37.4454786
|
||||
],
|
||||
[
|
||||
126.9644443,
|
||||
37.4454544
|
||||
],
|
||||
[
|
||||
126.9644447,
|
||||
37.4454142
|
||||
],
|
||||
[
|
||||
126.9644298,
|
||||
37.4453984
|
||||
],
|
||||
[
|
||||
126.9644073,
|
||||
37.4454045
|
||||
],
|
||||
[
|
||||
126.9644102,
|
||||
37.4454574
|
||||
],
|
||||
[
|
||||
126.9643506,
|
||||
37.445427
|
||||
],
|
||||
[
|
||||
126.9642934,
|
||||
37.4454174
|
||||
],
|
||||
[
|
||||
126.9642857,
|
||||
37.4454008
|
||||
],
|
||||
[
|
||||
126.9643346,
|
||||
37.4453985
|
||||
],
|
||||
[
|
||||
126.9643315,
|
||||
37.4453643
|
||||
],
|
||||
[
|
||||
126.9642929,
|
||||
37.4453472
|
||||
],
|
||||
[
|
||||
126.9642727,
|
||||
37.4453499
|
||||
],
|
||||
[
|
||||
126.9642484,
|
||||
37.4453197
|
||||
],
|
||||
[
|
||||
126.9642584,
|
||||
37.4452722
|
||||
],
|
||||
[
|
||||
126.9642427,
|
||||
37.4452121
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
export const TERRAIN = {
|
||||
metadataUrl: '/terrain/gwanaksan.json',
|
||||
heightsUrl: '/terrain/gwanaksan.f32',
|
||||
earthRadiusMeters: 6371008.8,
|
||||
fogNear: 4000,
|
||||
fogFar: 16500,
|
||||
refinementRadiusMeters: 180,
|
||||
refinementSegments: 6,
|
||||
} as const;
|
||||
@@ -0,0 +1,5 @@
|
||||
export const TIME_ZONE = 'Asia/Seoul';
|
||||
export const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
export const LIVE_INTERVAL_MS = 1000;
|
||||
export const TIME_STEP_SECONDS = 10;
|
||||
export const LAST_SELECTABLE_SECOND = 86400 - TIME_STEP_SECONDS;
|
||||
@@ -0,0 +1,15 @@
|
||||
export const DIALOG_TITLES = {
|
||||
menu: '메뉴', location: '장소', time: '날짜·시간', information: '관측 정보',
|
||||
view: '지도·시점', session: '닉네임·접속 상태',
|
||||
settings: '설정·조작 방법', sources: '출처·오픈소스',
|
||||
} as const;
|
||||
|
||||
export const EQUIPMENT_NOTICE = {
|
||||
weather: '기상악화시 사용할 수 있습니다',
|
||||
raincoat: '우비를 먼저 착용해 주세요',
|
||||
durationMs: 2400,
|
||||
margin: 12,
|
||||
pointerOffset: 16,
|
||||
} as const;
|
||||
|
||||
export const PRAYER_NOTICE = '일출 전후 5분에 사용할 수 있습니다';
|
||||
@@ -0,0 +1,22 @@
|
||||
export const WEATHER = {
|
||||
forecastUrl: 'https://api.open-meteo.com/v1/forecast',
|
||||
historyUrl: 'https://historical-forecast-api.open-meteo.com/v1/forecast',
|
||||
archiveUrl: 'https://archive-api.open-meteo.com/v1/archive',
|
||||
archiveStart: '1940-01-01',
|
||||
historyStart: '2022-01-01',
|
||||
recentDays: 5,
|
||||
forecastDays: 16,
|
||||
hourMs: 3600000,
|
||||
dayMs: 86400000,
|
||||
refreshMs: 30 * 60 * 1000,
|
||||
retryMs: 2 * 60 * 1000,
|
||||
timeoutMs: 15000,
|
||||
cacheDays: 8,
|
||||
transitionSeconds: 0.7,
|
||||
cloudOpticalDepth: 3.5,
|
||||
hazePathMeters: 2000,
|
||||
cloudScale: 3.8,
|
||||
overcastStart: 0.7,
|
||||
cloudOpacity: 0.98,
|
||||
overcastAmbient: 0.65,
|
||||
} as const;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { CHARACTER } from '../constants/character.ts';
|
||||
|
||||
export type Emote = 'idle' | 'wave' | 'pray';
|
||||
|
||||
export function canPray(date: Date, sunrise: Date | null) {
|
||||
return sunrise !== null && Math.abs(+date - +sunrise) <= CHARACTER.prayerWindowMs;
|
||||
}
|
||||
|
||||
export function createEmoteState() {
|
||||
let current: Emote = 'idle';
|
||||
let elapsed = 0;
|
||||
return {
|
||||
get current() { return current; },
|
||||
get elapsed() { return elapsed; },
|
||||
start(emote: Exclude<Emote, 'idle'>, prayerAvailable: boolean) {
|
||||
if (emote === 'pray' && !prayerAvailable) return false;
|
||||
current = emote;
|
||||
elapsed = 0;
|
||||
return true;
|
||||
},
|
||||
cancel() { current = 'idle'; elapsed = 0; },
|
||||
update(delta: number, moving: boolean, prayerAvailable: boolean): Emote {
|
||||
elapsed += Math.max(0, delta);
|
||||
if (moving || (current === 'pray' && !prayerAvailable)
|
||||
|| (current === 'wave' && elapsed >= CHARACTER.waveSeconds)) this.cancel();
|
||||
return current;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { MathUtils, PerspectiveCamera, type Vector3 } from 'three';
|
||||
import { SCENE } from '../constants/scene.ts';
|
||||
import { MOVEMENT } from '../constants/movement.ts';
|
||||
|
||||
export function createFpsCameraController(camera: PerspectiveCamera, canvas: HTMLCanvasElement,
|
||||
pickDestination: (x: number, y: number) => Vector3 | null) {
|
||||
let destination: Vector3 | null = null;
|
||||
let gesture: { id: number; startX: number; startY: number; x: number; y: number; dragged: boolean; canMove: boolean } | null = null;
|
||||
canvas.tabIndex = 0;
|
||||
const cancelGesture = () => {
|
||||
const id = gesture?.id;
|
||||
gesture = null;
|
||||
if (id !== undefined && canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
|
||||
};
|
||||
const clear = () => { destination = null; cancelGesture(); };
|
||||
const down = (event: PointerEvent) => {
|
||||
if (!event.isPrimary || gesture || (event.button !== 0 && event.button !== 2)) return;
|
||||
event.preventDefault();
|
||||
canvas.focus({ preventScroll: true });
|
||||
gesture = { id: event.pointerId, startX: event.clientX, startY: event.clientY,
|
||||
x: event.clientX, y: event.clientY, dragged: false, canMove: event.pointerType !== 'mouse' || event.button === 2 };
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
};
|
||||
const move = (event: PointerEvent) => {
|
||||
if (!gesture || gesture.id !== event.pointerId) return;
|
||||
if (Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) > MOVEMENT.dragThreshold) gesture.dragged = true;
|
||||
if (gesture.dragged) {
|
||||
camera.rotation.reorder('YXZ');
|
||||
camera.rotation.y -= (event.clientX - gesture.x) * MOVEMENT.dragSensitivity;
|
||||
camera.rotation.x = MathUtils.clamp(camera.rotation.x - (event.clientY - gesture.y) * MOVEMENT.dragSensitivity,
|
||||
-MathUtils.degToRad(SCENE.pitchLimitDegrees), MathUtils.degToRad(SCENE.pitchLimitDegrees));
|
||||
}
|
||||
gesture.x = event.clientX;
|
||||
gesture.y = event.clientY;
|
||||
};
|
||||
const up = (event: PointerEvent) => {
|
||||
if (!gesture || gesture.id !== event.pointerId) return;
|
||||
const tap = !gesture.dragged && Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) <= MOVEMENT.dragThreshold;
|
||||
if (tap && gesture.canMove) destination = pickDestination(event.clientX, event.clientY);
|
||||
cancelGesture();
|
||||
};
|
||||
const cancel = (event: PointerEvent) => { if (gesture?.id === event.pointerId) cancelGesture(); };
|
||||
const contextMenu = (event: Event) => event.preventDefault();
|
||||
window.addEventListener('blur', clear);
|
||||
canvas.addEventListener('blur', clear);
|
||||
document.addEventListener('visibilitychange', clear);
|
||||
canvas.addEventListener('pointerdown', down);
|
||||
canvas.addEventListener('pointermove', move);
|
||||
canvas.addEventListener('pointerup', up);
|
||||
canvas.addEventListener('pointercancel', cancel);
|
||||
canvas.addEventListener('lostpointercapture', cancel);
|
||||
canvas.addEventListener('contextmenu', contextMenu);
|
||||
return {
|
||||
get destination() { return destination; },
|
||||
get looking() { return Boolean(gesture?.dragged); },
|
||||
stop() { destination = null; },
|
||||
active() { return document.activeElement === canvas; },
|
||||
clear,
|
||||
dispose() {
|
||||
clear();
|
||||
window.removeEventListener('blur', clear);
|
||||
canvas.removeEventListener('blur', clear);
|
||||
document.removeEventListener('visibilitychange', clear);
|
||||
canvas.removeEventListener('pointerdown', down);
|
||||
canvas.removeEventListener('pointermove', move);
|
||||
canvas.removeEventListener('pointerup', up);
|
||||
canvas.removeEventListener('pointercancel', cancel);
|
||||
canvas.removeEventListener('lostpointercapture', cancel);
|
||||
canvas.removeEventListener('contextmenu', contextMenu);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type RainGearItem = 'umbrella' | 'raincoat' | 'hood';
|
||||
|
||||
/** Equipment is independent of movement and emotes. */
|
||||
export function createRainGearState() {
|
||||
const worn = { umbrella: false, raincoat: false, hood: false };
|
||||
let available = false;
|
||||
return {
|
||||
get available() { return available; },
|
||||
get umbrella() { return worn.umbrella; },
|
||||
get raincoat() { return worn.raincoat; },
|
||||
get hood() { return worn.hood; },
|
||||
apply(state: { umbrella: boolean; raincoat: boolean; hood: boolean }) {
|
||||
worn.umbrella = state.umbrella;
|
||||
worn.raincoat = state.raincoat;
|
||||
worn.hood = state.raincoat && state.hood;
|
||||
},
|
||||
updateWeather(rain: number, snowfall: number) {
|
||||
available = rain > 0 || snowfall > 0;
|
||||
if (!available) { worn.umbrella = false; worn.raincoat = false; worn.hood = false; }
|
||||
},
|
||||
toggle(item: RainGearItem) {
|
||||
if (!available) return false;
|
||||
if (item === 'hood' && !worn.raincoat) return false;
|
||||
worn[item] = !worn[item];
|
||||
if (!worn.raincoat) worn.hood = false;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** WGS84 local east/south metres, matching the scene's structurePosition. */
|
||||
export function summitPosition(latitude: number, longitude: number, origin: { latitude: number; longitude: number }) {
|
||||
const radians = Math.PI / 180;
|
||||
const phi = origin.latitude * radians;
|
||||
const e2 = 0.00669437999014;
|
||||
const denom = 1 - e2 * Math.sin(phi) ** 2;
|
||||
const northRadius = 6378137 * (1 - e2) / denom ** 1.5;
|
||||
const eastRadius = 6378137 / Math.sqrt(denom);
|
||||
return { x: (longitude - origin.longitude) * radians * eastRadius * Math.cos(phi),
|
||||
z: -(latitude - origin.latitude) * radians * northRadius };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { SUMMIT_TRAILS } from '../constants/summitTrails.ts';
|
||||
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
|
||||
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
|
||||
import { STRUCTURES } from '../constants/structures.ts';
|
||||
import { summitPosition as structurePosition } from './summitCoordinates.ts';
|
||||
|
||||
type Point = { x: number; z: number };
|
||||
export function createSummitWalking(origin: { latitude: number; longitude: number }) {
|
||||
const start = structurePosition(SUMMIT_TERRAIN.start.latitude, SUMMIT_TERRAIN.start.longitude, origin);
|
||||
const routes = SUMMIT_TRAILS.routes.map(route => ({ ...route,
|
||||
points: route.points.map(([lon,lat]) => structurePosition(lat,lon,origin)) }));
|
||||
const segments = routes.flatMap(route => route.points.slice(1).map((b,i) => ({a:route.points[i],b,width:route.width})));
|
||||
const rock = SUMMIT_TRAILS.rockPolygon.map(([x,z]) => ({x:x+start.x,z:z+start.z}));
|
||||
const outcrops = SUMMIT_TRAILS.rockOutcrops.map(p => ({...structurePosition(p.latitude,p.longitude,origin),radius:p.radius}));
|
||||
const facilities = [
|
||||
{ x: start.x + SUMMIT_PLATEAU.marker.offset[0], z: start.z + SUMMIT_PLATEAU.marker.offset[1], radius: SUMMIT_PLATEAU.marker.blockedRadius },
|
||||
{ ...structurePosition(STRUCTURES.radar.latitude,STRUCTURES.radar.longitude,origin), radius: 12 },
|
||||
{ ...structurePosition(STRUCTURES.towers[0].latitude,STRUCTURES.towers[0].longitude,origin), radius: 8 },
|
||||
];
|
||||
function inside(x:number,z:number) { return Math.hypot(x-start.x,z-start.z)<=SUMMIT_TRAILS.radius; }
|
||||
function pathDistance(x:number,z:number) {
|
||||
let nearest=Infinity;
|
||||
for (const {a,b,width} of segments) {
|
||||
const dx=b.x-a.x,dz=b.z-a.z;
|
||||
const t=Math.max(0,Math.min(1,((x-a.x)*dx+(z-a.z)*dz)/(dx*dx+dz*dz || 1)));
|
||||
nearest=Math.min(nearest,Math.hypot(x-a.x-t*dx,z-a.z-t*dz)-width/2);
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
function onPlatform(x:number,z:number) {
|
||||
let hit=false;
|
||||
for(let i=0,j=rock.length-1;i<rock.length;j=i++) {
|
||||
const a=rock[i],b=rock[j];
|
||||
if((a.z>z)!==(b.z>z) && x<(b.x-a.x)*(z-a.z)/(b.z-a.z)+a.x) hit=!hit;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
function blocked(x:number,z:number) { return facilities.some(p=>Math.hypot(x-p.x,z-p.z)<p.radius); }
|
||||
/** Signed distance for visual material blending only; does not change walkability. */
|
||||
function rockDistance(x:number,z:number) {
|
||||
let distance=Infinity;
|
||||
for(let i=0;i<rock.length;i++) {
|
||||
const a=rock[i],b=rock[(i+1)%rock.length],dx=b.x-a.x,dz=b.z-a.z;
|
||||
const t=Math.max(0,Math.min(1,((x-a.x)*dx+(z-a.z)*dz)/(dx*dx+dz*dz)));
|
||||
distance=Math.min(distance,Math.hypot(x-a.x-t*dx,z-a.z-t*dz));
|
||||
}
|
||||
if(onPlatform(x,z))distance=-distance;
|
||||
for(const p of outcrops)distance=Math.min(distance,Math.hypot(x-p.x,z-p.z)-p.radius);
|
||||
return distance;
|
||||
}
|
||||
function surface(x:number,z:number): 'path'|'rock'|'forest' {
|
||||
if(onPlatform(x,z) || outcrops.some(p=>Math.hypot(x-p.x,z-p.z)<p.radius)) return 'rock';
|
||||
return pathDistance(x,z)<=0 ? 'path' : 'forest';
|
||||
}
|
||||
function walkable(x:number,z:number) { return inside(x,z) && !blocked(x,z) && (onPlatform(x,z)||pathDistance(x,z)<=0); }
|
||||
function canTraverse(a:Point,b:Point) {
|
||||
const steps=Math.max(1,Math.ceil(Math.hypot(b.x-a.x,b.z-a.z)/SUMMIT_TRAILS.sweepStep));
|
||||
for(let i=0;i<=steps;i++) { const t=i/steps; if(!walkable(a.x+(b.x-a.x)*t,a.z+(b.z-a.z)*t)) return false; }
|
||||
return true;
|
||||
}
|
||||
return { start,routes,inside,pathDistance,rockDistance,onPlatform,blocked,surface,walkable,canTraverse };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Vector3, type Object3D, type PerspectiveCamera } from 'three';
|
||||
import { CHARACTER } from '../constants/character.ts';
|
||||
|
||||
const direction = new Vector3();
|
||||
const target = new Vector3();
|
||||
|
||||
/** Face the accepted ground step, while the camera catches up along the shortest yaw arc. */
|
||||
export function faceTravel(character: Object3D, observer: PerspectiveCamera, step: Vector3,
|
||||
delta: number, follow = true) {
|
||||
if (step.x === 0 && step.z === 0) return 0;
|
||||
const heading = Math.atan2(step.x, step.z);
|
||||
const turn = Math.atan2(Math.sin(heading - character.rotation.y), Math.cos(heading - character.rotation.y));
|
||||
const limit = CHARACTER.turnSpeed * Math.min(delta, .05);
|
||||
character.rotation.y += Math.max(-limit, Math.min(limit, turn));
|
||||
// Turn in place for rear destinations; accelerate as the body faces its path.
|
||||
const alignment = Math.max(0, Math.cos(heading - character.rotation.y)) ** 2;
|
||||
if (!follow) return alignment;
|
||||
observer.rotation.reorder('YXZ');
|
||||
const targetYaw = character.rotation.y + Math.PI;
|
||||
const difference = Math.atan2(Math.sin(targetYaw - observer.rotation.y), Math.cos(targetYaw - observer.rotation.y));
|
||||
observer.rotation.y += difference * (1 - Math.exp(-CHARACTER.cameraTurnRate * Math.min(delta, .1)));
|
||||
return alignment;
|
||||
}
|
||||
|
||||
/** Observer stays on the walking surface; only the render camera is offset. */
|
||||
export function updateThirdPersonCamera(camera: PerspectiveCamera, observer: PerspectiveCamera,
|
||||
heightAt: (x: number, z: number) => number) {
|
||||
observer.getWorldDirection(direction);
|
||||
// Limit vertical orbit so the character remains readable at extreme look angles.
|
||||
direction.y = Math.max(-0.45, Math.min(0.45, direction.y));
|
||||
direction.normalize();
|
||||
camera.position.copy(observer.position).addScaledVector(direction, -CHARACTER.cameraDistance);
|
||||
camera.position.y += CHARACTER.cameraLift;
|
||||
// Sample the boom as well as its endpoint to avoid crossing a rising slope.
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
const t = i / 12;
|
||||
const x = observer.position.x + (camera.position.x - observer.position.x) * t;
|
||||
const z = observer.position.z + (camera.position.z - observer.position.z) * t;
|
||||
const minimum = heightAt(x, z) + CHARACTER.cameraClearance;
|
||||
camera.position.y = Math.max(camera.position.y, observer.position.y + (minimum - observer.position.y) / t);
|
||||
}
|
||||
target.copy(observer.position).addScaledVector(direction, 2);
|
||||
camera.lookAt(target);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Vector3 } from 'three';
|
||||
import { MOVEMENT } from '../constants/movement.ts';
|
||||
import { TERRAIN } from '../constants/terrain.ts';
|
||||
import type { TerrainMetadata } from '../scene/terrainGeometry.ts';
|
||||
|
||||
/** Sample the rendered triangle, rather than bilinear heights (which can intersect the mesh). */
|
||||
export function walkingHeight(meta: TerrainMetadata, heights: Float32Array, x: number, z: number) {
|
||||
const col = (x + meta.radiusMeters) / meta.spacingMeters, row = (z + meta.radiusMeters) / meta.spacingMeters;
|
||||
const left = Math.min(meta.width - 2, Math.max(0, Math.floor(col)));
|
||||
const top = Math.min(meta.height - 2, Math.max(0, Math.floor(row)));
|
||||
const u = col - left, v = row - top;
|
||||
const vertex = (c: number, r: number) => {
|
||||
const vx = c * meta.spacingMeters - meta.radiusMeters, vz = r * meta.spacingMeters - meta.radiusMeters;
|
||||
return heights[r * meta.width + c] - meta.observerSurfaceElevation - (vx * vx + vz * vz) / (2 * TERRAIN.earthRadiusMeters);
|
||||
};
|
||||
const a = vertex(left, top), b = vertex(left + 1, top), c = vertex(left, top + 1), d = vertex(left + 1, top + 1);
|
||||
return u + v <= 1 ? a * (1 - u - v) + b * u + c * v : b * (1 - v) + c * (1 - u) + d * (u + v - 1);
|
||||
}
|
||||
|
||||
export function walkStep(position: Vector3, destination: Vector3, dt: number, radius: number) {
|
||||
const direction = new Vector3(destination.x - position.x, 0, destination.z - position.z);
|
||||
const distance = Math.min(direction.length(), Math.min(Math.max(dt, 0), MOVEMENT.maxDelta) * MOVEMENT.speed);
|
||||
direction.normalize().multiplyScalar(distance);
|
||||
return new Vector3(Math.max(-radius, Math.min(radius, position.x + direction.x)), position.y,
|
||||
Math.max(-radius, Math.min(radius, position.z + direction.z)));
|
||||
}
|
||||
|
||||
export function walkingCoordinates(x: number, z: number, origin: { latitude: number; longitude: number }) {
|
||||
const phi = origin.latitude * Math.PI / 180, e2 = 0.00669437999014;
|
||||
const denom = 1 - e2 * Math.sin(phi) ** 2;
|
||||
const northRadius = 6378137 * (1 - e2) / denom ** 1.5, eastRadius = 6378137 / Math.sqrt(denom);
|
||||
return { latitude: origin.latitude - z / northRadius * 180 / Math.PI,
|
||||
longitude: origin.longitude + x / (eastRadius * Math.cos(phi)) * 180 / Math.PI };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { WeatherSample } from '../weather/weather.ts';
|
||||
|
||||
const samples: Record<string, WeatherSample | null> = {
|
||||
clear: null,
|
||||
rain: { cloudCover: 80, visibility: 10000, rain: 2, snowfall: 0 },
|
||||
snow: { cloudCover: 80, visibility: 8000, rain: 0, snowfall: .8 },
|
||||
};
|
||||
|
||||
/** Deterministic gear checks without waiting for the real forecast to change. */
|
||||
export function installRainGearPreview(change: (sample: WeatherSample | null) => void) {
|
||||
const label = document.createElement('label');
|
||||
label.textContent = 'DEV 날씨 ';
|
||||
label.style.cssText = 'position:fixed;left:12px;top:12px;z-index:1001;background:#17202a;padding:8px;color:white;font:12px sans-serif';
|
||||
const select = document.createElement('select');
|
||||
select.setAttribute('aria-label', 'DEV 날씨');
|
||||
for (const [value, text] of [['clear', '맑음'], ['rain', '비'], ['snow', '눈']]) select.add(new Option(text, value));
|
||||
const requested = new URLSearchParams(location.search).get('weather') ?? 'clear';
|
||||
select.value = requested in samples ? requested : 'clear';
|
||||
const apply = () => {
|
||||
change(samples[select.value]);
|
||||
document.getElementById('weather-status')!.textContent = `DEV 장비 확인용 가상 날씨 · ${select.selectedOptions[0].text}`;
|
||||
};
|
||||
select.addEventListener('change', apply);
|
||||
label.append(select); document.body.append(label); apply();
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Light, Mesh, Texture, Vector2, Vector3 } from 'three';
|
||||
import type { PerspectiveCamera, Scene, WebGLRenderer } from 'three';
|
||||
import { frameSummary } from '../scene/frameSummary.ts';
|
||||
import { createAdaptiveResolution, renderPixelRatio } from '../scene/adaptiveResolution.ts';
|
||||
import { RENDER_QUALITY } from '../constants/renderQuality.ts';
|
||||
|
||||
const scenarios = ['static', 'rotation', 'movement', 'dense', 'endurance'] as const;
|
||||
type Scenario = typeof scenarios[number];
|
||||
type Costs = { update: number; minimap: number; submit: number };
|
||||
|
||||
/** Development-only replay and report. Never imported by the production build. */
|
||||
export function createRenderProfile(renderer: WebGLRenderer, scene: Scene, camera: PerspectiveCamera,
|
||||
reset: () => void, denseTarget: Vector3, passes: () => string[], applyResolution: () => void) {
|
||||
const query = new URLSearchParams(location.search);
|
||||
const forcedDpr = Number(query.get('dpr'));
|
||||
const fixedDpr = forcedDpr >= 0.75 && forcedDpr <= 2 ? forcedDpr : undefined;
|
||||
// Explicit experiment only: do not change production defaults before matched measurements.
|
||||
const adaptive = query.get('quality') === 'adaptive' && fixedDpr === undefined
|
||||
? createAdaptiveResolution(RENDER_QUALITY.mobileCap) : undefined;
|
||||
adaptive?.reset(performance.now());
|
||||
const panel = document.createElement('details');
|
||||
panel.id = 'render-profile';
|
||||
panel.style.cssText = 'position:fixed;right:8px;top:8px;z-index:1000;background:#10151eee;color:white;padding:8px;max-width:390px;max-height:50vh;overflow:auto;font:12px monospace';
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = 'DEV performance';
|
||||
panel.append(summary);
|
||||
const output = document.createElement('pre');
|
||||
output.id = 'render-profile-result';
|
||||
output.style.whiteSpace = 'pre-wrap';
|
||||
let scenario: Scenario | undefined, startAt = 0, sampleAt = 0, previous = 0;
|
||||
let intervals: number[] = [], calls: number[] = [], triangles: number[] = [];
|
||||
let updates: number[] = [], minimaps: number[] = [], submits: number[] = [];
|
||||
let frames = 0, rejectedMoves = 0, distance = 0;
|
||||
const origin = new Vector3(), previousPosition = new Vector3();
|
||||
let baseYaw = 0, basePitch = 0;
|
||||
let initialInventory: unknown;
|
||||
const inventory = () => {
|
||||
const lights: unknown[] = [], textures = new Map<string, unknown>();
|
||||
scene.traverseVisible(object => {
|
||||
if (object instanceof Light) lights.push({ type: object.type, intensity: object.intensity, castShadow: object.castShadow });
|
||||
if (object instanceof Mesh) for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
|
||||
const values = [...Object.values(material), ...Object.values((material as unknown as { uniforms?: Record<string, {value: unknown}> }).uniforms ?? {}).map(u => u.value)];
|
||||
for (const value of values) if (value instanceof Texture) {
|
||||
const image = value.image as { width?: number; height?: number } | undefined;
|
||||
textures.set(value.uuid, { name: value.name, width: image?.width, height: image?.height, mipmaps: value.generateMipmaps });
|
||||
}
|
||||
}
|
||||
});
|
||||
const size = renderer.getDrawingBufferSize(new Vector2());
|
||||
return { css: [innerWidth, innerHeight], pixels: size.toArray(), pixelCount: size.x * size.y,
|
||||
deviceDpr: devicePixelRatio, renderDpr: renderer.getPixelRatio(), lights,
|
||||
shadowsEnabled: renderer.shadowMap.enabled, passes: passes(), textureSources: [...textures.values()],
|
||||
memory: { ...renderer.info.memory }, camera: { position: camera.position.toArray(), quaternion: camera.quaternion.toArray() } };
|
||||
};
|
||||
function start(name: Scenario) {
|
||||
reset();
|
||||
scenario = name;
|
||||
startAt = performance.now(); sampleAt = startAt + 8000; previous = 0;
|
||||
adaptive?.restart(startAt);
|
||||
if (adaptive) applyResolution();
|
||||
intervals = []; calls = []; triangles = []; updates = []; minimaps = []; submits = [];
|
||||
frames = rejectedMoves = distance = 0;
|
||||
origin.copy(camera.position); previousPosition.copy(origin);
|
||||
camera.rotation.reorder('YXZ'); baseYaw = camera.rotation.y; basePitch = camera.rotation.x;
|
||||
if (name === 'dense') { camera.lookAt(denseTarget); baseYaw = camera.rotation.y; basePitch = camera.rotation.x; }
|
||||
output.textContent = `${name}: warming up (8 seconds)`;
|
||||
summary.textContent = `DEV ${name}: warming up`;
|
||||
initialInventory = undefined;
|
||||
}
|
||||
for (const name of scenarios) {
|
||||
const button = document.createElement('button');
|
||||
button.textContent = name; button.onclick = () => start(name); panel.append(button);
|
||||
}
|
||||
const stop = document.createElement('button');
|
||||
stop.textContent = 'stop';
|
||||
stop.onclick = () => { scenario = undefined; output.textContent = 'STOPPED: incomplete run discarded'; summary.textContent = 'DEV stopped'; };
|
||||
panel.append(stop);
|
||||
panel.append(output); document.body.append(panel);
|
||||
const onVisibility = () => {
|
||||
adaptive?.reset(performance.now());
|
||||
if (document.hidden && scenario) {
|
||||
scenario = undefined; output.textContent = 'ABORTED: tab hidden; rerun in foreground'; summary.textContent = 'DEV aborted';
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
const onResize = () => {
|
||||
adaptive?.reset(performance.now());
|
||||
if (scenario) {
|
||||
scenario = undefined; output.textContent = 'ABORTED: viewport changed; rerun at fixed dimensions'; summary.textContent = 'DEV aborted';
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return {
|
||||
get forcedDpr() { return fixedDpr ?? (adaptive
|
||||
? renderPixelRatio(devicePixelRatio, adaptive.cap(), innerWidth, innerHeight, true) : undefined); },
|
||||
transition() { adaptive?.reset(performance.now()); },
|
||||
observe(now: number, rendered: boolean) { return adaptive?.observe(now, rendered && !document.hidden) ?? false; },
|
||||
disablePost: query.get('post') === 'off',
|
||||
active: () => Boolean(scenario),
|
||||
step(now: number) {
|
||||
if (!scenario) return undefined;
|
||||
const t = Math.max(0, (now - sampleAt) / 1000);
|
||||
const rotate = scenario === 'rotation' || scenario === 'endurance';
|
||||
camera.rotation.set(basePitch, baseYaw - (rotate ? t * Math.PI / 10 : 0), 0, 'YXZ');
|
||||
const moving = scenario === 'movement' || scenario === 'dense' || scenario === 'endurance';
|
||||
// Fixed 6m out-and-back path, independent of measured frame rate. Existing collision/height checks still apply.
|
||||
return moving ? new Vector3(origin.x + 3 * Math.sin(t * Math.PI / 4), origin.y, origin.z) : undefined;
|
||||
},
|
||||
end(now: number, costs: Costs) {
|
||||
if (!scenario) return;
|
||||
if (!initialInventory && now >= sampleAt - 1000) {
|
||||
initialInventory = inventory();
|
||||
// Do scene traversal before the sample window, including after a late first frame.
|
||||
if (now >= sampleAt) sampleAt = now + 1000;
|
||||
}
|
||||
if (now < sampleAt) return;
|
||||
if (previous) intervals.push(now - previous);
|
||||
previous = now; frames++;
|
||||
calls.push(renderer.info.render.calls); triangles.push(renderer.info.render.triangles);
|
||||
updates.push(costs.update); minimaps.push(costs.minimap); submits.push(costs.submit);
|
||||
distance += camera.position.distanceTo(previousPosition); previousPosition.copy(camera.position);
|
||||
const duration = scenario === 'endurance' ? 300000 : 20000;
|
||||
if (frames % 60 === 0) summary.textContent = `DEV ${scenario}: ${Math.floor((now - sampleAt) / 1000)}s / ${duration / 1000}s`;
|
||||
if (now - sampleAt < duration) return;
|
||||
const range = (values: number[]) => ({ mean: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
min: values.reduce((a, b) => Math.min(a, b), Infinity), max: values.reduce((a, b) => Math.max(a, b), -Infinity) });
|
||||
const result = { scenario, url: location.href, userAgent: navigator.userAgent, measuredAt: new Date().toISOString(),
|
||||
durationMs: now - sampleAt, frame: frameSummary(intervals), calls: range(calls), triangles: range(triangles),
|
||||
cpuMs: { update: range(updates), minimap: range(minimaps), renderSubmission: range(submits) },
|
||||
initial: initialInventory, final: inventory(), distanceMeters: distance, rejectedMoves,
|
||||
note: 'Forced continuous render; scripted path, not manual input validation. CPU submit time is not GPU time.' };
|
||||
output.textContent = JSON.stringify(result, null, 2);
|
||||
console.info('render-profile-result', JSON.stringify(result));
|
||||
summary.textContent = `DEV ${scenario}: ${result.frame.fps?.toFixed(1)} FPS / p95 ${result.frame.p95Ms?.toFixed(1)}ms`;
|
||||
scenario = undefined;
|
||||
},
|
||||
rejected() { rejectedMoves++; },
|
||||
dispose() { document.removeEventListener('visibilitychange', onVisibility); window.removeEventListener('resize', onResize); panel.remove(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { start } from '../main.ts';
|
||||
|
||||
// Local asset capture entry; not imported by the application.
|
||||
void start({ ready() {
|
||||
document.querySelector<HTMLInputElement>('#date')!.value = '2026-10-29';
|
||||
const slider = document.querySelector<HTMLInputElement>('#time-slider')!;
|
||||
slider.value = '26400';
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
document.getElementById('look-east')!.click();
|
||||
} });
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import './style.css';
|
||||
import './lobby.css';
|
||||
import { APP_NAME, LOBBY, type LobbyData } from './constants/lobby.ts';
|
||||
|
||||
const lobby = document.getElementById('lobby')!;
|
||||
const title = document.getElementById('lobby-title')!;
|
||||
const status = document.getElementById('lobby-status')!;
|
||||
const options = document.getElementById('lobby-options')!;
|
||||
const back = document.getElementById('lobby-back') as HTMLButtonElement;
|
||||
const retry = document.getElementById('lobby-retry') as HTMLButtonElement;
|
||||
const hud = document.querySelector<HTMLElement>('.hud')!;
|
||||
hud.inert = true;
|
||||
document.title = APP_NAME;
|
||||
let channel: LobbyData['channel'];
|
||||
let channelRequest: AbortController | undefined;
|
||||
let background: { dispose(): void } | undefined;
|
||||
let backgroundCancelled = false;
|
||||
|
||||
function stopBackground() {
|
||||
backgroundCancelled = true;
|
||||
background?.dispose();
|
||||
background = undefined;
|
||||
}
|
||||
|
||||
function message(text: string) { status.textContent = text; status.hidden = !text; }
|
||||
function screen(heading = '', text = '') {
|
||||
title.textContent = heading;
|
||||
title.hidden = !heading;
|
||||
message(text);
|
||||
options.replaceChildren();
|
||||
back.hidden = retry.hidden = true;
|
||||
if (heading) title.focus({ preventScroll: true });
|
||||
}
|
||||
function choice(name: string, detail: string, disabled: boolean, action: () => void) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button'; button.className = 'lobby-choice'; button.disabled = disabled;
|
||||
const label = document.createElement('span'); label.textContent = name; button.append(label);
|
||||
if (detail) { const description = document.createElement('small'); description.textContent = detail; button.append(description); }
|
||||
button.addEventListener('click', action); options.append(button);
|
||||
}
|
||||
function home(focus = false) {
|
||||
channelRequest?.abort();
|
||||
screen();
|
||||
choice('혼자보기', '', false, () => { void enter(false); });
|
||||
choice('같이 보기', '', false, () => { void showChannels(); });
|
||||
if (focus) options.querySelector('button')!.focus();
|
||||
}
|
||||
async function showChannels() {
|
||||
channelRequest?.abort();
|
||||
const request = new AbortController(); channelRequest = request;
|
||||
screen('채널 선택', '불러오는 중…');
|
||||
back.hidden = false; back.onclick = () => home(true);
|
||||
try {
|
||||
const response = await fetch(LOBBY.endpoint, { cache: 'no-store', signal: AbortSignal.any([request.signal, AbortSignal.timeout(LOBBY.timeoutMs)]) });
|
||||
if (!response.ok) throw new Error('채널 조회 실패');
|
||||
const data = await response.json() as LobbyData;
|
||||
if (request.signal.aborted) return;
|
||||
channel = data.channel;
|
||||
message(channel.available ? '' : '지금은 입장할 수 없습니다.');
|
||||
choice(channel.name, `${channel.players} / ${channel.capacity}명`, !channel.available, () => { void enter(true); });
|
||||
if (!channel.available) { retry.hidden = false; retry.onclick = () => { void showChannels(); }; }
|
||||
} catch {
|
||||
if (request.signal.aborted) return;
|
||||
message('채널에 연결하지 못했습니다.');
|
||||
retry.hidden = false; retry.onclick = () => { void showChannels(); };
|
||||
}
|
||||
}
|
||||
function reveal() {
|
||||
lobby.hidden = true;
|
||||
document.body.classList.remove('in-lobby');
|
||||
hud.inert = false;
|
||||
document.querySelector<HTMLButtonElement>('.scene-toolbar button')?.focus();
|
||||
}
|
||||
async function enter(shared: boolean) {
|
||||
stopBackground();
|
||||
screen('', '불러오는 중…');
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('site', 'gwanaksan');
|
||||
url.searchParams.set('mode', shared ? 'shared' : 'solo');
|
||||
url.searchParams.delete('terrain'); url.searchParams.delete('profile');
|
||||
history.replaceState(null, '', url);
|
||||
let ready = false, connected = !shared, entered = false;
|
||||
const finish = () => {
|
||||
if (!ready || !connected || entered) return;
|
||||
entered = true;
|
||||
requestAnimationFrame(() => requestAnimationFrame(reveal));
|
||||
};
|
||||
const fail = () => {
|
||||
message('입장하지 못했습니다.');
|
||||
retry.textContent = '다시 시도'; retry.hidden = false;
|
||||
retry.onclick = () => location.reload();
|
||||
};
|
||||
try {
|
||||
const scene = await import('./main.ts');
|
||||
await scene.start({
|
||||
roomId: shared ? channel.id : undefined,
|
||||
connection(text, joined) {
|
||||
connected = !shared || joined;
|
||||
if (entered || !shared) return;
|
||||
message(joined ? '불러오는 중…' : text);
|
||||
retry.hidden = joined;
|
||||
retry.textContent = '다시 입장';
|
||||
retry.onclick = () => document.getElementById('retry-connection')!.click();
|
||||
finish();
|
||||
},
|
||||
ready() { ready = true; finish(); },
|
||||
error: fail,
|
||||
});
|
||||
} catch { fail(); }
|
||||
}
|
||||
|
||||
// Explicit solo/comparison and developer links retain direct entry.
|
||||
const query = new URLSearchParams(location.search);
|
||||
if (query.get('mode') === 'solo' || (import.meta.env.DEV && query.has('profile'))) {
|
||||
void import('./main.ts').then(module => module.start({ ready: reveal, error() {
|
||||
message('장면을 불러오지 못했습니다.'); retry.hidden = false; retry.onclick = () => location.reload();
|
||||
} })).catch(() => { message('장면을 불러오지 못했습니다.'); retry.hidden = false; retry.onclick = () => location.reload(); });
|
||||
} else {
|
||||
if (query.get('mode') === 'shared') void showChannels();
|
||||
else home();
|
||||
void import('./scene/lobbySunrise.ts').then(module => {
|
||||
if (backgroundCancelled) return;
|
||||
const scene = module.createLobbySunrise(document.querySelector<HTMLCanvasElement>('#lobby-sunrise')!);
|
||||
background = scene;
|
||||
return scene.ready;
|
||||
}).catch(error => {
|
||||
background?.dispose();
|
||||
console.error('시작 화면 지형을 불러오지 못했습니다.', error);
|
||||
});
|
||||
}
|
||||
window.addEventListener('pagehide', event => { if (!event.persisted) stopBackground(); });
|
||||
@@ -0,0 +1,28 @@
|
||||
.in-lobby > #world, .in-lobby > .hud, .in-lobby > #render-error { visibility: hidden; }
|
||||
#lobby { position: fixed; inset: 0; z-index: 20; overflow-y: auto; background: #0c1515; color: #f6e9d0; }
|
||||
#lobby[hidden], #lobby [hidden] { display: none; }
|
||||
#lobby-sunrise { position: fixed; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; }
|
||||
.lobby-content { position: relative; min-height: 100%; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: max(32px, env(safe-area-inset-top)) 24px max(110px, env(safe-area-inset-bottom)); }
|
||||
h1.lobby-brand { display: flex; align-items: center; justify-content: center; gap: 14px; margin: 0 0 36px; font-family: "Gowun Dodum", "Pretendard Variable", sans-serif; font-size: clamp(36px, 5vw, 56px); font-weight: 400; letter-spacing: -.025em; color: #f8ead0; text-shadow: 0 2px 18px #071515b3; }
|
||||
.lobby-brand img { width: clamp(48px, 6vw, 70px); height: auto; filter: drop-shadow(0 2px 8px #07151580); }
|
||||
#lobby h1.lobby-brand { -webkit-text-stroke: 2px #293632; paint-order: stroke fill; text-shadow: 0 2px 8px #07151550; }
|
||||
#lobby .lobby-panel h2, #lobby #lobby-status, #lobby #lobby-back, #lobby #lobby-retry { -webkit-text-stroke: 1.5px #293632; paint-order: stroke fill; text-shadow: 0 1px 3px #07151560; }
|
||||
.lobby-brand img { filter: drop-shadow(1px 0 0 #293632) drop-shadow(-1px 0 0 #293632) drop-shadow(0 1px 0 #293632) drop-shadow(0 -1px 0 #293632); }
|
||||
#lobby #lobby-back, #lobby #lobby-retry { color: inherit; }
|
||||
.lobby-panel { width: min(100%, 340px); text-align: center; }
|
||||
.lobby-panel h2 { margin: 0 0 16px; font-size: 18px; font-weight: 500; }
|
||||
.lobby-panel h2:focus { outline: none; }
|
||||
#lobby-status { margin: 0 0 18px; font-size: 13px; color: #e4d8c7; line-height: 1.7; }
|
||||
#lobby-options { display: flex; justify-content: center; gap: 0; }
|
||||
#lobby .lobby-choice { flex: 1; display: grid; justify-items: center; min-width: 0; min-height: 52px; padding: 12px 18px; color: #f2e5cd; background: #131e1ed9; border: 1px solid #efdbb449; border-radius: 0; font-size: 16px; font-weight: 400; }
|
||||
#lobby .lobby-choice:first-child { border-radius: 12px 0 0 12px; }
|
||||
#lobby .lobby-choice:last-child { border-radius: 0 12px 12px 0; }
|
||||
#lobby .lobby-choice:only-child { border-radius: 12px; }
|
||||
#lobby .lobby-choice + .lobby-choice { border-left: 0; }
|
||||
#lobby .lobby-choice:hover:not(:disabled) { background: #eed4a8; color: #1b2827; }
|
||||
.lobby-choice small { font-size: 12px; font-weight: 400; margin-top: 4px; }
|
||||
#lobby .lobby-choice:disabled { opacity: .5; cursor: not-allowed; }
|
||||
#lobby button:focus-visible { outline: 2px solid #ffe2a8; outline-offset: 4px; }
|
||||
#lobby-back, #lobby-retry { margin: 14px 4px 0; min-height: 44px; padding: 8px 14px; background: transparent; border-color: #efdbb430; font-size: 13px; }
|
||||
@media (max-width: 600px) { .lobby-content { padding-bottom: 120px; } }
|
||||
@media (max-height: 450px) { .lobby-content { padding-block: 24px 64px; } h1.lobby-brand { margin-bottom: 18px; font-size: 32px; } .lobby-brand img { width: 44px; } }
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import './style.css';
|
||||
import { createDialogs } from './ui/dialogs.ts';
|
||||
import { loadSummitAssets } from './scene/summitAssets.ts';
|
||||
import { calculateSun } from './astronomy/sunCalculator.ts';
|
||||
import { createWorld } from './scene/world.ts';
|
||||
import { createInformationPanel } from './ui/informationPanel.ts';
|
||||
import { TimeController } from './ui/timeController.ts';
|
||||
import { loadTerrain } from './scene/terrain.ts';
|
||||
import { createHeightController } from './ui/heightController.ts';
|
||||
import type { TimeMode } from './ui/timeController.ts';
|
||||
import { loadBuildingAssets } from './scene/buildingAssets.ts';
|
||||
import { LOCATIONS, DEFAULT_LOCATION, LOCATION_ENABLED } from './constants/locations.ts';
|
||||
import { loadCoast } from './scene/coast.ts';
|
||||
import { loadStructures } from './scene/structures.ts';
|
||||
import { WeatherController } from './weather/weather.ts';
|
||||
import { loadSheep } from './scene/sheep.ts';
|
||||
import type { WeatherStatus } from './weather/weather.ts';
|
||||
import { createMultiplayerClient } from './multiplayer/client.ts';
|
||||
import { MULTIPLAYER } from './constants/multiplayer.ts';
|
||||
|
||||
export interface StartOptions {
|
||||
roomId?: string;
|
||||
connection?: (message: string, connected: boolean) => void;
|
||||
ready?: () => void;
|
||||
error?: (message: string) => void;
|
||||
}
|
||||
|
||||
export async function start(options: StartOptions = {}) {
|
||||
const dialogs = createDialogs();
|
||||
const requested = new URLSearchParams(location.search).get('site');
|
||||
const candidate = requested === 'hajodae' || requested === 'gisamun' || requested === 'gwanaksan' ? requested : DEFAULT_LOCATION;
|
||||
const siteId = LOCATION_ENABLED[candidate] ? candidate : DEFAULT_LOCATION;
|
||||
if (requested && requested !== siteId) {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('site', siteId);
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
document.querySelectorAll<HTMLAnchorElement>('[data-site]').forEach(link => {
|
||||
if (!LOCATION_ENABLED[link.dataset.site as keyof typeof LOCATIONS]) {
|
||||
link.removeAttribute('href');
|
||||
link.setAttribute('aria-disabled', 'true');
|
||||
link.title = '준비 중';
|
||||
link.textContent += ' · 준비 중';
|
||||
}
|
||||
});
|
||||
const site = LOCATIONS[siteId];
|
||||
const coastal = site.coast !== null;
|
||||
const reconstructSummit = !coastal && new URLSearchParams(location.search).get('terrain') !== 'original';
|
||||
const query = new URLSearchParams(location.search);
|
||||
const sharedEligible = siteId === MULTIPLAYER.locationId && reconstructSummit && !query.has('profile');
|
||||
let sharedMode = sharedEligible && query.get('mode') !== 'solo';
|
||||
const terrainComparison = document.getElementById('terrain-comparison') as HTMLAnchorElement;
|
||||
terrainComparison.hidden = coastal;
|
||||
const comparisonUrl = new URL(location.href);
|
||||
comparisonUrl.searchParams.set('mode', 'solo');
|
||||
if (reconstructSummit) comparisonUrl.searchParams.set('terrain', 'original');
|
||||
else comparisonUrl.searchParams.delete('terrain');
|
||||
terrainComparison.href = comparisonUrl.pathname + comparisonUrl.search;
|
||||
terrainComparison.textContent = reconstructSummit ? '원본 지형 비교' : '사진 기반 보정 적용';
|
||||
if (!coastal) {
|
||||
document.getElementById('reset-position')!.textContent = '시작 위치 복귀';
|
||||
document.getElementById('terrain-source')!.textContent = '지형 보정 근거와 한계';
|
||||
(document.getElementById('terrain-source') as HTMLAnchorElement).href = '/terrain/summit-reconstruction.html';
|
||||
document.querySelector('#terrain-minimap canvas')!.setAttribute('aria-label', reconstructSummit ? '사진 기반 추정 지형 등고선 · 화살표는 현재 시점' : '원본 DSM 등고선 · 화살표는 현재 시점');
|
||||
}
|
||||
const status = document.getElementById('terrain-status')!;
|
||||
document.title = `${site.name} · 해돋이 시뮬레이터`;
|
||||
document.getElementById('site-label')!.textContent = site.label;
|
||||
document.getElementById('observer-name')!.textContent = site.name;
|
||||
document.getElementById('current-location')!.textContent = site.name;
|
||||
document.querySelector('#world')!.setAttribute('aria-label', `${site.name} 태양 관측 3D 풍경`);
|
||||
status.textContent = `${site.name} 지형 불러오는 중…`;
|
||||
document.querySelector<HTMLAnchorElement>(`[data-site="${siteId}"]`)!.setAttribute('aria-current', 'page');
|
||||
if (site.coast) {
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-look]').forEach(button => { button.hidden = true; });
|
||||
document.getElementById('look-east')!.textContent = '동쪽 바다';
|
||||
document.getElementById('look-city')!.textContent = site.coast.lookLabel;
|
||||
if (siteId === 'hajodae') document.getElementById('reset-position')!.textContent = '정자 위치 복귀';
|
||||
if (siteId === 'gisamun') {
|
||||
document.getElementById('look-city')!.textContent = '조도 방향';
|
||||
document.getElementById('reset-position')!.textContent = '조도 기준점 복귀';
|
||||
}
|
||||
document.getElementById('city-overview')!.textContent = '해안 조감';
|
||||
document.getElementById('movement-status')!.textContent = '시작점에서 0m · 해안 육지 안에서 이동';
|
||||
for (const id of ['building-status', 'terrain-source', 'structure-source', 'osm-source']) {
|
||||
(document.getElementById(id) as HTMLAnchorElement).href = `${site.coast.directory}/source.html`;
|
||||
}
|
||||
document.getElementById('terrain-source')!.textContent = 'DSM 30m급 원본 보간 · 해안 높이 보정 없음 · 출처/제한';
|
||||
document.getElementById('structure-source')!.textContent = '물결·반사는 시각적 표현 · 조석·실제 파고 미반영';
|
||||
document.getElementById('osm-source')!.textContent = '해안선: © OpenStreetMap contributors · ODbL';
|
||||
}
|
||||
try {
|
||||
const [terrain, buildings, coast, structures, summitGeometry] = await Promise.all([
|
||||
loadTerrain(site.terrain, site.observer), coastal ? null : loadBuildingAssets(),
|
||||
site.coast ? loadCoast(site.coast.directory, site.observer, site.coast.lookTarget) : undefined,
|
||||
coastal ? undefined : loadStructures(reconstructSummit),
|
||||
reconstructSummit ? loadSummitAssets() : undefined,
|
||||
]);
|
||||
const effectiveObserver = { ...site.observer, elevation: terrain.metadata.observerSurfaceElevation };
|
||||
const information = createInformationPanel(effectiveObserver, site.name, coastal ? '근사 지면' : reconstructSummit ? '추정 지면' : '원본 DSM');
|
||||
const sheep = await loadSheep();
|
||||
const world = createWorld(document.querySelector<HTMLCanvasElement>('#world')!, terrain, buildings, (location) => {
|
||||
Object.assign(effectiveObserver, location);
|
||||
update();
|
||||
}, coast, structures, reconstructSummit, summitGeometry, sheep);
|
||||
const viewEvents = new AbortController();
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-look]').forEach(button => {
|
||||
button.addEventListener('click', () => world.lookAtStructure(button.dataset.look!), { signal: viewEvents.signal });
|
||||
});
|
||||
document.getElementById('look-east')!.addEventListener('click', () => world.lookEast(), { signal: viewEvents.signal });
|
||||
document.getElementById('look-city')!.addEventListener('click', () => world.lookAtCity(), { signal: viewEvents.signal });
|
||||
document.getElementById('city-overview')!.addEventListener('click', () => world.showCityOverview(), { signal: viewEvents.signal });
|
||||
document.getElementById('reset-position')!.addEventListener('click', () => {
|
||||
if (sharedMode) multiplayer.reset(); else world.resetPosition();
|
||||
}, { signal: viewEvents.signal });
|
||||
const counts = world.buildingCounts;
|
||||
document.getElementById('building-status')!.textContent = buildings && counts
|
||||
? `과천 건물 ${(counts.tagged + counts.levels + counts.fallback).toLocaleString()}개 · 높이 자료 ${counts.tagged} / 층수 환산 ${counts.levels} / 임시 높이 ${counts.fallback}`
|
||||
: siteId === 'gisamun' ? 'OSM 해안선 적용 · 모래 영역·관측점·해안 높이는 근사값'
|
||||
: 'OSM 해안선·모래사장 적용 · 관측점과 해안 높이는 근사값';
|
||||
let selectedDate = new Date();
|
||||
let selectedMode: TimeMode = 'LIVE';
|
||||
const update = () => {
|
||||
const state = calculateSun(selectedDate, effectiveObserver);
|
||||
information.update(state, selectedMode);
|
||||
world.update(state);
|
||||
};
|
||||
const weatherStatus = document.getElementById('weather-status')!;
|
||||
const applyWeather = ({ status, weather, source }: WeatherStatus) => {
|
||||
if (import.meta.env.DEV && new URLSearchParams(location.search).has('profile')) return;
|
||||
world.setWeather(weather);
|
||||
weatherStatus.textContent = status === 'ready' && weather
|
||||
? `${source} · 운량 ${Math.round(weather.cloudCover)}% · ${weather.visibility === null
|
||||
? '시정 미제공' : `시정 ${(weather.visibility / 1000).toFixed(1)}km`} · ${weather.rain === null
|
||||
? '강우 미제공' : `비 ${weather.rain.toFixed(2)}mm/h`} · ${weather.snowfall === null
|
||||
? '강설 미제공' : `눈 ${weather.snowfall.toFixed(2)}cm/h`}`
|
||||
: status === 'loading' ? '선택 시각의 기상자료 불러오는 중 · 기본 하늘'
|
||||
: status === 'error' ? '기상자료 연결 실패 · 기본 하늘'
|
||||
: '선택 시각의 기상자료 없음 · 기본 하늘';
|
||||
};
|
||||
const weather = new WeatherController(site.observer, state => { if (!sharedMode) applyWeather(state); });
|
||||
const time = new TimeController((date, mode) => {
|
||||
selectedDate = date;
|
||||
selectedMode = mode;
|
||||
update();
|
||||
if (!sharedMode) weather.update(date);
|
||||
});
|
||||
const height = createHeightController(effectiveObserver.eyeHeight, (meters) => {
|
||||
effectiveObserver.eyeHeight = meters;
|
||||
world.setEyeHeight(meters);
|
||||
update();
|
||||
});
|
||||
world.resetPosition();
|
||||
const connectionStatus = document.getElementById('connection-status')!;
|
||||
const modeButton = document.getElementById('session-mode') as HTMLButtonElement;
|
||||
const retryButton = document.getElementById('retry-connection') as HTMLButtonElement;
|
||||
const nickname = document.getElementById('nickname') as HTMLInputElement;
|
||||
const newVisitorId = () => '10000000-1000-4000-8000-100000000000'.replace(/[018]/g,
|
||||
c => (Number(c) ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> Number(c) / 4).toString(16));
|
||||
let visitorId = newVisitorId();
|
||||
try {
|
||||
const saved = localStorage.getItem('sunrise-visitor-id');
|
||||
if (saved && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(saved)) visitorId = saved;
|
||||
else localStorage.setItem('sunrise-visitor-id', visitorId);
|
||||
} catch { /* Storage-disabled browsers keep a per-page anonymous identity. */ }
|
||||
nickname.value = `방문자-${visitorId.slice(-4)}`;
|
||||
const multiplayer = createMultiplayerClient({
|
||||
readPlayer: () => world.readPlayer(),
|
||||
joined: player => world.applyPlayer(player),
|
||||
correction: player => world.applyPlayer(player),
|
||||
snapshot: (state, selfId) => world.synchronizePlayers(state.players, selfId),
|
||||
weather: state => { if (sharedMode) applyWeather(state); },
|
||||
cleared: () => { world.clearPlayers(); world.setConnection(sharedMode, sharedMode ? () => multiplayer.now() : null); },
|
||||
status: (message, connected) => {
|
||||
options.connection?.(message, connected);
|
||||
connectionStatus.textContent = message;
|
||||
retryButton.hidden = !sharedMode || connected;
|
||||
nickname.disabled = connected;
|
||||
world.setConnection(sharedMode && !connected, sharedMode ? () => multiplayer.now() : null);
|
||||
},
|
||||
});
|
||||
const join = () => multiplayer.connect({ locationId: siteId, roomId: options.roomId ?? MULTIPLAYER.roomId,
|
||||
visitorId, nickname: nickname.value.trim() || `방문자-${visitorId.slice(-4)}` });
|
||||
const selectMode = (shared: boolean) => {
|
||||
sharedMode = shared;
|
||||
dialogs.setSharedMode(shared);
|
||||
modeButton.textContent = shared ? '혼자 보기로 전환' : '함께 보기로 전환';
|
||||
document.getElementById('menu-current-mode')!.textContent = shared ? '현재 함께 보기 중' : '현재 혼자 보기 중';
|
||||
terrainComparison.hidden = coastal || shared;
|
||||
retryButton.hidden = !shared;
|
||||
time.setSharedClock(shared ? () => new Date(multiplayer.now()) : null);
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('mode', shared ? 'shared' : 'solo');
|
||||
history.replaceState(null, '', url);
|
||||
if (shared) {
|
||||
applyWeather({ status: 'loading', weather: null });
|
||||
join();
|
||||
} else {
|
||||
multiplayer.disconnect();
|
||||
world.setConnection(false, null);
|
||||
nickname.disabled = false;
|
||||
connectionStatus.textContent = '혼자 보기 · 날짜와 시간을 자유롭게 변경할 수 있습니다';
|
||||
weather.update(selectedDate);
|
||||
}
|
||||
};
|
||||
modeButton.addEventListener('click', () => {
|
||||
if (!sharedMode) {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('site', MULTIPLAYER.locationId);
|
||||
url.searchParams.set('mode', 'shared'); url.searchParams.delete('terrain'); url.searchParams.delete('profile');
|
||||
location.assign(url.href); return;
|
||||
}
|
||||
selectMode(false);
|
||||
}, { signal: viewEvents.signal });
|
||||
retryButton.addEventListener('click', join, { signal: viewEvents.signal });
|
||||
selectMode(sharedMode);
|
||||
if (import.meta.env.DEV && new URLSearchParams(location.search).has('profile')) {
|
||||
// Reproducible developer benchmark: fixed date, time and clear atmosphere.
|
||||
const query = new URLSearchParams(location.search);
|
||||
document.querySelector<HTMLInputElement>('#date')!.value = '2026-10-30';
|
||||
document.querySelector<HTMLInputElement>('#time-slider')!.value = query.get('time') === 'day' ? '43200' : '25200';
|
||||
document.querySelector<HTMLInputElement>('#time-slider')!.dispatchEvent(new Event('input'));
|
||||
weatherStatus.textContent = 'DEV benchmark · fixed clear weather';
|
||||
const { installRainGearPreview } = await import('./dev/rainGearPreview.ts');
|
||||
installRainGearPreview(sample => world.setWeather(sample));
|
||||
}
|
||||
status.textContent = coastal ? `${site.name} 3 × 3km · 30m급 DSM을 10m 간격으로 보간 · 영역 밖 육지 미반영`
|
||||
: reconstructSummit ? '사진 기반 추정 지형 · 반경 100m 보정 / 200m까지 연결 · 실측 아님'
|
||||
: '원본 30m DSM 비교 · 동일한 시작 위치 · 지역 보정 미적용';
|
||||
document.querySelector<HTMLFieldSetElement>('#time-controls')!.disabled = false;
|
||||
window.addEventListener('pagehide', (event) => {
|
||||
multiplayer.disconnect();
|
||||
if (!event.persisted) { dialogs.dispose(); viewEvents.abort(); height.dispose(); time.dispose(); weather.dispose(); multiplayer.dispose(); world.dispose(); }
|
||||
});
|
||||
window.addEventListener('pageshow', event => { if (event.persisted && sharedMode) join(); });
|
||||
options.ready?.();
|
||||
} catch (error) {
|
||||
status.textContent = '지형을 불러오지 못했습니다';
|
||||
const message = document.getElementById('render-error')!;
|
||||
message.hidden = false;
|
||||
message.textContent = `시뮬레이터를 시작하지 못했습니다. ${error instanceof Error ? error.message : ''} 지형 파일과 WebGL 2 지원을 확인하고 새로고침해 주세요.`;
|
||||
options.error?.(message.textContent);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { MULTIPLAYER } from '../constants/multiplayer.ts';
|
||||
import { createServerClock } from './clock.ts';
|
||||
import type { ClientEvents, ServerEvents, JoinRequest, RoomState, PlayerState, PlayerUpdate } from './protocol.ts';
|
||||
import type { WeatherStatus } from '../weather/weather.ts';
|
||||
|
||||
interface Callbacks {
|
||||
joined: (player: PlayerState) => void;
|
||||
snapshot: (state: RoomState, selfId: string) => void;
|
||||
correction: (state: PlayerState) => void;
|
||||
weather: (state: WeatherStatus) => void;
|
||||
status: (message: string, connected: boolean) => void;
|
||||
cleared: () => void;
|
||||
readPlayer: () => PlayerUpdate;
|
||||
}
|
||||
|
||||
export function createMultiplayerClient(callbacks: Callbacks) {
|
||||
const socket: Socket<ServerEvents, ClientEvents> = io({ autoConnect: false, timeout: MULTIPLAYER.ackTimeoutMs });
|
||||
const clock = createServerClock();
|
||||
let request: JoinRequest | null = null;
|
||||
let playerId = '';
|
||||
let sequence = -1;
|
||||
let lastPayload = '';
|
||||
let lastSentAt = 0;
|
||||
let generation = 0;
|
||||
const clear = () => { playerId = ''; sequence = -1; lastPayload = ''; callbacks.cleared(); };
|
||||
const snapshot = (state: RoomState) => {
|
||||
if (!playerId || state.roomId !== request?.roomId || state.sequence <= sequence) return;
|
||||
sequence = state.sequence;
|
||||
callbacks.snapshot(state, playerId);
|
||||
callbacks.status(`관악산 1번 방 · ${state.players.length}/${state.capacity}명`, true);
|
||||
};
|
||||
const syncClock = () => {
|
||||
if (!playerId || !socket.connected) return;
|
||||
const sentAt = performance.now(), epoch = generation;
|
||||
socket.timeout(MULTIPLAYER.ackTimeoutMs).emit('clock:sync', (error, serverTime) => {
|
||||
if (!error && epoch === generation && Number.isFinite(serverTime)) clock.synchronize(serverTime, sentAt);
|
||||
});
|
||||
};
|
||||
socket.on('connect', () => {
|
||||
if (!request) return;
|
||||
clear();
|
||||
const epoch = ++generation;
|
||||
socket.timeout(MULTIPLAYER.ackTimeoutMs).emit('join', request, (error, result) => {
|
||||
if (epoch !== generation || !request || !socket.connected) return;
|
||||
if (error || !result?.ok) {
|
||||
const code = !error && result && !result.ok ? result.code : null;
|
||||
const message = code === 'ROOM_FULL' ? '방이 가득 찼습니다 · 잠시 후 다시 입장해 주세요'
|
||||
: code === 'STORAGE_UNAVAILABLE' ? '접속 기록 서버에 연결할 수 없습니다 · 다시 입장해 주세요'
|
||||
: '입장하지 못했습니다 · 다시 입장하거나 혼자 보기를 선택해 주세요';
|
||||
socket.disconnect(); clear(); callbacks.status(message, false); return;
|
||||
}
|
||||
playerId = result.playerId;
|
||||
// Admission includes a database round trip: don't use its RTT to estimate clock offset.
|
||||
clock.synchronize(result.state.serverTime, performance.now());
|
||||
const self = result.state.players.find(p => p.id === playerId);
|
||||
if (self) callbacks.joined(self);
|
||||
snapshot(result.state);
|
||||
syncClock();
|
||||
});
|
||||
});
|
||||
socket.on('room:state', snapshot);
|
||||
socket.on('player:correction', state => { if (playerId === state.id) callbacks.correction(state); });
|
||||
socket.on('weather:state', state => { if (request) callbacks.weather(state); });
|
||||
socket.on('disconnect', () => {
|
||||
generation++; clear();
|
||||
if (request) callbacks.status('연결이 끊겼습니다 · 다시 연결 중…', false);
|
||||
});
|
||||
socket.on('connect_error', () => { if (request) callbacks.status('서버에 연결할 수 없습니다 · 다시 연결 중…', false); });
|
||||
const updateTimer = setInterval(() => {
|
||||
if (!playerId || !socket.connected) return;
|
||||
const state = callbacks.readPlayer();
|
||||
const payload = JSON.stringify(state);
|
||||
if (payload === lastPayload && performance.now() - lastSentAt < MULTIPLAYER.playerHeartbeatMs) return;
|
||||
socket.volatile.emit('player:update', state);
|
||||
lastPayload = payload; lastSentAt = performance.now();
|
||||
}, MULTIPLAYER.tickMs);
|
||||
const clockTimer = setInterval(syncClock, MULTIPLAYER.clockSyncMs);
|
||||
return {
|
||||
now: clock.now,
|
||||
connect(join: JoinRequest) {
|
||||
request = null; generation++; socket.disconnect(); clear();
|
||||
request = join;
|
||||
callbacks.status('관악산 공동 방에 연결 중…', false);
|
||||
socket.connect();
|
||||
},
|
||||
disconnect() { request = null; generation++; socket.disconnect(); clear(); },
|
||||
reset() {
|
||||
if (!playerId) return;
|
||||
const epoch = generation;
|
||||
socket.timeout(MULTIPLAYER.ackTimeoutMs).emit('player:reset', (error, state) => {
|
||||
if (epoch !== generation || error || !state) return;
|
||||
lastPayload = ''; callbacks.correction(state);
|
||||
});
|
||||
},
|
||||
dispose() {
|
||||
request = null; generation++; clearInterval(updateTimer); clearInterval(clockTimer);
|
||||
socket.removeAllListeners(); socket.disconnect(); clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function createServerClock(monotonic = () => performance.now()) {
|
||||
let anchor = Date.now();
|
||||
let receivedAt = monotonic();
|
||||
return {
|
||||
synchronize(serverTime: number, sentAt: number) {
|
||||
receivedAt = monotonic();
|
||||
anchor = serverTime + Math.max(0, receivedAt - sentAt) / 2;
|
||||
},
|
||||
now() { return anchor + monotonic() - receivedAt; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Emote } from '../controls/emotes.ts';
|
||||
import type { WeatherStatus } from '../weather/weather.ts';
|
||||
|
||||
export interface Equipment { umbrella: boolean; raincoat: boolean; hood: boolean }
|
||||
export interface PlayerUpdate {
|
||||
x: number;
|
||||
z: number;
|
||||
yaw: number;
|
||||
moving: boolean;
|
||||
emote: Emote;
|
||||
equipment: Equipment;
|
||||
}
|
||||
export interface PlayerState extends PlayerUpdate {
|
||||
id: string;
|
||||
nickname: string;
|
||||
updatedAt: number;
|
||||
emoteStartedAt: number;
|
||||
}
|
||||
export interface RoomState {
|
||||
locationId: string;
|
||||
roomId: string;
|
||||
capacity: number;
|
||||
sequence: number;
|
||||
serverTime: number;
|
||||
players: PlayerState[];
|
||||
}
|
||||
export interface JoinRequest { locationId: string; roomId: string; visitorId: string; nickname: string }
|
||||
export type JoinResult = { ok: true; playerId: string; state: RoomState }
|
||||
| { ok: false; code: 'ROOM_FULL' | 'INVALID_JOIN' | 'STORAGE_UNAVAILABLE' | 'JOIN_PENDING' };
|
||||
export interface ClientEvents {
|
||||
'join': (request: JoinRequest, reply: (result: JoinResult) => void) => void;
|
||||
'player:update': (state: PlayerUpdate) => void;
|
||||
'player:reset': (reply: (state: PlayerState | null) => void) => void;
|
||||
'clock:sync': (reply: (serverTime: number) => void) => void;
|
||||
}
|
||||
export interface ServerEvents {
|
||||
'room:state': (state: RoomState) => void;
|
||||
'player:correction': (state: PlayerState) => void;
|
||||
'weather:state': (state: WeatherStatus) => void;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type Object3D, MathUtils, Vector3 } from 'three';
|
||||
import type { Sheep } from '../scene/sheep.ts';
|
||||
import type { PlayerState } from './protocol.ts';
|
||||
import { MULTIPLAYER } from '../constants/multiplayer.ts';
|
||||
|
||||
export function createRemotePlayers(template: Sheep, scene: Object3D, heightAt: (x: number, z: number) => number) {
|
||||
const players = new Map<string, { sheep: Sheep; state: PlayerState; from: Vector3; yaw: number; elapsed: number }>();
|
||||
return {
|
||||
synchronize(states: PlayerState[], selfId: string) {
|
||||
const present = new Set(states.filter(p => p.id !== selfId).map(p => p.id));
|
||||
for (const [id, entry] of players) if (!present.has(id)) { entry.sheep.dispose(); players.delete(id); }
|
||||
for (const state of states) {
|
||||
if (state.id === selfId) continue;
|
||||
let entry = players.get(state.id);
|
||||
if (!entry) {
|
||||
const sheep = template.clone();
|
||||
sheep.group.name = state.nickname;
|
||||
sheep.group.position.set(state.x, heightAt(state.x, state.z), state.z);
|
||||
sheep.group.rotation.y = state.yaw;
|
||||
entry = { sheep, state, from: sheep.group.position.clone(), yaw: state.yaw, elapsed: 0 };
|
||||
players.set(state.id, entry);
|
||||
scene.add(sheep.group);
|
||||
} else if (state.updatedAt !== entry.state.updatedAt) {
|
||||
entry.from.copy(entry.sheep.group.position);
|
||||
entry.yaw = entry.sheep.group.rotation.y;
|
||||
entry.elapsed = 0;
|
||||
}
|
||||
entry.state = state;
|
||||
entry.sheep.setEquipment(state.equipment.umbrella, state.equipment.raincoat, state.equipment.hood);
|
||||
}
|
||||
},
|
||||
update(delta: number, serverTime: number) {
|
||||
let changed = false;
|
||||
for (const entry of players.values()) {
|
||||
const { sheep, state, from } = entry;
|
||||
const duration = MULTIPLAYER.interpolationMs / 1000;
|
||||
const interpolating = entry.elapsed < duration;
|
||||
entry.elapsed += Math.max(0, delta);
|
||||
const fraction = Math.min(1, entry.elapsed / duration);
|
||||
const x = MathUtils.lerp(from.x, state.x, fraction), z = MathUtils.lerp(from.z, state.z, fraction);
|
||||
sheep.group.position.set(x, heightAt(x, z), z);
|
||||
const angle = Math.atan2(Math.sin(state.yaw - entry.yaw), Math.cos(state.yaw - entry.yaw));
|
||||
sheep.group.rotation.y = entry.yaw + angle * fraction;
|
||||
const active = sheep.pose(state.emote, Math.max(0, serverTime - state.emoteStartedAt) / 1000,
|
||||
state.moving && serverTime - state.updatedAt < MULTIPLAYER.staleMovementMs, serverTime / 1000);
|
||||
changed = changed || interpolating || active;
|
||||
}
|
||||
return changed;
|
||||
},
|
||||
clear() { for (const entry of players.values()) entry.sheep.dispose(); players.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { RENDER_QUALITY as QUALITY } from '../constants/renderQuality.ts';
|
||||
import { frameSummary } from './frameSummary.ts';
|
||||
|
||||
export function renderPixelRatio(nativeDpr: number, cap: number, width: number, height: number, mobile: boolean) {
|
||||
const pixelCap = mobile ? Math.sqrt(QUALITY.mobilePixelBudget / Math.max(1, width * height)) : Infinity;
|
||||
return Math.min(nativeDpr, cap, pixelCap);
|
||||
}
|
||||
|
||||
/** Observes consecutive rendered frames only. Never controls simulation time or drops frames. */
|
||||
export function createAdaptiveResolution(maximum: number) {
|
||||
const levels = QUALITY.levels.filter(value => value < maximum);
|
||||
levels.push(maximum);
|
||||
let index = levels.length - 1;
|
||||
let previous: number | undefined, since = 0, settleUntil: number = QUALITY.settleMs, healthy = 0;
|
||||
let samples: number[] = [];
|
||||
const clear = () => { previous = undefined; samples = []; since = 0; healthy = 0; };
|
||||
return {
|
||||
cap: () => levels[index],
|
||||
reset(now: number) { clear(); settleUntil = now + QUALITY.settleMs; },
|
||||
restart(now: number) { index = levels.length - 1; clear(); settleUntil = now + QUALITY.settleMs; },
|
||||
observe(now: number, rendered: boolean) {
|
||||
if (!rendered) { clear(); return false; }
|
||||
if (now < settleUntil) { previous = now; return false; }
|
||||
if (previous === undefined) { previous = now; since = now; return false; }
|
||||
const interval = now - previous;
|
||||
previous = now;
|
||||
if (!since) since = now;
|
||||
samples.push(interval);
|
||||
if (now - since < QUALITY.windowMs || samples.length < QUALITY.minimumSamples) return false;
|
||||
const stats = frameSummary(samples);
|
||||
samples = []; since = now;
|
||||
let next = index;
|
||||
if (stats.p95Ms! > QUALITY.lowerP95Ms) { healthy = 0; next = Math.max(0, index - 1); }
|
||||
else if (stats.p95Ms! < QUALITY.raiseP95Ms && stats.meanMs! < QUALITY.raiseMeanMs) {
|
||||
if (++healthy >= QUALITY.healthyWindows) { next = Math.min(levels.length - 1, index + 1); healthy = 0; }
|
||||
} else healthy = 0;
|
||||
if (next === index) return false;
|
||||
index = next; clear(); settleUntil = now + QUALITY.settleMs;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Box3, BufferAttribute, BufferGeometry, Group, Sphere, Vector3, Vector4 } from 'three';
|
||||
import { BUILDINGS } from '../constants/buildings.ts';
|
||||
import { createBuildingMesh, finishBuildingScene } from './buildingScene.ts';
|
||||
import type { BuildingCounts, BuildingHeightSource } from './buildingScene.ts';
|
||||
import { restoreBuildingLightSpill } from './buildingLightSpill.ts';
|
||||
import type { TerrainMetadata } from './terrainGeometry.ts';
|
||||
|
||||
export interface BuildingAssetManifest {
|
||||
version: number;
|
||||
sourceHash: string;
|
||||
bufferHash: string;
|
||||
byteLength: number;
|
||||
terrain: Pick<TerrainMetadata, 'origin' | 'observerSurfaceElevation'>;
|
||||
counts: BuildingCounts;
|
||||
target: [number, number, number];
|
||||
meshes: {
|
||||
source: BuildingHeightSource;
|
||||
vertices: number;
|
||||
position: number;
|
||||
normal: number;
|
||||
lightHeight: number;
|
||||
index: number;
|
||||
indexCount: number;
|
||||
indexType: 'uint16' | 'uint32';
|
||||
box: [number, number, number, number, number, number];
|
||||
sphere: [number, number, number, number];
|
||||
}[];
|
||||
spill: { offset: number; size: number; bounds: [number, number, number, number] };
|
||||
}
|
||||
|
||||
export interface BuildingAssets { manifest: BuildingAssetManifest; buffer: ArrayBuffer }
|
||||
|
||||
export async function loadBuildingAssets(): Promise<BuildingAssets> {
|
||||
const response = await fetch(BUILDINGS.assetManifestUrl);
|
||||
if (!response.ok) throw new Error('건물 메타데이터를 불러오지 못했습니다.');
|
||||
const manifest = await response.json() as BuildingAssetManifest;
|
||||
if (manifest.version !== BUILDINGS.assetVersion) throw new Error('건물 파일 버전이 다릅니다. 건물을 다시 생성해 주세요.');
|
||||
const binary = await fetch(`${BUILDINGS.assetBufferUrl}?v=${manifest.bufferHash}`);
|
||||
if (!binary.ok) throw new Error('건물 버퍼를 불러오지 못했습니다.');
|
||||
const buffer = await binary.arrayBuffer();
|
||||
if (buffer.byteLength !== manifest.byteLength) throw new Error('건물 버퍼 크기가 올바르지 않습니다.');
|
||||
return { manifest, buffer };
|
||||
}
|
||||
|
||||
export function createBakedBuildings({ manifest, buffer }: BuildingAssets, terrain: TerrainMetadata) {
|
||||
if (manifest.version !== BUILDINGS.assetVersion || buffer.byteLength !== manifest.byteLength) {
|
||||
throw new Error('건물 파일 버전 또는 버퍼 크기가 올바르지 않습니다.');
|
||||
}
|
||||
if (manifest.terrain.origin.latitude !== terrain.origin.latitude || manifest.terrain.origin.longitude !== terrain.origin.longitude ||
|
||||
manifest.terrain.observerSurfaceElevation !== terrain.observerSurfaceElevation) {
|
||||
throw new Error('건물과 지형 기준점이 다릅니다. 건물을 다시 생성해 주세요.');
|
||||
}
|
||||
const group = new Group();
|
||||
for (const mesh of manifest.meshes) {
|
||||
const geometry = new BufferGeometry();
|
||||
// Views of the downloaded buffer: no extrusion, deduplication or array copies.
|
||||
geometry.setAttribute('position', new BufferAttribute(new Float32Array(buffer, mesh.position, mesh.vertices * 3), 3));
|
||||
geometry.setAttribute('normal', new BufferAttribute(new Float32Array(buffer, mesh.normal, mesh.vertices * 3), 3));
|
||||
geometry.setAttribute('buildingLightHeight', new BufferAttribute(new Float32Array(buffer, mesh.lightHeight, mesh.vertices), 1));
|
||||
const indices = mesh.indexType === 'uint32' ? new Uint32Array(buffer, mesh.index, mesh.indexCount) : new Uint16Array(buffer, mesh.index, mesh.indexCount);
|
||||
geometry.setIndex(new BufferAttribute(indices, 1));
|
||||
geometry.boundingBox = new Box3(new Vector3().fromArray(mesh.box), new Vector3().fromArray(mesh.box, 3));
|
||||
geometry.boundingSphere = new Sphere(new Vector3().fromArray(mesh.sphere), mesh.sphere[3]);
|
||||
group.add(createBuildingMesh(mesh.source, geometry));
|
||||
}
|
||||
const spill = restoreBuildingLightSpill(new Uint8Array(buffer, manifest.spill.offset, manifest.spill.size ** 2),
|
||||
manifest.spill.size, new Vector4().fromArray(manifest.spill.bounds));
|
||||
return finishBuildingScene(group, manifest.counts, new Vector3().fromArray(manifest.target), spill);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { DataTexture, LinearFilter, MeshStandardMaterial, RedFormat, Vector4 } from 'three';
|
||||
import { BUILDINGS } from '../constants/buildings.ts';
|
||||
|
||||
export interface BuildingLightSource { x: number; z: number; radius: number }
|
||||
|
||||
/** Static, approximate ground illumination. Does not simulate shadowed light transport. */
|
||||
export function createBuildingLightSpill(sources: BuildingLightSource[]) {
|
||||
const size = BUILDINGS.lighting.spillMapSize;
|
||||
let minX = 0, minZ = 0, maxX = 1, maxZ = 1;
|
||||
if (sources.length) {
|
||||
minX = minZ = Infinity;
|
||||
maxX = maxZ = -Infinity;
|
||||
for (const source of sources) {
|
||||
minX = Math.min(minX, source.x - source.radius);
|
||||
minZ = Math.min(minZ, source.z - source.radius);
|
||||
maxX = Math.max(maxX, source.x + source.radius);
|
||||
maxZ = Math.max(maxZ, source.z + source.radius);
|
||||
}
|
||||
}
|
||||
const width = maxX - minX, depth = maxZ - minZ;
|
||||
const accumulated = new Float32Array(size * size);
|
||||
for (const source of sources) {
|
||||
const left = Math.max(0, Math.floor((source.x - source.radius - minX) / width * size));
|
||||
const right = Math.min(size - 1, Math.ceil((source.x + source.radius - minX) / width * size));
|
||||
const top = Math.max(0, Math.floor((source.z - source.radius - minZ) / depth * size));
|
||||
const bottom = Math.min(size - 1, Math.ceil((source.z + source.radius - minZ) / depth * size));
|
||||
for (let row = top; row <= bottom; row++) {
|
||||
for (let col = left; col <= right; col++) {
|
||||
const x = minX + (col + 0.5) / size * width;
|
||||
const z = minZ + (row + 0.5) / size * depth;
|
||||
const distance = Math.hypot(x - source.x, z - source.z) / source.radius;
|
||||
const falloff = Math.max(0, 1 - distance * distance);
|
||||
accumulated[row * size + col] += falloff * falloff;
|
||||
}
|
||||
}
|
||||
}
|
||||
const pixels = new Uint8Array(accumulated.length);
|
||||
for (let i = 0; i < accumulated.length; i++) {
|
||||
pixels[i] = Math.round(255 * (1 - Math.exp(-accumulated[i])));
|
||||
}
|
||||
return restoreBuildingLightSpill(pixels, size, new Vector4(minX, minZ, width, depth));
|
||||
}
|
||||
|
||||
export function restoreBuildingLightSpill(pixels: Uint8Array, size: number, bounds: Vector4) {
|
||||
const texture = new DataTexture(pixels, size, size, RedFormat);
|
||||
texture.minFilter = texture.magFilter = LinearFilter;
|
||||
texture.needsUpdate = true;
|
||||
const intensity = { value: 0 };
|
||||
return { texture, bounds, intensity, applyTo(material: MeshStandardMaterial, facade = false) {
|
||||
const previous = material.onBeforeCompile;
|
||||
const previousKey = material.customProgramCacheKey();
|
||||
material.onBeforeCompile = (shader, renderer) => {
|
||||
previous.call(material, shader, renderer);
|
||||
shader.uniforms.buildingSpillMap = { value: texture };
|
||||
shader.uniforms.buildingSpillBounds = { value: bounds };
|
||||
shader.uniforms.buildingSpillIntensity = intensity;
|
||||
shader.vertexShader = 'varying vec2 vBuildingSpillPosition;\n' + shader.vertexShader;
|
||||
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>',
|
||||
'#include <begin_vertex>\nvBuildingSpillPosition = (modelMatrix * vec4(position, 1.0)).xz;');
|
||||
if (facade) {
|
||||
shader.vertexShader = 'attribute float buildingLightHeight;\nvarying vec2 vBuildingFacade;\n' + shader.vertexShader;
|
||||
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>',
|
||||
'#include <begin_vertex>\nvBuildingFacade = vec2(buildingLightHeight, 1.0 - step(0.5, abs(normal.y)));');
|
||||
shader.fragmentShader = 'varying vec2 vBuildingFacade;\n' + shader.fragmentShader;
|
||||
}
|
||||
shader.fragmentShader = `uniform sampler2D buildingSpillMap;
|
||||
uniform vec4 buildingSpillBounds;
|
||||
uniform float buildingSpillIntensity;
|
||||
varying vec2 vBuildingSpillPosition;\n` + shader.fragmentShader;
|
||||
shader.fragmentShader = shader.fragmentShader.replace('#include <opaque_fragment>', `
|
||||
vec2 spillUv = (vBuildingSpillPosition - buildingSpillBounds.xy) / buildingSpillBounds.zw;
|
||||
float inSpillArea = step(0.0, spillUv.x) * step(spillUv.x, 1.0)
|
||||
* step(0.0, spillUv.y) * step(spillUv.y, 1.0);
|
||||
float spill = texture2D(buildingSpillMap, spillUv).r * inSpillArea * buildingSpillIntensity;
|
||||
${facade ? `spill *= vBuildingFacade.y * (1.0 - smoothstep(0.0, ${BUILDINGS.lighting.facadeFadeHeight.toFixed(1)}, vBuildingFacade.x));` : ''}
|
||||
outgoingLight += diffuseColor.rgb * vec3(1.0, 0.86, 0.65) * spill;
|
||||
#include <opaque_fragment>
|
||||
`);
|
||||
};
|
||||
material.customProgramCacheKey = () => previousKey + '-building-spill-v3-' + facade;
|
||||
material.needsUpdate = true;
|
||||
}, dispose() { texture.dispose(); } };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BufferGeometry, Group, MathUtils, Mesh, MeshStandardMaterial, Vector3 } from 'three';
|
||||
import { BUILDINGS } from '../constants/buildings.ts';
|
||||
import type { restoreBuildingLightSpill } from './buildingLightSpill.ts';
|
||||
|
||||
export type BuildingHeightSource = keyof typeof BUILDINGS.colors;
|
||||
export type BuildingCounts = Record<BuildingHeightSource, number>;
|
||||
|
||||
export function createBuildingMesh(source: BuildingHeightSource, geometry: BufferGeometry) {
|
||||
const material = new MeshStandardMaterial({ color: BUILDINGS.colors[source], roughness: 0.9 });
|
||||
const mesh = new Mesh(geometry, material);
|
||||
mesh.name = `building-height-${source}`;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
export function finishBuildingScene(group: Group, counts: BuildingCounts, target: Vector3,
|
||||
spill: ReturnType<typeof restoreBuildingLightSpill>) {
|
||||
group.name = 'Gwacheon VWorld buildings';
|
||||
group.children.forEach(child => spill.applyTo((child as Mesh).material as MeshStandardMaterial, true));
|
||||
return { group, counts, target, spill, update(altitude: number) {
|
||||
const intensity = 1 - MathUtils.smoothstep(altitude,
|
||||
BUILDINGS.lighting.fadeStartAltitude, BUILDINGS.lighting.fadeEndAltitude);
|
||||
spill.intensity.value = intensity * BUILDINGS.lighting.spillIntensity;
|
||||
}, dispose() {
|
||||
spill.dispose();
|
||||
group.children.forEach(child => { const m = child as Mesh; m.geometry.dispose(); (m.material as MeshStandardMaterial).dispose(); });
|
||||
} };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { BufferAttribute, BufferGeometry, ExtrudeGeometry, Group, MathUtils, Path, Shape, Vector2 } from 'three';
|
||||
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
||||
import { BUILDINGS } from '../constants/buildings.ts';
|
||||
import { TERRAIN } from '../constants/terrain.ts';
|
||||
import { structurePosition } from './structures.ts';
|
||||
import { terrainHeightAt } from './terrainGeometry.ts';
|
||||
import type { loadTerrain } from './terrain.ts';
|
||||
import { createBuildingLightSpill } from './buildingLightSpill.ts';
|
||||
import type { BuildingLightSource } from './buildingLightSpill.ts';
|
||||
import { createBuildingMesh, finishBuildingScene } from './buildingScene.ts';
|
||||
|
||||
export interface BuildingFeature {
|
||||
id: string;
|
||||
properties: Record<string, string>;
|
||||
geometry: { type: 'Polygon'; coordinates: [number, number][][] };
|
||||
}
|
||||
export interface BuildingData { type: 'FeatureCollection'; features: BuildingFeature[] }
|
||||
|
||||
/** Exact attribute matching avoids tolerance-based changes to facade normals. */
|
||||
function indexBuildingGeometry(geometry: BufferGeometry) {
|
||||
const attributes = Object.entries(geometry.attributes);
|
||||
const unique = new Map<string, number>();
|
||||
const sourceVertices: number[] = [], indices: number[] = [];
|
||||
for (let vertex = 0; vertex < geometry.getAttribute('position').count; vertex++) {
|
||||
const values: number[] = [];
|
||||
for (const [, attribute] of attributes) {
|
||||
for (let component = 0; component < attribute.itemSize; component++) {
|
||||
values.push(attribute.array[vertex * attribute.itemSize + component]);
|
||||
}
|
||||
}
|
||||
const key = values.join(',');
|
||||
let index = unique.get(key);
|
||||
if (index === undefined) {
|
||||
index = sourceVertices.length;
|
||||
unique.set(key, index);
|
||||
sourceVertices.push(vertex);
|
||||
}
|
||||
indices.push(index);
|
||||
}
|
||||
const indexed = new BufferGeometry();
|
||||
for (const [name, attribute] of attributes) {
|
||||
const array = new Float32Array(sourceVertices.length * attribute.itemSize);
|
||||
sourceVertices.forEach((vertex, index) => {
|
||||
for (let component = 0; component < attribute.itemSize; component++) {
|
||||
array[index * attribute.itemSize + component] = attribute.array[vertex * attribute.itemSize + component];
|
||||
}
|
||||
});
|
||||
indexed.setAttribute(name, new BufferAttribute(array, attribute.itemSize));
|
||||
}
|
||||
indexed.setIndex(indices);
|
||||
return indexed;
|
||||
}
|
||||
|
||||
export function buildingHeight(tags: Record<string, string>) {
|
||||
const match = tags.height?.trim().match(/^(\d+(?:\.\d+)?)\s*(m|ft)?$/i);
|
||||
const height = match ? Number(match[1]) * (match[2]?.toLowerCase() === 'ft' ? 0.3048 : 1) : 0;
|
||||
if (height > 0 && height <= 400) return { meters: height, source: 'tagged' as const };
|
||||
const levels = Number(tags['building:levels']);
|
||||
if (levels > 0 && levels <= 100) return { meters: levels * BUILDINGS.metersPerLevel, source: 'levels' as const };
|
||||
return { meters: BUILDINGS.fallbackHeight[tags.building] ?? BUILDINGS.fallbackHeight.default, source: 'fallback' as const };
|
||||
}
|
||||
export async function loadBuildings(): Promise<BuildingData> {
|
||||
const response = await fetch(BUILDINGS.url);
|
||||
if (!response.ok) throw new Error('과천 건물 데이터를 불러오지 못했습니다.');
|
||||
const data = await response.json() as BuildingData;
|
||||
if (data.type !== 'FeatureCollection' || !Array.isArray(data.features) || data.features.some(f => f.geometry.type !== 'Polygon' || f.geometry.coordinates.some(r => r.length < 4 || r.some(c => c.length !== 2 || !c.every(Number.isFinite))))) throw new Error('건물 외곽선 데이터 형식이 올바르지 않습니다.');
|
||||
return data;
|
||||
}
|
||||
|
||||
export function createBuildings(data: BuildingData, terrain: Awaited<ReturnType<typeof loadTerrain>>) {
|
||||
const group = new Group();
|
||||
const geometries: Record<'tagged' | 'levels' | 'fallback', ExtrudeGeometry[]> = { tagged: [], levels: [], fallback: [] };
|
||||
const counts = { tagged: 0, levels: 0, fallback: 0 };
|
||||
const lightSources: BuildingLightSource[] = [];
|
||||
const surface = (x: number, z: number) => terrainHeightAt(terrain.metadata, terrain.heights, x, z) - terrain.metadata.observerSurfaceElevation - (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
|
||||
for (const feature of data.features) {
|
||||
const rings = feature.geometry.coordinates.map(ring => ring.slice(0, -1).map(([lon, lat]) => structurePosition(lat, lon, terrain.metadata.origin)));
|
||||
const outer = rings[0];
|
||||
const x = outer.reduce((sum, v) => sum + v.x, 0) / outer.length;
|
||||
const z = outer.reduce((sum, v) => sum + v.z, 0) / outer.length;
|
||||
// DSM can contain roofs. Use a low surrounding-surface proxy, not roof elevation + height.
|
||||
const radius = Math.max(BUILDINGS.baseSampleRadius, ...outer.map(v => Math.hypot(v.x - x, v.z - z) + 15));
|
||||
const samples = Array.from({ length: 8 }, (_, i) => surface(x + radius * Math.cos(i * Math.PI / 4), z + radius * Math.sin(i * Math.PI / 4)));
|
||||
const base = Math.min(surface(x, z), ...samples);
|
||||
const { meters, source } = buildingHeight(feature.properties);
|
||||
const shape = new Shape(outer.map(v => new Vector2(v.x - x, -(v.z - z))));
|
||||
for (const hole of rings.slice(1)) shape.holes.push(new Path(hole.map(v => new Vector2(v.x - x, -(v.z - z)))));
|
||||
const geometry = new ExtrudeGeometry(shape, { depth: meters, bevelEnabled: false, steps: 1 });
|
||||
const positions = geometry.getAttribute('position');
|
||||
const lightHeights = new Float32Array(positions.count);
|
||||
const unlit = ['greenhouse', 'roof', 'garage', 'garages', 'shed'].includes(feature.properties.building);
|
||||
if (!unlit) {
|
||||
// Approximate facade/entrance lighting along the footprint, not a city-wide circular wash.
|
||||
for (let edge = 0; edge < outer.length; edge++) {
|
||||
const a = outer[edge], b = outer[(edge + 1) % outer.length];
|
||||
const steps = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.z - a.z) / BUILDINGS.lighting.spillSpacing));
|
||||
for (let step = 0; step < steps; step++) {
|
||||
const t = (step + 0.5) / steps;
|
||||
lightSources.push({ x: MathUtils.lerp(a.x, b.x, t), z: MathUtils.lerp(a.z, b.z, t), radius: BUILDINGS.lighting.spillDistance });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
// Extrusion is still along Z. Exterior lighting only needs local height.
|
||||
lightHeights[i] = unlit ? BUILDINGS.lighting.facadeFadeHeight : positions.getZ(i);
|
||||
}
|
||||
geometry.setAttribute('buildingLightHeight', new BufferAttribute(lightHeights, 1));
|
||||
geometry.rotateX(-Math.PI / 2);
|
||||
geometry.translate(x, base, z);
|
||||
geometries[source].push(geometry);
|
||||
counts[source]++;
|
||||
}
|
||||
for (const source of ['tagged', 'levels', 'fallback'] as const) {
|
||||
if (!geometries[source].length) continue;
|
||||
const merged = mergeGeometries(geometries[source], false)!;
|
||||
geometries[source].forEach(g => g.dispose());
|
||||
// Lighting uses world XZ and local height, not UVs. Retain normals and
|
||||
// lighting heights when sharing vertices so facade/roof seams stay sharp.
|
||||
merged.deleteAttribute('uv');
|
||||
const indexed = indexBuildingGeometry(merged);
|
||||
merged.dispose();
|
||||
group.add(createBuildingMesh(source, indexed));
|
||||
}
|
||||
const target = structurePosition(BUILDINGS.target.latitude, BUILDINGS.target.longitude, terrain.metadata.origin);
|
||||
target.y = surface(target.x, target.z) + 20;
|
||||
const spill = createBuildingLightSpill(lightSources);
|
||||
return finishBuildingScene(group, counts, target, spill);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { WEATHER } from '../constants/weather.ts';
|
||||
|
||||
// One directional field for the sky and sun: the disc cannot shine through a
|
||||
// cloud drawn at the same angle. Static patterns keep CUSTOM time deterministic.
|
||||
export const cloudShader = /* glsl */ `
|
||||
float weatherHash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
float weatherNoise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(weatherHash(i), weatherHash(i + vec2(1, 0)), f.x),
|
||||
mix(weatherHash(i + vec2(0, 1)), weatherHash(i + vec2(1, 1)), f.x), f.y);
|
||||
}
|
||||
float weatherCloudField(vec3 direction) {
|
||||
// Bounded projection keeps texture visible at and just below the horizon.
|
||||
vec2 p = direction.xz / (max(direction.y, -0.06) + 0.35) * ${WEATHER.cloudScale.toFixed(2)};
|
||||
float value = 0.0, weight = 0.5;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
value += weight * weatherNoise(p);
|
||||
p = mat2(1.6, -1.2, 1.2, 1.6) * p + vec2(7.3, 2.8);
|
||||
weight *= 0.5;
|
||||
}
|
||||
// Fade detail only below the horizon; keep visible low clouds intact.
|
||||
return mix(0.5, value, smoothstep(-0.06, 0.0, direction.y));
|
||||
}
|
||||
float weatherCloudOpacity(vec3 direction, float coverage, float field) {
|
||||
float patches = smoothstep(1.0 - coverage - 0.12, 1.0 - coverage + 0.12, field);
|
||||
patches *= smoothstep(0.0, 0.15, coverage);
|
||||
float overcast = smoothstep(${WEATHER.overcastStart.toFixed(2)}, 1.0, coverage);
|
||||
// Full overcast is opaque, including the sunrise horizon; fade below it only.
|
||||
return mix(patches * ${WEATHER.cloudOpacity.toFixed(2)}, 1.0, overcast)
|
||||
* smoothstep(-0.18, -0.06, direction.y);
|
||||
}
|
||||
`;
|
||||
|
||||
export const skyCloudFragment = /* glsl */ `
|
||||
if (cloudCoverage > 0.0) {
|
||||
float field = weatherCloudField(direction);
|
||||
float opacity = weatherCloudOpacity(direction, cloudCoverage, field);
|
||||
float daylight = smoothstep(-0.12, 0.22, vSunDirection.y);
|
||||
float textureShade = smoothstep(0.15, 0.85, field);
|
||||
vec3 cloudColor = mix(vec3(0.24, 0.27, 0.31), vec3(0.68, 0.71, 0.75), textureShade);
|
||||
cloudColor *= mix(0.003, 1.8, daylight);
|
||||
// Diffuse warm dawn tint, rather than the clear sky's unoccluded solar glow.
|
||||
float dawn = (1.0 - smoothstep(0.0, 0.25, vSunDirection.y)) * daylight;
|
||||
cloudColor += vec3(0.09, 0.045, 0.018) * dawn;
|
||||
texColor = mix(texColor, cloudColor, opacity);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { MathUtils, Mesh, PlaneGeometry, ShaderMaterial, TextureLoader, Vector3, LinearFilter } from 'three';
|
||||
import type { MeshStandardMaterial, Texture } from 'three';
|
||||
import { COAST } from '../constants/coast.ts';
|
||||
import { TERRAIN } from '../constants/terrain.ts';
|
||||
import { harbourShader, isGisamun } from './harbour.ts';
|
||||
import { GISAMUN_HARBOUR } from '../constants/harbour.ts';
|
||||
|
||||
type Ring = [number, number][];
|
||||
export interface CoastData {
|
||||
origin: { latitude: number; longitude: number };
|
||||
radiusMeters: number;
|
||||
land: Ring[];
|
||||
beaches: Ring[];
|
||||
shorelines: Ring[];
|
||||
}
|
||||
function inRing(ring: Ring, x: number, z: number) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [ax, az] = ring[i], [bx, bz] = ring[j];
|
||||
if ((az > z) !== (bz > z) && x < (bx - ax) * (z - az) / (bz - az) + ax) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
export function isCoastalLand(data: CoastData, x: number, z: number) {
|
||||
return Math.abs(x) <= data.radiusMeters && Math.abs(z) <= data.radiusMeters && data.land.some(ring => inRing(ring, x, z));
|
||||
}
|
||||
export function seaHeight(x: number, z: number, originElevation: number) {
|
||||
return -originElevation - (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
|
||||
}
|
||||
export function createOceanGeometry(originElevation: number) {
|
||||
const geometry = new PlaneGeometry(COAST.oceanRadiusMeters * 2, COAST.oceanRadiusMeters * 2, COAST.oceanSegments, COAST.oceanSegments);
|
||||
geometry.rotateX(-Math.PI / 2);
|
||||
const positions = geometry.getAttribute('position');
|
||||
for (let i = 0; i < positions.count; i++) positions.setY(i, seaHeight(positions.getX(i), positions.getZ(i), originElevation));
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingSphere();
|
||||
return geometry;
|
||||
}
|
||||
export async function loadCoast(directory = '/terrain/hajodae', origin: CoastData['origin'] = COAST,
|
||||
lookTarget: CoastData['origin'] = COAST.cape): Promise<{ data: CoastData; texture: Texture; lookTarget: CoastData['origin'] }> {
|
||||
const response = await fetch(`${directory}/coast.json`);
|
||||
if (!response.ok) throw new Error('해안선을 불러오지 못했습니다.');
|
||||
const data = await response.json() as CoastData;
|
||||
if (data.origin.latitude !== origin.latitude || data.origin.longitude !== origin.longitude || data.radiusMeters !== COAST.radiusMeters) {
|
||||
throw new Error('해안선과 관측점 설정이 다릅니다. 해당 해안 데이터를 다시 생성해 주세요.');
|
||||
}
|
||||
const texture = await new TextureLoader().loadAsync(`${directory}/surface.png`);
|
||||
texture.minFilter = texture.magFilter = LinearFilter;
|
||||
texture.generateMipmaps = false;
|
||||
return { data, texture, lookTarget };
|
||||
}
|
||||
export type LoadedCoast = Awaited<ReturnType<typeof loadCoast>>;
|
||||
|
||||
/** Shared UV convention: PNG top row is north, local +Z points south. */
|
||||
const maskUv = `vec2((vCoastPosition.x + coastRadius + 1.0), (coastRadius - vCoastPosition.z + 1.0)) / (2.0 * coastRadius + 2.0)`;
|
||||
export function applyCoastalTerrain(material: MeshStandardMaterial, coast: LoadedCoast) {
|
||||
// Convert shader-edge coverage into MSAA samples while retaining opaque depth.
|
||||
material.alphaToCoverage = true;
|
||||
material.onBeforeCompile = shader => {
|
||||
shader.uniforms.coastSurface = { value: coast.texture };
|
||||
shader.uniforms.coastRadius = { value: coast.data.radiusMeters };
|
||||
shader.vertexShader = 'varying vec3 vCoastPosition;\n' + shader.vertexShader;
|
||||
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\nvCoastPosition = position;');
|
||||
shader.fragmentShader = 'uniform sampler2D coastSurface; uniform float coastRadius; varying vec3 vCoastPosition;\n' + harbourShader(isGisamun(coast.data.origin)) + '\n' + shader.fragmentShader;
|
||||
shader.fragmentShader = shader.fragmentShader.replace('#include <color_fragment>', `#include <color_fragment>
|
||||
vec3 surface = texture2D(coastSurface, ${maskUv}).rgb;
|
||||
// The mapped coastline footprint is rendered as a solid quay instead
|
||||
// of the underlying DSM surface.
|
||||
if (harbourLand(vCoastPosition.xz) > 0.0) discard;
|
||||
float shoreDistance = (surface.b - .5) * ${COAST.distanceRangeMeters * 2}.0;
|
||||
float shorePixel = max(fwidth(shoreDistance), .001);
|
||||
diffuseColor.a *= smoothstep(-.5 * shorePixel, .5 * shorePixel, shoreDistance);
|
||||
if (diffuseColor.a <= 0.0) discard;
|
||||
float grainFilter = exp(-dot(fwidth(vCoastPosition.xz), vec2(1.5)));
|
||||
float grain = sin(vCoastPosition.x * 1.9) * sin(vCoastPosition.z * 2.3) * 0.025 * grainFilter;
|
||||
float wet = 1.0 - smoothstep(0.0, 5.0, shoreDistance);
|
||||
vec3 sand = mix(vec3(.59,.46,.29),vec3(.30,.26,.19),wet*.7);
|
||||
diffuseColor.rgb = mix(diffuseColor.rgb, sand + grain, surface.g);
|
||||
`);
|
||||
};
|
||||
material.customProgramCacheKey = () => `coast-harbour-v4-${isGisamun(coast.data.origin)}`;
|
||||
}
|
||||
|
||||
export function createOcean(coast: LoadedCoast, originElevation: number) {
|
||||
const uniforms = {
|
||||
coastSurface: { value: coast.texture }, coastRadius: { value: coast.data.radiusMeters },
|
||||
elapsed: { value: 0 }, sunDirection: { value: new Vector3(1, 0.2, 0) }, daylight: { value: 1 },
|
||||
};
|
||||
const material = new ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: `varying vec3 vCoastPosition;
|
||||
void main() {
|
||||
vCoastPosition = position;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `uniform sampler2D coastSurface; uniform float coastRadius;
|
||||
uniform float elapsed; uniform vec3 sunDirection; uniform float daylight;
|
||||
varying vec3 vCoastPosition;
|
||||
${harbourShader(isGisamun(coast.data.origin))}
|
||||
float hash(vec2 p) { return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); }
|
||||
float noise(vec2 p) {
|
||||
vec2 i=floor(p), f=fract(p); f=f*f*f*(f*(f*6.0-15.0)+10.0);
|
||||
return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);
|
||||
}
|
||||
void main() {
|
||||
vec2 uv = ${maskUv};
|
||||
bool inCrop = max(abs(vCoastPosition.x),abs(vCoastPosition.z)) <= coastRadius;
|
||||
float shoreDistance = (texture2D(coastSurface, uv).b - .5) * ${COAST.distanceRangeMeters * 2}.0;
|
||||
// Keep continuous water beneath the terrain's partially covered MSAA
|
||||
// samples. Complementary cutouts would leave holes at the shoreline.
|
||||
vec2 p = vCoastPosition.xz;
|
||||
float shelter = smoothstep(0.0, ${GISAMUN_HARBOUR.entranceBlendMeters.toFixed(1)}, harbourBasin(p));
|
||||
vec3 view = normalize(cameraPosition - vCoastPosition);
|
||||
float range = length(cameraPosition.xz - p);
|
||||
// Fade frequencies smaller than a screen pixel to avoid distant moire.
|
||||
float footprint = max(length(dFdx(p)), length(dFdy(p)));
|
||||
float ripple = exp(-range / 1200.0) * (1.0-smoothstep(.25,1.0,footprint*.37));
|
||||
float fineRipple = exp(-range / 1200.0) * (1.0-smoothstep(.25,1.0,footprint*1.9));
|
||||
ripple *= mix(1.0, ${GISAMUN_HARBOUR.shelterWaveScale}, shelter);
|
||||
fineRipple *= mix(1.0, ${GISAMUN_HARBOUR.shelterWaveScale}, shelter);
|
||||
vec2 flow = vec2(elapsed*.18,elapsed*.07);
|
||||
vec2 waves = vec2(noise(p*.32+flow),noise(p*.37-flow+19.7))-.5;
|
||||
vec2 detail = vec2(noise(p*1.7-flow*2.0),noise(p*1.9+flow*1.4+41.3))-.5;
|
||||
vec3 n = normalize(vec3(waves.x*.12*ripple+detail.x*.05*fineRipple,1.0,
|
||||
waves.y*.12*ripple+detail.y*.05*fineRipple));
|
||||
float fresnel = pow(1.0-max(dot(view,n),0.0),4.0);
|
||||
vec3 deep = vec3(.015,.115,.15);
|
||||
vec3 sky = mix(vec3(.24,.33,.40), vec3(.42,.48,.51), smoothstep(800.0,14000.0,range));
|
||||
vec3 color = mix(deep,sky,fresnel*.85) * daylight;
|
||||
vec3 halfVector = normalize(view + sunDirection);
|
||||
float specular = pow(max(dot(n,halfVector),0.0),190.0);
|
||||
float sunVisible = smoothstep(-.015,.025,sunDirection.y);
|
||||
vec3 sunColor = mix(vec3(1.0,.34,.08),vec3(1.0,.94,.76),smoothstep(0.0,.4,sunDirection.y));
|
||||
color += sunColor * specular * sunVisible * 2.4;
|
||||
if (inCrop) {
|
||||
float wash = .9+.55*sin(elapsed*.8+noise(p*.08)*2.0);
|
||||
float foam = exp(-pow((shoreDistance+wash)/.7,2.0)) * (.16+.28*noise(p*1.4-flow));
|
||||
foam *= exp(-footprint*.2);
|
||||
// Do not draw a sandy swash line along vertical harbour walls.
|
||||
foam *= smoothstep(0.0, 4.0, -harbourLand(p)) * (1.0-shelter);
|
||||
color = mix(color,vec3(.64,.74,.73)*daylight,foam);
|
||||
}
|
||||
gl_FragColor = vec4(color,1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}`,
|
||||
});
|
||||
const mesh = new Mesh(createOceanGeometry(originElevation), material);
|
||||
mesh.name = 'Coastal sea (visual waves, no tide model)';
|
||||
return { mesh,
|
||||
animate(seconds: number) { uniforms.elapsed.value = seconds; },
|
||||
update(direction: Vector3, altitude: number) {
|
||||
uniforms.sunDirection.value.copy(direction);
|
||||
uniforms.daylight.value = MathUtils.lerp(0.025, 1, MathUtils.smoothstep(altitude, -10, 10));
|
||||
},
|
||||
dispose() { mesh.geometry.dispose(); material.dispose(); coast.texture.dispose(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Nearest-rank percentile; long foreground frames must remain in the sample. */
|
||||
export function frameSummary(intervals: readonly number[]) {
|
||||
const sorted = [...intervals].sort((a, b) => a - b);
|
||||
const total = sorted.reduce((sum, value) => sum + value, 0);
|
||||
return {
|
||||
samples: sorted.length,
|
||||
fps: total > 0 ? sorted.length * 1000 / total : null,
|
||||
meanMs: sorted.length ? total / sorted.length : null,
|
||||
p95Ms: sorted.length ? sorted[Math.ceil(sorted.length * 0.95) - 1] : null,
|
||||
maxMs: sorted.length ? sorted[sorted.length - 1] : null,
|
||||
overBudget: sorted.filter(value => value > 1000 / 30 + 0.1).length,
|
||||
over50Ms: sorted.filter(value => value > 50).length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BoxGeometry, ExtrudeGeometry, Group, Mesh, MeshStandardMaterial, Shape, Vector2 } from 'three';
|
||||
import { GISAMUN_HARBOUR as PORT } from '../constants/harbour.ts';
|
||||
import { GISAMUN } from '../constants/gisamun.ts';
|
||||
import { TERRAIN } from '../constants/terrain.ts';
|
||||
|
||||
type Ring = readonly (readonly [number, number])[];
|
||||
export function isGisamun(origin: { latitude: number; longitude: number }) {
|
||||
return origin.latitude === GISAMUN.latitude && origin.longitude === GISAMUN.longitude;
|
||||
}
|
||||
export function insideHarbour(ring: Ring, x: number, z: number) {
|
||||
let inside = false;
|
||||
for (let i=0,j=ring.length-1;i<ring.length;j=i++) {
|
||||
const [ax,az]=ring[i], [bx,bz]=ring[j];
|
||||
if ((az>z)!==(bz>z) && x<(bx-ax)*(z-az)/(bz-az)+ax) inside=!inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
// Same vector outlines drive geometry, terrain replacement and wave shelter.
|
||||
export function harbourShader(enabled: boolean) {
|
||||
if (!enabled) return 'float harbourLand(vec2 p){return -10000.;} float harbourBasin(vec2 p){return -10000.;}';
|
||||
return ([['harbourLand',PORT.land],['harbourBasin',PORT.basin]] as const).map(([name,ring]) => `
|
||||
float ${name}(vec2 p) {
|
||||
vec2 points[${ring.length}] = vec2[${ring.length}](${ring.map(([x,z])=>`vec2(${x.toFixed(3)},${z.toFixed(3)})`).join(',')});
|
||||
float distanceToEdge=10000.; bool inside=false;
|
||||
for(int i=0,j=${ring.length-1};i<${ring.length};j=i++) {
|
||||
vec2 a=points[i], b=points[j], e=b-a;
|
||||
distanceToEdge=min(distanceToEdge,length(p-a-e*clamp(dot(p-a,e)/dot(e,e),0.,1.)));
|
||||
if ((a.y>p.y)!=(b.y>p.y)) {
|
||||
if(p.x<(b.x-a.x)*(p.y-a.y)/(b.y-a.y)+a.x) inside=!inside;
|
||||
}
|
||||
}
|
||||
return inside ? distanceToEdge : -distanceToEdge;
|
||||
}`).join('\n');
|
||||
}
|
||||
|
||||
export function createHarbour(originElevation: number) {
|
||||
const group=new Group(); group.name='Gisamun harbour (estimated heights)';
|
||||
const top=new MeshStandardMaterial({color:PORT.topColor,roughness:.95});
|
||||
const wall=new MeshStandardMaterial({color:PORT.wallColor,roughness:1});
|
||||
const shape=new Shape(PORT.land.map(([x,z])=>new Vector2(x,-z)));
|
||||
const geometry=new ExtrudeGeometry(shape,{depth:PORT.topElevation-PORT.bottomElevation,bevelEnabled:false,steps:1,curveSegments:1});
|
||||
geometry.rotateX(-Math.PI/2);
|
||||
geometry.translate(0,PORT.bottomElevation-originElevation,0);
|
||||
const p=geometry.getAttribute('position');
|
||||
for(let i=0;i<p.count;i++) p.setY(i,p.getY(i)-(p.getX(i)**2+p.getZ(i)**2)/(2*TERRAIN.earthRadiusMeters));
|
||||
geometry.computeVertexNormals();
|
||||
const apron=new Mesh(geometry,[top,wall]); apron.name='Breakwaters and quay'; group.add(apron);
|
||||
for(let i=1;i<PORT.parapet.length;i++) {
|
||||
const [ax,az]=PORT.parapet[i-1], [bx,bz]=PORT.parapet[i];
|
||||
const x=(ax+bx)/2,z=(az+bz)/2;
|
||||
const mesh=new Mesh(new BoxGeometry(PORT.parapetWidth,PORT.parapetHeight,Math.hypot(bx-ax,bz-az)),wall);
|
||||
mesh.position.set(x,PORT.topElevation-originElevation+PORT.parapetHeight/2-(x*x+z*z)/(2*TERRAIN.earthRadiusMeters),z);
|
||||
mesh.rotation.y=Math.atan2(bx-ax,bz-az); group.add(mesh);
|
||||
}
|
||||
return {group,
|
||||
heightAt(x:number,z:number) {return insideHarbour(PORT.land,x,z) ? PORT.topElevation-originElevation-(x*x+z*z)/(2*TERRAIN.earthRadiusMeters) : undefined;},
|
||||
dispose() {group.traverse(o=>{if(o instanceof Mesh)o.geometry.dispose();});top.dispose();wall.dispose();},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BackSide, Color, Group, MathUtils, Mesh, MeshBasicMaterial, SphereGeometry, Vector3 } from 'three';
|
||||
import { SCENE } from '../constants/scene.ts';
|
||||
import { TERRAIN } from '../constants/terrain.ts';
|
||||
|
||||
export function createHorizon(eye: Vector3, surfaceElevation: number) {
|
||||
const group = new Group();
|
||||
// Outside the cropped DSM, use a simplified sea-level Earth horizon,
|
||||
// never an invented continuation of terrain. No visible 0° guide line.
|
||||
group.position.copy(eye);
|
||||
// Match the terrain's distant fog, and feather only the visible color at the rim.
|
||||
// Keep depth writes even at zero alpha: the sea-level horizon must still hide the sun.
|
||||
const rim = { value: 0 };
|
||||
const groundMaterial = new MeshBasicMaterial({ color: '#0b111a', side: BackSide, fog: true, transparent: true, depthWrite: true });
|
||||
groundMaterial.onBeforeCompile = shader => {
|
||||
shader.uniforms.horizonRim = rim;
|
||||
shader.uniforms.horizonBlend = { value: Math.sin(MathUtils.degToRad(SCENE.horizonBlendDegrees)) };
|
||||
shader.vertexShader = 'varying vec3 vHorizonDirection;\n' + shader.vertexShader;
|
||||
shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\nvHorizonDirection = position;');
|
||||
shader.fragmentShader = 'varying vec3 vHorizonDirection;\nuniform float horizonRim;\nuniform float horizonBlend;\n' + shader.fragmentShader;
|
||||
shader.fragmentShader = shader.fragmentShader.replace('#include <fog_fragment>', `#include <fog_fragment>
|
||||
float belowRim = horizonRim - normalize(vHorizonDirection).y;
|
||||
gl_FragColor.a *= smoothstep(0.0, horizonBlend, belowRim);`);
|
||||
};
|
||||
const groundGeometry = (height: number) => {
|
||||
const dip = Math.acos(TERRAIN.earthRadiusMeters / (TERRAIN.earthRadiusMeters + surfaceElevation + height));
|
||||
rim.value = -Math.sin(dip);
|
||||
return new SphereGeometry(SCENE.horizonRadius, 96, 32, 0, Math.PI * 2, Math.PI / 2 + dip, Math.PI / 2 - dip);
|
||||
};
|
||||
const ground = new Mesh(groundGeometry(eye.y), groundMaterial);
|
||||
let lastHeight = eye.y;
|
||||
group.add(ground);
|
||||
const night = new Color('#070c15');
|
||||
const day = new Color('#26343e');
|
||||
return {
|
||||
group,
|
||||
move(position: Vector3, elevation: number, eyeHeight: number) {
|
||||
group.position.copy(position);
|
||||
if (Math.abs(surfaceElevation + lastHeight - elevation - eyeHeight) > 0.5) {
|
||||
surfaceElevation = elevation;
|
||||
lastHeight = eyeHeight;
|
||||
ground.geometry.dispose();
|
||||
ground.geometry = groundGeometry(eyeHeight);
|
||||
}
|
||||
},
|
||||
setEyeHeight(meters: number) {
|
||||
group.position.y = meters;
|
||||
ground.geometry.dispose();
|
||||
ground.geometry = groundGeometry(meters);
|
||||
},
|
||||
update(altitude: number) {
|
||||
groundMaterial.color.copy(night).lerp(day, MathUtils.smoothstep(altitude, -8, 15));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ACESFilmicToneMapping, Box3, Color, Fog, HemisphereLight, MathUtils, OrthographicCamera, PerspectiveCamera, Scene, Vector3, WebGLRenderer, Mesh } from 'three';
|
||||
import { getMoonPosition } from 'suncalc';
|
||||
import { loadSheep } from './sheep.ts';
|
||||
import { createTerrain, loadTerrain } from './terrain.ts';
|
||||
import { createSky } from './sky.ts';
|
||||
import { createSun } from './sun.ts';
|
||||
import { createNightSky } from './nightSky.ts';
|
||||
import { calculateSun } from '../astronomy/sunCalculator.ts';
|
||||
import { directionFromAngles } from '../astronomy/direction.ts';
|
||||
import { summitPosition } from '../controls/summitCoordinates.ts';
|
||||
import { LOCATIONS } from '../constants/locations.ts';
|
||||
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
|
||||
import { SCENE } from '../constants/scene.ts';
|
||||
import { TERRAIN } from '../constants/terrain.ts';
|
||||
import { LOBBY_CHARACTER, LOBBY_SUNRISE } from '../constants/lobby.ts';
|
||||
|
||||
/** Same Gwanaksan DSM, summit correction, sky and solar disc as the observation scene. */
|
||||
export function createLobbySunrise(canvas: HTMLCanvasElement) {
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
const ready = (async () => {
|
||||
const site = LOCATIONS.gwanaksan;
|
||||
const [data, sheep] = await Promise.all([loadTerrain(site.terrain, site.observer), loadSheep()]);
|
||||
if (disposed) { sheep.dispose(); return; }
|
||||
const renderer = new WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.toneMapping = ACESFilmicToneMapping;
|
||||
const terrain = createTerrain(data, undefined, true);
|
||||
const scene = new Scene();
|
||||
const camera = new PerspectiveCamera(LOBBY_SUNRISE.fieldOfView, 1, SCENE.near, SCENE.far);
|
||||
const start = summitPosition(SUMMIT_TERRAIN.start.latitude, SUMMIT_TERRAIN.start.longitude, data.metadata.origin);
|
||||
const ground = terrain.heightAt(start.x, start.z);
|
||||
camera.position.set(start.x, ground + site.observer.eyeHeight, start.z);
|
||||
camera.lookAt(camera.position.clone().add(new Vector3(...directionFromAngles(LOBBY_SUNRISE.heading, LOBBY_SUNRISE.pitch))));
|
||||
const observer = { ...site.observer, ...SUMMIT_TERRAIN.start, elevation: ground + data.metadata.observerSurfaceElevation };
|
||||
const sky = createSky();
|
||||
const sun = createSun(camera.position);
|
||||
const nightSky = createNightSky(camera.position);
|
||||
const ambient = new HemisphereLight('#c7def0', '#3e4235', 1.5);
|
||||
const fog = new Fog('#060b15', TERRAIN.fogNear, TERRAIN.fogFar);
|
||||
const night = new Color('#060b15'), dawn = new Color('#383e38'), day = new Color('#a6b6bc');
|
||||
scene.fog = fog;
|
||||
scene.add(terrain.mesh, sky.mesh, sun.group, sun.light, sun.light.target, nightSky.group, ambient);
|
||||
const direction = new Vector3();
|
||||
// A foreground pass keeps the existing model in the lower-left on every aspect ratio.
|
||||
const foreground = new Scene();
|
||||
sheep.group.rotation.y = MathUtils.degToRad(LOBBY_CHARACTER.heading);
|
||||
const bounds = new Box3();
|
||||
sheep.group.updateMatrixWorld(true);
|
||||
sheep.group.traverseVisible(object => {
|
||||
if (!(object instanceof Mesh)) return;
|
||||
object.geometry.computeBoundingBox();
|
||||
bounds.union(object.geometry.boundingBox!.clone().applyMatrix4(object.matrixWorld));
|
||||
});
|
||||
const characterHeight = bounds.max.y - bounds.min.y;
|
||||
sheep.group.position.y -= bounds.min.y;
|
||||
const halfWidth = characterHeight * (LOBBY_CHARACTER.frameTop - LOBBY_CHARACTER.frameBottom) / 2;
|
||||
const characterCamera = new OrthographicCamera(-halfWidth, halfWidth,
|
||||
characterHeight * LOBBY_CHARACTER.frameTop, characterHeight * LOBBY_CHARACTER.frameBottom, 0.1, 20);
|
||||
characterCamera.position.z = 5;
|
||||
foreground.add(sheep.group);
|
||||
const head = sheep.group.getObjectByName('Head')!;
|
||||
const headRest = head.quaternion.clone();
|
||||
let progress = matchMedia('(prefers-reduced-motion: reduce)').matches ? 1 : 0;
|
||||
let frame = 0;
|
||||
let elapsed = 0;
|
||||
let lastFrame = 0;
|
||||
const render = () => {
|
||||
const date = new Date(LOBBY_SUNRISE.from + (LOBBY_SUNRISE.to - LOBBY_SUNRISE.from) * progress);
|
||||
const state = calculateSun(date, observer);
|
||||
direction.set(...directionFromAngles(state.azimuth, state.altitude));
|
||||
sky.update(direction, state.altitude);
|
||||
sun.update(direction, state.altitude);
|
||||
nightSky.update(date, observer, state.altitude);
|
||||
const moonAltitude = getMoonPosition(date, observer.latitude, observer.longitude).altitude;
|
||||
const gazeAltitude = MathUtils.lerp(moonAltitude, state.altitude, MathUtils.smoothstep(state.altitude, -8, 0));
|
||||
head.quaternion.copy(headRest);
|
||||
head.rotateX(-MathUtils.degToRad(MathUtils.clamp(gazeAltitude, 8, 28)));
|
||||
sheep.updateLighting(Math.max(LOBBY_CHARACTER.minimumLightingAltitude, state.altitude), 1);
|
||||
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(state.altitude, -8, 15));
|
||||
fog.color.copy(night).lerp(dawn, MathUtils.smoothstep(state.altitude, -8, 0));
|
||||
fog.color.lerp(day, MathUtils.smoothstep(state.altitude, 0, 15));
|
||||
renderer.toneMappingExposure = MathUtils.lerp(0.12, LOBBY_SUNRISE.maxExposure, MathUtils.smoothstep(state.altitude, -12, 5));
|
||||
renderer.render(scene, camera);
|
||||
const { clientWidth: width, clientHeight: height } = canvas;
|
||||
const size = Math.min(LOBBY_CHARACTER.maxSize, width * LOBBY_CHARACTER.widthFraction, height * LOBBY_CHARACTER.heightFraction);
|
||||
renderer.autoClear = false;
|
||||
renderer.clearDepth();
|
||||
renderer.setViewport(width * LOBBY_CHARACTER.marginFraction, height * LOBBY_CHARACTER.marginFraction, size, size);
|
||||
renderer.render(foreground, characterCamera);
|
||||
renderer.setViewport(0, 0, width, height);
|
||||
renderer.autoClear = true;
|
||||
};
|
||||
const resize = () => {
|
||||
const { clientWidth: width, clientHeight: height } = canvas;
|
||||
camera.aspect = width / Math.max(1, height);
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, LOBBY_SUNRISE.maxPixelRatio));
|
||||
renderer.setSize(width, height, false);
|
||||
render();
|
||||
};
|
||||
const tick = (now: number) => {
|
||||
if (disposed) return;
|
||||
if (!document.hidden && lastFrame) elapsed += now - lastFrame;
|
||||
lastFrame = now;
|
||||
progress = MathUtils.clamp((elapsed - LOBBY_SUNRISE.nightHoldMs) / LOBBY_SUNRISE.durationMs, 0, 1);
|
||||
if (!document.hidden) render();
|
||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
const visible = () => { lastFrame = 0; if (!document.hidden) render(); };
|
||||
cleanup = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', resize);
|
||||
document.removeEventListener('visibilitychange', visible);
|
||||
terrain.dispose();
|
||||
nightSky.dispose();
|
||||
sheep.dispose();
|
||||
sky.mesh.geometry.dispose(); sky.mesh.material.dispose();
|
||||
sun.group.traverse(object => {
|
||||
if (object instanceof Mesh) {
|
||||
object.geometry.dispose();
|
||||
for (const material of Array.isArray(object.material) ? object.material : [object.material]) material.dispose();
|
||||
}
|
||||
});
|
||||
renderer.dispose(); renderer.forceContextLoss();
|
||||
};
|
||||
window.addEventListener('resize', resize);
|
||||
document.addEventListener('visibilitychange', visible);
|
||||
resize();
|
||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||
})();
|
||||
return { ready, dispose() { disposed = true; cleanup?.(); cleanup = undefined; } };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { BufferGeometry, Float32BufferAttribute, Group, MathUtils, Mesh, PlaneGeometry, Points, ShaderMaterial, Vector3 } from 'three';
|
||||
import { getMoonIllumination, getMoonPosition } from 'suncalc';
|
||||
import type { ObserverConfig } from '../config/observer.ts';
|
||||
import { directionFromAngles } from '../astronomy/direction.ts';
|
||||
import { NIGHT_SKY } from '../constants/nightSky.ts';
|
||||
import { cloudShader } from './clouds.ts';
|
||||
|
||||
/** Decorative, deterministic stars; lunar position and phase follow the selected time. */
|
||||
export function createNightSky(eye: Vector3) {
|
||||
const group = new Group();
|
||||
const coverage = { value: 0 };
|
||||
const visibility = { value: 0 };
|
||||
const moonVisibility = { value: 0 };
|
||||
let seed: number = NIGHT_SKY.seed;
|
||||
const random = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
|
||||
const positions: number[] = [], sizes: number[] = [], brightness: number[] = [];
|
||||
for (let i = 0; i < NIGHT_SKY.starCount; i++) {
|
||||
const y = random(), azimuth = random() * Math.PI * 2;
|
||||
const radius = Math.sqrt(1 - y * y);
|
||||
positions.push(radius * Math.cos(azimuth) * NIGHT_SKY.distance, y * NIGHT_SKY.distance,
|
||||
radius * Math.sin(azimuth) * NIGHT_SKY.distance);
|
||||
sizes.push(1.2 + Math.pow(random(), 3) * 1.8);
|
||||
brightness.push(0.35 + Math.pow(random(), 2) * 0.65);
|
||||
}
|
||||
const starGeometry = new BufferGeometry();
|
||||
starGeometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
|
||||
starGeometry.setAttribute('size', new Float32BufferAttribute(sizes, 1));
|
||||
starGeometry.setAttribute('brightness', new Float32BufferAttribute(brightness, 1));
|
||||
const starMaterial = new ShaderMaterial({
|
||||
uniforms: { coverage, visibility, pixelRatio: { value: 1 } },
|
||||
transparent: true, depthWrite: false, toneMapped: false,
|
||||
vertexShader: `attribute float size; attribute float brightness;
|
||||
uniform float pixelRatio; varying float vBrightness; varying vec3 vDirection;
|
||||
void main() {
|
||||
vBrightness = brightness; vDirection = normalize(position);
|
||||
gl_Position = projectionMatrix * viewMatrix * vec4(cameraPosition + position, 1.0);
|
||||
gl_PointSize = size * pixelRatio;
|
||||
}`,
|
||||
fragmentShader: `${cloudShader}
|
||||
uniform float coverage; uniform float visibility;
|
||||
varying float vBrightness; varying vec3 vDirection;
|
||||
void main() {
|
||||
float disc = 1.0 - smoothstep(0.1, 0.5, length(gl_PointCoord - 0.5));
|
||||
float cloud = weatherCloudOpacity(vDirection, coverage, weatherCloudField(vDirection));
|
||||
float horizon = smoothstep(0.0, 0.12, vDirection.y);
|
||||
gl_FragColor = vec4(vec3(0.83, 0.9, 1.0) * ${NIGHT_SKY.starRadiance.toFixed(1)}, disc * vBrightness * visibility * horizon * (1.0 - cloud));
|
||||
#include <colorspace_fragment>
|
||||
}`,
|
||||
});
|
||||
const stars = new Points(starGeometry, starMaterial);
|
||||
stars.frustumCulled = false;
|
||||
stars.renderOrder = 1;
|
||||
stars.onBeforeRender = renderer => { starMaterial.uniforms.pixelRatio.value = renderer.getPixelRatio(); };
|
||||
const moonMaterial = new ShaderMaterial({
|
||||
uniforms: { coverage, moonVisibility, lightDirection: { value: new Vector3() } },
|
||||
transparent: true, depthWrite: false,
|
||||
vertexShader: `varying vec2 vUv; varying vec3 vDirection;
|
||||
void main() { vUv = uv;
|
||||
vec4 world = modelMatrix * vec4(position, 1.0);
|
||||
vDirection = normalize(world.xyz - cameraPosition);
|
||||
gl_Position = projectionMatrix * viewMatrix * world;
|
||||
}`,
|
||||
fragmentShader: `${cloudShader}
|
||||
uniform float coverage; uniform float moonVisibility; uniform vec3 lightDirection;
|
||||
varying vec2 vUv; varying vec3 vDirection;
|
||||
void main() {
|
||||
vec2 p = (vUv - 0.5) * 2.0;
|
||||
float r2 = dot(p, p); if (r2 > 1.0) discard;
|
||||
vec3 normal = vec3(p, sqrt(max(0.0, 1.0 - r2)));
|
||||
float light = smoothstep(-0.015, 0.035, dot(normal, lightDirection));
|
||||
float shading = 0.65 + 0.35 * normal.z;
|
||||
float cloud = weatherCloudOpacity(vDirection, coverage, weatherCloudField(vDirection));
|
||||
gl_FragColor = vec4(vec3(0.91, 0.94, 1.0) * ${NIGHT_SKY.moonRadiance.toFixed(1)} * shading,
|
||||
light * (1.0 - smoothstep(0.96, 1.0, r2)) * (1.0 - cloud) * moonVisibility);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}`,
|
||||
});
|
||||
const moonGeometry = new PlaneGeometry(1, 1);
|
||||
const moon = new Mesh(moonGeometry, moonMaterial);
|
||||
moon.renderOrder = 3;
|
||||
group.add(stars, moon);
|
||||
return {
|
||||
group,
|
||||
update(date: Date, observer: ObserverConfig, sunAltitude: number, cloudCover = 0) {
|
||||
coverage.value = cloudCover;
|
||||
visibility.value = 1 - MathUtils.smoothstep(sunAltitude, NIGHT_SKY.fadeStartAltitude, NIGHT_SKY.fadeEndAltitude);
|
||||
stars.visible = visibility.value > 0;
|
||||
moonVisibility.value = 1 - MathUtils.smoothstep(sunAltitude, NIGHT_SKY.moonFadeStartAltitude, NIGHT_SKY.moonFadeEndAltitude);
|
||||
moon.visible = moonVisibility.value > 0;
|
||||
const position = getMoonPosition(date, observer.latitude, observer.longitude);
|
||||
const illumination = getMoonIllumination(date);
|
||||
moon.position.copy(eye).addScaledVector(new Vector3(...directionFromAngles(position.azimuth, position.altitude)), NIGHT_SKY.distance);
|
||||
moon.lookAt(eye);
|
||||
moon.scale.setScalar(2 * NIGHT_SKY.distance * NIGHT_SKY.moonRadiusKm / position.distance);
|
||||
const angle = MathUtils.degToRad(illumination.angle - position.parallacticAngle);
|
||||
const z = 2 * illumination.fraction - 1;
|
||||
const xy = Math.sqrt(Math.max(0, 1 - z * z));
|
||||
moonMaterial.uniforms.lightDirection.value.set(-Math.sin(angle) * xy, Math.cos(angle) * xy, z);
|
||||
// The existing terrain and Earth horizon provide depth occlusion.
|
||||
},
|
||||
dispose() {
|
||||
starGeometry.dispose(); starMaterial.dispose(); moonGeometry.dispose(); moonMaterial.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { BufferAttribute, BufferGeometry, Color, DynamicDrawUsage, Group, LineBasicMaterial, LineSegments, MathUtils, Points, ShaderMaterial } from 'three';
|
||||
import type { Vector3 } from 'three';
|
||||
import { PRECIPITATION as P } from '../constants/precipitation.ts';
|
||||
|
||||
/** Bounded, reusable particles around the viewing camera, in world coordinates. */
|
||||
export function createPrecipitation(heightAt: (x: number, z: number) => number) {
|
||||
const group = new Group();
|
||||
group.name = 'weather-precipitation';
|
||||
const rainMaterial = new LineBasicMaterial({ color: '#b9cddd', transparent: true, depthWrite: false });
|
||||
const snowMaterial = new ShaderMaterial({
|
||||
transparent: true, depthWrite: false,
|
||||
uniforms: { tint: { value: new Color() }, opacity: { value: P.snow.opacity }, pointScale: { value: 1 } },
|
||||
vertexShader: `
|
||||
uniform float pointScale;
|
||||
void main() {
|
||||
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_Position = projectionMatrix * viewPosition;
|
||||
gl_PointSize = clamp(${P.snow.size} * pointScale * projectionMatrix[1][1] / max(0.1, -viewPosition.z), 1.0, 24.0);
|
||||
}`,
|
||||
fragmentShader: `
|
||||
uniform vec3 tint;
|
||||
uniform float opacity;
|
||||
void main() {
|
||||
float radius = length(gl_PointCoord - 0.5) * 2.0;
|
||||
float alpha = (1.0 - smoothstep(0.3, 1.0, radius)) * opacity;
|
||||
if (alpha < 0.01) discard;
|
||||
gl_FragColor = vec4(tint, alpha);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}`,
|
||||
});
|
||||
const layers = ([false, true] as const).map(snow => {
|
||||
const settings = snow ? P.snow : P.rain;
|
||||
const vertices = snow ? 1 : 2;
|
||||
const geometry = new BufferGeometry();
|
||||
const attribute = new BufferAttribute(new Float32Array(settings.maxParticles * vertices * 3), 3).setUsage(DynamicDrawUsage);
|
||||
geometry.setAttribute('position', attribute);
|
||||
geometry.setDrawRange(0, 0);
|
||||
const mesh = snow ? new Points(geometry, snowMaterial) : new LineSegments(geometry, rainMaterial);
|
||||
// The buffer moves with the camera; avoid recomputing its bounds every frame.
|
||||
mesh.frustumCulled = false;
|
||||
mesh.visible = false;
|
||||
group.add(mesh);
|
||||
return { snow, settings, vertices, mesh, attribute,
|
||||
positions: new Float32Array(settings.maxParticles * 3),
|
||||
ground: new Float32Array(settings.maxParticles),
|
||||
seeds: Float32Array.from({ length: settings.maxParticles }, () => Math.random()), count: 0 };
|
||||
});
|
||||
let elapsed = 0;
|
||||
const span = P.radius * 2;
|
||||
const wrap = (value: number, center: number) => MathUtils.euclideanModulo(value - center + P.radius, span) + center - P.radius;
|
||||
return {
|
||||
group,
|
||||
update(delta: number, camera: Vector3, rain: number, snowfall: number, daylight: number, viewportHeight: number) {
|
||||
const dt = Math.min(Math.max(delta, 0), P.maxDelta);
|
||||
elapsed += dt;
|
||||
rainMaterial.color.setRGB(0.65, 0.75, 0.85).multiplyScalar(0.25 + daylight * 0.75);
|
||||
snowMaterial.uniforms.tint.value.setRGB(0.9, 0.95, 1).multiplyScalar(0.25 + daylight * 0.75);
|
||||
snowMaterial.uniforms.pointScale.value = viewportHeight / 2;
|
||||
for (const layer of layers) {
|
||||
const { snow, settings, vertices, mesh, attribute, positions, ground, seeds } = layer;
|
||||
const rate = snow ? snowfall : rain;
|
||||
const strength = Math.sqrt(MathUtils.clamp(rate / settings.fullRate, 0, 1));
|
||||
const count = Math.floor(settings.maxParticles * strength);
|
||||
mesh.visible = count > 0;
|
||||
mesh.geometry.setDrawRange(0, count * vertices);
|
||||
if (!snow) rainMaterial.opacity = P.rain.opacity + strength * 0.3;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const offset = i * 3, seed = seeds[i];
|
||||
const fresh = i >= layer.count;
|
||||
let x = fresh ? camera.x + (Math.random() - 0.5) * span : positions[offset];
|
||||
let z = fresh ? camera.z + (Math.random() - 0.5) * span : positions[offset + 2];
|
||||
if (snow) {
|
||||
x += Math.sin(elapsed + seed * Math.PI * 2) * P.snow.drift * dt;
|
||||
z += Math.cos(elapsed * 0.7 + seed * Math.PI * 2) * P.snow.drift * dt;
|
||||
}
|
||||
x = wrap(x, camera.x); z = wrap(z, camera.z);
|
||||
if (fresh || snow || x !== positions[offset] || z !== positions[offset + 2]) ground[i] = heightAt(x, z);
|
||||
const bottom = Math.max(camera.y - P.belowCamera, ground[i] + 0.05);
|
||||
const top = Math.max(bottom + 0.1, camera.y + P.height - P.belowCamera);
|
||||
const speed = settings.speed * (0.8 + seed * 0.4) * (snow ? 1 : 1 + strength * 0.4);
|
||||
let y = fresh ? MathUtils.lerp(bottom, top, seed) : positions[offset + 1] - speed * dt;
|
||||
if (y < bottom || y > top) y = top;
|
||||
positions[offset] = x; positions[offset + 1] = y; positions[offset + 2] = z;
|
||||
attribute.setXYZ(i * vertices, x, y, z);
|
||||
if (!snow) attribute.setXYZ(i * vertices + 1, x, y + P.rain.streak * (0.6 + strength), z);
|
||||
}
|
||||
if (count) attribute.needsUpdate = true;
|
||||
layer.count = count;
|
||||
}
|
||||
return layers.some(layer => layer.mesh.visible);
|
||||
},
|
||||
dispose() {
|
||||
for (const layer of layers) layer.mesh.geometry.dispose();
|
||||
rainMaterial.dispose(); snowMaterial.dispose();
|
||||
group.removeFromParent();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Quaternion, Vector3 } from 'three';
|
||||
import type { Camera } from 'three';
|
||||
|
||||
/** Keep input polling active, but skip GPU work when the scene has not changed. */
|
||||
export function createRenderInvalidation() {
|
||||
let dirty = true;
|
||||
let previousCamera: Camera | undefined;
|
||||
const position = new Vector3();
|
||||
const rotation = new Quaternion();
|
||||
return {
|
||||
invalidate() { dirty = true; },
|
||||
shouldRender(camera: Camera, animated = false) {
|
||||
if (!dirty && !animated && previousCamera === camera
|
||||
&& position.equals(camera.position) && rotation.equals(camera.quaternion)) return false;
|
||||
dirty = false;
|
||||
previousCamera = camera;
|
||||
position.copy(camera.position);
|
||||
rotation.copy(camera.quaternion);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { BackSide, Group, MathUtils, Mesh, MeshBasicMaterial, Quaternion, Vector3, type Material, type Object3D } from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
import { CHARACTER } from '../constants/character.ts';
|
||||
import { RAIN_GEAR } from '../constants/rainGear.ts';
|
||||
import type { Emote } from '../controls/emotes.ts';
|
||||
|
||||
export interface Sheep {
|
||||
group: Group;
|
||||
clone(): Sheep;
|
||||
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp?: boolean): void;
|
||||
updateLighting(altitude: number, ambient: number): void;
|
||||
pose(emote: Emote, elapsed: number, moving: boolean, time: number): boolean;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export async function loadSheep() {
|
||||
const loader = new GLTFLoader();
|
||||
const [gltf, umbrella, raincoat] = await Promise.all(
|
||||
[CHARACTER.url, RAIN_GEAR.umbrellaUrl, RAIN_GEAR.raincoatUrl].map(url => loader.loadAsync(url)));
|
||||
return createSheep(gltf.scene, { umbrella: umbrella.scene, raincoat: raincoat.scene });
|
||||
}
|
||||
|
||||
export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Group }): Sheep {
|
||||
const group = new Group();
|
||||
group.name = 'PlayerSheep';
|
||||
group.add(model);
|
||||
group.scale.setScalar(CHARACTER.scale);
|
||||
const joints = Object.fromEntries(['Head', 'ArmLeft', 'ArmRight', 'LegLeft', 'LegRight']
|
||||
.map(name => {
|
||||
const joint = model.getObjectByName(name);
|
||||
if (!joint) throw new Error(`양 모델 관절 누락: ${name}`);
|
||||
return [name, joint];
|
||||
})) as Record<string, Object3D>;
|
||||
const rest = Object.fromEntries(Object.entries(joints).map(([name, joint]) => [name, joint.quaternion.clone()]));
|
||||
const coatParts: Object3D[] = [];
|
||||
let hood: Object3D | undefined;
|
||||
let foldedHood: Object3D | undefined;
|
||||
if (gear) {
|
||||
model.add(gear.umbrella, gear.raincoat);
|
||||
hood = gear.raincoat.getObjectByName('CoatHood');
|
||||
foldedHood = gear.raincoat.getObjectByName('CoatFoldedHood');
|
||||
if (!hood || !foldedHood) throw new Error('우비 후드 모델 누락');
|
||||
joints.Head.add(hood);
|
||||
hood.position.set(0, 0, 0); hood.quaternion.identity(); hood.scale.setScalar(1);
|
||||
hood.visible = false;
|
||||
coatParts.push(gear.raincoat);
|
||||
for (const name of ['ArmLeft', 'ArmRight']) {
|
||||
const sleeve = gear.raincoat.getObjectByName(`Coat${name}`);
|
||||
if (!sleeve) throw new Error(`비옷 소매 누락: ${name}`);
|
||||
joints[name].add(sleeve);
|
||||
sleeve.position.set(0, 0, 0); sleeve.quaternion.identity(); sleeve.scale.setScalar(1);
|
||||
coatParts.push(sleeve);
|
||||
}
|
||||
gear.umbrella.visible = false;
|
||||
coatParts.forEach(part => { part.visible = false; });
|
||||
}
|
||||
const materials = new Set<MeshBasicMaterial>();
|
||||
const converted = new Map<Material, MeshBasicMaterial>();
|
||||
const outlineMaterial = new MeshBasicMaterial({ color: CHARACTER.ink, side: BackSide, toneMapped: false });
|
||||
materials.add(outlineMaterial);
|
||||
const meshes: Mesh[] = [];
|
||||
model.traverse(object => { if (object instanceof Mesh) meshes.push(object); });
|
||||
for (const mesh of meshes) {
|
||||
const sourceMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||
const alreadyInk = sourceMaterials.every(material => material.name.startsWith('SheepV2_Ink'));
|
||||
const replace = (source: Material) => {
|
||||
let material = converted.get(source);
|
||||
if (!material) {
|
||||
material = new MeshBasicMaterial({ color: 'color' in source ? source.color as never : CHARACTER.ink, toneMapped: false });
|
||||
converted.set(source, material);
|
||||
materials.add(material);
|
||||
}
|
||||
return material;
|
||||
};
|
||||
mesh.material = Array.isArray(mesh.material) ? mesh.material.map(replace) : replace(mesh.material);
|
||||
// Small facial marks and the badge are already drawn shapes, not silhouettes.
|
||||
if (alreadyInk || mesh.userData.sheepOutline === false || mesh.parent?.userData.sheepOutline === false) continue;
|
||||
const hull = mesh.geometry.clone();
|
||||
const positions = hull.attributes.position;
|
||||
const normals = hull.attributes.normal;
|
||||
// Custom glTF attributes keep their authored Blender Z-up components.
|
||||
const offsets = hull.attributes._outline_offset;
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
positions.setXYZ(i, positions.getX(i) + (offsets ? offsets.getX(i) : normals.getX(i) * CHARACTER.outline),
|
||||
positions.getY(i) + (offsets ? offsets.getZ(i) : normals.getY(i) * CHARACTER.outline),
|
||||
positions.getZ(i) + (offsets ? -offsets.getY(i) : normals.getZ(i) * CHARACTER.outline));
|
||||
}
|
||||
hull.computeBoundingSphere();
|
||||
const outline = new Mesh(hull, outlineMaterial);
|
||||
outline.name = 'Outline';
|
||||
mesh.add(outline);
|
||||
}
|
||||
for (const source of converted.keys()) source.dispose();
|
||||
const baseColors = new Map([...materials].map(material => [material, material.color.clone()]));
|
||||
const originals: Object3D[] = [];
|
||||
group.traverse(object => originals.push(object));
|
||||
function bindRig(group: Group, ownsAssets: boolean): Sheep {
|
||||
const copies: Object3D[] = [];
|
||||
group.traverse(object => copies.push(object));
|
||||
const copy = (object: Object3D | undefined) => object ? copies[originals.indexOf(object)] : undefined;
|
||||
const rigJoints = Object.fromEntries(['Head', 'ArmLeft', 'ArmRight', 'LegLeft', 'LegRight']
|
||||
.map(name => [name, group.getObjectByName(name)!]));
|
||||
const joints = rigJoints;
|
||||
const rigCoatParts = coatParts.map(part => copy(part)!);
|
||||
const rigHood = copy(hood), rigFoldedHood = copy(foldedHood), rigUmbrella = copy(gear?.umbrella);
|
||||
let umbrellaOpen = false;
|
||||
const rotation = new Quaternion();
|
||||
const down = new Vector3(0, -1, 0);
|
||||
const aim = new Vector3();
|
||||
let previousMoving = false;
|
||||
let previousEmote: Emote = 'idle';
|
||||
// Exported pivot rotations are identity in glTF's Y-up coordinates.
|
||||
function pointArm(name: string, direction: Vector3) {
|
||||
rotation.setFromUnitVectors(down, direction.normalize());
|
||||
joints[name].quaternion.copy(rotation).multiply(rest[name]);
|
||||
}
|
||||
return {
|
||||
group,
|
||||
clone() { return bindRig(group.clone(true), false); },
|
||||
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp = false) {
|
||||
umbrellaOpen = umbrella;
|
||||
if (rigUmbrella) rigUmbrella.visible = umbrella;
|
||||
rigCoatParts.forEach(part => { part.visible = raincoat; });
|
||||
if (rigHood) rigHood.visible = raincoat && hoodUp;
|
||||
if (rigFoldedHood) rigFoldedHood.visible = raincoat && !hoodUp;
|
||||
},
|
||||
updateLighting(altitude: number, ambient: number) {
|
||||
const settings = CHARACTER.lighting;
|
||||
const daylight = MathUtils.smoothstep(altitude, settings.nightAltitude, settings.dayAltitude);
|
||||
const brightness = MathUtils.clamp(MathUtils.lerp(settings.nightBrightness, 1, daylight) * ambient,
|
||||
settings.minimumBrightness, 1);
|
||||
// Preserve the flat palette and outlines, always tinting from the original colors.
|
||||
for (const [material, color] of baseColors) material.color.copy(color).multiplyScalar(brightness);
|
||||
},
|
||||
pose(emote: Emote, elapsed: number, moving: boolean, time: number) {
|
||||
const changed = moving || emote !== 'idle' || previousMoving !== moving || previousEmote !== emote;
|
||||
previousMoving = moving;
|
||||
previousEmote = emote;
|
||||
for (const [name, joint] of Object.entries(joints)) joint.quaternion.copy(rest[name]);
|
||||
if (emote === 'wave') {
|
||||
pointArm(umbrellaOpen ? 'ArmLeft' : 'ArmRight',
|
||||
aim.set(umbrellaOpen ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15));
|
||||
} else if (emote === 'pray') {
|
||||
pointArm('ArmLeft', aim.set(.68, -.22, .64));
|
||||
pointArm('ArmRight', aim.set(-.68, -.22, .64));
|
||||
joints.Head.rotateX(.16);
|
||||
} else if (moving) {
|
||||
const stride = Math.sin(time * 9) * .4;
|
||||
joints.LegLeft.rotateX(stride);
|
||||
joints.LegRight.rotateX(-stride);
|
||||
joints.ArmLeft.rotateX(-stride * .6);
|
||||
joints.ArmRight.rotateX(stride * .6);
|
||||
}
|
||||
// Keep the grip stable while the free arm and legs perform their own animations.
|
||||
if (umbrellaOpen) pointArm('ArmRight', aim.set(.05, -.45, .7));
|
||||
return changed;
|
||||
},
|
||||
dispose() {
|
||||
if (ownsAssets) {
|
||||
group.traverse(object => { if (object instanceof Mesh) object.geometry.dispose(); });
|
||||
for (const material of materials) material.dispose();
|
||||
}
|
||||
group.removeFromParent();
|
||||
},
|
||||
};
|
||||
}
|
||||
return bindRig(group, true);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MathUtils, Vector3 } from 'three';
|
||||
import { Sky } from 'three/addons/objects/Sky.js';
|
||||
import { SCENE } from '../constants/scene.ts';
|
||||
import { NIGHT_SKY } from '../constants/nightSky.ts';
|
||||
import { cloudShader, skyCloudFragment } from './clouds.ts';
|
||||
|
||||
export function createSky() {
|
||||
const mesh = new Sky();
|
||||
const fragment = mesh.material.fragmentShader;
|
||||
const start = fragment.indexOf('// Clouds');
|
||||
const end = fragment.indexOf('gl_FragColor = vec4( texColor, 1.0 );', start);
|
||||
if (start < 0 || end < 0) throw new Error('Sky cloud shader integration needs updating');
|
||||
const nightBackground = `texColor += vec3(${NIGHT_SKY.backgroundRadiance.map(value => value.toFixed(3)).join(', ')})
|
||||
* (1.0 - smoothstep(-0.07, 0.05, vSunDirection.y));`;
|
||||
mesh.material.fragmentShader = cloudShader + fragment.slice(0, start) + nightBackground + skyCloudFragment + fragment.slice(end);
|
||||
mesh.scale.setScalar(SCENE.skyRadius);
|
||||
const uniforms = mesh.material.uniforms;
|
||||
uniforms.turbidity.value = 2.5;
|
||||
uniforms.rayleigh.value = 2;
|
||||
uniforms.mieCoefficient.value = SCENE.mieCoefficient;
|
||||
uniforms.mieDirectionalG.value = SCENE.mieDirectionalG;
|
||||
uniforms.showSunDisc.value = 0;
|
||||
uniforms.cloudCoverage.value = 0;
|
||||
return {
|
||||
mesh,
|
||||
update(direction: Vector3, altitude: number, cloudCover = 0) {
|
||||
uniforms.sunPosition.value.copy(direction).multiplyScalar(SCENE.skyRadius);
|
||||
uniforms.rayleigh.value = MathUtils.lerp(2.5, 1.7, MathUtils.smoothstep(altitude, -6, 30));
|
||||
uniforms.cloudCoverage.value = cloudCover;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { BoxGeometry, BufferGeometry, Float32BufferAttribute, Group, MathUtils, Mesh, MeshStandardMaterial, Vector3 } from 'three';
|
||||
import type { Material } from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
import { STRUCTURES, STRUCTURE_ASSETS } from '../constants/structures.ts';
|
||||
import { SUMMIT_RELIEF } from '../constants/summitRelief.ts';
|
||||
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
|
||||
import { summitPosition } from '../controls/summitCoordinates.ts';
|
||||
|
||||
export type StructureAssets = Map<string, Group>;
|
||||
type SupportAnchor = { position: [number, number, number]; postWidth: number };
|
||||
|
||||
export async function loadStructures(includeRelief = false): Promise<StructureAssets> {
|
||||
const loader = new GLTFLoader();
|
||||
const ids = [...STRUCTURES.towers.map(s => s.id), 'weather-radar', ...STRUCTURES.decks.map(s => s.id),
|
||||
...(includeRelief ? [SUMMIT_RELIEF.assetId, SUMMIT_PLATEAU.marker.assetId] : [])];
|
||||
return new Map(await Promise.all(ids.map(async id => {
|
||||
const model = await loader.loadAsync(STRUCTURE_ASSETS.baseUrl + '/' + id + '.glb');
|
||||
return [id, model.scene] as const;
|
||||
})));
|
||||
}
|
||||
|
||||
/** Local WGS84 tangent approximation, sufficient for the < 1 km structure area. +z south. */
|
||||
export function structurePosition(latitude: number, longitude: number, origin: { latitude: number; longitude: number }) {
|
||||
const { x, z } = summitPosition(latitude, longitude, origin);
|
||||
return new Vector3(x, 0, z);
|
||||
}
|
||||
|
||||
export function createStructures(origin: { latitude: number; longitude: number }, groundAt: (x: number, z: number) => number,
|
||||
assets: StructureAssets) {
|
||||
const group = new Group();
|
||||
group.name = 'Refined summit assets (estimated dimensions)';
|
||||
const geometries = new Set<BufferGeometry>();
|
||||
const materials = new Set<Material>();
|
||||
const wall = new MeshStandardMaterial({ color: STRUCTURE_ASSETS.foundationColor, roughness: 1 });
|
||||
const rail = new MeshStandardMaterial({ color: STRUCTURE_ASSETS.supportColor, roughness: .86 });
|
||||
materials.add(wall); materials.add(rail);
|
||||
function box(parent: Group, width: number, height: number, depth: number, x: number, y: number, z: number, material: Material) {
|
||||
const geometry = new BoxGeometry(width, height, depth);
|
||||
geometries.add(geometry);
|
||||
const result = new Mesh(geometry, material);
|
||||
result.position.set(x, y, z);
|
||||
parent.add(result);
|
||||
}
|
||||
function site(id: string, latitude: number, longitude: number) {
|
||||
const parent = new Group();
|
||||
parent.name = id;
|
||||
parent.position.copy(structurePosition(latitude, longitude, origin));
|
||||
parent.position.y = groundAt(parent.position.x, parent.position.z);
|
||||
const model = assets.get(id)!;
|
||||
model.traverse(object => {
|
||||
if (object instanceof Mesh) {
|
||||
geometries.add(object.geometry);
|
||||
(Array.isArray(object.material) ? object.material : [object.material]).forEach(m => materials.add(m));
|
||||
if (object.userData.materialBatch === 'groove') {
|
||||
const material = object.material as Material;
|
||||
material.polygonOffset = true;
|
||||
material.polygonOffsetFactor = -1;
|
||||
material.polygonOffsetUnits = -1;
|
||||
}
|
||||
}
|
||||
});
|
||||
parent.add(model);
|
||||
group.add(parent);
|
||||
return parent;
|
||||
}
|
||||
// Solid foundations span the local terrain relief instead of floating on a single sample.
|
||||
function foundation(parent: Group, width: number, depth: number) {
|
||||
const samples = [-width / 2, width / 2].flatMap(x => [-depth / 2, depth / 2].map(z => groundAt(parent.position.x + x, parent.position.z + z)));
|
||||
const top = Math.max(parent.position.y, ...samples) + .2;
|
||||
const bottom = Math.min(parent.position.y, ...samples) - .4;
|
||||
parent.position.y = top;
|
||||
box(parent, width, top - bottom, depth, 0, -(top - bottom) / 2, 0, wall);
|
||||
}
|
||||
for (const spec of STRUCTURES.towers) {
|
||||
const tower = site(spec.id, spec.latitude, spec.longitude);
|
||||
tower.userData = { source: spec.source, dimensionsEstimated: true, positionEstimated: spec.estimatedPosition };
|
||||
foundation(tower, spec.width + 1, spec.width + 1);
|
||||
}
|
||||
const radarSpec = STRUCTURES.radar;
|
||||
const radar = site('weather-radar', radarSpec.latitude, radarSpec.longitude);
|
||||
radar.userData = { source: radarSpec.source, dimensionsEstimated: true, positionEstimated: true };
|
||||
foundation(radar, 8, 8);
|
||||
|
||||
for (const spec of STRUCTURES.decks) {
|
||||
const deck = site(spec.id, spec.latitude, spec.longitude);
|
||||
deck.rotation.y = MathUtils.degToRad(spec.heading);
|
||||
deck.userData = { source: spec.source, dimensionsEstimated: true, positionEstimated: true };
|
||||
const modelRoot = assets.get(spec.id)!.getObjectByName(spec.id)!;
|
||||
const anchors = JSON.parse(modelRoot.userData.terrainSupportAnchorsJson) as SupportAnchor[];
|
||||
const localGround = (x: number, z: number) => {
|
||||
const v = new Vector3(x, 0, z).applyAxisAngle(new Vector3(0, 1, 0), deck.rotation.y).add(deck.position);
|
||||
return groundAt(v.x, v.z);
|
||||
};
|
||||
const bottomZ = -spec.length / 2 - spec.stairLength;
|
||||
const samples = [-spec.width / 2, spec.width / 2].flatMap(x => [-spec.length / 2, 0, spec.length / 2].map(z => localGround(x, z)));
|
||||
samples.push(localGround(-spec.width / 2, bottomZ) + spec.stairDrop, localGround(spec.width / 2, bottomZ) + spec.stairDrop);
|
||||
// Only the walking surface needs clearance; buried post feet must not lift the entire deck.
|
||||
deck.position.y = Math.max(...samples) + STRUCTURES.deck.clearance;
|
||||
const supports = new Group();
|
||||
supports.name = 'terrain-supports';
|
||||
deck.add(supports);
|
||||
for (const { position: [x, y, z], postWidth } of anchors) {
|
||||
const bottom = localGround(x, z) - deck.position.y - .1;
|
||||
const top = y + .02;
|
||||
if (top > bottom) box(supports, postWidth, top - bottom, postWidth, x, (top + bottom) / 2, z, rail);
|
||||
}
|
||||
let wood: Material = wall;
|
||||
modelRoot.traverse(object => {
|
||||
if (object instanceof Mesh && object.userData.materialBatch === 'wood0') wood = object.material as Material;
|
||||
});
|
||||
function connectEnd(name: string, startZ: number, topY: number, direction: number) {
|
||||
const config = STRUCTURE_ASSETS.access;
|
||||
const access = new Group();
|
||||
access.name = name;
|
||||
deck.add(access);
|
||||
const ground = (x: number, z: number) => localGround(x, z) - deck.position.y;
|
||||
let count = 1;
|
||||
let landingY = topY;
|
||||
for (; count <= config.maxSteps; count++) {
|
||||
const z = startZ + direction * count * config.treadDepth;
|
||||
landingY = Math.max(ground(-spec.width / 2, z), ground(0, z), ground(spec.width / 2, z)) + .02;
|
||||
if (Math.abs(topY - landingY) <= count * config.maxRiser) break;
|
||||
}
|
||||
count = Math.min(count, config.maxSteps);
|
||||
const rise = (topY - landingY) / count;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const z = startZ + direction * (i + .5) * config.treadDepth;
|
||||
const y = topY - (i + 1) * rise;
|
||||
const bottom = Math.min(...[-spec.width / 2, spec.width / 2].flatMap(x =>
|
||||
[-.5, .5].map(dz => ground(x, z + dz * config.treadDepth)))) - config.groundOverlap;
|
||||
// Closed footing beneath each tread reaches the slope instead of ending in mid-air.
|
||||
box(access, spec.width, Math.max(.02, y - .04 - bottom), config.treadDepth,
|
||||
0, (y - .04 + bottom) / 2, z, wall);
|
||||
box(access, spec.width, .04, config.treadDepth + .002, 0, y - .02, z, wood);
|
||||
}
|
||||
const z0 = startZ + direction * count * config.treadDepth;
|
||||
const endZ = z0 + direction * config.landingLength;
|
||||
// The outer landing edge follows the cross-slope too, not just its highest corner.
|
||||
const vertices = [-spec.width / 2, spec.width / 2].flatMap(x =>
|
||||
[x, landingY, z0, x, ground(x, endZ) + .02, endZ]);
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3));
|
||||
geometry.setIndex(direction > 0 ? [0, 1, 2, 2, 1, 3] : [0, 2, 1, 2, 3, 1]);
|
||||
geometry.computeVertexNormals();
|
||||
geometries.add(geometry);
|
||||
access.add(new Mesh(geometry, wood));
|
||||
access.userData = { startZ, endZ, topY, stepCount: count };
|
||||
}
|
||||
if (STRUCTURE_ASSETS.access.deckIds.includes(spec.id)) {
|
||||
connectEnd('entry-access', spec.length / 2, 0, 1);
|
||||
connectEnd('exit-access', bottomZ, -spec.stairDrop, -1);
|
||||
}
|
||||
}
|
||||
return { group, dispose() { geometries.forEach(g => g.dispose()); materials.forEach(m => m.dispose()); } };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BufferAttribute, BufferGeometry } from 'three';
|
||||
import { SUMMIT_ASSETS } from '../constants/summitAssets.ts';
|
||||
|
||||
/** Build-time only: share bit-identical vertices, preserving topology and every attribute. */
|
||||
export function encodeSummitGeometry(geometry:BufferGeometry) {
|
||||
const attributes=SUMMIT_ASSETS.attributes.map(([name])=>geometry.getAttribute(name));
|
||||
const unique:number[]=[],indices:number[]=[],known=new Map<string,number>();
|
||||
for(let i=0;i<attributes[0].count;i++) {
|
||||
const key=attributes.flatMap(a=>Array.from(a.array.subarray(i*a.itemSize,(i+1)*a.itemSize))).join(',');
|
||||
let id=known.get(key);
|
||||
if(id===undefined) {id=unique.length;known.set(key,id);unique.push(i);}
|
||||
indices.push(id);
|
||||
}
|
||||
const floats=SUMMIT_ASSETS.attributes.reduce((n,[,size])=>n+size,0);
|
||||
const buffer=new ArrayBuffer(16+unique.length*floats*4+indices.length*4);
|
||||
new Uint32Array(buffer,0,4).set([SUMMIT_ASSETS.magic,SUMMIT_ASSETS.version,unique.length,indices.length]);
|
||||
let offset=16;
|
||||
for(const a of attributes) {
|
||||
const target=new Float32Array(buffer,offset,unique.length*a.itemSize);
|
||||
unique.forEach((source,i)=>{for(let j=0;j<a.itemSize;j++)target[i*a.itemSize+j]=a.array[source*a.itemSize+j];});
|
||||
offset+=target.byteLength;
|
||||
}
|
||||
new Uint32Array(buffer,offset).set(indices);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function decodeSummitGeometry(buffer:ArrayBuffer) {
|
||||
if(buffer.byteLength<16)throw new Error('정상 지면 파일이 불완전합니다.');
|
||||
const [magic,version,vertices,indices]=new Uint32Array(buffer,0,4);
|
||||
const floats=SUMMIT_ASSETS.attributes.reduce((n,[,size])=>n+size,0);
|
||||
if(magic!==SUMMIT_ASSETS.magic||version!==SUMMIT_ASSETS.version||indices%3!==0||
|
||||
buffer.byteLength!==16+vertices*floats*4+indices*4)throw new Error('정상 지면 파일 형식이 다릅니다.');
|
||||
const geometry=new BufferGeometry();
|
||||
let offset=16;
|
||||
for(const [name,size] of SUMMIT_ASSETS.attributes) {
|
||||
const values=new Float32Array(buffer,offset,vertices*size);
|
||||
geometry.setAttribute(name,new BufferAttribute(values,size));offset+=values.byteLength;
|
||||
}
|
||||
const index=new Uint32Array(buffer,offset,indices);
|
||||
if(index.some(value=>value>=vertices))throw new Error('정상 지면 인덱스가 올바르지 않습니다.');
|
||||
geometry.setIndex(new BufferAttribute(index,1));geometry.computeBoundingSphere();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export async function loadSummitAssets():Promise<BufferGeometry> {
|
||||
const response=await fetch(SUMMIT_ASSETS.manifestUrl);
|
||||
if(!response.ok)throw new Error('정상 지면 메타데이터를 불러오지 못했습니다.');
|
||||
const manifest=await response.json() as {version:number;bufferHash:string;byteLength:number};
|
||||
if(manifest.version!==SUMMIT_ASSETS.version)throw new Error('정상 지면을 다시 생성해 주세요.');
|
||||
const compressed=typeof DecompressionStream!=='undefined';
|
||||
const binary=await fetch(`${SUMMIT_ASSETS.bufferUrl}${compressed?'.gz':''}?v=${manifest.bufferHash}`);
|
||||
if(!binary.ok)throw new Error('정상 지면 파일을 불러오지 못했습니다.');
|
||||
// Vite/most hosts decode Content-Encoding in fetch; plain static hosts may serve raw gzip.
|
||||
const alreadyDecoded=binary.headers.get('Content-Encoding')?.toLowerCase().includes('gzip');
|
||||
const buffer=compressed&&!alreadyDecoded
|
||||
? await new Response(binary.body!.pipeThrough(new DecompressionStream('gzip'))).arrayBuffer()
|
||||
: await binary.arrayBuffer();
|
||||
if(buffer.byteLength!==manifest.byteLength)throw new Error('정상 지면 파일 크기가 다릅니다.');
|
||||
return decodeSummitGeometry(buffer);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BufferGeometry, Float32BufferAttribute } from 'three';
|
||||
import { SUMMIT_TRAILS } from '../constants/summitTrails.ts';
|
||||
import { SUMMIT_MATERIALS } from '../constants/summitMaterials.ts';
|
||||
import { SUMMIT_ASSETS } from '../constants/summitAssets.ts';
|
||||
import type { createSummitWalking } from '../controls/summitWalking.ts';
|
||||
|
||||
/** Offline subdivisions stay on the source terrain; adaptive interiors preserve blend bands. */
|
||||
export function buildSummitGeometry(walking: ReturnType<typeof createSummitWalking>, terrainGeometry: BufferGeometry, adaptive=false) {
|
||||
const {radius,surfaceStep:step,surfaceLift:lift}=SUMMIT_TRAILS;
|
||||
const {start}=walking;
|
||||
const surfaces = { forest: { vertices: [] as number[], uv: [] as number[], shades: [] as number[], normals: [] as number[] } };
|
||||
const trailDistances:number[]=[];
|
||||
const rockDistances:number[]=[];
|
||||
// Subdivide the actual terrain triangles: a separate regular grid cuts through ridges.
|
||||
type Vertex = number[];
|
||||
const position=terrainGeometry.getAttribute('position'),normal=terrainGeometry.getAttribute('normal');
|
||||
const index=terrainGeometry.getIndex()!;
|
||||
const vertex=(i:number):Vertex=>[position.getX(i),position.getY(i),position.getZ(i),normal.getX(i),normal.getY(i),normal.getZ(i)];
|
||||
const midpoint=(a:Vertex,b:Vertex)=>a.map((v,i)=>(v+b[i])/2);
|
||||
function triangle(a:Vertex,b:Vertex,c:Vertex) {
|
||||
const x=(a[0]+b[0]+c[0])/3,z=(a[2]+b[2]+c[2])/3;
|
||||
const edge=Math.max(Math.hypot(a[0]-b[0],a[2]-b[2]),Math.hypot(b[0]-c[0],b[2]-c[2]),Math.hypot(c[0]-a[0],c[2]-a[2]));
|
||||
if(Math.hypot(x-start.x,z-start.z)>radius+edge) return;
|
||||
let refine=edge>step*2;
|
||||
const distance=Math.hypot(x-start.x,z-start.z);
|
||||
if(adaptive && edge<=SUMMIT_ASSETS.interiorMaxEdge && distance+edge<radius && distance-edge>SUMMIT_ASSETS.detailedStartRadius) {
|
||||
// Signed distances are 1-Lipschitz: an entire triangle must clear the blend band.
|
||||
const rock=walking.rockDistance(x,z),path=walking.pathDistance(x,z);
|
||||
const r=SUMMIT_MATERIALS.rockBlend,t=SUMMIT_MATERIALS.edgeBlend;
|
||||
const pureRock=rock+edge<r.innerMeters-r.irregularityMeters;
|
||||
const outsideRock=rock-edge>r.outerMeters+r.irregularityMeters;
|
||||
const purePath=path+edge<t.innerMeters-t.irregularityMeters;
|
||||
const pureForest=path-edge>t.outerMeters+t.irregularityMeters;
|
||||
if(pureRock || outsideRock&&(purePath||pureForest))refine=false;
|
||||
}
|
||||
if(refine) {
|
||||
const ab=midpoint(a,b),bc=midpoint(b,c),ca=midpoint(c,a);
|
||||
triangle(a,ab,ca);triangle(ab,b,bc);triangle(ca,bc,c);triangle(ab,bc,ca);return;
|
||||
}
|
||||
if(!walking.inside(x,z))return;
|
||||
// All three surfaces share one continuous material; boundaries are sampled per pixel.
|
||||
const surface='forest',tile=SUMMIT_MATERIALS.forest.tileMeters;
|
||||
const {vertices,uv,shades,normals}=surfaces[surface];
|
||||
for(const p of [a,b,c]) {
|
||||
vertices.push(p[0],p[1]+lift,p[2]);uv.push(p[0]/tile,-p[2]/tile);
|
||||
if(surface==='forest') trailDistances.push(Math.max(-4,Math.min(4,walking.pathDistance(p[0],p[2]))));
|
||||
rockDistances.push(Math.max(-4,Math.min(4,walking.rockDistance(p[0],p[2]))));
|
||||
const length=Math.hypot(p[3],p[4],p[5]);normals.push(p[3]/length,p[4]/length,p[5]/length);
|
||||
const variation=.94+.06*Math.sin(p[0]*.19)*Math.sin(p[2]*.13);
|
||||
shades.push(variation,variation,variation);
|
||||
}
|
||||
}
|
||||
for(let i=0;i<index.count;i+=3) {
|
||||
const ia=index.getX(i),ib=index.getX(i+1),ic=index.getX(i+2);
|
||||
// Coarse rejection before allocating or subdividing distant triangles.
|
||||
const xs=[position.getX(ia),position.getX(ib),position.getX(ic)];
|
||||
const zs=[position.getZ(ia),position.getZ(ib),position.getZ(ic)];
|
||||
if(Math.min(...xs)>start.x+radius||Math.max(...xs)<start.x-radius||Math.min(...zs)>start.z+radius||Math.max(...zs)<start.z-radius)continue;
|
||||
triangle(vertex(ia),vertex(ib),vertex(ic));
|
||||
}
|
||||
const {vertices,uv,shades,normals}=surfaces.forest;
|
||||
const geometry=new BufferGeometry();
|
||||
for(const [name,values,size] of [
|
||||
['position',vertices,3],['normal',normals,3],['uv',uv,2],['color',shades,3],
|
||||
['trailDistance',trailDistances,1],['rockDistance',rockDistances,1],
|
||||
] as const) geometry.setAttribute(name,new Float32BufferAttribute(values,size));
|
||||
if(!adaptive)return geometry;
|
||||
const stitched=stitchSummitGeometry(geometry);geometry.dispose();return stitched;
|
||||
}
|
||||
|
||||
/** Close hanging edges with the neighbour's actual vertex, including its shading attributes. */
|
||||
export function stitchSummitGeometry(source:BufferGeometry) {
|
||||
const p=source.getAttribute('position');
|
||||
const key=(x:number,z:number)=>`${Math.round(x*1e6)},${Math.round(z*1e6)}`;
|
||||
const known=new Map<string,number>();
|
||||
for(let i=0;i<p.count;i++)known.set(key(p.getX(i),p.getZ(i)),i);
|
||||
const order:number[]=[];
|
||||
function triangle(a:number,b:number,c:number) {
|
||||
for(const [u,v,w] of [[a,b,c],[b,c,a],[c,a,b]]) {
|
||||
const middle=known.get(key((p.getX(u)+p.getX(v))/2,(p.getZ(u)+p.getZ(v))/2));
|
||||
if(middle!==undefined && key(p.getX(middle),p.getZ(middle))!==key(p.getX(u),p.getZ(u)) && key(p.getX(middle),p.getZ(middle))!==key(p.getX(v),p.getZ(v))) {
|
||||
triangle(u,middle,w);triangle(middle,v,w);return;
|
||||
}
|
||||
}
|
||||
order.push(a,b,c);
|
||||
}
|
||||
for(let i=0;i<p.count;i+=3)triangle(i,i+1,i+2);
|
||||
const result=new BufferGeometry();
|
||||
for(const [name,a] of Object.entries(source.attributes)) {
|
||||
const values=new Float32Array(order.length*a.itemSize);
|
||||
order.forEach((sourceIndex,i)=>{for(let j=0;j<a.itemSize;j++)values[i*a.itemSize+j]=a.array[sourceIndex*a.itemSize+j];});
|
||||
result.setAttribute(name,new Float32BufferAttribute(values,a.itemSize));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Group, Mesh, type Material, type BufferGeometry, type MeshStandardMaterial } from 'three';
|
||||
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
|
||||
|
||||
export function createSummitMarker(asset: Group, start: {x: number; z: number},
|
||||
groundAt: (x: number, z: number) => number, rockMaterial: MeshStandardMaterial) {
|
||||
const group = new Group();
|
||||
group.name = SUMMIT_PLATEAU.marker.assetId;
|
||||
const [x,z] = SUMMIT_PLATEAU.marker.offset;
|
||||
group.position.set(start.x+x, groundAt(start.x+x,start.z+z), start.z+z);
|
||||
const granite = rockMaterial.clone();
|
||||
granite.vertexColors = false;
|
||||
const materials = new Set<Material>([granite]);
|
||||
const geometries = new Set<BufferGeometry>();
|
||||
asset.traverse(object => {
|
||||
if (!(object instanceof Mesh)) return;
|
||||
geometries.add(object.geometry);
|
||||
(Array.isArray(object.material) ? object.material : [object.material]).forEach(m => materials.add(m));
|
||||
if (object.name.replaceAll('_',' ') === 'Marker granite body') object.material = granite;
|
||||
});
|
||||
group.add(asset);
|
||||
return { group, dispose() {
|
||||
geometries.forEach(g => g.dispose());
|
||||
materials.forEach(m => m.dispose());
|
||||
} };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { BoxGeometry, CylinderGeometry, Group, Mesh, MeshStandardMaterial, Vector3 } from 'three';
|
||||
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
|
||||
import { SUMMIT_RELIEF } from '../constants/summitRelief.ts';
|
||||
|
||||
/** Non-walkable cliff detail and facility stairs; existing terrain remains the walking surface. */
|
||||
export function createSummitRelief(radar: Vector3, groundAt: (x: number, z: number) => number, rockMaterial: MeshStandardMaterial, rockAsset: Group) {
|
||||
const group = new Group();
|
||||
group.name = 'Photo-estimated radar cliffs and access';
|
||||
const stone = rockMaterial.clone();
|
||||
stone.onBeforeCompile = rockMaterial.onBeforeCompile;
|
||||
stone.customProgramCacheKey = rockMaterial.customProgramCacheKey;
|
||||
stone.color.set(SUMMIT_RELIEF.rockTint);
|
||||
stone.flatShading = false;
|
||||
const metal = new MeshStandardMaterial({ color: SUMMIT_RELIEF.access.color, roughness: .72, metalness: .45 });
|
||||
const ground = (x: number, z: number) => groundAt(radar.x + x, radar.z + z);
|
||||
// Blender-authored joints, UVs and normals, in metres relative to the radar base.
|
||||
rockAsset.position.copy(radar);
|
||||
const replacedMaterials=new Set<import('three').Material>();
|
||||
rockAsset.traverse(object=>{
|
||||
if(object instanceof Mesh) {
|
||||
(Array.isArray(object.material)?object.material:[object.material]).forEach(m=>replacedMaterials.add(m));
|
||||
stone.vertexColors=object.geometry.hasAttribute('color');
|
||||
object.material=stone;
|
||||
}
|
||||
});
|
||||
replacedMaterials.forEach(m=>m.dispose());
|
||||
group.add(rockAsset);
|
||||
const config=SUMMIT_RELIEF.access;
|
||||
const start=new Vector3(radar.x+config.bottom[0],ground(...config.bottom)+.15,radar.z+config.bottom[1]);
|
||||
const end=new Vector3(radar.x+config.top[0],radar.y+.15,radar.z+config.top[1]);
|
||||
const dx=end.x-start.x,dz=end.z-start.z,length=Math.hypot(dx,dz);
|
||||
const side=new Vector3(dz/length,0,-dx/length);
|
||||
const count=Math.ceil(length/config.treadDepth);
|
||||
const tread=new BoxGeometry(config.width,.09,length/count+.02);
|
||||
const railGeometry=new CylinderGeometry(config.railRadius,config.railRadius,1,6);
|
||||
const up=new Vector3(0,1,0);
|
||||
const beam=(a:Vector3,b:Vector3)=>{
|
||||
const mesh=new Mesh(railGeometry,metal),direction=b.clone().sub(a);
|
||||
mesh.position.copy(a).add(b).multiplyScalar(.5);mesh.scale.y=direction.length();
|
||||
mesh.quaternion.setFromUnitVectors(up,direction.normalize());group.add(mesh);
|
||||
};
|
||||
for(let i=0;i<count;i++) {
|
||||
const mesh=new Mesh(tread,metal);mesh.position.lerpVectors(start,end,(i+.5)/count);
|
||||
mesh.rotation.y=Math.atan2(dx,dz);group.add(mesh);
|
||||
}
|
||||
for(const sign of [-1,1]) {
|
||||
const a=start.clone().addScaledVector(side,sign*config.width/2),b=end.clone().addScaledVector(side,sign*config.width/2);
|
||||
beam(a,b);
|
||||
beam(a.clone().addScaledVector(up,config.railHeight),b.clone().addScaledVector(up,config.railHeight));
|
||||
for(let i=0;i<=count;i+=config.postEvery) {
|
||||
const foot=a.clone().lerp(b,i/count);beam(foot,foot.clone().addScaledVector(up,config.railHeight));
|
||||
}
|
||||
beam(b,b.clone().addScaledVector(up,config.railHeight));
|
||||
}
|
||||
const landingEnd=new Vector3(radar.x+config.landingEnd[0],end.y,radar.z+config.landingEnd[1]);
|
||||
const landing=new Mesh(new BoxGeometry(end.distanceTo(landingEnd),.09,config.width),metal);
|
||||
landing.position.copy(end).add(landingEnd).multiplyScalar(.5);group.add(landing);
|
||||
for(const sign of [-1,1]) {
|
||||
const offset=new Vector3(0,0,sign*config.width/2);
|
||||
const a=end.clone().add(offset),b=landingEnd.clone().add(offset);
|
||||
beam(a,b);beam(a,a.clone().addScaledVector(up,config.railHeight));
|
||||
beam(b,b.clone().addScaledVector(up,config.railHeight));
|
||||
beam(a.clone().addScaledVector(up,config.railHeight),b.clone().addScaledVector(up,config.railHeight));
|
||||
}
|
||||
// Keep the static repeated treads and rails in one draw call.
|
||||
const originals=new Set<import('three').BufferGeometry>();
|
||||
for(const material of [metal]) {
|
||||
const parts=group.children.filter((o): o is Mesh => o instanceof Mesh && o.material===material);
|
||||
const transformed=parts.map(mesh=>{mesh.updateMatrix();originals.add(mesh.geometry);return mesh.geometry.clone().applyMatrix4(mesh.matrix);});
|
||||
const merged=new Mesh(mergeGeometries(transformed),material);
|
||||
merged.name='Radar access stairs and railing';
|
||||
parts.forEach(mesh=>group.remove(mesh));transformed.forEach(g=>g.dispose());group.add(merged);
|
||||
}
|
||||
originals.forEach(g=>g.dispose());
|
||||
return { group, dispose() {
|
||||
const geometries=new Set<import('three').BufferGeometry>();
|
||||
group.traverse(o=>{if(o instanceof Mesh)geometries.add(o.geometry);});
|
||||
geometries.forEach(g=>g.dispose());stone.dispose();metal.dispose();
|
||||
}};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { BufferGeometry } from 'three';
|
||||
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
|
||||
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
|
||||
import { TERRAIN } from '../constants/terrain.ts';
|
||||
import { structurePosition } from './structures.ts';
|
||||
import { terrainGeometryHeightAt } from './terrainGeometry.ts';
|
||||
import type { TerrainMetadata } from './terrainGeometry.ts';
|
||||
|
||||
/** Apply only to the Gwanaksan mesh. Original height arrays and reference origin stay unchanged. */
|
||||
export function applySummitTerrain(geometry: BufferGeometry, meta: TerrainMetadata) {
|
||||
const start = structurePosition(SUMMIT_TERRAIN.start.latitude, SUMMIT_TERRAIN.start.longitude, meta.origin);
|
||||
// Sample all residuals BEFORE editing any vertex, avoiding order-dependent corrections.
|
||||
const controls = SUMMIT_TERRAIN.controls.map(control => {
|
||||
const point = structurePosition(control.latitude, control.longitude, meta.origin);
|
||||
const original = terrainGeometryHeightAt(geometry, point.x, point.z) + meta.observerSurfaceElevation
|
||||
+ (point.x * point.x + point.z * point.z) / (2 * TERRAIN.earthRadiusMeters);
|
||||
return { x: point.x, z: point.z, residual: control.elevation - original };
|
||||
});
|
||||
const positions = geometry.getAttribute('position');
|
||||
const key = (x: number, z: number) => `${Math.round(x * 1000)},${Math.round(z * 1000)}`;
|
||||
const slab = new Map(SUMMIT_PLATEAU.heights.map(([x,z,height]) => [key(x,z),height]));
|
||||
const xs = [...new Set(SUMMIT_PLATEAU.heights.map(p=>p[0]))].sort((a,b)=>a-b);
|
||||
const zs = [...new Set(SUMMIT_PLATEAU.heights.map(p=>p[1]))].sort((a,b)=>a-b);
|
||||
const authoredHeight = (x:number,z:number) => {
|
||||
if(x<xs[0] || x>xs.at(-1)! || z<zs[0] || z>zs.at(-1)!)return undefined;
|
||||
const cell=(axis:number[],v:number)=>{let lo=0,hi=axis.length-1;while(hi-lo>1){const m=(lo+hi)>>1;if(axis[m]<=v)lo=m;else hi=m;}return lo;};
|
||||
const i=cell(xs,x),j=cell(zs,z),u=(x-xs[i])/(xs[i+1]-xs[i]),v=(z-zs[j])/(zs[j+1]-zs[j]);
|
||||
const a=slab.get(key(xs[i],zs[j]))!,b=slab.get(key(xs[i+1],zs[j]))!;
|
||||
const c=slab.get(key(xs[i],zs[j+1]))!,d=slab.get(key(xs[i+1],zs[j+1]))!;
|
||||
return u+v<=1 ? a*(1-u-v)+b*u+c*v : b*(1-v)+c*(1-u)+d*(u+v-1);
|
||||
};
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
const x = positions.getX(i), z = positions.getZ(i);
|
||||
const radius = Math.hypot(x - start.x, z - start.z);
|
||||
if (radius >= SUMMIT_TERRAIN.outerRadius) continue;
|
||||
let weighted = 0, weights = 0;
|
||||
for (const control of controls) {
|
||||
const distanceSquared = (x - control.x) ** 2 + (z - control.z) ** 2;
|
||||
if (distanceSquared < 1e-8) { weighted = control.residual; weights = 1; break; }
|
||||
const weight = 1 / (distanceSquared * distanceSquared);
|
||||
weighted += control.residual * weight;
|
||||
weights += weight;
|
||||
}
|
||||
const t = Math.max(0, (radius - SUMMIT_TERRAIN.coreRadius) / (SUMMIT_TERRAIN.outerRadius - SUMMIT_TERRAIN.coreRadius));
|
||||
const fade = 1 - t * t * (3 - 2 * t);
|
||||
positions.setY(i, positions.getY(i) + weighted / weights * fade);
|
||||
const authored = authoredHeight(x - start.x, z - start.z);
|
||||
if (authored !== undefined) positions.setY(i, authored - meta.observerSurfaceElevation
|
||||
- (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters));
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingSphere();
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { BufferGeometry, Color, Group, Mesh, MeshStandardMaterial, RepeatWrapping, ShaderChunk, SRGBColorSpace, TextureLoader, Vector2 } from 'three';
|
||||
import { SUMMIT_MATERIALS } from '../constants/summitMaterials.ts';
|
||||
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
|
||||
|
||||
/** Illustrative land cover, not photogrammetry. Uses the same corridors as walking. */
|
||||
export function createSummitTrails(geometry: BufferGeometry, onTextureLoaded: ()=>void = ()=>{}, start={x:0,z:0}) {
|
||||
const group=new Group();
|
||||
group.name='Summit trails and estimated land cover (200m)';
|
||||
const loader=new TextureLoader();
|
||||
const extraTextures: import('three').Texture[]=[];
|
||||
let disposed=false;
|
||||
let rockMaterial: MeshStandardMaterial;
|
||||
function createMesh() {
|
||||
const name='forest';
|
||||
const settings=SUMMIT_MATERIALS.forest;
|
||||
const load=(kind:string,surface=name)=>{
|
||||
const texture=loader.load(`${SUMMIT_MATERIALS.baseUrl}/${surface}-${kind}.jpg`,()=>{if(!disposed)onTextureLoaded();});
|
||||
texture.wrapS=texture.wrapT=RepeatWrapping;
|
||||
texture.anisotropy=SUMMIT_MATERIALS.anisotropy;
|
||||
if(kind==='color')texture.colorSpace=SRGBColorSpace;
|
||||
return texture;
|
||||
};
|
||||
const material=new MeshStandardMaterial({color:new Color(settings.tint),vertexColors:true,
|
||||
map:load('color'),normalMap:load('normal'),roughnessMap:load('roughness'),
|
||||
normalScale:new Vector2(settings.normalStrength,settings.normalStrength),roughness:1,
|
||||
polygonOffset:true,polygonOffsetFactor:-1,polygonOffsetUnits:-1});
|
||||
{
|
||||
material.color.set('#ffffff');
|
||||
const pathColor=load('color','path'),pathNormal=load('normal','path'),pathRoughness=load('roughness','path');
|
||||
extraTextures.push(pathColor,pathNormal,pathRoughness);
|
||||
const rockColor=load('color','rock'),rockNormal=load('normal','rock'),rockRoughness=load('roughness','rock');
|
||||
extraTextures.push(rockColor,rockNormal,rockRoughness);
|
||||
const plateauColor=loader.load(`${SUMMIT_MATERIALS.baseUrl}/plateau-color.png`,()=>{if(!disposed)onTextureLoaded();});
|
||||
plateauColor.colorSpace=SRGBColorSpace;plateauColor.anisotropy=SUMMIT_MATERIALS.anisotropy;extraTextures.push(plateauColor);
|
||||
rockMaterial=new MeshStandardMaterial({map:rockColor,normalMap:rockNormal,roughnessMap:rockRoughness,
|
||||
normalScale:new Vector2(SUMMIT_MATERIALS.rock.normalStrength,SUMMIT_MATERIALS.rock.normalStrength),roughness:1});
|
||||
const detail=SUMMIT_MATERIALS.rock;
|
||||
const secondaryUv=`uv*${detail.secondaryScale}+vec2(${detail.secondaryOffset.join(',')})`;
|
||||
const secondaryBlend=`${detail.secondaryBlend}+${detail.secondaryVariation}*sin(uv.x*0.71+sin(uv.y*0.53))`;
|
||||
rockMaterial.onBeforeCompile=shader=>{
|
||||
shader.fragmentShader=`vec4 rockDetail(sampler2D tex,vec2 uv) {
|
||||
return mix(texture2D(tex,uv),texture2D(tex,${secondaryUv}),${secondaryBlend});
|
||||
}\n`+shader.fragmentShader;
|
||||
for(const [chunk,map,uv] of [['map_fragment','map','vMapUv'],['normal_fragment_maps','normalMap','vNormalMapUv'],['roughnessmap_fragment','roughnessMap','vRoughnessMapUv']] as const)
|
||||
shader.fragmentShader=shader.fragmentShader.replace(`#include <${chunk}>`,ShaderChunk[chunk].replaceAll(`texture2D( ${map}, ${uv} )`,`rockDetail(${map},${uv})`));
|
||||
};
|
||||
rockMaterial.customProgramCacheKey=()=>`route-rock-detail-${detail.secondaryScale}-${secondaryBlend}`;
|
||||
// Keep leaf detail near the observer; avoid obvious repeated bands across distant slopes.
|
||||
material.onBeforeCompile=shader=>{
|
||||
shader.uniforms.farGroundColor={value:new Color(SUMMIT_MATERIALS.forest.farColor)};
|
||||
shader.uniforms.forestTint={value:new Color(SUMMIT_MATERIALS.forest.tint)};
|
||||
shader.uniforms.trailColor={value:pathColor};shader.uniforms.trailNormal={value:pathNormal};shader.uniforms.trailRoughness={value:pathRoughness};
|
||||
shader.uniforms.rockColor={value:rockColor};shader.uniforms.rockNormal={value:rockNormal};shader.uniforms.rockRoughness={value:rockRoughness};
|
||||
shader.uniforms.plateauColor={value:plateauColor};
|
||||
shader.vertexShader='attribute float trailDistance,rockDistance; varying float vTrailDistance,vRockDistance;\n'+shader.vertexShader;
|
||||
shader.vertexShader=shader.vertexShader.replace('#include <begin_vertex>','#include <begin_vertex>\nvTrailDistance=trailDistance;vRockDistance=rockDistance;');
|
||||
const edge=SUMMIT_MATERIALS.edgeBlend;
|
||||
const rockEdge=SUMMIT_MATERIALS.rockBlend;
|
||||
shader.fragmentShader=`uniform vec3 farGroundColor,forestTint;
|
||||
uniform sampler2D trailColor,trailNormal,trailRoughness;
|
||||
uniform sampler2D rockColor,rockNormal,rockRoughness;
|
||||
uniform sampler2D plateauColor;
|
||||
varying float vTrailDistance,vRockDistance;
|
||||
vec2 summitDx,summitDy;
|
||||
float summitRock,summitTrail,summitFade;
|
||||
vec2 rockUv(vec2 uv) { return uv*${(SUMMIT_MATERIALS.forest.tileMeters/SUMMIT_MATERIALS.rock.tileMeters).toFixed(6)}; }
|
||||
float rockWeight(vec2 uv) {
|
||||
float scatter=sin(uv.x*9.7+sin(uv.y*11.3))*sin(uv.y*7.1+uv.x*4.3);
|
||||
return 1.0-smoothstep(${rockEdge.innerMeters},${rockEdge.outerMeters},vRockDistance+scatter*${rockEdge.irregularityMeters});
|
||||
}
|
||||
float trailWeight(vec2 uv) {
|
||||
float scatter=sin(uv.x*13.7+sin(uv.y*8.3))*sin(uv.y*17.1+uv.x*3.1);
|
||||
return 1.0-smoothstep(${edge.innerMeters},${edge.outerMeters},vTrailDistance+scatter*${edge.irregularityMeters});
|
||||
}
|
||||
vec4 summitTex(sampler2D tex,vec2 uv,float scale) {
|
||||
return textureGrad(tex,uv,summitDx*scale,summitDy*scale);
|
||||
}
|
||||
vec4 summitGroundSample(sampler2D tex,vec2 uv) {
|
||||
float blend=0.5+0.5*sin(uv.x*0.71+sin(uv.y*0.53));
|
||||
return mix(summitTex(tex,uv,1.0),summitTex(tex,uv*1.37+vec2(0.31,0.73),1.37),blend);
|
||||
}
|
||||
vec4 summitRockSample(sampler2D tex,vec2 uv) {
|
||||
float scale=${(SUMMIT_MATERIALS.forest.tileMeters/SUMMIT_MATERIALS.rock.tileMeters).toFixed(6)};
|
||||
return mix(summitTex(tex,uv,scale),summitTex(tex,${secondaryUv},scale*${detail.secondaryScale}),${secondaryBlend});
|
||||
}
|
||||
// Uniform interiors sample one material; only transition fragments sample neighbours.
|
||||
vec4 summitSample(sampler2D groundMap,sampler2D dirtMap,sampler2D stoneMap,vec2 uv,int kind) {
|
||||
vec4 stone=vec4(0.0),dirt=vec4(0.0);
|
||||
if(summitRock>0.0) {
|
||||
stone=summitRockSample(stoneMap,rockUv(uv));
|
||||
vec2 local=vec2(uv.x,-uv.y)*${SUMMIT_MATERIALS.forest.tileMeters.toFixed(1)}-vec2(${start.x.toFixed(6)},${start.z.toFixed(6)});
|
||||
vec2 atlas=(local-vec2(${SUMMIT_PLATEAU.atlasBounds.slice(0,2).join(',')}))/vec2(${SUMMIT_PLATEAU.atlasBounds[2]-SUMMIT_PLATEAU.atlasBounds[0]},${SUMMIT_PLATEAU.atlasBounds[3]-SUMMIT_PLATEAU.atlasBounds[1]});
|
||||
float atlasMask=smoothstep(0.0,0.035,min(min(atlas.x,atlas.y),min(1.0-atlas.x,1.0-atlas.y)));
|
||||
if(kind==0)stone.rgb=mix(stone.rgb,texture2D(plateauColor,vec2(atlas.x,1.0-atlas.y)).rgb,atlasMask*0.95);
|
||||
if(kind==1)stone.xy=mix(stone.xy,vec2(0.5)+(stone.xy-0.5)*0.32,atlasMask);
|
||||
if(kind==1)stone.xy=(stone.xy-0.5)*${SUMMIT_MATERIALS.rock.normalStrength/SUMMIT_MATERIALS.forest.normalStrength}+0.5;
|
||||
if(summitRock>=1.0)return stone;
|
||||
}
|
||||
if(summitTrail>0.0) {
|
||||
dirt=summitTex(dirtMap,uv,1.0);
|
||||
if(kind==1)dirt.xy=(dirt.xy-0.5)*${SUMMIT_MATERIALS.path.normalStrength/SUMMIT_MATERIALS.forest.normalStrength}+0.5;
|
||||
if(summitTrail>=1.0)return mix(dirt,stone,summitRock);
|
||||
}
|
||||
vec4 ground=summitGroundSample(groundMap,uv);
|
||||
if(kind==0)ground.rgb=mix(ground.rgb*forestTint,farGroundColor,summitFade);
|
||||
return mix(mix(ground,dirt,summitTrail),stone,summitRock);
|
||||
}\n`+shader.fragmentShader;
|
||||
shader.fragmentShader=shader.fragmentShader.replace('#include <map_fragment>',
|
||||
`// Derivatives are computed before divergent branches to preserve mip selection at edges.
|
||||
summitDx=dFdx(vMapUv);summitDy=dFdy(vMapUv);
|
||||
summitRock=rockWeight(vMapUv);summitTrail=trailWeight(vMapUv);
|
||||
summitFade=smoothstep(${SUMMIT_MATERIALS.forest.fadeNear.toFixed(1)},${SUMMIT_MATERIALS.forest.fadeFar.toFixed(1)},length(vViewPosition))*0.9;
|
||||
${ShaderChunk.map_fragment.replace('texture2D( map, vMapUv )','summitSample(map,trailColor,rockColor,vMapUv,0)')}`);
|
||||
shader.fragmentShader=shader.fragmentShader.replace('#include <normal_fragment_maps>',
|
||||
ShaderChunk.normal_fragment_maps.replaceAll('texture2D( normalMap, vNormalMapUv )','summitSample(normalMap,trailNormal,rockNormal,vNormalMapUv,1)'));
|
||||
shader.fragmentShader=shader.fragmentShader.replace('#include <roughnessmap_fragment>',
|
||||
ShaderChunk.roughnessmap_fragment.replace('texture2D( roughnessMap, vRoughnessMapUv )','summitSample(roughnessMap,trailRoughness,rockRoughness,vRoughnessMapUv,2)'));
|
||||
};
|
||||
}
|
||||
const mesh=new Mesh(geometry,material);mesh.name=`Summit ${name} PBR surface`;group.add(mesh);
|
||||
return mesh;
|
||||
}
|
||||
const meshes=[createMesh()];
|
||||
return {group,rockMaterial:rockMaterial!,dispose(){
|
||||
disposed=true;
|
||||
rockMaterial.dispose();
|
||||
extraTextures.forEach(texture=>texture.dispose());
|
||||
for(const {geometry,material} of meshes) {
|
||||
geometry.dispose();material.map?.dispose();material.normalMap?.dispose();material.roughnessMap?.dispose();material.dispose();
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { AdditiveBlending, Color, DirectionalLight, Group, MathUtils, Mesh, PlaneGeometry, ShaderMaterial, Vector3 } from 'three';
|
||||
import { SCENE } from '../constants/scene.ts';
|
||||
import { cloudShader } from './clouds.ts';
|
||||
|
||||
/** Optical geometry only: the supplied unit direction is never changed here. */
|
||||
export function createSun(eye: Vector3) {
|
||||
const group = new Group();
|
||||
const color = new Color();
|
||||
const warm = new Color('#ff7835');
|
||||
const white = new Color('#fff9df');
|
||||
const radiance = { value: SCENE.sunRadianceAtHorizon as number };
|
||||
const transmission = { value: 1 };
|
||||
const cloudCoverage = { value: 0 };
|
||||
function layer(diameter: number, halo: boolean) {
|
||||
const size = 2 * SCENE.sunDistance * Math.tan(MathUtils.degToRad(diameter / 2));
|
||||
const material = new ShaderMaterial({
|
||||
uniforms: { tint: { value: color }, halo: { value: halo }, radiance, transmission, cloudCoverage,
|
||||
haloOpacity: { value: SCENE.haloOpacity } },
|
||||
vertexShader: `varying vec2 vUv; varying vec3 vCloudDirection;
|
||||
void main() { vUv = uv; vec4 world = modelMatrix * vec4(position, 1.0);
|
||||
vCloudDirection = world.xyz - cameraPosition;
|
||||
gl_Position = projectionMatrix * viewMatrix * world; }`,
|
||||
fragmentShader: `${cloudShader}
|
||||
uniform vec3 tint; uniform bool halo; uniform float cloudCoverage;
|
||||
uniform float radiance; uniform float haloOpacity; uniform float transmission;
|
||||
varying vec2 vUv; varying vec3 vCloudDirection;
|
||||
void main() {
|
||||
float r = length(vUv - 0.5) * 2.0;
|
||||
float alpha = halo ? exp(-r * r * 11.0) * (1.0 - smoothstep(0.6, 1.0, r)) * haloOpacity
|
||||
: 1.0 - smoothstep(0.96, 1.0, r);
|
||||
gl_FragColor = vec4(tint * (halo ? 1.0 : radiance), alpha);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
// Fade after tone mapping too: a bright HDR disc must not stay white in overcast skies.
|
||||
vec3 direction = normalize(vCloudDirection);
|
||||
float cloud = weatherCloudOpacity(direction, cloudCoverage, weatherCloudField(direction));
|
||||
gl_FragColor.a *= transmission * (1.0 - cloud);
|
||||
}`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
...(halo ? { blending: AdditiveBlending } : {}),
|
||||
});
|
||||
const mesh = new Mesh(new PlaneGeometry(size, size), material);
|
||||
mesh.renderOrder = halo ? 1 : 2;
|
||||
group.add(mesh);
|
||||
return mesh;
|
||||
}
|
||||
layer(SCENE.haloDiameterDegrees, true);
|
||||
layer(SCENE.sunDiameterDegrees, false);
|
||||
const light = new DirectionalLight();
|
||||
light.target.position.copy(eye);
|
||||
return {
|
||||
group, light,
|
||||
update(direction: Vector3, altitude: number, weatherTransmission = 1, coverage = 0) {
|
||||
transmission.value = weatherTransmission;
|
||||
cloudCoverage.value = coverage;
|
||||
group.position.copy(eye).addScaledVector(direction, SCENE.sunDistance);
|
||||
group.lookAt(eye);
|
||||
// Terrain (and the distant reference Earth) occludes the disc by depth.
|
||||
// A 0° cutoff would incorrectly hide sunrise seen from an elevated surface.
|
||||
color.copy(warm).lerp(white, MathUtils.smoothstep(altitude, 0, 18));
|
||||
// Visual exposure approximation: retain warm disc color near sunrise,
|
||||
// then approach the bright daytime disc without changing its angular size.
|
||||
radiance.value = MathUtils.lerp(SCENE.sunRadianceAtHorizon, SCENE.sunRadianceAtDay,
|
||||
MathUtils.smoothstep(altitude, 0, SCENE.sunRadianceDayAltitude)) * weatherTransmission;
|
||||
light.color.copy(color);
|
||||
light.intensity = 2 * MathUtils.smoothstep(altitude, -1, 25) * weatherTransmission;
|
||||
light.position.copy(group.position);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { BufferAttribute, Color, Mesh, MeshStandardMaterial } from 'three';
|
||||
import { TERRAIN } from '../constants/terrain.ts';
|
||||
import { observer } from '../config/observer.ts';
|
||||
import { createTerrainGeometry, terrainGeometryHeightAt, validateTerrain } from './terrainGeometry.ts';
|
||||
import type { TerrainMetadata } from './terrainGeometry.ts';
|
||||
import { applyCoastalTerrain } from './coast.ts';
|
||||
import type { LoadedCoast } from './coast.ts';
|
||||
import { applySummitTerrain } from './summitTerrain.ts';
|
||||
|
||||
export async function loadTerrain(paths: { metadataUrl: string; heightsUrl: string } = TERRAIN, origin = observer) {
|
||||
const responses = await Promise.all([fetch(paths.metadataUrl), fetch(paths.heightsUrl)]);
|
||||
if (responses.some(response => !response.ok)) throw new Error('지형 파일을 내려받지 못했습니다.');
|
||||
const [metadata, buffer] = await Promise.all([
|
||||
responses[0].json() as Promise<TerrainMetadata>, responses[1].arrayBuffer(),
|
||||
]);
|
||||
const heights = new Float32Array(buffer);
|
||||
validateTerrain(metadata, heights, origin);
|
||||
return { metadata, heights };
|
||||
}
|
||||
|
||||
export function createTerrain(data: Awaited<ReturnType<typeof loadTerrain>>, coast?: LoadedCoast, reconstructSummit = false) {
|
||||
const geometry = createTerrainGeometry(data.metadata, data.heights, true);
|
||||
if (reconstructSummit && !coast) applySummitTerrain(geometry, data.metadata);
|
||||
const positions = geometry.getAttribute('position');
|
||||
const normals = geometry.getAttribute('normal');
|
||||
const colors = new Float32Array(positions.count * 3);
|
||||
const low = new Color('#596047'), high = new Color('#7d8167'), rock = new Color('#94958a');
|
||||
const color = new Color();
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
const relativeHeight = Math.min(1, Math.max(0, (positions.getY(i) + data.metadata.observerSurfaceElevation) / 650));
|
||||
const slope = Math.min(1, (1 - normals.getY(i)) * 2.5);
|
||||
color.copy(low).lerp(high, relativeHeight).lerp(rock, slope);
|
||||
color.toArray(colors, i * 3);
|
||||
}
|
||||
geometry.setAttribute('color', new BufferAttribute(colors, 3));
|
||||
// Color is illustrative elevation/slope shading, not aerial imagery or land cover.
|
||||
const material = new MeshStandardMaterial({ vertexColors: true, roughness: 1, metalness: 0 });
|
||||
if (coast) applyCoastalTerrain(material, coast);
|
||||
const mesh = new Mesh(geometry, material);
|
||||
mesh.name = 'Copernicus GLO-30 terrain';
|
||||
return { mesh, heightAt: (x: number, z: number) => terrainGeometryHeightAt(geometry, x, z), dispose() { geometry.dispose(); material.dispose(); } };
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { BufferAttribute, BufferGeometry } from 'three';
|
||||
import { TERRAIN } from '../constants/terrain.ts';
|
||||
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
|
||||
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
|
||||
import { structurePosition } from './structures.ts';
|
||||
|
||||
export interface TerrainMetadata {
|
||||
version: number;
|
||||
dataset: string;
|
||||
surfaceType: string;
|
||||
verticalDatum: string;
|
||||
origin: { latitude: number; longitude: number };
|
||||
width: number;
|
||||
height: number;
|
||||
spacingMeters: number;
|
||||
radiusMeters: number;
|
||||
observerSurfaceElevation: number;
|
||||
minElevation: number;
|
||||
maxElevation: number;
|
||||
geographicCorners: [number, number][];
|
||||
sources: { file: string; bytes: number; sha256: string; url: string }[];
|
||||
attribution: string;
|
||||
}
|
||||
|
||||
export function validateTerrain(meta: TerrainMetadata, heights: Float32Array, origin: TerrainMetadata['origin']) {
|
||||
if (meta.version !== 1 || meta.width < 2 || meta.height < 2 ||
|
||||
!Number.isInteger(meta.width) || !Number.isInteger(meta.height) ||
|
||||
meta.width !== meta.height || meta.width % 2 !== 1 ||
|
||||
!Number.isFinite(meta.spacingMeters) || meta.spacingMeters <= 0 ||
|
||||
meta.radiusMeters !== (meta.width - 1) * meta.spacingMeters / 2 ||
|
||||
heights.length !== meta.width * meta.height ||
|
||||
!Number.isFinite(meta.observerSurfaceElevation) ||
|
||||
heights.some(value => !Number.isFinite(value) || value < -500 || value > 9000)) {
|
||||
throw new Error('지형 격자 크기·높이 데이터가 올바르지 않습니다.');
|
||||
}
|
||||
if (Math.abs(meta.origin.latitude - origin.latitude) > 1e-8 ||
|
||||
Math.abs(meta.origin.longitude - origin.longitude) > 1e-8) {
|
||||
throw new Error('관측점 좌표가 지형 원점과 다릅니다. 지형 데이터를 다시 생성해 주세요.');
|
||||
}
|
||||
if (Math.abs(terrainHeightAt(meta, heights, 0, 0) - meta.observerSurfaceElevation) > 0.01) {
|
||||
throw new Error('관측점 표면 높이가 지형 데이터와 다릅니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Local east (x), south (z) in meters → source EGM2008 surface elevation. */
|
||||
export function terrainHeightAt(meta: TerrainMetadata, heights: Float32Array, x: number, z: number): number {
|
||||
const col = (x + meta.radiusMeters) / meta.spacingMeters;
|
||||
const row = (z + meta.radiusMeters) / meta.spacingMeters;
|
||||
if (col < 0 || row < 0 || col > meta.width - 1 || row > meta.height - 1) throw new RangeError('Outside terrain coverage');
|
||||
const left = Math.min(Math.floor(col), meta.width - 2);
|
||||
const top = Math.min(Math.floor(row), meta.height - 2);
|
||||
const u = col - left, v = row - top;
|
||||
const offset = top * meta.width + left;
|
||||
return (1 - v) * ((1 - u) * heights[offset] + u * heights[offset + 1])
|
||||
+ v * ((1 - u) * heights[offset + meta.width] + u * heights[offset + meta.width + 1]);
|
||||
}
|
||||
|
||||
export function createTerrainGeometry(meta: TerrainMetadata, heights: Float32Array, refine = false): BufferGeometry {
|
||||
// Keep every source sample. Only insert vertices near the observation origin.
|
||||
const axis: number[] = [];
|
||||
for (let c = 0; c < meta.width - 1; c++) {
|
||||
const x = c * meta.spacingMeters - meta.radiusMeters;
|
||||
const segments = refine && Math.abs(x + meta.spacingMeters / 2) < TERRAIN.refinementRadiusMeters ? TERRAIN.refinementSegments : 1;
|
||||
for (let j = 0; j < segments; j++) axis.push(x + j * meta.spacingMeters / segments);
|
||||
}
|
||||
axis.push(meta.radiusMeters);
|
||||
if (refine) {
|
||||
const start = structurePosition(SUMMIT_TERRAIN.start.latitude, SUMMIT_TERRAIN.start.longitude, meta.origin);
|
||||
// Retain the source lattice and insert the Blender sculpt's half-metre samples.
|
||||
const detailed = new Set(axis);
|
||||
for (const [x,z] of SUMMIT_PLATEAU.heights) {
|
||||
for (const value of [start.x+x,start.z+z]) {
|
||||
const snapped = Math.round(value * 1000) / 1000;
|
||||
if (Math.abs(snapped) < meta.radiusMeters) detailed.add(snapped);
|
||||
}
|
||||
}
|
||||
axis.splice(0,axis.length,...[...detailed].sort((a,b)=>a-b));
|
||||
}
|
||||
const width = axis.length;
|
||||
const sample = (c: number, r: number) => heights[Math.max(0, Math.min(meta.height - 1, r)) * meta.width + Math.max(0, Math.min(meta.width - 1, c))];
|
||||
const cubic = (a: number, b: number, c: number, d: number, t: number) => {
|
||||
const slope = (u: number, v: number) => u*v <= 0 ? 0 : 2*u*v/(u+v);
|
||||
const m0 = slope(b-a,c-b), m1 = slope(c-b,d-c);
|
||||
return (2*t*t*t-3*t*t+1)*b + (t*t*t-2*t*t+t)*m0
|
||||
+ (-2*t*t*t+3*t*t)*c + (t*t*t-t*t)*m1;
|
||||
};
|
||||
const elevation = (x: number, z: number) => {
|
||||
const u = (x + meta.radiusMeters) / meta.spacingMeters, v = (z + meta.radiusMeters) / meta.spacingMeters;
|
||||
const c = Math.floor(u), r = Math.floor(v), tx = u-c, tz = v-r;
|
||||
const rows = [-1,0,1,2].map(d => cubic(sample(c-1,r+d),sample(c,r+d),sample(c+1,r+d),sample(c+2,r+d),tx));
|
||||
const corners = [sample(c,r),sample(c+1,r),sample(c,r+1),sample(c+1,r+1)];
|
||||
return Math.max(Math.min(...corners), Math.min(Math.max(...corners), cubic(rows[0],rows[1],rows[2],rows[3],tz)));
|
||||
};
|
||||
const positions = new Float32Array(width * width * 3);
|
||||
for (let row = 0; row < width; row++) {
|
||||
for (let col = 0; col < width; col++) {
|
||||
const index = row * width + col;
|
||||
const x = axis[col], z = axis[row];
|
||||
const curvature = (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
|
||||
const originalCol = (x + meta.radiusMeters) / meta.spacingMeters;
|
||||
const originalRow = (z + meta.radiusMeters) / meta.spacingMeters;
|
||||
const h = refine ? (Number.isInteger(originalCol) && Number.isInteger(originalRow)
|
||||
? sample(originalCol,originalRow) : elevation(x,z)) : heights[index];
|
||||
positions.set([x, h - meta.observerSurfaceElevation - curvature, z], index * 3);
|
||||
}
|
||||
}
|
||||
const indices = new Uint32Array((width - 1) * (width - 1) * 6);
|
||||
let offset = 0;
|
||||
for (let row = 0; row < width - 1; row++) {
|
||||
for (let col = 0; col < width - 1; col++) {
|
||||
const a = row * width + col, b = a + 1, c = a + width, d = c + 1;
|
||||
indices.set([a, c, b, b, c, d], offset);
|
||||
offset += 6;
|
||||
}
|
||||
}
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute('position', new BufferAttribute(positions, 3));
|
||||
geometry.setIndex(new BufferAttribute(indices, 1));
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingSphere();
|
||||
geometry.userData.axis = axis;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/** Match the refined rendered triangles so eye height stays above the visible ground. */
|
||||
export function terrainGeometryHeightAt(geometry: BufferGeometry, x: number, z: number) {
|
||||
const axis = geometry.userData.axis as number[];
|
||||
const cell = (value: number) => {
|
||||
let lo = 0, hi = axis.length - 1;
|
||||
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (axis[mid] <= value) lo = mid; else hi = mid; }
|
||||
return lo;
|
||||
};
|
||||
const col = cell(x), row = cell(z), width = axis.length;
|
||||
const u = (x-axis[col])/(axis[col+1]-axis[col]), v = (z-axis[row])/(axis[row+1]-axis[row]);
|
||||
const p = geometry.getAttribute('position'), k = row*width+col;
|
||||
const a=p.getY(k), b=p.getY(k+1), c=p.getY(k+width), d=p.getY(k+width+1);
|
||||
return u+v <= 1 ? a*(1-u-v)+b*u+c*v : b*(1-v)+c*(1-u)+d*(u+v-1);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
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<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;
|
||||
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();
|
||||
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<HTMLButtonElement>('#emote-wave')!;
|
||||
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 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<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();
|
||||
};
|
||||
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;
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
@font-face { font-family: "Pretendard Variable"; src: url('/fonts/PretendardVariable.woff2') format('woff2'); font-style: normal; font-weight: 45 920; font-display: swap; }
|
||||
/* Google Fonts subset: 해 보러가기 (the brand title only). */
|
||||
@font-face { font-family: "Gowun Dodum"; src: url('/fonts/GowunDodum-Title.ttf') format('truetype'); font-style: normal; font-weight: 400; font-display: swap; }
|
||||
:root { font-family: "Pretendard Variable", "Segoe UI", "Malgun Gothic", sans-serif; color: #e8edf2; background: #080f19; font-synthesis: none; color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body { margin: 0; overflow: hidden; }
|
||||
#world { display: block; width: 100vw; height: 100vh; height: 100dvh; touch-action: none; }
|
||||
.icon-definitions { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||
.icon { width: 21px; height: 21px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; }
|
||||
button, input { font: inherit; }
|
||||
button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 44px; min-height: 46px; padding: 11px 15px; border: 1px solid #ffffff26; border-radius: 14px; background: #23313e; color: #eef1f4; cursor: pointer; font-size: 14px; touch-action: manipulation; }
|
||||
button:hover:not(:disabled) { background: #334657; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
button[aria-pressed=true], button.primary { background: #e3ba86; color: #28251f; border-color: #e3ba86; }
|
||||
button[aria-pressed=true]:hover, button.primary:hover { background: #f0cca0; }
|
||||
:focus-visible { outline: 2px solid #f0c48e; outline-offset: 3px; }
|
||||
.hud { position: fixed; inset: 0; pointer-events: none; }
|
||||
#compass { position: absolute; top: max(18px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); width: min(460px, calc(100vw - 360px)); height: 66px; color: #f4f2e9; text-shadow: 0 1px 3px #000, 0 0 6px #0009; pointer-events: none; }
|
||||
.compass-scale { position: relative; height: 43px; overflow: hidden; mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent); }
|
||||
.compass-tick { position: absolute; top: 0; transform: translateX(-50%); height: 39px; padding-top: 18px; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.compass-tick::before { content: ''; position: absolute; top: 5px; left: 50%; width: 1px; height: 6px; background: #f4f2e9a6; box-shadow: 0 1px 3px #000; }
|
||||
.compass-tick.major::before, .compass-tick.cardinal::before { height: 10px; background: #f4f2e9; }
|
||||
.compass-tick.cardinal { font-size: 15px; font-weight: 600; }
|
||||
.compass-pointer { position: absolute; top: -3px; left: 50%; transform: translateX(-50%); border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 6px solid #f0c48e; filter: drop-shadow(0 1px 2px #000); }
|
||||
.compass-bearing { display: block; width: max-content; margin: 0 auto; padding: 1px 7px; border-radius: 4px; background: #10182370; color: #f0c48e; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
@media (max-width: 760px) { #compass { top: calc(max(18px, env(safe-area-inset-top)) + 58px); width: min(400px, calc(100vw - 40px)); } }
|
||||
.scene-toolbar { position: absolute; top: max(18px, env(safe-area-inset-top)); left: max(18px, env(safe-area-inset-left)); right: max(18px, env(safe-area-inset-right)); display: flex; justify-content: space-between; gap: 12px; }
|
||||
.scene-toolbar button, .emote-controls button, #minimap-overlay { pointer-events: auto; box-shadow: 0 3px 14px #0003; }
|
||||
.toolbar-end, .button-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.emote-controls { position: absolute; right: max(18px, env(safe-area-inset-right)); bottom: max(24px, env(safe-area-inset-bottom)); display: flex; gap: 10px; }
|
||||
.emote-controls button { flex-direction: column; gap: 6px; min-width: 70px; padding: 12px 14px; background: #eee5d6; border-color: #fff3dd80; color: #3a382f; font-size: 12px; }
|
||||
.emote-controls button:hover:not(:disabled) { background: #fff0d8; }
|
||||
.emote-controls button[aria-pressed=true] { background: #e3ba86; border-color: #e3ba86; }
|
||||
.equipment-menu, .location-menu { position: relative; display: flex; }
|
||||
#equipment-toggle[aria-expanded=true] { background: #e3ba86; border-color: #e3ba86; }
|
||||
#equipment-options, #location-options { position: absolute; max-height: calc(100dvh - 120px); overflow-y: auto; pointer-events: auto; animation: equipment-expand .16s ease-out; }
|
||||
#equipment-options { right: 0; bottom: calc(100% + 10px); width: 100%; }
|
||||
#location-options { left: 0; top: calc(100% + 10px); width: max-content; min-width: 100%; max-width: calc(100vw - 36px); }
|
||||
#location-toggle[aria-expanded=true] { border-color: #e3ba86; }
|
||||
#location-options .site-navigation { gap: 8px; }
|
||||
#location-options a { display: flex; align-items: center; min-height: 46px; padding: 11px 15px; border-radius: 14px; background: #23313e; border-color: #ffffff26; color: #eef1f4; font-size: 14px; box-shadow: 0 3px 14px #0003; }
|
||||
#location-options a:hover { background: #334657; }
|
||||
#location-options a[aria-current=page] { background: #23313e; border-color: #e3ba86; color: #ffe0b9; }
|
||||
#location-options a[aria-disabled=true] { background: #23313e; border-color: #ffffff26; color: #87939e; opacity: 1; }
|
||||
.equipment-controls { display: grid; gap: 8px; }
|
||||
.equipment-controls button { width: 100%; min-height: 56px; }
|
||||
.equipment-controls .icon { width: 25px; height: 25px; }
|
||||
.emote-controls button[aria-disabled=true], .emote-controls button[aria-disabled=true]:hover { background: #64696f; border-color: #ffffff26; color: #bdc3c9; cursor: default; }
|
||||
#equipment-notice { position: fixed; z-index: 3; display: flex; align-items: center; gap: 10px; width: max-content; max-width: calc(100vw - 24px); padding: 13px 17px; border: 1px solid #ffffff26; border-radius: 16px; background: #1e2b36f2; box-shadow: 0 8px 28px #0004; color: #edf1f5; font-size: 13px; line-height: 1.6; pointer-events: none; backdrop-filter: blur(12px); animation: equipment-expand .16s ease-out; }
|
||||
#equipment-notice .icon { color: #e3ba86; }
|
||||
@keyframes equipment-expand { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@media (prefers-reduced-motion: reduce) { #equipment-options, #location-options, #equipment-notice { animation: none; } }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
|
||||
#ui-dialog { width: min(440px, calc(100vw - 32px)); max-width: none; max-height: calc(100vh - 32px); max-height: calc(100dvh - max(16px, env(safe-area-inset-top)) - max(16px, env(safe-area-inset-bottom))); padding: 0; border: 1px solid #52617070; border-radius: 22px; color: #e8edf2; background: #18232e; box-shadow: 0 24px 80px #0006; overflow: hidden; }
|
||||
#ui-dialog[open] { display: flex; flex-direction: column; }
|
||||
#ui-dialog::backdrop { background: #08121c65; }
|
||||
.dialog-header { display: flex; align-items: center; gap: 10px; padding: 16px 18px 12px; flex-shrink: 0; border-bottom: 1px solid #ffffff12; }
|
||||
.dialog-header h1 { flex: 1; font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.dialog-header button { background: transparent; padding: 8px; font-size: 22px; border-color: transparent; }
|
||||
.dialog-body { padding: 20px; overflow-y: auto; overscroll-behavior: contain; min-height: 0; }
|
||||
.dialog-body h2 { font-size: 16px; font-weight: 500; margin: 0 0 14px; }
|
||||
.menu-list { display: grid; gap: 9px; }
|
||||
.menu-list > button, .menu-list > a { width: 100%; text-align: left; justify-content: space-between; }
|
||||
.menu-list > a { display: block; padding: 14px; border: 1px solid #ffffff26; border-radius: 12px; color: #e8edf2; text-decoration: none; }
|
||||
.menu-list > a:hover { background: #334657; }
|
||||
.menu-current-mode { margin: 0 0 12px; }
|
||||
.menu-primary > a, .menu-primary > button { display: flex; align-items: center; min-height: 58px; padding: 16px; border-radius: 14px; background: #23313e; font-size: 16px; font-weight: 600; }
|
||||
.menu-primary > button { border-color: #e3ba8680; color: #ffe0b9; }
|
||||
.menu-secondary .menu-list > button { background: transparent; font-size: 13px; }
|
||||
.site-navigation a[aria-current=page] { border-color: #e3ba86; color: #ffe0b9; }
|
||||
.site-navigation a[aria-disabled=true] { opacity: .45; cursor: not-allowed; }
|
||||
.eyebrow { color: #a8bac8; font-size: 11px; letter-spacing: .14em; margin: 0 0 8px; }
|
||||
.muted, .unit { color: #adbdca; font-size: 12px; line-height: 1.7; }
|
||||
.muted { margin: 12px 0; }
|
||||
.date-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-size: 13px; font-variant-numeric: tabular-nums; margin-bottom: 20px; }
|
||||
.mode { border-radius: 5px; padding: 3px 6px; background: #304e42; color: #bce6cf; font-size: 11px; }
|
||||
.mode.custom { background: #5d4931; color: #ffe0b9; }
|
||||
.time-panel { display: grid; gap: 22px; border: 0; margin: 0; padding: 0; min-width: 0; }
|
||||
.time-panel:disabled { opacity: .5; }
|
||||
.date-field { display: grid; gap: 9px; font-size: 13px; }
|
||||
input[type=date], input[type=text] { min-width: 0; width: 100%; max-width: 100%; min-height: 46px; padding: 10px 12px; border: 1px solid #596875; border-radius: 10px; background: #111d28; color: #edf4fc; font-size: 16px; }
|
||||
.date-picker { position: relative; display: flex; align-items: center; gap: 10px; min-height: 46px; padding: 10px 14px; border: 1px solid #ffffff40; border-radius: 12px; background: #18232ed9; box-shadow: 0 3px 14px #0003; color: #edf4fc; font-size: 14px; font-variant-numeric: tabular-nums; text-shadow: none; }
|
||||
.date-picker .icon { color: #e3ba86; flex-shrink: 0; }
|
||||
.date-picker:focus-within { outline: 2px solid #e3ba86; outline-offset: 3px; }
|
||||
.date-picker input[type=date] { position: absolute; inset: 0; height: 100%; opacity: 0; cursor: pointer; }
|
||||
.date-picker input[type=date]::-webkit-calendar-picker-indicator { position: absolute; inset: 0; width: auto; height: auto; cursor: pointer; }
|
||||
#live-button { display: flex; align-items: center; justify-content: center; gap: 8px; min-width: 88px; border-radius: 12px; font-size: 12px; letter-spacing: .08em; color: #adbdca; }
|
||||
.live-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
#live-button[aria-pressed=true], #solo-time-controls #live-button[aria-pressed=true] { background: #173d32ed; border-color: #77d8ac80; color: #9aefc4; }
|
||||
#live-button[aria-pressed=true] .live-dot { box-shadow: 0 0 8px #77d8ac80; }
|
||||
.slider-label { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: 13px; }
|
||||
output { font-size: 20px; font-variant-numeric: tabular-nums; color: #ffe0b9; }
|
||||
input[type=range] { display: block; width: 100%; min-height: 36px; margin: 8px 0 0; accent-color: #e3ba86; cursor: pointer; }
|
||||
.ticks { display: flex; justify-content: space-between; font-size: 11px; color: #a1b1bf; font-variant-numeric: tabular-nums; }
|
||||
#solo-time-controls { position: absolute; left: max(18px, env(safe-area-inset-left)); bottom: max(24px, env(safe-area-inset-bottom)); width: min(420px, calc(100vw - 310px)); color: #f4f2e9; text-shadow: 0 1px 4px #000, 0 0 8px #0008; }
|
||||
#solo-time-controls .date-row { display: none; }
|
||||
#solo-time-controls .time-panel { grid-template-columns: minmax(0, 1fr) auto; gap: 10px 12px; }
|
||||
#solo-time-controls .date-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
#solo-time-controls .date-field { grid-column: 1; grid-row: 1; }
|
||||
#solo-time-controls #live-button { grid-column: 2; grid-row: 1; }
|
||||
#solo-time-controls .slider-field { grid-column: 1 / -1; }
|
||||
#solo-time-controls input, #solo-time-controls button { pointer-events: auto; }
|
||||
#solo-time-controls input[type=date], #solo-time-controls button { background: #18232ed9; border-color: #ffffff40; box-shadow: 0 3px 14px #0003; border-radius: 12px; }
|
||||
#solo-time-controls button:disabled { opacity: .75; }
|
||||
#solo-time-controls .slider-label label::after { content: ' · KST'; }
|
||||
#solo-time-controls .ticks { color: #e8edf2; }
|
||||
#solo-time-controls.shared-time .date-field,
|
||||
#solo-time-controls.shared-time #time-slider,
|
||||
#solo-time-controls.shared-time .ticks,
|
||||
#solo-time-controls.shared-time .slider-label label { display: none; }
|
||||
#solo-time-controls.shared-time .time-panel { grid-template-columns: auto auto; justify-content: start; gap: 16px; }
|
||||
#solo-time-controls.shared-time .slider-field { grid-column: 1; grid-row: 1; align-self: center; }
|
||||
#solo-time-controls.shared-time .slider-label { justify-content: flex-start; }
|
||||
@media (max-width: 600px) {
|
||||
#solo-time-controls { left: max(14px, env(safe-area-inset-left)); right: max(14px, env(safe-area-inset-right)); bottom: calc(max(20px, env(safe-area-inset-bottom)) + 90px); width: auto; }
|
||||
}
|
||||
.text-button { border: 0; padding-inline: 0; background: transparent; color: #e3ba86; }
|
||||
.full-width { width: 100%; }
|
||||
.angles { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.angles > div { display: flex; flex-direction: column; gap: 8px; }
|
||||
.label { color: #b9c6d0; font-size: 12px; }
|
||||
.angles strong { font-size: 28px; font-weight: 400; font-variant-numeric: tabular-nums; }
|
||||
.events { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: 20px; padding-top: 16px; border-top: 1px solid #ffffff18; font-size: 13px; }
|
||||
.events b { font-weight: 500; color: #ffe0b9; }
|
||||
#standard-times { display: block; }
|
||||
.weather-information { padding-top: 18px; margin-top: 18px; border-top: 1px solid #ffffff18; }
|
||||
.weather-information h2 { margin: 0; }
|
||||
details { border-top: 1px solid #ffffff18; margin-top: 16px; padding-top: 8px; }
|
||||
summary { padding: 12px 0; cursor: pointer; font-size: 14px; }
|
||||
.terrain-source { display: block; font-size: 12px; color: #bfd0dd; line-height: 1.7; margin: 10px 0; text-underline-offset: 4px; overflow-wrap: anywhere; }
|
||||
.switch-row { display: flex; align-items: center; gap: 10px; margin: 0 0 20px; min-height: 44px; font-size: 14px; }
|
||||
.switch-row input { width: 20px; height: 20px; accent-color: #e3ba86; }
|
||||
.session-controls label { display: block; margin-bottom: 8px; font-size: 13px; }
|
||||
.session-controls .button-row { margin-top: 16px; }
|
||||
#control-hint { font-size: 13px; line-height: 1.7; }
|
||||
|
||||
#minimap-overlay { position: absolute; left: max(18px, env(safe-area-inset-left)); top: calc(max(18px, env(safe-area-inset-top)) + 60px); width: 180px; }
|
||||
.minimap { padding: 6px; background: #15232ef2; border: 1px solid #ffffff26; border-radius: 16px; }
|
||||
.minimap-map { position: relative; }
|
||||
.minimap canvas { display: block; width: 100%; aspect-ratio: 1; border-radius: 10px; }
|
||||
.minimap-direction { position: absolute; font-size: 11px; color: #e8edf2; text-shadow: 0 1px 4px #000; pointer-events: none; }
|
||||
.minimap-direction.north { top: 4px; left: 50%; transform: translateX(-50%); }
|
||||
.minimap-direction.east { right: 4px; top: 50%; transform: translateY(-50%); }
|
||||
.minimap-direction.south { bottom: 4px; left: 50%; transform: translateX(-50%); }
|
||||
.minimap-direction.west { left: 4px; top: 50%; transform: translateY(-50%); }
|
||||
.minimap-zoom { position: absolute; right: 0; bottom: 0; display: flex; gap: 4px; }
|
||||
.minimap-zoom button { padding: 0; min-height: 44px; font-size: 20px; border-radius: 10px; }
|
||||
#minimap-close { position: absolute; right: -10px; top: -12px; z-index: 1; padding: 0; font-size: 22px; border-radius: 50%; }
|
||||
#render-error { position: fixed; z-index: 2; top: 25%; left: 50%; transform: translateX(-50%); width: min(440px, calc(100% - 32px)); padding: 24px; background: #18232e; border: 1px solid #aa7550; border-radius: 16px; line-height: 1.7; }
|
||||
@media (max-width: 540px) {
|
||||
.scene-toolbar { top: max(14px, env(safe-area-inset-top)); left: max(14px, env(safe-area-inset-left)); right: max(14px, env(safe-area-inset-right)); }
|
||||
.emote-controls { right: max(14px, env(safe-area-inset-right)); bottom: max(20px, env(safe-area-inset-bottom)); gap: 8px; }
|
||||
.emote-controls button { min-width: 64px; padding: 11px 12px; }
|
||||
#minimap-overlay { width: 152px; }
|
||||
.dialog-body { padding: 18px; }
|
||||
}
|
||||
@media (max-height: 480px) {
|
||||
#ui-dialog { max-height: calc(100dvh - 16px); }
|
||||
.dialog-header { padding-block: 6px; }
|
||||
#minimap-overlay { width: 132px; }
|
||||
.emote-controls { bottom: max(12px, env(safe-area-inset-bottom)); }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type Camera, Vector3 } from 'three';
|
||||
import { COMPASS } from '../constants/compass.ts';
|
||||
|
||||
export function createCompass() {
|
||||
const root = document.getElementById('compass')!;
|
||||
const scale = root.querySelector<HTMLElement>('.compass-scale')!;
|
||||
const bearing = root.querySelector<HTMLElement>('.compass-bearing')!;
|
||||
const direction = new Vector3();
|
||||
const ticks = Array.from({ length: 360 / COMPASS.tickDegrees }, (_, index) => {
|
||||
const degrees = index * COMPASS.tickDegrees;
|
||||
const tick = document.createElement('span');
|
||||
const cardinal = degrees % 45 === 0;
|
||||
tick.className = `compass-tick${cardinal ? ' cardinal' : degrees % 15 === 0 ? ' major' : ''}`;
|
||||
tick.textContent = cardinal ? COMPASS.labels[degrees / 45] : degrees % 15 === 0 ? String(degrees) : '';
|
||||
scale.append(tick);
|
||||
return { tick, degrees };
|
||||
});
|
||||
return {
|
||||
update(camera: Camera) {
|
||||
camera.getWorldDirection(direction);
|
||||
const heading = (Math.atan2(direction.x, -direction.z) * 180 / Math.PI + 360) % 360;
|
||||
for (const { tick, degrees } of ticks) {
|
||||
const offset = (degrees - heading + 540) % 360 - 180;
|
||||
tick.hidden = Math.abs(offset) > COMPASS.visibleDegrees / 2;
|
||||
if (!tick.hidden) tick.style.left = `${50 + offset / COMPASS.visibleDegrees * 100}%`;
|
||||
}
|
||||
const text = `${Math.round(heading) % 360}°`;
|
||||
if (bearing.textContent !== text) {
|
||||
bearing.textContent = text;
|
||||
root.setAttribute('aria-label', `현재 시선 방위 ${text} · 북쪽 기준`);
|
||||
}
|
||||
},
|
||||
dispose() { scale.replaceChildren(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { DIALOG_TITLES, EQUIPMENT_NOTICE } from '../constants/ui.ts';
|
||||
|
||||
export function createDialogs() {
|
||||
const dialog = document.querySelector<HTMLDialogElement>('#ui-dialog')!;
|
||||
const title = document.getElementById('dialog-title')!;
|
||||
const back = document.getElementById('dialog-back')!;
|
||||
const panels = dialog.querySelectorAll<HTMLElement>('[data-dialog-panel]');
|
||||
const minimap = document.getElementById('minimap-overlay')!;
|
||||
const mapToggle = document.querySelector<HTMLInputElement>('#minimap-toggle')!;
|
||||
const soloTimeControls = document.getElementById('solo-time-controls')!;
|
||||
const events = new AbortController();
|
||||
const options = { signal: events.signal };
|
||||
const equipmentMenu = document.querySelector<HTMLElement>('.equipment-menu')!;
|
||||
const equipmentToggle = document.getElementById('equipment-toggle')!;
|
||||
const equipmentOptions = document.getElementById('equipment-options')!;
|
||||
const locationMenu = document.querySelector<HTMLElement>('.location-menu')!;
|
||||
const locationToggle = document.getElementById('location-toggle')!;
|
||||
const locationOptions = document.getElementById('location-options')!;
|
||||
const equipmentNotice = document.getElementById('equipment-notice')!;
|
||||
let noticeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const hideNotice = () => {
|
||||
clearTimeout(noticeTimer);
|
||||
equipmentNotice.hidden = true;
|
||||
};
|
||||
document.querySelector<HTMLElement>('.emote-controls')!.addEventListener('click', event => {
|
||||
const button = (event.target as Element).closest<HTMLButtonElement>('button[aria-disabled="true"]');
|
||||
if (!button?.dataset.unavailableReason) return;
|
||||
event.stopPropagation();
|
||||
hideNotice();
|
||||
equipmentNotice.querySelector('use')!.setAttribute('href', button.id === 'emote-pray' ? '#icon-sunrise' : '#icon-umbrella');
|
||||
equipmentNotice.querySelector('span')!.textContent = button.dataset.unavailableReason;
|
||||
equipmentNotice.hidden = false;
|
||||
const { width, height } = equipmentNotice.getBoundingClientRect();
|
||||
const { margin, pointerOffset } = EQUIPMENT_NOTICE;
|
||||
const mouse = event.detail > 0 && (!(event instanceof PointerEvent) || event.pointerType === 'mouse');
|
||||
const x = mouse ? event.clientX + pointerOffset : (innerWidth - width) / 2;
|
||||
const y = mouse ? event.clientY - height - pointerOffset : (innerHeight - height) / 2;
|
||||
equipmentNotice.style.left = `${Math.max(margin, Math.min(x, innerWidth - width - margin))}px`;
|
||||
equipmentNotice.style.top = `${Math.max(margin, Math.min(y, innerHeight - height - margin))}px`;
|
||||
noticeTimer = setTimeout(hideNotice, EQUIPMENT_NOTICE.durationMs);
|
||||
}, { ...options, capture: true });
|
||||
const setEquipmentOpen = (open: boolean) => {
|
||||
if (!open) hideNotice();
|
||||
equipmentOptions.hidden = !open;
|
||||
equipmentToggle.setAttribute('aria-expanded', String(open));
|
||||
};
|
||||
const setLocationOpen = (open: boolean) => {
|
||||
locationOptions.hidden = !open;
|
||||
locationToggle.setAttribute('aria-expanded', String(open));
|
||||
};
|
||||
const menus = [
|
||||
{ root: equipmentMenu, toggle: equipmentToggle, content: equipmentOptions, setOpen: setEquipmentOpen },
|
||||
{ root: locationMenu, toggle: locationToggle, content: locationOptions, setOpen: setLocationOpen },
|
||||
];
|
||||
for (const menu of menus) {
|
||||
menu.toggle.addEventListener('click', () => {
|
||||
const open = menu.content.hidden;
|
||||
menus.forEach(other => other.setOpen(other === menu && open));
|
||||
}, options);
|
||||
document.addEventListener('pointerdown', event => {
|
||||
if (!menu.root.contains(event.target as Node)) menu.setOpen(false);
|
||||
}, options);
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !menu.content.hidden) {
|
||||
menu.setOpen(false);
|
||||
menu.toggle.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
}, options);
|
||||
}
|
||||
const history: string[] = [];
|
||||
let opener: HTMLElement | null = null;
|
||||
let current = '';
|
||||
let backdropDown = false;
|
||||
|
||||
const show = (name: string) => {
|
||||
current = name;
|
||||
title.textContent = DIALOG_TITLES[name as keyof typeof DIALOG_TITLES];
|
||||
panels.forEach(panel => { panel.hidden = panel.dataset.dialogPanel !== name; });
|
||||
back.hidden = history.length === 0;
|
||||
dialog.querySelector('.dialog-body')!.scrollTop = 0;
|
||||
};
|
||||
const close = () => dialog.close();
|
||||
document.addEventListener('click', event => {
|
||||
const target = (event.target as Element).closest<HTMLElement>('[data-panel], [data-close], [data-scene-action]');
|
||||
if (!target) return;
|
||||
if (target.dataset.panel) {
|
||||
setEquipmentOpen(false);
|
||||
setLocationOpen(false);
|
||||
if (dialog.open) history.push(current);
|
||||
else { opener = target; history.length = 0; }
|
||||
show(target.dataset.panel);
|
||||
if (!dialog.open) dialog.showModal();
|
||||
dialog.querySelector<HTMLButtonElement>('[data-close]')!.focus();
|
||||
} else if (dialog.open) close();
|
||||
}, options);
|
||||
back.addEventListener('click', () => {
|
||||
const previous = history.pop();
|
||||
if (previous) {
|
||||
const child = current;
|
||||
show(previous);
|
||||
dialog.querySelector<HTMLButtonElement>(`[data-dialog-panel="${previous}"] [data-panel="${child}"]`)?.focus();
|
||||
}
|
||||
}, options);
|
||||
dialog.addEventListener('close', () => { history.length = 0; opener?.focus(); }, options);
|
||||
dialog.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusable = [...dialog.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, summary, [tabindex]')]
|
||||
.filter(element => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length > 0);
|
||||
const first = focusable[0], last = focusable.at(-1);
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
||||
}, options);
|
||||
// Native dialog makes the canvas inert and blurs it, clearing pending movement.
|
||||
// Empty dialog padding must not count as a backdrop click.
|
||||
const outside = (event: PointerEvent | MouseEvent) => {
|
||||
const box = dialog.getBoundingClientRect();
|
||||
return event.clientX < box.left || event.clientX > box.right || event.clientY < box.top || event.clientY > box.bottom;
|
||||
};
|
||||
dialog.addEventListener('pointerdown', event => { backdropDown = outside(event); }, options);
|
||||
dialog.addEventListener('click', event => { if (event.target === dialog && backdropDown && outside(event)) close(); backdropDown = false; }, options);
|
||||
mapToggle.addEventListener('change', () => { minimap.hidden = !mapToggle.checked; }, options);
|
||||
document.getElementById('minimap-close')!.addEventListener('click', () => {
|
||||
minimap.hidden = true;
|
||||
mapToggle.checked = false;
|
||||
}, options);
|
||||
return {
|
||||
setSharedMode(shared: boolean) {
|
||||
soloTimeControls.classList.toggle('shared-time', shared);
|
||||
},
|
||||
dispose() { events.abort(); menus.forEach(menu => menu.setOpen(false)); if (dialog.open) dialog.close(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { EYE_HEIGHT } from '../constants/observer.ts';
|
||||
|
||||
export function createHeightController(initialMeters: number, onChange: (meters: number) => void) {
|
||||
const slider = document.querySelector<HTMLInputElement>('#eye-height')!;
|
||||
const output = document.querySelector<HTMLOutputElement>('#eye-height-output')!;
|
||||
slider.min = String(EYE_HEIGHT.minCentimeters);
|
||||
slider.max = String(EYE_HEIGHT.maxCentimeters);
|
||||
slider.step = String(EYE_HEIGHT.stepCentimeters);
|
||||
slider.value = String(Math.round(initialMeters * 100));
|
||||
const showValue = () => {
|
||||
output.value = `${slider.value} cm`;
|
||||
slider.setAttribute('aria-valuetext', `지면에서 눈까지 ${slider.value}센티미터`);
|
||||
};
|
||||
const input = () => {
|
||||
showValue();
|
||||
onChange(Number(slider.value) / 100);
|
||||
};
|
||||
showValue();
|
||||
slider.addEventListener('input', input);
|
||||
return { dispose() { slider.removeEventListener('input', input); } };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SunState } from '../astronomy/sunCalculator.ts';
|
||||
import { observerIsProvisional, observerName } from '../config/observer.ts';
|
||||
import type { ObserverConfig } from '../config/observer.ts';
|
||||
import { kstClock, kstFields } from './time.ts';
|
||||
import type { TimeMode } from './timeController.ts';
|
||||
|
||||
export function createInformationPanel(observer: ObserverConfig, name = observerName, surfaceLabel = 'DSM') {
|
||||
const get = (id: string) => document.getElementById(id)!;
|
||||
get('observer-name').textContent = name;
|
||||
const mode = get('mode');
|
||||
return {
|
||||
update(sun: SunState, timeMode: TimeMode) {
|
||||
get('observer-details').textContent = `${observerIsProvisional ? '탐색 좌표 · ' : ''}${observer.latitude.toFixed(5)}° N / ${observer.longitude.toFixed(5)}° E · ${surfaceLabel} ${observer.elevation.toFixed(1)}m + 눈높이 ${observer.eyeHeight.toFixed(2)}m`;
|
||||
mode.textContent = timeMode;
|
||||
mode.classList.toggle('custom', timeMode === 'CUSTOM');
|
||||
const time = get('selected-time');
|
||||
time.textContent = `${kstFields(sun.date).date.replaceAll('-', '.')} ${kstClock(sun.date)}`;
|
||||
time.setAttribute('datetime', sun.date.toISOString());
|
||||
get('azimuth').textContent = `${sun.azimuth.toFixed(2)}°`;
|
||||
get('altitude').textContent = `${sun.altitude.toFixed(2)}°`;
|
||||
get('sun-status').textContent = sun.altitude < 0 ? '0° 아래 · 지형 차폐' : '0° 위 · 지형 차폐';
|
||||
get('sunrise').textContent = kstClock(sun.sunrise);
|
||||
get('sunset').textContent = kstClock(sun.sunset);
|
||||
get('standard-times').textContent = `표준 0m: 일출 ${kstClock(sun.standardSunrise)} / 일몰 ${kstClock(sun.standardSunset)}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Vector3 } from 'three';
|
||||
import type { BufferGeometry, Camera } from 'three';
|
||||
import { MINIMAP } from '../constants/minimap.ts';
|
||||
import { TERRAIN } from '../constants/terrain.ts';
|
||||
import type { loadTerrain } from '../scene/terrain.ts';
|
||||
import type { createSummitWalking } from '../controls/summitWalking.ts';
|
||||
import { SUMMIT_TRAILS } from '../constants/summitTrails.ts';
|
||||
|
||||
export function createTerrainMinimap(data: Awaited<ReturnType<typeof loadTerrain>>, geometry: BufferGeometry, walking?: ReturnType<typeof createSummitWalking>) {
|
||||
const panel = document.getElementById('terrain-minimap')!;
|
||||
const canvas = panel.querySelector('canvas')!;
|
||||
const context = canvas.getContext('2d')!;
|
||||
const { size, padding, contourInterval, majorInterval } = MINIMAP;
|
||||
const { metadata: meta } = data;
|
||||
const axis = geometry.userData.axis as number[];
|
||||
const width = axis.length;
|
||||
const positions = geometry.getAttribute('position');
|
||||
const heights = new Float32Array(positions.count);
|
||||
for (let i = 0; i < heights.length; i++) {
|
||||
const x = positions.getX(i), z = positions.getZ(i);
|
||||
heights[i] = positions.getY(i) + meta.observerSurfaceElevation + (x*x+z*z)/(2*TERRAIN.earthRadiusMeters);
|
||||
}
|
||||
const extent = size - padding * 2;
|
||||
const pixelScale = extent / (meta.radiusMeters * 2);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = canvas.height = size * pixelRatio;
|
||||
const background = document.createElement('canvas');
|
||||
background.width = background.height = canvas.width;
|
||||
const map = background.getContext('2d')!;
|
||||
|
||||
// Intersect the same two triangles per source cell as the terrain mesh.
|
||||
// Keep vector paths so zooming preserves contour detail.
|
||||
const paths = new Map<number, Path2D>();
|
||||
const triangle = (a: number, b: number, c: number) => {
|
||||
const vertices = [a, b, c];
|
||||
const low = Math.min(heights[a], heights[b], heights[c]);
|
||||
const high = Math.max(heights[a], heights[b], heights[c]);
|
||||
for (let level = Math.ceil(low / contourInterval) * contourInterval; level < high; level += contourInterval) {
|
||||
let path = paths.get(level);
|
||||
if (!path) { path = new Path2D(); paths.set(level, path); }
|
||||
let first = true;
|
||||
for (let edge = 0; edge < 3; edge++) {
|
||||
const from = vertices[edge], to = vertices[(edge + 1) % 3];
|
||||
if ((heights[from] > level) === (heights[to] > level)) continue;
|
||||
const t = (level - heights[from]) / (heights[to] - heights[from]);
|
||||
const x = padding + (meta.radiusMeters + axis[from % width] * (1 - t) + axis[to % width] * t) * pixelScale;
|
||||
const y = padding + (meta.radiusMeters + axis[Math.floor(from / width)] * (1 - t) + axis[Math.floor(to / width)] * t) * pixelScale;
|
||||
if (first) path.moveTo(x, y);
|
||||
else path.lineTo(x, y);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
for (let row = 0; row < width - 1; row++) {
|
||||
for (let col = 0; col < width - 1; col++) {
|
||||
const a = row * width + col, b = a + 1, c = a + width, d = c + 1;
|
||||
triangle(a, c, b);
|
||||
triangle(b, c, d);
|
||||
}
|
||||
}
|
||||
panel.hidden = false;
|
||||
const direction = new Vector3();
|
||||
let lastX = NaN, lastZ = NaN, lastAngle = NaN;
|
||||
let zoom: number = walking ? SUMMIT_TRAILS.mapZoom : MINIMAP.minZoom;
|
||||
const maxZoom = walking ? SUMMIT_TRAILS.mapMaxZoom : MINIMAP.maxZoom;
|
||||
const events = new AbortController();
|
||||
const zoomIn = panel.querySelector<HTMLButtonElement>('[data-zoom="in"]')!;
|
||||
const zoomOut = panel.querySelector<HTMLButtonElement>('[data-zoom="out"]')!;
|
||||
zoomOut.disabled = zoom === MINIMAP.minZoom;
|
||||
const drawBackground = () => {
|
||||
map.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
map.fillStyle = MINIMAP.background;
|
||||
map.fillRect(0, 0, size, size);
|
||||
map.translate(size / 2, size / 2);
|
||||
map.scale(zoom, zoom);
|
||||
map.translate(-(padding + (lastX + meta.radiusMeters) / (meta.radiusMeters * 2) * extent),
|
||||
-(padding + (lastZ + meta.radiusMeters) / (meta.radiusMeters * 2) * extent));
|
||||
for (const [level, path] of paths) {
|
||||
map.strokeStyle = level % majorInterval === 0 ? MINIMAP.majorColor : MINIMAP.contourColor;
|
||||
map.lineWidth = (level % majorInterval === 0 ? 0.85 : 0.45) / zoom;
|
||||
map.stroke(path);
|
||||
}
|
||||
map.strokeStyle = '#bed0ad40';
|
||||
map.lineWidth = 1 / zoom;
|
||||
map.strokeRect(padding, padding, extent, extent);
|
||||
if(walking) {
|
||||
const point=(x:number,z:number)=>[padding+(x+meta.radiusMeters)*pixelScale,padding+(z+meta.radiusMeters)*pixelScale];
|
||||
const [cx,cz]=point(walking.start.x,walking.start.z);
|
||||
map.save();map.beginPath();map.arc(cx,cz,SUMMIT_TRAILS.radius*pixelScale,0,Math.PI*2);
|
||||
map.strokeStyle='#e7bd7c';map.setLineDash([3/zoom,3/zoom]);map.stroke();map.clip();map.setLineDash([]);
|
||||
map.beginPath();
|
||||
SUMMIT_TRAILS.rockPolygon.forEach(([x,z],i)=>{const [px,pz]=point(x+walking.start.x,z+walking.start.z);if(i)map.lineTo(px,pz);else map.moveTo(px,pz);});
|
||||
map.closePath();map.fillStyle=SUMMIT_TRAILS.colors.rock;map.fill();
|
||||
map.strokeStyle='#e7bd7c';map.lineWidth=2/zoom;
|
||||
for(const route of walking.routes) {
|
||||
map.beginPath();route.points.forEach((p,i)=>{const [x,z]=point(p.x,p.z);if(i)map.lineTo(x,z);else map.moveTo(x,z);});map.stroke();
|
||||
}
|
||||
map.fillStyle='#ffffff';map.beginPath();map.arc(cx,cz,2.5/zoom,0,Math.PI*2);map.fill();map.restore();
|
||||
}
|
||||
};
|
||||
const draw = () => {
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.drawImage(background, 0, 0);
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.translate(size / 2, size / 2);
|
||||
context.rotate(lastAngle);
|
||||
context.beginPath();
|
||||
context.moveTo(0, 0);
|
||||
context.arc(0, 0, 24, -Math.PI / 2 - 0.45, -Math.PI / 2 + 0.45);
|
||||
context.closePath();
|
||||
context.fillStyle = '#ffd29a30';
|
||||
context.fill();
|
||||
context.beginPath();
|
||||
context.moveTo(0, -9);
|
||||
context.lineTo(5, 5);
|
||||
context.lineTo(0, 2);
|
||||
context.lineTo(-5, 5);
|
||||
context.closePath();
|
||||
context.fillStyle = MINIMAP.markerColor;
|
||||
context.strokeStyle = MINIMAP.background;
|
||||
context.lineWidth = 2;
|
||||
context.stroke();
|
||||
context.fill();
|
||||
};
|
||||
const changeZoom = (factor: number) => {
|
||||
const next = Math.max(MINIMAP.minZoom, Math.min(maxZoom, zoom * factor));
|
||||
if (next === zoom) return;
|
||||
zoom = next;
|
||||
zoomIn.disabled = zoom === maxZoom;
|
||||
zoomOut.disabled = zoom === MINIMAP.minZoom;
|
||||
if (!Number.isFinite(lastX)) return;
|
||||
drawBackground();
|
||||
draw();
|
||||
};
|
||||
zoomIn.addEventListener('click', () => changeZoom(MINIMAP.zoomStep), { signal: events.signal });
|
||||
zoomOut.addEventListener('click', () => changeZoom(1 / MINIMAP.zoomStep), { signal: events.signal });
|
||||
panel.addEventListener('wheel', event => {
|
||||
event.preventDefault();
|
||||
if (event.deltaY !== 0) changeZoom(event.deltaY < 0 ? MINIMAP.zoomStep : 1 / MINIMAP.zoomStep);
|
||||
}, { passive: false, signal: events.signal });
|
||||
return {
|
||||
update(camera: Camera) {
|
||||
camera.getWorldDirection(direction);
|
||||
const angle = Math.atan2(direction.x, -direction.z);
|
||||
const { x, z } = camera.position;
|
||||
if (x === lastX && z === lastZ && angle === lastAngle) return;
|
||||
const moved = x !== lastX || z !== lastZ;
|
||||
lastX = x; lastZ = z; lastAngle = angle;
|
||||
if (moved) drawBackground();
|
||||
draw();
|
||||
},
|
||||
dispose() { events.abort(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { KST_OFFSET_MS, TIME_ZONE } from '../constants/time.ts';
|
||||
|
||||
const calendar = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
||||
});
|
||||
|
||||
function seoulParts(date: Date) {
|
||||
return Object.fromEntries(calendar.formatToParts(date).map(part => [part.type, part.value]));
|
||||
}
|
||||
|
||||
export function kstFields(date: Date) {
|
||||
const parts = seoulParts(date);
|
||||
return {
|
||||
date: `${parts.year}-${parts.month}-${parts.day}`,
|
||||
minutes: Number(parts.hour) * 60 + Number(parts.minute),
|
||||
};
|
||||
}
|
||||
|
||||
export function fromKstInput(date: string, minutes: number, seconds = 0): Date {
|
||||
const wallTime = Date.parse(`${date}T${minuteLabel(minutes)}:${String(seconds).padStart(2, '0')}Z`);
|
||||
if (!Number.isFinite(wallTime)) return new Date(NaN);
|
||||
let instant = wallTime - KST_OFFSET_MS;
|
||||
// Modern KST is UTC+9. Resolve historical offsets through the same IANA zone
|
||||
// used by labels, so historical DST/standard-time dates do not shift the UI.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const p = seoulParts(new Date(instant));
|
||||
const localWallTime = Date.parse(`${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}Z`);
|
||||
if (localWallTime === wallTime) return new Date(instant);
|
||||
instant += wallTime - localWallTime;
|
||||
}
|
||||
return new Date(NaN); // Nonexistent local time at a historical DST jump.
|
||||
}
|
||||
|
||||
export function minuteLabel(minutes: number): string {
|
||||
return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const clock = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: TIME_ZONE, hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
||||
});
|
||||
export function kstClock(date: Date | null): string {
|
||||
return date && Number.isFinite(date.getTime()) ? clock.format(date) : '—';
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { LAST_SELECTABLE_SECOND, LIVE_INTERVAL_MS, TIME_STEP_SECONDS } from '../constants/time.ts';
|
||||
import { fromKstInput, kstFields, kstClock } from './time.ts';
|
||||
|
||||
export type TimeMode = 'LIVE' | 'CUSTOM';
|
||||
|
||||
export class TimeController {
|
||||
mode: TimeMode = 'LIVE';
|
||||
private timer: ReturnType<typeof setInterval>;
|
||||
private dateInput = document.querySelector<HTMLInputElement>('#date')!;
|
||||
private dateDisplay = document.querySelector<HTMLElement>('#date-display')!;
|
||||
private slider = document.querySelector<HTMLInputElement>('#time-slider')!;
|
||||
private output = document.querySelector<HTMLOutputElement>('#time-output')!;
|
||||
private liveButton = document.querySelector<HTMLButtonElement>('#live-button')!;
|
||||
private onChange: (date: Date, mode: TimeMode) => void;
|
||||
private sharedClock: (() => Date) | null = null;
|
||||
|
||||
setSharedClock(clock: (() => Date) | null) {
|
||||
this.sharedClock = clock;
|
||||
this.dateInput.disabled = this.slider.disabled = clock !== null;
|
||||
this.live();
|
||||
}
|
||||
|
||||
constructor(onChange: (date: Date, mode: TimeMode) => void) {
|
||||
this.onChange = onChange;
|
||||
this.slider.max = String(LAST_SELECTABLE_SECOND);
|
||||
this.slider.step = String(TIME_STEP_SECONDS);
|
||||
this.dateInput.addEventListener('change', this.custom);
|
||||
this.slider.addEventListener('input', this.custom);
|
||||
this.liveButton.addEventListener('click', this.live);
|
||||
document.addEventListener('visibilitychange', this.sync);
|
||||
window.addEventListener('focus', this.sync);
|
||||
window.addEventListener('keydown', this.adjustTime);
|
||||
this.timer = setInterval(this.sync, LIVE_INTERVAL_MS);
|
||||
this.sync();
|
||||
}
|
||||
|
||||
private adjustTime = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey
|
||||
|| this.sharedClock || this.slider.matches(':disabled')
|
||||
|| !['ArrowLeft', 'ArrowRight'].includes(event.key)
|
||||
|| document.querySelector('dialog[open]')
|
||||
|| document.activeElement?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])')) return;
|
||||
event.preventDefault();
|
||||
const direction = event.key === 'ArrowRight' ? 1 : -1;
|
||||
this.slider.value = String(Math.max(0, Math.min(LAST_SELECTABLE_SECOND,
|
||||
Number(this.slider.value) + direction * TIME_STEP_SECONDS)));
|
||||
this.custom();
|
||||
};
|
||||
|
||||
private custom = () => {
|
||||
if (this.sharedClock) return;
|
||||
if (!this.dateInput.value || !this.dateInput.validity.valid) return;
|
||||
const seconds = Number(this.slider.value);
|
||||
const date = fromKstInput(this.dateInput.value, Math.floor(seconds / 60), seconds % 60);
|
||||
if (!Number.isFinite(date.getTime())) return;
|
||||
this.mode = 'CUSTOM';
|
||||
this.render(date);
|
||||
};
|
||||
|
||||
private live = () => {
|
||||
this.mode = 'LIVE';
|
||||
this.sync();
|
||||
};
|
||||
|
||||
private sync = () => {
|
||||
if (this.mode === 'LIVE') this.render(this.sharedClock ? this.sharedClock() : new Date());
|
||||
};
|
||||
|
||||
private render(date: Date) {
|
||||
const fields = kstFields(date);
|
||||
if (this.mode === 'CUSTOM' || document.activeElement !== this.dateInput) {
|
||||
this.dateInput.value = fields.date;
|
||||
}
|
||||
const [year, month, day] = fields.date.split('-');
|
||||
this.dateDisplay.textContent = `${year}년 ${month}월 ${day}일`;
|
||||
const clock = kstClock(date);
|
||||
const seconds = fields.minutes * 60 + Number(clock.slice(-2));
|
||||
this.slider.value = String(Math.floor(seconds / TIME_STEP_SECONDS) * TIME_STEP_SECONDS);
|
||||
this.output.value = clock;
|
||||
this.slider.setAttribute('aria-valuetext', `${this.output.value} KST`);
|
||||
this.liveButton.setAttribute('aria-pressed', String(this.mode === 'LIVE'));
|
||||
this.onChange(date, this.mode);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
clearInterval(this.timer);
|
||||
this.dateInput.removeEventListener('change', this.custom);
|
||||
this.slider.removeEventListener('input', this.custom);
|
||||
this.liveButton.removeEventListener('click', this.live);
|
||||
document.removeEventListener('visibilitychange', this.sync);
|
||||
window.removeEventListener('focus', this.sync);
|
||||
window.removeEventListener('keydown', this.adjustTime);
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,183 @@
|
||||
import { WEATHER } from '../constants/weather.ts';
|
||||
import { TERRAIN } from '../constants/terrain.ts';
|
||||
|
||||
export interface WeatherSample {
|
||||
cloudCover: number;
|
||||
visibility: number | null;
|
||||
rain: number | null; // mm in the preceding hour, including showers
|
||||
snowfall: number | null; // cm in the preceding hour
|
||||
}
|
||||
type Point = { latitude: number; longitude: number };
|
||||
type Source = '예보' | '과거 예보' | '재분석';
|
||||
interface WeatherRequest { url: string; source: Source }
|
||||
interface WeatherRow extends Omit<WeatherSample, 'cloudCover'> { time: number; cloudCover: number | null }
|
||||
export interface WeatherStatus {
|
||||
status: 'loading' | 'ready' | 'unavailable' | 'error';
|
||||
weather: WeatherSample | null;
|
||||
source?: Source;
|
||||
}
|
||||
|
||||
/** UTC request windows and Unix timestamps avoid local timezone/DST ambiguity. */
|
||||
export function weatherRequest(date: Date, point: Point, now = new Date()): WeatherRequest | null {
|
||||
const day = date.toISOString().slice(0, 10);
|
||||
const today = Math.floor(now.getTime() / WEATHER.dayMs) * WEATHER.dayMs;
|
||||
const start = Date.parse(day);
|
||||
const last = today + (WEATHER.forecastDays - 1) * WEATHER.dayMs;
|
||||
if (day < WEATHER.archiveStart || start > last) return null;
|
||||
const recent = start >= today - WEATHER.recentDays * WEATHER.dayMs;
|
||||
const archive = !recent && day < WEATHER.historyStart;
|
||||
const url = new URL(recent ? WEATHER.forecastUrl : archive ? WEATHER.archiveUrl : WEATHER.historyUrl);
|
||||
url.search = new URLSearchParams({
|
||||
latitude: String(point.latitude), longitude: String(point.longitude),
|
||||
hourly: archive ? 'cloud_cover,rain,snowfall' : 'cloud_cover,visibility,rain,showers,snowfall',
|
||||
start_date: day,
|
||||
end_date: new Date(Math.min(start + WEATHER.dayMs, last)).toISOString().slice(0, 10),
|
||||
timezone: 'GMT', timeformat: 'unixtime',
|
||||
}).toString();
|
||||
return { url: url.href, source: recent ? '예보' : archive ? '재분석' : '과거 예보' };
|
||||
}
|
||||
|
||||
export function parseWeather(data: unknown): WeatherRow[] {
|
||||
const hourly = (data as { hourly?: Record<string, unknown> } | null)?.hourly;
|
||||
if (!hourly || !Array.isArray(hourly.time) || !Array.isArray(hourly.cloud_cover)
|
||||
|| hourly.time.length !== hourly.cloud_cover.length) throw new Error('Invalid weather response');
|
||||
const clouds = hourly.cloud_cover;
|
||||
const visibility = Array.isArray(hourly.visibility) ? hourly.visibility : [];
|
||||
const valid = (value: unknown, max: number) => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= max;
|
||||
const amount = (name: string, i: number): number | null => {
|
||||
const value = Array.isArray(hourly[name]) ? hourly[name][i] : null;
|
||||
return valid(value, Infinity) ? value as number : null;
|
||||
};
|
||||
return hourly.time.map((time: unknown, i: number) => {
|
||||
if (typeof time !== 'number' || !Number.isFinite(time) || (i > 0 && time <= (hourly.time as number[])[i - 1])) {
|
||||
throw new Error('Invalid weather timestamps');
|
||||
}
|
||||
const rain = amount('rain', i);
|
||||
// Reanalysis includes convective rain in `rain` and has no `showers` field.
|
||||
const showers = hourly.showers === undefined ? 0 : amount('showers', i);
|
||||
return { time: time * 1000, cloudCover: valid(clouds[i], 100) ? clouds[i] : null,
|
||||
visibility: valid(visibility[i], Infinity) ? visibility[i] : null,
|
||||
rain: rain === null || showers === null ? null : rain + showers, snowfall: amount('snowfall', i) };
|
||||
});
|
||||
}
|
||||
|
||||
export function sampleWeather(rows: WeatherRow[], date: Date): WeatherSample | null {
|
||||
const time = date.getTime();
|
||||
const index = rows.findIndex(row => row.time > time);
|
||||
const left = rows[index === -1 ? rows.length - 1 : index - 1];
|
||||
if (!left || left.cloudCover === null || time - left.time >= WEATHER.hourMs) return null;
|
||||
const right = rows[index];
|
||||
// Amounts belong to (timestamp - 1h, timestamp], not instantaneous samples.
|
||||
const precipitation = time === left.time ? left : right;
|
||||
const amounts = { rain: precipitation?.rain ?? null, snowfall: precipitation?.snowfall ?? null };
|
||||
if (!right || time === left.time) return { cloudCover: left.cloudCover, visibility: left.visibility, ...amounts };
|
||||
if (right.cloudCover === null || right.time - left.time > WEATHER.hourMs) return null;
|
||||
const fraction = (time - left.time) / (right.time - left.time);
|
||||
return { ...amounts, cloudCover: left.cloudCover + (right.cloudCover - left.cloudCover) * fraction,
|
||||
visibility: left.visibility === null || right.visibility === null ? null
|
||||
: left.visibility + (right.visibility - left.visibility) * fraction };
|
||||
}
|
||||
|
||||
/** Artistic transmission, not a reconstruction of clouds along the solar ray. */
|
||||
export function weatherAppearance(weather: WeatherSample | null) {
|
||||
const cloud = (weather?.cloudCover ?? 0) / 100;
|
||||
const visibility = weather?.visibility;
|
||||
const haze = visibility == null ? 1 : Math.exp(-WEATHER.hazePathMeters / Math.max(visibility, 1));
|
||||
return {
|
||||
cloud,
|
||||
transmission: Math.exp(-WEATHER.cloudOpticalDepth * cloud * cloud) * haze,
|
||||
fogNear: visibility == null ? TERRAIN.fogNear : Math.min(TERRAIN.fogNear, visibility * 0.08),
|
||||
fogFar: visibility == null ? TERRAIN.fogFar : Math.max(50, visibility),
|
||||
ambient: 1 - (1 - WEATHER.overcastAmbient) * cloud,
|
||||
rain: weather?.rain ?? 0,
|
||||
snowfall: weather?.snowfall ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
abort: AbortController;
|
||||
rows?: WeatherRow[];
|
||||
error?: boolean;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
export class WeatherController {
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
private selected: { date: Date; request: WeatherRequest | null } | null = null;
|
||||
private disposed = false;
|
||||
private point: Point;
|
||||
private onChange: (state: WeatherStatus) => void;
|
||||
private fetcher: typeof fetch;
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
constructor(point: Point, onChange: (state: WeatherStatus) => void, fetcher: typeof fetch = fetch) {
|
||||
this.point = point;
|
||||
this.onChange = onChange;
|
||||
this.fetcher = fetcher.bind(globalThis);
|
||||
}
|
||||
|
||||
update(date: Date) {
|
||||
if (this.disposed) return;
|
||||
clearTimeout(this.refreshTimer);
|
||||
const request = weatherRequest(date, this.point);
|
||||
this.selected = { date, request };
|
||||
if (!request) { this.emit(); return; }
|
||||
const key = request.url;
|
||||
let entry = this.cache.get(key);
|
||||
if (entry && entry.expires <= Date.now()) {
|
||||
entry.abort.abort();
|
||||
this.cache.delete(key);
|
||||
entry = undefined;
|
||||
}
|
||||
if (!entry) {
|
||||
entry = { abort: new AbortController(), expires: Infinity };
|
||||
this.cache.set(key, entry);
|
||||
while (this.cache.size > WEATHER.cacheDays) {
|
||||
const oldest = this.cache.keys().next().value!;
|
||||
this.cache.get(oldest)!.abort.abort();
|
||||
this.cache.delete(oldest);
|
||||
}
|
||||
void this.load(key, entry);
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private async load(key: string, entry: CacheEntry) {
|
||||
const timeout = setTimeout(() => entry.abort.abort(), WEATHER.timeoutMs);
|
||||
try {
|
||||
const response = await this.fetcher(key, { signal: entry.abort.signal });
|
||||
if (!response.ok) throw new Error(`Weather HTTP ${response.status}`);
|
||||
entry.rows = parseWeather(await response.json());
|
||||
entry.expires = Date.now() + WEATHER.refreshMs;
|
||||
} catch {
|
||||
entry.error = true;
|
||||
entry.expires = Date.now() + WEATHER.retryMs;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (!this.disposed && this.selected?.request?.url === key && this.cache.get(key) === entry) this.emit();
|
||||
}
|
||||
}
|
||||
|
||||
private emit() {
|
||||
if (!this.selected) return;
|
||||
const { date, request } = this.selected;
|
||||
if (!request) { this.onChange({ status: 'unavailable', weather: null }); return; }
|
||||
const entry = this.cache.get(request.url)!;
|
||||
const weather = entry.rows ? sampleWeather(entry.rows, date) : null;
|
||||
clearTimeout(this.refreshTimer);
|
||||
if (Number.isFinite(entry.expires)) {
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
if (this.selected) this.update(this.selected.date);
|
||||
}, Math.max(1, entry.expires - Date.now()));
|
||||
}
|
||||
this.onChange({ status: entry.error ? 'error' : !entry.rows ? 'loading' : weather ? 'ready' : 'unavailable',
|
||||
weather, source: request.source });
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
clearTimeout(this.refreshTimer);
|
||||
for (const entry of this.cache.values()) entry.abort.abort();
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user