This commit is contained in:
33333-33333 2026-05-29 18:50:54 +09:00
commit 4a8aff90b2
7 changed files with 3806 additions and 83 deletions

97
app.js
View file

@ -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,
};
}
}

View file

@ -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>

File diff suppressed because it is too large Load diff

2660
mapPatch.js.bak Normal file

File diff suppressed because it is too large Load diff

View file

@ -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);

View file

@ -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 });

View file

@ -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}