tarinai/js/physics_shape_editor_system.js
2026-07-09 16:08:11 +09:00

323 lines
13 KiB
JavaScript

"use strict";
// Layer: ui/physics-shape-editor
// Shared button labels/templates for rotator, reciprocator, and poison-block
// segment editors. Low-level editor pointer behavior remains in placement log.
(function (global) {
const MODE_LABELS = Object.freeze({
line: "\uD83D\uDCCF \u76F4\u7DDA",
free: "\u270E \u81EA\u7531\u63CF\u753B",
erase: "\u232B \u6D88\u3057\u30B4\u30E0",
});
const TEMPLATE_LABELS = Object.freeze({
bar: "\u2503 \u68D2",
cross: "\uFF0B \u5341\u5B57",
circle: "\u25CB \u5186",
});
const ACTION_LABELS = Object.freeze({
undo: "\u21B6 \u4E00\u3064\u623B\u3059",
clear: "\uD83D\uDDD1 \u5168\u6D88\u3057",
});
const DEFAULT_GRID_SIZE = 20;
function normalizedGridSize(value = DEFAULT_GRID_SIZE) {
return Math.max(10, Math.min(80, Math.round(Number(value || DEFAULT_GRID_SIZE) || DEFAULT_GRID_SIZE)));
}
function snapValue(value, gridSize = DEFAULT_GRID_SIZE) {
const grid = normalizedGridSize(gridSize);
return Math.round((Number(value) || 0) / grid) * grid;
}
function snapPoint(point, gridSize = DEFAULT_GRID_SIZE) {
return { x: snapValue(point?.x, gridSize), y: snapValue(point?.y, gridSize) };
}
function templateSegments(name) {
if (name === "bar") return [[-120, 0, 120, 0]];
if (name === "cross") return [[-120, 0, 120, 0], [0, -80, 0, 80]];
if (name === "circle") {
// 24-sided lattice circle. More vertices preserve a round silhouette while
// every endpoint remains on the shared 20px editor grid.
const points = [
[120, 0], [120, 40], [100, 60], [80, 80], [60, 100], [40, 120],
[0, 120], [-40, 120], [-60, 100], [-80, 80], [-100, 60], [-120, 40],
[-120, 0], [-120, -40], [-100, -60], [-80, -80], [-60, -100], [-40, -120],
[0, -120], [40, -120], [60, -100], [80, -80], [100, -60], [120, -40],
];
return points.map((point, index) => {
const next = points[(index + 1) % points.length];
return [point[0], point[1], next[0], next[1]];
});
}
return null;
}
function decorateToolbar(dialog, prefix = "rotator") {
if (!dialog) return false;
for (const btn of dialog.querySelectorAll(`[data-${prefix}-mode]`)) {
const key = btn.dataset[`${prefix}Mode`];
if (MODE_LABELS[key]) { btn.textContent = MODE_LABELS[key]; btn.title = MODE_LABELS[key].replace(/^[^\s]+\s*/, ""); }
}
for (const btn of dialog.querySelectorAll(`[data-${prefix}-template]`)) {
const key = btn.dataset[`${prefix}Template`];
if (TEMPLATE_LABELS[key]) { btn.textContent = TEMPLATE_LABELS[key]; btn.title = TEMPLATE_LABELS[key].replace(/^[^\s]+\s*/, ""); }
}
const undo = dialog.querySelector(`#${prefix}Undo`);
const clear = dialog.querySelector(`#${prefix}Clear`);
if (undo) undo.textContent = ACTION_LABELS.undo;
if (clear) clear.textContent = ACTION_LABELS.clear;
return true;
}
function pointSegmentDistance(p, seg) {
if (!p || !Array.isArray(seg) || seg.length < 4) return Infinity;
return global.TarinaiGeometry.pointSegmentDistance(p.x, p.y, seg[0], seg[1], seg[2], seg[3]);
}
function canvasLocalPoint(canvas, e, cx = canvas?.width / 2 || 0, cy = canvas?.height / 2 || 0) {
if (!canvas || !e) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
return { x: (e.clientX - rect.left) * sx - cx, y: (e.clientY - rect.top) * sy - cy };
}
function cleanSegments(segments = [], opts = {}) {
const limit = Math.max(1, Number(opts.limit || 96) || 96);
const bound = Math.max(1, Number(opts.bound || 420) || 420);
const gridSize = normalizedGridSize(opts.gridSize);
const snap = opts.snap !== false;
const minLength = Math.max(gridSize, Number(opts.minLength || gridSize) || gridSize);
const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-80, 0, 80, 0];
const coord = value => {
const raw = snap ? snapValue(value, gridSize) : (Number(value) || 0);
return Math.max(-bound, Math.min(bound, raw));
};
const out = [];
for (const seg of segments || []) {
if (!Array.isArray(seg) || seg.length < 4) continue;
const x1 = coord(seg[0]);
const y1 = coord(seg[1]);
const x2 = coord(seg[2]);
const y2 = coord(seg[3]);
if (Math.hypot(x2 - x1, y2 - y1) >= minLength) out.push([x1, y1, x2, y2]);
if (out.length >= limit) break;
}
if (!out.length) {
const snappedFallback = fallback.slice(0, 4).map(value => coord(value));
out.push(snappedFallback);
}
return out;
}
function drawEditorBase(ctx, canvas, cx = canvas?.width / 2 || 0, cy = canvas?.height / 2 || 0, gridSize = DEFAULT_GRID_SIZE) {
if (!ctx || !canvas) return false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.fillStyle = "#f7f3ea";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.translate(cx, cy);
ctx.strokeStyle = "rgba(100,80,60,0.16)";
ctx.lineWidth = 1;
const grid = normalizedGridSize(gridSize);
for (let x = -cx; x <= cx; x += grid) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); }
for (let y = -cy; y <= cy; y += grid) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); }
ctx.strokeStyle = "rgba(70,60,50,0.38)";
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); ctx.moveTo(0, -cy); ctx.lineTo(0, cy); ctx.stroke();
return true;
}
function drawSegments(ctx, segments = [], opts = {}) {
if (!ctx) return false;
const lineWidth = Math.max(1, Number(opts.lineWidth || 12) || 12);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = opts.strokeStyle || "rgba(80,150,205,0.92)";
ctx.lineWidth = lineWidth;
ctx.beginPath();
for (const seg of segments || []) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
ctx.stroke();
if (opts.highlight !== false) {
ctx.strokeStyle = opts.highlightStyle || "rgba(255,255,255,0.58)";
ctx.lineWidth = Math.max(1.3, lineWidth * 0.20);
ctx.beginPath();
for (const seg of segments || []) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
ctx.stroke();
}
return true;
}
function eraseSegmentsAt(segments = [], point, radius) {
const before = segments.length;
const next = (segments || []).filter(seg => pointSegmentDistance(point, seg) > radius);
return { segments: next, changed: next.length !== before };
}
function syncInputPair(a, b, onChange = null) {
if (!a || !b) return false;
const sync = (from, to) => {
to.value = from.value;
if (typeof onChange === "function") onChange();
};
a.oninput = () => sync(a, b);
b.oninput = () => sync(b, a);
return true;
}
function drawDraftSegment(ctx, draft, lineWidth, opts = {}) {
if (!ctx || !draft) return false;
ctx.setLineDash(opts.dash || [7, 5]);
ctx.strokeStyle = opts.strokeStyle || "rgba(60,130,210,0.85)";
ctx.lineWidth = Math.max(2, Number(lineWidth || 12) * 0.45);
ctx.beginPath();
ctx.moveTo(draft[0], draft[1]);
ctx.lineTo(draft[2], draft[3]);
ctx.stroke();
ctx.setLineDash([]);
return true;
}
function drawErasePreview(ctx, cx, cy, point, radius, opts = {}) {
if (!ctx || !point) return false;
ctx.save();
ctx.translate(cx || 0, cy || 0);
ctx.globalAlpha = opts.alpha ?? 0.42;
ctx.strokeStyle = opts.strokeStyle || "rgba(210,70,60,0.86)";
ctx.lineWidth = opts.lineWidth || 1.6;
ctx.setLineDash(opts.dash || [4, 4]);
ctx.beginPath();
ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
return true;
}
function createSegmentEditorController(opts = {}) {
const dialog = opts.dialog;
const canvas = opts.canvas;
const ctx = opts.ctx || canvas?.getContext?.("2d");
if (!dialog || !canvas || !ctx) return null;
const prefix = opts.prefix || "rotator";
const cx = opts.cx ?? canvas.width / 2;
const cy = opts.cy ?? canvas.height / 2;
const gridSize = normalizedGridSize(opts.gridSize);
const fallback = Array.isArray(opts.fallback) ? opts.fallback : [-80, 0, 80, 0];
let mode = "line";
let segments = cleanSegments(opts.segments || [], { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength, gridSize, snap: true });
let draft = null;
let drawing = false;
const lineWidth = () => Math.max(1, Number(typeof opts.lineWidth === "function" ? opts.lineWidth() : opts.lineWidth || 12) || 12);
const strokeStyle = () => typeof opts.strokeStyle === "function" ? opts.strokeStyle() : opts.strokeStyle;
const clean = () => { segments = cleanSegments(segments, { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength, gridSize, snap: true }); };
const toLocal = (e) => snapPoint(canvasLocalPoint(canvas, e, cx, cy), gridSize);
const draw = () => {
drawEditorBase(ctx, canvas, cx, cy, gridSize);
if (typeof opts.drawBeforeSegments === "function") opts.drawBeforeSegments(ctx, { cx, cy, lineWidth: lineWidth(), segments: segments.slice() });
const lw = lineWidth();
drawSegments(ctx, segments, { lineWidth: lw, strokeStyle: strokeStyle() || "rgba(80,150,205,0.92)" });
drawDraftSegment(ctx, draft, lw, opts.draft || {});
if (typeof opts.drawCenter === "function") opts.drawCenter(ctx, { cx, cy, lineWidth: lw, segments: segments.slice() });
ctx.restore();
return true;
};
const setMode = (next) => {
mode = next === "free" || next === "erase" ? next : "line";
for (const btn of dialog.querySelectorAll(`[data-${prefix}-mode]`)) btn.classList.toggle("active", btn.dataset[`${prefix}Mode`] === mode);
canvas.style.cursor = mode === "erase" ? "cell" : "crosshair";
return mode;
};
const eraseAt = (p) => {
const radius = Math.max(1, typeof opts.eraseRadius === "function" ? opts.eraseRadius(lineWidth()) : Number(opts.eraseRadius || 24) || 24);
const erased = eraseSegmentsAt(segments, p, radius);
segments = erased.segments;
if (erased.changed) { draft = null; draw(); return true; }
draw();
drawErasePreview(ctx, cx, cy, p, radius, opts.erasePreview || {});
return false;
};
const setTemplate = (name) => {
const shared = templateSegments(name);
if (shared) segments = shared.map(seg => seg.slice(0, 4));
clean();
draw();
};
dialog.querySelectorAll(`[data-${prefix}-mode]`).forEach(btn => { btn.onclick = () => setMode(btn.dataset[`${prefix}Mode`]); });
dialog.querySelectorAll(`[data-${prefix}-template]`).forEach(btn => { btn.onclick = () => setTemplate(btn.dataset[`${prefix}Template`]); });
canvas.onpointerdown = (e) => {
canvas.setPointerCapture?.(e.pointerId);
const p = toLocal(e);
drawing = true;
if (mode === "erase") { draft = null; eraseAt(p); return; }
draft = [p.x, p.y, p.x, p.y];
if (mode === "free") segments.push([p.x, p.y, p.x, p.y]);
draw();
};
canvas.onpointermove = (e) => {
if (!drawing) return;
const p = toLocal(e);
if (mode === "erase") { eraseAt(p); return; }
if (!draft) return;
if (mode === "free") {
const last = segments[segments.length - 1];
if (!last) return;
const lx = last[2], ly = last[3];
if (Math.hypot(p.x - lx, p.y - ly) >= gridSize) {
last[2] = p.x; last[3] = p.y;
segments.push([p.x, p.y, p.x, p.y]);
}
} else {
draft[2] = p.x; draft[3] = p.y;
}
draw();
};
canvas.onpointerup = (e) => {
if (!drawing) return;
drawing = false;
const p = toLocal(e);
if (mode === "erase") { draft = null; draw(); return; }
if (!draft) return;
if (mode === "line") {
const seg = [draft[0], draft[1], p.x, p.y];
if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= gridSize) segments.push(seg);
}
draft = null;
clean();
draw();
};
const undo = dialog.querySelector(`#${prefix}Undo`);
const clear = dialog.querySelector(`#${prefix}Clear`);
if (undo) undo.onclick = () => { segments.pop(); clean(); draw(); };
if (clear) clear.onclick = () => { segments = []; draft = null; draw(); };
const close = () => {
canvas.style.cursor = "";
canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null;
};
setMode(opts.initialMode || "line");
return {
cleanSegments: clean,
close,
draw,
getSegments: () => segments.map(seg => seg.slice(0, 4)),
setMode,
setSegments(next = []) { segments = cleanSegments(next, { fallback, limit: opts.limit, bound: opts.bound, minLength: opts.minLength, gridSize, snap: true }); draft = null; draw(); },
};
}
global.TarinaiPhysicsShapeEditorSystem = Object.freeze({
decorateToolbar,
createSegmentEditorController,
syncInputPair,
snapPoint,
cleanSegments,
templateSegments,
gridSize: DEFAULT_GRID_SIZE,
});
})(typeof window !== "undefined" ? window : globalThis);