not bad
This commit is contained in:
parent
27ceb6568a
commit
4a8aff90b2
7 changed files with 3806 additions and 83 deletions
97
app.js
97
app.js
|
|
@ -70,6 +70,10 @@ const dragState = {
|
|||
startClientY: 0,
|
||||
startCameraX: 0,
|
||||
startCameraY: 0,
|
||||
lastClientX: 0,
|
||||
lastClientY: 0,
|
||||
panRemainderX: 0,
|
||||
panRemainderY: 0,
|
||||
selectStart: null,
|
||||
selectEnd: null,
|
||||
selectPath: null,
|
||||
|
|
@ -241,15 +245,34 @@ function selectionPathToShape(points) {
|
|||
};
|
||||
}
|
||||
|
||||
function syncSelectionSvgToCanvas() {
|
||||
if (!selectionSvgEl || !canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = Math.max(1, rect.width || canvas.clientWidth || canvas.width || 1);
|
||||
const height = Math.max(1, rect.height || canvas.clientHeight || canvas.height || 1);
|
||||
selectionSvgEl.style.left = `${canvas.offsetLeft}px`;
|
||||
selectionSvgEl.style.top = `${canvas.offsetTop}px`;
|
||||
selectionSvgEl.style.width = `${width}px`;
|
||||
selectionSvgEl.style.height = `${height}px`;
|
||||
selectionSvgEl.setAttribute("width", String(width));
|
||||
selectionSvgEl.setAttribute("height", String(height));
|
||||
selectionSvgEl.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function drawSelectionSvg(points, invalid = false) {
|
||||
if (!selectionSvgEl) return;
|
||||
if (!points || points.length < 3) {
|
||||
const bounds = syncSelectionSvgToCanvas();
|
||||
if (!bounds || !points || points.length < 3) {
|
||||
selectionSvgEl.style.display = "none";
|
||||
selectionSvgEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const pts = points.map((p) => `${p.x},${p.y}`).join(" ");
|
||||
selectionSvgEl.setAttribute("viewBox", `0 0 ${canvas.clientWidth || canvas.width || 1} ${canvas.clientHeight || canvas.height || 1}`);
|
||||
const pts = points.map((p) => {
|
||||
const x = Math.min(Math.max(p.x, 0), bounds.width);
|
||||
const y = Math.min(Math.max(p.y, 0), bounds.height);
|
||||
return `${x},${y}`;
|
||||
}).join(" ");
|
||||
selectionSvgEl.innerHTML = `<polygon points="${pts}" />`;
|
||||
selectionSvgEl.style.display = "block";
|
||||
selectionSvgEl.classList.toggle("invalid", !!invalid);
|
||||
|
|
@ -375,6 +398,8 @@ function clearDragMode() {
|
|||
dragState.mode = null;
|
||||
dragState.pointerId = null;
|
||||
dragState.pendingCamera = null;
|
||||
dragState.panRemainderX = 0;
|
||||
dragState.panRemainderY = 0;
|
||||
if (dragState.panRaf != null) {
|
||||
cancelAnimationFrame(dragState.panRaf);
|
||||
dragState.panRaf = null;
|
||||
|
|
@ -392,7 +417,10 @@ function schedulePanRedraw(camera) {
|
|||
dragState.pendingCamera = null;
|
||||
if (next.x === state.camera.x && next.y === state.camera.y) return;
|
||||
state.camera = next;
|
||||
redraw({ fastTerrain: true });
|
||||
// Do not auto-expand the backing world while a pointer drag is active.
|
||||
// Expansion shifts world coordinates; doing it mid-drag invalidates the
|
||||
// pointer-to-camera baseline and can make the viewport appear to jump.
|
||||
redraw({ fastTerrain: true, allowWorldExpand: false });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -432,6 +460,10 @@ function handleMapPointerDown(event) {
|
|||
dragState.startClientY = event.clientY;
|
||||
dragState.startCameraX = state.camera.x;
|
||||
dragState.startCameraY = state.camera.y;
|
||||
dragState.lastClientX = event.clientX;
|
||||
dragState.lastClientY = event.clientY;
|
||||
dragState.panRemainderX = 0;
|
||||
dragState.panRemainderY = 0;
|
||||
tooltipEl?.classList.remove("visible");
|
||||
|
||||
if (event.button === 0) {
|
||||
|
|
@ -455,14 +487,34 @@ function handleMapPointerMove(event) {
|
|||
tooltipEl?.classList.remove("visible");
|
||||
|
||||
if (dragState.mode === "pan") {
|
||||
const cellSize = Math.max(1, displayedCellSize());
|
||||
const dxCells = Math.round((event.clientX - dragState.startClientX) / cellSize);
|
||||
const dyCells = Math.round((event.clientY - dragState.startClientY) / cellSize);
|
||||
const nextCamera = clampCameraForView({
|
||||
x: dragState.startCameraX - dxCells,
|
||||
y: dragState.startCameraY - dyCells,
|
||||
}, viewportSizeForZoom(state.zoom));
|
||||
schedulePanRedraw(nextCamera);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const maxDeltaX = Math.max(320, rect.width * 0.72);
|
||||
const maxDeltaY = Math.max(240, rect.height * 0.72);
|
||||
const rawDx = event.clientX - dragState.lastClientX;
|
||||
const rawDy = event.clientY - dragState.lastClientY;
|
||||
dragState.lastClientX = event.clientX;
|
||||
dragState.lastClientY = event.clientY;
|
||||
|
||||
// Pointer capture can occasionally deliver a stale/outlier coordinate after
|
||||
// a tab switch, resize, context-menu gesture, or OS-level event hiccup. A
|
||||
// single implausibly large delta would otherwise become a large camera jump.
|
||||
if (Math.abs(rawDx) <= maxDeltaX && Math.abs(rawDy) <= maxDeltaY) {
|
||||
const cellSize = Math.max(1, displayedCellSize());
|
||||
const totalX = dragState.panRemainderX + rawDx / cellSize;
|
||||
const totalY = dragState.panRemainderY + rawDy / cellSize;
|
||||
const dxCells = totalX < 0 ? Math.ceil(totalX) : Math.floor(totalX);
|
||||
const dyCells = totalY < 0 ? Math.ceil(totalY) : Math.floor(totalY);
|
||||
dragState.panRemainderX = totalX - dxCells;
|
||||
dragState.panRemainderY = totalY - dyCells;
|
||||
if (dxCells || dyCells) {
|
||||
const base = dragState.pendingCamera || state.camera;
|
||||
const nextCamera = clampCameraForView({
|
||||
x: base.x - dxCells,
|
||||
y: base.y - dyCells,
|
||||
}, viewportSizeForZoom(state.zoom));
|
||||
schedulePanRedraw(nextCamera);
|
||||
}
|
||||
}
|
||||
} else if (dragState.mode === "select") {
|
||||
dragState.selectEnd = clampCanvasPoint(event);
|
||||
if (!dragState.selectPath || Math.hypot(dragState.selectEnd.x - dragState.selectPath[dragState.selectPath.length - 1].x, dragState.selectEnd.y - dragState.selectPath[dragState.selectPath.length - 1].y) >= 3) {
|
||||
|
|
@ -954,17 +1006,22 @@ function redraw(options = {}) {
|
|||
const viewSize = syncViewportSize();
|
||||
const expansion = options.allowWorldExpand === false ? null : ensureWorldPaddingForCamera(state.world, state.camera, viewSize.width, viewSize.height);
|
||||
if (expansion?.expanded) {
|
||||
state.camera = { x: (state.camera?.x || 0) + (expansion.dx || 0), y: (state.camera?.y || 0) + (expansion.dy || 0) };
|
||||
const ex = expansion.dx || 0;
|
||||
const ey = expansion.dy || 0;
|
||||
state.camera = { x: (state.camera?.x || 0) + ex, y: (state.camera?.y || 0) + ey };
|
||||
if (dragState.mode === "pan") {
|
||||
dragState.startCameraX += ex;
|
||||
dragState.startCameraY += ey;
|
||||
if (dragState.pendingCamera) dragState.pendingCamera = { x: dragState.pendingCamera.x + ex, y: dragState.pendingCamera.y + ey };
|
||||
}
|
||||
if (state.selectionRect) {
|
||||
const dx = expansion.dx || 0;
|
||||
const dy = expansion.dy || 0;
|
||||
state.selectionRect = {
|
||||
...state.selectionRect,
|
||||
x0: state.selectionRect.x0 + dx,
|
||||
y0: state.selectionRect.y0 + dy,
|
||||
x1: state.selectionRect.x1 + dx,
|
||||
y1: state.selectionRect.y1 + dy,
|
||||
polygon: Array.isArray(state.selectionRect.polygon) ? state.selectionRect.polygon.map((p) => ({ x: p.x + dx, y: p.y + dy })) : state.selectionRect.polygon,
|
||||
x0: state.selectionRect.x0 + ex,
|
||||
y0: state.selectionRect.y0 + ey,
|
||||
x1: state.selectionRect.x1 + ex,
|
||||
y1: state.selectionRect.y1 + ey,
|
||||
polygon: Array.isArray(state.selectionRect.polygon) ? state.selectionRect.polygon.map((p) => ({ x: p.x + ex, y: p.y + ey })) : state.selectionRect.polygon,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
<option value="tohoku_spine">Tohoku spine</option>
|
||||
<option value="chubu_mountain">Chubu mountain</option>
|
||||
<option value="setouchi_inland_sea">Setouchi inland sea</option>
|
||||
<option value="oceanic_archipelago">Oceanic archipelago</option>
|
||||
<option value="oceanic_archipelago">Ocean</option>
|
||||
<option value="kanto_alluvial">Kanto alluvial plain</option>
|
||||
<option value="mixed_archipelago">Mixed archipelago</option>
|
||||
</select>
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
<option value="tohoku_spine">Tohoku spine</option>
|
||||
<option value="chubu_mountain">Chubu mountain</option>
|
||||
<option value="setouchi_inland_sea">Setouchi inland sea</option>
|
||||
<option value="oceanic_archipelago">Oceanic archipelago</option>
|
||||
<option value="oceanic_archipelago">Ocean</option>
|
||||
<option value="kanto_alluvial">Kanto alluvial plain</option>
|
||||
<option value="mixed_archipelago">Mixed archipelago</option>
|
||||
</select>
|
||||
|
|
|
|||
1038
mapPatch.js
1038
mapPatch.js
File diff suppressed because it is too large
Load diff
2660
mapPatch.js.bak
Normal file
2660
mapPatch.js.bak
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -209,15 +209,16 @@ const TERRAIN_TYPES = [
|
|||
},
|
||||
{
|
||||
id: "oceanic_archipelago",
|
||||
label: "海洋型・多島海",
|
||||
weight: 0.16,
|
||||
label: "Ocean",
|
||||
weight: 0.00,
|
||||
autoSelectable: false,
|
||||
coastStyle: "oceanic_archipelago",
|
||||
mountainMode: "mixed",
|
||||
massifnessRange: [0.04, 0.28],
|
||||
seaRatioRange: [0.72, 0.90],
|
||||
seaRatioRange: [0.955, 0.985],
|
||||
twoSidedChance: 1.0,
|
||||
mountainOffsetRange: [0.25, 0.55],
|
||||
baseHeightRange: [0.30, 0.62],
|
||||
baseHeightRange: [0.20, 0.46],
|
||||
primaryLengthRange: [0.20, 0.52],
|
||||
primaryWidthRange: [0.08, 0.24],
|
||||
systemCountRange: [7, 14],
|
||||
|
|
@ -227,7 +228,7 @@ const TERRAIN_TYPES = [
|
|||
lengthScale: 0.78,
|
||||
widthScale: 0.82,
|
||||
heightScale: 0.58,
|
||||
coastStrength: 1.55,
|
||||
coastStrength: 2.10,
|
||||
plainBiasRange: [0.12, 0.34],
|
||||
riverRichnessRange: [0.18, 0.52],
|
||||
bigRiverChanceRange: [0.02, 0.12],
|
||||
|
|
@ -315,11 +316,11 @@ function pickTerrainType(seed, requestedType = "auto") {
|
|||
const selected = TERRAIN_TYPES.find((type) => type.id === normalizedType);
|
||||
if (selected) return selected;
|
||||
}
|
||||
// Terrain type selection is intentionally uniform. Individual terrain
|
||||
// templates still contain their own parameter ranges, but there is no
|
||||
// terrain-type appearance weighting.
|
||||
const index = Math.floor(rand(seed, 10001) * TERRAIN_TYPES.length) % TERRAIN_TYPES.length;
|
||||
return TERRAIN_TYPES[index];
|
||||
// Auto excludes explicitly manual/special-purpose templates such as Ocean.
|
||||
// The selection remains uniform over ordinary land-bearing templates.
|
||||
const autoTypes = TERRAIN_TYPES.filter((type) => type.autoSelectable !== false);
|
||||
const index = Math.floor(rand(seed, 10001) * autoTypes.length) % autoTypes.length;
|
||||
return autoTypes[index];
|
||||
}
|
||||
|
||||
function rangeValue(seed, salt, [lo, hi]) {
|
||||
|
|
@ -1174,8 +1175,8 @@ function enforceLandGradient(elevation, sea, seaLevel) {
|
|||
function rectTerrainProfile(template) {
|
||||
const id = String(template?.terrainType || "auto");
|
||||
if (id.includes("oceanic")) return {
|
||||
base: 0.36, relief: 0.19, ridge: 0.30, ridgeWidth: 22, ridgeSpacing: 76, coast: 0.34, archipelago: 0.28,
|
||||
seaQuantile: Math.max(0.68, template.seaRatio ?? 0.76), plain: 0.20, moisture: 0.60, capStart: 0.64, capMax: 0.86,
|
||||
base: 0.28, relief: 0.11, ridge: 0.20, ridgeWidth: 18, ridgeSpacing: 70, coast: 0.52, archipelago: 0.18,
|
||||
seaQuantile: Math.max(0.95, template.seaRatio ?? 0.965), plain: 0.12, moisture: 0.66, capStart: 0.56, capMax: 0.74,
|
||||
};
|
||||
if (id.includes("setouchi") || id.includes("archipelago")) return {
|
||||
base: 0.43, relief: 0.18, ridge: 0.34, ridgeWidth: 30, ridgeSpacing: 94, coast: 0.25, archipelago: 0.20,
|
||||
|
|
@ -1697,7 +1698,11 @@ export function generateTerrainRect(options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
const seaLevel = clamp(Number.isFinite(options.seaLevel) ? options.seaLevel : rectQuantile(elevation, profile.seaQuantile), 0.13, 0.50);
|
||||
const seaLevel = clamp(
|
||||
Number.isFinite(options.seaLevel) ? options.seaLevel : rectQuantile(elevation, profile.seaQuantile),
|
||||
0.13,
|
||||
terrainTemplate.terrainType === "oceanic_archipelago" ? 0.72 : 0.50
|
||||
);
|
||||
const oceanCells = classifyRectWater(ctx, fields, seaLevel);
|
||||
recomputeRectSlope(ctx, fields);
|
||||
const filled = priorityFloodRect(ctx, fields);
|
||||
|
|
@ -1922,7 +1927,7 @@ export function generateTerrainAndRivers(seed, options = {}) {
|
|||
}
|
||||
|
||||
let seaLevel = quantile(elevation, terrainTemplate.seaRatio);
|
||||
seaLevel = clamp(seaLevel, 0.14, 0.47);
|
||||
seaLevel = clamp(seaLevel, 0.14, terrainTemplate.terrainType === "oceanic_archipelago" ? 0.72 : 0.47);
|
||||
classifyWater(elevation, seaLevel, sea, ocean, lake);
|
||||
recomputeSlope(elevation, sea, slope);
|
||||
|
||||
|
|
|
|||
55
renderer.js
55
renderer.js
|
|
@ -1,4 +1,4 @@
|
|||
import { CELL_SIZE, MAP_H, MAP_W, clamp } from "./mapUtils.js";
|
||||
import { CELL_SIZE, MAP_H, MAP_W, clamp, valueNoise } from "./mapUtils.js";
|
||||
|
||||
|
||||
const segmentVectorCache = new WeakMap();
|
||||
|
|
@ -296,6 +296,38 @@ function isWaterSample(map, fx, fy) {
|
|||
return seaCoverageSample(map, fx, fy) >= 0.50;
|
||||
}
|
||||
|
||||
function terrainWorldPoint(map, fx, fy) {
|
||||
return {
|
||||
x: fx + (map?.worldCamera?.x || 0),
|
||||
y: fy + (map?.worldCamera?.y || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function waterVisualTone(map, fx, fy) {
|
||||
const p = terrainWorldPoint(map, fx, fy);
|
||||
const seed = (map?.seed || 0) >>> 0;
|
||||
const broad = valueNoise(p.x, p.y, seed ^ 0x5d2a9b31, 74);
|
||||
const mid = valueNoise(p.x, p.y, seed ^ 0x8f4c2d19, 29);
|
||||
return clamp(broad * 0.72 + mid * 0.28);
|
||||
}
|
||||
|
||||
function waterVisualColor(map, fx, fy, waterCoverage = null) {
|
||||
const coverage = waterCoverage ?? seaCoverageSample(map, fx, fy);
|
||||
const tone = waterVisualTone(map, fx, fy) - 0.5;
|
||||
const offshore = clamp((coverage - 0.42) / 0.58);
|
||||
const depth = clamp(0.48 + offshore * 0.22 + tone * 0.10);
|
||||
return [
|
||||
Math.round(174 - depth * 6),
|
||||
Math.round(203 + depth * 2),
|
||||
Math.round(224 + depth * 4),
|
||||
];
|
||||
}
|
||||
|
||||
function waterVisualShade(map, fx, fy) {
|
||||
const tone = waterVisualTone(map, fx * 0.85 + 11.7, fy * 0.85 - 3.1) - 0.5;
|
||||
return clamp(0.992 + tone * 0.018, 0.976, 1.012);
|
||||
}
|
||||
|
||||
function mixRgb(a, b, t) {
|
||||
return [
|
||||
Math.round(a[0] + (b[0] - a[0]) * t),
|
||||
|
|
@ -363,8 +395,10 @@ function terrainColorContinuous(map, fx, fy, mode) {
|
|||
let color;
|
||||
|
||||
const waterCoverage = seaCoverageSample(map, fx, fy);
|
||||
const depth = clamp(((map.seaLevel ?? 0.285) + 0.065 - fieldSample(map, map.elevation, fx, fy)) * 2.4);
|
||||
const waterColor = [Math.round(174 - depth * 7), Math.round(203 + depth * 2), Math.round(224 + depth * 5)];
|
||||
// Water must not inherit terrain DEM hill bands. Use a stable world-coordinate
|
||||
// visual water tone instead of elevation-derived depth; otherwise patched sea
|
||||
// areas expose rectangular/horizontal generation artefacts.
|
||||
const waterColor = waterVisualColor(map, fx, fy, waterCoverage);
|
||||
|
||||
let landColor;
|
||||
if (mode === "development") {
|
||||
|
|
@ -405,6 +439,9 @@ function terrainColorContinuous(map, fx, fy, mode) {
|
|||
}
|
||||
|
||||
function terrainShadeContinuous(map, fx, fy) {
|
||||
const waterCoverage = seaCoverageSample(map, fx, fy);
|
||||
if (waterCoverage >= 0.50) return waterVisualShade(map, fx, fy);
|
||||
|
||||
const step = 0.50;
|
||||
const eC = fieldSample(map, map.elevation, fx, fy);
|
||||
const eL = fieldSample(map, map.elevation, fx - step, fy);
|
||||
|
|
@ -589,6 +626,14 @@ function getRasterBorderSegments(map, fieldName) {
|
|||
return segments;
|
||||
}
|
||||
|
||||
function getDisplayBorderSegments(map, fieldName, precomputedSegments, mode) {
|
||||
// Raw raster borders expose every one-cell ID fragment and also reveal patch
|
||||
// ownership seams as long rectangular prefecture/municipality lines. Keep
|
||||
// that extractor debug-only; normal modes must use prepared vector layers.
|
||||
if (mode === "borders-debug") return getRasterBorderSegments(map, fieldName);
|
||||
return Array.isArray(precomputedSegments) ? precomputedSegments : [];
|
||||
}
|
||||
|
||||
function drawRiverPath(ctx, map, path, color, widthFn, alpha = 1) {
|
||||
if (!path || path.length < 2) return;
|
||||
ctx.save();
|
||||
|
|
@ -1067,8 +1112,8 @@ export function drawMap(canvas, map, options) {
|
|||
const showPrefectureRegions = ["all", "admin", "borders-debug"].includes(mode);
|
||||
if (showPrefectureRegions) drawPrefectureRegionFill(ctx, map, mode);
|
||||
|
||||
const adminBorderSegments = map.adminId ? getRasterBorderSegments(map, "adminId") : map.adminBorders;
|
||||
const prefectureBorderSegments = map.prefectureRegionId ? getRasterBorderSegments(map, "prefectureRegionId") : map.regionalPrefectureBorders;
|
||||
const adminBorderSegments = getDisplayBorderSegments(map, "adminId", map.adminBorders, mode);
|
||||
const prefectureBorderSegments = getDisplayBorderSegments(map, "prefectureRegionId", map.regionalPrefectureBorders, mode);
|
||||
if (showAdmin && adminBorderSegments?.length) {
|
||||
drawVectorSegments(ctx, adminBorderSegments, "rgba(255, 255, 255, 0.8)", 2.8, false, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
drawVectorSegments(ctx, adminBorderSegments, "rgba(150, 140, 150, 0.9)", 1.2, true, { iterations: 1, tolerance: 0.05, offsetX: -0.5, offsetY: -0.5 });
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ code{background:#e8e8e8;border-radius:4px;padding:1px 4px}
|
|||
.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(0,0,0,0.06);padding-top:4px}
|
||||
|
||||
.canvas-shell.panning{cursor:grabbing}
|
||||
.map-selection-svg{position:absolute;inset:12px;z-index:18;display:none;pointer-events:none;overflow:visible}
|
||||
.map-selection-svg{position:absolute;left:0;top:0;width:0;height:0;z-index:18;display:none;pointer-events:none;overflow:visible}
|
||||
.map-selection-svg polygon{fill:rgba(26,115,232,0.16);stroke:rgba(26,115,232,0.88);stroke-width:2;vector-effect:non-scaling-stroke;stroke-linejoin:round}
|
||||
.map-selection-svg.invalid polygon{fill:rgba(179,38,30,0.14);stroke:rgba(179,38,30,0.88)}
|
||||
.canvas-shell.selecting{cursor:crosshair}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue