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:
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user