feat: initialize sunrise with 100-player shared rooms

This commit is contained in:
2026-10-30 16:36:35 +09:00
commit 31d6eac4c2
257 changed files with 66063 additions and 0 deletions
+6
View File
@@ -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)];
}
+27
View File
@@ -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,
};
}
+17
View File
@@ -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;
+19
View File
@@ -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;
+19
View File
@@ -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;
+15
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
export const COMPASS = {
tickDegrees: 5,
visibleDegrees: 150,
labels: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'],
} as const;
+15
View File
@@ -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;
+78
View File
@@ -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;
+6
View File
@@ -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;
+35
View File
@@ -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 };
}
+31
View File
@@ -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,
};
+13
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export const MOVEMENT = { speed: 4, maxDelta: 0.05, dragSensitivity: 0.003, dragThreshold: 6, arrivalDistance: 0.01 } as const;
+15
View File
@@ -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;
+13
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
export const EYE_HEIGHT = {
minCentimeters: 160,
maxCentimeters: 195,
stepCentimeters: 1,
} as const;
+9
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
export const RAIN_GEAR = {
umbrellaUrl: '/characters/umbrella.glb?v=1',
raincoatUrl: '/characters/raincoat.glb?v=5',
} as const;
+12
View File
@@ -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;
+20
View File
@@ -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;
+24
View File
@@ -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;
+9
View File
@@ -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,
};
+10
View File
@@ -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
+9
View File
@@ -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;
+33
View File
@@ -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;
+343
View File
@@ -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;
+9
View File
@@ -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;
+5
View File
@@ -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;
+15
View File
@@ -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분에 사용할 수 있습니다';
+22
View File
@@ -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;
+29
View File
@@ -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;
},
};
}
+72
View File
@@ -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);
},
};
}
+29
View File
@@ -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;
},
};
}
+11
View File
@@ -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 };
}
+62
View File
@@ -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 };
}
+44
View File
@@ -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);
}
+34
View File
@@ -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 };
}
+25
View File
@@ -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();
}
+140
View File
@@ -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(); },
};
}
+10
View File
@@ -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
View File
@@ -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(); });
+28
View File
@@ -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
View File
@@ -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);
}
}
+101
View File
@@ -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();
},
};
}
+11
View File
@@ -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; },
};
}
+40
View File
@@ -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;
}
+52
View File
@@ -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(); },
};
}
+42
View File
@@ -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;
},
};
}
+69
View File
@@ -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);
}
+83
View File
@@ -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(); } };
}
+27
View File
@@ -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(); });
} };
}
+128
View File
@@ -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);
}
+50
View File
@@ -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);
}
`;
+158
View File
@@ -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(); },
};
}
+14
View File
@@ -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,
};
}
+60
View File
@@ -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();},
};
}
+54
View File
@@ -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));
},
};
}
+132
View File
@@ -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; } };
}
+106
View File
@@ -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();
},
};
}
+99
View File
@@ -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();
},
};
}
+22
View File
@@ -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;
},
};
}
+168
View File
@@ -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);
}
+32
View File
@@ -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;
},
};
}
+155
View File
@@ -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()); } };
}
+60
View File
@@ -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);
}
+95
View File
@@ -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;
}
+25
View File
@@ -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());
} };
}
+80
View File
@@ -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();
}};
}
+54
View File
@@ -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();
}
+133
View File
@@ -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();
}
}};
}
+71
View File
@@ -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);
},
};
}
+42
View File
@@ -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(); } };
}
+138
View File
@@ -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);
}
+487
View File
@@ -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
View File
@@ -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)); }
}
+35
View File
@@ -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(); },
};
}
+133
View File
@@ -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(); },
};
}
+21
View File
@@ -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); } };
}
+27
View File
@@ -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)}`;
},
};
}
+154
View File
@@ -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(); },
};
}
+44
View File
@@ -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) : '—';
}
+94
View File
@@ -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);
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+183
View File
@@ -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();
}
}