feat: initialize sunrise with 100-player shared rooms

This commit is contained in:
2026-10-30 16:36:35 +09:00
commit 31d6eac4c2
257 changed files with 66063 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three';
import { observer } from '../src/config/observer.ts';
import { createTerrainGeometry, terrainGeometryHeightAt, terrainHeightAt, validateTerrain } from '../src/scene/terrainGeometry.ts';
import type { TerrainMetadata } from '../src/scene/terrainGeometry.ts';
import { calculateSun } from '../src/astronomy/sunCalculator.ts';
import { directionFromAngles } from '../src/astronomy/direction.ts';
import { SCENE } from '../src/constants/scene.ts';
const metadata: TerrainMetadata = JSON.parse(readFileSync(new URL('../public/terrain/gwanaksan.json', import.meta.url), 'utf8'));
const bytes = readFileSync(new URL('../public/terrain/gwanaksan.f32', import.meta.url));
const heights = new Float32Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
test('near terrain refinement preserves samples and bounds interpolated elevations', () => {
const g = createTerrainGeometry(metadata, heights, true);
const p = g.getAttribute('position');
assert.ok(p.count > heights.length, 'nearby terrain needs intermediate vertices');
let foundOrigin = false;
for (let i = 0; i < p.count; i++) {
const x = p.getX(i), z = p.getZ(i);
if (x === 0 && z === 0) { foundOrigin = true; assert.ok(Math.abs(p.getY(i)) < 1e-5); }
if (Math.abs(x) > 100 || Math.abs(z) > 100) continue;
const col = Math.min(metadata.width - 2, Math.floor((x + metadata.radiusMeters) / metadata.spacingMeters));
const row = Math.min(metadata.height - 2, Math.floor((z + metadata.radiusMeters) / metadata.spacingMeters));
const k = row * metadata.width + col;
const corners = [heights[k], heights[k+1], heights[k+metadata.width], heights[k+metadata.width+1]];
const h = p.getY(i) + metadata.observerSurfaceElevation + (x*x+z*z)/(2*6371008.8);
assert.ok(h >= Math.min(...corners)-0.001 && h <= Math.max(...corners)+0.001, 'no invented peaks or pits');
}
assert.ok(foundOrigin);
const mesh = new Mesh(g, new MeshBasicMaterial());
for (const [x,z] of [[2,3],[-17.5,28.2],[179.5,180.5],[210.4,-302.8]]) {
const ray = new Raycaster(new Vector3(x,1000,z),new Vector3(0,-1,0));
const hit = ray.intersectObject(mesh)[0];
assert.ok(hit);
assert.ok(Math.abs(terrainGeometryHeightAt(g,x,z)-hit.point.y)<1e-4, 'walking follows the actual refined triangle');
}
mesh.material.dispose();
g.dispose();
});
test('downloaded grid covers both longitude tiles, no holes, and observer surface is consistent', () => {
validateTerrain(metadata, heights, observer);
assert.equal(metadata.width, 801);
assert.equal(heights.length, 801 * 801);
assert.equal(metadata.sources.length, 2);
assert.ok(metadata.geographicCorners.some(([lon]) => lon < 127));
assert.ok(metadata.geographicCorners.some(([lon]) => lon > 127));
assert.equal(terrainHeightAt(metadata, heights, 0, 0), metadata.observerSurfaceElevation);
assert.ok(metadata.observerSurfaceElevation > 500 && metadata.observerSurfaceElevation < 650);
assert.throws(() => validateTerrain(metadata, heights.subarray(1), observer));
assert.throws(() => validateTerrain(metadata, heights, { ...observer, longitude: 127 }));
});
test('mesh keeps east +X, north -Z, true scale, and the camera above its center', () => {
const geometry = createTerrainGeometry(metadata, heights);
const positions = geometry.getAttribute('position');
assert.equal(positions.getX(0), -12000);
assert.equal(positions.getZ(0), -12000);
assert.equal(positions.getX(800), 12000);
assert.equal(positions.getZ(801 * 800), 12000);
assert.equal(positions.getY(400 * 801 + 400), 0);
const expectedCorner = heights[0] - metadata.observerSurfaceElevation - (12000 ** 2 * 2) / (2 * 6371008.8);
assert.ok(Math.abs(positions.getY(0) - expectedCorner) < 0.001);
const mesh = new Mesh(geometry, new MeshBasicMaterial());
const hits = new Raycaster(new Vector3(0, observer.eyeHeight, 0), new Vector3(0, -1, 0)).intersectObject(mesh);
assert.ok(hits.length > 0, 'triangles must face upward and be visible to a downward ray');
assert.ok(Math.abs(hits[0].distance - observer.eyeHeight) < 0.001);
geometry.dispose();
});
test('a synthetic ridge blocks a low sun direction and allows a higher one', () => {
const grid = { ...metadata, width: 3, height: 3, spacingMeters: 30, radiusMeters: 30, observerSurfaceElevation: 0 };
const data = new Float32Array([0, 0, 12, 0, 0, 12, 0, 0, 12]);
assert.equal(terrainHeightAt(grid, data, 15, 0), 6);
const geometry = createTerrainGeometry(grid, data);
const mesh = new Mesh(geometry, new MeshBasicMaterial());
const origin = new Vector3(0, 1.7, 0);
assert.ok(new Raycaster(origin, new Vector3(1, 0.1, 0).normalize()).intersectObject(mesh).length > 0);
assert.equal(new Raycaster(origin, new Vector3(1, 1, 0).normalize()).intersectObject(mesh).length, 0);
geometry.dispose();
});
test('real Gwanaksan ridge blocks the March 06:40 sun center but clears it by 06:50', () => {
const geometry = createTerrainGeometry(metadata, heights, true);
const mesh = new Mesh(geometry, new MeshBasicMaterial());
for (const [time, blocked] of [['06:40', true], ['06:50', false]] as const) {
const sun = calculateSun(new Date(`2026-03-20T${time}:00+09:00`), observer);
const direction = new Vector3(...directionFromAngles(sun.azimuth, sun.altitude));
const hits = new Raycaster(new Vector3(0, observer.eyeHeight, 0), direction, 0, SCENE.sunDistance).intersectObject(mesh);
assert.equal(hits.length > 0, blocked, time);
}
assert.ok(SCENE.sunDistance > Math.SQRT2 * metadata.radiusMeters);
geometry.dispose();
});