Implement tiled multiplayer city simulation and scaling stages
This commit is contained in:
1 parent
3d4b29fedf
commit
3b1fcd996a
53 files changed
+7354
-13
No files matched your search
@@ -12,6 +12,7 @@ import { Rng, hashString } from '../util/rng';
|
||||
import { SpatialGrid } from '../util/spatial';
|
||||
import { classify, clampLevels, defaultLevels, parseLevels } from './classify';
|
||||
import { carShapes, CAR_COLORS, treeProp } from './props';
|
||||
import { populateStreets } from './streets';
|
||||
|
||||
export interface InteriorJob {
|
||||
building: Building;
|
||||
@@ -379,6 +380,7 @@ export function buildCity(elements: OsmElement[], bbox: BBox, name: string, seed
|
||||
models: {},
|
||||
attribution: '© OpenStreetMap contributors (ODbL)',
|
||||
};
|
||||
populateStreets(world, elements);
|
||||
return { world, jobs };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Финальная упаковка мира: общие модели предметов и округление координат до сантиметра.
|
||||
|
||||
import type { Building, Prop, Shape, World } from '../types';
|
||||
import { attachInteractions } from './objects';
|
||||
|
||||
const r2 = (v: number) => Math.round(v * 100) / 100;
|
||||
const r3 = (v: number) => Math.round(v * 1000) / 1000;
|
||||
@@ -44,6 +45,7 @@ function roundProp(p: Prop) {
|
||||
p.rot = r3(p.rot);
|
||||
p.w = r2(p.w);
|
||||
p.d = r2(p.d);
|
||||
attachInteractions(p);
|
||||
if (!p.tags?.length) delete p.tags;
|
||||
if (!p.roomId) delete p.roomId;
|
||||
if (!p.color) delete p.color;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { World } from '../types';
|
||||
import { buildCity, type InteriorJob } from './city';
|
||||
import { generateInterior, type InteriorResult } from './interior';
|
||||
import { finalizeWorld } from './finalize';
|
||||
import { addStreetEntrances } from './streets';
|
||||
|
||||
export type Progress = (stage: string, done: number, total: number) => void;
|
||||
|
||||
@@ -29,6 +30,7 @@ export function generateWorldSync(elements: OsmElement[], bbox: BBox, name: stri
|
||||
console.warn('interior failed', job.building.id, e);
|
||||
}
|
||||
});
|
||||
addStreetEntrances(world);
|
||||
finalizeWorld(world);
|
||||
return world;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { InteractionSlot, Prop, Shape, Vec } from '../types';
|
||||
|
||||
interface ObjectType {
|
||||
w: number; d: number; blocksMove: boolean; shapes: Shape[]; tags: string[];
|
||||
}
|
||||
const rect = (w: number, h: number, s: Shape['s']): Shape => ({ t: 'rect', x: 0, y: 0, w, h, s });
|
||||
/** Shared registry of outdoor types; indoor types use the same tagged slot rules. */
|
||||
export const OBJECT_TYPES: Record<string, ObjectType> = {
|
||||
bench: { w: 1.8, d: 0.6, blocksMove: true, shapes: [rect(1.8, 0.6, 'wood')], tags: ['sit:3', 'rest'] },
|
||||
lamp: { w: 0.4, d: 0.4, blocksMove: true, shapes: [{ t: 'circle', x: 0, y: 0, r: 0.2, s: 'metal' }], tags: [] },
|
||||
bin: { w: 0.5, d: 0.5, blocksMove: true, shapes: [rect(0.5, 0.5, 'metal')], tags: ['dispose'] },
|
||||
bus_stop: { w: 3, d: 1.2, blocksMove: true, shapes: [rect(3, 1.2, 'glass'), rect(2.6, 0.3, 'wood')], tags: ['wait:6'] },
|
||||
parking_spot: { w: 2.5, d: 5, blocksMove: false, shapes: [rect(2.5, 5, 'line')], tags: ['park'] },
|
||||
courtyard: { w: 3, d: 3, blocksMove: false, shapes: [rect(3, 3, 'green')], tags: ['rest:4'] },
|
||||
crossing: { w: 2.5, d: 6, blocksMove: false, shapes: [rect(2.5, 6, 'line')], tags: ['cross'] },
|
||||
entrance: { w: 1, d: 0.3, blocksMove: false, shapes: [rect(1, 0.3, 'wood')], tags: ['enter'] },
|
||||
market_stall: { w: 2, d: 1.2, blocksMove: true, shapes: [rect(2, 1.2, 'fabric'), rect(1.8, 0.6, 'wood')], tags: ['trade:2', 'work'] },
|
||||
};
|
||||
|
||||
export function streetObject(kind: string, id: string, pos: Vec, rot = 0): Prop {
|
||||
const def = OBJECT_TYPES[kind];
|
||||
if (!def) throw new Error(`Unknown street object: ${kind}`);
|
||||
const p: Prop = { id, kind, pos, rot, w: def.w, d: def.d, shapes: structuredClone(def.shapes), tags: [...def.tags], blocksMove: def.blocksMove, blocksSight: false };
|
||||
attachInteractions(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Tags already describe variable-size furniture, e.g. sleep:2 or sit:6.
|
||||
* All points are world coordinates, just outside blocking geometry. */
|
||||
export function attachInteractions(p: Prop) {
|
||||
const tags = p.tags ?? OBJECT_TYPES[p.kind]?.tags ?? [];
|
||||
const actions = new Set<string>();
|
||||
const slots: InteractionSlot[] = [];
|
||||
for (const tag of tags) {
|
||||
const [action, count] = tag.split(':');
|
||||
if (!['sit', 'sleep', 'rest', 'wait', 'park', 'enter', 'cross', 'trade', 'work', 'cook', 'food', 'storage', 'dispose'].includes(action) || actions.has(action)) continue;
|
||||
actions.add(action);
|
||||
const capacity = Math.max(1, Math.min(32, Number(count) || 1));
|
||||
const y = p.blocksMove ? p.d / 2 + 0.45 : 0;
|
||||
const pos: Vec = [p.pos[0] - Math.sin(p.rot) * y, p.pos[1] + Math.cos(p.rot) * y];
|
||||
slots.push({ id: `${p.id}:${action}`, action, pos: pos.map(n => Math.round(n * 100) / 100) as Vec, capacity });
|
||||
}
|
||||
p.interactions = slots;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { OsmElement, BBox } from '../osm/overpass';
|
||||
import type { World } from '../types';
|
||||
import { buildCity, type InteriorJob } from './city';
|
||||
import { finalizeWorld } from './finalize';
|
||||
import { addStreetEntrances } from './streets';
|
||||
|
||||
export type OnProgress = (text: string, frac: number) => void;
|
||||
|
||||
@@ -14,6 +15,8 @@ export async function generateWorld(elements: OsmElement[], bbox: BBox, name: st
|
||||
finalizeWorld(world, false);
|
||||
onProgress(`Планировки: 0 / ${jobs.length}`, 0.1);
|
||||
await runInteriors(world, jobs, (done) => onProgress(`Планировки: ${done} / ${jobs.length} зданий`, 0.1 + 0.88 * (done / Math.max(1, jobs.length))), signal);
|
||||
addStreetEntrances(world);
|
||||
finalizeWorld(world, false);
|
||||
onProgress('Готово', 1);
|
||||
return world;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { OsmElement } from '../osm/overpass';
|
||||
import type { Prop, Vec, World } from '../types';
|
||||
import { bboxOf, centroid, closestOnSegment, distToSegment, pointInRings } from '../util/geom';
|
||||
import { SpatialGrid } from '../util/spatial';
|
||||
import { Projector } from './project';
|
||||
import { streetObject } from './objects';
|
||||
|
||||
export function populateStreets(world: World, elements: OsmElement[]) {
|
||||
const buildings = new SpatialGrid<World['buildings'][number]>(50);
|
||||
for (const b of world.buildings) buildings.insert(b, bboxOf(b.footprint));
|
||||
const water = new SpatialGrid<World['areas'][number]>(100);
|
||||
for (const a of world.areas) if (['water', 'wetland'].includes(a.kind)) water.insert(a, bboxOf(a.rings[0]));
|
||||
const occupied = new SpatialGrid<Prop>(10);
|
||||
const box = (p: Prop): [number, number, number, number] => {
|
||||
const x = (Math.abs(Math.cos(p.rot)) * p.w + Math.abs(Math.sin(p.rot)) * p.d) / 2 + 0.3;
|
||||
const y = (Math.abs(Math.sin(p.rot)) * p.w + Math.abs(Math.cos(p.rot)) * p.d) / 2 + 0.3;
|
||||
return [p.pos[0] - x, p.pos[1] - y, p.pos[0] + x, p.pos[1] + y];
|
||||
};
|
||||
for (const p of world.props) occupied.insert(p, box(p));
|
||||
const roadSegments = new SpatialGrid<{ a: Vec; b: Vec; width: number }>(40);
|
||||
for (const road of world.roads) if (!road.tunnel && !road.bridge) for (let i = 1; i < road.pts.length; i++) {
|
||||
const a = road.pts[i - 1], b = road.pts[i];
|
||||
roadSegments.insert({ a, b, width: road.width }, [Math.min(a[0], b[0]) - road.width, Math.min(a[1], b[1]) - road.width, Math.max(a[0], b[0]) + road.width, Math.max(a[1], b[1]) + road.width]);
|
||||
}
|
||||
const ids = new Set(world.props.map(p => p.id));
|
||||
let budget = 20000;
|
||||
const add = (p: Prop, onRoad = false) => {
|
||||
if (budget <= 0 || ids.has(p.id)) return;
|
||||
const bb = box(p), B = world.bounds;
|
||||
if (bb[0] < B.minX || bb[1] < B.minY || bb[2] > B.maxX || bb[3] > B.maxY) return;
|
||||
const samples: Vec[] = [p.pos, [bb[0], bb[1]], [bb[2], bb[1]], [bb[2], bb[3]], [bb[0], bb[3]]];
|
||||
if (buildings.query(bb).some(b => samples.some(q => pointInRings(q, [b.footprint, ...b.holes]))) ||
|
||||
water.query(bb).some(a => samples.some(q => pointInRings(q, a.rings)))) return;
|
||||
if (!onRoad && roadSegments.query(bb).some(s => samples.some(q => distToSegment(q, s.a, s.b) < s.width / 2 + 0.15))) return;
|
||||
if (occupied.query(bb).some(other => {
|
||||
const b = box(other);
|
||||
return bb[0] < b[2] && bb[2] > b[0] && bb[1] < b[3] && bb[3] > b[1];
|
||||
})) return;
|
||||
world.props.push(p); occupied.insert(p, bb); ids.add(p.id); budget--;
|
||||
};
|
||||
|
||||
const projector = new Projector(world.origin.lat, world.origin.lon);
|
||||
for (const element of elements) {
|
||||
if (element.type !== 'node' || element.lat == null || element.lon == null) continue;
|
||||
const t = element.tags ?? {}, pos = projector.toXY(element.lat, element.lon);
|
||||
const kind = t.highway === 'bus_stop' || t.public_transport === 'platform' ? 'bus_stop' :
|
||||
t.highway === 'crossing' ? 'crossing' : t.amenity === 'waste_basket' ? 'bin' : t.amenity === 'marketplace' ? 'market_stall' : null;
|
||||
if (kind) {
|
||||
const p = streetObject(kind, `osm-${kind}-${element.id}`, pos);
|
||||
if (kind === 'crossing') {
|
||||
const near = roadSegments.query([pos[0] - 10, pos[1] - 10, pos[0] + 10, pos[1] + 10])
|
||||
.sort((a, b) => distToSegment(pos, a.a, a.b) - distToSegment(pos, b.a, b.b))[0];
|
||||
if (near) {
|
||||
const q = closestOnSegment(pos, near.a, near.b);
|
||||
p.pos = q; p.rot = Math.atan2(near.b[1] - near.a[1], near.b[0] - near.a[0]); p.d = near.width;
|
||||
p.shapes = [{ t: 'rect', x: 0, y: 0, w: p.w, h: p.d, s: 'line' }];
|
||||
}
|
||||
}
|
||||
add(p, kind === 'crossing');
|
||||
}
|
||||
}
|
||||
// Regular furniture along walkable streets; stable IDs and per-road seeds.
|
||||
for (const road of world.roads) {
|
||||
if (road.bridge || road.tunnel || road.layer !== 0 || !['residential', 'secondary', 'tertiary', 'pedestrian', 'living_street', 'footway'].includes(road.kind)) continue;
|
||||
let distance = 15;
|
||||
let serial = 0;
|
||||
for (let i = 1; i < road.pts.length; i++) {
|
||||
const a = road.pts[i - 1], b = road.pts[i], length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
if (!length) continue;
|
||||
const angle = Math.atan2(b[1] - a[1], b[0] - a[0]);
|
||||
for (; distance < length && budget > 0; distance += 35) {
|
||||
const side = serial % 2 ? 1 : -1;
|
||||
const offset = road.width / 2 + road.sidewalk + 1;
|
||||
const p: Vec = [a[0] + (b[0] - a[0]) * distance / length - Math.sin(angle) * offset * side,
|
||||
a[1] + (b[1] - a[1]) * distance / length + Math.cos(angle) * offset * side];
|
||||
const kind = serial % 4 === 0 ? 'bench' : serial % 4 === 2 ? 'bin' : 'lamp';
|
||||
add(streetObject(kind, `street-${road.id}-${serial++}`, p, angle + (side < 0 ? Math.PI : 0)));
|
||||
}
|
||||
distance -= length;
|
||||
}
|
||||
}
|
||||
for (const area of world.areas) {
|
||||
if (!['park', 'garden', 'playground', 'pedestrian'].includes(area.kind)) continue;
|
||||
const pos = centroid(area.rings[0]);
|
||||
if (!pointInRings(pos, area.rings)) continue;
|
||||
add(streetObject('courtyard', `courtyard-${area.id}`, pos));
|
||||
add(streetObject(area.kind === 'pedestrian' ? 'market_stall' : 'bench', `amenity-${area.id}`, [pos[0] + 4, pos[1]]));
|
||||
}
|
||||
}
|
||||
|
||||
/** Entrances are derived from the actual finished floor plans, never guessed. */
|
||||
export function addStreetEntrances(world: World) {
|
||||
const ids = new Set(world.props.map(p => p.id));
|
||||
for (const building of world.buildings) for (const floor of building.floors) {
|
||||
if (floor.level !== 0) continue;
|
||||
for (const door of floor.doors) {
|
||||
if (!door.rooms.includes('outside')) continue;
|
||||
const id = `entrance-${building.id}-${door.id}`;
|
||||
if (ids.has(id)) continue;
|
||||
const pos: Vec = [(door.a[0] + door.b[0]) / 2, (door.a[1] + door.b[1]) / 2];
|
||||
const prop = streetObject('entrance', id, pos, Math.atan2(door.b[1] - door.a[1], door.b[0] - door.a[0]));
|
||||
prop.target = { buildingId: building.id, doorId: door.id, level: floor.level };
|
||||
world.props.push(prop); ids.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user