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