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