Compare commits

...
3 Commits
Author SHA1 Message Date
horoli eb49ea4b84 Improve summit terrain, mobile HUD and multiplayer night equipment
Add the Blender flashlight asset with a 600 lm beam and reduced bloom, shared-mode self badge, and compact phone controls. Relocate the summit marker, blend terrain seams, widen walking routes, and align local server configuration on port 2027.
2026-10-31 23:14:44 +09:00
horoli 18dfb63350 Fix stale packed textures in summit marker export 2026-10-31 11:04:53 +09:00
horoli 1b8698565a Refine Gwanaksan summit marker proportions and inscription 2026-10-31 09:49:29 +09:00
56 changed files with 965 additions and 68 deletions
+3 -3
View File
@@ -1,12 +1,12 @@
{
"host": "127.0.0.1",
"port": 3000,
"port": 2027,
"mongoUri": "mongodb://172.16.0.7:27017",
"mongoDatabase": "sunrise",
"visitStore": "mongo",
"allowedOrigins": [
"http://127.0.0.1:3000",
"http://localhost:3000",
"http://127.0.0.1:2027",
"http://localhost:2027",
"http://127.0.0.1:5173",
"http://localhost:5173"
]
+2 -2
View File
@@ -13,7 +13,7 @@ OSM 건물의 외곽선·높이·층수와 OSM 대조 기반 선별을 제거했
## 공동 관측 서버
관악산 채널 1은 최대 100명을 수용합니다. Node.js + Socket.IO로 이동·감정표현·우천 장비를 공유하고 MongoDB에는 접속 기록만 저장합니다. `npm run build` 후 `npm start`로 실행합니다. 기본 주소는 `http://127.0.0.1:3000`입니다.
관악산 채널 1은 최대 100명을 수용합니다. Node.js + Socket.IO로 이동·감정표현·우천 장비를 공유하고 MongoDB에는 접속 기록만 저장합니다. `npm run build` 후 `npm start`로 실행합니다. 기본 주소는 `http://127.0.0.1:2027`입니다.
환경설정·개발 실행·검증 방법과 운영 한계는 [서버 설명](docs/multiplayer-server.md)을 참고하세요. 기본 MongoDB는 `172.16.0.7:27017`의 `sunrise.visitSessions`이며 `.config.example.json`을 `.config.json`으로 복사한 뒤 변경합니다.
@@ -37,7 +37,7 @@ OSM 건물의 외곽선·높이·층수와 OSM 대조 기반 선별을 제거했
- 현재 관악산 공동 방을 `채널 1`로 사용한다.
- `/api/lobby`에서 서버 목록, 채널 정원·인원·입장 가능 여부를 조회한다. 선택 화면에서는 방문 기록이나 3D 장면을 생성하지 않는다.
- 개발 시 프런트엔드(`npm run dev`)와 백엔드(`npm run dev:server`)를 함께 실행한다. Vite가 `/api`와 `/socket.io`를 백엔드 3000번 포트로 전달한다.
- 개발 시 프런트엔드(`npm run dev`)와 백엔드(`npm run dev:server`)를 함께 실행한다. Vite가 `/api`와 `/socket.io`를 백엔드 2027번 포트로 전달한다.
- 서버 조회 실패·입장 실패는 재시도할 수 있다. 명시적인 `?mode=solo` 비교 화면과 개발용 `?profile` 경로는 기존 직접 진입을 유지한다.
아래 내용은 각 기능의 개발 기록이다.
+2 -2
View File
@@ -12,13 +12,13 @@ npm run build
npm start
```
예제 설정의 접속 주소: `http://127.0.0.1:3000`. DB: `mongodb://172.16.0.7:27017`, DB 이름 `sunrise`, 컬렉션 `visitSessions`. 인증이 필요하면 `mongoUri`에 계정과 `authSource` 등 해당 서버 설정을 지정한다. URI를 브라우저용 `VITE_*` 변수에 넣지 않는다. `.config.json`은 버전 관리에서 제외한다.
예제 설정의 접속 주소: `http://127.0.0.1:2027`. DB: `mongodb://172.16.0.7:27017`, DB 이름 `sunrise`, 컬렉션 `visitSessions`. 인증이 필요하면 `mongoUri`에 계정과 `authSource` 등 해당 서버 설정을 지정한다. URI를 브라우저용 `VITE_*` 변수에 넣지 않는다. `.config.json`은 버전 관리에서 제외한다.
Linux에서는 `cp .config.example.json .config.json`으로 복사한다. 설정 파일은 작업 디렉터리와 관계없이 프로젝트 루트에서 읽으며 모든 예제 항목을 유지한다. 파일이 없거나 JSON 또는 항목 형식이 잘못되면 서버 시작을 중단한다. 서버 설정은 `.env`나 환경변수에서 읽지 않는다. 수정 후 서버를 재시작한다. `npm run verify:mongo`도 같은 파일의 `mongoUri`를 사용한다.
운영 환경에서는 `allowedOrigins`를 실제 접속 주소의 배열(예: `["https://sunrise.example.com"]`)로 바꾸고 `visitStore`는 `"mongo"`로 유지한다. JSON에는 주석을 넣지 않는다.
개발 시 별도 터미널에서 `npm run dev:server`와 `npm run dev`를 실행한다. Vite의 `/socket.io`, `/health`, `/api` 요청은 3000 포트 서버로 전달된다. `npm run preview`는 프런트 미리보기이며 공동 방 서버를 대신하지 않는다.
개발 시 별도 터미널에서 `npm run dev:server`와 `npm run dev`를 실행한다. Vite의 `/socket.io`, `/health`, `/api` 요청은 2027 포트 서버로 전달된다. `npm run preview`는 프런트 미리보기이며 공동 방 서버를 대신하지 않는다.
`host` 기본값은 `127.0.0.1`이라 현재 컴퓨터에서만 접속할 수 있다. LAN/운영 배포 시 실제 바인딩 주소, `port`, `allowedOrigins`를 지정하고 TLS를 제공하는 프록시와 방화벽을 구성한다. 이번 구현은 외부 호스팅 배포까지 수행하지 않는다. 동일 방/정원을 여러 Node 프로세스에서 따로 실행하지 않는다. 프로세스별 메모리는 공유되지 않으며 현재는 단일 인스턴스 구성이다.
+4 -1
View File
@@ -31,6 +31,7 @@
<symbol id="icon-umbrella" viewBox="0 0 24 24"><path d="M2 12a10 10 0 0 1 20 0H2Zm10 0v7a2 2 0 0 0 4 0M12 2V1"/></symbol>
<symbol id="icon-raincoat" viewBox="0 0 24 24"><path d="m8 3 4 3 4-3 5 5-3 4-2-2v11H8V10l-2 2-3-4 5-5ZM12 6v15M9 16h1M14 16h1"/></symbol>
<symbol id="icon-hood" viewBox="0 0 24 24"><path d="M4 15V10a8 8 0 0 1 16 0v5l-3 6H7l-3-6Z"/><path d="M8 15V10a4 4 0 0 1 8 0v5l-4 3-4-3ZM10 18v4M14 18v4"/></symbol>
<symbol id="icon-flashlight" viewBox="0 0 24 24"><path d="M8 10h8l-1 11H9L8 10ZM6 5h12v5H6V5ZM9 14h6M8 1l-1 2M12 0v3M16 1l1 2"/></symbol>
</svg>
<canvas id="world" aria-label="태양 관측 3D 풍경"></canvas>
<main class="hud" aria-label="관측 조작">
@@ -64,6 +65,7 @@
</section>
</aside>
<div id="solo-time-controls" aria-label="관측 시간">
<button id="time-toggle" type="button" aria-expanded="false" aria-controls="time-settings" aria-label="관측 시간 설정"><svg class="icon" aria-hidden="true"><use href="#icon-clock"/></svg><span id="compact-time" aria-hidden="true">00:00</span><span class="time-toggle-label">설정</span></button>
<div id="time-settings">
<div class="date-row"><span id="mode" class="mode">LIVE</span><time id="selected-time"></time><span class="unit">KST</span></div>
<fieldset id="time-controls" class="time-panel" aria-label="관측 시각 조절" disabled>
@@ -82,7 +84,8 @@
<div class="equipment-menu">
<button type="button" id="equipment-toggle" aria-expanded="false" aria-controls="equipment-options"><svg class="icon" aria-hidden="true"><use href="#icon-umbrella"/></svg>장비</button>
<div id="equipment-options" hidden>
<div class="equipment-controls" role="group" aria-label="우천 장비">
<div class="equipment-controls" role="group" aria-label="장비">
<button type="button" id="gear-flashlight" disabled aria-pressed="false" aria-label="손전등 켜기" title="손전등 · 단축키 6"><svg class="icon" aria-hidden="true"><use href="#icon-flashlight"/></svg><span id="flashlight-lumens"></span></button>
<button type="button" id="gear-umbrella" disabled aria-pressed="false" aria-label="우산 쓰기" title="우산 쓰기 · 단축키 3" aria-describedby="gear-status"><svg class="icon" aria-hidden="true"><use href="#icon-umbrella"/></svg></button>
<button type="button" id="gear-raincoat" disabled aria-pressed="false" aria-label="우비 입기" title="우비 입기 · 단축키 4" aria-describedby="gear-status"><svg class="icon" aria-hidden="true"><use href="#icon-raincoat"/></svg></button>
<button type="button" id="gear-hood" disabled aria-pressed="false" aria-label="모자 쓰기" title="모자 쓰기 · 단축키 5" aria-describedby="gear-status"><svg class="icon" aria-hidden="true"><use href="#icon-hood"/></svg></button>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": 1,
"sourceHash": "6f90fbd7927a723bbf3c46e68f66f22819666d374f069ae12087cc34a30af0c2",
"sourceHash": "ec19bec7b7dded0b4ed8e0733dea4ff2a87976869461cde9a99f6608c6b7836b",
"bufferHash": "d8cf0a36a46572060189e2ca9e9a61e448fb3d0b441abc346b56b7c4603e0d65",
"byteLength": 11036560,
"terrain": {
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -10,7 +10,7 @@
{
"id": "way/337498789",
"label": "정상 암반길",
"width": 3.2,
"width": 5.2,
"points": [
[
126.9642427,
@@ -57,7 +57,7 @@
{
"id": "way/37751526",
"label": "남쪽 하산 계단길",
"width": 2.4,
"width": 4.4,
"points": [
[
126.9652191,
@@ -148,7 +148,7 @@
{
"id": "way/337498787",
"label": "동쪽 계단길",
"width": 2.2,
"width": 4.2,
"points": [
[
126.9646956,
@@ -167,7 +167,7 @@
{
"id": "way/337498788",
"label": "북쪽 능선길",
"width": 2.4,
"width": 4.4,
"points": [
[
126.9652188,
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.
Binary file not shown.
+10 -10
View File
@@ -1,13 +1,13 @@
{
"version": 1,
"sourceHash": "8f7306b64c88b4d706c8bd8ecce0385fe1f5f64502123735b13d7affc2cb8ba7",
"bufferHash": "59f836d4ff44c1f047c4165c99672fc9e7a04f9981f15561f4277754577d6e5a",
"byteLength": 15601904,
"gzipBytes": 6138340,
"originalVertices": 1180560,
"vertices": 209224,
"triangles": 393520,
"originalAttributeBytes": 61389120,
"generateMs": 3773.8809,
"decodeMs": 13.8812999999991
"sourceHash": "6f03c38b2007114b94747b0abbcc26c56b408b0353fc3068b19cc17518542ecc",
"bufferHash": "8fa1ab6646cacc15fc1e84ace034a71826a8dc50f9d5dc2c15aab39e290166d6",
"byteLength": 16099324,
"gzipBytes": 6369382,
"originalVertices": 1219830,
"vertices": 215769,
"triangles": 406610,
"originalAttributeBytes": 63431160,
"generateMs": 4164.3897,
"decodeMs": 16.178200000000288
}
+21
View File
@@ -0,0 +1,21 @@
{
"resolution": 2048,
"grainScale": 145,
"crystalScale": 75,
"fineScale": 600,
"roughness": 0.91,
"relief": 0.0018,
"faceTint": 1.0,
"rimTint": 0.76,
"terrainBlend": 0.82,
"terrainSaturation": 0.65,
"terrainBrightness": 1.15,
"groundPaletteBlend": 0.55,
"grainColors": [
[0.26, [0.045, 0.049, 0.052, 1]],
[0.39, [0.18, 0.19, 0.19, 1]],
[0.46, [0.49, 0.50, 0.49, 1]],
[0.56, [0.72, 0.73, 0.71, 1]],
[0.72, [0.88, 0.89, 0.86, 1]]
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"description": "Hand-traced polygon silhouettes from the user supplied 191x377 reference. Coordinates follow the photograph; depth is an artistic estimate. Rings after the first are holes.",
"layout": {"scale": 0.65, "anchor": [0.18, 1.1], "glyphVerticalOffsets": [0, -0.12, -0.24], "captionVerticalOffset": -0.24},
"caption": {
"text": "해발 629 m",
"font": "C:/Windows/Fonts/HANBatangB.ttf",
"left": -0.125,
"baseline": 0.20,
"runs": [
{"text":"해발", "width":0.20, "height":0.080, "gap":0.04},
{"text":"629", "width":0.24, "height":0.105, "gap":0.05},
{"text":"m", "width":0.10, "height":0.075, "gap":0}
]
},
"glyphs": [
{"character":"冠", "bounds":[60,20,142,99], "placement":[0.18,1.78,0.53,0.45], "shapes":[
[[[67,36],[75,31],[88,29],[104,25],[117,24],[126,20],[132,25],[140,29],[142,34],[134,37],[125,34],[117,31],[102,33],[89,37],[82,44],[76,49],[68,50],[65,45]]],
[[[86,42],[94,38],[102,38],[105,43],[100,49],[97,54],[104,56],[105,61],[98,65],[100,71],[95,76],[98,82],[106,86],[118,87],[127,82],[129,72],[129,60],[133,66],[135,77],[138,84],[136,90],[124,94],[108,97],[96,93],[87,87],[81,94],[72,94],[66,91],[63,86],[69,81],[73,75],[70,71],[73,66],[80,64],[85,60],[82,55],[79,54],[81,48]], [[86,71],[82,76],[82,81],[87,77],[90,72]]],
[[[112,35],[118,34],[125,40],[124,44],[119,48],[120,53],[127,51],[129,56],[125,60],[120,61],[119,74],[116,82],[110,84],[105,78],[101,75],[103,71],[110,74],[112,68],[111,62],[105,65],[101,61],[104,56],[112,54],[112,46],[108,44]]]
]},
{"character":"岳", "bounds":[60,129,142,214], "placement":[0.15,1.29,0.55,0.46], "shapes":[
[[[77,153],[86,145],[99,138],[109,131],[117,130],[122,135],[119,141],[110,145],[98,147],[91,152],[90,158],[97,157],[108,151],[116,149],[124,153],[125,159],[119,162],[116,171],[128,170],[134,173],[139,174],[142,181],[136,183],[128,180],[117,181],[105,184],[91,186],[78,190],[68,189],[62,186],[60,181],[67,180],[76,179],[80,175],[79,169],[75,164]], [[92,164],[98,160],[105,161],[103,171],[97,175],[91,175]]],
[[[83,191],[88,188],[93,188],[95,194],[92,201],[100,199],[102,190],[108,185],[115,187],[116,192],[111,199],[119,197],[121,190],[127,187],[132,190],[130,197],[126,203],[122,208],[115,211],[107,209],[103,214],[97,211],[95,207],[86,211],[80,212],[77,207],[76,201],[73,198],[75,194]]]
]},
{"character":"山", "bounds":[57,250,145,313], "placement":[0.20,0.83,0.57,0.38], "shapes":[
[[[58,305],[64,298],[68,287],[72,277],[78,277],[81,282],[80,289],[78,296],[87,294],[91,282],[95,270],[99,257],[105,255],[110,251],[115,255],[118,262],[113,268],[109,272],[108,281],[105,289],[107,293],[114,292],[115,281],[119,275],[125,277],[129,281],[129,287],[125,293],[131,294],[135,291],[140,294],[143,302],[145,307],[138,307],[127,306],[117,307],[106,308],[94,308],[83,311],[71,313],[62,311],[57,309]]]
]}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"source": "User-supplied Naver place 11491310 coordinate and annotated north-up satellite screenshot, 2026-10-31",
"pin": { "latitude": 37.445208, "longitude": 126.96413 },
"pinTipPixels": [206, 175],
"markerCenterPixels": [231, 230],
"metersPerPixel": 0.2,
"scaleNote": "Approximately 50px per 10m in the same 18.30 zoom satellite view; screenshot registration is approximate.",
"estimatedUncertaintyMeters": 3,
"offsetFromPinMeters": [5, 11],
"offsetFromStartMeters": [-2.9642, -43.1612]
}
+10
View File
@@ -0,0 +1,10 @@
{
"scale": [1.30, 1.30, 1.20],
"leftBowlDepth": 0.42,
"cleftDepth": 0.19,
"leftShoulderLift": 0.20,
"rightFaceOffset": 0.10,
"bedrockRadius": 2.15,
"bedrockBlend": 0.8,
"bedrockOverlap": 0.12
}
+6
View File
@@ -0,0 +1,6 @@
export const SUNRISE_WISH = {
width: 1080, height: 1920, fps: 30, seconds: 10,
from: Date.parse('2026-11-06T06:55:00+09:00'),
to: Date.parse('2026-11-06T07:28:00+09:00'),
heading: 109, pitch: 5, fieldOfView: 30,
} as const;
+38
View File
@@ -0,0 +1,38 @@
"""Blender-authored pocket torch; local -Z exports as the grip's -Y axis."""
import bpy, math
from pathlib import Path
ROOT = Path('D:/project/sunrise')
previous = bpy.context.window.scene
scene = bpy.data.scenes.new('Sheep pocket flashlight')
bpy.context.window.scene = scene
root = bpy.data.objects.new('Flashlight', None)
scene.collection.objects.link(root)
def material(name, color, metal=0):
m=bpy.data.materials.new(name);m.use_nodes=True
s=next(n for n in m.node_tree.nodes if n.type=='BSDF_PRINCIPLED')
s.inputs['Base Color'].default_value=(*color,1)
s.inputs['Metallic'].default_value=metal;s.inputs['Roughness'].default_value=.45
return m
body=material('Torch charcoal',(.065,.085,.10))
trim=material('Torch warm brass',(.55,.35,.12),.5)
lens=material('Torch lens',(.72,.83,.88))
def cylinder(name, radius, depth, z, mat):
bpy.ops.mesh.primitive_cylinder_add(vertices=24,radius=radius,depth=depth,location=(0,0,z))
o=bpy.context.object;o.name=name;o.parent=root;o.data.materials.append(mat)
bevel=o.modifiers.new('Soft edges','BEVEL');bevel.width=.007;bevel.segments=2
return o
cylinder('Torch grip',.065,.22,-.035,body)
for z in [-.10,-.06,-.02,.02]: cylinder('Grip ring',.068,.012,z,trim)
cylinder('Torch cap',.07,.035,.09,body)
cylinder('Torch bezel',.10,.09,-.19,body)
cylinder('Lens rim',.105,.018,-.24,trim)
cylinder('FlashlightLens',.088,.009,-.253,lens)
for o in scene.objects:o.select_set(True)
bpy.context.view_layer.objects.active=root
import io_scene_gltf2
fmt=next(i[0] for i in io_scene_gltf2.get_format_items(None,bpy.context) if i[0]=='GLB')
bpy.ops.export_scene.gltf(filepath=str(ROOT/'public/characters/flashlight.glb'),export_format=fmt,use_selection=True,use_active_scene=True,export_yup=True,export_apply=True)
(ROOT/'assets/characters').mkdir(parents=True,exist_ok=True)
bpy.data.libraries.write(str(ROOT/'assets/characters/flashlight.blend'),{scene})
bpy.context.window.scene=previous
print('Exported flashlight.glb; charcoal body, brass grip rings, inset lens')
+180
View File
@@ -0,0 +1,180 @@
"""Bake photo-guided vector outlines into the existing marker; run inside Blender.
Edits authored vector shapes, not the reference photograph. Existing stone and UVs
are reused. Color and tangent normal are baked for the same glTF material.
"""
import json
from pathlib import Path
import bpy
import numpy as np
ROOT = Path('D:/project/sunrise')
scene = bpy.data.scenes['Summit slab and marker']
bpy.context.window.scene = scene
body = bpy.data.objects['Photo matched summit boulder']
mat = body.data.materials[0]
nodes, links = mat.node_tree.nodes, mat.node_tree.links
bs = next(n for n in nodes if n.type == 'BSDF_PRINCIPLED')
atlas = next(n for n in nodes if n.type == 'UVMAP' and n.uv_map == 'Painted monument atlas')
spec = json.loads((ROOT / 'scripts/constants/marker-inscription.json').read_text(encoding='utf-8'))
N = 2048
mask = np.zeros((N, N), dtype=np.float32)
def atlas_point(x, z, vertical_offset):
ax, az = spec['layout']['anchor']
scale = spec['layout']['scale']
x = ax+(x-ax)*scale
z = az+(z-az)*scale+vertical_offset
return ((.02+.46*(x+1.3)/2.6)*N, (.02+.96*(z+.2)/2.3)*N)
def polygon(points, value):
p = np.array(points)
lo = np.maximum(0, np.floor(p.min(axis=0)).astype(int))
hi = np.minimum(N-1, np.ceil(p.max(axis=0)).astype(int))
X, Y = np.meshgrid(np.arange(lo[0],hi[0]+1)+.5, np.arange(lo[1],hi[1]+1)+.5)
inside = np.zeros(X.shape, dtype=bool)
for a, b in zip(p, np.roll(p, -1, axis=0)):
if abs(b[1]-a[1]) < 1e-9:
continue
inside ^= ((a[1]>Y) != (b[1]>Y)) & (X < (b[0]-a[0])*(Y-a[1])/(b[1]-a[1])+a[0])
patch = mask[lo[1]:hi[1]+1,lo[0]:hi[0]+1]
patch[inside] = value
for glyph_index, glyph in enumerate(spec['glyphs']):
x0,y0,x1,y1 = glyph['bounds']
cx,top,w,h = glyph['placement']
for shape in glyph['shapes']:
for i, ring in enumerate(shape):
points = []
for x,y in ring:
px = cx+((x-x0)/(x1-x0)-.5)*w
pz = top-(y-y0)/(y1-y0)*h
points.append(atlas_point(px,pz,spec['layout']['glyphVerticalOffsets'][glyph_index]))
polygon(points, 1 if i == 0 else 0)
# Rasterize small serif lettering into the same atlas; no font or text mesh is
# exported. Separate runs match the photo's smaller Korean label and unit.
caption = spec['caption']
font = bpy.data.fonts.load(caption['font'], check_existing=True)
left = caption['left']
for run in caption['runs']:
curve = bpy.data.curves.new('Temporary elevation lettering', type='FONT')
curve.body = run['text']
curve.font = font
curve.resolution_u = 16
obj = bpy.data.objects.new('Temporary elevation lettering', curve)
scene.collection.objects.link(obj)
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = evaluated.to_mesh()
try:
mesh.calc_loop_triangles()
coordinates = np.array([(v.co.x,v.co.y) for v in mesh.vertices])
low, high = coordinates.min(axis=0), coordinates.max(axis=0)
for tri in mesh.loop_triangles:
points = []
for vertex in tri.vertices:
x,y = (coordinates[vertex]-low)/(high-low)
px = left+x*run['width']
pz = caption['baseline']+y*run['height']
points.append(atlas_point(px,pz,spec['layout']['captionVerticalOffset']))
polygon(points,1)
finally:
evaluated.to_mesh_clear()
bpy.data.objects.remove(obj,do_unlink=True)
bpy.data.curves.remove(curve)
left += run['width']+run['gap']
if font.users == 0:
bpy.data.fonts.remove(font)
# A sub-texel soft edge preserves the chisel silhouette without stair steps.
soft = (mask*4 + np.roll(mask,1,0)+np.roll(mask,-1,0)+np.roll(mask,1,1)+np.roll(mask,-1,1))/8
rng = np.random.default_rng(194)
wear = rng.uniform(.94,1,mask.shape).astype(np.float32)
wear[rng.random(mask.shape)<.007] = .55
def write_image(name, values):
image = bpy.data.images.get(name) or bpy.data.images.new(name,width=N,height=N,alpha=False)
if tuple(image.size) != (N,N):
image.scale(N,N)
rgba = np.ones((N,N,4),dtype=np.float32)
rgba[:,:,:3] = values[:,:,None]
image.pixels.foreach_set(rgba.ravel())
image.update()
image.pack()
return image
write_image('Hanja pigment mask', soft*wear)
paint = next(n for n in nodes if n.type == 'MIX_RGB' and n.inputs[0].is_linked)
paint.inputs[2].default_value = (.008,.012,.014,1)
target = next(n for n in nodes if n.type == 'TEX_IMAGE' and n.image and n.image.name == 'Painted Hanja on granite')
links.new(paint.outputs[0], bs.inputs['Base Color'])
nodes.active = target
for obj in scene.objects:
obj.select_set(False)
body.select_set(True)
bpy.context.view_layer.objects.active = body
scene.render.bake.use_pass_direct = False
scene.render.bake.use_pass_indirect = False
scene.render.bake.use_pass_color = True
scene.render.bake.margin = 8
bake_types = bpy.ops.object.bake.get_rna_type().properties['type'].enum_items
bpy.ops.object.bake(type=next(i.identifier for i in bake_types if i.identifier == 'DIFFUSE'))
target.image.save()
target.image.pack()
links.new(target.outputs['Color'],bs.inputs['Base Color'])
# Bake the shallow engraving plus the original granite normal into the atlas.
# Reuse nodes on rerun; always start from the original granite normal.
def node(kind, label):
found = next((n for n in nodes if n.label == label),None)
if found is None:
found = nodes.new(kind)
found.label = label
return found
depth = node('ShaderNodeTexImage','Inscription depth source')
depth.image = write_image('Inscription recessed height',1-soft)
depth.image.colorspace_settings.name = 'Non-Color'
links.new(atlas.outputs['UV'],depth.inputs['Vector'])
original_normal = next(n for n in nodes if n.type == 'NORMAL_MAP' and n.label != 'Baked inscription normal')
# The old material used a default tangent basis; explicitly retain its grain UV.
original_normal.uv_map = 'Granite grain UV'
bump = node('ShaderNodeBump','Shallow inscription bevel')
bump.inputs['Strength'].default_value = .65
bump.inputs['Distance'].default_value = .0025
links.new(depth.outputs['Color'],bump.inputs['Height'])
links.new(original_normal.outputs['Normal'],bump.inputs['Normal'])
links.new(bump.outputs['Normal'],bs.inputs['Normal'])
normal_target = node('ShaderNodeTexImage','Baked inscription normal texture')
normal_image = bpy.data.images.get('Marker inscription normal') or bpy.data.images.new('Marker inscription normal',width=N,height=N,alpha=False)
normal_image.colorspace_settings.name = 'Non-Color'
normal_target.image = normal_image
links.new(atlas.outputs['UV'],normal_target.inputs['Vector'])
nodes.active = normal_target
# The edge material needs its own temporary bake target to keep its maps intact.
temporary = []
for side in list(body.data.materials)[1:]:
previous = side.node_tree.nodes.active
t = side.node_tree.nodes.new('ShaderNodeTexImage')
t.image = normal_image
side.node_tree.nodes.active = t
temporary.append((side,t,previous))
try:
bpy.ops.object.bake(type=next(i.identifier for i in bake_types if i.identifier == 'NORMAL'))
finally:
for side,t,previous in temporary:
side.node_tree.nodes.remove(t)
side.node_tree.nodes.active = previous
normal_image.filepath_raw = str(ROOT / 'public/terrain/materials/marker-inscription-normal.png')
normal_image.file_format = next(i.identifier for i in normal_image.bl_rna.properties['file_format'].enum_items if i.identifier == 'PNG')
normal_image.save()
normal_image.pack()
normal = node('ShaderNodeNormalMap','Baked inscription normal')
normal.uv_map = 'Painted monument atlas'
links.new(normal_target.outputs['Color'],normal.inputs['Color'])
links.new(normal.outputs['Normal'],bs.inputs['Normal'])
body['inscriptionStyle'] = 'Photo-guided silhouette polygons with negative spaces, dense charcoal pigment and shallow baked engraving'
scene.render.filepath = str(ROOT / 'assets/summit/blender-plateau-study/marker-detailed-inscription.png')
bpy.ops.render.render(write_still=True)
print('Detailed inscription baked:',int(np.count_nonzero(mask)),'texels, 3 glyphs +',caption['text'],'; color + tangent normal.')
+10
View File
@@ -15,6 +15,16 @@ for obj in [marker,*marker.children_recursive]:obj.select_set(True)
bpy.context.view_layer.objects.active=marker
location=marker.location.copy();marker.location=(0,0,0)
photo_body=bpy.data.objects.get('Photo matched summit boulder')
# Repacking an already-packed image after baking can retain its old PNG bytes.
# Refresh from the saved bake before glTF copies those bytes into the GLB.
if photo_body and photo_body.get('paintedInscription'):
images={n.image for mat in photo_body.data.materials for n in mat.node_tree.nodes
if n.type=='TEX_IMAGE' and n.image and n.image.filepath}
for image in images:
path=Path(bpy.path.abspath(image.filepath))
data=path.read_bytes()
image.pack(data=data,data_len=len(data))
image.reload()
restore=None
if photo_body and not photo_body.get('paintedInscription'):
mat=photo_body.data.materials[0]
+18
View File
@@ -0,0 +1,18 @@
"""Apply the satellite-estimated marker location in the authored plateau scene."""
import bpy, json
from pathlib import Path
ROOT = Path('D:/project/sunrise')
spec = json.loads((ROOT/'scripts/constants/marker-location.json').read_text())
root = bpy.data.objects['summit-marker']
baseline = bpy.data.meshes['Slab before marker seating']
x, south = spec['offsetFromStartMeters']
def ground(x, y):
return min(baseline.vertices, key=lambda v:(v.co.x-x)**2+(v.co.y-y)**2).co.z
root.location.z += ground(x, -south)-ground(root.location.x, root.location.y)
root.location.x, root.location.y = x, -south
root['locationSource'] = spec['source']
root['locationUncertaintyMeters'] = spec['estimatedUncertaintyMeters']
# Restore the original ground, then seat the existing stone at its new location.
exec(compile((ROOT/'scripts/sculpt-asymmetric-marker.py').read_text(), 'sculpt-asymmetric-marker.py', 'exec'))
exec(compile((ROOT/'scripts/export-summit-plateau.py').read_text(), 'export-summit-plateau.py', 'exec'))
+178
View File
@@ -0,0 +1,178 @@
"""Author and bake a dedicated light-grey granite material in Blender.
The procedural material is retained for editing; the temporary bake plane is
removed. Other terrain textures and the monument's silhouette stay unchanged.
Run detail-marker-inscription.py afterwards to rebake the preserved lettering.
"""
import bpy, json, math
from pathlib import Path
from mathutils import Vector
from mathutils.noise import noise_vector
ROOT = Path('D:/project/sunrise')
spec = json.loads((ROOT/'scripts/constants/marker-granite.json').read_text())
scene = bpy.data.scenes['Summit slab and marker']
bpy.context.window.scene = scene
body = bpy.data.objects['Photo matched summit boulder']
mat = bpy.data.materials.get('Marker grey granite authoring') or bpy.data.materials.new('Marker grey granite authoring')
mat.use_nodes = True
mat.use_fake_user = True
nodes, links = mat.node_tree.nodes, mat.node_tree.links
nodes.clear()
bs = nodes.new('ShaderNodeBsdfPrincipled')
out = nodes.new('ShaderNodeOutputMaterial')
links.new(bs.outputs['BSDF'],out.inputs['Surface'])
uv = nodes.new('ShaderNodeTexCoord')
def noise(scale):
n = nodes.new('ShaderNodeTexNoise')
n.inputs['Scale'].default_value = scale
n.inputs['Detail'].default_value = 2
links.new(uv.outputs['UV'],n.inputs['Vector'])
return n
grain = noise(spec['grainScale'])
fine = noise(spec['fineScale'])
cloud = noise(18)
ramp = nodes.new('ShaderNodeValToRGB')
for i,(position,color) in enumerate(spec['grainColors']):
element = ramp.color_ramp.elements[i] if i < 2 else ramp.color_ramp.elements.new(position)
element.position = position
element.color = color
links.new(grain.outputs['Fac'],ramp.inputs['Fac'])
crystal = nodes.new('ShaderNodeTexVoronoi')
crystal.inputs['Scale'].default_value = spec['crystalScale']
links.new(uv.outputs['UV'],crystal.inputs['Vector'])
mica = nodes.new('ShaderNodeValToRGB')
mica.color_ramp.elements[0].position = .12
mica.color_ramp.elements[0].color = (.11,.12,.13,1)
mica.color_ramp.elements[1].position = .26
mica.color_ramp.elements[1].color = (1,1,1,1)
links.new(crystal.outputs['Distance'],mica.inputs['Fac'])
mix = nodes.new('ShaderNodeMixRGB')
mix.blend_type = next(i.identifier for i in mix.bl_rna.properties['blend_type'].enum_items if i.identifier == 'MULTIPLY')
mix.inputs[0].default_value = .8
links.new(ramp.outputs['Color'],mix.inputs[1])
links.new(mica.outputs['Color'],mix.inputs[2])
cloud_ramp = nodes.new('ShaderNodeValToRGB')
cloud_ramp.color_ramp.elements[0].position = .3
cloud_ramp.color_ramp.elements[0].color = (.36,.38,.37,1)
cloud_ramp.color_ramp.elements[1].position = .65
cloud_ramp.color_ramp.elements[1].color = (1,1,1,1)
links.new(cloud.outputs['Fac'],cloud_ramp.inputs['Fac'])
weather = nodes.new('ShaderNodeMixRGB')
weather.blend_type = mix.blend_type
weather.inputs[0].default_value = .5
links.new(mix.outputs[0],weather.inputs[1])
links.new(cloud_ramp.outputs[0],weather.inputs[2])
# The same source as the underlying summit rock anchors the monument's palette.
terrain = nodes.new('ShaderNodeTexImage')
terrain.image = bpy.data.images.load(str(ROOT/'public/terrain/materials/rock-color.jpg'),check_existing=True)
links.new(uv.outputs['UV'],terrain.inputs['Vector'])
tone = nodes.new('ShaderNodeHueSaturation')
tone.inputs['Saturation'].default_value = spec['terrainSaturation']
tone.inputs['Value'].default_value = spec['terrainBrightness']
links.new(terrain.outputs['Color'],tone.inputs['Color'])
slab = bpy.data.objects['SLAB_SURFACE']
root = bpy.data.objects['summit-marker']
nearest = min(slab.data.vertices,key=lambda v:(v.co.x-root.location.x)**2+(v.co.y-root.location.y)**2)
loop = next(l for l in slab.data.loops if l.vertex_index == nearest.index)
ground_uv = slab.data.uv_layers.active.data[loop.index].uv
ground = nodes.new('ShaderNodeTexImage')
ground.image = bpy.data.images['Summit unique weathered granite']
ground.inputs['Vector'].default_value = (ground_uv.x,ground_uv.y,0)
palette = nodes.new('ShaderNodeMixRGB')
palette.inputs[0].default_value = spec['groundPaletteBlend']
links.new(tone.outputs['Color'],palette.inputs[1])
links.new(ground.outputs['Color'],palette.inputs[2])
surface = nodes.new('ShaderNodeMixRGB')
surface.inputs[0].default_value = spec['terrainBlend']
links.new(weather.outputs[0],surface.inputs[1])
links.new(palette.outputs[0],surface.inputs[2])
links.new(surface.outputs[0],bs.inputs['Base Color'])
bs.inputs['Roughness'].default_value = spec['roughness']
bump = nodes.new('ShaderNodeBump')
bump.inputs['Distance'].default_value = spec['relief']
bump.inputs['Strength'].default_value = .65
links.new(grain.outputs['Fac'],bump.inputs['Height'])
micro = nodes.new('ShaderNodeBump')
micro.inputs['Distance'].default_value = spec['relief']*.18
micro.inputs['Strength'].default_value = .4
terrain_normal = nodes.new('ShaderNodeTexImage')
terrain_normal.image = bpy.data.images.load(str(ROOT/'public/terrain/materials/rock-normal.jpg'),check_existing=True)
terrain_normal.image.colorspace_settings.name = 'Non-Color'
links.new(uv.outputs['UV'],terrain_normal.inputs['Vector'])
normal = nodes.new('ShaderNodeNormalMap')
normal.inputs['Strength'].default_value = .65
links.new(terrain_normal.outputs['Color'],normal.inputs['Color'])
links.new(normal.outputs['Normal'],bump.inputs['Normal'])
links.new(fine.outputs['Fac'],micro.inputs['Height'])
links.new(bump.outputs['Normal'],micro.inputs['Normal'])
links.new(micro.outputs['Normal'],bs.inputs['Normal'])
mesh = bpy.data.meshes.new('Temporary granite bake plane')
mesh.from_pydata([(0,0,0),(1,0,0),(1,1,0),(0,1,0)],[],[(0,1,2,3)])
mesh.uv_layers.new(name='UVMap')
for loop in mesh.loops:
mesh.uv_layers.active.data[loop.index].uv = mesh.vertices[loop.vertex_index].co.xy
plane = bpy.data.objects.new(mesh.name,mesh)
scene.collection.objects.link(plane)
mesh.materials.append(mat)
for obj in scene.objects:
obj.select_set(False)
plane.select_set(True)
bpy.context.view_layer.objects.active = plane
target = nodes.new('ShaderNodeTexImage')
nodes.active = target
images = {}
try:
for suffix,bake_type in [('color','DIFFUSE'),('normal','NORMAL')]:
name = 'marker-grey-granite-'+suffix
image = bpy.data.images.get(name) or bpy.data.images.new(name,width=spec['resolution'],height=spec['resolution'],alpha=False)
if suffix == 'normal':
image.colorspace_settings.name = 'Non-Color'
target.image = image
scene.render.bake.use_pass_direct = False
scene.render.bake.use_pass_indirect = False
scene.render.bake.use_pass_color = True
bpy.ops.object.bake(type=next(i.identifier for i in bpy.ops.object.bake.get_rna_type().properties['type'].enum_items if i.identifier == bake_type))
image.filepath_raw = str(ROOT/'public/terrain/materials'/(name+'.png'))
image.file_format = next(i.identifier for i in image.bl_rna.properties['file_format'].enum_items if i.identifier == 'PNG')
image.save()
image.pack()
images[suffix] = image
finally:
bpy.data.objects.remove(plane,do_unlink=True)
bpy.data.meshes.remove(mesh)
# Replace only this monument's source textures; surrounding rocks keep theirs.
for i,material in enumerate(list(body.data.materials)):
if material.users > 1:
material = material.copy()
body.data.materials[i] = material
for n in material.node_tree.nodes:
if n.type != 'TEX_IMAGE' or not n.image:
continue
if n.image.name.startswith(('rock-color','marker-grey-granite-color')):
n.image = images['color']
elif n.image.name.startswith(('rock-normal','marker-grey-granite-normal')):
n.image = images['normal']
shader = next(n for n in material.node_tree.nodes if n.type == 'BSDF_PRINCIPLED')
shader.inputs['Roughness'].default_value = spec['roughness']
# Recompute neutral weathering, rather than repeatedly brightening the old tint.
colors = body.data.color_attributes['Granite weathering']
for v in body.data.vertices:
x,y,z = v.co
panel = abs((x-.18)/max(.16,.57-.18*z))
rim = min(1,max(0,(panel-.8)/.4))
rim = rim*rim*(3-2*rim)
weathering = .045*noise_vector(Vector((x*9,z*9,2.7)))[0]
seam = .05*math.exp(-((x+.32-.18*z)/.07)**2)
flecks = noise_vector(Vector((x*31,z*31,y*31)))[0]
patina = .20*max(0,min(1,(flecks-.05)*3))*rim
cleft = .12*math.exp(-((x+.18-.12*z)/.085)**2)
grey = spec['faceTint']*(1-rim)+spec['rimTint']*rim+weathering-seam-patina-cleft
colors.data[v.index].color = (grey,grey,grey,1)
body['graniteFinish'] = 'Light neutral grey quartz/feldspar, dark mica specks, granular microrelief; photo-guided'
print('Dedicated grey granite baked; marker source nodes updated, geometry and inscription unchanged.')
+59
View File
@@ -0,0 +1,59 @@
"""Shape the left hollow and deep cleft from the supplied oblique photos.
Restores a saved mesh baseline before every run so edits never accumulate.
The front UV atlas and inscription remain attached to their original vertices.
"""
import bpy, math, json
from mathutils import Vector
from pathlib import Path
ROOT = Path('D:/project/sunrise')
spec = json.loads((ROOT/'scripts/constants/marker-shape.json').read_text())
scene = bpy.data.scenes['Summit slab and marker']
bpy.context.window.scene = scene
body = bpy.data.objects['Photo matched summit boulder']
root = bpy.data.objects['summit-marker']
baseline = bpy.data.meshes.get('Marker before asymmetric hollow')
if baseline is None:
baseline = body.data.copy()
baseline.name = 'Marker before asymmetric hollow'
baseline.use_fake_user = True
mesh = body.data
for vertex, source in zip(mesh.vertices,baseline.vertices):
x,y,z = source.co
front = max(0,min(1,(.15-y)/.5))
bowl = math.exp(-((x+.55)/.34)**2-((z-.86)/.67)**4)
# A narrow, slanting cleft between the sunken left bowl and inscribed slab.
cleft = math.exp(-((x+.18-.12*z)/.065)**2)*math.exp(-((z-.86)/.88)**4)
shoulder = math.exp(-((x+.44)/.48)**2)*max(0,min(1,(z-.8)/.7))
vertex.co = (x-.13*shoulder+spec['rightFaceOffset']*max(0,min(1,(x+.12)/.6)),
y+front*(spec['leftBowlDepth']*bowl+spec['cleftDepth']*cleft),
z+spec['leftShoulderLift']*shoulder)
mesh.update()
root.scale = spec['scale']
root['blockedRadius'] = round(max(math.hypot(v.co.x*root.scale.x,v.co.y*root.scale.y) for v in mesh.vertices)+.12,2)
body['asymmetricHollow'] = True
# Close the local fissure under the enlarged stone using the shared terrain mesh.
# This surface is exported for both rendering and walking; no separate pedestal.
bpy.context.view_layer.update()
slab = bpy.data.objects['SLAB_SURFACE']
ground_baseline = bpy.data.meshes.get('Slab before marker seating')
if ground_baseline is None:
ground_baseline = slab.data.copy()
ground_baseline.name = 'Slab before marker seating'
ground_baseline.use_fake_user = True
base = min((body.matrix_world@v.co).z for v in mesh.vertices)
for vertex,source in zip(slab.data.vertices,ground_baseline.vertices):
vertex.co = source.co
d = math.hypot(vertex.co.x-root.location.x,vertex.co.y-root.location.y)
if d >= spec['bedrockRadius']+spec['bedrockBlend']:
continue
t = max(0,min(1,(d-spec['bedrockRadius'])/spec['bedrockBlend']))
weight = 1-t*t*(3-2*t)
target = base+spec['bedrockOverlap']+.025*math.sin(vertex.co.x*3+vertex.co.y*2)
if vertex.co.z < target:
vertex.co.z += (target-vertex.co.z)*weight
slab.data.update()
scene.camera.location = root.location+Vector((1.7,-10,3.5))
scene.camera.rotation_euler = (root.location+Vector((0,0,1.5))-scene.camera.location).to_track_quat('-Z','Y').to_euler()
scene.camera.data.lens = 55
print('Asymmetric left hollow sculpted; scale',spec['scale'],'blocked radius',root['blockedRadius'])
+1 -1
View File
@@ -3,5 +3,5 @@ export const SERVER_LIMITS = {
movementBudgetSeconds: 0.5,
updateIntervalMs: 100,
weatherSampleMs: 60000,
defaultOrigins: ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:5173', 'http://127.0.0.1:5173'],
defaultOrigins: ['http://localhost:2027', 'http://127.0.0.1:2027', 'http://localhost:5173', 'http://127.0.0.1:5173'],
} as const;
+4 -2
View File
@@ -43,7 +43,8 @@ function validUpdate(value: unknown): value is PlayerUpdate {
&& Math.abs(v.yaw) <= Math.PI * 2 && typeof v.moving === 'boolean'
&& ['idle', 'wave', 'pray'].includes(v.emote) && !!gear
&& typeof gear.umbrella === 'boolean' && typeof gear.raincoat === 'boolean'
&& typeof gear.hood === 'boolean' && (!gear.hood || gear.raincoat);
&& typeof gear.hood === 'boolean' && (!gear.hood || gear.raincoat)
&& (gear.flashlight === undefined || typeof gear.flashlight === 'boolean');
}
export class SummitRoom {
@@ -191,7 +192,8 @@ export class SummitRoom {
|| Object.keys(update.equipment).some(key => update.equipment[key as keyof typeof update.equipment] !== s.player!.equipment[key as keyof typeof update.equipment]);
if (!changed) return;
s.player = { ...s.player, x: update.x, z: update.z, yaw: update.yaw, moving: update.moving, emote,
equipment: { umbrella: update.equipment.umbrella, raincoat: update.equipment.raincoat, hood: update.equipment.hood },
equipment: { umbrella: update.equipment.umbrella, raincoat: update.equipment.raincoat, hood: update.equipment.hood,
...(update.equipment.flashlight === undefined ? {} : { flashlight: update.equipment.flashlight }) },
updatedAt: at, emoteStartedAt: emote === s.player.emote ? s.player.emoteStartedAt : at };
this.dirty = true;
}
+1
View File
@@ -1,6 +1,7 @@
export const CHARACTER = {
url: '/characters/red-sheep.glb?v=rounded-v10',
scale: 0.62,
selfBadgeGap: 0.12,
outline: 0.018,
ink: '#352724',
lighting: {
+12
View File
@@ -0,0 +1,12 @@
export const FLASHLIGHT = {
url: '/characters/flashlight.glb?v=1',
lumens: 600,
bloomStrengthScale: 0.08,
bloomThreshold: 4,
bloomRadius: 0.03,
distance: 18,
halfAngle: 28 * Math.PI / 180,
penumbra: 0.6,
color: '#fff2dc',
nightAltitude: 0,
} as const;
+5
View File
@@ -0,0 +1,5 @@
export const SUMMIT_MARKER = {
assetRevision: 'e8ea25042407',
buriedDepth: 0.12,
footHeight: 0.18,
} as const;
+1
View File
@@ -2,6 +2,7 @@
export const SUMMIT_MATERIALS = {
baseUrl: '/terrain/materials',
anisotropy: 8,
minimumRoughness: 0.85,
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' },
File diff suppressed because one or more lines are too long
+1
View File
@@ -3,6 +3,7 @@ export const SUMMIT_TERRAIN = {
start: { latitude: 37.44472, longitude: 126.96422 },
coreRadius: 100,
outerRadius: 200,
plateauBlendMeters: 10,
// Looking south leaves the radar to the right, the eastern valley to the left.
heading: 190,
pitch: -3,
+5 -5
View File
@@ -1,4 +1,4 @@
/** OSM centerlines; widths and surface boundaries estimated from Naver Oct 2013 panoramas.
/** OSM centerlines; photo-estimated widths include 1m of movement clearance on each side.
* No public access inferred for the radar facility spur (way/337498785).
*/
import { SUMMIT_PLATEAU } from './summitPlateau.ts';
@@ -18,7 +18,7 @@ export const SUMMIT_TRAILS = {
{
"id": "way/337498789",
"label": "정상 암반길",
"width": 3.2,
"width": 5.2,
"points": [
[
126.9642427,
@@ -65,7 +65,7 @@ export const SUMMIT_TRAILS = {
{
"id": "way/37751526",
"label": "남쪽 하산 계단길",
"width": 2.4,
"width": 4.4,
"points": [
[
126.9652191,
@@ -156,7 +156,7 @@ export const SUMMIT_TRAILS = {
{
"id": "way/337498787",
"label": "동쪽 계단길",
"width": 2.2,
"width": 4.2,
"points": [
[
126.9646956,
@@ -175,7 +175,7 @@ export const SUMMIT_TRAILS = {
{
"id": "way/337498788",
"label": "북쪽 능선길",
"width": 2.4,
"width": 4.4,
"points": [
[
126.9652188,
+1 -1
View File
@@ -1,7 +1,7 @@
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 Equipment { umbrella: boolean; raincoat: boolean; hood: boolean; flashlight?: boolean }
export interface PlayerUpdate {
x: number;
z: number;
+1 -1
View File
@@ -26,7 +26,7 @@ export function createRemotePlayers(template: Sheep, scene: Object3D, heightAt:
entry.elapsed = 0;
}
entry.state = state;
entry.sheep.setEquipment(state.equipment.umbrella, state.equipment.raincoat, state.equipment.hood);
entry.sheep.setEquipment(state.equipment.umbrella, state.equipment.raincoat, state.equipment.hood, state.equipment.flashlight);
}
},
update(delta: number, serverTime: number) {
+23
View File
@@ -0,0 +1,23 @@
import { SpotLight, Vector3, type Object3D } from 'three';
import { FLASHLIGHT } from '../constants/flashlight.ts';
/** Convert nominal lumens into candela over the beam cone. Distance is a render cutoff. */
export function createFlashlight(player: Object3D) {
const grip = player.getObjectByName('HeldFlashlight')!;
const light = new SpotLight(FLASHLIGHT.color, 0, FLASHLIGHT.distance, FLASHLIGHT.halfAngle, FLASHLIGHT.penumbra, 2);
const solidAngle = 2 * Math.PI * (1 - Math.cos(FLASHLIGHT.halfAngle));
light.intensity = FLASHLIGHT.lumens / solidAngle;
light.visible = false;
const tip = new Vector3(), aim = new Vector3();
return {
light,
update(on: boolean) {
light.visible = on;
if (!on) return;
grip.updateWorldMatrix(true, false);
light.position.copy(tip.set(0, -.27, 0).applyMatrix4(grip.matrixWorld));
light.target.position.copy(aim.set(0, -1.27, 0).applyMatrix4(grip.matrixWorld));
},
dispose() { light.removeFromParent(); light.target.removeFromParent(); light.dispose(); },
};
}
+18 -6
View File
@@ -2,12 +2,13 @@ import { BackSide, Group, MathUtils, Mesh, MeshBasicMaterial, Quaternion, Vector
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { CHARACTER } from '../constants/character.ts';
import { RAIN_GEAR } from '../constants/rainGear.ts';
import { FLASHLIGHT } from '../constants/flashlight.ts';
import type { Emote } from '../controls/emotes.ts';
export interface Sheep {
group: Group;
clone(): Sheep;
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp?: boolean): void;
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp?: boolean, flashlight?: boolean): void;
updateLighting(altitude: number, ambient: number): void;
pose(emote: Emote, elapsed: number, moving: boolean, time: number): boolean;
dispose(): void;
@@ -15,12 +16,12 @@ export interface Sheep {
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 });
const [gltf, umbrella, raincoat, flashlight] = await Promise.all(
[CHARACTER.url, RAIN_GEAR.umbrellaUrl, RAIN_GEAR.raincoatUrl, FLASHLIGHT.url].map(url => loader.loadAsync(url)));
return createSheep(gltf.scene, { umbrella: umbrella.scene, raincoat: raincoat.scene, flashlight: flashlight.scene });
}
export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Group }): Sheep {
export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Group; flashlight?: Group }): Sheep {
const group = new Group();
group.name = 'PlayerSheep';
group.add(model);
@@ -32,6 +33,12 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
return [name, joint];
})) as Record<string, Object3D>;
const rest = Object.fromEntries(Object.entries(joints).map(([name, joint]) => [name, joint.quaternion.clone()]));
if (gear?.flashlight) {
gear.flashlight.name = 'HeldFlashlight';
gear.flashlight.position.set(0, -.52, 0);
joints.ArmLeft.add(gear.flashlight);
gear.flashlight.visible = false;
}
const coatParts: Object3D[] = [];
let hood: Object3D | undefined;
let foldedHood: Object3D | undefined;
@@ -103,6 +110,8 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
const joints = rigJoints;
const rigCoatParts = coatParts.map(part => copy(part)!);
const rigHood = copy(hood), rigFoldedHood = copy(foldedHood), rigUmbrella = copy(gear?.umbrella);
const rigFlashlight = copy(gear?.flashlight);
let flashlightOn = false;
let umbrellaOpen = false;
const rotation = new Quaternion();
const down = new Vector3(0, -1, 0);
@@ -117,7 +126,9 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
return {
group,
clone() { return bindRig(group.clone(true), false); },
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp = false) {
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp = false, flashlight = false) {
flashlightOn = flashlight;
if (rigFlashlight) rigFlashlight.visible = flashlight;
umbrellaOpen = umbrella;
if (rigUmbrella) rigUmbrella.visible = umbrella;
rigCoatParts.forEach(part => { part.visible = raincoat; });
@@ -153,6 +164,7 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
}
// Keep the grip stable while the free arm and legs perform their own animations.
if (umbrellaOpen) pointArm('ArmRight', aim.set(.05, -.45, .7));
if (flashlightOn) pointArm('ArmLeft', aim.set(-.08, -.22, 1));
return changed;
},
dispose() {
+3 -1
View File
@@ -4,6 +4,7 @@ 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 { SUMMIT_MARKER } from '../constants/summitMarker.ts';
import { summitPosition } from '../controls/summitCoordinates.ts';
export type StructureAssets = Map<string, Group>;
@@ -14,7 +15,8 @@ export async function loadStructures(includeRelief = false): Promise<StructureAs
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');
const revision = id === SUMMIT_PLATEAU.marker.assetId ? `?v=${SUMMIT_MARKER.assetRevision}` : '';
const model = await loader.loadAsync(STRUCTURE_ASSETS.baseUrl + '/' + id + '.glb' + revision);
return [id, model.scene] as const;
})));
}
+19 -2
View File
@@ -1,5 +1,6 @@
import { Group, Mesh, type Material, type BufferGeometry, type MeshStandardMaterial } from 'three';
import { Box3, Group, Mesh, Vector3, type Material, type BufferGeometry, type MeshStandardMaterial } from 'three';
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
import { SUMMIT_MARKER } from '../constants/summitMarker.ts';
export function createSummitMarker(asset: Group, start: {x: number; z: number},
groundAt: (x: number, z: number) => number, rockMaterial: MeshStandardMaterial) {
@@ -7,6 +8,23 @@ export function createSummitMarker(asset: Group, start: {x: number; z: number},
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);
group.add(asset);
group.updateMatrixWorld(true);
const base = new Box3().setFromObject(asset).min.y;
const point = new Vector3();
let seatingOffset = Infinity;
asset.traverse(object => {
if (!(object instanceof Mesh)) return;
const positions = object.geometry.getAttribute('position');
for (let i=0; i<positions.count; i++) {
point.fromBufferAttribute(positions,i).applyMatrix4(object.matrixWorld);
if (point.y-base > SUMMIT_MARKER.footHeight) continue;
seatingOffset = Math.min(seatingOffset,
groundAt(point.x,point.z)-SUMMIT_MARKER.buriedDepth-point.y);
}
});
if (Number.isFinite(seatingOffset)) group.position.y += seatingOffset;
group.updateMatrixWorld(true);
const granite = rockMaterial.clone();
granite.vertexColors = false;
const materials = new Set<Material>([granite]);
@@ -17,7 +35,6 @@ export function createSummitMarker(asset: Group, start: {x: number; z: number},
(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());
+14 -2
View File
@@ -45,8 +45,20 @@ export function applySummitTerrain(geometry: BufferGeometry, meta: TerrainMetada
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));
if (authored !== undefined) {
// Fade the sculpt into the corrected terrain before its rectangular edge.
// This also suppresses discontinuities in the exported outermost samples.
const localX = x - start.x, localZ = z - start.z;
const smooth = (distance: number) => {
const t = Math.min(1, Math.max(0, distance / SUMMIT_TERRAIN.plateauBlendMeters));
return t * t * (3 - 2 * t);
};
const blend = smooth(Math.min(localX - xs[0], xs.at(-1)! - localX))
* smooth(Math.min(localZ - zs[0], zs.at(-1)! - localZ));
const target = authored - meta.observerSurfaceElevation
- (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
positions.setY(i, positions.getY(i) + (target - positions.getY(i)) * blend);
}
}
positions.needsUpdate = true;
geometry.computeVertexNormals();
+4 -1
View File
@@ -43,6 +43,8 @@ export function createSummitTrails(geometry: BufferGeometry, onTextureLoaded: ()
}\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})`));
shader.fragmentShader=shader.fragmentShader.replace('#include <metalnessmap_fragment>',
`roughnessFactor=max(roughnessFactor,${SUMMIT_MATERIALS.minimumRoughness});\n#include <metalnessmap_fragment>`);
};
rockMaterial.customProgramCacheKey=()=>`route-rock-detail-${detail.secondaryScale}-${secondaryBlend}`;
// Keep leaf detail near the observer; avoid obvious repeated bands across distant slopes.
@@ -114,7 +116,8 @@ export function createSummitTrails(geometry: BufferGeometry, onTextureLoaded: ()
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)'));
ShaderChunk.roughnessmap_fragment.replace('texture2D( roughnessMap, vRoughnessMapUv )','summitSample(roughnessMap,trailRoughness,rockRoughness,vRoughnessMapUv,2)')
+ `\nroughnessFactor=max(roughnessFactor,${SUMMIT_MATERIALS.minimumRoughness});`);
};
}
const mesh=new Mesh(geometry,material);mesh.name=`Summit ${name} PBR surface`;group.add(mesh);
+48 -10
View File
@@ -49,6 +49,9 @@ import { createRainGearState, type RainGearItem } from '../controls/rainGear.ts'
import { faceTravel, updateThirdPersonCamera } from '../controls/thirdPersonCamera.ts';
import { createRemotePlayers } from '../multiplayer/remotePlayers.ts';
import type { PlayerState, PlayerUpdate } from '../multiplayer/protocol.ts';
import { createSelfBadge } from '../ui/selfBadge.ts';
import { createFlashlight } from './flashlight.ts';
import { FLASHLIGHT } from '../constants/flashlight.ts';
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,
@@ -66,6 +69,10 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
const horizon = createHorizon(camera.position, terrainData.metadata.observerSurfaceElevation);
const terrain = createTerrain(terrainData, coast, reconstructSummit);
const remotePlayers = sheep ? createRemotePlayers(sheep, scene, terrain.heightAt) : undefined;
const selfBadge = sheep ? createSelfBadge(sheep.group, canvas) : undefined;
const flashlight = sheep ? createFlashlight(sheep.group) : undefined;
if (flashlight) scene.add(flashlight.light, flashlight.light.target);
let flashlightOn = false;
let networkBlocked = false;
let sharedNow: (() => number) | null = null;
let playerMoving = false;
@@ -181,8 +188,17 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
const umbrellaButton = document.querySelector<HTMLButtonElement>('#gear-umbrella')!;
const raincoatButton = document.querySelector<HTMLButtonElement>('#gear-raincoat')!;
const hoodButton = document.querySelector<HTMLButtonElement>('#gear-hood')!;
const flashlightButton = document.querySelector<HTMLButtonElement>('#gear-flashlight')!;
document.getElementById('flashlight-lumens')!.textContent = `${FLASHLIGHT.lumens} lm`;
flashlightButton.title = `손전등 · ${FLASHLIGHT.lumens} lm · 단축키 6`;
const gearStatus = document.getElementById('gear-status')!;
const refreshGear = () => {
flashlightButton.disabled = !sheep || overview || networkBlocked;
const dark = Boolean(solarState && solarState.altitude <= FLASHLIGHT.nightAltitude);
flashlightButton.setAttribute('aria-disabled', String(flashlightButton.disabled || !dark));
flashlightButton.dataset.unavailableReason = dark ? '' : '손전등은 해가 진 뒤 사용할 수 있습니다';
flashlightButton.setAttribute('aria-pressed', String(flashlightOn));
flashlightButton.setAttribute('aria-label', `손전등 ${flashlightOn ? '끄기' : '켜기'} · ${FLASHLIGHT.lumens} lm`);
umbrellaButton.disabled = raincoatButton.disabled = hoodButton.disabled = !sheep || overview || networkBlocked;
for (const button of [umbrellaButton, raincoatButton, hoodButton]) {
const reason = !rainGear.available ? EQUIPMENT_NOTICE.weather
@@ -207,12 +223,19 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
};
const toggleGear = (item: RainGearItem) => {
if (!sheep || overview || networkBlocked || !rainGear.toggle(item)) return;
sheep.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood);
sheep.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
refreshGear(); rendering.invalidate();
};
umbrellaButton.addEventListener('click', () => toggleGear('umbrella'), { signal: emoteEvents.signal });
raincoatButton.addEventListener('click', () => toggleGear('raincoat'), { signal: emoteEvents.signal });
hoodButton.addEventListener('click', () => toggleGear('hood'), { signal: emoteEvents.signal });
const toggleFlashlight = () => {
if (!sheep || overview || networkBlocked || !solarState || solarState.altitude > FLASHLIGHT.nightAltitude) return;
flashlightOn = !flashlightOn;
sheep.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
refreshGear(); rendering.invalidate();
};
flashlightButton.addEventListener('click', toggleFlashlight, { signal: emoteEvents.signal });
const prayerAvailable = () => Boolean(solarState && canPray(solarState.date, solarState.sunrise));
const refreshEmotes = () => {
refreshGear();
@@ -240,8 +263,9 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
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;
if (!controls.active() || event.repeat || !['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6'].includes(event.code)) return;
event.preventDefault();
if (event.code === 'Digit6') { toggleFlashlight(); return; }
if (['Digit3', 'Digit4', 'Digit5'].includes(event.code)) {
toggleGear(event.code === 'Digit3' ? 'umbrella' : event.code === 'Digit4' ? 'raincoat' : 'hood'); return;
}
@@ -344,6 +368,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
}
const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera;
flashlight?.update(flashlightOn && !overview);
const precipitating = precipitation.update(delta, activeCamera.position, atmosphere.rain, atmosphere.snowfall,
MathUtils.smoothstep(solarState?.altitude ?? -12, -8, 15), canvas.height);
if(profile?.active()) rendering.invalidate();
@@ -353,10 +378,21 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
compass.update(activeCamera);
const minimapEnd = cpuStart ? performance.now() : 0;
if (profile) renderer.info.reset();
if (bloom) {
const altitude = solarState?.altitude ?? 90;
const torchActive = flashlightOn && !overview;
bloom.enabled = altitude < BUILDINGS.lighting.fadeEndAltitude;
bloom.strength = BUILDINGS.lighting.bloomStrength * (1 - MathUtils.smoothstep(altitude,
BUILDINGS.lighting.fadeStartAltitude, BUILDINGS.lighting.fadeEndAltitude))
* (torchActive ? FLASHLIGHT.bloomStrengthScale : 1);
bloom.threshold = torchActive ? FLASHLIGHT.bloomThreshold : BUILDINGS.lighting.bloomThreshold;
bloom.radius = torchActive ? FLASHLIGHT.bloomRadius : BUILDINGS.lighting.bloomRadius;
}
if (composer && !profile?.disablePost) {
renderPass.camera = activeCamera;
composer.render(delta);
} else renderer.render(scene, activeCamera);
selfBadge?.update(activeCamera, sharedNow !== null && !overview);
if (cpuStart) profile?.end(now, { update: updateEnd - cpuStart, minimap: minimapEnd - updateEnd,
submit: performance.now() - minimapEnd });
if (profile?.observe(now, true)) resize();
@@ -367,7 +403,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
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 } };
equipment: { umbrella: rainGear.umbrella, raincoat: rainGear.raincoat, hood: rainGear.hood, flashlight: flashlightOn } };
},
setConnection(blocked: boolean, clock: (() => number) | null) {
networkBlocked = blocked; sharedNow = clock;
@@ -379,7 +415,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
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);
flashlightOn = Boolean(state.equipment.flashlight && solarState && solarState.altitude <= FLASHLIGHT.nightAltitude);
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
placeObserver(); refreshEmotes(); rendering.invalidate();
},
synchronizePlayers(players: PlayerState[], selfId: string) {
@@ -389,7 +426,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
setWeather(weather: WeatherSample | null) {
weatherTarget = weatherAppearance(weather);
rainGear.updateWeather(weatherTarget.rain, weatherTarget.snowfall);
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood);
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
refreshGear();
// Do not blend an old precipitation type into the newly selected weather.
if (weatherTarget.rain === 0) atmosphere.rain = 0;
@@ -429,6 +466,10 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
},
update(state: SunState) {
solarState = state;
if (flashlightOn && state.altitude > FLASHLIGHT.nightAltitude) {
flashlightOn = false;
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, false);
}
emotes.update(0, false, prayerAvailable());
refreshEmotes();
// LIVE calls this every second; never skip a new solar/time state.
@@ -437,11 +478,6 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
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));
@@ -453,6 +489,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
renderer.setAnimationLoop(null);
minimap.dispose();
compass.dispose();
selfBadge?.dispose();
flashlight?.dispose();
bloom?.dispose();
outputPass?.dispose();
renderPass.dispose();
+28
View File
@@ -16,6 +16,7 @@ button[aria-pressed=true], button.primary { background: #e3ba86; color: #28251f;
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; }
.self-badge { position: fixed; transform: translate(-50%, -100%); padding: 3px 9px; border: 1px solid #ffffff80; border-radius: 10px; background: #172731e8; color: #fff; font-size: 12px; font-weight: 700; line-height: 18px; box-shadow: 0 2px 6px #0004; pointer-events: none; user-select: 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; }
@@ -117,6 +118,7 @@ input[type=range] { display: block; width: 100%; min-height: 36px; margin: 8px 0
#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; }
#time-toggle { display: none; }
.full-width { width: 100%; }
.angles { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.angles > div { display: flex; flex-direction: column; gap: 8px; }
@@ -162,3 +164,29 @@ summary { padding: 12px 0; cursor: pointer; font-size: 14px; }
#minimap-overlay { width: 132px; }
.emote-controls { bottom: max(12px, env(safe-area-inset-bottom)); }
}
/* Compact phone HUD: keep the view clear and expand time editing on demand. */
@media (max-width: 600px) {
.scene-toolbar { top: max(8px, env(safe-area-inset-top)); left: max(10px, env(safe-area-inset-left)); right: max(10px, env(safe-area-inset-right)); }
.scene-toolbar button { width: 44px; height: 44px; min-height: 44px; padding: 10px; border-radius: 12px; }
#current-location { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
#compass { top: calc(max(8px, env(safe-area-inset-top)) + 3px); width: calc(100vw - 140px); height: 52px; }
.compass-scale { height: 34px; }
.compass-tick { padding-top: 15px; font-size: 10px; }
.compass-tick.cardinal { font-size: 12px; }
.compass-tick:not(.cardinal) { font-size: 0; }
.compass-bearing { font-size: 10px; }
.emote-controls { right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); gap: 6px; }
.emote-controls button { min-width: 44px; min-height: 48px; padding: 5px 6px; gap: 3px; border-radius: 11px; font-size: 10px; }
.emote-controls .icon { width: 18px; height: 18px; }
#solo-time-controls { left: max(10px, env(safe-area-inset-left)); right: auto; bottom: max(10px, env(safe-area-inset-bottom)); width: auto; }
#solo-time-controls #time-toggle { display: inline-flex; min-height: 48px; padding: 8px 10px; gap: 6px; font-size: 15px; font-variant-numeric: tabular-nums; }
.time-toggle-label { font-size: 10px; color: #adbdca; }
#solo-time-controls:not(.shared-time) #time-settings { display: none; position: absolute; bottom: 60px; left: 0; width: min(360px, calc(100vw - 20px - env(safe-area-inset-left) - env(safe-area-inset-right))); max-height: calc(100dvh - 140px); overflow-y: auto; padding: 16px; border: 1px solid #ffffff26; border-radius: 16px; background: #18232ef5; pointer-events: auto; }
#solo-time-controls:not(.shared-time):has(#time-toggle[aria-expanded=true]) #time-settings { display: block; }
#solo-time-controls.shared-time #time-toggle { display: none; }
#solo-time-controls.shared-time .time-panel { gap: 6px; min-height: 48px; align-items: center; }
#solo-time-controls.shared-time output { font-size: 13px; }
#solo-time-controls.shared-time #live-button { min-width: 48px; min-height: 44px; padding: 6px; gap: 4px; font-size: 10px; letter-spacing: 0; }
#solo-time-controls.shared-time .live-dot { width: 5px; height: 5px; }
}
+30
View File
@@ -0,0 +1,30 @@
import { Box3, Vector3, type Camera, type Object3D } from 'three';
import { CHARACTER } from '../constants/character.ts';
/** A screen-sized label attached only to the local player's head. */
export function createSelfBadge(player: Object3D, canvas: HTMLCanvasElement) {
const head = player.getObjectByName('Head')!;
const point = new Vector3();
player.updateWorldMatrix(true, true);
const headTop = new Box3().setFromObject(head).max.y - head.getWorldPosition(point).y;
const badge = document.createElement('span');
badge.className = 'self-badge';
badge.textContent = '나';
badge.setAttribute('aria-label', '내 캐릭터');
badge.hidden = true;
document.querySelector('.hud')!.append(badge);
return {
update(camera: Camera, visible: boolean) {
if (!visible) { badge.hidden = true; return; }
head.getWorldPosition(point);
point.y += headTop + CHARACTER.selfBadgeGap;
point.project(camera);
badge.hidden = point.z < -1 || point.z > 1 || Math.abs(point.x) > 1 || Math.abs(point.y) > 1;
if (badge.hidden) return;
const rect = canvas.getBoundingClientRect();
badge.style.left = `${rect.left + (point.x + 1) * rect.width / 2}px`;
badge.style.top = `${rect.top + (1 - point.y) * rect.height / 2}px`;
},
dispose() { badge.remove(); },
};
}
+27
View File
@@ -11,6 +11,8 @@ export class TimeController {
private slider = document.querySelector<HTMLInputElement>('#time-slider')!;
private output = document.querySelector<HTMLOutputElement>('#time-output')!;
private liveButton = document.querySelector<HTMLButtonElement>('#live-button')!;
private timeToggle = document.querySelector<HTMLButtonElement>('#time-toggle')!;
private compactTime = document.querySelector<HTMLElement>('#compact-time')!;
private onChange: (date: Date, mode: TimeMode) => void;
private sharedClock: (() => Date) | null = null;
@@ -27,6 +29,9 @@ export class TimeController {
this.dateInput.addEventListener('change', this.custom);
this.slider.addEventListener('input', this.custom);
this.liveButton.addEventListener('click', this.live);
this.timeToggle.addEventListener('click', this.toggleSettings);
document.addEventListener('pointerdown', this.dismissSettings);
document.addEventListener('keydown', this.closeSettings);
document.addEventListener('visibilitychange', this.sync);
window.addEventListener('focus', this.sync);
window.addEventListener('keydown', this.adjustTime);
@@ -34,6 +39,23 @@ export class TimeController {
this.sync();
}
private toggleSettings = () => {
this.timeToggle.setAttribute('aria-expanded', String(this.timeToggle.getAttribute('aria-expanded') !== 'true'));
};
private dismissSettings = (event: PointerEvent) => {
if (event.target instanceof Element && !event.target.closest('#solo-time-controls')) {
this.timeToggle.setAttribute('aria-expanded', 'false');
}
};
private closeSettings = (event: KeyboardEvent) => {
if (event.key === 'Escape' && this.timeToggle.getAttribute('aria-expanded') === 'true') {
this.timeToggle.setAttribute('aria-expanded', 'false');
this.timeToggle.focus();
}
};
private adjustTime = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey
|| this.sharedClock || this.slider.matches(':disabled')
@@ -74,6 +96,8 @@ export class TimeController {
const [year, month, day] = fields.date.split('-');
this.dateDisplay.textContent = `${year}년 ${month}월 ${day}일`;
const clock = kstClock(date);
this.compactTime.textContent = clock.slice(0, 5);
this.timeToggle.setAttribute('aria-label', `관측 시간 ${clock} KST · 설정`);
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;
@@ -87,6 +111,9 @@ export class TimeController {
this.dateInput.removeEventListener('change', this.custom);
this.slider.removeEventListener('input', this.custom);
this.liveButton.removeEventListener('click', this.live);
this.timeToggle.removeEventListener('click', this.toggleSettings);
document.removeEventListener('pointerdown', this.dismissSettings);
document.removeEventListener('keydown', this.closeSettings);
document.removeEventListener('visibilitychange', this.sync);
window.removeEventListener('focus', this.sync);
window.removeEventListener('keydown', this.adjustTime);
+2 -2
View File
@@ -24,10 +24,10 @@ test('remote snapshot reconciles membership, equipment and interpolated motion w
pose: () => false, dispose() { this.group.removeFromParent(); disposed++; } } as unknown as Sheep; } } as Sheep;
const remote = createRemotePlayers(template, scene, () => 3);
const player: PlayerState = { id: 'other', nickname: '방문자', x: 0, z: 0, yaw: 0, moving: false,
emote: 'idle', equipment: { umbrella: true, raincoat: true, hood: false }, updatedAt: 1000, emoteStartedAt: 1000 };
emote: 'idle', equipment: { umbrella: true, raincoat: true, hood: false, flashlight: true }, updatedAt: 1000, emoteStartedAt: 1000 };
remote.synchronize([player, { ...player, id: 'self' }], 'self');
assert.equal(scene.children.length, 1);
assert.deepEqual(equipment.at(-1), [true, true, false]);
assert.deepEqual(equipment.at(-1), [true, true, false, true]);
remote.synchronize([{ ...player, x: 1, updatedAt: 1100 }], 'self');
remote.update(.075, 1100);
assert.ok(scene.children[0].position.x > 0 && scene.children[0].position.x < 1);
+1 -1
View File
@@ -125,7 +125,7 @@ test('validated gear reaches late joiner; invalid movement corrects; reset prese
const f = await fixture();
try {
const a = await f.connect(); const initial = player(await join(a));
const gear = { umbrella: true, raincoat: true, hood: true };
const gear = { umbrella: true, raincoat: true, hood: true, flashlight: true };
a.emit('player:update', { ...initial, equipment: gear, id: 'spoof' });
await until(() => f.app.room.snapshot().players[0]?.equipment.hood === true);
const b = await f.connect(); const second = await join(b); assert.ok(second.ok);
+28
View File
@@ -5,6 +5,34 @@ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { createRainGearState } from '../src/controls/rainGear.ts';
import { createEmoteState } from '../src/controls/emotes.ts';
import { createSheep } from '../src/scene/sheep.ts';
import { createFlashlight } from '../src/scene/flashlight.ts';
import { FLASHLIGHT } from '../src/constants/flashlight.ts';
import { Vector3 } from 'three';
test('authored flashlight stays in the left grip and uses lumen power only when switched on', async () => {
const [model, umbrella, raincoat, torch] = await Promise.all(['red-sheep', 'umbrella', 'raincoat', 'flashlight'].map(asset));
const sheep = createSheep(model, { umbrella, raincoat, flashlight: torch });
const beam = createFlashlight(sheep.group);
assert.equal(torch.parent, model.getObjectByName('ArmLeft'));
assert.equal(torch.visible, false);
assert.equal(beam.light.visible, false);
assert.equal(beam.light.intensity, FLASHLIGHT.lumens / (2 * Math.PI * (1 - Math.cos(FLASHLIGHT.halfAngle))));
sheep.setEquipment(true, false, false, true);
sheep.pose('idle', 0, true, 1);
beam.update(true);
assert.equal(torch.visible, true);
const direction = new Vector3().subVectors(beam.light.target.position, beam.light.position).normalize();
assert.ok(direction.z > .9 && direction.y < 0, 'beam points forward and slightly down');
const before = beam.light.position.clone();
sheep.group.position.x += 3;
beam.update(true);
assert.ok(Math.abs(beam.light.position.x-before.x-3) < .001);
sheep.setEquipment(false, false, false, false);
beam.update(false);
assert.equal(torch.visible, false);
assert.equal(beam.light.visible, false);
beam.dispose(); sheep.dispose();
});
test('rain equipment is independent of emotes, remains in snow, and clears in dry weather', () => {
const gear = createRainGearState();
+3 -3
View File
@@ -8,10 +8,10 @@ import { loadServerConfig } from '../server/config.ts';
test('server and Mongo verifier can load the documented JSON configuration', () => {
const example = new URL('../.config.example.json', import.meta.url);
const config = loadServerConfig(example);
assert.equal(config.port, 3000);
assert.equal(config.port, 2027);
assert.equal(config.visitStore, 'mongo');
assert.equal(config.mongoDatabase, 'sunrise');
assert.ok(config.allowedOrigins.includes('http://127.0.0.1:3000'));
assert.ok(config.allowedOrigins.includes('http://127.0.0.1:2027'));
});
test('missing or invalid configuration fails without exposing its contents', () => {
@@ -22,7 +22,7 @@ test('missing or invalid configuration fails without exposing its contents', ()
writeFileSync(path, '{"mongoUri":"secret",');
assert.throws(() => loadServerConfig(path), /^Error: Invalid JSON in .config.json$/);
const example = JSON.parse(readFileSync(new URL('../.config.example.json', import.meta.url), 'utf8'));
for (const override of [{ port: '3000' }, { port: 65536 }, { host: '' },
for (const override of [{ port: '2027' }, { port: 65536 }, { host: '' },
{ allowedOrigins: 'https://example.com' }, { visitStore: 'invalid' }, { mongoUri: null }]) {
writeFileSync(path, JSON.stringify({ ...example, ...override }));
assert.throws(() => loadServerConfig(path), /Invalid .config.json field/);
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Vector3 } from 'three';
import { createSummitMarker } from '../src/scene/summitMarker.ts';
import { summitPosition } from '../src/controls/summitCoordinates.ts';
import { SUMMIT_TERRAIN } from '../src/constants/summitTerrain.ts';
import { SUMMIT_PLATEAU } from '../src/constants/summitPlateau.ts';
test('marker placement matches the map pin plus the estimated satellite offset', () => {
const spec = JSON.parse(readFileSync(new URL('../scripts/constants/marker-location.json', import.meta.url), 'utf8'));
const pin = summitPosition(spec.pin.latitude, spec.pin.longitude, SUMMIT_TERRAIN.start);
const expected = [pin.x + spec.offsetFromPinMeters[0], pin.z + spec.offsetFromPinMeters[1]];
SUMMIT_PLATEAU.marker.offset.forEach((value, axis) => assert.ok(Math.abs(value-expected[axis]) < .001));
});
test('exported marker embeds the current baked textures, not stale packed images', () => {
const glb = readFileSync(new URL('../public/models/summit/summit-marker.glb', import.meta.url));
const jsonLength = glb.readUInt32LE(12);
const document = JSON.parse(glb.subarray(20, 20 + jsonLength).toString());
const binaryStart = 28 + jsonLength;
assert.equal(document.images.length, 4);
for (const image of document.images) {
const view = document.bufferViews[image.bufferView];
const start = binaryStart + (view.byteOffset ?? 0);
const embedded = glb.subarray(start, start + view.byteLength);
const source = readFileSync(new URL(`../public/terrain/materials/${image.name}.png`, import.meta.url));
assert.ok(embedded.equals(source), `${image.name} must match the latest bake`);
}
});
test('marker seats its foot on sloping bedrock without deforming the stone', () => {
const asset = new Group();
const mesh = new Mesh(new BoxGeometry(4, 4, 2), new MeshStandardMaterial());
mesh.position.y = 2;
asset.add(mesh);
const groundAt = (x: number, z: number) => 12 + .3*x - .2*z;
const rock = new MeshStandardMaterial();
const marker = createSummitMarker(asset, {x:0,z:0}, groundAt, rock);
marker.group.updateMatrixWorld(true);
const position = mesh.geometry.getAttribute('position');
const point = new Vector3();
let nearestContact = Infinity;
for (let i=0; i<position.count; i++) {
point.fromBufferAttribute(position,i).applyMatrix4(mesh.matrixWorld);
if (position.getY(i)<0) {
const burial = groundAt(point.x,point.z)-point.y;
assert.ok(burial > .05, `base must stay below terrain: ${burial}`);
assert.equal(position.getY(i),-2,'base geometry remains unchanged');
nearestContact = Math.min(nearestContact,burial);
} else {
assert.equal(position.getY(i),2,'upper geometry remains unchanged');
}
}
assert.ok(nearestContact < .3,'the stone is seated close to its lowest contact');
marker.dispose();
rock.dispose();
});
+25
View File
@@ -5,6 +5,7 @@ import { Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three';
import { createTerrainGeometry, terrainGeometryHeightAt } from '../src/scene/terrainGeometry.ts';
import { applySummitTerrain } from '../src/scene/summitTerrain.ts';
import { SUMMIT_TERRAIN } from '../src/constants/summitTerrain.ts';
import { SUMMIT_PLATEAU } from '../src/constants/summitPlateau.ts';
import { structurePosition } from '../src/scene/structures.ts';
import { STRUCTURES } from '../src/constants/structures.ts';
@@ -13,6 +14,30 @@ const bytes = readFileSync(new URL('../public/terrain/gwanaksan.f32', import.met
const heights = new Float32Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
const start = structurePosition(SUMMIT_TERRAIN.start.latitude, SUMMIT_TERRAIN.start.longitude, meta.origin);
test('authored terrain edges join the surrounding slope without vertical steps', () => {
const geometry = createTerrainGeometry(meta, heights, true);
applySummitTerrain(geometry, meta);
const [left, top, right, bottom] = SUMMIT_PLATEAU.atlasBounds;
const height = (x: number, z: number) => terrainGeometryHeightAt(geometry, start.x+x, start.z+z);
try {
// Scan across all four seams, including the last authored half-metre cell.
for (const [edge, from, to, horizontal] of [
[left, top, bottom, true], [right, top, bottom, true],
[top, left, right, false], [bottom, left, right, false],
] as const) {
for (let along = from; along <= to; along += .5) {
for (let offset = -1; offset < 1; offset += .25) {
const a = horizontal ? height(edge+offset, along) : height(along, edge+offset);
const b = horizontal ? height(edge+offset+.25, along) : height(along, edge+offset+.25);
assert.ok(Math.abs(b-a) < .5, `terrain seam at ${edge}, ${along}: ${Math.abs(b-a)}m step over 0.25m`);
}
}
}
} finally {
geometry.dispose();
}
});
test('local reconstruction preserves source data and every vertex outside 200m', () => {
const geometry = createTerrainGeometry(meta, heights, true);
const p = geometry.getAttribute('position');
+7 -4
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { createSummitWalking } from '../src/controls/summitWalking.ts';
import { structurePosition } from '../src/scene/structures.ts';
import { SUMMIT_TERRAIN } from '../src/constants/summitTerrain.ts';
import { SUMMIT_PLATEAU } from '../src/constants/summitPlateau.ts';
const origin = { latitude: 37.445, longitude: 126.964 };
const walking = createSummitWalking(origin);
@@ -28,8 +29,8 @@ test('walking cannot cross forest, radar facility or the 200m boundary', () => {
test('summit slab allows lateral movement beside the peak and connects to the lower forecourt', () => {
// OSM peak is north of the estimated camera; photos show a broad slab, not just a trail.
const peak = structurePosition(37.4451398,126.9642379,origin);
const eastSlab = { x: peak.x+7, z: peak.z+4 };
const westSlab = { x: peak.x-12, z: peak.z+4 };
const eastSlab = { x: peak.x+7, z: peak.z+10 };
const westSlab = { x: peak.x-12, z: peak.z+10 };
assert.ok(walking.canTraverse(westSlab,eastSlab),'cross the upper slab beside the summit marker');
assert.ok(walking.canTraverse(start,eastSlab),'lower forecourt joins the summit slab');
const mast = structurePosition(37.4450675,126.9638043,origin);
@@ -41,6 +42,8 @@ test('visible summit slab stops at its walking edge and the marker footprint is
const outside={x:start.x-32,z:start.z-34};
assert.equal(walking.onPlatform(outside.x,outside.z),false);
assert.ok(walking.rockDistance(outside.x,outside.z)>0,'no circular rock patch beyond the slab boundary');
assert.equal(walking.walkable(start.x-12,start.z-22),false,'cannot walk through the summit stone');
assert.ok(walking.walkable(start.x-15,start.z-22),'can walk beside the stone');
const [x,z] = SUMMIT_PLATEAU.marker.offset;
assert.equal(walking.walkable(start.x+x,start.z+z),false,'cannot walk through the relocated summit stone');
assert.ok(walking.walkable(start.x+x-3,start.z+z),'can walk beside the stone');
assert.ok(walking.walkable(start.x-12,start.z-22),'the former marker position is no longer blocked');
});
+7 -1
View File
@@ -11,12 +11,13 @@ class Field extends EventTarget {
attributes = new Map<string, string>();
textContent = '';
setAttribute(name: string, value: string) { this.attributes.set(name, value); }
getAttribute(name: string) { return this.attributes.get(name) ?? null; }
matches() { return this.disabled; }
closest() { return this; }
}
test('live ticks preserve date edits; custom freezes; live and visibility resync wall clock', (t) => {
const fields = new Map(['#date', '#date-display', '#time-slider', '#time-output', '#live-button'].map(id => [id, new Field()]));
const fields = new Map(['#date', '#date-display', '#time-slider', '#time-output', '#live-button', '#time-toggle', '#compact-time'].map(id => [id, new Field()]));
let dialogOpen = false;
const doc = Object.assign(new EventTarget(), {
activeElement: null as Field | null,
@@ -31,6 +32,11 @@ test('live ticks preserve date edits; custom freezes; live and visibility resync
const controller = new TimeController((date, mode) => calls.push({ date, mode }));
assert.equal(fields.get('#live-button')!.attributes.get('aria-pressed'), 'true');
assert.equal(fields.get('#date-display')!.textContent, '2026년 10월 29일');
assert.equal(fields.get('#compact-time')!.textContent, '09:00');
fields.get('#time-toggle')!.dispatchEvent(new Event('click'));
assert.equal(fields.get('#time-toggle')!.getAttribute('aria-expanded'), 'true');
fields.get('#time-toggle')!.dispatchEvent(new Event('click'));
assert.equal(fields.get('#time-toggle')!.getAttribute('aria-expanded'), 'false');
const arrow = (key: string) => {
const event = Object.assign(new Event('keydown', { cancelable: true }), { key });
window.dispatchEvent(event);
+1 -1
View File
@@ -6,7 +6,7 @@ import { buildingCompressionMiddleware } from './scripts/building-compression.ts
const directoryUrl = (path: string) => pathToFileURL(resolve(path) + '/');
export default defineConfig({
server: { proxy: { '/socket.io': { target: 'http://127.0.0.1:3000', ws: true }, '/health': 'http://127.0.0.1:3000', '/api': 'http://127.0.0.1:3000' } },
server: { proxy: { '/socket.io': { target: 'http://127.0.0.1:2027', ws: true }, '/health': 'http://127.0.0.1:2027', '/api': 'http://127.0.0.1:2027' } },
plugins: [{
name: 'precompressed-buildings',
configureServer(server) {