@world-engines/project-host 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +46 -0
- package/assets/gui/WELogo-title.svg +11 -0
- package/assets/gui/app.js +1326 -0
- package/assets/gui/index.html +118 -0
- package/assets/gui/local-preview-asset-urls.js +61 -0
- package/assets/gui/local-preview-bridge.js +393 -0
- package/assets/gui/spatial-template-panel.js +233 -0
- package/assets/gui/styles.css +115 -0
- package/assets/gui/terrain-render.js +120 -0
- package/assets/gui/workspace-status.css +17 -0
- package/assets/gui/workspace-status.js +255 -0
- package/assets/gui/world-map-globe.js +187 -0
- package/assets/gui/world-map-worker.js +62 -0
- package/assets/gui/world-map.css +30 -0
- package/assets/gui/world-map.js +2466 -0
- package/dist/account-model-proxy.d.ts +21 -0
- package/dist/account-model-proxy.js +470 -0
- package/dist/authoring-browser-runtime.d.ts +7 -0
- package/dist/authoring-browser-runtime.js +110 -0
- package/dist/authoring-ingestion.d.ts +38 -0
- package/dist/authoring-ingestion.js +410 -0
- package/dist/authoring-model-connection.d.ts +166 -0
- package/dist/authoring-model-connection.js +757 -0
- package/dist/chinese-lexical.d.ts +152 -0
- package/dist/chinese-lexical.js +555 -0
- package/dist/chromium-preview-browser-driver.d.ts +43 -0
- package/dist/chromium-preview-browser-driver.js +575 -0
- package/dist/cli/download.d.ts +12 -0
- package/dist/cli/download.js +50 -0
- package/dist/cli/dump.d.ts +32 -0
- package/dist/cli/dump.js +91 -0
- package/dist/cli/ingest.d.ts +49 -0
- package/dist/cli/ingest.js +225 -0
- package/dist/cli/review.d.ts +10 -0
- package/dist/cli/review.js +44 -0
- package/dist/cli/upload.d.ts +57 -0
- package/dist/cli/upload.js +112 -0
- package/dist/cli-command.d.ts +60 -0
- package/dist/cli-command.js +613 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +182 -0
- package/dist/desktop-authoring-runtime.d.ts +88 -0
- package/dist/desktop-authoring-runtime.js +1602 -0
- package/dist/desktop-sidecar.d.ts +11 -0
- package/dist/desktop-sidecar.js +143 -0
- package/dist/document-dump.d.ts +113 -0
- package/dist/document-dump.js +431 -0
- package/dist/document-graph-extractor.d.ts +125 -0
- package/dist/document-graph-extractor.js +588 -0
- package/dist/document-ingestion-journal.d.ts +68 -0
- package/dist/document-ingestion-journal.js +471 -0
- package/dist/document-ingestion.d.ts +183 -0
- package/dist/document-ingestion.js +976 -0
- package/dist/document-query-path-proof.d.ts +30 -0
- package/dist/document-query-path-proof.js +34 -0
- package/dist/document-query-path-renderer.d.ts +50 -0
- package/dist/document-query-path-renderer.js +75 -0
- package/dist/document-query-proof-paths.d.ts +22 -0
- package/dist/document-query-proof-paths.js +71 -0
- package/dist/document-query.d.ts +97 -0
- package/dist/document-query.js +1106 -0
- package/dist/document-relation-role.d.ts +14 -0
- package/dist/document-relation-role.js +86 -0
- package/dist/document-source.d.ts +107 -0
- package/dist/document-source.js +255 -0
- package/dist/embedding-cache.d.ts +35 -0
- package/dist/embedding-cache.js +317 -0
- package/dist/frozen-review-local-pipeline.d.ts +88 -0
- package/dist/frozen-review-local-pipeline.js +609 -0
- package/dist/frozen-review-source-capture.d.ts +15 -0
- package/dist/frozen-review-source-capture.js +551 -0
- package/dist/frozen-review-wasm-encryptor.d.ts +16 -0
- package/dist/frozen-review-wasm-encryptor.js +72 -0
- package/dist/frozen-review.d.ts +131 -0
- package/dist/frozen-review.js +802 -0
- package/dist/graph-traversal.d.ts +23 -0
- package/dist/graph-traversal.js +100 -0
- package/dist/gui/graph-layout-worker.js +1 -0
- package/dist/gui/graph-layout.wasm +0 -0
- package/dist/gui/graph.css +1 -0
- package/dist/gui/graph.js +76 -0
- package/dist/gui/plane-generation.js +112 -0
- package/dist/gui/plane-generator.js +310 -0
- package/dist/gui/plane-generator_bg.wasm +0 -0
- package/dist/gui/procedural-city.js +595 -0
- package/dist/gui/road-generation.js +129 -0
- package/dist/gui/road-parcels.js +1584 -0
- package/dist/gui/score.css +1 -0
- package/dist/gui/score.js +54 -0
- package/dist/gui/spatial-runtime.js +1231 -0
- package/dist/gui/spatial-templates.js +200 -0
- package/dist/gui/trigger.css +1 -0
- package/dist/gui/trigger.js +142 -0
- package/dist/harness-callback-transport.d.ts +17 -0
- package/dist/harness-callback-transport.js +128 -0
- package/dist/harness-document-ingestion.d.ts +77 -0
- package/dist/harness-document-ingestion.js +343 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +43 -0
- package/dist/ingestion-source-reconciliation.d.ts +88 -0
- package/dist/ingestion-source-reconciliation.js +253 -0
- package/dist/ladybug-runtime.d.ts +117 -0
- package/dist/ladybug-runtime.js +886 -0
- package/dist/local-account-embedding.d.ts +34 -0
- package/dist/local-account-embedding.js +294 -0
- package/dist/local-gallery.d.ts +35 -0
- package/dist/local-gallery.js +227 -0
- package/dist/local-gui-auth.d.ts +96 -0
- package/dist/local-gui-auth.js +703 -0
- package/dist/local-gui-contract.d.ts +53 -0
- package/dist/local-gui-contract.js +5 -0
- package/dist/local-gui-image-assets.d.ts +17 -0
- package/dist/local-gui-image-assets.js +96 -0
- package/dist/local-gui-preview-client.d.ts +3 -0
- package/dist/local-gui-preview-client.js +111 -0
- package/dist/local-gui-score.d.ts +72 -0
- package/dist/local-gui-score.js +184 -0
- package/dist/local-gui-server.d.ts +10 -0
- package/dist/local-gui-server.js +987 -0
- package/dist/local-gui-version.d.ts +17 -0
- package/dist/local-gui-version.js +147 -0
- package/dist/local-gui-workspace.d.ts +14 -0
- package/dist/local-gui-workspace.js +171 -0
- package/dist/local-model-settings.d.ts +28 -0
- package/dist/local-model-settings.js +124 -0
- package/dist/local-preview-assets.d.ts +28 -0
- package/dist/local-preview-assets.js +124 -0
- package/dist/local-preview-bound-resources.d.ts +12 -0
- package/dist/local-preview-bound-resources.js +80 -0
- package/dist/local-preview-message-store.d.ts +63 -0
- package/dist/local-preview-message-store.js +214 -0
- package/dist/local-preview-player-read-models.d.ts +47 -0
- package/dist/local-preview-player-read-models.js +90 -0
- package/dist/local-preview-projections.d.ts +61 -0
- package/dist/local-preview-projections.js +117 -0
- package/dist/local-preview-runtime.d.ts +105 -0
- package/dist/local-preview-runtime.js +553 -0
- package/dist/local-preview-session.d.ts +73 -0
- package/dist/local-preview-session.js +395 -0
- package/dist/local-preview-source.d.ts +40 -0
- package/dist/local-preview-source.js +192 -0
- package/dist/local-preview-view-events.d.ts +101 -0
- package/dist/local-preview-view-events.js +179 -0
- package/dist/local-preview-view-state-sqlite.d.ts +24 -0
- package/dist/local-preview-view-state-sqlite.js +263 -0
- package/dist/local-preview-view-state.d.ts +93 -0
- package/dist/local-preview-view-state.js +108 -0
- package/dist/local-preview-vite-config.d.ts +6 -0
- package/dist/local-preview-vite-config.js +122 -0
- package/dist/local-preview-world-effects.d.ts +9 -0
- package/dist/local-preview-world-effects.js +55 -0
- package/dist/local-preview-worldline-store.d.ts +47 -0
- package/dist/local-preview-worldline-store.js +282 -0
- package/dist/local-preview-worldlines.d.ts +95 -0
- package/dist/local-preview-worldlines.js +136 -0
- package/dist/mcp-async-dispatcher.d.ts +3 -0
- package/dist/mcp-async-dispatcher.js +129 -0
- package/dist/mcp-line-queue.d.ts +14 -0
- package/dist/mcp-line-queue.js +184 -0
- package/dist/mcp-pipe-internal.d.ts +6 -0
- package/dist/mcp-pipe-internal.js +151 -0
- package/dist/mcp-pipe-response.d.ts +14 -0
- package/dist/mcp-pipe-response.js +50 -0
- package/dist/mcp-stdio-response.d.ts +12 -0
- package/dist/mcp-stdio-response.js +27 -0
- package/dist/mcp.d.ts +27 -0
- package/dist/mcp.js +194 -0
- package/dist/prepared-embedding-cache.d.ts +38 -0
- package/dist/prepared-embedding-cache.js +238 -0
- package/dist/preview-browser-adapter.d.ts +53 -0
- package/dist/preview-browser-adapter.js +171 -0
- package/dist/preview-runtime.d.ts +67 -0
- package/dist/preview-runtime.js +460 -0
- package/dist/project-auth-transport.d.ts +27 -0
- package/dist/project-auth-transport.js +121 -0
- package/dist/project-auth.d.ts +51 -0
- package/dist/project-auth.js +108 -0
- package/dist/project-git-capture-recovery.d.ts +16 -0
- package/dist/project-git-capture-recovery.js +347 -0
- package/dist/project-git-cli.d.ts +5 -0
- package/dist/project-git-cli.js +40 -0
- package/dist/project-git-scenario-source.d.ts +10 -0
- package/dist/project-git-scenario-source.js +240 -0
- package/dist/project-git-view-source.d.ts +13 -0
- package/dist/project-git-view-source.js +134 -0
- package/dist/project-git.d.ts +97 -0
- package/dist/project-git.js +656 -0
- package/dist/project-host.d.ts +59 -0
- package/dist/project-host.js +471 -0
- package/dist/project-writer-lease.d.ts +18 -0
- package/dist/project-writer-lease.js +430 -0
- package/dist/public-error.d.ts +7 -0
- package/dist/public-error.js +61 -0
- package/dist/retrieval-benchmark.d.ts +46 -0
- package/dist/retrieval-benchmark.js +58 -0
- package/dist/safe-project-path.d.ts +8 -0
- package/dist/safe-project-path.js +93 -0
- package/dist/scenario-writer.d.ts +32 -0
- package/dist/scenario-writer.js +191 -0
- package/dist/transfer/download.d.ts +159 -0
- package/dist/transfer/download.js +598 -0
- package/dist/transfer/transport.d.ts +26 -0
- package/dist/transfer/transport.js +1 -0
- package/dist/transfer/upload.d.ts +162 -0
- package/dist/transfer/upload.js +354 -0
- package/dist/transfer-types.d.ts +95 -0
- package/dist/transfer-types.js +1 -0
- package/dist/transfer.d.ts +18 -0
- package/dist/transfer.js +273 -0
- package/dist/trigger-runtime.d.ts +121 -0
- package/dist/trigger-runtime.js +153 -0
- package/dist/vendor/wasm-core/asset-hashes.json +5 -0
- package/dist/vendor/wasm-core/wasm_core.d.ts +490 -0
- package/dist/vendor/wasm-core/wasm_core.js +1 -0
- package/dist/vendor/wasm-core/wasm_core_bg.wasm +0 -0
- package/dist/view-watcher.d.ts +9 -0
- package/dist/view-watcher.js +34 -0
- package/package.json +110 -0
|
@@ -0,0 +1,2466 @@
|
|
|
1
|
+
import { DEFAULT_PLANE_GENERATION, PLANE_GENERATION_MAX_EXTENT_METERS, PLANE_GENERATION_PROFILE, validatePlaneGeneration } from "./plane-generation.js";
|
|
2
|
+
import { PALETTE, PROCEDURAL_GENERATOR_IDENTITY, PROCEDURAL_WORLD_PROFILE, generateCityLayout, validateProceduralWorld } from "./procedural-city.js";
|
|
3
|
+
import { DEFAULT_ROAD_GENERATION, ROAD_GENERATION_IDENTITY, ROAD_GENERATION_PROFILE, roadGenerationUpgradeTarget, roadParcelAlgorithm, validateRoadGeneration } from "./road-generation.js";
|
|
4
|
+
import { generateRoadParcels } from "./road-parcels.js";
|
|
5
|
+
import { resolveRoadParcelTemplateSettings, validateSpatialTemplateConfiguration } from "./spatial-templates.js";
|
|
6
|
+
import { DEFAULT_TERRAIN_LAYERS, contourSegments, hillshadeAt, packContourSegments, sharedContourPlan } from "./terrain-render.js";
|
|
7
|
+
import { renderWorkspaceStatus } from "./workspace-status.js";
|
|
8
|
+
|
|
9
|
+
const LOD_TILE_SIZE = 128;
|
|
10
|
+
const LOD_MAX_LEVEL = 12;
|
|
11
|
+
const LOD_CACHE_MAX_TILES = 192;
|
|
12
|
+
const LOD_CACHE_BYTE_BUDGET = 16 * 1024 * 1024;
|
|
13
|
+
const LOD_REQUEST_DEBOUNCE_MS = 80;
|
|
14
|
+
const LOD_MAX_IN_FLIGHT = 6;
|
|
15
|
+
const LOD_MAX_VISIBLE_TILES = 120;
|
|
16
|
+
// 120 个可见 tile:raster 约 7.5MiB,1200×40B 等高线约 5.5MiB,给边界向量和 LRU 留余量。
|
|
17
|
+
const LOD_CONTOUR_SEGMENT_CAP = 1200;
|
|
18
|
+
const LOD_CROSSFADE_MS = 150;
|
|
19
|
+
const LOD_DENSITY = { low: 1, normal: 1.25, high: 1.55 };
|
|
20
|
+
const CITY_LAYOUT_CACHE_MAX = 16;
|
|
21
|
+
const CITY_LAYOUT_CACHE_BYTE_BUDGET = 24 * 1024 * 1024;
|
|
22
|
+
const ROAD_PREVIEW_DEBOUNCE_MS = 220;
|
|
23
|
+
const CITY_SIZE_PRESETS = Object.freeze([
|
|
24
|
+
Object.freeze({ id: "town", label: "小镇", radiusMeters: 480 }),
|
|
25
|
+
Object.freeze({ id: "small-city", label: "小城", radiusMeters: 1_200 }),
|
|
26
|
+
Object.freeze({ id: "city", label: "中城", radiusMeters: 3_000 }),
|
|
27
|
+
Object.freeze({ id: "large-city", label: "大城", radiusMeters: 8_000 }),
|
|
28
|
+
]);
|
|
29
|
+
const PROCEDURAL_CITY = { PALETTE, PROCEDURAL_GENERATOR_IDENTITY, PROCEDURAL_WORLD_PROFILE, generateCityLayout, validateProceduralWorld };
|
|
30
|
+
|
|
31
|
+
function element(tag, className, text) {
|
|
32
|
+
const node = document.createElement(tag);
|
|
33
|
+
if (className) node.className = className;
|
|
34
|
+
if (text !== undefined) node.textContent = text;
|
|
35
|
+
return node;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function svgIcon(kind) {
|
|
39
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
40
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
41
|
+
svg.setAttribute("aria-hidden", "true");
|
|
42
|
+
const paths = {
|
|
43
|
+
plus: ["M12 5v14", "M5 12h14"],
|
|
44
|
+
minus: ["M5 12h14"],
|
|
45
|
+
fit: ["M8 4H4v4", "M16 4h4v4", "M4 16v4h4", "M20 16v4h-4"],
|
|
46
|
+
settings: ["M4 7h10", "M18 7h2", "M4 17h2", "M10 17h10", "M14 4v6", "M6 14v6"],
|
|
47
|
+
};
|
|
48
|
+
for (const d of paths[kind] ?? []) {
|
|
49
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
50
|
+
path.setAttribute("d", d);
|
|
51
|
+
svg.append(path);
|
|
52
|
+
}
|
|
53
|
+
return svg;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function iconButton(kind, label) {
|
|
57
|
+
const button = element("button", "graph-fab world-map-icon-button");
|
|
58
|
+
button.type = "button";
|
|
59
|
+
button.title = label;
|
|
60
|
+
button.setAttribute("aria-label", label);
|
|
61
|
+
button.append(svgIcon(kind));
|
|
62
|
+
return button;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function numberInRange(value, name, minimum, maximum, integer = false) {
|
|
66
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum || (integer && !Number.isInteger(value))) {
|
|
67
|
+
throw new TypeError(`${name} 参数无效`);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sameGeneration(left, right) {
|
|
73
|
+
if (!left || !right) return false;
|
|
74
|
+
return Object.keys(DEFAULT_PLANE_GENERATION).every((key) => left[key] === right[key]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validatePlaneOnlyGeneration(value) {
|
|
78
|
+
if (value?.profile !== PLANE_GENERATION_PROFILE) throw new TypeError("平面地图仅支持 Perlin 水文生成参数");
|
|
79
|
+
return validatePlaneGeneration(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateSnapshot(value) {
|
|
83
|
+
if (!value || typeof value !== "object") throw new TypeError("项目地图读回无效");
|
|
84
|
+
if ((typeof value.projectId !== "string" && typeof value.projectId !== "number") || value.projectId === "") throw new TypeError("项目 ID 缺失");
|
|
85
|
+
if (!Number.isSafeInteger(value.revision) || value.revision < 0) throw new TypeError("项目 revision 无效");
|
|
86
|
+
if (value.source !== null && (typeof value.source !== "object" || Array.isArray(value.source))) throw new TypeError("地图 source 无效");
|
|
87
|
+
const savedGeneration = value.source?.topology?.generation == null ? null : validatePlaneOnlyGeneration(value.source.topology.generation);
|
|
88
|
+
const savedRoadGeneration = value.source?.topology?.roadGeneration == null ? null : validateRoadGeneration(value.source.topology.roadGeneration);
|
|
89
|
+
return { value, savedGeneration, savedRoadGeneration };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function initialGeneration(snapshot, savedGeneration) {
|
|
93
|
+
if (savedGeneration) return savedGeneration;
|
|
94
|
+
const topology = snapshot.source?.topology;
|
|
95
|
+
const width = topology?.width;
|
|
96
|
+
const height = topology?.height;
|
|
97
|
+
const generation = { ...DEFAULT_PLANE_GENERATION };
|
|
98
|
+
if (typeof width === "number" && Number.isSafeInteger(width) && width > 0) generation.widthMeters = width;
|
|
99
|
+
if (typeof height === "number" && Number.isSafeInteger(height) && height > 0) generation.heightMeters = height;
|
|
100
|
+
return validatePlaneOnlyGeneration(generation);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateResult(value, settings) {
|
|
104
|
+
if (!value || typeof value !== "object") throw new TypeError("WASM 返回了无效地图");
|
|
105
|
+
const expectedKeys = ["width", "height", "elevations", "riverFlux", "lakeMask", "seaLevel", "seed", "profile"];
|
|
106
|
+
if (Object.keys(value).length !== expectedKeys.length || expectedKeys.some((key) => !Object.hasOwn(value, key))) throw new TypeError("WASM 返回了未知字段");
|
|
107
|
+
const width = numberInRange(value.width, "width", 1, 4096, true);
|
|
108
|
+
const height = numberInRange(value.height, "height", 1, 4096, true);
|
|
109
|
+
const size = width * height;
|
|
110
|
+
if (size > settings.resolution * settings.resolution) throw new TypeError("WASM 地图网格超过请求分辨率");
|
|
111
|
+
for (const name of ["elevations", "riverFlux"]) {
|
|
112
|
+
const values = value[name];
|
|
113
|
+
if ((!Array.isArray(values) && !ArrayBuffer.isView(values)) || values.length !== size) throw new TypeError(`WASM ${name} 长度无效`);
|
|
114
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
115
|
+
if (typeof values[index] !== "number" || !Number.isFinite(values[index])) throw new TypeError(`WASM ${name} 包含非数值`);
|
|
116
|
+
if (name === "elevations" && (values[index] < -1 || values[index] > 1)) throw new RangeError("WASM elevations 必须位于 -1..1");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const lakeMask = value.lakeMask;
|
|
120
|
+
if (!Array.isArray(lakeMask) || lakeMask.length !== size || lakeMask.some((entry) => typeof entry !== "boolean")) throw new TypeError("WASM lakeMask 必须是布尔网格");
|
|
121
|
+
if (value.profile !== settings.profile || value.seed !== settings.seed) throw new TypeError("WASM 返回的生成身份不匹配");
|
|
122
|
+
const seaLevel = numberInRange(value.seaLevel, "seaLevel", -1, 1);
|
|
123
|
+
return { ...value, width, height, seaLevel };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateTileResult(value, settings, requestedTile) {
|
|
127
|
+
if (!value || typeof value !== "object") throw new TypeError("WASM 返回了无效地图细节区块");
|
|
128
|
+
const expectedKeys = ["level", "x", "y", "width", "height", "elevations", "riverFlux", "lakeMask", "seaLevel", "seed", "profile"];
|
|
129
|
+
if (Object.keys(value).length !== expectedKeys.length || expectedKeys.some((key) => !Object.hasOwn(value, key))) throw new TypeError("WASM 细节区块返回了未知字段");
|
|
130
|
+
if (value.level !== requestedTile.level || value.x !== requestedTile.x || value.y !== requestedTile.y
|
|
131
|
+
|| value.width !== LOD_TILE_SIZE || value.height !== LOD_TILE_SIZE) {
|
|
132
|
+
throw new TypeError("WASM 细节区块身份不匹配");
|
|
133
|
+
}
|
|
134
|
+
const size = LOD_TILE_SIZE * LOD_TILE_SIZE;
|
|
135
|
+
for (const name of ["elevations", "riverFlux"]) {
|
|
136
|
+
const values = value[name];
|
|
137
|
+
if ((!Array.isArray(values) && !ArrayBuffer.isView(values)) || values.length !== size) throw new TypeError(`WASM ${name} 长度无效`);
|
|
138
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
139
|
+
if (typeof values[index] !== "number" || !Number.isFinite(values[index])) throw new TypeError(`WASM ${name} 包含非数值`);
|
|
140
|
+
if (name === "elevations" && (values[index] < -1 || values[index] > 1)) throw new RangeError("WASM elevations 必须位于 -1..1");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!Array.isArray(value.lakeMask) || value.lakeMask.length !== size || value.lakeMask.some((entry) => typeof entry !== "boolean")) {
|
|
144
|
+
throw new TypeError("WASM lakeMask 必须是布尔网格");
|
|
145
|
+
}
|
|
146
|
+
if (value.profile !== settings.profile || value.seed !== settings.seed) throw new TypeError("WASM 细节区块生成身份不匹配");
|
|
147
|
+
return { ...value, width: LOD_TILE_SIZE, height: LOD_TILE_SIZE, seaLevel: numberInRange(value.seaLevel, "seaLevel", -1, 1) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function field(form, name) {
|
|
151
|
+
const input = form.elements.namedItem(name);
|
|
152
|
+
if (!(input instanceof HTMLInputElement) && !(input instanceof HTMLSelectElement)) throw new TypeError(`缺少 ${name} 控件`);
|
|
153
|
+
return input;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function numberFromForm(form, name) {
|
|
157
|
+
const control = field(form, name);
|
|
158
|
+
const raw = control.value.trim();
|
|
159
|
+
if (!raw) throw new TypeError(`${name} 参数不能为空`);
|
|
160
|
+
const value = Number(raw);
|
|
161
|
+
if (!Number.isFinite(value)) throw new TypeError(`${name} 参数无效`);
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function generationFromForm(form) {
|
|
166
|
+
const widthMeters = Math.round(numberFromForm(form, "widthKm") * 1000);
|
|
167
|
+
const heightMeters = Math.round(numberFromForm(form, "heightKm") * 1000);
|
|
168
|
+
return validatePlaneOnlyGeneration({
|
|
169
|
+
profile: PLANE_GENERATION_PROFILE,
|
|
170
|
+
seed: numberFromForm(form, "seed"),
|
|
171
|
+
widthMeters,
|
|
172
|
+
heightMeters,
|
|
173
|
+
resolution: numberFromForm(form, "resolution"),
|
|
174
|
+
waterCoverage: numberFromForm(form, "waterCoverage"),
|
|
175
|
+
relief: numberFromForm(form, "relief"),
|
|
176
|
+
riverDensity: numberFromForm(form, "riverDensity"),
|
|
177
|
+
lakeDensity: numberFromForm(form, "lakeDensity"),
|
|
178
|
+
rainfall: numberFromForm(form, "rainfall"),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function setFormGeneration(form, generation) {
|
|
183
|
+
const values = {
|
|
184
|
+
seed: generation.seed,
|
|
185
|
+
widthKm: generation.widthMeters / 1000,
|
|
186
|
+
heightKm: generation.heightMeters / 1000,
|
|
187
|
+
resolution: generation.resolution,
|
|
188
|
+
waterCoverage: generation.waterCoverage,
|
|
189
|
+
relief: generation.relief,
|
|
190
|
+
riverDensity: generation.riverDensity,
|
|
191
|
+
lakeDensity: generation.lakeDensity,
|
|
192
|
+
rainfall: generation.rainfall,
|
|
193
|
+
};
|
|
194
|
+
for (const [name, value] of Object.entries(values)) field(form, name).value = String(value);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function settingField(labelText, name, options = {}) {
|
|
198
|
+
const label = element("label", "world-map-field");
|
|
199
|
+
const labelRow = element("span", "world-map-field-label");
|
|
200
|
+
labelRow.append(element("span", "", labelText));
|
|
201
|
+
if (options.hint) labelRow.append(element("small", "", options.hint));
|
|
202
|
+
let input;
|
|
203
|
+
if (options.values) {
|
|
204
|
+
input = document.createElement("select");
|
|
205
|
+
for (const value of options.values) {
|
|
206
|
+
const option = element("option", "", String(value));
|
|
207
|
+
option.value = String(value);
|
|
208
|
+
input.append(option);
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
input = document.createElement("input");
|
|
212
|
+
input.type = "number";
|
|
213
|
+
for (const key of ["min", "max", "step"]) if (options[key] !== undefined) input.setAttribute(key, String(options[key]));
|
|
214
|
+
input.inputMode = "decimal";
|
|
215
|
+
}
|
|
216
|
+
input.name = name;
|
|
217
|
+
input.setAttribute("aria-label", labelText);
|
|
218
|
+
label.append(labelRow, input);
|
|
219
|
+
return label;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function layerToggle(name, text, checked = true) {
|
|
223
|
+
const label = element("label", "world-map-layer");
|
|
224
|
+
const input = document.createElement("input");
|
|
225
|
+
input.type = "checkbox";
|
|
226
|
+
input.name = name;
|
|
227
|
+
input.checked = checked;
|
|
228
|
+
label.append(input, element("span", "", text));
|
|
229
|
+
return label;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function objectRows(objects) {
|
|
233
|
+
if (Array.isArray(objects)) return objects.map((value, index) => [String(index), value]);
|
|
234
|
+
if (objects && typeof objects === "object") return Object.entries(objects);
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function objectMarkers(objects, generation) {
|
|
239
|
+
const markers = [];
|
|
240
|
+
for (const [key, value] of objectRows(objects)) {
|
|
241
|
+
if (!value || typeof value !== "object") continue;
|
|
242
|
+
const position = value.position && typeof value.position === "object" ? value.position : value;
|
|
243
|
+
const coordinates = Array.isArray(position.coordinates) ? position.coordinates : null;
|
|
244
|
+
const rawX = coordinates?.[0] ?? position.x;
|
|
245
|
+
const rawY = coordinates?.[1] ?? position.y;
|
|
246
|
+
if (typeof rawX !== "number" || typeof rawY !== "number" || !Number.isFinite(rawX) || !Number.isFinite(rawY)) continue;
|
|
247
|
+
const x = rawX >= 0 && rawX <= 1 ? rawX : rawX / generation.widthMeters;
|
|
248
|
+
const y = rawY >= 0 && rawY <= 1 ? rawY : rawY / generation.heightMeters;
|
|
249
|
+
if (x < 0 || x > 1 || y < 0 || y > 1) continue;
|
|
250
|
+
const idValue = value.id ?? value.objectId ?? value.object_id ?? key;
|
|
251
|
+
markers.push({ id: String(idValue), x, y });
|
|
252
|
+
}
|
|
253
|
+
return markers;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function formatPercent(value) {
|
|
257
|
+
return `${(value * 100).toFixed(1)}%`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function errorText(error) {
|
|
261
|
+
if (typeof error === "string") return error;
|
|
262
|
+
if (error && typeof error.message === "string") return `${typeof error.code === "string" ? `${error.code}: ` : ""}${error.message}`;
|
|
263
|
+
return "未知错误";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function sameRoadGeneration(left, right) {
|
|
267
|
+
if (!left || !right) return false;
|
|
268
|
+
return Object.keys(DEFAULT_ROAD_GENERATION).every((key) => left[key] === right[key]);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function checkedFromForm(form, name) {
|
|
272
|
+
const control = form.elements.namedItem(name);
|
|
273
|
+
if (!(control instanceof HTMLInputElement) || control.type !== "checkbox") throw new TypeError(`缺少 ${name} 控件`);
|
|
274
|
+
return control.checked;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function roadGenerationFromForm(form, identity = ROAD_GENERATION_IDENTITY) {
|
|
278
|
+
return validateRoadGeneration({
|
|
279
|
+
profile: ROAD_GENERATION_PROFILE,
|
|
280
|
+
identity,
|
|
281
|
+
allowBridges: checkedFromForm(form, "allowBridges"),
|
|
282
|
+
allowTunnels: checkedFromForm(form, "allowTunnels"),
|
|
283
|
+
maxBridgeLengthMeters: numberFromForm(form, "maxBridgeLengthMeters"),
|
|
284
|
+
maxTunnelLengthMeters: numberFromForm(form, "maxTunnelLengthMeters"),
|
|
285
|
+
maxGrade: numberFromForm(form, "maxGradePercent") / 100,
|
|
286
|
+
roadWidthMeters: numberFromForm(form, "roadWidthMeters"),
|
|
287
|
+
elevationScaleMeters: numberFromForm(form, "elevationScaleMeters"),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function setFormRoadGeneration(form, generation) {
|
|
292
|
+
const values = {
|
|
293
|
+
allowBridges: generation.allowBridges,
|
|
294
|
+
allowTunnels: generation.allowTunnels,
|
|
295
|
+
maxBridgeLengthMeters: generation.maxBridgeLengthMeters,
|
|
296
|
+
maxTunnelLengthMeters: generation.maxTunnelLengthMeters,
|
|
297
|
+
maxGradePercent: generation.maxGrade * 100,
|
|
298
|
+
roadWidthMeters: generation.roadWidthMeters,
|
|
299
|
+
elevationScaleMeters: generation.elevationScaleMeters,
|
|
300
|
+
};
|
|
301
|
+
for (const [name, value] of Object.entries(values)) {
|
|
302
|
+
const control = form.elements.namedItem(name);
|
|
303
|
+
if (control instanceof HTMLInputElement && control.type === "checkbox") control.checked = value === true;
|
|
304
|
+
else if (control instanceof HTMLInputElement || control instanceof HTMLSelectElement) control.value = String(value);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function point3(value, label) {
|
|
309
|
+
if (!value || typeof value !== "object") throw new TypeError(`${label} 点无效`);
|
|
310
|
+
const point = {};
|
|
311
|
+
for (const key of ["x", "y", "z"]) point[key] = numberInRange(value[key], `${label}.${key}`, -1_000_000_000, 1_000_000_000);
|
|
312
|
+
return point;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function validateRoadParcelTerrain(value, label) {
|
|
316
|
+
if (!value || typeof value !== "object") throw new TypeError(`${label} 缺少 terrain`);
|
|
317
|
+
const width = numberInRange(value.width, `${label}.terrain.width`, 2, 4096, true);
|
|
318
|
+
const height = numberInRange(value.height, `${label}.terrain.height`, 2, 4096, true);
|
|
319
|
+
const size = width * height;
|
|
320
|
+
const widthMeters = numberInRange(value.widthMeters, `${label}.terrain.widthMeters`, Number.EPSILON, 1_000_000_000);
|
|
321
|
+
const heightMeters = numberInRange(value.heightMeters, `${label}.terrain.heightMeters`, Number.EPSILON, 1_000_000_000);
|
|
322
|
+
const originX = numberInRange(value.originX, `${label}.terrain.originX`, -1_000_000_000, 1_000_000_000);
|
|
323
|
+
const originY = numberInRange(value.originY, `${label}.terrain.originY`, -1_000_000_000, 1_000_000_000);
|
|
324
|
+
if (!Array.isArray(value.elevationsMeters) || value.elevationsMeters.length !== size || value.elevationsMeters.some((entry) => typeof entry !== "number" || !Number.isFinite(entry))) {
|
|
325
|
+
throw new TypeError(`${label}.terrain.elevationsMeters 无效`);
|
|
326
|
+
}
|
|
327
|
+
if (!Array.isArray(value.waterMask) || value.waterMask.length !== size || value.waterMask.some((entry) => entry !== true && entry !== false && entry !== 0 && entry !== 1)) {
|
|
328
|
+
throw new TypeError(`${label}.terrain.waterMask 无效`);
|
|
329
|
+
}
|
|
330
|
+
return { width, height, widthMeters, heightMeters, originX, originY, elevationsMeters: [...value.elevationsMeters], waterMask: [...value.waterMask] };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 道路结果在渲染前收紧为唯一的 terrain-roads-v1 形状,杜绝旧正交道路伪装成成功结果。 */
|
|
334
|
+
export function validateTerrainRoadResult(value, expectedCityIds = []) {
|
|
335
|
+
if (!value || typeof value !== "object" || value.profile !== "terrain-roads-v1" || !Array.isArray(value.roads)
|
|
336
|
+
|| !Array.isArray(value.unreachable) || !Array.isArray(value.cityLayouts)) throw new TypeError("WASM 返回了无效地形路网");
|
|
337
|
+
const roads = value.roads.map((road, index) => {
|
|
338
|
+
if (!road || typeof road !== "object" || typeof road.id !== "string" || !road.id || !["intercity", "arterial", "local"].includes(road.kind)
|
|
339
|
+
|| !["motorway", "street"].includes(road.roadClass) || (road.kind === "intercity" ? road.roadClass !== "motorway" : road.roadClass !== "street")
|
|
340
|
+
|| !Array.isArray(road.points) || road.points.length < 2 || !Array.isArray(road.segments) || !road.segments.length) throw new TypeError(`道路 ${index} 无效`);
|
|
341
|
+
const points = road.points.map((point, pointIndex) => point3(point, `roads[${index}].points[${pointIndex}]`));
|
|
342
|
+
const segments = road.segments.map((segment, segmentIndex) => {
|
|
343
|
+
if (!segment || typeof segment !== "object" || !["surface", "bridge", "tunnel"].includes(segment.kind)
|
|
344
|
+
|| !Array.isArray(segment.points) || segment.points.length < 2) throw new TypeError(`道路 ${index} 路段 ${segmentIndex} 无效`);
|
|
345
|
+
return { kind: segment.kind, points: segment.points.map((point, pointIndex) => point3(point, `roads[${index}].segments[${segmentIndex}].points[${pointIndex}]`)), lengthMeters: numberInRange(segment.lengthMeters, "道路长度", 0, 1_000_000_000) };
|
|
346
|
+
});
|
|
347
|
+
return { id: road.id, kind: road.kind, roadClass: road.roadClass, cityId: typeof road.cityId === "string" ? road.cityId : undefined, fromCityId: typeof road.fromCityId === "string" ? road.fromCityId : undefined, toCityId: typeof road.toCityId === "string" ? road.toCityId : undefined, widthMeters: numberInRange(road.widthMeters, "道路宽度", 0.1, 10_000), points, segments };
|
|
348
|
+
});
|
|
349
|
+
const unreachable = value.unreachable.map((entry, index) => {
|
|
350
|
+
if (!entry || typeof entry !== "object" || typeof entry.fromId !== "string" || typeof entry.toId !== "string" || typeof entry.reason !== "string") throw new TypeError(`不可达道路 ${index} 无效`);
|
|
351
|
+
return { fromId: entry.fromId, toId: entry.toId, reason: entry.reason };
|
|
352
|
+
});
|
|
353
|
+
const cityLayoutIds = new Set();
|
|
354
|
+
const cityLayouts = value.cityLayouts.map((layout, index) => {
|
|
355
|
+
if (!layout || typeof layout !== "object" || typeof layout.cityId !== "string" || !layout.cityId || !Array.isArray(layout.roads)
|
|
356
|
+
|| layout.roads.some((roadId) => typeof roadId !== "string")) throw new TypeError(`城市道路布局 ${index} 无效`);
|
|
357
|
+
if (cityLayoutIds.has(layout.cityId)) throw new TypeError(`城市道路布局 ${index} 重复`);
|
|
358
|
+
cityLayoutIds.add(layout.cityId);
|
|
359
|
+
const boundaryValue = layout.boundary;
|
|
360
|
+
if (!boundaryValue || typeof boundaryValue !== "object" || Array.isArray(boundaryValue)
|
|
361
|
+
|| Object.keys(boundaryValue).sort().join(",") !== "cityId,points,status" || !["bounded", "unbuildable"].includes(boundaryValue.status)
|
|
362
|
+
|| boundaryValue.cityId !== layout.cityId || !Array.isArray(boundaryValue.points)) throw new TypeError(`城市道路布局 ${index} 边界无效`);
|
|
363
|
+
const points = boundaryValue.points.map((point, pointIndex) => {
|
|
364
|
+
if (!point || typeof point !== "object") throw new TypeError(`城市道路布局 ${index} 边界点 ${pointIndex} 无效`);
|
|
365
|
+
return { x: numberInRange(point.x, "城市边界.x", -1_000_000_000, 1_000_000_000), y: numberInRange(point.y, "城市边界.y", -1_000_000_000, 1_000_000_000) };
|
|
366
|
+
});
|
|
367
|
+
if (boundaryValue.status === "unbuildable" && (points.length !== 0 || layout.roads.length !== 0)) throw new TypeError(`城市道路布局 ${index} 不可建边界无效`);
|
|
368
|
+
if (boundaryValue.status === "bounded") {
|
|
369
|
+
if (points.length < 3 || points.some((point, pointIndex) => pointIndex > 0 && point.x === points[pointIndex - 1].x && point.y === points[pointIndex - 1].y)) throw new TypeError(`城市道路布局 ${index} 有界点无效`);
|
|
370
|
+
if (points[0].x === points.at(-1).x && points[0].y === points.at(-1).y) throw new TypeError(`城市道路布局 ${index} 边界不得闭合重复点`);
|
|
371
|
+
const twiceArea = points.reduce((sum, point, pointIndex) => { const next = points[(pointIndex + 1) % points.length]; return sum + point.x * next.y - next.x * point.y; }, 0);
|
|
372
|
+
if (!(twiceArea > 0)) throw new TypeError(`城市道路布局 ${index} 边界必须逆时针`);
|
|
373
|
+
}
|
|
374
|
+
return { cityId: layout.cityId, roads: [...layout.roads], boundary: { status: boundaryValue.status, cityId: layout.cityId, points }, terrain: validateRoadParcelTerrain(layout.terrain, `城市道路布局 ${index}`) };
|
|
375
|
+
});
|
|
376
|
+
for (const cityId of expectedCityIds) if (!cityLayoutIds.has(cityId)) throw new TypeError(`道路结果缺少城市 ${cityId} 的 terrain 布局`);
|
|
377
|
+
return { profile: value.profile, roads, unreachable, cityLayouts };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** 供 Canvas 与测试共用:overview 只画城际路,靠近城市后再画 arterial/local。 */
|
|
381
|
+
export function roadVisibleAtMetersPerPixel(road, metersPerPixel) {
|
|
382
|
+
return road.kind === "intercity" || Number(metersPerPixel) <= (road.kind === "arterial" ? 140 : 38);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function mountWorldMap(root, options) {
|
|
386
|
+
if (!(root instanceof HTMLElement)) throw new TypeError("world map root 必须是 HTMLElement");
|
|
387
|
+
if (!options || typeof options.read !== "function" || typeof options.save !== "function" || typeof options.notify !== "function") {
|
|
388
|
+
throw new TypeError("world map 需要 read、save 与 notify 回调");
|
|
389
|
+
}
|
|
390
|
+
let cityTemplates = [];
|
|
391
|
+
function normalizeCityTemplates(records) {
|
|
392
|
+
return Array.isArray(records) ? records.filter((template) => template && typeof template.id === "string" && template.id
|
|
393
|
+
&& typeof template.label === "string" && template.label && template.parameters && typeof template.parameters === "object") : [];
|
|
394
|
+
}
|
|
395
|
+
cityTemplates = normalizeCityTemplates(options.cityTemplates);
|
|
396
|
+
|
|
397
|
+
const shell = element("section", "world-map-shell");
|
|
398
|
+
shell.hidden = true;
|
|
399
|
+
shell.setAttribute("aria-label", "世界地图编辑器");
|
|
400
|
+
const header = element("header", "world-map-header");
|
|
401
|
+
const heading = element("div", "world-map-heading");
|
|
402
|
+
const title = element("h1", "", "世界地图");
|
|
403
|
+
const status = element("p", "world-map-status", "尚未打开");
|
|
404
|
+
status.setAttribute("role", "status");
|
|
405
|
+
status.setAttribute("aria-live", "polite");
|
|
406
|
+
heading.append(title, status);
|
|
407
|
+
const actions = element("div", "world-map-header-actions");
|
|
408
|
+
const settingsButton = iconButton("settings", "地图设置");
|
|
409
|
+
settingsButton.dataset.action = "edit-parameters";
|
|
410
|
+
settingsButton.setAttribute("aria-expanded", "false");
|
|
411
|
+
const generateButton = element("button", "button ghost compact", "下一步:生成预览");
|
|
412
|
+
generateButton.type = "button";
|
|
413
|
+
generateButton.dataset.action = "generate-preview";
|
|
414
|
+
const adjustButton = element("button", "button ghost compact", "返回调整参数");
|
|
415
|
+
adjustButton.type = "button";
|
|
416
|
+
adjustButton.dataset.action = "adjust-parameters";
|
|
417
|
+
adjustButton.hidden = true;
|
|
418
|
+
const saveButton = element("button", "button primary compact", "确认并保存");
|
|
419
|
+
saveButton.type = "button";
|
|
420
|
+
saveButton.dataset.action = "confirm-save";
|
|
421
|
+
saveButton.disabled = true;
|
|
422
|
+
actions.append(settingsButton, adjustButton, generateButton, saveButton);
|
|
423
|
+
header.append(heading, actions);
|
|
424
|
+
|
|
425
|
+
const stage = element("div", "world-map-stage");
|
|
426
|
+
const phaseBadge = element("div", "world-map-phase", "阶段 1/2 · 参数");
|
|
427
|
+
phaseBadge.setAttribute("aria-hidden", "true");
|
|
428
|
+
const canvas = document.createElement("canvas");
|
|
429
|
+
canvas.className = "world-map-canvas";
|
|
430
|
+
canvas.tabIndex = 0;
|
|
431
|
+
canvas.setAttribute("role", "img");
|
|
432
|
+
canvas.setAttribute("aria-label", "生成的世界地图,可拖动画布并使用滚轮缩放");
|
|
433
|
+
const overlay = element("div", "world-map-overlay", "正在读取地图…");
|
|
434
|
+
overlay.setAttribute("role", "status");
|
|
435
|
+
const previewWatermark = element("div", "world-map-preview-watermark");
|
|
436
|
+
previewWatermark.hidden = true;
|
|
437
|
+
const controls = element("div", "world-map-controls");
|
|
438
|
+
const zoomIn = iconButton("plus", "放大地图");
|
|
439
|
+
const zoomOut = iconButton("minus", "缩小地图");
|
|
440
|
+
const fit = iconButton("fit", "适合画布");
|
|
441
|
+
controls.append(zoomIn, zoomOut, fit);
|
|
442
|
+
const detailStatus = element("output", "world-map-detail-status", "细节待命");
|
|
443
|
+
detailStatus.setAttribute("aria-label", "地图镜头与细节状态");
|
|
444
|
+
|
|
445
|
+
const settingsPanel = element("section", "world-map-settings");
|
|
446
|
+
settingsPanel.hidden = true;
|
|
447
|
+
settingsPanel.setAttribute("aria-label", "地图生成设置");
|
|
448
|
+
const panelHeading = element("div", "world-map-settings-heading");
|
|
449
|
+
const panelHint = element("small", "", "下一步生成预览");
|
|
450
|
+
panelHeading.append(element("h2", "", "地图参数"), panelHint);
|
|
451
|
+
const wizardProgress = element("div", "world-map-wizard-progress");
|
|
452
|
+
const parameterStep = element("span", "is-active", "1 参数");
|
|
453
|
+
parameterStep.dataset.step = "parameters";
|
|
454
|
+
const previewStep = element("span", "", "2 预览并保存");
|
|
455
|
+
previewStep.dataset.step = "preview";
|
|
456
|
+
wizardProgress.append(parameterStep, previewStep);
|
|
457
|
+
const form = element("form", "world-map-form");
|
|
458
|
+
form.append(
|
|
459
|
+
settingField("种子", "seed", { min: 0, max: 0xffffffff, step: 1 }),
|
|
460
|
+
settingField("东西长度", "widthKm", { min: 0.001, max: PLANE_GENERATION_MAX_EXTENT_METERS / 1000, step: 0.001, hint: "km(最小 1 m)" }),
|
|
461
|
+
settingField("南北长度", "heightKm", { min: 0.001, max: PLANE_GENERATION_MAX_EXTENT_METERS / 1000, step: 0.001, hint: "km(最小 1 m)" }),
|
|
462
|
+
settingField("分辨率", "resolution", { values: [128, 256, 512] }),
|
|
463
|
+
settingField("水域覆盖", "waterCoverage", { min: 0, max: 0.95, step: 0.01 }),
|
|
464
|
+
settingField("地形起伏", "relief", { min: 0, max: 1, step: 0.01 }),
|
|
465
|
+
settingField("河流密度", "riverDensity", { min: 0, max: 1, step: 0.01 }),
|
|
466
|
+
settingField("湖泊密度", "lakeDensity", { min: 0, max: 0.25, step: 0.01 }),
|
|
467
|
+
settingField("降雨量", "rainfall", { min: 0.1, max: 8, step: 0.1 }),
|
|
468
|
+
);
|
|
469
|
+
form.addEventListener("submit", (event) => event.preventDefault());
|
|
470
|
+
const layerHeading = element("h3", "", "显示图层");
|
|
471
|
+
const layers = element("div", "world-map-layers");
|
|
472
|
+
layers.append(layerToggle("showRelief", "高地阴影"), layerToggle("showContours", "等高线", DEFAULT_TERRAIN_LAYERS.showContours), layerToggle("showRivers", "河流"), layerToggle("showLakes", "湖泊"), layerToggle("showRoads", "地形路网"), layerToggle("showCities", "程序化城市"), layerToggle("showObjects", "世界对象"));
|
|
473
|
+
const roadHeading = element("h3", "", "地形路网");
|
|
474
|
+
const roadHint = element("p", "world-map-road-hint", "道路仅为预览;保存前不会改写世界 source。");
|
|
475
|
+
const roadForm = element("form", "world-map-road-form");
|
|
476
|
+
roadForm.append(
|
|
477
|
+
layerToggle("allowBridges", "允许架桥", DEFAULT_ROAD_GENERATION.allowBridges),
|
|
478
|
+
layerToggle("allowTunnels", "允许隧道", DEFAULT_ROAD_GENERATION.allowTunnels),
|
|
479
|
+
settingField("最大单座桥长度", "maxBridgeLengthMeters", { min: 1, max: 200000, step: 1, hint: "m" }),
|
|
480
|
+
settingField("最大单隧道长度", "maxTunnelLengthMeters", { min: 1, max: 200000, step: 1, hint: "m" }),
|
|
481
|
+
settingField("最大坡度", "maxGradePercent", { min: 0.1, max: 100, step: 0.1, hint: "%" }),
|
|
482
|
+
settingField("道路宽度", "roadWidthMeters", { min: 3, max: 40, step: 0.5, hint: "m" }),
|
|
483
|
+
settingField("高程米制尺度", "elevationScaleMeters", { min: 1, max: 100000, step: 1, hint: "m" }),
|
|
484
|
+
);
|
|
485
|
+
setFormRoadGeneration(roadForm, DEFAULT_ROAD_GENERATION);
|
|
486
|
+
roadForm.addEventListener("submit", (event) => event.preventDefault());
|
|
487
|
+
const roadActions = element("div", "world-map-road-actions");
|
|
488
|
+
const previewRoadsButton = element("button", "button ghost compact", "预览地形路网"); previewRoadsButton.type = "button"; previewRoadsButton.dataset.action = "preview-roads";
|
|
489
|
+
const previewRoadUpgradeButton = element("button", "button ghost compact", "使用新版规则预览"); previewRoadUpgradeButton.type = "button"; previewRoadUpgradeButton.dataset.action = "preview-road-upgrade";
|
|
490
|
+
const saveRoadsButton = element("button", "button ghost compact", "保存道路参数"); saveRoadsButton.type = "button"; saveRoadsButton.dataset.action = "save-roads";
|
|
491
|
+
roadActions.append(previewRoadsButton, previewRoadUpgradeButton, saveRoadsButton);
|
|
492
|
+
const roadStatus = element("p", "world-map-road-status", "等待地图和城市…"); roadStatus.setAttribute("role", "status");
|
|
493
|
+
const roadParcelError = element("details", "world-map-road-parcel-error");
|
|
494
|
+
const roadParcelErrorSummary = element("summary", "", "地块生成失败,请调整参数/重试");
|
|
495
|
+
const roadParcelErrorDetail = element("pre", "", "");
|
|
496
|
+
roadParcelError.append(roadParcelErrorSummary, roadParcelErrorDetail);
|
|
497
|
+
roadParcelError.hidden = true;
|
|
498
|
+
const detailHeading = element("h3", "", "镜头细节");
|
|
499
|
+
const detailControls = element("div", "world-map-detail-controls");
|
|
500
|
+
const densityLabel = element("label", "world-map-field");
|
|
501
|
+
const densityLabelRow = element("span", "world-map-field-label");
|
|
502
|
+
densityLabelRow.append(element("span", "", "细节密度"), element("small", "", "带滞回切换"));
|
|
503
|
+
const densitySelect = document.createElement("select");
|
|
504
|
+
densitySelect.name = "detailDensity";
|
|
505
|
+
densitySelect.setAttribute("aria-label", "地图细节密度");
|
|
506
|
+
for (const [value, text] of [["low", "低"], ["normal", "标准"], ["high", "高"]]) {
|
|
507
|
+
const option = element("option", "", text); option.value = value; densitySelect.append(option);
|
|
508
|
+
}
|
|
509
|
+
densitySelect.value = "normal";
|
|
510
|
+
densityLabel.append(densityLabelRow, densitySelect);
|
|
511
|
+
const cameraField = element("div", "world-map-camera-field");
|
|
512
|
+
cameraField.append(element("span", "", "镜头比例"));
|
|
513
|
+
const cameraMpp = element("output", "", "— m/px");
|
|
514
|
+
cameraMpp.setAttribute("aria-label", "镜头米每像素");
|
|
515
|
+
cameraField.append(cameraMpp);
|
|
516
|
+
const roadThresholdLabel = settingField("道路显示半径", "roadThreshold", { min: 40, max: 200, step: 5, hint: "px" });
|
|
517
|
+
const parcelThresholdLabel = settingField("地块显示尺度", "parcelThreshold", { min: 6, max: 40, step: 1, hint: "px" });
|
|
518
|
+
const roadThresholdInput = roadThresholdLabel.querySelector("input");
|
|
519
|
+
const parcelThresholdInput = parcelThresholdLabel.querySelector("input");
|
|
520
|
+
roadThresholdInput.value = "80";
|
|
521
|
+
parcelThresholdInput.value = "12";
|
|
522
|
+
detailControls.append(densityLabel, cameraField, roadThresholdLabel, parcelThresholdLabel);
|
|
523
|
+
const actorBinding = element("section", "world-map-actor-binding");
|
|
524
|
+
actorBinding.hidden = typeof options.setControlledActor !== "function";
|
|
525
|
+
const actorHeading = element("h3", "", "Player 受控角色");
|
|
526
|
+
const actorRow = element("div", "world-map-actor-row");
|
|
527
|
+
const actorSelect = document.createElement("select"); actorSelect.setAttribute("aria-label", "受控角色");
|
|
528
|
+
const saveActorButton = element("button", "button ghost compact", "保存角色绑定"); saveActorButton.type = "button";
|
|
529
|
+
const actorStatus = element("p", "world-map-actor-status", ""); actorStatus.setAttribute("role", "status");
|
|
530
|
+
actorRow.append(actorSelect, saveActorButton); actorBinding.append(actorHeading, actorRow, actorStatus);
|
|
531
|
+
settingsPanel.append(panelHeading, wizardProgress, form, layerHeading, layers, roadHeading, roadHint, roadForm, roadActions, roadStatus, roadParcelError, detailHeading, detailControls, actorBinding);
|
|
532
|
+
|
|
533
|
+
const createHint = element("div", "world-map-create-hint", "右键地图新建城市");
|
|
534
|
+
createHint.hidden = typeof options.createCity !== "function";
|
|
535
|
+
const cityDialog = document.createElement("dialog");
|
|
536
|
+
cityDialog.className = "world-map-city-dialog";
|
|
537
|
+
cityDialog.setAttribute("aria-label", "新建城市");
|
|
538
|
+
const cityForm = element("form", "world-map-city-form");
|
|
539
|
+
cityForm.append(element("h2", "", "新建程序化城市"));
|
|
540
|
+
const cityTemplateLabel = element("label", "world-map-field");
|
|
541
|
+
cityTemplateLabel.append(element("span", "world-map-field-label", "城市模板"));
|
|
542
|
+
const cityTemplate = document.createElement("select"); cityTemplate.name = "cityTemplate"; cityTemplate.setAttribute("aria-label", "城市模板");
|
|
543
|
+
const customTemplate = element("option", "", "自定义"); customTemplate.value = ""; cityTemplate.append(customTemplate);
|
|
544
|
+
for (const template of cityTemplates) { const option = element("option", "", template.label); option.value = template.id; cityTemplate.append(option); }
|
|
545
|
+
cityTemplateLabel.append(cityTemplate);
|
|
546
|
+
const cityGrid = element("div", "world-map-city-grid");
|
|
547
|
+
const citySizeLabel = element("label", "world-map-field");
|
|
548
|
+
citySizeLabel.append(element("span", "world-map-field-label", "城市规模"));
|
|
549
|
+
const citySize = document.createElement("select"); citySize.name = "citySize"; citySize.setAttribute("aria-label", "城市规模");
|
|
550
|
+
const customSize = element("option", "", "自定义"); customSize.value = ""; citySize.append(customSize);
|
|
551
|
+
for (const preset of CITY_SIZE_PRESETS) { const option = element("option", "", `${preset.label} · 约半径 ${preset.radiusMeters.toLocaleString()} m`); option.value = preset.id; citySize.append(option); }
|
|
552
|
+
citySizeLabel.append(citySize);
|
|
553
|
+
const cityNameLabel = element("label", "world-map-field");
|
|
554
|
+
cityNameLabel.append(element("span", "world-map-field-label", "名称"));
|
|
555
|
+
const cityName = document.createElement("input"); cityName.name = "cityName"; cityName.required = true; cityName.maxLength = 128; cityNameLabel.append(cityName);
|
|
556
|
+
cityGrid.append(
|
|
557
|
+
cityNameLabel,
|
|
558
|
+
citySizeLabel,
|
|
559
|
+
settingField("半径", "cityRadius", { min: 32, max: 200000, step: 1, hint: "m" }),
|
|
560
|
+
settingField("街区尺度", "cityBlockSize", { min: 16, max: 2000, step: 1, hint: "m" }),
|
|
561
|
+
settingField("道路宽度", "cityRoadWidth", { min: 3, max: 40, step: .5, hint: "m" }),
|
|
562
|
+
);
|
|
563
|
+
const useHeading = element("h3", "", "用途权重");
|
|
564
|
+
const useGrid = element("div", "world-map-city-grid land-use");
|
|
565
|
+
for (const [name, label] of [["residential", "居住"], ["commercial", "商业"], ["industrial", "工业"], ["civic", "公共设施"], ["park", "公园"]]) {
|
|
566
|
+
useGrid.append(settingField(label, `cityUse_${name}`, { min: 0, max: 100, step: 1 }));
|
|
567
|
+
}
|
|
568
|
+
const cityDialogStatus = element("p", "world-map-city-dialog-status", "");
|
|
569
|
+
cityDialogStatus.setAttribute("role", "status");
|
|
570
|
+
const cityActions = element("div", "world-map-city-actions");
|
|
571
|
+
const cancelCity = element("button", "button ghost compact", "取消"); cancelCity.type = "button";
|
|
572
|
+
const submitCity = element("button", "button primary compact", "生成城市"); submitCity.type = "submit";
|
|
573
|
+
cityActions.append(cancelCity, submitCity);
|
|
574
|
+
cityForm.append(cityTemplateLabel, cityGrid, useHeading, useGrid, cityDialogStatus, cityActions);
|
|
575
|
+
cityDialog.append(cityForm);
|
|
576
|
+
|
|
577
|
+
const stats = element("dl", "world-map-stats");
|
|
578
|
+
const statNodes = {};
|
|
579
|
+
for (const [key, label] of [["size", "范围"], ["grid", "网格"], ["water", "水域"], ["relief", "相对高程"], ["rivers", "河网"], ["objects", "对象"]]) {
|
|
580
|
+
const item = element("div", "world-map-stat");
|
|
581
|
+
const value = element("dd", "", "—");
|
|
582
|
+
item.append(element("dt", "", label), value);
|
|
583
|
+
stats.append(item);
|
|
584
|
+
statNodes[key] = value;
|
|
585
|
+
}
|
|
586
|
+
const legend = element("div", "world-map-legend");
|
|
587
|
+
for (const [className, text] of [["sea", "海洋"], ["land", "陆地"], ["high", "高地"], ["river", "河流"], ["lake", "湖泊"], ["object", "对象"]]) {
|
|
588
|
+
const item = element("span", "");
|
|
589
|
+
item.append(element("i", className), document.createTextNode(text));
|
|
590
|
+
legend.append(item);
|
|
591
|
+
}
|
|
592
|
+
const cityLegend = element("div", "world-map-city-legend");
|
|
593
|
+
cityLegend.hidden = true;
|
|
594
|
+
cityLegend.setAttribute("aria-label", "城市用途图例");
|
|
595
|
+
for (const [landUse, entry] of Object.entries(PALETTE)) {
|
|
596
|
+
const item = element("span", "");
|
|
597
|
+
const swatch = element("i", ""); swatch.style.backgroundColor = entry.color; swatch.dataset.landUse = landUse;
|
|
598
|
+
item.append(swatch, document.createTextNode(entry.label)); cityLegend.append(item);
|
|
599
|
+
}
|
|
600
|
+
const roadLegend = element("div", "world-map-road-legend");
|
|
601
|
+
roadLegend.hidden = true;
|
|
602
|
+
roadLegend.setAttribute("aria-label", "地形路网图例");
|
|
603
|
+
for (const [className, text] of [["intercity", "高速公路"], ["arterial", "城市干道"], ["local", "城市支路"], ["bridge", "桥梁"], ["tunnel", "隧道"]]) {
|
|
604
|
+
const item = element("span", ""); item.append(element("i", className), document.createTextNode(text)); roadLegend.append(item);
|
|
605
|
+
}
|
|
606
|
+
stage.append(canvas, phaseBadge, overlay, previewWatermark, controls, detailStatus, createHint, settingsPanel, stats, legend, cityLegend, roadLegend, cityDialog);
|
|
607
|
+
shell.append(header, stage);
|
|
608
|
+
root.replaceChildren(shell);
|
|
609
|
+
|
|
610
|
+
const state = {
|
|
611
|
+
open: false,
|
|
612
|
+
destroyed: false,
|
|
613
|
+
epoch: 0,
|
|
614
|
+
requestId: 0,
|
|
615
|
+
tileRequestId: 0,
|
|
616
|
+
worker: null,
|
|
617
|
+
snapshot: null,
|
|
618
|
+
savedGeneration: null,
|
|
619
|
+
previewGeneration: null,
|
|
620
|
+
result: null,
|
|
621
|
+
baseCanvas: null,
|
|
622
|
+
baseRenderStats: null,
|
|
623
|
+
tileWorker: null,
|
|
624
|
+
tileQueue: [],
|
|
625
|
+
tileInFlight: new Map(),
|
|
626
|
+
tileCache: new Map(),
|
|
627
|
+
tileCacheBytes: 0,
|
|
628
|
+
tileRequestTimer: null,
|
|
629
|
+
tileEpoch: 0,
|
|
630
|
+
lodLevel: 0,
|
|
631
|
+
lodLevelInitialized: false,
|
|
632
|
+
lodLoading: false,
|
|
633
|
+
lodError: null,
|
|
634
|
+
visibleTiles: [],
|
|
635
|
+
metersPerPixel: null,
|
|
636
|
+
desiredTileSignature: "",
|
|
637
|
+
fadeFrame: null,
|
|
638
|
+
cityModule: PROCEDURAL_CITY,
|
|
639
|
+
cityWorld: null,
|
|
640
|
+
cities: [],
|
|
641
|
+
cityLayouts: new Map(),
|
|
642
|
+
cityLayoutBytes: 0,
|
|
643
|
+
cityModes: new Map(),
|
|
644
|
+
roadParcelLayouts: new Map(),
|
|
645
|
+
roadParcelTemplateKey: "parcel-template:absent",
|
|
646
|
+
roadParcelError: null,
|
|
647
|
+
roadWorker: null,
|
|
648
|
+
roadRequestId: 0,
|
|
649
|
+
roadPreviewTimer: null,
|
|
650
|
+
roadResult: null,
|
|
651
|
+
roadRequestedKey: "",
|
|
652
|
+
savedRoadGeneration: null,
|
|
653
|
+
previewRoadGeneration: null,
|
|
654
|
+
// 已保存 source 的 identity 与表单参数分开保存;只有显式新版规则预览才可切换草稿 identity。
|
|
655
|
+
roadDraftIdentity: ROAD_GENERATION_IDENTITY,
|
|
656
|
+
roadBusy: false,
|
|
657
|
+
roadSaving: false,
|
|
658
|
+
roadSaveToken: 0,
|
|
659
|
+
roadAutoPreviewEnabled: false,
|
|
660
|
+
roadError: null,
|
|
661
|
+
cityTemplateDefaultId: "",
|
|
662
|
+
cityPoint: null,
|
|
663
|
+
creatingCity: false,
|
|
664
|
+
savingActor: false,
|
|
665
|
+
markers: [],
|
|
666
|
+
loading: false,
|
|
667
|
+
busy: false,
|
|
668
|
+
saving: false,
|
|
669
|
+
saveToken: 0,
|
|
670
|
+
requestedGeneration: null,
|
|
671
|
+
autoPreviewEnabled: false,
|
|
672
|
+
previewTimer: null,
|
|
673
|
+
errorKind: null,
|
|
674
|
+
view: { scale: 1, x: 0, y: 0 },
|
|
675
|
+
fitted: false,
|
|
676
|
+
drag: null,
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
function notify(text, isError = false) {
|
|
680
|
+
options.notify(String(text), Boolean(isError));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function setOverlay(text, error = false, actionLabel, action) {
|
|
684
|
+
const statusView = Boolean(text && (error || text.startsWith("正在")));
|
|
685
|
+
overlay.classList.toggle("is-workspace-status", statusView);
|
|
686
|
+
if (statusView) {
|
|
687
|
+
const view = renderWorkspaceStatus(overlay, {
|
|
688
|
+
state: error ? "error" : "loading", surface: "map", error: text,
|
|
689
|
+
onRetry: action, retryLabel: actionLabel,
|
|
690
|
+
});
|
|
691
|
+
const retryButton = view.querySelector("button");
|
|
692
|
+
if (retryButton) retryButton.dataset.action = `retry-${state.errorKind ?? "operation"}`;
|
|
693
|
+
overlay.hidden = false;
|
|
694
|
+
overlay.classList.toggle("error", error);
|
|
695
|
+
overlay.classList.toggle("has-action", Boolean(retryButton));
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
overlay.replaceChildren();
|
|
699
|
+
if (text) overlay.append(element("p", "", text));
|
|
700
|
+
if (text && actionLabel && typeof action === "function") {
|
|
701
|
+
const button = element("button", "button ghost compact", actionLabel);
|
|
702
|
+
button.type = "button";
|
|
703
|
+
button.dataset.action = error ? `retry-${state.errorKind ?? "operation"}` : "generate-preview-empty";
|
|
704
|
+
button.addEventListener("click", action);
|
|
705
|
+
overlay.append(button);
|
|
706
|
+
}
|
|
707
|
+
overlay.hidden = !text;
|
|
708
|
+
overlay.classList.toggle("error", error);
|
|
709
|
+
overlay.classList.toggle("has-action", Boolean(text && actionLabel && typeof action === "function"));
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function draftGeneration() {
|
|
713
|
+
try { return generationFromForm(form); } catch { return null; }
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function updateStatus() {
|
|
717
|
+
const draft = draftGeneration();
|
|
718
|
+
const previewCurrent = draft && state.previewGeneration && sameGeneration(draft, state.previewGeneration);
|
|
719
|
+
const saved = previewCurrent && state.savedGeneration && sameGeneration(state.previewGeneration, state.savedGeneration);
|
|
720
|
+
const phase = previewCurrent && state.result ? "preview" : "parameters";
|
|
721
|
+
shell.dataset.phase = phase;
|
|
722
|
+
shell.dataset.state = state.loading ? "loading" : state.errorKind ? `${state.errorKind}-error` : state.saving ? "saving" : state.busy ? "generating" : saved ? "saved" : previewCurrent ? "preview-ready" : "parameters";
|
|
723
|
+
phaseBadge.textContent = phase === "preview" ? "阶段 2/2 · 预览" : "阶段 1/2 · 参数";
|
|
724
|
+
parameterStep.classList.toggle("is-active", phase === "parameters");
|
|
725
|
+
parameterStep.classList.toggle("is-complete", phase === "preview");
|
|
726
|
+
previewStep.classList.toggle("is-active", phase === "preview");
|
|
727
|
+
panelHint.textContent = phase === "preview" ? "可确认保存或返回调整" : "下一步生成预览";
|
|
728
|
+
if (state.loading) status.textContent = "正在读取项目地图…";
|
|
729
|
+
else if (state.saving) status.textContent = "正在保存…";
|
|
730
|
+
else if (state.busy) status.textContent = "正在运行 WASM 水文生成器…";
|
|
731
|
+
else if (state.errorKind === "read") status.textContent = "地图读取失败 · 可重试";
|
|
732
|
+
else if (state.errorKind === "generate") status.textContent = "预览生成失败 · 参数已保留";
|
|
733
|
+
else if (state.errorKind === "save") status.textContent = "保存失败 · 预览仍保留";
|
|
734
|
+
else if (!draft) status.textContent = "设置无效";
|
|
735
|
+
else if (!previewCurrent) status.textContent = state.result ? "参数已调整 · 待重新生成" : "阶段 1 · 设置参数";
|
|
736
|
+
else if (saved) status.textContent = `已保存 · revision ${state.snapshot?.revision ?? "—"}`;
|
|
737
|
+
else status.textContent = "阶段 2 · 预览待确认";
|
|
738
|
+
generateButton.textContent = state.errorKind === "read" ? "重新读取" : state.busy ? "正在生成…" : state.result ? "重新生成预览" : "下一步:生成预览";
|
|
739
|
+
generateButton.disabled = state.loading || state.busy || state.saving || state.errorKind === "read" || !state.snapshot || !draft;
|
|
740
|
+
saveButton.disabled = state.loading || state.busy || state.saving || state.errorKind === "read" || !state.snapshot || !previewCurrent || Boolean(saved) || !state.result;
|
|
741
|
+
settingsButton.disabled = state.saving;
|
|
742
|
+
adjustButton.hidden = phase !== "preview";
|
|
743
|
+
adjustButton.disabled = state.busy || state.saving;
|
|
744
|
+
shell.classList.toggle("is-dirty", Boolean(previewCurrent && !saved));
|
|
745
|
+
shell.classList.toggle("has-map-preview", Boolean(state.baseCanvas));
|
|
746
|
+
previewWatermark.hidden = !state.result;
|
|
747
|
+
previewWatermark.textContent = previewCurrent ? (saved ? "已保存地图预览" : "未保存预览") : "上次预览 · 参数已调整";
|
|
748
|
+
const roadDraft = draftRoadGeneration();
|
|
749
|
+
const roadSaved = roadDraft && state.savedRoadGeneration && sameRoadGeneration(roadDraft, state.savedRoadGeneration);
|
|
750
|
+
const roadPreviewCurrent = roadDraft && state.previewRoadGeneration && sameRoadGeneration(roadDraft, state.previewRoadGeneration);
|
|
751
|
+
roadParcelError.hidden = !state.roadParcelError;
|
|
752
|
+
roadParcelErrorDetail.textContent = state.roadParcelError ?? "";
|
|
753
|
+
roadStatus.dataset.state = state.roadSaving ? "saving" : state.roadBusy ? "loading" : state.roadError ? "error" : state.roadResult ? "ready" : "idle";
|
|
754
|
+
if (!state.result) roadStatus.textContent = "先生成地形后才能预览道路。";
|
|
755
|
+
else if (!roadDraft) roadStatus.textContent = "道路参数无效。";
|
|
756
|
+
else if (state.roadSaving) roadStatus.textContent = "正在保存道路参数…";
|
|
757
|
+
else if (state.roadBusy) roadStatus.textContent = "正在生成地形路网…";
|
|
758
|
+
else if (state.roadError) roadStatus.textContent = `道路预览失败:${state.roadError}`;
|
|
759
|
+
else if (state.roadResult && !roadPreviewCurrent) roadStatus.textContent = "上次道路预览仍可见;参数已调整,正在等待新预览。";
|
|
760
|
+
else if (state.roadResult?.unreachable?.length) roadStatus.textContent = `已预览;${state.roadResult.unreachable.length} 条连接受坡度/桥隧限制不可达。${roadSaved ? "参数已保存。" : "参数尚未保存。"}`;
|
|
761
|
+
else if (state.roadResult) roadStatus.textContent = state.roadParcelError ? "地形路网预览可用;地块未生成。" : state.cities.length === 1 ? (roadSaved ? "单城地形路网已保存。" : "单城地形路网已预览,参数未保存。") : roadSaved ? "地形路网已保存。" : "地形路网预览可用,参数尚未保存。";
|
|
762
|
+
else if (!state.cities.length) roadStatus.textContent = "没有城市:路网预览为空;新增城市后会自动重算。";
|
|
763
|
+
else if (state.cities.length === 1) roadStatus.textContent = "单城地形路网待预览。";
|
|
764
|
+
const roadUpgradeTarget = state.savedRoadGeneration === null ? undefined : roadGenerationUpgradeTarget(state.savedRoadGeneration.identity);
|
|
765
|
+
previewRoadUpgradeButton.hidden = roadUpgradeTarget === undefined;
|
|
766
|
+
previewRoadUpgradeButton.disabled = roadUpgradeTarget === undefined || !state.result || state.roadBusy || state.roadSaving;
|
|
767
|
+
previewRoadsButton.disabled = !state.result || state.roadBusy || state.roadSaving || !roadDraft;
|
|
768
|
+
saveRoadsButton.disabled = !state.result || state.roadBusy || state.roadSaving || !roadDraft || !roadPreviewCurrent || roadSaved || typeof options.saveRoadGeneration !== "function";
|
|
769
|
+
roadLegend.hidden = !state.roadResult || !layerEnabled("showRoads");
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function terminateWorker() {
|
|
773
|
+
if (state.worker) state.worker.terminate();
|
|
774
|
+
state.worker = null;
|
|
775
|
+
state.busy = false;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function clearRoadPreviewTimer() {
|
|
779
|
+
if (state.roadPreviewTimer !== null) clearTimeout(state.roadPreviewTimer);
|
|
780
|
+
state.roadPreviewTimer = null;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function terminateRoadWorker() {
|
|
784
|
+
if (state.roadWorker) state.roadWorker.terminate();
|
|
785
|
+
state.roadWorker = null;
|
|
786
|
+
state.roadBusy = false;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function draftRoadGeneration() {
|
|
790
|
+
try { return roadGenerationFromForm(roadForm, state.roadDraftIdentity); } catch { return null; }
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function previewRoadGenerationUpgrade() {
|
|
794
|
+
const saved = state.savedRoadGeneration;
|
|
795
|
+
const target = saved === null ? undefined : roadGenerationUpgradeTarget(saved.identity);
|
|
796
|
+
if (target === undefined || target === state.roadDraftIdentity) return;
|
|
797
|
+
// 只有用户点击此按钮才切换内存草稿 identity;不会写 source。
|
|
798
|
+
state.roadDraftIdentity = target;
|
|
799
|
+
state.roadAutoPreviewEnabled = true;
|
|
800
|
+
generateRoadPreview();
|
|
801
|
+
updateStatus();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function cityRoadKey(cities) {
|
|
805
|
+
return cities.map((city) => `${city.id}:${city.x}:${city.y}:${city.seed}:${JSON.stringify(city.parameters)}`).sort().join("|");
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function roadParcelTemplate(snapshot = state.snapshot) {
|
|
809
|
+
const templates = snapshot?.source?.topology?.templates;
|
|
810
|
+
if (templates === undefined) return { key: "parcel-template:absent", settings: undefined };
|
|
811
|
+
const validated = validateSpatialTemplateConfiguration(templates);
|
|
812
|
+
const parcel = validated.parcel;
|
|
813
|
+
if (parcel === undefined) return { key: "parcel-template:absent", settings: undefined };
|
|
814
|
+
return {
|
|
815
|
+
key: `parcel-template:${parcel.presetId}:${parcel.revision}:${parcel.parameters?.subdivision}:${parcel.parameters?.roadParcelMaxFaceAreaMeters}`,
|
|
816
|
+
settings: resolveRoadParcelTemplateSettings(validated),
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function roadPreviewKey(generation, roadGeneration, cities) {
|
|
821
|
+
return `${generationCacheKey(generation)}|${Object.keys(DEFAULT_ROAD_GENERATION).map((key) => `${key}:${roadGeneration[key]}`).join("|")}|${cityRoadKey(cities)}|${roadParcelTemplate().key}`;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function currentRoadGate(gate) {
|
|
825
|
+
return state.open && !state.destroyed && gate.worker === state.roadWorker && gate.epoch === state.epoch && gate.id === state.roadRequestId
|
|
826
|
+
&& state.snapshot?.projectId === gate.projectId && state.snapshot?.revision === gate.revision && state.roadRequestedKey === gate.key;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function clearTileTimer() {
|
|
830
|
+
if (state.tileRequestTimer !== null) clearTimeout(state.tileRequestTimer);
|
|
831
|
+
state.tileRequestTimer = null;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function clearTileRuntime(clearCache = false) {
|
|
835
|
+
clearTileTimer();
|
|
836
|
+
if (state.tileWorker) state.tileWorker.terminate();
|
|
837
|
+
state.tileWorker = null;
|
|
838
|
+
state.tileQueue = [];
|
|
839
|
+
state.tileInFlight.clear();
|
|
840
|
+
state.lodLoading = false;
|
|
841
|
+
state.tileEpoch += 1;
|
|
842
|
+
if (state.fadeFrame !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(state.fadeFrame);
|
|
843
|
+
state.fadeFrame = null;
|
|
844
|
+
if (clearCache) {
|
|
845
|
+
state.tileCache.clear();
|
|
846
|
+
state.tileCacheBytes = 0;
|
|
847
|
+
state.lodLevel = 0;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function clearPreviewTimer() {
|
|
852
|
+
if (state.previewTimer !== null) clearTimeout(state.previewTimer);
|
|
853
|
+
state.previewTimer = null;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function currentGate(gate) {
|
|
857
|
+
return state.open && !state.destroyed && gate.worker === state.worker && gate.epoch === state.epoch && gate.id === state.requestId && state.snapshot?.projectId === gate.projectId && state.snapshot?.revision === gate.revision;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function percentileThreshold(values, density) {
|
|
861
|
+
const positive = [];
|
|
862
|
+
for (let index = 0; index < values.length; index += 1) if (Number(values[index]) > 0) positive.push(Number(values[index]));
|
|
863
|
+
if (!positive.length) return Infinity;
|
|
864
|
+
positive.sort((a, b) => a - b);
|
|
865
|
+
const visibleShare = Math.max(0.015, Math.min(0.2, density * 0.16));
|
|
866
|
+
return positive[Math.max(0, Math.floor(positive.length * (1 - visibleShare)))] ?? Infinity;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function layerEnabled(name) {
|
|
870
|
+
return layers.querySelector(`input[name="${name}"]`)?.checked === true;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function terrainElevationScaleMeters() {
|
|
874
|
+
return draftRoadGeneration()?.elevationScaleMeters ?? DEFAULT_ROAD_GENERATION.elevationScaleMeters;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function buildBaseMap(result, generation, reference = null) {
|
|
878
|
+
const base = document.createElement("canvas");
|
|
879
|
+
base.width = result.width;
|
|
880
|
+
base.height = result.height;
|
|
881
|
+
const context = base.getContext("2d", { alpha: false });
|
|
882
|
+
if (!context) throw new Error("浏览器不支持 2D 地图画布");
|
|
883
|
+
const image = context.createImageData(result.width, result.height);
|
|
884
|
+
const elevations = result.elevations;
|
|
885
|
+
const rivers = result.riverFlux;
|
|
886
|
+
const lakes = result.lakeMask;
|
|
887
|
+
let minimum = Infinity;
|
|
888
|
+
let maximum = -Infinity;
|
|
889
|
+
let waterCells = 0;
|
|
890
|
+
let riverCells = 0;
|
|
891
|
+
let lakeCells = 0;
|
|
892
|
+
for (let index = 0; index < elevations.length; index += 1) {
|
|
893
|
+
const elevation = Number(elevations[index]);
|
|
894
|
+
if (!Number.isFinite(elevation)) throw new TypeError("WASM elevations 包含非数值");
|
|
895
|
+
minimum = Math.min(minimum, elevation);
|
|
896
|
+
maximum = Math.max(maximum, elevation);
|
|
897
|
+
}
|
|
898
|
+
const colorMinimum = reference?.minimum ?? minimum;
|
|
899
|
+
const colorMaximum = reference?.maximum ?? maximum;
|
|
900
|
+
const landRange = Math.max(0.000001, colorMaximum - result.seaLevel);
|
|
901
|
+
const seaRange = Math.max(0.000001, result.seaLevel - colorMinimum);
|
|
902
|
+
const riverThreshold = reference?.riverThreshold ?? percentileThreshold(rivers, generation.riverDensity);
|
|
903
|
+
for (let y = 0; y < result.height; y += 1) {
|
|
904
|
+
for (let x = 0; x < result.width; x += 1) {
|
|
905
|
+
const index = y * result.width + x;
|
|
906
|
+
const elevation = Number(elevations[index]);
|
|
907
|
+
const lake = lakes[index] === true;
|
|
908
|
+
const sea = elevation <= result.seaLevel;
|
|
909
|
+
const river = Number(rivers[index]) >= riverThreshold;
|
|
910
|
+
if (lake) lakeCells += 1;
|
|
911
|
+
if (!sea && river) riverCells += 1;
|
|
912
|
+
let red;
|
|
913
|
+
let green;
|
|
914
|
+
let blue;
|
|
915
|
+
if (sea) {
|
|
916
|
+
const depth = Math.max(0, Math.min(1, (result.seaLevel - elevation) / seaRange));
|
|
917
|
+
red = 15 - depth * 7; green = 54 - depth * 19; blue = 82 - depth * 20; waterCells += 1;
|
|
918
|
+
} else {
|
|
919
|
+
const height = Math.max(0, Math.min(1, (elevation - result.seaLevel) / landRange));
|
|
920
|
+
const shade = layerEnabled("showRelief")
|
|
921
|
+
? (hillshadeAt({
|
|
922
|
+
elevations, width: result.width, height: result.height, x, y,
|
|
923
|
+
extentWidthMeters: generation.widthMeters / (result.level === undefined ? 1 : 2 ** result.level),
|
|
924
|
+
extentHeightMeters: generation.heightMeters / (result.level === undefined ? 1 : 2 ** result.level),
|
|
925
|
+
elevationScaleMeters: terrainElevationScaleMeters(),
|
|
926
|
+
}) - 0.5) * 42
|
|
927
|
+
: 0;
|
|
928
|
+
red = 42 + height * 112 + shade; green = 91 + height * 69 + shade; blue = 52 + height * 52 + shade;
|
|
929
|
+
}
|
|
930
|
+
if (lake && !sea) waterCells += 1;
|
|
931
|
+
if (lake && layerEnabled("showLakes")) { red = 22; green = 103; blue = 131; }
|
|
932
|
+
if (!sea && river && layerEnabled("showRivers")) { red = 28; green = 121; blue = 167; }
|
|
933
|
+
const offset = index * 4;
|
|
934
|
+
image.data[offset] = Math.max(0, Math.min(255, Math.round(red)));
|
|
935
|
+
image.data[offset + 1] = Math.max(0, Math.min(255, Math.round(green)));
|
|
936
|
+
image.data[offset + 2] = Math.max(0, Math.min(255, Math.round(blue)));
|
|
937
|
+
image.data[offset + 3] = 255;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
context.putImageData(image, 0, 0);
|
|
941
|
+
const surfaceContours = packContourSegments(contourSegments({
|
|
942
|
+
elevations, width: result.width, height: result.height, seaLevel: result.seaLevel, lakeMask: lakes,
|
|
943
|
+
extentWidthMeters: generation.widthMeters, extentHeightMeters: generation.heightMeters, elevationScaleMeters: terrainElevationScaleMeters(), intervalMeters: 5,
|
|
944
|
+
}));
|
|
945
|
+
return { canvas: base, minimum, maximum, riverThreshold, waterCells, riverCells, lakeCells, surfaceContours };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function pixelRatio() {
|
|
949
|
+
return Math.max(1, Math.min(3, window.devicePixelRatio || 1));
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function shorelineSegments(result) {
|
|
953
|
+
const segments = [];
|
|
954
|
+
const width = result.width;
|
|
955
|
+
const height = result.height;
|
|
956
|
+
const values = result.elevations;
|
|
957
|
+
const seaLevel = result.seaLevel;
|
|
958
|
+
const interpolate = (left, right) => {
|
|
959
|
+
const span = right - left;
|
|
960
|
+
return Math.max(0, Math.min(1, Math.abs(span) < 1e-12 ? 0.5 : (seaLevel - left) / span));
|
|
961
|
+
};
|
|
962
|
+
for (let y = 0; y + 1 < height; y += 1) {
|
|
963
|
+
for (let x = 0; x + 1 < width; x += 1) {
|
|
964
|
+
const topLeft = Number(values[y * width + x]);
|
|
965
|
+
const topRight = Number(values[y * width + x + 1]);
|
|
966
|
+
const bottomRight = Number(values[(y + 1) * width + x + 1]);
|
|
967
|
+
const bottomLeft = Number(values[(y + 1) * width + x]);
|
|
968
|
+
const mask = Number(topLeft > seaLevel) | Number(topRight > seaLevel) << 1
|
|
969
|
+
| Number(bottomRight > seaLevel) << 2 | Number(bottomLeft > seaLevel) << 3;
|
|
970
|
+
if (mask === 0 || mask === 15) continue;
|
|
971
|
+
const edges = {
|
|
972
|
+
top: [x + interpolate(topLeft, topRight), y],
|
|
973
|
+
right: [x + 1, y + interpolate(topRight, bottomRight)],
|
|
974
|
+
bottom: [x + interpolate(bottomLeft, bottomRight), y + 1],
|
|
975
|
+
left: [x, y + interpolate(topLeft, bottomLeft)],
|
|
976
|
+
};
|
|
977
|
+
const pairs = {
|
|
978
|
+
1: [["left", "top"]], 2: [["top", "right"]], 3: [["left", "right"]],
|
|
979
|
+
4: [["right", "bottom"]], 5: [["left", "top"], ["right", "bottom"]], 6: [["top", "bottom"]],
|
|
980
|
+
7: [["left", "bottom"]], 8: [["bottom", "left"]], 9: [["top", "bottom"]],
|
|
981
|
+
10: [["top", "right"], ["bottom", "left"]], 11: [["right", "bottom"]],
|
|
982
|
+
12: [["left", "right"]], 13: [["top", "right"]], 14: [["left", "top"]],
|
|
983
|
+
}[mask] ?? [];
|
|
984
|
+
for (const [from, to] of pairs) segments.push([...edges[from], ...edges[to]]);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return segments;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function lakeBoundarySegments(result) {
|
|
991
|
+
const segments = [];
|
|
992
|
+
const width = result.width;
|
|
993
|
+
const height = result.height;
|
|
994
|
+
const lake = (x, y) => result.lakeMask[y * width + x] === true && Number(result.elevations[y * width + x]) > result.seaLevel;
|
|
995
|
+
for (let y = 0; y < height; y += 1) {
|
|
996
|
+
for (let x = 0; x < width; x += 1) {
|
|
997
|
+
if (x + 1 < width && lake(x, y) !== lake(x + 1, y)) segments.push([x + 0.5, Math.max(0, y - 0.5), x + 0.5, Math.min(height - 1, y + 0.5)]);
|
|
998
|
+
if (y + 1 < height && lake(x, y) !== lake(x, y + 1)) segments.push([Math.max(0, x - 0.5), y + 0.5, Math.min(width - 1, x + 0.5), y + 0.5]);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return segments;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function generationCacheKey(generation) {
|
|
1005
|
+
return Object.keys(DEFAULT_PLANE_GENERATION).map((key) => `${key}:${generation[key]}`).join("|");
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function tileCacheKey(generation, tile) {
|
|
1009
|
+
return `${generationCacheKey(generation)}|${tile.level}/${tile.x}/${tile.y}`;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function touchTile(key) {
|
|
1013
|
+
const cached = state.tileCache.get(key);
|
|
1014
|
+
if (!cached) return null;
|
|
1015
|
+
state.tileCache.delete(key);
|
|
1016
|
+
state.tileCache.set(key, cached);
|
|
1017
|
+
return cached;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function cacheTile(key, tile, result, canvas) {
|
|
1021
|
+
const generation = state.previewGeneration;
|
|
1022
|
+
if (!generation) return;
|
|
1023
|
+
const previous = state.tileCache.get(key);
|
|
1024
|
+
if (previous) state.tileCacheBytes -= previous.bytes;
|
|
1025
|
+
const contours = shorelineSegments(result);
|
|
1026
|
+
const lakeContours = lakeBoundarySegments(result);
|
|
1027
|
+
const count = 2 ** tile.level;
|
|
1028
|
+
const originXMeters = tile.x * generation.widthMeters / count;
|
|
1029
|
+
const originYMeters = tile.y * generation.heightMeters / count;
|
|
1030
|
+
const contourInput = {
|
|
1031
|
+
elevations: result.elevations, width: result.width, height: result.height, seaLevel: result.seaLevel, lakeMask: result.lakeMask,
|
|
1032
|
+
originXMeters, originYMeters, extentWidthMeters: generation.widthMeters / count, extentHeightMeters: generation.heightMeters / count,
|
|
1033
|
+
elevationScaleMeters: terrainElevationScaleMeters(),
|
|
1034
|
+
};
|
|
1035
|
+
const surfaceContours = packContourSegments(contourSegments({ ...contourInput, intervalMeters: 5, maxSegments: LOD_CONTOUR_SEGMENT_CAP }));
|
|
1036
|
+
const bytes = LOD_TILE_SIZE * LOD_TILE_SIZE * 4 + (contours.length + lakeContours.length) * 32 + surfaceContours.byteLength;
|
|
1037
|
+
state.tileCache.delete(key);
|
|
1038
|
+
state.tileCache.set(key, { canvas, contours, lakeContours, surfaceContours, contourIntervalMeters: surfaceContours.intervalMeters, tile: { level: tile.level, x: tile.x, y: tile.y }, loadedAt: performance.now(), bytes });
|
|
1039
|
+
state.tileCacheBytes += bytes;
|
|
1040
|
+
const visible = new Set(state.visibleTiles.map((tile) => tile.key));
|
|
1041
|
+
while (state.tileCache.size > LOD_CACHE_MAX_TILES || state.tileCacheBytes > LOD_CACHE_BYTE_BUDGET) {
|
|
1042
|
+
const candidate = [...state.tileCache.keys()].find((entry) => !visible.has(entry)) ?? state.tileCache.keys().next().value;
|
|
1043
|
+
if (!candidate) break;
|
|
1044
|
+
const removed = state.tileCache.get(candidate);
|
|
1045
|
+
state.tileCache.delete(candidate);
|
|
1046
|
+
state.tileCacheBytes -= removed?.bytes ?? 0;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function detailPlan() {
|
|
1051
|
+
if (!state.baseCanvas || !state.previewGeneration) return null;
|
|
1052
|
+
const viewport = canvasSize();
|
|
1053
|
+
const generation = state.previewGeneration;
|
|
1054
|
+
const metersPerBasePixel = Math.sqrt(
|
|
1055
|
+
generation.widthMeters / state.baseCanvas.width * generation.heightMeters / state.baseCanvas.height,
|
|
1056
|
+
);
|
|
1057
|
+
const metersPerPixel = metersPerBasePixel / Math.max(0.000001, state.view.scale);
|
|
1058
|
+
state.metersPerPixel = metersPerPixel;
|
|
1059
|
+
const density = (LOD_DENSITY[densitySelect.value] ?? LOD_DENSITY.normal) * pixelRatio();
|
|
1060
|
+
const ideal = Math.log2(Math.max(generation.widthMeters, generation.heightMeters) * density / (LOD_TILE_SIZE * metersPerPixel));
|
|
1061
|
+
let level = state.lodLevel;
|
|
1062
|
+
if (!state.lodLevelInitialized) {
|
|
1063
|
+
level = Math.round(ideal);
|
|
1064
|
+
state.lodLevelInitialized = true;
|
|
1065
|
+
} else {
|
|
1066
|
+
while (level < LOD_MAX_LEVEL && ideal > level + 0.62) level += 1;
|
|
1067
|
+
while (level > 0 && ideal < level - 0.62) level -= 1;
|
|
1068
|
+
}
|
|
1069
|
+
level = Math.max(0, Math.min(LOD_MAX_LEVEL, level));
|
|
1070
|
+
const normalizedBounds = () => ({
|
|
1071
|
+
left: Math.max(0, Math.min(1, (0 - state.view.x) / state.view.scale / state.baseCanvas.width)),
|
|
1072
|
+
right: Math.max(0, Math.min(1, (viewport.width - state.view.x) / state.view.scale / state.baseCanvas.width)),
|
|
1073
|
+
top: Math.max(0, Math.min(1, (0 - state.view.y) / state.view.scale / state.baseCanvas.height)),
|
|
1074
|
+
bottom: Math.max(0, Math.min(1, (viewport.height - state.view.y) / state.view.scale / state.baseCanvas.height)),
|
|
1075
|
+
});
|
|
1076
|
+
const bounds = normalizedBounds();
|
|
1077
|
+
if (bounds.right <= bounds.left || bounds.bottom <= bounds.top) {
|
|
1078
|
+
state.lodLevel = level;
|
|
1079
|
+
return { level, metersPerPixel, tiles: [], signature: `${generationCacheKey(generation)}|${level}|offscreen` };
|
|
1080
|
+
}
|
|
1081
|
+
const tileRange = (candidateLevel) => {
|
|
1082
|
+
const count = 2 ** candidateLevel;
|
|
1083
|
+
const margin = candidateLevel > 0 ? 1 : 0;
|
|
1084
|
+
const minimumX = Math.max(0, Math.floor(bounds.left * count) - margin);
|
|
1085
|
+
const maximumX = Math.min(count - 1, Math.ceil(bounds.right * count) - 1 + margin);
|
|
1086
|
+
const minimumY = Math.max(0, Math.floor(bounds.top * count) - margin);
|
|
1087
|
+
const maximumY = Math.min(count - 1, Math.ceil(bounds.bottom * count) - 1 + margin);
|
|
1088
|
+
return { count, minimumX, maximumX, minimumY, maximumY, total: (maximumX - minimumX + 1) * (maximumY - minimumY + 1) };
|
|
1089
|
+
};
|
|
1090
|
+
const requiredTileRange = (candidateLevel) => {
|
|
1091
|
+
const count = 2 ** candidateLevel;
|
|
1092
|
+
const minimumX = Math.max(0, Math.floor(bounds.left * count));
|
|
1093
|
+
const maximumX = Math.min(count - 1, Math.ceil(bounds.right * count) - 1);
|
|
1094
|
+
const minimumY = Math.max(0, Math.floor(bounds.top * count));
|
|
1095
|
+
const maximumY = Math.min(count - 1, Math.ceil(bounds.bottom * count) - 1);
|
|
1096
|
+
return { count, minimumX, maximumX, minimumY, maximumY, total: (maximumX - minimumX + 1) * (maximumY - minimumY + 1) };
|
|
1097
|
+
};
|
|
1098
|
+
let range = tileRange(level);
|
|
1099
|
+
while (level > 0 && requiredTileRange(level).total > LOD_MAX_VISIBLE_TILES) { level -= 1; range = tileRange(level); }
|
|
1100
|
+
const required = requiredTileRange(level);
|
|
1101
|
+
state.lodLevel = level;
|
|
1102
|
+
const centerX = (bounds.left + bounds.right) / 2 * range.count;
|
|
1103
|
+
const centerY = (bounds.top + bounds.bottom) / 2 * range.count;
|
|
1104
|
+
const tiles = [];
|
|
1105
|
+
for (let y = range.minimumY; y <= range.maximumY; y += 1) {
|
|
1106
|
+
for (let x = range.minimumX; x <= range.maximumX; x += 1) {
|
|
1107
|
+
const tile = { level, x, y, size: LOD_TILE_SIZE };
|
|
1108
|
+
const requiredTile = x >= required.minimumX && x <= required.maximumX && y >= required.minimumY && y <= required.maximumY;
|
|
1109
|
+
tiles.push({ ...tile, key: tileCacheKey(generation, tile), required: requiredTile, distance: Math.hypot(x + 0.5 - centerX, y + 0.5 - centerY) });
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
tiles.sort((left, right) => Number(right.required) - Number(left.required) || left.distance - right.distance);
|
|
1113
|
+
const visibleTiles = tiles.slice(0, LOD_MAX_VISIBLE_TILES);
|
|
1114
|
+
return { level, metersPerPixel, tiles: visibleTiles, signature: `${generationCacheKey(generation)}|${level}|${visibleTiles.map((tile) => `${tile.x},${tile.y}`).join(";")}` };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function updateDetailStatus() {
|
|
1118
|
+
if (!state.baseCanvas || !state.previewGeneration) {
|
|
1119
|
+
detailStatus.dataset.state = "idle";
|
|
1120
|
+
detailStatus.textContent = "细节待命";
|
|
1121
|
+
cameraMpp.textContent = "— m/px";
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
const loaded = state.visibleTiles.reduce((count, tile) => count + Number(state.tileCache.has(tile.key)), 0);
|
|
1125
|
+
const total = state.visibleTiles.length;
|
|
1126
|
+
cameraMpp.textContent = `${state.metersPerPixel < 10 ? state.metersPerPixel.toFixed(1) : Math.round(state.metersPerPixel).toLocaleString()} m/px`;
|
|
1127
|
+
if (state.lodError) {
|
|
1128
|
+
detailStatus.dataset.state = "error";
|
|
1129
|
+
detailStatus.textContent = `LOD ${state.lodLevel} · 细节加载失败,底图可用`;
|
|
1130
|
+
} else if (loaded < total || state.lodLoading) {
|
|
1131
|
+
detailStatus.dataset.state = "loading";
|
|
1132
|
+
detailStatus.textContent = `LOD ${state.lodLevel} · ${loaded}/${total} 区块加载中`;
|
|
1133
|
+
} else {
|
|
1134
|
+
detailStatus.dataset.state = "ready";
|
|
1135
|
+
detailStatus.textContent = `LOD ${state.lodLevel} · ${total} 区块已加载`;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function pumpTileQueue(worker, epoch, generation) {
|
|
1140
|
+
if (state.tileWorker !== worker || state.tileEpoch !== epoch) return;
|
|
1141
|
+
while (state.tileInFlight.size < LOD_MAX_IN_FLIGHT && state.tileQueue.length) {
|
|
1142
|
+
const tile = state.tileQueue.shift();
|
|
1143
|
+
const id = ++state.tileRequestId;
|
|
1144
|
+
state.tileInFlight.set(id, tile);
|
|
1145
|
+
worker.postMessage({ id, kind: "tile", settings: generation, tile: { level: tile.level, x: tile.x, y: tile.y, size: LOD_TILE_SIZE } });
|
|
1146
|
+
}
|
|
1147
|
+
if (!state.tileQueue.length && !state.tileInFlight.size) {
|
|
1148
|
+
state.lodLoading = false;
|
|
1149
|
+
updateDetailStatus();
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function beginTileRequests(plan) {
|
|
1154
|
+
if (!state.open || !state.previewGeneration || state.desiredTileSignature !== plan.signature) return;
|
|
1155
|
+
const missing = plan.tiles.filter((tile) => !state.tileCache.has(tile.key));
|
|
1156
|
+
clearTileRuntime(false);
|
|
1157
|
+
if (!missing.length) { updateDetailStatus(); return; }
|
|
1158
|
+
state.lodLoading = true;
|
|
1159
|
+
state.lodError = null;
|
|
1160
|
+
state.tileQueue = missing;
|
|
1161
|
+
const generation = state.previewGeneration;
|
|
1162
|
+
const epoch = state.tileEpoch;
|
|
1163
|
+
const worker = new Worker(new URL("./world-map-worker.js", import.meta.url), { type: "module", name: "world-map-detail-loader" });
|
|
1164
|
+
state.tileWorker = worker;
|
|
1165
|
+
worker.addEventListener("message", (event) => {
|
|
1166
|
+
if (state.tileWorker !== worker || state.tileEpoch !== epoch || event.data?.kind !== "tile") return;
|
|
1167
|
+
const requested = state.tileInFlight.get(event.data?.id);
|
|
1168
|
+
if (!requested) return;
|
|
1169
|
+
state.tileInFlight.delete(event.data.id);
|
|
1170
|
+
if (event.data?.error) {
|
|
1171
|
+
state.lodError = String(event.data.error);
|
|
1172
|
+
} else {
|
|
1173
|
+
try {
|
|
1174
|
+
const result = validateTileResult(event.data?.result, generation, requested);
|
|
1175
|
+
if (!state.baseRenderStats) throw new Error("地图底图调色基准缺失");
|
|
1176
|
+
cacheTile(requested.key, requested, result, buildBaseMap(result, generation, state.baseRenderStats).canvas);
|
|
1177
|
+
draw();
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
state.lodError = errorText(error);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
pumpTileQueue(worker, epoch, generation);
|
|
1183
|
+
updateDetailStatus();
|
|
1184
|
+
});
|
|
1185
|
+
worker.addEventListener("error", (event) => {
|
|
1186
|
+
if (state.tileWorker !== worker || state.tileEpoch !== epoch) return;
|
|
1187
|
+
state.lodError = event.message || "地图细节 Worker 失败";
|
|
1188
|
+
state.tileQueue = [];
|
|
1189
|
+
state.tileInFlight.clear();
|
|
1190
|
+
state.lodLoading = false;
|
|
1191
|
+
worker.terminate();
|
|
1192
|
+
state.tileWorker = null;
|
|
1193
|
+
updateDetailStatus();
|
|
1194
|
+
});
|
|
1195
|
+
pumpTileQueue(worker, epoch, generation);
|
|
1196
|
+
updateDetailStatus();
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function refreshDetailPlan(immediate = false) {
|
|
1200
|
+
const plan = detailPlan();
|
|
1201
|
+
if (!plan) { state.visibleTiles = []; updateDetailStatus(); return; }
|
|
1202
|
+
state.visibleTiles = plan.tiles;
|
|
1203
|
+
state.desiredTileSignature = plan.signature;
|
|
1204
|
+
state.lodLoading = plan.tiles.some((tile) => !state.tileCache.has(tile.key));
|
|
1205
|
+
state.lodError = null;
|
|
1206
|
+
clearTileTimer();
|
|
1207
|
+
updateDetailStatus();
|
|
1208
|
+
if (!state.lodLoading) return;
|
|
1209
|
+
if (immediate) beginTileRequests(plan);
|
|
1210
|
+
else state.tileRequestTimer = setTimeout(() => { state.tileRequestTimer = null; beginTileRequests(plan); }, LOD_REQUEST_DEBOUNCE_MS);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function cachedTileOrParent(tile, skipExact = false) {
|
|
1214
|
+
const generation = state.previewGeneration;
|
|
1215
|
+
if (!generation) return null;
|
|
1216
|
+
for (let level = skipExact ? tile.level - 1 : tile.level; level >= 0; level -= 1) {
|
|
1217
|
+
const divisor = 2 ** (tile.level - level);
|
|
1218
|
+
const ancestor = { level, x: Math.floor(tile.x / divisor), y: Math.floor(tile.y / divisor), size: LOD_TILE_SIZE };
|
|
1219
|
+
const key = tileCacheKey(generation, ancestor);
|
|
1220
|
+
const cached = touchTile(key);
|
|
1221
|
+
if (cached) return { cached, ancestor, divisor };
|
|
1222
|
+
}
|
|
1223
|
+
return null;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function drawTileRaster(context, tile, source, alpha = 1) {
|
|
1227
|
+
const count = 2 ** tile.level;
|
|
1228
|
+
const destinationWidth = state.baseCanvas.width / count;
|
|
1229
|
+
const destinationHeight = state.baseCanvas.height / count;
|
|
1230
|
+
const relativeX = tile.x - source.ancestor.x * source.divisor;
|
|
1231
|
+
const relativeY = tile.y - source.ancestor.y * source.divisor;
|
|
1232
|
+
const sourceWidth = LOD_TILE_SIZE / source.divisor;
|
|
1233
|
+
const sourceHeight = LOD_TILE_SIZE / source.divisor;
|
|
1234
|
+
const previousAlpha = context.globalAlpha;
|
|
1235
|
+
context.globalAlpha = alpha;
|
|
1236
|
+
context.drawImage(
|
|
1237
|
+
source.cached.canvas,
|
|
1238
|
+
relativeX * sourceWidth, relativeY * sourceHeight, sourceWidth, sourceHeight,
|
|
1239
|
+
tile.x * destinationWidth, tile.y * destinationHeight, destinationWidth, destinationHeight,
|
|
1240
|
+
);
|
|
1241
|
+
context.globalAlpha = previousAlpha;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function drawTileShoreline(context, tile, cached, alpha = 1) {
|
|
1245
|
+
if (!cached.contours.length && !cached.lakeContours.length) return;
|
|
1246
|
+
const count = 2 ** tile.level;
|
|
1247
|
+
const destinationWidth = state.baseCanvas.width / count;
|
|
1248
|
+
const destinationHeight = state.baseCanvas.height / count;
|
|
1249
|
+
const denominator = LOD_TILE_SIZE - 1;
|
|
1250
|
+
const previousAlpha = context.globalAlpha;
|
|
1251
|
+
context.globalAlpha = alpha;
|
|
1252
|
+
const stroke = (segments, color) => {
|
|
1253
|
+
if (!segments.length) return;
|
|
1254
|
+
context.beginPath();
|
|
1255
|
+
for (const [x1, y1, x2, y2] of segments) {
|
|
1256
|
+
context.moveTo(tile.x * destinationWidth + x1 / denominator * destinationWidth, tile.y * destinationHeight + y1 / denominator * destinationHeight);
|
|
1257
|
+
context.lineTo(tile.x * destinationWidth + x2 / denominator * destinationWidth, tile.y * destinationHeight + y2 / denominator * destinationHeight);
|
|
1258
|
+
}
|
|
1259
|
+
context.strokeStyle = color;
|
|
1260
|
+
context.lineWidth = 0.7 / state.view.scale;
|
|
1261
|
+
context.lineCap = "round";
|
|
1262
|
+
context.stroke();
|
|
1263
|
+
};
|
|
1264
|
+
stroke(cached.contours, "rgba(224,244,249,.88)");
|
|
1265
|
+
stroke(cached.lakeContours, "rgba(116,211,232,.9)");
|
|
1266
|
+
context.globalAlpha = previousAlpha;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function drawTileTerrainContours(context, tile, cached, plan, alpha = 1) {
|
|
1270
|
+
if (!layerEnabled("showContours") || !cached.surfaceContours.length || !state.metersPerPixel) return;
|
|
1271
|
+
const count = 2 ** tile.level;
|
|
1272
|
+
const destinationWidth = state.baseCanvas.width / count;
|
|
1273
|
+
const destinationHeight = state.baseCanvas.height / count;
|
|
1274
|
+
const originX = tile.x * state.previewGeneration.widthMeters / count;
|
|
1275
|
+
const originY = tile.y * state.previewGeneration.heightMeters / count;
|
|
1276
|
+
for (const [interval, opacity] of [[plan.fineIntervalMeters, 1 - plan.coarseAlpha], [plan.coarseIntervalMeters, plan.coarseAlpha]]) {
|
|
1277
|
+
if (opacity <= 0) continue;
|
|
1278
|
+
context.beginPath();
|
|
1279
|
+
for (let index = 0; index < cached.surfaceContours.length; index += 5) {
|
|
1280
|
+
const x1 = cached.surfaceContours[index]; const y1 = cached.surfaceContours[index + 1];
|
|
1281
|
+
const x2 = cached.surfaceContours[index + 2]; const y2 = cached.surfaceContours[index + 3]; const elevation = cached.surfaceContours[index + 4];
|
|
1282
|
+
if (Math.abs(elevation / interval - Math.round(elevation / interval)) > 1e-8) continue;
|
|
1283
|
+
context.moveTo(tile.x * destinationWidth + (x1 - originX) / (state.previewGeneration.widthMeters / count) * destinationWidth, tile.y * destinationHeight + (y1 - originY) / (state.previewGeneration.heightMeters / count) * destinationHeight);
|
|
1284
|
+
context.lineTo(tile.x * destinationWidth + (x2 - originX) / (state.previewGeneration.widthMeters / count) * destinationWidth, tile.y * destinationHeight + (y2 - originY) / (state.previewGeneration.heightMeters / count) * destinationHeight);
|
|
1285
|
+
}
|
|
1286
|
+
context.globalAlpha = alpha * opacity;
|
|
1287
|
+
context.strokeStyle = "rgba(18,37,22,.78)";
|
|
1288
|
+
context.lineWidth = 0.65 / state.view.scale;
|
|
1289
|
+
context.lineCap = "round";
|
|
1290
|
+
context.stroke();
|
|
1291
|
+
}
|
|
1292
|
+
context.globalAlpha = alpha;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function drawBaseTerrainContours(context, plan) {
|
|
1296
|
+
if (!layerEnabled("showContours") || !state.baseRenderStats?.surfaceContours?.length || !state.metersPerPixel) return;
|
|
1297
|
+
for (const [interval, opacity] of [[plan.fineIntervalMeters, 1 - plan.coarseAlpha], [plan.coarseIntervalMeters, plan.coarseAlpha]]) {
|
|
1298
|
+
if (opacity <= 0) continue;
|
|
1299
|
+
context.beginPath();
|
|
1300
|
+
for (let index = 0; index < state.baseRenderStats.surfaceContours.length; index += 5) {
|
|
1301
|
+
const x1 = state.baseRenderStats.surfaceContours[index]; const y1 = state.baseRenderStats.surfaceContours[index + 1];
|
|
1302
|
+
const x2 = state.baseRenderStats.surfaceContours[index + 2]; const y2 = state.baseRenderStats.surfaceContours[index + 3]; const elevation = state.baseRenderStats.surfaceContours[index + 4];
|
|
1303
|
+
if (Math.abs(elevation / interval - Math.round(elevation / interval)) > 1e-8) continue;
|
|
1304
|
+
context.moveTo(x1 / state.previewGeneration.widthMeters * state.baseCanvas.width, y1 / state.previewGeneration.heightMeters * state.baseCanvas.height);
|
|
1305
|
+
context.lineTo(x2 / state.previewGeneration.widthMeters * state.baseCanvas.width, y2 / state.previewGeneration.heightMeters * state.baseCanvas.height);
|
|
1306
|
+
}
|
|
1307
|
+
context.globalAlpha = opacity;
|
|
1308
|
+
context.strokeStyle = "rgba(18,37,22,.58)";
|
|
1309
|
+
context.lineWidth = 0.55 / state.view.scale;
|
|
1310
|
+
context.stroke();
|
|
1311
|
+
}
|
|
1312
|
+
context.globalAlpha = 1;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function visibleContourPlan() {
|
|
1316
|
+
let minimumAvailableIntervalMeters = 5;
|
|
1317
|
+
for (const tile of state.visibleTiles) {
|
|
1318
|
+
const cached = state.tileCache.get(tile.key);
|
|
1319
|
+
if (cached?.tile.level === tile.level) minimumAvailableIntervalMeters = Math.max(minimumAvailableIntervalMeters, cached.contourIntervalMeters ?? 5);
|
|
1320
|
+
}
|
|
1321
|
+
return sharedContourPlan(state.metersPerPixel ?? Infinity, minimumAvailableIntervalMeters);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function scheduleFadeFrame() {
|
|
1325
|
+
if (state.fadeFrame !== null || typeof requestAnimationFrame !== "function") return;
|
|
1326
|
+
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
|
|
1327
|
+
state.fadeFrame = requestAnimationFrame(() => {
|
|
1328
|
+
state.fadeFrame = null;
|
|
1329
|
+
if (state.open) draw();
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function proceduralInput(snapshot) {
|
|
1334
|
+
return snapshot?.source?.topology?.procedural ?? null;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
async function loadCities(snapshot, generation, isCurrent = () => true) {
|
|
1338
|
+
if (!isCurrent()) return false;
|
|
1339
|
+
const input = proceduralInput(snapshot);
|
|
1340
|
+
if (!input) {
|
|
1341
|
+
if (!isCurrent()) return false;
|
|
1342
|
+
state.cityWorld = null;
|
|
1343
|
+
state.cities = [];
|
|
1344
|
+
state.cityLayouts.clear();
|
|
1345
|
+
state.roadParcelLayouts.clear();
|
|
1346
|
+
state.roadParcelError = null;
|
|
1347
|
+
state.cityLayoutBytes = 0;
|
|
1348
|
+
return true;
|
|
1349
|
+
}
|
|
1350
|
+
const cityModule = state.cityModule;
|
|
1351
|
+
if (!isCurrent()) return false;
|
|
1352
|
+
const world = cityModule.validateProceduralWorld(input, { widthMeters: generation.widthMeters, heightMeters: generation.heightMeters });
|
|
1353
|
+
state.cityWorld = world;
|
|
1354
|
+
state.cities = [...world.cities];
|
|
1355
|
+
const liveIds = new Set(state.cities.map((city) => city.id));
|
|
1356
|
+
for (const [key, cached] of state.cityLayouts) {
|
|
1357
|
+
if (!liveIds.has(cached.cityId)) { state.cityLayouts.delete(key); state.cityLayoutBytes -= cached.bytes; }
|
|
1358
|
+
}
|
|
1359
|
+
if (state.roadResult) {
|
|
1360
|
+
try {
|
|
1361
|
+
cacheRoadParcels(state.roadResult, state.previewRoadGeneration);
|
|
1362
|
+
state.roadParcelError = null;
|
|
1363
|
+
} catch (error) {
|
|
1364
|
+
state.roadParcelLayouts.clear();
|
|
1365
|
+
state.roadParcelError = errorText(error);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
return true;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function updateActorBinding(snapshot) {
|
|
1372
|
+
if (typeof options.setControlledActor !== "function") return;
|
|
1373
|
+
const choices = Array.isArray(snapshot?.actorChoices) ? snapshot.actorChoices.filter((choice) => choice
|
|
1374
|
+
&& typeof choice.id === "string" && choice.id && typeof choice.label === "string" && choice.label) : [];
|
|
1375
|
+
const saved = snapshot?.source?.topology?.controlledActorId;
|
|
1376
|
+
actorSelect.replaceChildren();
|
|
1377
|
+
if (!choices.length) {
|
|
1378
|
+
const option = element("option", "", "暂无可绑定角色"); option.value = ""; actorSelect.append(option);
|
|
1379
|
+
actorStatus.textContent = "请先在世界图中新建人物实体。";
|
|
1380
|
+
} else {
|
|
1381
|
+
for (const choice of choices) { const option = element("option", "", choice.label); option.value = choice.id; actorSelect.append(option); }
|
|
1382
|
+
actorSelect.value = choices.some((choice) => choice.id === saved) ? saved : choices[0].id;
|
|
1383
|
+
actorStatus.textContent = saved ? `当前绑定:${choices.find((choice) => choice.id === saved)?.label ?? saved}` : "尚未保存受控角色绑定。";
|
|
1384
|
+
}
|
|
1385
|
+
const worldSaved = Boolean(state.savedGeneration && state.previewGeneration && sameGeneration(state.savedGeneration, state.previewGeneration));
|
|
1386
|
+
actorSelect.disabled = state.savingActor || !choices.length || !worldSaved;
|
|
1387
|
+
saveActorButton.disabled = actorSelect.disabled || actorSelect.value === saved;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
async function saveActorBinding() {
|
|
1391
|
+
if (typeof options.setControlledActor !== "function" || state.savingActor || !state.open || !state.snapshot || !state.previewGeneration) return;
|
|
1392
|
+
const actorId = actorSelect.value;
|
|
1393
|
+
const expectedProject = state.snapshot.projectId;
|
|
1394
|
+
const expectedRevision = state.snapshot.revision;
|
|
1395
|
+
const expectedEpoch = state.epoch;
|
|
1396
|
+
const draft = draftGeneration();
|
|
1397
|
+
if (!actorId || !draft || !state.savedGeneration || !sameGeneration(draft, state.previewGeneration)
|
|
1398
|
+
|| !sameGeneration(state.savedGeneration, state.previewGeneration) || state.busy || state.saving || state.loading) return;
|
|
1399
|
+
state.savingActor = true; updateActorBinding(state.snapshot); actorStatus.textContent = "正在保存角色绑定…";
|
|
1400
|
+
try {
|
|
1401
|
+
const response = await options.setControlledActor(actorId, expectedRevision);
|
|
1402
|
+
if (!state.open || state.epoch !== expectedEpoch || state.snapshot?.projectId !== expectedProject || state.snapshot?.revision !== expectedRevision) return;
|
|
1403
|
+
const validated = validateSnapshot(response);
|
|
1404
|
+
if (validated.value.projectId !== expectedProject || validated.value.revision <= expectedRevision
|
|
1405
|
+
|| validated.value.source?.topology?.controlledActorId !== actorId) throw new Error("角色绑定读回无效");
|
|
1406
|
+
state.snapshot = validated.value; state.savedGeneration = validated.savedGeneration;
|
|
1407
|
+
state.markers = objectMarkers(validated.value.source?.objects, state.previewGeneration);
|
|
1408
|
+
updateActorBinding(validated.value); notify("受控角色绑定已保存。", false); draw(); updateStatus();
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
if (state.open && state.epoch === expectedEpoch && state.snapshot?.projectId === expectedProject) {
|
|
1411
|
+
actorStatus.textContent = errorText(error); notify(errorText(error), true);
|
|
1412
|
+
}
|
|
1413
|
+
} finally {
|
|
1414
|
+
state.savingActor = false;
|
|
1415
|
+
if (state.open && state.epoch === expectedEpoch) updateActorBinding(state.snapshot);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function cityCacheKey(city) {
|
|
1420
|
+
return `${state.cityWorld?.identity ?? ""}|${state.cityWorld?.worldSeed ?? ""}|${city.id}|${city.seed}|${JSON.stringify(city.parameters)}`;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function cityLayout(city) {
|
|
1424
|
+
if (!state.cityModule || !state.cityWorld) return null;
|
|
1425
|
+
const key = cityCacheKey(city);
|
|
1426
|
+
const cached = state.cityLayouts.get(key);
|
|
1427
|
+
if (cached) {
|
|
1428
|
+
state.cityLayouts.delete(key); state.cityLayouts.set(key, cached); return cached.layout;
|
|
1429
|
+
}
|
|
1430
|
+
const layout = state.cityModule.generateCityLayout(state.cityWorld.worldSeed, city);
|
|
1431
|
+
const bytes = layout.roads.length * 72 + layout.parcels.length * 96;
|
|
1432
|
+
state.cityLayouts.set(key, { cityId: city.id, layout, bytes });
|
|
1433
|
+
state.cityLayoutBytes += bytes;
|
|
1434
|
+
while (state.cityLayouts.size > CITY_LAYOUT_CACHE_MAX || state.cityLayoutBytes > CITY_LAYOUT_CACHE_BYTE_BUDGET) {
|
|
1435
|
+
const oldest = state.cityLayouts.keys().next().value;
|
|
1436
|
+
if (!oldest) break;
|
|
1437
|
+
const removed = state.cityLayouts.get(oldest); state.cityLayouts.delete(oldest); state.cityLayoutBytes -= removed?.bytes ?? 0;
|
|
1438
|
+
}
|
|
1439
|
+
return layout;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/** 道路响应到达时只生成一次;此函数失败必须由调用方可见报告,不能伪装成合法空地块。 */
|
|
1443
|
+
function cacheRoadParcels(result, roadGeneration) {
|
|
1444
|
+
state.roadParcelLayouts.clear();
|
|
1445
|
+
if (!state.cityWorld || !roadGeneration) throw new Error("道路地块缺少城市或道路设置上下文");
|
|
1446
|
+
for (const city of state.cities) {
|
|
1447
|
+
const cityLayout = result.cityLayouts.find((layout) => layout.cityId === city.id);
|
|
1448
|
+
const terrain = cityLayout?.terrain;
|
|
1449
|
+
const boundary = cityLayout?.boundary;
|
|
1450
|
+
const roads = result.roads;
|
|
1451
|
+
if (!terrain) throw new Error(`城市 ${city.label} 缺少确定性地形采样`);
|
|
1452
|
+
if (!boundary) throw new Error(`城市 ${city.label} 缺少地形边界`);
|
|
1453
|
+
if (boundary.status === "unbuildable") { state.roadParcelLayouts.set(city.id, []); continue; }
|
|
1454
|
+
const parcelTemplate = roadParcelTemplate();
|
|
1455
|
+
const layout = generateRoadParcels(state.cityWorld.worldSeed, city, { roads, boundary, terrain,
|
|
1456
|
+
// preview 的 identity 来自当前 draft(旧图打开时就是 saved identity),不是 current 默认值。
|
|
1457
|
+
algorithm: roadParcelAlgorithm(roadGeneration.identity), settings: {
|
|
1458
|
+
maxSlope: roadGeneration.maxGrade,
|
|
1459
|
+
...parcelTemplate.settings,
|
|
1460
|
+
} });
|
|
1461
|
+
if (!Array.isArray(layout?.parcels)) throw new Error(`城市 ${city.label} 的地块结果无效`);
|
|
1462
|
+
state.roadParcelLayouts.set(city.id, layout.parcels.filter((parcel) => Array.isArray(parcel?.polygon) && parcel.polygon.length >= 3));
|
|
1463
|
+
}
|
|
1464
|
+
state.roadParcelTemplateKey = roadParcelTemplate().key;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function drawRoadParcels(context, city, metersToMapX, metersToMapY) {
|
|
1468
|
+
for (const parcel of state.roadParcelLayouts.get(city.id) ?? []) {
|
|
1469
|
+
const palette = state.cityModule.PALETTE[parcel.landUse];
|
|
1470
|
+
if (!palette) continue;
|
|
1471
|
+
const [first, ...rest] = parcel.polygon;
|
|
1472
|
+
context.beginPath(); context.moveTo(first.x * metersToMapX, first.y * metersToMapY);
|
|
1473
|
+
for (const point of rest) context.lineTo(point.x * metersToMapX, point.y * metersToMapY);
|
|
1474
|
+
context.closePath(); context.fillStyle = `${palette.color}c4`; context.fill();
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function nextCityMode(city, screenRadius, screenBlock) {
|
|
1479
|
+
const roadThreshold = Number(roadThresholdInput.value) || 80;
|
|
1480
|
+
const parcelThreshold = Number(parcelThresholdInput.value) || 12;
|
|
1481
|
+
const previous = state.cityModes.get(city.id) ?? "marker";
|
|
1482
|
+
let mode = previous;
|
|
1483
|
+
if (previous === "marker") {
|
|
1484
|
+
if (screenRadius >= roadThreshold) mode = "roads";
|
|
1485
|
+
} else if (screenRadius < roadThreshold * 0.86) mode = "marker";
|
|
1486
|
+
if (mode === "roads" && screenBlock >= parcelThreshold) mode = "parcels";
|
|
1487
|
+
else if (mode === "parcels" && screenBlock < parcelThreshold * 0.86) mode = "roads";
|
|
1488
|
+
state.cityModes.set(city.id, mode);
|
|
1489
|
+
return mode;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function drawCities(context) {
|
|
1493
|
+
cityLegend.hidden = true;
|
|
1494
|
+
if (!layerEnabled("showCities") || !state.previewGeneration || !state.cityModule || !state.cityWorld) return;
|
|
1495
|
+
const generation = state.previewGeneration;
|
|
1496
|
+
const metersToMapX = state.baseCanvas.width / generation.widthMeters;
|
|
1497
|
+
const metersToMapY = state.baseCanvas.height / generation.heightMeters;
|
|
1498
|
+
const viewport = canvasSize();
|
|
1499
|
+
let parcelsVisible = false;
|
|
1500
|
+
for (const city of state.cities) {
|
|
1501
|
+
const x = city.x * metersToMapX;
|
|
1502
|
+
const y = city.y * metersToMapY;
|
|
1503
|
+
const screenRadius = city.parameters.radiusMeters * Math.sqrt(metersToMapX * metersToMapY) * state.view.scale;
|
|
1504
|
+
const screenBlock = city.parameters.blockSizeMeters * Math.sqrt(metersToMapX * metersToMapY) * state.view.scale;
|
|
1505
|
+
const screenX = state.view.x + x * state.view.scale;
|
|
1506
|
+
const screenY = state.view.y + y * state.view.scale;
|
|
1507
|
+
if (screenX + screenRadius < 0 || screenY + screenRadius < 0 || screenX - screenRadius > viewport.width || screenY - screenRadius > viewport.height) continue;
|
|
1508
|
+
const mode = state.roadResult ? nextCityMode(city, screenRadius, screenBlock) : "marker";
|
|
1509
|
+
if (mode !== "marker") {
|
|
1510
|
+
const boundaryValue = state.roadResult?.cityLayouts.find((layout) => layout.cityId === city.id)?.boundary;
|
|
1511
|
+
const boundary = boundaryValue?.status === "bounded" ? boundaryValue.points : null;
|
|
1512
|
+
if (boundary) {
|
|
1513
|
+
context.beginPath(); context.moveTo(boundary[0].x * metersToMapX, boundary[0].y * metersToMapY);
|
|
1514
|
+
for (let index = 1; index < boundary.length; index += 1) context.lineTo(boundary[index].x * metersToMapX, boundary[index].y * metersToMapY);
|
|
1515
|
+
context.closePath(); context.lineWidth = Math.max(.8 / state.view.scale, 2 / state.view.scale); context.strokeStyle = "rgba(255,206,102,.9)"; context.setLineDash([4 / state.view.scale, 3 / state.view.scale]); context.stroke(); context.setLineDash([]);
|
|
1516
|
+
}
|
|
1517
|
+
if (state.roadResult && mode === "parcels") {
|
|
1518
|
+
const parcels = state.roadParcelLayouts.get(city.id) ?? [];
|
|
1519
|
+
parcelsVisible ||= parcels.length > 0;
|
|
1520
|
+
drawRoadParcels(context, city, metersToMapX, metersToMapY);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
if (mode === "marker") {
|
|
1524
|
+
context.beginPath(); context.arc(x, y, Math.max(3 / state.view.scale, Math.min(city.parameters.radiusMeters * metersToMapX, 9 / state.view.scale)), 0, Math.PI * 2);
|
|
1525
|
+
context.fillStyle = "#ffce66"; context.fill();
|
|
1526
|
+
}
|
|
1527
|
+
if (mode !== "parcels") {
|
|
1528
|
+
context.fillStyle = "#fff4d6"; context.font = `${11 / state.view.scale}px ui-sans-serif, sans-serif`;
|
|
1529
|
+
context.fillText(city.label, x + (mode === "marker" ? 8 / state.view.scale : 4 / state.view.scale), y);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
cityLegend.hidden = !parcelsVisible;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function drawCityDraft(context) {
|
|
1536
|
+
const draft = state.cityPoint;
|
|
1537
|
+
if (!draft || !state.previewGeneration || !state.baseCanvas) return;
|
|
1538
|
+
const x = draft.x / state.previewGeneration.widthMeters * state.baseCanvas.width;
|
|
1539
|
+
const y = draft.y / state.previewGeneration.heightMeters * state.baseCanvas.height;
|
|
1540
|
+
const busy = draft.phase === "creating" || draft.phase === "roads";
|
|
1541
|
+
const failed = draft.phase === "error";
|
|
1542
|
+
const radius = 7 / state.view.scale;
|
|
1543
|
+
context.beginPath(); context.arc(x, y, radius, 0, Math.PI * 2);
|
|
1544
|
+
context.fillStyle = failed ? "#ff959a" : busy ? "#ffd879" : "#32d551";
|
|
1545
|
+
context.fill();
|
|
1546
|
+
context.beginPath(); context.arc(x, y, radius * .48, 0, Math.PI * 2);
|
|
1547
|
+
context.fillStyle = "#201f20"; context.fill();
|
|
1548
|
+
const label = failed ? "生成失败,可重试" : draft.phase === "creating" ? "正在生成城市…" : draft.phase === "roads" ? "正在生成地形路网…" : "待生成城市";
|
|
1549
|
+
context.fillStyle = "#fff4d6";
|
|
1550
|
+
context.font = `${11 / state.view.scale}px ui-sans-serif, sans-serif`;
|
|
1551
|
+
context.fillText(label, x + radius * 1.7, y + radius * 1.7);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function roadPath(context, points, metersToMapX, metersToMapY) {
|
|
1555
|
+
const first = points[0];
|
|
1556
|
+
context.beginPath();
|
|
1557
|
+
context.moveTo(first.x * metersToMapX, first.y * metersToMapY);
|
|
1558
|
+
for (let index = 1; index < points.length; index += 1) context.lineTo(points[index].x * metersToMapX, points[index].y * metersToMapY);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function drawRoads(context) {
|
|
1562
|
+
if (!layerEnabled("showRoads") || !state.roadResult || !state.previewGeneration || !state.baseCanvas) return;
|
|
1563
|
+
const metersToMapX = state.baseCanvas.width / state.previewGeneration.widthMeters;
|
|
1564
|
+
const metersToMapY = state.baseCanvas.height / state.previewGeneration.heightMeters;
|
|
1565
|
+
for (const road of state.roadResult.roads) {
|
|
1566
|
+
if (!roadVisibleAtMetersPerPixel(road, state.metersPerPixel ?? Infinity)) continue;
|
|
1567
|
+
const width = Math.max(0.65 / state.view.scale, road.widthMeters * Math.sqrt(metersToMapX * metersToMapY));
|
|
1568
|
+
for (const segment of road.segments) {
|
|
1569
|
+
roadPath(context, segment.points, metersToMapX, metersToMapY);
|
|
1570
|
+
context.lineCap = "round";
|
|
1571
|
+
context.lineJoin = "round";
|
|
1572
|
+
context.lineWidth = width;
|
|
1573
|
+
context.setLineDash(segment.kind === "bridge" ? [width * 3, width * 1.6] : segment.kind === "tunnel" ? [width * 1.2, width * 2.2] : []);
|
|
1574
|
+
context.strokeStyle = segment.kind === "bridge" ? "#e9c46a" : segment.kind === "tunnel" ? "#b592f5" : road.roadClass === "motorway" ? "#f5f5f5" : road.kind === "arterial" ? "#e0a85d" : "rgba(242,242,242,.75)";
|
|
1575
|
+
context.stroke();
|
|
1576
|
+
}
|
|
1577
|
+
context.setLineDash([]);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function stableCityId(label, x, y) {
|
|
1582
|
+
const input = `${state.snapshot?.projectId ?? "world"}\u001f${label}\u001f${x.toFixed(3)}\u001f${y.toFixed(3)}`;
|
|
1583
|
+
let hash = 0x811c9dc5;
|
|
1584
|
+
for (let index = 0; index < input.length; index += 1) hash = Math.imul(hash ^ input.charCodeAt(index), 0x01000193);
|
|
1585
|
+
return `city:${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function stableCitySeed(id, x, y) {
|
|
1589
|
+
const input = `${state.previewGeneration?.seed ?? 0}\u001f${id}\u001f${x.toFixed(3)}\u001f${y.toFixed(3)}`;
|
|
1590
|
+
let hash = 0x811c9dc5;
|
|
1591
|
+
for (let index = 0; index < input.length; index += 1) hash = Math.imul(hash ^ input.charCodeAt(index), 0x01000193);
|
|
1592
|
+
return hash >>> 0;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
function closeCityDialog() {
|
|
1596
|
+
state.cityPoint = null;
|
|
1597
|
+
cityDialogStatus.textContent = "";
|
|
1598
|
+
cityDialogStatus.dataset.state = "idle";
|
|
1599
|
+
if (typeof cityDialog.close === "function" && cityDialog.open) cityDialog.close();
|
|
1600
|
+
else { cityDialog.removeAttribute("open"); cityDialog.hidden = true; }
|
|
1601
|
+
submitCity.textContent = "生成城市";
|
|
1602
|
+
cancelCity.textContent = "取消";
|
|
1603
|
+
draw();
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function applyCityTemplate(templateId) {
|
|
1607
|
+
const template = cityTemplates.find((candidate) => candidate.id === templateId);
|
|
1608
|
+
if (!template || !state.cityPoint) return;
|
|
1609
|
+
applyCityParameters(template.parameters);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function applyCityParameters(parameters) {
|
|
1613
|
+
if (!state.cityPoint || !parameters || typeof parameters !== "object") return;
|
|
1614
|
+
const radius = Math.max(32, Math.min(state.cityPoint.maximumRadius, Number(parameters.radiusMeters)));
|
|
1615
|
+
if (Number.isFinite(radius)) cityForm.elements.namedItem("cityRadius").value = String(Math.round(radius));
|
|
1616
|
+
for (const [name, value] of [["cityBlockSize", parameters.blockSizeMeters], ["cityRoadWidth", parameters.roadWidthMeters], ...Object.entries(parameters.landUseWeights ?? {}).map(([name, value]) => [`cityUse_${name}`, value])]) {
|
|
1617
|
+
const control = cityForm.elements.namedItem(name);
|
|
1618
|
+
if ((control instanceof HTMLInputElement || control instanceof HTMLSelectElement) && typeof value === "number" && Number.isFinite(value)) control.value = String(value);
|
|
1619
|
+
}
|
|
1620
|
+
syncCitySize();
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function syncCitySize() {
|
|
1624
|
+
const radius = Number(cityForm.elements.namedItem("cityRadius")?.value);
|
|
1625
|
+
citySize.value = CITY_SIZE_PRESETS.find((preset) => preset.radiusMeters === radius)?.id ?? "";
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function sourceCityTemplate() {
|
|
1629
|
+
const templates = state.snapshot?.source?.topology?.templates;
|
|
1630
|
+
if (templates === undefined) return null;
|
|
1631
|
+
const city = validateSpatialTemplateConfiguration(templates).city;
|
|
1632
|
+
return city === undefined ? null : {
|
|
1633
|
+
value: `source:${city.presetId}@${city.revision}`,
|
|
1634
|
+
label: `当前已套用:${city.presetId} r${city.revision}`,
|
|
1635
|
+
parameters: city.parameters,
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
function populateCityTemplateOptions(active = cityTemplate.value) {
|
|
1640
|
+
const applied = sourceCityTemplate();
|
|
1641
|
+
cityTemplate.replaceChildren();
|
|
1642
|
+
const custom = element("option", "", "自定义"); custom.value = ""; cityTemplate.append(custom);
|
|
1643
|
+
if (applied) { const option = element("option", "", applied.label); option.value = applied.value; cityTemplate.append(option); }
|
|
1644
|
+
for (const template of cityTemplates) { const option = element("option", "", template.label); option.value = template.id; cityTemplate.append(option); }
|
|
1645
|
+
const values = new Set([...cityTemplate.options].map((option) => option.value));
|
|
1646
|
+
cityTemplate.value = values.has(active) ? active : "";
|
|
1647
|
+
return applied;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function setCityTemplates(records, templateOptions = {}) {
|
|
1651
|
+
const active = cityTemplate.value;
|
|
1652
|
+
const focused = document.activeElement === cityTemplate;
|
|
1653
|
+
cityTemplates = normalizeCityTemplates(records);
|
|
1654
|
+
state.cityTemplateDefaultId = typeof templateOptions.defaultId === "string" ? templateOptions.defaultId : state.cityTemplateDefaultId;
|
|
1655
|
+
populateCityTemplateOptions(active);
|
|
1656
|
+
if (focused) cityTemplate.focus();
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function markCityRoadPreviewError(error) {
|
|
1660
|
+
if (!state.cityPoint?.cityId) return;
|
|
1661
|
+
state.cityPoint.phase = "error";
|
|
1662
|
+
state.cityPoint.error = errorText(error);
|
|
1663
|
+
cityDialogStatus.dataset.state = "error";
|
|
1664
|
+
cityDialogStatus.textContent = `城市已保存,但地形路网生成失败:${state.cityPoint.error}`;
|
|
1665
|
+
submitCity.textContent = "重试生成路网";
|
|
1666
|
+
submitCity.disabled = false;
|
|
1667
|
+
cancelCity.disabled = false;
|
|
1668
|
+
cancelCity.textContent = "关闭";
|
|
1669
|
+
draw();
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function completeCityRoadPreview(result) {
|
|
1673
|
+
const cityId = state.cityPoint?.cityId;
|
|
1674
|
+
if (!cityId) return;
|
|
1675
|
+
const layout = result.cityLayouts.find((candidate) => candidate.cityId === cityId);
|
|
1676
|
+
const cityRoads = result.roads.filter((road) => road.cityId === cityId);
|
|
1677
|
+
const connectedRoads = layout ? cityRoads.filter((road) => layout.roads.includes(road.id)) : [];
|
|
1678
|
+
const unreachable = result.unreachable.filter((entry) => entry.fromId === cityId || entry.toId === cityId);
|
|
1679
|
+
if (!connectedRoads.length) {
|
|
1680
|
+
markCityRoadPreviewError(`城市已保存,但路网全部不可达${unreachable.length ? `(${unreachable.length} 条受限连接)` : ""}`);
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
const cityLabel = state.cities.find((city) => city.id === cityId)?.label ?? cityId;
|
|
1684
|
+
closeCityDialog();
|
|
1685
|
+
notify(unreachable.length ? `城市“${cityLabel}”已保存;${unreachable.length} 条路网连接不可达,其余道路已生成。` : `城市“${cityLabel}”及地形路网已生成。`, unreachable.length > 0);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
async function openCityDialogAt(clientX, clientY) {
|
|
1689
|
+
if (typeof options.createCity !== "function" || !state.baseCanvas || !state.previewGeneration || !state.snapshot) return;
|
|
1690
|
+
const draft = draftGeneration();
|
|
1691
|
+
if (state.busy || state.saving || state.loading || !draft || !state.savedGeneration
|
|
1692
|
+
|| !sameGeneration(state.savedGeneration, state.previewGeneration) || !sameGeneration(draft, state.previewGeneration)) {
|
|
1693
|
+
notify("请先确认并保存地图地形,再创建城市。", true); return;
|
|
1694
|
+
}
|
|
1695
|
+
const rect = canvas.getBoundingClientRect();
|
|
1696
|
+
const mapX = (clientX - rect.left - state.view.x) / state.view.scale;
|
|
1697
|
+
const mapY = (clientY - rect.top - state.view.y) / state.view.scale;
|
|
1698
|
+
if (mapX < 0 || mapY < 0 || mapX > state.baseCanvas.width || mapY > state.baseCanvas.height) return;
|
|
1699
|
+
const x = mapX / state.baseCanvas.width * state.previewGeneration.widthMeters;
|
|
1700
|
+
const y = mapY / state.baseCanvas.height * state.previewGeneration.heightMeters;
|
|
1701
|
+
const maximumRadius = Math.min(x, y, state.previewGeneration.widthMeters - x, state.previewGeneration.heightMeters - y, 200_000);
|
|
1702
|
+
if (maximumRadius < 32) { notify("该坐标离地图边界过近,无法容纳最小城市。", true); return; }
|
|
1703
|
+
state.cityPoint = { x, y, maximumRadius, phase: "draft", cityId: null, error: null, cityTemplateTouched: false };
|
|
1704
|
+
cityName.value = `城市 ${state.cities.length + 1}`;
|
|
1705
|
+
const radius = Math.max(32, Math.min(maximumRadius, Math.max(240, Math.min(state.previewGeneration.widthMeters, state.previewGeneration.heightMeters) / 40)));
|
|
1706
|
+
const blockSize = Math.max(16, Math.min(2000, Math.floor(radius / 6)));
|
|
1707
|
+
cityForm.elements.namedItem("cityRadius").value = String(Math.round(radius));
|
|
1708
|
+
cityForm.elements.namedItem("cityBlockSize").value = String(blockSize);
|
|
1709
|
+
cityForm.elements.namedItem("cityRoadWidth").value = String(Math.max(3, Math.min(40, blockSize * .18)));
|
|
1710
|
+
syncCitySize();
|
|
1711
|
+
for (const [name, value] of Object.entries({ residential: 50, commercial: 20, industrial: 10, civic: 10, park: 10 })) {
|
|
1712
|
+
cityForm.elements.namedItem(`cityUse_${name}`).value = String(value);
|
|
1713
|
+
}
|
|
1714
|
+
const appliedTemplate = populateCityTemplateOptions();
|
|
1715
|
+
if (appliedTemplate) {
|
|
1716
|
+
cityTemplate.value = appliedTemplate.value;
|
|
1717
|
+
applyCityParameters(appliedTemplate.parameters);
|
|
1718
|
+
} else {
|
|
1719
|
+
const defaultTemplate = cityTemplates.find((template) => template.id === state.cityTemplateDefaultId);
|
|
1720
|
+
if (defaultTemplate) { cityTemplate.value = defaultTemplate.id; applyCityParameters(defaultTemplate.parameters); }
|
|
1721
|
+
}
|
|
1722
|
+
cityDialogStatus.textContent = `坐标 ${Math.round(x).toLocaleString()}, ${Math.round(y).toLocaleString()} m`;
|
|
1723
|
+
cityDialogStatus.dataset.state = "ready";
|
|
1724
|
+
cityDialog.hidden = false;
|
|
1725
|
+
if (typeof cityDialog.show === "function") cityDialog.show(); else cityDialog.setAttribute("open", "");
|
|
1726
|
+
draw();
|
|
1727
|
+
cityName.focus(); cityName.select();
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
async function createCityFromDialog(event) {
|
|
1731
|
+
event.preventDefault();
|
|
1732
|
+
if (state.creatingCity || !state.cityPoint || !state.snapshot || !state.previewGeneration || !state.cityModule) return;
|
|
1733
|
+
if (state.cityPoint.cityId) {
|
|
1734
|
+
state.cityPoint.phase = "roads";
|
|
1735
|
+
state.cityPoint.error = null;
|
|
1736
|
+
cityDialogStatus.dataset.state = "loading";
|
|
1737
|
+
cityDialogStatus.textContent = "正在重新生成地形路网…";
|
|
1738
|
+
submitCity.disabled = true;
|
|
1739
|
+
state.roadAutoPreviewEnabled = true;
|
|
1740
|
+
generateRoadPreview();
|
|
1741
|
+
draw();
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
const expectedProject = state.snapshot.projectId;
|
|
1745
|
+
const expectedRevision = state.snapshot.revision;
|
|
1746
|
+
const expectedEpoch = state.epoch;
|
|
1747
|
+
const point = state.cityPoint;
|
|
1748
|
+
try {
|
|
1749
|
+
const draft = draftGeneration();
|
|
1750
|
+
if (state.busy || state.saving || state.loading || !draft || !state.savedGeneration
|
|
1751
|
+
|| !sameGeneration(state.savedGeneration, state.previewGeneration) || !sameGeneration(draft, state.previewGeneration)) {
|
|
1752
|
+
throw new Error("地图生成参数已变化,请先重新生成并保存。");
|
|
1753
|
+
}
|
|
1754
|
+
const label = cityName.value.trim();
|
|
1755
|
+
const radiusMeters = numberFromForm(cityForm, "cityRadius");
|
|
1756
|
+
const blockSizeMeters = numberFromForm(cityForm, "cityBlockSize");
|
|
1757
|
+
const landUseWeights = Object.fromEntries(["residential", "commercial", "industrial", "civic", "park"].map((name) => [name, numberFromForm(cityForm, `cityUse_${name}`)]));
|
|
1758
|
+
const roadWidthMeters = numberFromForm(cityForm, "cityRoadWidth");
|
|
1759
|
+
const id = stableCityId(label, point.x, point.y);
|
|
1760
|
+
const city = {
|
|
1761
|
+
id, label, x: point.x, y: point.y,
|
|
1762
|
+
seed: stableCitySeed(id, point.x, point.y),
|
|
1763
|
+
parameters: { radiusMeters, blockSizeMeters, roadWidthMeters, landUseWeights },
|
|
1764
|
+
};
|
|
1765
|
+
const currentWorld = state.cityWorld ?? {
|
|
1766
|
+
profile: state.cityModule.PROCEDURAL_WORLD_PROFILE,
|
|
1767
|
+
identity: state.cityModule.PROCEDURAL_GENERATOR_IDENTITY,
|
|
1768
|
+
worldSeed: state.previewGeneration.seed,
|
|
1769
|
+
cities: [],
|
|
1770
|
+
};
|
|
1771
|
+
const validatedWorld = state.cityModule.validateProceduralWorld(
|
|
1772
|
+
{ ...currentWorld, cities: [...currentWorld.cities, city] },
|
|
1773
|
+
{ widthMeters: state.previewGeneration.widthMeters, heightMeters: state.previewGeneration.heightMeters },
|
|
1774
|
+
);
|
|
1775
|
+
const validatedCity = validatedWorld.cities.at(-1);
|
|
1776
|
+
state.creatingCity = true; state.cityPoint.phase = "creating"; submitCity.disabled = true; cancelCity.disabled = true;
|
|
1777
|
+
cityDialogStatus.dataset.state = "loading"; cityDialogStatus.textContent = "正在生成城市…"; draw();
|
|
1778
|
+
const response = await options.createCity(validatedCity, expectedRevision);
|
|
1779
|
+
if (!state.open || state.epoch !== expectedEpoch || state.snapshot?.projectId !== expectedProject || state.snapshot?.revision !== expectedRevision) {
|
|
1780
|
+
closeCityDialog(); return;
|
|
1781
|
+
}
|
|
1782
|
+
const validated = validateSnapshot(response);
|
|
1783
|
+
if (validated.value.projectId !== expectedProject || validated.value.revision <= expectedRevision) throw new Error("城市创建读回 revision 无效");
|
|
1784
|
+
state.snapshot = validated.value;
|
|
1785
|
+
state.savedGeneration = validated.savedGeneration;
|
|
1786
|
+
state.savedRoadGeneration = validated.savedRoadGeneration;
|
|
1787
|
+
updateActorBinding(validated.value);
|
|
1788
|
+
if (!await loadCities(validated.value, state.previewGeneration, () => state.open && state.epoch === expectedEpoch
|
|
1789
|
+
&& state.snapshot?.projectId === expectedProject && state.snapshot?.revision === validated.value.revision)) return;
|
|
1790
|
+
state.roadAutoPreviewEnabled = true;
|
|
1791
|
+
state.cityPoint = { ...point, phase: "roads", cityId: validatedCity.id, error: null };
|
|
1792
|
+
cityDialogStatus.dataset.state = "loading";
|
|
1793
|
+
cityDialogStatus.textContent = "城市已保存,正在生成地形路网…";
|
|
1794
|
+
submitCity.textContent = "重试生成路网";
|
|
1795
|
+
cancelCity.textContent = "关闭";
|
|
1796
|
+
scheduleRoadPreview();
|
|
1797
|
+
draw(); updateStatus();
|
|
1798
|
+
} catch (error) {
|
|
1799
|
+
if (state.open && state.epoch === expectedEpoch && state.snapshot?.projectId === expectedProject) {
|
|
1800
|
+
state.cityPoint = { ...point, phase: "error", cityId: null, error: errorText(error) };
|
|
1801
|
+
cityDialogStatus.dataset.state = "error"; cityDialogStatus.textContent = errorText(error); submitCity.textContent = "重试生成城市"; notify(errorText(error), true); draw();
|
|
1802
|
+
}
|
|
1803
|
+
} finally {
|
|
1804
|
+
state.creatingCity = false; submitCity.disabled = false; cancelCity.disabled = false;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
function canvasSize() {
|
|
1809
|
+
const rect = canvas.getBoundingClientRect();
|
|
1810
|
+
return { width: Math.max(320, Math.round(rect.width || stage.clientWidth || 800)), height: Math.max(320, Math.round(rect.height || stage.clientHeight || 560)) };
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function draw() {
|
|
1814
|
+
const context = canvas.getContext("2d", { alpha: false });
|
|
1815
|
+
if (!context) return;
|
|
1816
|
+
const size = canvasSize();
|
|
1817
|
+
const ratio = pixelRatio();
|
|
1818
|
+
if (canvas.width !== Math.round(size.width * ratio) || canvas.height !== Math.round(size.height * ratio)) {
|
|
1819
|
+
canvas.width = Math.round(size.width * ratio); canvas.height = Math.round(size.height * ratio);
|
|
1820
|
+
}
|
|
1821
|
+
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
1822
|
+
context.fillStyle = "#161516";
|
|
1823
|
+
context.fillRect(0, 0, size.width, size.height);
|
|
1824
|
+
if (!state.baseCanvas) return;
|
|
1825
|
+
context.save();
|
|
1826
|
+
context.translate(state.view.x, state.view.y);
|
|
1827
|
+
context.scale(state.view.scale, state.view.scale);
|
|
1828
|
+
context.imageSmoothingEnabled = true;
|
|
1829
|
+
context.drawImage(state.baseCanvas, 0, 0);
|
|
1830
|
+
const terrainContours = visibleContourPlan();
|
|
1831
|
+
drawBaseTerrainContours(context, terrainContours);
|
|
1832
|
+
const shorelines = [];
|
|
1833
|
+
const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
|
1834
|
+
const now = performance.now();
|
|
1835
|
+
for (const tile of state.visibleTiles) {
|
|
1836
|
+
const exact = cachedTileOrParent(tile);
|
|
1837
|
+
if (!exact) continue;
|
|
1838
|
+
if (exact.ancestor.level !== tile.level) {
|
|
1839
|
+
drawTileRaster(context, tile, exact);
|
|
1840
|
+
continue;
|
|
1841
|
+
}
|
|
1842
|
+
const alpha = reducedMotion ? 1 : Math.max(0, Math.min(1, (now - exact.cached.loadedAt) / LOD_CROSSFADE_MS));
|
|
1843
|
+
if (alpha < 1) {
|
|
1844
|
+
const parent = cachedTileOrParent(tile, true);
|
|
1845
|
+
if (parent) drawTileRaster(context, tile, parent);
|
|
1846
|
+
scheduleFadeFrame();
|
|
1847
|
+
}
|
|
1848
|
+
drawTileRaster(context, tile, exact, alpha);
|
|
1849
|
+
shorelines.push({ tile, cached: exact.cached, alpha });
|
|
1850
|
+
}
|
|
1851
|
+
for (const shoreline of shorelines) {
|
|
1852
|
+
drawTileShoreline(context, shoreline.tile, shoreline.cached, shoreline.alpha);
|
|
1853
|
+
drawTileTerrainContours(context, shoreline.tile, shoreline.cached, terrainContours, shoreline.alpha);
|
|
1854
|
+
}
|
|
1855
|
+
drawRoads(context);
|
|
1856
|
+
drawCities(context);
|
|
1857
|
+
drawCityDraft(context);
|
|
1858
|
+
if (layerEnabled("showObjects")) {
|
|
1859
|
+
context.font = `${11 / state.view.scale}px ui-sans-serif, sans-serif`;
|
|
1860
|
+
context.textBaseline = "middle";
|
|
1861
|
+
for (const marker of state.markers) {
|
|
1862
|
+
const x = marker.x * state.baseCanvas.width;
|
|
1863
|
+
const y = marker.y * state.baseCanvas.height;
|
|
1864
|
+
const radius = 4 / state.view.scale;
|
|
1865
|
+
context.beginPath(); context.arc(x, y, radius, 0, Math.PI * 2); context.fillStyle = "#f2f2f2"; context.fill();
|
|
1866
|
+
context.beginPath(); context.arc(x, y, radius * 0.5, 0, Math.PI * 2); context.fillStyle = "#32d551"; context.fill();
|
|
1867
|
+
context.fillStyle = "#f2f2f2"; context.fillText(marker.id, x + radius * 1.8, y);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
context.restore();
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
function fitMap() {
|
|
1874
|
+
if (!state.baseCanvas) return;
|
|
1875
|
+
const size = canvasSize();
|
|
1876
|
+
state.view.scale = Math.min(size.width / state.baseCanvas.width, size.height / state.baseCanvas.height) * 0.92;
|
|
1877
|
+
state.view.x = (size.width - state.baseCanvas.width * state.view.scale) / 2;
|
|
1878
|
+
state.view.y = (size.height - state.baseCanvas.height * state.view.scale) / 2;
|
|
1879
|
+
state.fitted = true;
|
|
1880
|
+
refreshDetailPlan(true);
|
|
1881
|
+
draw();
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function zoom(factor, centerX, centerY) {
|
|
1885
|
+
if (!state.baseCanvas) return;
|
|
1886
|
+
const size = canvasSize();
|
|
1887
|
+
const x = centerX ?? size.width / 2;
|
|
1888
|
+
const y = centerY ?? size.height / 2;
|
|
1889
|
+
const metersPerBasePixel = Math.sqrt(
|
|
1890
|
+
state.previewGeneration.widthMeters / state.baseCanvas.width * state.previewGeneration.heightMeters / state.baseCanvas.height,
|
|
1891
|
+
);
|
|
1892
|
+
const finestMetersPerTexel = Math.max(state.previewGeneration.widthMeters, state.previewGeneration.heightMeters) / (2 ** LOD_MAX_LEVEL * (LOD_TILE_SIZE - 1));
|
|
1893
|
+
const maxScale = metersPerBasePixel / Math.max(0.25, finestMetersPerTexel);
|
|
1894
|
+
const next = Math.max(0.25, Math.min(maxScale, state.view.scale * factor));
|
|
1895
|
+
const worldX = (x - state.view.x) / state.view.scale;
|
|
1896
|
+
const worldY = (y - state.view.y) / state.view.scale;
|
|
1897
|
+
state.view.scale = next;
|
|
1898
|
+
state.view.x = x - worldX * next;
|
|
1899
|
+
state.view.y = y - worldY * next;
|
|
1900
|
+
state.fitted = false;
|
|
1901
|
+
refreshDetailPlan();
|
|
1902
|
+
draw();
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function focusObject(spatialId) {
|
|
1906
|
+
if (!state.open || !state.result || !state.baseCanvas || !state.previewGeneration || typeof spatialId !== "string" || !spatialId) return false;
|
|
1907
|
+
const viewport = canvasSize();
|
|
1908
|
+
const marker = state.markers.find((entry) => entry.id === spatialId);
|
|
1909
|
+
const city = state.cities.find((entry) => entry.id === spatialId);
|
|
1910
|
+
if (!marker && !city) return false;
|
|
1911
|
+
let mapX;
|
|
1912
|
+
let mapY;
|
|
1913
|
+
if (city) {
|
|
1914
|
+
mapX = city.x / state.previewGeneration.widthMeters * state.baseCanvas.width;
|
|
1915
|
+
mapY = city.y / state.previewGeneration.heightMeters * state.baseCanvas.height;
|
|
1916
|
+
const radiusInMapPixels = city.parameters.radiusMeters * Math.sqrt(
|
|
1917
|
+
state.baseCanvas.width / state.previewGeneration.widthMeters * state.baseCanvas.height / state.previewGeneration.heightMeters,
|
|
1918
|
+
);
|
|
1919
|
+
const roadThreshold = Number(roadThresholdInput.value) || 80;
|
|
1920
|
+
state.view.scale = Math.max(state.view.scale, roadThreshold / Math.max(0.000001, radiusInMapPixels));
|
|
1921
|
+
} else {
|
|
1922
|
+
mapX = marker.x * state.baseCanvas.width;
|
|
1923
|
+
mapY = marker.y * state.baseCanvas.height;
|
|
1924
|
+
}
|
|
1925
|
+
state.view.x = viewport.width / 2 - mapX * state.view.scale;
|
|
1926
|
+
state.view.y = viewport.height / 2 - mapY * state.view.scale;
|
|
1927
|
+
state.fitted = false;
|
|
1928
|
+
refreshDetailPlan();
|
|
1929
|
+
draw();
|
|
1930
|
+
return true;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
function updateStats(rendered, result, generation) {
|
|
1934
|
+
statNodes.size.textContent = `${(generation.widthMeters / 1000).toLocaleString()} × ${(generation.heightMeters / 1000).toLocaleString()} km`;
|
|
1935
|
+
statNodes.grid.textContent = `${result.width} × ${result.height}`;
|
|
1936
|
+
statNodes.water.textContent = formatPercent(rendered.waterCells / result.elevations.length);
|
|
1937
|
+
statNodes.relief.textContent = `${rendered.minimum.toFixed(2)} — ${rendered.maximum.toFixed(2)}`;
|
|
1938
|
+
statNodes.rivers.textContent = `${rendered.riverCells.toLocaleString()} 格`;
|
|
1939
|
+
statNodes.objects.textContent = `${state.markers.length.toLocaleString()} 个`;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
function acceptResult(result, generation) {
|
|
1943
|
+
const generationChanged = state.previewGeneration && !sameGeneration(state.previewGeneration, generation);
|
|
1944
|
+
if (generationChanged) {
|
|
1945
|
+
clearTileRuntime(true);
|
|
1946
|
+
terminateRoadWorker();
|
|
1947
|
+
state.roadResult = null;
|
|
1948
|
+
state.previewRoadGeneration = null;
|
|
1949
|
+
}
|
|
1950
|
+
const firstPreview = !state.baseCanvas;
|
|
1951
|
+
const previousGeneration = state.previewGeneration;
|
|
1952
|
+
const previousWidth = state.baseCanvas?.width ?? result.width;
|
|
1953
|
+
const previousHeight = state.baseCanvas?.height ?? result.height;
|
|
1954
|
+
const viewport = canvasSize();
|
|
1955
|
+
const centerX = (viewport.width / 2 - state.view.x) / state.view.scale / previousWidth;
|
|
1956
|
+
const centerY = (viewport.height / 2 - state.view.y) / state.view.scale / previousHeight;
|
|
1957
|
+
const rendered = buildBaseMap(result, generation);
|
|
1958
|
+
state.result = result;
|
|
1959
|
+
state.previewGeneration = generation;
|
|
1960
|
+
state.baseCanvas = rendered.canvas;
|
|
1961
|
+
state.baseRenderStats = rendered;
|
|
1962
|
+
state.requestedGeneration = null;
|
|
1963
|
+
state.markers = objectMarkers(state.snapshot?.source?.objects, generation);
|
|
1964
|
+
updateActorBinding(state.snapshot);
|
|
1965
|
+
updateStats(rendered, result, generation);
|
|
1966
|
+
state.errorKind = null;
|
|
1967
|
+
setOverlay("");
|
|
1968
|
+
if (firstPreview) fitMap();
|
|
1969
|
+
else {
|
|
1970
|
+
let nextCenterX = centerX;
|
|
1971
|
+
let nextCenterY = centerY;
|
|
1972
|
+
if (previousGeneration) {
|
|
1973
|
+
nextCenterX = Math.max(0, Math.min(1, centerX * previousGeneration.widthMeters / generation.widthMeters));
|
|
1974
|
+
nextCenterY = Math.max(0, Math.min(1, centerY * previousGeneration.heightMeters / generation.heightMeters));
|
|
1975
|
+
const scaleX = state.view.scale * previousWidth / previousGeneration.widthMeters * generation.widthMeters / result.width;
|
|
1976
|
+
const scaleY = state.view.scale * previousHeight / previousGeneration.heightMeters * generation.heightMeters / result.height;
|
|
1977
|
+
const metersPerBasePixel = Math.sqrt(generation.widthMeters / result.width * generation.heightMeters / result.height);
|
|
1978
|
+
state.view.scale = Math.max(0.01, Math.min(metersPerBasePixel / 0.25, Math.sqrt(scaleX * scaleY)));
|
|
1979
|
+
}
|
|
1980
|
+
state.view.x = viewport.width / 2 - nextCenterX * result.width * state.view.scale;
|
|
1981
|
+
state.view.y = viewport.height / 2 - nextCenterY * result.height * state.view.scale;
|
|
1982
|
+
state.fitted = false;
|
|
1983
|
+
refreshDetailPlan(true);
|
|
1984
|
+
draw();
|
|
1985
|
+
}
|
|
1986
|
+
if (state.roadAutoPreviewEnabled) scheduleRoadPreview();
|
|
1987
|
+
updateStatus();
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
function staleGeneration(message) {
|
|
1991
|
+
terminateWorker();
|
|
1992
|
+
state.result = null;
|
|
1993
|
+
state.previewGeneration = null;
|
|
1994
|
+
state.requestedGeneration = null;
|
|
1995
|
+
state.errorKind = "generate";
|
|
1996
|
+
setOverlay(message, true, "重新读取", () => { void open(); });
|
|
1997
|
+
status.textContent = "项目已变化 · 预览已丢弃";
|
|
1998
|
+
notify(message, true);
|
|
1999
|
+
updateStatus();
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
function generate(generation) {
|
|
2003
|
+
if (!state.snapshot || state.loading || state.errorKind === "read") return;
|
|
2004
|
+
clearPreviewTimer();
|
|
2005
|
+
terminateWorker();
|
|
2006
|
+
clearTileRuntime(false);
|
|
2007
|
+
state.errorKind = null;
|
|
2008
|
+
state.busy = true;
|
|
2009
|
+
state.requestedGeneration = generation;
|
|
2010
|
+
const id = ++state.requestId;
|
|
2011
|
+
const gate = { id, epoch: state.epoch, projectId: state.snapshot.projectId, revision: state.snapshot.revision };
|
|
2012
|
+
const worker = new Worker(new URL("./world-map-worker.js", import.meta.url), { type: "module", name: "world-map-generator" });
|
|
2013
|
+
gate.worker = worker;
|
|
2014
|
+
state.worker = worker;
|
|
2015
|
+
worker.addEventListener("message", async (event) => {
|
|
2016
|
+
if (event.data?.id !== id || !currentGate(gate)) return;
|
|
2017
|
+
if (event.data?.error) {
|
|
2018
|
+
terminateWorker();
|
|
2019
|
+
state.errorKind = "generate";
|
|
2020
|
+
const message = String(event.data.error);
|
|
2021
|
+
setOverlay(message, true, "重试生成", generateFromCurrentForm);
|
|
2022
|
+
notify(message, true); updateStatus(); return;
|
|
2023
|
+
}
|
|
2024
|
+
try {
|
|
2025
|
+
const result = validateResult(event.data?.result, generation);
|
|
2026
|
+
const latest = validateSnapshot(await options.read()).value;
|
|
2027
|
+
if (!currentGate(gate)) return;
|
|
2028
|
+
if (latest.projectId !== gate.projectId || latest.revision !== gate.revision) {
|
|
2029
|
+
staleGeneration("项目在生成期间已变化,旧预览已丢弃,请重新打开地图。");
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
terminateWorker();
|
|
2033
|
+
acceptResult(result, generation);
|
|
2034
|
+
} catch (error) {
|
|
2035
|
+
if (!currentGate(gate)) return;
|
|
2036
|
+
terminateWorker();
|
|
2037
|
+
state.errorKind = "generate";
|
|
2038
|
+
setOverlay(errorText(error), true, "重试生成", generateFromCurrentForm);
|
|
2039
|
+
notify(errorText(error), true); updateStatus();
|
|
2040
|
+
}
|
|
2041
|
+
});
|
|
2042
|
+
worker.addEventListener("error", (event) => {
|
|
2043
|
+
if (!currentGate(gate)) return;
|
|
2044
|
+
const message = event.message || "地图生成 Worker 失败";
|
|
2045
|
+
terminateWorker();
|
|
2046
|
+
state.errorKind = "generate";
|
|
2047
|
+
setOverlay(message, true, "重试生成", generateFromCurrentForm);
|
|
2048
|
+
notify(message, true); updateStatus();
|
|
2049
|
+
});
|
|
2050
|
+
setOverlay("正在运行 WASM 水文生成器…");
|
|
2051
|
+
updateStatus();
|
|
2052
|
+
worker.postMessage({ id, settings: generation });
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
function generateFromCurrentForm() {
|
|
2056
|
+
state.autoPreviewEnabled = true;
|
|
2057
|
+
const generation = draftGeneration();
|
|
2058
|
+
if (generation) generate(generation);
|
|
2059
|
+
else updateStatus();
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
function schedulePreview() {
|
|
2063
|
+
updateStatus();
|
|
2064
|
+
if (!state.autoPreviewEnabled || !state.open || state.loading || state.saving || state.errorKind === "read") return;
|
|
2065
|
+
const generation = draftGeneration();
|
|
2066
|
+
if (!generation) {
|
|
2067
|
+
clearPreviewTimer();
|
|
2068
|
+
terminateWorker();
|
|
2069
|
+
state.requestedGeneration = null;
|
|
2070
|
+
updateStatus();
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
terminateWorker();
|
|
2074
|
+
state.requestedGeneration = null;
|
|
2075
|
+
clearPreviewTimer();
|
|
2076
|
+
updateStatus();
|
|
2077
|
+
const gate = { epoch: state.epoch, projectId: state.snapshot?.projectId, revision: state.snapshot?.revision };
|
|
2078
|
+
state.previewTimer = setTimeout(() => {
|
|
2079
|
+
state.previewTimer = null;
|
|
2080
|
+
if (!state.open || state.epoch !== gate.epoch || state.snapshot?.projectId !== gate.projectId || state.snapshot?.revision !== gate.revision) return;
|
|
2081
|
+
const latest = draftGeneration();
|
|
2082
|
+
if (latest && sameGeneration(latest, generation)) generate(latest);
|
|
2083
|
+
}, 180);
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
function generateRoadPreview(roadGeneration = draftRoadGeneration()) {
|
|
2087
|
+
if (!state.open || !state.snapshot || !state.previewGeneration || !roadGeneration || state.loading || state.saving) return;
|
|
2088
|
+
clearRoadPreviewTimer();
|
|
2089
|
+
terminateRoadWorker();
|
|
2090
|
+
const generation = state.previewGeneration;
|
|
2091
|
+
const cities = [...state.cities];
|
|
2092
|
+
const key = roadPreviewKey(generation, roadGeneration, cities);
|
|
2093
|
+
state.roadError = null;
|
|
2094
|
+
state.roadBusy = true;
|
|
2095
|
+
state.roadRequestedKey = key;
|
|
2096
|
+
const id = ++state.roadRequestId;
|
|
2097
|
+
const gate = { id, epoch: state.epoch, projectId: state.snapshot.projectId, revision: state.snapshot.revision, key };
|
|
2098
|
+
const worker = new Worker(new URL("./world-map-worker.js", import.meta.url), { type: "module", name: "world-map-road-generator" });
|
|
2099
|
+
gate.worker = worker;
|
|
2100
|
+
state.roadWorker = worker;
|
|
2101
|
+
worker.addEventListener("message", async (event) => {
|
|
2102
|
+
if (event.data?.id !== id || event.data?.kind !== "roads" || !currentRoadGate(gate)) return;
|
|
2103
|
+
if (event.data?.error) {
|
|
2104
|
+
terminateRoadWorker();
|
|
2105
|
+
state.roadError = String(event.data.error);
|
|
2106
|
+
markCityRoadPreviewError(state.roadError);
|
|
2107
|
+
notify(state.roadError, true); updateStatus(); draw(); return;
|
|
2108
|
+
}
|
|
2109
|
+
try {
|
|
2110
|
+
const result = validateTerrainRoadResult(event.data?.result, state.cities.map((city) => city.id));
|
|
2111
|
+
const latest = validateSnapshot(await options.read()).value;
|
|
2112
|
+
if (!currentRoadGate(gate)) return;
|
|
2113
|
+
if (latest.projectId !== gate.projectId || latest.revision !== gate.revision) {
|
|
2114
|
+
terminateRoadWorker(); state.roadResult = null; state.roadError = "项目已变化,旧道路预览已丢弃。"; updateStatus(); draw(); return;
|
|
2115
|
+
}
|
|
2116
|
+
terminateRoadWorker();
|
|
2117
|
+
state.roadResult = result;
|
|
2118
|
+
state.previewRoadGeneration = roadGeneration;
|
|
2119
|
+
try {
|
|
2120
|
+
cacheRoadParcels(result, roadGeneration);
|
|
2121
|
+
state.roadParcelError = null;
|
|
2122
|
+
} catch (error) {
|
|
2123
|
+
state.roadParcelLayouts.clear();
|
|
2124
|
+
state.roadParcelError = errorText(error);
|
|
2125
|
+
notify(`地块生成失败,请调整参数/重试:${state.roadParcelError}`, true);
|
|
2126
|
+
}
|
|
2127
|
+
state.roadError = null;
|
|
2128
|
+
completeCityRoadPreview(result);
|
|
2129
|
+
updateStatus(); draw();
|
|
2130
|
+
} catch (error) {
|
|
2131
|
+
if (!currentRoadGate(gate)) return;
|
|
2132
|
+
terminateRoadWorker(); state.roadError = errorText(error); markCityRoadPreviewError(error); notify(state.roadError, true); updateStatus(); draw();
|
|
2133
|
+
}
|
|
2134
|
+
});
|
|
2135
|
+
worker.addEventListener("error", (event) => {
|
|
2136
|
+
if (!currentRoadGate(gate)) return;
|
|
2137
|
+
terminateRoadWorker(); state.roadError = event.message || "道路生成 Worker 失败"; markCityRoadPreviewError(state.roadError); notify(state.roadError, true); updateStatus(); draw();
|
|
2138
|
+
});
|
|
2139
|
+
updateStatus();
|
|
2140
|
+
worker.postMessage({ id, epoch: gate.epoch, kind: "roads", settings: generation, roadGeneration, cities });
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function scheduleRoadPreview() {
|
|
2144
|
+
updateStatus();
|
|
2145
|
+
if (!state.roadAutoPreviewEnabled || !state.open || !state.previewGeneration || state.loading || state.saving || state.roadSaving) return;
|
|
2146
|
+
const roadGeneration = draftRoadGeneration();
|
|
2147
|
+
if (!roadGeneration) { clearRoadPreviewTimer(); terminateRoadWorker(); return; }
|
|
2148
|
+
terminateRoadWorker();
|
|
2149
|
+
clearRoadPreviewTimer();
|
|
2150
|
+
const key = roadPreviewKey(state.previewGeneration, roadGeneration, state.cities);
|
|
2151
|
+
state.roadPreviewTimer = setTimeout(() => {
|
|
2152
|
+
state.roadPreviewTimer = null;
|
|
2153
|
+
if (!state.open || !state.previewGeneration || state.roadRequestedKey === key && state.roadResult) return;
|
|
2154
|
+
const latest = draftRoadGeneration();
|
|
2155
|
+
if (latest && roadPreviewKey(state.previewGeneration, latest, state.cities) === key) generateRoadPreview(latest);
|
|
2156
|
+
}, ROAD_PREVIEW_DEBOUNCE_MS);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
async function saveRoadPreview() {
|
|
2160
|
+
if (typeof options.saveRoadGeneration !== "function" || state.roadSaving || state.roadBusy || !state.snapshot || !state.previewGeneration || !state.roadResult) return;
|
|
2161
|
+
const roadGeneration = draftRoadGeneration();
|
|
2162
|
+
if (!roadGeneration || !state.previewRoadGeneration || !sameRoadGeneration(roadGeneration, state.previewRoadGeneration)) { notify("道路参数已变化,请先完成新的预览。", true); return; }
|
|
2163
|
+
const replacing = Boolean(state.savedRoadGeneration && !sameRoadGeneration(state.savedRoadGeneration, roadGeneration));
|
|
2164
|
+
if (replacing && !window.confirm("保存会替换当前道路生成参数;确定继续吗?")) return;
|
|
2165
|
+
const expectedProject = state.snapshot.projectId;
|
|
2166
|
+
const expectedRevision = state.snapshot.revision;
|
|
2167
|
+
const expectedEpoch = state.epoch;
|
|
2168
|
+
const token = ++state.roadSaveToken;
|
|
2169
|
+
state.roadSaving = true; updateStatus();
|
|
2170
|
+
try {
|
|
2171
|
+
// 点击“保存道路参数”本身即为首次保存的明确确认;替换已有参数另给一次确认框。
|
|
2172
|
+
const response = await options.saveRoadGeneration(roadGeneration, expectedRevision, true);
|
|
2173
|
+
const validated = validateSnapshot(response);
|
|
2174
|
+
if (validated.value.projectId !== expectedProject || validated.value.revision <= expectedRevision || !validated.savedRoadGeneration
|
|
2175
|
+
|| !sameRoadGeneration(validated.savedRoadGeneration, roadGeneration)) throw new Error("道路参数保存读回无效");
|
|
2176
|
+
if (state.open && state.epoch === expectedEpoch && state.snapshot?.projectId === expectedProject && state.snapshot?.revision === expectedRevision) {
|
|
2177
|
+
state.snapshot = validated.value;
|
|
2178
|
+
state.savedGeneration = validated.savedGeneration;
|
|
2179
|
+
state.savedRoadGeneration = validated.savedRoadGeneration;
|
|
2180
|
+
state.markers = objectMarkers(validated.value.source?.objects, state.previewGeneration);
|
|
2181
|
+
await loadCities(validated.value, state.previewGeneration, () => state.open && state.epoch === expectedEpoch
|
|
2182
|
+
&& state.snapshot?.projectId === expectedProject && state.snapshot?.revision === validated.value.revision);
|
|
2183
|
+
notify("道路参数已保存;派生路网保持本地缓存。", false);
|
|
2184
|
+
}
|
|
2185
|
+
} catch (error) {
|
|
2186
|
+
if (state.open && state.epoch === expectedEpoch && state.snapshot?.projectId === expectedProject) { state.roadError = errorText(error); notify(state.roadError, true); }
|
|
2187
|
+
} finally {
|
|
2188
|
+
if (state.roadSaveToken === token) state.roadSaving = false;
|
|
2189
|
+
if (state.open && state.epoch === expectedEpoch) { updateStatus(); draw(); }
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
async function open() {
|
|
2194
|
+
if (state.destroyed) throw new Error("世界地图已销毁");
|
|
2195
|
+
const epoch = ++state.epoch;
|
|
2196
|
+
const previousSnapshot = state.snapshot;
|
|
2197
|
+
const interruptedGeneration = state.requestedGeneration;
|
|
2198
|
+
state.open = true;
|
|
2199
|
+
shell.hidden = false;
|
|
2200
|
+
terminateWorker();
|
|
2201
|
+
clearTileRuntime(false);
|
|
2202
|
+
clearPreviewTimer();
|
|
2203
|
+
state.loading = true;
|
|
2204
|
+
state.saving = false;
|
|
2205
|
+
state.saveToken += 1;
|
|
2206
|
+
state.errorKind = null;
|
|
2207
|
+
setOverlay("正在读取地图…");
|
|
2208
|
+
status.textContent = "正在读取项目地图…";
|
|
2209
|
+
updateStatus();
|
|
2210
|
+
try {
|
|
2211
|
+
const validated = validateSnapshot(await options.read());
|
|
2212
|
+
if (!state.open || state.epoch !== epoch) return;
|
|
2213
|
+
const sameIdentity = previousSnapshot?.projectId === validated.value.projectId && previousSnapshot?.revision === validated.value.revision;
|
|
2214
|
+
state.snapshot = validated.value;
|
|
2215
|
+
state.savedGeneration = validated.savedGeneration;
|
|
2216
|
+
state.savedRoadGeneration = validated.savedRoadGeneration;
|
|
2217
|
+
updateActorBinding(validated.value);
|
|
2218
|
+
state.autoPreviewEnabled = sameIdentity ? state.autoPreviewEnabled || Boolean(validated.savedGeneration) || Boolean(state.result) : Boolean(validated.savedGeneration);
|
|
2219
|
+
state.loading = false;
|
|
2220
|
+
state.errorKind = null;
|
|
2221
|
+
if (sameIdentity && state.result && state.previewGeneration) {
|
|
2222
|
+
state.markers = objectMarkers(validated.value.source?.objects, state.previewGeneration);
|
|
2223
|
+
if (!await loadCities(validated.value, state.previewGeneration, () => state.open && state.epoch === epoch
|
|
2224
|
+
&& state.snapshot?.projectId === validated.value.projectId && state.snapshot?.revision === validated.value.revision)) return;
|
|
2225
|
+
if (state.roadAutoPreviewEnabled) scheduleRoadPreview();
|
|
2226
|
+
setOverlay(""); draw(); updateStatus();
|
|
2227
|
+
const draft = draftGeneration();
|
|
2228
|
+
if (state.autoPreviewEnabled && draft && !sameGeneration(draft, state.previewGeneration)) schedulePreview();
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
if (sameIdentity && interruptedGeneration && state.autoPreviewEnabled) {
|
|
2232
|
+
generate(draftGeneration() ?? interruptedGeneration);
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
if (previousSnapshot && !sameIdentity) notify("项目或 revision 已变化,先前地图草稿已丢弃。", true);
|
|
2236
|
+
const retainedDraft = sameIdentity ? draftGeneration() : null;
|
|
2237
|
+
const generation = retainedDraft ?? initialGeneration(validated.value, validated.savedGeneration);
|
|
2238
|
+
if (!retainedDraft) setFormGeneration(form, generation);
|
|
2239
|
+
// 打开旧图只保留其 identity,不把它替换成当前默认值;升级入口只切换内存草稿。
|
|
2240
|
+
state.roadDraftIdentity = validated.savedRoadGeneration?.identity ?? ROAD_GENERATION_IDENTITY;
|
|
2241
|
+
setFormRoadGeneration(roadForm, validated.savedRoadGeneration ?? DEFAULT_ROAD_GENERATION);
|
|
2242
|
+
state.previewRoadGeneration = null;
|
|
2243
|
+
state.roadResult = null;
|
|
2244
|
+
state.roadError = null;
|
|
2245
|
+
state.roadAutoPreviewEnabled = sameIdentity ? state.roadAutoPreviewEnabled || Boolean(validated.savedRoadGeneration) : Boolean(validated.savedRoadGeneration);
|
|
2246
|
+
state.markers = objectMarkers(validated.value.source?.objects, generation);
|
|
2247
|
+
if (!await loadCities(validated.value, generation, () => state.open && state.epoch === epoch
|
|
2248
|
+
&& state.snapshot?.projectId === validated.value.projectId && state.snapshot?.revision === validated.value.revision)) return;
|
|
2249
|
+
// 已保存地形且已有城市时,首次进入默认生成本地未保存路网;不写 source。
|
|
2250
|
+
if (validated.savedGeneration && state.cities.length > 0) state.roadAutoPreviewEnabled = true;
|
|
2251
|
+
state.result = null;
|
|
2252
|
+
state.previewGeneration = null;
|
|
2253
|
+
state.baseCanvas = null;
|
|
2254
|
+
state.baseRenderStats = null;
|
|
2255
|
+
clearTileRuntime(true);
|
|
2256
|
+
state.requestedGeneration = null;
|
|
2257
|
+
if (validated.savedGeneration) {
|
|
2258
|
+
settingsPanel.hidden = false;
|
|
2259
|
+
settingsButton.setAttribute("aria-expanded", "true");
|
|
2260
|
+
generate(generation);
|
|
2261
|
+
} else {
|
|
2262
|
+
settingsPanel.hidden = false;
|
|
2263
|
+
settingsButton.setAttribute("aria-expanded", "true");
|
|
2264
|
+
setOverlay("这张地图还没有地形。先在右侧设置范围与水文参数,再进入预览。", false, "下一步:生成预览", generateFromCurrentForm);
|
|
2265
|
+
draw();
|
|
2266
|
+
updateStatus();
|
|
2267
|
+
}
|
|
2268
|
+
} catch (error) {
|
|
2269
|
+
if (!state.open || state.epoch !== epoch) return;
|
|
2270
|
+
state.loading = false;
|
|
2271
|
+
state.errorKind = "read";
|
|
2272
|
+
setOverlay(errorText(error), true, "重新读取", () => { void open(); });
|
|
2273
|
+
notify(errorText(error), true); updateStatus();
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
function close() {
|
|
2278
|
+
if (state.destroyed) return;
|
|
2279
|
+
state.open = false;
|
|
2280
|
+
state.epoch += 1;
|
|
2281
|
+
state.saveToken += 1;
|
|
2282
|
+
state.saving = false;
|
|
2283
|
+
state.loading = false;
|
|
2284
|
+
clearPreviewTimer();
|
|
2285
|
+
clearRoadPreviewTimer();
|
|
2286
|
+
terminateWorker();
|
|
2287
|
+
terminateRoadWorker();
|
|
2288
|
+
clearTileRuntime(false);
|
|
2289
|
+
if (!state.creatingCity) closeCityDialog();
|
|
2290
|
+
state.drag = null;
|
|
2291
|
+
shell.hidden = true;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
function destroy() {
|
|
2295
|
+
if (state.destroyed) return;
|
|
2296
|
+
close();
|
|
2297
|
+
state.destroyed = true;
|
|
2298
|
+
resizeObserver?.disconnect();
|
|
2299
|
+
root.replaceChildren();
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
async function savePreview() {
|
|
2303
|
+
if (state.saving || state.busy || !state.result || !state.previewGeneration || !state.snapshot) return;
|
|
2304
|
+
const currentDraft = draftGeneration();
|
|
2305
|
+
if (!currentDraft || !sameGeneration(currentDraft, state.previewGeneration)) {
|
|
2306
|
+
notify("设置已变化,请先重新生成预览。", true); updateStatus(); return;
|
|
2307
|
+
}
|
|
2308
|
+
const replacing = Boolean(state.savedGeneration && !sameGeneration(state.savedGeneration, currentDraft));
|
|
2309
|
+
if (replacing && !window.confirm("保存会替换当前地图地形生成参数;已有世界对象会保留。确定继续吗?")) return;
|
|
2310
|
+
const expectedProject = state.snapshot.projectId;
|
|
2311
|
+
const expectedRevision = state.snapshot.revision;
|
|
2312
|
+
const saveToken = ++state.saveToken;
|
|
2313
|
+
const saveEpoch = state.epoch;
|
|
2314
|
+
state.saving = true;
|
|
2315
|
+
updateStatus();
|
|
2316
|
+
try {
|
|
2317
|
+
const validated = validateSnapshot(await options.save(currentDraft, expectedRevision, replacing));
|
|
2318
|
+
if (validated.value.projectId !== expectedProject) throw new Error("保存读回属于另一个项目");
|
|
2319
|
+
if (validated.value.revision <= expectedRevision) throw new Error("地图保存读回 revision 未前进");
|
|
2320
|
+
if (!validated.savedGeneration || !sameGeneration(validated.savedGeneration, currentDraft)) throw new Error("地图保存读回与提交参数不一致");
|
|
2321
|
+
if (state.open && state.epoch === saveEpoch && state.snapshot?.projectId === expectedProject && state.snapshot?.revision === expectedRevision) {
|
|
2322
|
+
state.snapshot = validated.value;
|
|
2323
|
+
state.savedGeneration = validated.savedGeneration;
|
|
2324
|
+
state.savedRoadGeneration = validated.savedRoadGeneration;
|
|
2325
|
+
state.markers = objectMarkers(validated.value.source?.objects, currentDraft);
|
|
2326
|
+
updateActorBinding(validated.value);
|
|
2327
|
+
if (!await loadCities(validated.value, currentDraft, () => state.open && state.epoch === saveEpoch
|
|
2328
|
+
&& state.snapshot?.projectId === validated.value.projectId && state.snapshot?.revision === validated.value.revision)) return;
|
|
2329
|
+
if (state.roadAutoPreviewEnabled) scheduleRoadPreview();
|
|
2330
|
+
state.errorKind = null;
|
|
2331
|
+
setOverlay("");
|
|
2332
|
+
notify("世界地图已保存。", false);
|
|
2333
|
+
}
|
|
2334
|
+
} catch (error) {
|
|
2335
|
+
if (state.open && state.epoch === saveEpoch && state.snapshot?.projectId === expectedProject) {
|
|
2336
|
+
state.errorKind = "save";
|
|
2337
|
+
setOverlay(errorText(error), true, "重试保存", () => { void savePreview(); });
|
|
2338
|
+
notify(errorText(error), true);
|
|
2339
|
+
}
|
|
2340
|
+
} finally {
|
|
2341
|
+
if (state.saveToken === saveToken) state.saving = false;
|
|
2342
|
+
if (state.open && state.epoch === saveEpoch) { draw(); updateStatus(); }
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
settingsButton.addEventListener("click", () => {
|
|
2347
|
+
settingsPanel.hidden = !settingsPanel.hidden;
|
|
2348
|
+
settingsButton.setAttribute("aria-expanded", String(!settingsPanel.hidden));
|
|
2349
|
+
if (!settingsPanel.hidden) field(form, "seed").focus();
|
|
2350
|
+
});
|
|
2351
|
+
adjustButton.addEventListener("click", () => {
|
|
2352
|
+
settingsPanel.hidden = false;
|
|
2353
|
+
settingsButton.setAttribute("aria-expanded", "true");
|
|
2354
|
+
field(form, "seed").focus();
|
|
2355
|
+
});
|
|
2356
|
+
generateButton.addEventListener("click", () => {
|
|
2357
|
+
generateFromCurrentForm();
|
|
2358
|
+
});
|
|
2359
|
+
saveButton.addEventListener("click", savePreview);
|
|
2360
|
+
form.addEventListener("input", schedulePreview);
|
|
2361
|
+
form.addEventListener("change", schedulePreview);
|
|
2362
|
+
previewRoadsButton.addEventListener("click", () => {
|
|
2363
|
+
state.roadAutoPreviewEnabled = true;
|
|
2364
|
+
generateRoadPreview();
|
|
2365
|
+
});
|
|
2366
|
+
previewRoadUpgradeButton.addEventListener("click", previewRoadGenerationUpgrade);
|
|
2367
|
+
saveRoadsButton.addEventListener("click", () => { void saveRoadPreview(); });
|
|
2368
|
+
roadForm.addEventListener("input", scheduleRoadPreview);
|
|
2369
|
+
roadForm.addEventListener("change", scheduleRoadPreview);
|
|
2370
|
+
layers.addEventListener("change", (event) => {
|
|
2371
|
+
const terrainLayer = ["showRelief", "showRivers", "showLakes"].includes(event.target?.name);
|
|
2372
|
+
if (terrainLayer && state.result && state.previewGeneration) {
|
|
2373
|
+
try {
|
|
2374
|
+
const rendered = buildBaseMap(state.result, state.previewGeneration);
|
|
2375
|
+
state.baseCanvas = rendered.canvas;
|
|
2376
|
+
state.baseRenderStats = rendered;
|
|
2377
|
+
clearTileRuntime(true);
|
|
2378
|
+
updateStats(rendered, state.result, state.previewGeneration);
|
|
2379
|
+
refreshDetailPlan(true);
|
|
2380
|
+
draw();
|
|
2381
|
+
} catch (error) { notify(errorText(error), true); }
|
|
2382
|
+
} else { updateStatus(); draw(); }
|
|
2383
|
+
});
|
|
2384
|
+
densitySelect.addEventListener("change", () => {
|
|
2385
|
+
state.lodLevelInitialized = false;
|
|
2386
|
+
clearTileRuntime(false);
|
|
2387
|
+
refreshDetailPlan(true);
|
|
2388
|
+
draw();
|
|
2389
|
+
});
|
|
2390
|
+
roadThresholdInput.addEventListener("change", () => { state.cityModes.clear(); draw(); });
|
|
2391
|
+
parcelThresholdInput.addEventListener("change", () => { state.cityModes.clear(); draw(); });
|
|
2392
|
+
actorSelect.addEventListener("change", () => {
|
|
2393
|
+
saveActorButton.disabled = state.savingActor || !actorSelect.value || actorSelect.value === state.snapshot?.source?.topology?.controlledActorId;
|
|
2394
|
+
});
|
|
2395
|
+
saveActorButton.addEventListener("click", () => { void saveActorBinding(); });
|
|
2396
|
+
canvas.addEventListener("contextmenu", (event) => {
|
|
2397
|
+
if (typeof options.createCity !== "function") return;
|
|
2398
|
+
event.preventDefault();
|
|
2399
|
+
void openCityDialogAt(event.clientX, event.clientY).catch((error) => notify(errorText(error), true));
|
|
2400
|
+
});
|
|
2401
|
+
cityForm.addEventListener("submit", createCityFromDialog);
|
|
2402
|
+
cityTemplate.addEventListener("change", () => {
|
|
2403
|
+
if (state.cityPoint) state.cityPoint.cityTemplateTouched = true;
|
|
2404
|
+
const applied = sourceCityTemplate();
|
|
2405
|
+
if (applied?.value === cityTemplate.value) applyCityParameters(applied.parameters);
|
|
2406
|
+
else applyCityTemplate(cityTemplate.value);
|
|
2407
|
+
});
|
|
2408
|
+
citySize.addEventListener("change", () => {
|
|
2409
|
+
const preset = CITY_SIZE_PRESETS.find((candidate) => candidate.id === citySize.value);
|
|
2410
|
+
if (!preset || !state.cityPoint) return;
|
|
2411
|
+
cityForm.elements.namedItem("cityRadius").value = String(Math.min(state.cityPoint.maximumRadius, preset.radiusMeters));
|
|
2412
|
+
});
|
|
2413
|
+
cityForm.elements.namedItem("cityRadius").addEventListener("input", syncCitySize);
|
|
2414
|
+
cityForm.elements.namedItem("cityBlockSize").addEventListener("input", () => {
|
|
2415
|
+
const blockSize = Number(cityForm.elements.namedItem("cityBlockSize").value);
|
|
2416
|
+
if (Number.isFinite(blockSize)) cityForm.elements.namedItem("cityRoadWidth").value = String(Math.max(3, Math.min(40, blockSize * .18)));
|
|
2417
|
+
});
|
|
2418
|
+
cancelCity.addEventListener("click", closeCityDialog);
|
|
2419
|
+
cityDialog.addEventListener("cancel", (event) => { event.preventDefault(); if (!state.creatingCity) closeCityDialog(); });
|
|
2420
|
+
zoomIn.addEventListener("click", () => zoom(1.3));
|
|
2421
|
+
zoomOut.addEventListener("click", () => zoom(1 / 1.3));
|
|
2422
|
+
fit.addEventListener("click", fitMap);
|
|
2423
|
+
canvas.addEventListener("wheel", (event) => {
|
|
2424
|
+
event.preventDefault();
|
|
2425
|
+
const rect = canvas.getBoundingClientRect();
|
|
2426
|
+
zoom(event.deltaY < 0 ? 1.15 : 1 / 1.15, event.clientX - rect.left, event.clientY - rect.top);
|
|
2427
|
+
}, { passive: false });
|
|
2428
|
+
canvas.addEventListener("pointerdown", (event) => {
|
|
2429
|
+
if (!state.baseCanvas) return;
|
|
2430
|
+
state.drag = { id: event.pointerId, x: event.clientX, y: event.clientY, originX: state.view.x, originY: state.view.y };
|
|
2431
|
+
canvas.setPointerCapture?.(event.pointerId);
|
|
2432
|
+
canvas.classList.add("is-panning");
|
|
2433
|
+
});
|
|
2434
|
+
canvas.addEventListener("pointermove", (event) => {
|
|
2435
|
+
if (!state.drag || state.drag.id !== event.pointerId) return;
|
|
2436
|
+
state.view.x = state.drag.originX + event.clientX - state.drag.x;
|
|
2437
|
+
state.view.y = state.drag.originY + event.clientY - state.drag.y;
|
|
2438
|
+
state.fitted = false;
|
|
2439
|
+
refreshDetailPlan();
|
|
2440
|
+
draw();
|
|
2441
|
+
});
|
|
2442
|
+
const endPan = (event) => {
|
|
2443
|
+
if (!state.drag || state.drag.id !== event.pointerId) return;
|
|
2444
|
+
canvas.releasePointerCapture?.(event.pointerId);
|
|
2445
|
+
state.drag = null;
|
|
2446
|
+
canvas.classList.remove("is-panning");
|
|
2447
|
+
};
|
|
2448
|
+
canvas.addEventListener("pointerup", endPan);
|
|
2449
|
+
canvas.addEventListener("pointercancel", endPan);
|
|
2450
|
+
canvas.addEventListener("keydown", (event) => {
|
|
2451
|
+
const amount = event.shiftKey ? 48 : 20;
|
|
2452
|
+
if (event.key === "+" || event.key === "=") zoom(1.2);
|
|
2453
|
+
else if (event.key === "-") zoom(1 / 1.2);
|
|
2454
|
+
else if (event.key === "0") fitMap();
|
|
2455
|
+
else if (event.key === "ArrowLeft") state.view.x += amount;
|
|
2456
|
+
else if (event.key === "ArrowRight") state.view.x -= amount;
|
|
2457
|
+
else if (event.key === "ArrowUp") state.view.y += amount;
|
|
2458
|
+
else if (event.key === "ArrowDown") state.view.y -= amount;
|
|
2459
|
+
else return;
|
|
2460
|
+
event.preventDefault(); refreshDetailPlan(); draw();
|
|
2461
|
+
});
|
|
2462
|
+
const resizeObserver = typeof ResizeObserver === "function" ? new ResizeObserver(() => { if (state.fitted) fitMap(); else { refreshDetailPlan(); draw(); } }) : null;
|
|
2463
|
+
resizeObserver?.observe(stage);
|
|
2464
|
+
|
|
2465
|
+
return { open, close, destroy, focusObject, setCityTemplates };
|
|
2466
|
+
}
|