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
+155
View File
@@ -0,0 +1,155 @@
import { BoxGeometry, BufferGeometry, Float32BufferAttribute, Group, MathUtils, Mesh, MeshStandardMaterial, Vector3 } from 'three';
import type { Material } from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { STRUCTURES, STRUCTURE_ASSETS } from '../constants/structures.ts';
import { SUMMIT_RELIEF } from '../constants/summitRelief.ts';
import { SUMMIT_PLATEAU } from '../constants/summitPlateau.ts';
import { summitPosition } from '../controls/summitCoordinates.ts';
export type StructureAssets = Map<string, Group>;
type SupportAnchor = { position: [number, number, number]; postWidth: number };
export async function loadStructures(includeRelief = false): Promise<StructureAssets> {
const loader = new GLTFLoader();
const ids = [...STRUCTURES.towers.map(s => s.id), 'weather-radar', ...STRUCTURES.decks.map(s => s.id),
...(includeRelief ? [SUMMIT_RELIEF.assetId, SUMMIT_PLATEAU.marker.assetId] : [])];
return new Map(await Promise.all(ids.map(async id => {
const model = await loader.loadAsync(STRUCTURE_ASSETS.baseUrl + '/' + id + '.glb');
return [id, model.scene] as const;
})));
}
/** Local WGS84 tangent approximation, sufficient for the < 1 km structure area. +z south. */
export function structurePosition(latitude: number, longitude: number, origin: { latitude: number; longitude: number }) {
const { x, z } = summitPosition(latitude, longitude, origin);
return new Vector3(x, 0, z);
}
export function createStructures(origin: { latitude: number; longitude: number }, groundAt: (x: number, z: number) => number,
assets: StructureAssets) {
const group = new Group();
group.name = 'Refined summit assets (estimated dimensions)';
const geometries = new Set<BufferGeometry>();
const materials = new Set<Material>();
const wall = new MeshStandardMaterial({ color: STRUCTURE_ASSETS.foundationColor, roughness: 1 });
const rail = new MeshStandardMaterial({ color: STRUCTURE_ASSETS.supportColor, roughness: .86 });
materials.add(wall); materials.add(rail);
function box(parent: Group, width: number, height: number, depth: number, x: number, y: number, z: number, material: Material) {
const geometry = new BoxGeometry(width, height, depth);
geometries.add(geometry);
const result = new Mesh(geometry, material);
result.position.set(x, y, z);
parent.add(result);
}
function site(id: string, latitude: number, longitude: number) {
const parent = new Group();
parent.name = id;
parent.position.copy(structurePosition(latitude, longitude, origin));
parent.position.y = groundAt(parent.position.x, parent.position.z);
const model = assets.get(id)!;
model.traverse(object => {
if (object instanceof Mesh) {
geometries.add(object.geometry);
(Array.isArray(object.material) ? object.material : [object.material]).forEach(m => materials.add(m));
if (object.userData.materialBatch === 'groove') {
const material = object.material as Material;
material.polygonOffset = true;
material.polygonOffsetFactor = -1;
material.polygonOffsetUnits = -1;
}
}
});
parent.add(model);
group.add(parent);
return parent;
}
// Solid foundations span the local terrain relief instead of floating on a single sample.
function foundation(parent: Group, width: number, depth: number) {
const samples = [-width / 2, width / 2].flatMap(x => [-depth / 2, depth / 2].map(z => groundAt(parent.position.x + x, parent.position.z + z)));
const top = Math.max(parent.position.y, ...samples) + .2;
const bottom = Math.min(parent.position.y, ...samples) - .4;
parent.position.y = top;
box(parent, width, top - bottom, depth, 0, -(top - bottom) / 2, 0, wall);
}
for (const spec of STRUCTURES.towers) {
const tower = site(spec.id, spec.latitude, spec.longitude);
tower.userData = { source: spec.source, dimensionsEstimated: true, positionEstimated: spec.estimatedPosition };
foundation(tower, spec.width + 1, spec.width + 1);
}
const radarSpec = STRUCTURES.radar;
const radar = site('weather-radar', radarSpec.latitude, radarSpec.longitude);
radar.userData = { source: radarSpec.source, dimensionsEstimated: true, positionEstimated: true };
foundation(radar, 8, 8);
for (const spec of STRUCTURES.decks) {
const deck = site(spec.id, spec.latitude, spec.longitude);
deck.rotation.y = MathUtils.degToRad(spec.heading);
deck.userData = { source: spec.source, dimensionsEstimated: true, positionEstimated: true };
const modelRoot = assets.get(spec.id)!.getObjectByName(spec.id)!;
const anchors = JSON.parse(modelRoot.userData.terrainSupportAnchorsJson) as SupportAnchor[];
const localGround = (x: number, z: number) => {
const v = new Vector3(x, 0, z).applyAxisAngle(new Vector3(0, 1, 0), deck.rotation.y).add(deck.position);
return groundAt(v.x, v.z);
};
const bottomZ = -spec.length / 2 - spec.stairLength;
const samples = [-spec.width / 2, spec.width / 2].flatMap(x => [-spec.length / 2, 0, spec.length / 2].map(z => localGround(x, z)));
samples.push(localGround(-spec.width / 2, bottomZ) + spec.stairDrop, localGround(spec.width / 2, bottomZ) + spec.stairDrop);
// Only the walking surface needs clearance; buried post feet must not lift the entire deck.
deck.position.y = Math.max(...samples) + STRUCTURES.deck.clearance;
const supports = new Group();
supports.name = 'terrain-supports';
deck.add(supports);
for (const { position: [x, y, z], postWidth } of anchors) {
const bottom = localGround(x, z) - deck.position.y - .1;
const top = y + .02;
if (top > bottom) box(supports, postWidth, top - bottom, postWidth, x, (top + bottom) / 2, z, rail);
}
let wood: Material = wall;
modelRoot.traverse(object => {
if (object instanceof Mesh && object.userData.materialBatch === 'wood0') wood = object.material as Material;
});
function connectEnd(name: string, startZ: number, topY: number, direction: number) {
const config = STRUCTURE_ASSETS.access;
const access = new Group();
access.name = name;
deck.add(access);
const ground = (x: number, z: number) => localGround(x, z) - deck.position.y;
let count = 1;
let landingY = topY;
for (; count <= config.maxSteps; count++) {
const z = startZ + direction * count * config.treadDepth;
landingY = Math.max(ground(-spec.width / 2, z), ground(0, z), ground(spec.width / 2, z)) + .02;
if (Math.abs(topY - landingY) <= count * config.maxRiser) break;
}
count = Math.min(count, config.maxSteps);
const rise = (topY - landingY) / count;
for (let i = 0; i < count; i++) {
const z = startZ + direction * (i + .5) * config.treadDepth;
const y = topY - (i + 1) * rise;
const bottom = Math.min(...[-spec.width / 2, spec.width / 2].flatMap(x =>
[-.5, .5].map(dz => ground(x, z + dz * config.treadDepth)))) - config.groundOverlap;
// Closed footing beneath each tread reaches the slope instead of ending in mid-air.
box(access, spec.width, Math.max(.02, y - .04 - bottom), config.treadDepth,
0, (y - .04 + bottom) / 2, z, wall);
box(access, spec.width, .04, config.treadDepth + .002, 0, y - .02, z, wood);
}
const z0 = startZ + direction * count * config.treadDepth;
const endZ = z0 + direction * config.landingLength;
// The outer landing edge follows the cross-slope too, not just its highest corner.
const vertices = [-spec.width / 2, spec.width / 2].flatMap(x =>
[x, landingY, z0, x, ground(x, endZ) + .02, endZ]);
const geometry = new BufferGeometry();
geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3));
geometry.setIndex(direction > 0 ? [0, 1, 2, 2, 1, 3] : [0, 2, 1, 2, 3, 1]);
geometry.computeVertexNormals();
geometries.add(geometry);
access.add(new Mesh(geometry, wood));
access.userData = { startZ, endZ, topY, stepCount: count };
}
if (STRUCTURE_ASSETS.access.deckIds.includes(spec.id)) {
connectEnd('entry-access', spec.length / 2, 0, 1);
connectEnd('exit-access', bottomZ, -spec.stairDrop, -1);
}
}
return { group, dispose() { geometries.forEach(g => g.dispose()); materials.forEach(m => m.dispose()); } };
}