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
@@ -0,0 +1,136 @@
|
||||
import type { Area, Building, Prop, Road, Vec, World } from '../types';
|
||||
|
||||
export const CITY_FORMAT_VERSION = 1;
|
||||
export const GENERATOR_VERSION = '0.2.0';
|
||||
export type Bounds = World['bounds'];
|
||||
export type CityMetadata = Omit<World, 'areas' | 'roads' | 'buildings' | 'props' | 'models'>;
|
||||
export interface TileRef { id: string; bounds: Bounds; url: string; sha256?: string }
|
||||
export interface CityManifest {
|
||||
formatVersion: 1;
|
||||
generatorVersion: string;
|
||||
tileSize: number;
|
||||
city: CityMetadata;
|
||||
tiles: TileRef[];
|
||||
navigation?: { url: 'navigation.json.gz'; sha256: string };
|
||||
areaOrder?: string[];
|
||||
counts: { buildings: number; roads: number; areas: number; props: number; maxLevels: number };
|
||||
}
|
||||
export interface CityTile {
|
||||
formatVersion: 1;
|
||||
cityId: string;
|
||||
id: string;
|
||||
areas: Area[];
|
||||
roads: Road[];
|
||||
buildings: Building[];
|
||||
props: Prop[];
|
||||
models: World['models'];
|
||||
}
|
||||
export function intersects(a: Bounds, b: Bounds): boolean {
|
||||
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
|
||||
}
|
||||
export function validBounds(value: unknown): value is Bounds {
|
||||
const b = value as Bounds;
|
||||
return !!b && [b.minX, b.minY, b.maxX, b.maxY].every(Number.isFinite) && b.minX < b.maxX && b.minY < b.maxY;
|
||||
}
|
||||
export function pointsBounds(points: Vec[], margin = 0): Bounds {
|
||||
if (!points.length || points.some(p => p.length !== 2 || !p.every(Number.isFinite))) throw new Error('Invalid geometry');
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const [x, y] of points) { minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); }
|
||||
return { minX: minX - margin, minY: minY - margin, maxX: maxX + margin, maxY: maxY + margin };
|
||||
}
|
||||
export function parseManifest(value: unknown): CityManifest {
|
||||
const m = value as CityManifest;
|
||||
if (!m || m.formatVersion !== CITY_FORMAT_VERSION) throw new Error('Unsupported city format version');
|
||||
if (!m.city || m.city.version !== 1 || typeof m.city.id !== 'string' || !validBounds(m.city.bounds) ||
|
||||
!Number.isFinite(m.tileSize) || m.tileSize < 1 || !Array.isArray(m.tiles) ||
|
||||
typeof m.generatorVersion !== 'string' || !m.counts || !Number.isInteger(m.counts.maxLevels) || m.counts.maxLevels < 1)
|
||||
throw new Error('Invalid city manifest');
|
||||
const ids = new Set<string>();
|
||||
if (m.navigation && (m.navigation.url !== 'navigation.json.gz' || !/^[a-f0-9]{64}$/.test(m.navigation.sha256))) throw new Error('Invalid navigation reference');
|
||||
if (m.areaOrder && (!Array.isArray(m.areaOrder) || m.areaOrder.some(id=>typeof id!=='string'))) throw new Error('Invalid area order');
|
||||
for (const t of m.tiles) {
|
||||
if (!t || typeof t.id !== 'string' || ids.has(t.id) || !validBounds(t.bounds) ||
|
||||
typeof t.url !== 'string' || !/^tiles\/-?\d+_-?\d+\.json(?:\.gz)?$/.test(t.url) || (t.sha256 !== undefined && !/^[a-f0-9]{64}$/.test(t.sha256))) throw new Error('Invalid tile reference');
|
||||
ids.add(t.id);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
export function parseTile(value: unknown, cityId: string, id: string): CityTile {
|
||||
const t = value as CityTile;
|
||||
if (!t || t.formatVersion !== CITY_FORMAT_VERSION || t.cityId !== cityId || t.id !== id ||
|
||||
!['areas', 'roads', 'buildings', 'props'].every(k => Array.isArray((t as any)[k])) || !t.models || typeof t.models !== 'object')
|
||||
throw new Error(`Invalid city tile: ${id}`);
|
||||
for (const group of [t.areas, t.roads, t.buildings, t.props]) {
|
||||
const ids = new Set<string>();
|
||||
for (const item of group) {
|
||||
if (!item || typeof item.id !== 'string' || ids.has(item.id)) throw new Error(`Invalid entity ID in tile: ${id}`);
|
||||
ids.add(item.id);
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Objects crossing a boundary are repeated whole, then deduplicated by ID on load.
|
||||
* Keeping buildings whole preserves doors, stairs, unit references and interiors. */
|
||||
export function tileWorld(world: World, tileSize = 256): { manifest: CityManifest; tiles: Map<string, CityTile> } {
|
||||
if (world.version !== 1 || !validBounds(world.bounds) || !Number.isFinite(tileSize) || tileSize < 1) throw new Error('Invalid world or tile size');
|
||||
const tiles = new Map<string, CityTile>();
|
||||
const included = { areas: new Set<string>(), roads: new Set<string>(), buildings: new Set<string>(), props: new Set<string>() };
|
||||
function add(key: 'areas' | 'roads' | 'buildings' | 'props', item: Area | Road | Building | Prop, b: Bounds) {
|
||||
// OSM ways and land-use rings often extend kilometres outside the selected
|
||||
// bbox. Keep the whole object, but only index tiles inside the selected city.
|
||||
if (!intersects(b, world.bounds)) return;
|
||||
b = { minX: Math.max(b.minX,world.bounds.minX), minY: Math.max(b.minY,world.bounds.minY), maxX: Math.min(b.maxX,world.bounds.maxX), maxY: Math.min(b.maxY,world.bounds.maxY) };
|
||||
if (included[key].has(item.id)) throw new Error(`Duplicate ${key} ID: ${item.id}`);
|
||||
included[key].add(item.id);
|
||||
const cells = (Math.floor(b.maxX / tileSize) - Math.floor(b.minX / tileSize) + 1) * (Math.floor(b.maxY / tileSize) - Math.floor(b.minY / tileSize) + 1);
|
||||
if (cells > 100_000) throw new Error('Geometry spans too many tiles');
|
||||
for (let y = Math.floor(b.minY / tileSize); y <= Math.floor(b.maxY / tileSize); y++) {
|
||||
for (let x = Math.floor(b.minX / tileSize); x <= Math.floor(b.maxX / tileSize); x++) {
|
||||
const id = `${x}_${y}`;
|
||||
let t = tiles.get(id);
|
||||
if (!t) { t = { formatVersion: 1, cityId: world.id, id, areas: [], roads: [], buildings: [], props: [], models: {} }; tiles.set(id, t); }
|
||||
(t[key] as (Area | Road | Building | Prop)[]).push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const a of world.areas) add('areas', a, pointsBounds(a.rings.flat()));
|
||||
for (const r of world.roads) add('roads', r, pointsBounds(r.pts, r.width / 2 + r.sidewalk));
|
||||
for (const b of world.buildings) add('buildings', b, pointsBounds(b.footprint));
|
||||
for (const p of world.props) add('props', p, pointsBounds([p.pos], Math.hypot(p.w, p.d) / 2));
|
||||
for (const t of tiles.values()) {
|
||||
const props = [...t.props, ...t.buildings.flatMap(b => b.floors.flatMap(f => f.props))];
|
||||
for (const p of props) if (p.model) {
|
||||
if (!Object.hasOwn(world.models, p.model)) throw new Error(`Missing model: ${p.model}`);
|
||||
Object.defineProperty(t.models, p.model, { value: world.models[p.model], enumerable: true, configurable: true });
|
||||
}
|
||||
}
|
||||
const { areas, roads, buildings, props, models, ...city } = world;
|
||||
const manifest: CityManifest = { formatVersion: 1, generatorVersion: GENERATOR_VERSION, tileSize, city,
|
||||
areaOrder:areas.map(a=>a.id),
|
||||
counts: { areas: included.areas.size, roads: included.roads.size, buildings: included.buildings.size, props: included.props.size, maxLevels: buildings.reduce((n, b) => Math.max(n, b.levels), 1) },
|
||||
tiles: [...tiles.keys()].sort().map(id => {
|
||||
const [x, y] = id.split('_').map(Number);
|
||||
return { id, url: `tiles/${id}.json.gz`, bounds: { minX: x * tileSize, minY: y * tileSize, maxX: (x + 1) * tileSize, maxY: (y + 1) * tileSize } };
|
||||
}) };
|
||||
return { manifest, tiles };
|
||||
}
|
||||
|
||||
export function assembleWorld(manifest: CityManifest, tiles: Iterable<CityTile>): World {
|
||||
const world: World = { ...manifest.city, areas: [], roads: [], buildings: [], props: [], models: Object.create(null) };
|
||||
const seen = { areas: new Set<string>(), roads: new Set<string>(), buildings: new Set<string>(), props: new Set<string>() };
|
||||
for (const tile of tiles) {
|
||||
for (const key of ['areas', 'roads', 'buildings', 'props'] as const) {
|
||||
for (const item of tile[key]) if (!seen[key].has(item.id)) {
|
||||
seen[key].add(item.id);
|
||||
(world[key] as (Area | Road | Building | Prop)[]).push(item);
|
||||
}
|
||||
}
|
||||
Object.assign(world.models, tile.models);
|
||||
}
|
||||
if (manifest.areaOrder) {
|
||||
const order=new Map(manifest.areaOrder.map((id,i)=>[id,i]));
|
||||
world.areas.sort((a,b)=>(order.get(a.id) ?? Infinity)-(order.get(b.id) ?? Infinity));
|
||||
}
|
||||
return world;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+171
-1
@@ -4,6 +4,11 @@ import { fetchOverpass, geocode, type BBox, type OsmElement } from './osm/overpa
|
||||
import { generateWorld } from './gen/pipeline';
|
||||
import { downloadWorld, hasWorld, loadWorld, readJsonFile, saveWorld } from './store';
|
||||
import type { World } from './types';
|
||||
import { TileLoader } from '../client/tiles';
|
||||
import type { Bounds } from './city/format';
|
||||
import { RoomConnection } from '../client/connection';
|
||||
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
||||
import { CityJobSchema, CreateCitySchema } from '../client/generated/city_pb';
|
||||
|
||||
const MAX_KM2 = 25;
|
||||
const WARN_KM2 = 10;
|
||||
@@ -11,6 +16,17 @@ const WARN_KM2 = 10;
|
||||
const app = document.getElementById('app')!;
|
||||
let viewer: Viewer | null = null;
|
||||
let picker: AreaPicker | null = null;
|
||||
let tileLoader: TileLoader | null = null;
|
||||
let cityLoad: AbortController | null = null;
|
||||
let roomConnection: RoomConnection | null = null;
|
||||
function stopRoom() { roomConnection?.destroy(); roomConnection = null; }
|
||||
|
||||
function stopTileLoading() {
|
||||
cityLoad?.abort();
|
||||
cityLoad = null;
|
||||
tileLoader?.destroy();
|
||||
tileLoader = null;
|
||||
}
|
||||
|
||||
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!));
|
||||
|
||||
@@ -33,6 +49,8 @@ const hideProgress = () => (overlay.hidden = true);
|
||||
// ------------------------------------------------------------------ экраны
|
||||
|
||||
function showViewer(world: World) {
|
||||
stopRoom();
|
||||
stopTileLoading();
|
||||
picker = null;
|
||||
app.innerHTML = '';
|
||||
viewer?.destroy();
|
||||
@@ -44,6 +62,8 @@ function showViewer(world: World) {
|
||||
}
|
||||
|
||||
async function showPicker(initial?: BBox) {
|
||||
stopRoom();
|
||||
stopTileLoading();
|
||||
viewer?.destroy();
|
||||
viewer = null;
|
||||
app.innerHTML = '';
|
||||
@@ -54,6 +74,7 @@ async function showPicker(initial?: BBox) {
|
||||
panel.className = 'picker-panel card';
|
||||
panel.innerHTML = `
|
||||
<div class="brand">Город сверху</div>
|
||||
<a class="btn" href="/?play">Сетевая игра — комнаты</a>
|
||||
<p class="lead">Выделите на карте небольшой город или район — по данным OpenStreetMap будет построена подробная карта с планировками и мебелью во всех зданиях.</p>
|
||||
<form class="search"><input type="search" placeholder="Найти место: город, район, улица…" autocomplete="off"><button class="btn">Найти</button></form>
|
||||
<div class="results"></div>
|
||||
@@ -144,16 +165,36 @@ async function showPicker(initial?: BBox) {
|
||||
});
|
||||
|
||||
goBtn.addEventListener('click', async () => {
|
||||
if (!bbox) return;
|
||||
if (!bbox || goBtn.disabled) return;
|
||||
goBtn.disabled = true;
|
||||
showErr(null);
|
||||
abort = new AbortController();
|
||||
(overlay.querySelector('[data-a=cancel]') as HTMLButtonElement).textContent = new URLSearchParams(location.search).has('create-city') ? 'Закрыть ожидание' : 'Отмена';
|
||||
try {
|
||||
if (new URLSearchParams(location.search).has('create-city')) {
|
||||
const response = await fetch('/api/cities/jobs',{method:'POST',headers:{'Content-Type':'application/x-protobuf'},body:toBinary(CreateCitySchema,create(CreateCitySchema,{bbox,name:nameInput.value.trim() || 'Новый город',seed:1})),signal:abort.signal});
|
||||
if (!response.ok) throw new Error((await response.json()).error || `HTTP ${response.status}`);
|
||||
const job = fromBinary(CityJobSchema,new Uint8Array(await response.arrayBuffer()));
|
||||
for (;;) {
|
||||
const response = await fetch(`/api/cities/jobs/${encodeURIComponent(job.id)}`,{signal:abort.signal});
|
||||
if (!response.ok) throw new Error((await response.json()).error || `HTTP ${response.status}`);
|
||||
const state = fromBinary(CityJobSchema,new Uint8Array(await response.arrayBuffer()));
|
||||
if (state.status === 'failed') throw new Error(state.error || 'Не удалось создать город');
|
||||
if (state.status === 'ready') { hideProgress(); history.replaceState(null,'',`/?play&room=${encodeURIComponent(state.roomId)}`); await showLobby(); return; }
|
||||
progress(state.status === 'queued' ? 'Город в очереди…' : `${state.stage || 'Подготовка города'}…`,state.status === 'queued' ? 0.05 : 0.5);
|
||||
await new Promise(resolve => setTimeout(resolve,1000));
|
||||
abort.signal.throwIfAborted();
|
||||
}
|
||||
}
|
||||
progress('Загрузка данных OpenStreetMap…', 0.02);
|
||||
const els = await fetchOverpass(bbox, (s) => progress(s, 0.03), abort.signal);
|
||||
await runGeneration(els, bbox, nameInput.value.trim() || 'Новая карта');
|
||||
} catch (ex) {
|
||||
hideProgress();
|
||||
if ((ex as Error).name !== 'AbortError') showErr((ex as Error).message);
|
||||
else if (new URLSearchParams(location.search).has('create-city')) showErr('Ожидание закрыто. Принятая сервером задача продолжится; готовый город появится в каталоге комнат.');
|
||||
} finally {
|
||||
goBtn.disabled = !bbox || bboxSizeKm(bbox).km2 > MAX_KM2;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -224,8 +265,137 @@ async function runGeneration(els: OsmElement[], bbox: BBox, name: string) {
|
||||
|
||||
// ------------------------------------------------------------------ старт
|
||||
|
||||
async function showTiledCity(url: string, connection?: RoomConnection) {
|
||||
stopTileLoading();
|
||||
const controller = cityLoad = new AbortController();
|
||||
progress('Загрузка манифеста города…', 0.1);
|
||||
abort = controller;
|
||||
try {
|
||||
const loader = await TileLoader.open(url, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
tileLoader = loader;
|
||||
viewer?.destroy();
|
||||
app.innerHTML = '';
|
||||
let lastKey = '';
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const tiledViewer = new Viewer(app, loader.emptyWorld(), {
|
||||
onNew: () => { clearTimeout(timer); if (connection) {history.replaceState(null,'','/?play');void showLobby();} else void showPicker(loader.manifest.city.bbox); },
|
||||
newLabel:connection ? 'Комнаты' : undefined,
|
||||
maxViewSpan: loader.manifest.tileSize * (connection ? 4 : 6),
|
||||
maxLevels: loader.manifest.counts.maxLevels,
|
||||
entities: connection ? () => connection.entities.positions() : undefined,
|
||||
onViewport: (bounds: Bounds) => {
|
||||
// Snap to tile boundaries: a paint after loading must not trigger a new request.
|
||||
const size = loader.manifest.tileSize;
|
||||
const x0 = Math.floor(bounds.minX / size) - 1, y0 = Math.floor(bounds.minY / size) - 1;
|
||||
const x1 = Math.floor(bounds.maxX / size) + 1, y1 = Math.floor(bounds.maxY / size) + 1;
|
||||
const key = `${x0},${y0},${x1},${y1}`;
|
||||
if (key === lastKey) return;
|
||||
lastKey = key;
|
||||
connection?.subscribe({ minX: x0 * size, minY: y0 * size, maxX: x1 * size, maxY: y1 * size });
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(async () => {
|
||||
if (tileLoader !== loader) return;
|
||||
tiledViewer.setLoadStatus('Загрузка тайлов…');
|
||||
try {
|
||||
const world = await loader.load({ minX: x0 * size, minY: y0 * size, maxX: x1 * size, maxY: y1 * size });
|
||||
if (tileLoader !== loader) return;
|
||||
tiledViewer.replaceWorld(world);
|
||||
tiledViewer.setLoadStatus(`${loader.manifest.counts.buildings} зданий в городе · ${world.buildings.length} в загруженной области`);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError' && tileLoader === loader) {
|
||||
tiledViewer.setLoadStatus((error as Error).message);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
viewer = tiledViewer;
|
||||
if (connection) {
|
||||
const controls = document.createElement('div'); controls.className = 'row';
|
||||
const label = document.createElement('span'); label.className = 'room-clock'; label.setAttribute('role','status');
|
||||
controls.appendChild(label);
|
||||
for (const speed of [0, 1, 2, 4]) {
|
||||
const button = document.createElement('button'); button.className = 'btn'; button.textContent = speed === 0 ? 'Пауза' : `×${speed}`;
|
||||
button.onclick = () => connection.propose(speed); controls.appendChild(button);
|
||||
}
|
||||
for (const yes of [true,false]) {
|
||||
const button = document.createElement('button'); button.className = 'btn'; button.textContent = yes ? 'За' : 'Против';
|
||||
button.onclick = () => connection.vote(yes); controls.appendChild(button);
|
||||
}
|
||||
const vote = document.createElement('span'); vote.className = 'room-vote'; vote.setAttribute('role','status'); controls.appendChild(vote);
|
||||
tiledViewer.root.querySelector('.viewer-top')!.appendChild(controls);
|
||||
}
|
||||
(window as any).viewer = viewer;
|
||||
hideProgress();
|
||||
} catch (error) {
|
||||
hideProgress();
|
||||
if ((error as Error).name === 'AbortError') return showPicker();
|
||||
await showPicker();
|
||||
const err = app.querySelector<HTMLElement>('.err')!;
|
||||
err.hidden = false;
|
||||
err.textContent = `Не удалось загрузить город: ${(error as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function showLobby() {
|
||||
stopRoom(); stopTileLoading(); viewer?.destroy(); viewer = null; app.innerHTML = '';
|
||||
const panel = document.createElement('div'); panel.className = 'card picker-panel'; panel.style.maxWidth = '700px';
|
||||
panel.innerHTML = `<div class="brand">Комнаты города</div>
|
||||
<label class="field">Ник <input name="nickname" maxlength="24" value="Наблюдатель"></label>
|
||||
<div class="row"><select aria-label="Город"></select><button class="btn primary" data-create>Создать комнату</button><button class="btn" data-refresh>Обновить</button></div>
|
||||
<div data-rooms></div><p role="status" data-status></p><a class="btn" href="/?create-city">Создать город на сервере</a><a class="btn" href="/">Генератор для отладки</a>`;
|
||||
app.appendChild(panel);
|
||||
const status = panel.querySelector<HTMLElement>('[data-status]')!;
|
||||
const api = async (url: string, init?: RequestInit) => {
|
||||
const response = await fetch(url,init); const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`); return body;
|
||||
};
|
||||
const join = (room: string) => {
|
||||
const nickname = panel.querySelector<HTMLInputElement>('[name=nickname]')!.value.trim();
|
||||
if (!nickname) { status.textContent = 'Введите ник'; return; }
|
||||
status.textContent = 'Подключение…';
|
||||
stopRoom();
|
||||
const connection = new RoomConnection(room,nickname,{
|
||||
welcome: w => { void showTiledCity(w.cityManifestUrl,connection); },
|
||||
status: text => { status.textContent = text; viewer?.setLoadStatus(text); },
|
||||
clock: c => { const clock = app.querySelector('.room-clock'); if (clock) clock.textContent = `День ${Math.floor(c.gameSeconds / 86400) + 1} · ${String(Math.floor(c.gameSeconds/3600)%24).padStart(2,'0')}:${String(Math.floor(c.gameSeconds/60)%60).padStart(2,'0')} · ×${c.speed}`; },
|
||||
vote: v => { const vote = app.querySelector('.room-vote'); if (vote) vote.textContent = v.active ? `Голосование за ×${v.speed}: ${v.yes}/${v.players}, ${v.remainingSeconds} с` : `${v.players} игроков`; },
|
||||
});
|
||||
roomConnection = connection;
|
||||
};
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const [cities, rooms] = await Promise.all([api('/api/cities'),api('/api/rooms')]);
|
||||
const select = panel.querySelector('select')!; select.replaceChildren();
|
||||
for (const city of cities) { const option = document.createElement('option'); option.value = city.id; option.textContent = city.name; select.appendChild(option); }
|
||||
const list = panel.querySelector('[data-rooms]')!; list.replaceChildren();
|
||||
for (const room of rooms) {
|
||||
const button = document.createElement('button'); button.className = 'btn'; button.textContent = `${room.cityId} · ${room.players}/30 · ${room.active ? 'открыта' : 'сохранена'}`;
|
||||
button.dataset.room=room.id;
|
||||
button.onclick = () => join(room.id); list.appendChild(button);
|
||||
}
|
||||
status.textContent = cities.length ? (rooms.length ? '' : 'Создайте первую комнату') : 'Каталог пуст. Экспортируйте город: npm run export-city -- world.json public/cities/demo';
|
||||
} catch (error) { status.textContent = `Сервер недоступен: ${(error as Error).message}`; }
|
||||
};
|
||||
panel.querySelector<HTMLButtonElement>('[data-refresh]')!.onclick = () => { void refresh(); };
|
||||
panel.querySelector<HTMLButtonElement>('[data-create]')!.onclick = async () => {
|
||||
const button = panel.querySelector<HTMLButtonElement>('[data-create]')!; button.disabled = true;
|
||||
try {
|
||||
const room = await api('/api/rooms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cityId:panel.querySelector('select')!.value,seed:1})});
|
||||
join(room.id);
|
||||
} catch (error) { status.textContent = (error as Error).message; }
|
||||
finally { button.disabled = false; }
|
||||
};
|
||||
await refresh();
|
||||
const requestedRoom = new URLSearchParams(location.search).get('room');
|
||||
if (requestedRoom) join(requestedRoom);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.has('play')) return showLobby();
|
||||
if (params.has('city')) return showTiledCity(params.get('city')!);
|
||||
if (params.has('last') && (await hasWorld())) {
|
||||
const w = await loadWorld();
|
||||
if (w) return showViewer(w);
|
||||
|
||||
@@ -65,6 +65,9 @@ export function buildQuery(b: BBox): string {
|
||||
node["tourism"]${bb};
|
||||
node["healthcare"]${bb};
|
||||
node["highway"="street_lamp"]${bb};
|
||||
node["highway"="bus_stop"]${bb};
|
||||
node["highway"="crossing"]${bb};
|
||||
node["public_transport"="platform"]${bb};
|
||||
);
|
||||
out geom;`;
|
||||
}
|
||||
|
||||
@@ -194,4 +194,14 @@ export interface Prop {
|
||||
color?: string;
|
||||
/** Для симуляции: можно сидеть/спать/хранить и т.п. */
|
||||
tags?: string[];
|
||||
/** World-space interaction points shared by indoor and outdoor objects. */
|
||||
interactions?: InteractionSlot[];
|
||||
target?: { buildingId: string; doorId: string; level: number };
|
||||
}
|
||||
|
||||
export interface InteractionSlot {
|
||||
id: string;
|
||||
action: string;
|
||||
pos: Vec;
|
||||
capacity: number;
|
||||
}
|
||||
+55
-6
@@ -4,6 +4,7 @@ import type { Building, Vec, World } from '../types';
|
||||
import { Renderer, type Camera, type ViewOpts } from './renderer';
|
||||
import { KIND_NAME, ROOM_NAME } from './style';
|
||||
import { centroid } from '../util/geom';
|
||||
import type { Bounds } from '../city/format';
|
||||
|
||||
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, html?: string) => {
|
||||
const e = document.createElement(tag);
|
||||
@@ -21,6 +22,9 @@ export class Viewer {
|
||||
cam: Camera;
|
||||
opts: ViewOpts = { floor: null, overrides: new Map(), selected: null, furniture: true, labels: true };
|
||||
private raf = 0;
|
||||
private disposed = false;
|
||||
private resizeObserver = new ResizeObserver(() => this.resize());
|
||||
private loadStatus = el('div', 'hint');
|
||||
private floorBar = el('div', 'floor-bar card');
|
||||
private info = el('div', 'info card');
|
||||
private scale = el('div', 'scale');
|
||||
@@ -28,28 +32,52 @@ export class Viewer {
|
||||
private pointers = new Map<number, { x: number; y: number }>();
|
||||
private gesture: { x: number; y: number; cam: Camera; d?: number; moved: boolean } | null = null;
|
||||
|
||||
constructor(parent: HTMLElement, public world: World, private actions: { onNew: () => void; onExport: () => void }) {
|
||||
constructor(parent: HTMLElement, public world: World, private actions: {
|
||||
onNew: () => void; onExport?: () => void; onViewport?: (bounds: Bounds) => void;
|
||||
maxViewSpan?: number; maxLevels?: number;
|
||||
newLabel?: string;
|
||||
entities?: () => { id: number; x: number; y: number }[];
|
||||
}) {
|
||||
this.renderer = new Renderer(world);
|
||||
const B = world.bounds;
|
||||
this.cam = { x: (B.minX + B.maxX) / 2, y: (B.minY + B.maxY) / 2, z: 1 };
|
||||
this.maxLevel = Math.min(40, Math.max(1, ...world.buildings.map((b) => b.levels)));
|
||||
this.maxLevel = Math.min(40, actions.maxLevels ?? world.buildings.reduce((n, b) => Math.max(n, b.levels), 1));
|
||||
this.root.append(this.canvas, this.buildTop(), this.floorBar, this.info, this.scale, el('div', 'attribution', '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors'));
|
||||
parent.appendChild(this.root);
|
||||
this.info.hidden = true;
|
||||
this.buildFloorBar();
|
||||
new ResizeObserver(() => this.resize()).observe(this.root);
|
||||
this.loadStatus.setAttribute('role', 'status');
|
||||
this.root.querySelector('.viewer-top')!.appendChild(this.loadStatus);
|
||||
this.resizeObserver.observe(this.root);
|
||||
this.bindInput();
|
||||
requestAnimationFrame(() => {
|
||||
if (this.disposed) return;
|
||||
this.resize();
|
||||
this.fitBounds();
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.disposed = true;
|
||||
cancelAnimationFrame(this.raf);
|
||||
this.resizeObserver.disconnect();
|
||||
this.root.remove();
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
}
|
||||
|
||||
setLoadStatus(message: string) { this.loadStatus.textContent = message; }
|
||||
|
||||
replaceWorld(world: World) {
|
||||
if (this.disposed) return;
|
||||
this.world = world;
|
||||
this.renderer = new Renderer(world);
|
||||
const stats = this.root.querySelector('.stats');
|
||||
if (stats) stats.textContent = `${world.buildings.length.toLocaleString('ru')} зданий · ${world.props.length.toLocaleString('ru')} уличных объектов в загруженной области`;
|
||||
if (!world.buildings.some(b => b.id === this.opts.selected)) this.opts.selected = null;
|
||||
this.updateInfo();
|
||||
this.request();
|
||||
}
|
||||
|
||||
private buildTop(): HTMLElement {
|
||||
const top = el('div', 'viewer-top card');
|
||||
const w = this.world;
|
||||
@@ -64,13 +92,16 @@ export class Viewer {
|
||||
<div class="title">${esc(w.name || 'Без названия')}</div>
|
||||
<div class="stats">${w.buildings.length.toLocaleString('ru')} зданий · ${floors.toLocaleString('ru')} этажей · ${rooms.toLocaleString('ru')} комнат · ${furn.toLocaleString('ru')} предметов · ${homes.toLocaleString('ru')} квартир/домов · ~${beds.toLocaleString('ru')} жителей</div>
|
||||
<div class="row">
|
||||
<button class="btn" data-a="new">Новая карта</button>
|
||||
<button class="btn" data-a="new">${esc(this.actions.newLabel || 'Новая карта')}</button>
|
||||
<button class="btn" data-a="export" title="Скачать мир целиком (JSON) — формат для будущей игры">Экспорт мира</button>
|
||||
<label class="chip"><input type="checkbox" data-t="furniture" checked> Мебель</label>
|
||||
<label class="chip"><input type="checkbox" data-t="labels" checked> Подписи</label>
|
||||
</div>`;
|
||||
top.querySelector('[data-a=new]')!.addEventListener('click', () => this.actions.onNew());
|
||||
top.querySelector('[data-a=export]')!.addEventListener('click', () => this.actions.onExport());
|
||||
const exportButton = top.querySelector<HTMLButtonElement>('[data-a=export]')!;
|
||||
exportButton.disabled = !this.actions.onExport;
|
||||
if (!this.actions.onExport) exportButton.title = 'Полный город экспортируется офлайн командой npm run export-city';
|
||||
exportButton.addEventListener('click', () => this.actions.onExport?.());
|
||||
top.querySelectorAll<HTMLInputElement>('input[data-t]').forEach((inp) =>
|
||||
inp.addEventListener('change', () => {
|
||||
(this.opts as any)[inp.dataset.t!] = inp.checked;
|
||||
@@ -126,6 +157,7 @@ export class Viewer {
|
||||
this.cam.x = (B.minX + B.maxX) / 2;
|
||||
this.cam.y = (B.minY + B.maxY) / 2;
|
||||
this.cam.z = Math.min(r.width / (B.maxX - B.minX), r.height / (B.maxY - B.minY)) * 0.92;
|
||||
if (this.actions.maxViewSpan) this.cam.z = Math.max(this.cam.z, Math.max(r.width, r.height) / this.actions.maxViewSpan);
|
||||
this.request();
|
||||
}
|
||||
|
||||
@@ -136,12 +168,29 @@ export class Viewer {
|
||||
}
|
||||
|
||||
request() {
|
||||
if (this.raf) return;
|
||||
if (this.raf || this.disposed) return;
|
||||
this.raf = requestAnimationFrame(() => {
|
||||
this.raf = 0;
|
||||
const r = this.root.getBoundingClientRect();
|
||||
this.renderer.draw(this.ctx, r.width, r.height, this.cam, this.opts, window.devicePixelRatio || 1);
|
||||
if (this.actions.entities) {
|
||||
const ctx = this.ctx;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
ctx.save(); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#382c21';
|
||||
for (const agent of this.actions.entities()) {
|
||||
const x = (agent.x - this.cam.x) * this.cam.z + r.width / 2;
|
||||
const y = (agent.y - this.cam.y) * this.cam.z + r.height / 2;
|
||||
if (x < -5 || x > r.width + 5 || y < -5 || y > r.height + 5) continue;
|
||||
ctx.beginPath(); ctx.arc(x,y,Math.max(2,Math.min(5,this.cam.z*0.3)),0,Math.PI*2); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
this.updateScale();
|
||||
if (r.width && r.height && this.cam.z > 0) this.actions.onViewport?.({
|
||||
minX: this.cam.x - r.width / this.cam.z / 2, maxX: this.cam.x + r.width / this.cam.z / 2,
|
||||
minY: this.cam.y - r.height / this.cam.z / 2, maxY: this.cam.y + r.height / this.cam.z / 2,
|
||||
});
|
||||
if (this.actions.entities) this.request();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user