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.
This commit is contained in:
2026-10-31 23:14:44 +09:00
parent 18dfb63350
commit eb49ea4b84
40 changed files with 402 additions and 64 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>
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.
Binary file not shown.
+10 -10
View File
@@ -1,13 +1,13 @@
{
"version": 1,
"sourceHash": "504596fa53badac50aad45e4f4aff3431257f26a39a8c5438b044c7e4ffbdaca",
"bufferHash": "5916b60f37298fbb046645b0854f233d2926ee817641841d8c38dad071c26eef",
"byteLength": 15601904,
"gzipBytes": 6138350,
"originalVertices": 1180560,
"vertices": 209224,
"triangles": 393520,
"originalAttributeBytes": 61389120,
"generateMs": 3849.3699000000006,
"decodeMs": 13.474900000001071
"sourceHash": "6f03c38b2007114b94747b0abbcc26c56b408b0353fc3068b19cc17518542ecc",
"bufferHash": "8fa1ab6646cacc15fc1e84ace034a71826a8dc50f9d5dc2c15aab39e290166d6",
"byteLength": 16099324,
"gzipBytes": 6369382,
"originalVertices": 1219830,
"vertices": 215769,
"triangles": 406610,
"originalAttributeBytes": 63431160,
"generateMs": 4164.3897,
"decodeMs": 16.178200000000288
}
+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]
}
+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')
+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'))
+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;
+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() {
+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/);
+10
View File
@@ -3,6 +3,16 @@ 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));
+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) {