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
+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();