Refactor launcher and studio modules
This commit is contained in:
parent
a20d298be2
commit
ec3e0f5138
34 changed files with 10300 additions and 8600 deletions
420
src/worldshaperStudio/tileArtEditorHelpers.ts
Normal file
420
src/worldshaperStudio/tileArtEditorHelpers.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// @ts-nocheck
|
||||
|
||||
import {
|
||||
buildSpritePreviewDataUrl,
|
||||
getSpritePalette,
|
||||
normalizeImagePlayback,
|
||||
} from "../editorCore";
|
||||
import { normalizeEditorTags } from "./tagUtils";
|
||||
|
||||
export const TILE_ART_SIZE = 16;
|
||||
|
||||
export const EYEDROPPER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<path d="M20.8 4.2c1.6-1.6 4.3-1.6 5.9 0s1.6 4.3 0 5.9l-3.1 3.1-5.9-5.9 3.1-3.1z" fill="#eef6ff" stroke="#08111d" stroke-width="1.5"/>
|
||||
<path d="M10.8 14.1l6.9-6.9 6.1 6.1-6.9 6.9-2.7.9-.9 2.7-3 3a2.2 2.2 0 0 1-3.1 0l-1-1a2.2 2.2 0 0 1 0-3.1l3-3 2.7-.9.9-2.7z" fill="#7ee8c6" stroke="#08111d" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<circle cx="8.4" cy="23.6" r="2" fill="#ff5f6d"/>
|
||||
</svg>`,
|
||||
)}") 4 28, crosshair`;
|
||||
|
||||
export function cloneValue(value) {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function normalizeRoleList(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(new Set(
|
||||
value
|
||||
.map((entry) => String(entry || "").trim().toLowerCase())
|
||||
.filter((entry) => entry === "tile" || entry === "sprite"),
|
||||
));
|
||||
}
|
||||
|
||||
export function normalizeTimelineRows(rows) {
|
||||
return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => {
|
||||
const row = Array.isArray(rows) ? String(rows[rowIndex] || "") : "";
|
||||
return row.padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeWorkingFrames(record) {
|
||||
const rawFrames = Array.isArray(record?.frames) ? record.frames.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) : [];
|
||||
const normalizedFrames = rawFrames.map((entry, index) => ({
|
||||
...cloneValue(entry),
|
||||
id: String(entry.id || `frame_${index}`).trim() || `frame_${index}`,
|
||||
enabled: entry.enabled !== false,
|
||||
index: Number.isFinite(Number(entry.index)) ? Math.max(0, Math.floor(Number(entry.index))) : index,
|
||||
rows: normalizeTimelineRows(entry.rows),
|
||||
}));
|
||||
if (normalizedFrames.length > 0) {
|
||||
return normalizedFrames;
|
||||
}
|
||||
return [{
|
||||
id: "frame_0",
|
||||
enabled: true,
|
||||
index: 0,
|
||||
rows: normalizeTimelineRows(record?.rows),
|
||||
}];
|
||||
}
|
||||
|
||||
export function sortWorkingFrames(frames) {
|
||||
return frames
|
||||
.map((frame, sourceIndex) => ({
|
||||
frame,
|
||||
sourceIndex,
|
||||
sortIndex: Number.isFinite(Number(frame?.index)) ? Number(frame.index) : sourceIndex,
|
||||
}))
|
||||
.sort((left, right) => (
|
||||
left.sortIndex !== right.sortIndex
|
||||
? left.sortIndex - right.sortIndex
|
||||
: left.sourceIndex - right.sourceIndex
|
||||
))
|
||||
.map((entry) => entry.frame);
|
||||
}
|
||||
|
||||
export function normalizeWorkingGraphicRecord(recordType, record) {
|
||||
const source = cloneValue(record) || {};
|
||||
const roles = normalizeRoleList(source.roles);
|
||||
const nextRoles = recordType === "tile"
|
||||
? Array.from(new Set([...roles, "tile"]))
|
||||
: (
|
||||
recordType === "sprite"
|
||||
? Array.from(new Set([...roles, "sprite"]))
|
||||
: roles.filter((entry) => entry !== "sprite")
|
||||
);
|
||||
const frames = normalizeWorkingFrames(source).map((frame, index) => ({
|
||||
...frame,
|
||||
index,
|
||||
}));
|
||||
const requestedDefaultFrameId = String(source.defaultFrame || "").trim();
|
||||
const defaultFrameId = String(
|
||||
frames.find((frame) => String(frame.id || "").trim() === requestedDefaultFrameId)?.id
|
||||
|| frames[0]?.id
|
||||
|| "frame_0",
|
||||
).trim() || "frame_0";
|
||||
const workingRows = normalizeTimelineRows(
|
||||
Array.isArray(source.rows) && source.rows.length > 0
|
||||
? source.rows
|
||||
: (frames.find((frame) => String(frame.id || "").trim() === defaultFrameId)?.rows || frames[0]?.rows || [])
|
||||
);
|
||||
return {
|
||||
...source,
|
||||
id: String(source.id || `${recordType === "tile" ? "tile" : "sprite"}_${Date.now()}`).trim(),
|
||||
name: typeof source.name === "string" ? source.name : "",
|
||||
description: typeof source.description === "string" ? source.description : "",
|
||||
width: TILE_ART_SIZE,
|
||||
height: TILE_ART_SIZE,
|
||||
pixelScale: Math.max(1, Number(source.pixelScale) || 2),
|
||||
opacity: Number.isFinite(Number(source.opacity)) ? Math.max(0, Math.min(1, Number(source.opacity))) : 1,
|
||||
tags: normalizeEditorTags(source.tags),
|
||||
roles: nextRoles,
|
||||
tileSymbol: nextRoles.includes("tile")
|
||||
? (String(source.tileSymbol ?? source.symbol ?? source.id ?? "T").trim().charAt(0) || "T")
|
||||
: "",
|
||||
defaultFrame: defaultFrameId,
|
||||
speed: Number.isFinite(Number(source.speed)) && Number(source.speed) >= 0 ? Number(source.speed) : 0,
|
||||
playback: normalizeImagePlayback(source.playback),
|
||||
frames,
|
||||
rows: workingRows,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOpacityValue(value, fallback = 1) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(0, Math.min(1, parsed));
|
||||
}
|
||||
|
||||
export function formatOpacityValue(value) {
|
||||
const normalized = normalizeOpacityValue(value, 1);
|
||||
return normalized.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
export function cloneRows(rows) {
|
||||
return Array.isArray(rows)
|
||||
? rows.map((row) => String(row || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE))
|
||||
: Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE));
|
||||
}
|
||||
|
||||
export function buildRowsPreviewRecord(rows) {
|
||||
return {
|
||||
width: TILE_ART_SIZE,
|
||||
height: TILE_ART_SIZE,
|
||||
rows: cloneRows(rows),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatPlaybackLabel(value) {
|
||||
const normalized = normalizeImagePlayback(value);
|
||||
if (normalized === "rewind") {
|
||||
return "Rewind";
|
||||
}
|
||||
if (normalized === "stop") {
|
||||
return "Stop";
|
||||
}
|
||||
return "Normal";
|
||||
}
|
||||
|
||||
export function getWorkingCellSymbol(record, x, y) {
|
||||
const rows = Array.isArray(record?.rows) ? record.rows : [];
|
||||
const row = String(rows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
|
||||
return String(row.charAt(x) || ".").charAt(0) || ".";
|
||||
}
|
||||
|
||||
export function paintWorkingRowsCell(rows, x, y, symbol) {
|
||||
const nextRows = cloneRows(rows);
|
||||
const nextSymbol = String(symbol || ".").charAt(0) || ".";
|
||||
const targetRow = String(nextRows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
|
||||
nextRows[y] = `${targetRow.slice(0, x)}${nextSymbol}${targetRow.slice(x + 1)}`;
|
||||
return nextRows;
|
||||
}
|
||||
|
||||
export function getRowsMatrix(rows) {
|
||||
return cloneRows(rows).map((row) => Array.from(row));
|
||||
}
|
||||
|
||||
export function buildRowsFromMatrix(matrix) {
|
||||
return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => {
|
||||
const sourceRow = Array.isArray(matrix?.[rowIndex]) ? matrix[rowIndex] : [];
|
||||
return sourceRow.join("").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
export function getAlternatePaintSymbol(record, preferredSymbol) {
|
||||
const normalizedPreferred = String(preferredSymbol || "").charAt(0) || ".";
|
||||
const palette = getSpritePalette(record || undefined);
|
||||
const nextSymbol = Object.keys(palette)
|
||||
.map((symbol) => String(symbol || "").charAt(0))
|
||||
.find((symbol) => symbol && symbol !== normalizedPreferred && symbol !== ".");
|
||||
return nextSymbol || ".";
|
||||
}
|
||||
|
||||
export function shiftRows(rows, offsetX, offsetY) {
|
||||
const nextRows = Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE).split(""));
|
||||
const sourceRows = cloneRows(rows);
|
||||
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
|
||||
const row = sourceRows[y] || ".".repeat(TILE_ART_SIZE);
|
||||
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
|
||||
const nextX = x + offsetX;
|
||||
const nextY = y + offsetY;
|
||||
if (nextX < 0 || nextX >= TILE_ART_SIZE || nextY < 0 || nextY >= TILE_ART_SIZE) {
|
||||
continue;
|
||||
}
|
||||
nextRows[nextY][nextX] = String(row.charAt(x) || ".").charAt(0) || ".";
|
||||
}
|
||||
}
|
||||
return buildRowsFromMatrix(nextRows);
|
||||
}
|
||||
|
||||
export function flipRowsHorizontally(rows) {
|
||||
return cloneRows(rows).map((row) => row.split("").reverse().join(""));
|
||||
}
|
||||
|
||||
export function flipRowsVertically(rows) {
|
||||
return cloneRows(rows).slice().reverse();
|
||||
}
|
||||
|
||||
export function rotateRowsClockwise(rows) {
|
||||
const matrix = getRowsMatrix(rows);
|
||||
const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => "."));
|
||||
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
|
||||
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
|
||||
nextMatrix[x][TILE_ART_SIZE - 1 - y] = String(matrix[y]?.[x] || ".").charAt(0) || ".";
|
||||
}
|
||||
}
|
||||
return buildRowsFromMatrix(nextMatrix);
|
||||
}
|
||||
|
||||
export function rotateRowsCounterClockwise(rows) {
|
||||
const matrix = getRowsMatrix(rows);
|
||||
const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => "."));
|
||||
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
|
||||
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
|
||||
nextMatrix[TILE_ART_SIZE - 1 - x][y] = String(matrix[y]?.[x] || ".").charAt(0) || ".";
|
||||
}
|
||||
}
|
||||
return buildRowsFromMatrix(nextMatrix);
|
||||
}
|
||||
|
||||
export function buildShapeFillMask(shapeKind, startX, startY, endX, endY) {
|
||||
const minX = Math.max(0, Math.min(startX, endX));
|
||||
const maxX = Math.min(TILE_ART_SIZE - 1, Math.max(startX, endX));
|
||||
const minY = Math.max(0, Math.min(startY, endY));
|
||||
const maxY = Math.min(TILE_ART_SIZE - 1, Math.max(startY, endY));
|
||||
const fillMask = new Set();
|
||||
const shape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle";
|
||||
const width = Math.max(1, (maxX - minX) + 1);
|
||||
const height = Math.max(1, (maxY - minY) + 1);
|
||||
const centerX = minX + (width / 2);
|
||||
const centerY = minY + (height / 2);
|
||||
const denomX = Math.max(0.5, width / 2);
|
||||
const denomY = Math.max(0.5, height / 2);
|
||||
const triangleAx = minX + (width - 1) / 2;
|
||||
const triangleAy = minY;
|
||||
const triangleBx = minX;
|
||||
const triangleBy = maxY;
|
||||
const triangleCx = maxX;
|
||||
const triangleCy = maxY;
|
||||
const triangleDenominator = ((triangleBy - triangleCy) * (triangleAx - triangleCx)) + ((triangleCx - triangleBx) * (triangleAy - triangleCy));
|
||||
for (let y = minY; y <= maxY; y += 1) {
|
||||
for (let x = minX; x <= maxX; x += 1) {
|
||||
let include;
|
||||
const sampleX = x + 0.5;
|
||||
const sampleY = y + 0.5;
|
||||
if (shape === "rectangle") {
|
||||
include = true;
|
||||
} else if (shape === "circle") {
|
||||
const normX = (sampleX - centerX) / denomX;
|
||||
const normY = (sampleY - centerY) / denomY;
|
||||
include = (normX * normX) + (normY * normY) <= 1;
|
||||
} else if (triangleDenominator !== 0) {
|
||||
const a = (((triangleBy - triangleCy) * (sampleX - triangleCx)) + ((triangleCx - triangleBx) * (sampleY - triangleCy))) / triangleDenominator;
|
||||
const b = (((triangleCy - triangleAy) * (sampleX - triangleCx)) + ((triangleAx - triangleCx) * (sampleY - triangleCy))) / triangleDenominator;
|
||||
const c = 1 - a - b;
|
||||
include = a >= 0 && b >= 0 && c >= 0;
|
||||
} else {
|
||||
include = x === Math.round(triangleAx) && y >= minY && y <= maxY;
|
||||
}
|
||||
if (include === true) {
|
||||
fillMask.add(`${x}:${y}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fillMask;
|
||||
}
|
||||
|
||||
export function buildOutlineMask(fillMask) {
|
||||
const outlineMask = new Set();
|
||||
fillMask.forEach((key) => {
|
||||
const [xText, yText] = String(key || "").split(":");
|
||||
const x = Number(xText);
|
||||
const y = Number(yText);
|
||||
const neighbors = [
|
||||
`${x - 1}:${y}`,
|
||||
`${x + 1}:${y}`,
|
||||
`${x}:${y - 1}`,
|
||||
`${x}:${y + 1}`,
|
||||
];
|
||||
if (neighbors.some((neighbor) => !fillMask.has(neighbor))) {
|
||||
outlineMask.add(key);
|
||||
}
|
||||
});
|
||||
return outlineMask;
|
||||
}
|
||||
|
||||
export function applyMaskToRows(baseRows, mask, symbol) {
|
||||
const matrix = getRowsMatrix(baseRows);
|
||||
mask.forEach((key) => {
|
||||
const [xText, yText] = String(key || "").split(":");
|
||||
const x = Number(xText);
|
||||
const y = Number(yText);
|
||||
if (x < 0 || x >= TILE_ART_SIZE || y < 0 || y >= TILE_ART_SIZE) {
|
||||
return;
|
||||
}
|
||||
matrix[y][x] = String(symbol || ".").charAt(0) || ".";
|
||||
});
|
||||
return buildRowsFromMatrix(matrix);
|
||||
}
|
||||
|
||||
export function getLineRows(baseRows, startX, startY, endX, endY, symbol) {
|
||||
const normalizedSymbol = String(symbol || ".").charAt(0) || ".";
|
||||
const matrix = getRowsMatrix(baseRows);
|
||||
let x0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startX) || 0));
|
||||
let y0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startY) || 0));
|
||||
const x1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endX) || 0));
|
||||
const y1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endY) || 0));
|
||||
const deltaX = Math.abs(x1 - x0);
|
||||
const deltaY = Math.abs(y1 - y0);
|
||||
const stepX = x0 < x1 ? 1 : -1;
|
||||
const stepY = y0 < y1 ? 1 : -1;
|
||||
let error = deltaX - deltaY;
|
||||
while (true) {
|
||||
matrix[y0][x0] = normalizedSymbol;
|
||||
if (x0 === x1 && y0 === y1) {
|
||||
break;
|
||||
}
|
||||
const nextError = error * 2;
|
||||
if (nextError > -deltaY) {
|
||||
error -= deltaY;
|
||||
x0 += stepX;
|
||||
}
|
||||
if (nextError < deltaX) {
|
||||
error += deltaX;
|
||||
y0 += stepY;
|
||||
}
|
||||
}
|
||||
return buildRowsFromMatrix(matrix);
|
||||
}
|
||||
|
||||
export function buildShapeOptionIconMarkup(shapeKind, variant, tone = "draw") {
|
||||
const normalizedShape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle";
|
||||
const normalizedVariant = variant === "outline" || variant === "two-tone" ? variant : "fill";
|
||||
const normalizedTone = tone === "erase" ? "erase" : "draw";
|
||||
return ""
|
||||
+ `<span class="tile-art-menu-shape-icon is-${normalizedShape} is-${normalizedVariant} is-${normalizedTone}" aria-hidden="true">`
|
||||
+ "<span class=\"tile-art-menu-shape-outline\"></span>"
|
||||
+ "<span class=\"tile-art-menu-shape-fill\"></span>"
|
||||
+ "</span>";
|
||||
}
|
||||
|
||||
export function buildLineOptionIconMarkup(tone = "draw") {
|
||||
const normalizedTone = tone === "erase" ? "erase" : "draw";
|
||||
return ""
|
||||
+ `<span class="tile-art-menu-line-icon is-${normalizedTone}" aria-hidden="true">`
|
||||
+ "<span class=\"tile-art-menu-line-stroke\"></span>"
|
||||
+ "</span>";
|
||||
}
|
||||
|
||||
export function buildCurrentShapeToolIconMarkup(state) {
|
||||
if (state?.activeTool === "line" || String(state?.activeShapeMenuId || "").trim() === "line") {
|
||||
return buildLineOptionIconMarkup("draw");
|
||||
}
|
||||
return buildShapeOptionIconMarkup(
|
||||
state?.activeShapeKind || "rectangle",
|
||||
state?.activeShapeVariant || "outline",
|
||||
"draw",
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCurrentEraseToolIconMarkup(state) {
|
||||
return buildShapeOptionIconMarkup(
|
||||
state?.activeEraseKind || "rectangle",
|
||||
"fill",
|
||||
"erase",
|
||||
);
|
||||
}
|
||||
|
||||
export function buildTransformCategoryIconMarkup(kind) {
|
||||
const normalizedKind = kind === "flip" ? "flip" : "rotate";
|
||||
return ""
|
||||
+ `<span class="tile-art-menu-transform-icon is-${normalizedKind}" aria-hidden="true">`
|
||||
+ "<span class=\"tile-art-menu-transform-part part-a\"></span>"
|
||||
+ "<span class=\"tile-art-menu-transform-part part-b\"></span>"
|
||||
+ "</span>";
|
||||
}
|
||||
|
||||
export function buildTransformOptionIconMarkup(kind) {
|
||||
const normalizedKind = [
|
||||
"rotate-cw",
|
||||
"rotate-ccw",
|
||||
"flip-h",
|
||||
"flip-v",
|
||||
].includes(String(kind || "").trim()) ? String(kind || "").trim() : "rotate-cw";
|
||||
return ""
|
||||
+ `<span class="tile-art-menu-transform-icon is-${normalizedKind}" aria-hidden="true">`
|
||||
+ "<span class=\"tile-art-menu-transform-part part-a\"></span>"
|
||||
+ "<span class=\"tile-art-menu-transform-part part-b\"></span>"
|
||||
+ "</span>";
|
||||
}
|
||||
|
||||
export function buildFramePreviewDataUrl(rows, scale = 10) {
|
||||
return buildSpritePreviewDataUrl(buildRowsPreviewRecord(rows), scale);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue