191 lines
5.7 KiB
TypeScript
191 lines
5.7 KiB
TypeScript
import React, { useState } from "react";
|
||
import { clone, type Asset } from "../engine/schema.ts";
|
||
import { sliceImage, validateImage } from "../engine/two-d.ts";
|
||
import { Field2D } from "./Inspector2D.tsx";
|
||
export function SpriteEditor({
|
||
asset,
|
||
save,
|
||
}: {
|
||
asset: Asset;
|
||
save: (a: Asset) => Promise<boolean>;
|
||
}) {
|
||
const [draft, setDraft] = useState(clone(asset)),
|
||
[w, setW] = useState(asset.image!.frames[0].width),
|
||
[h, setH] = useState(asset.image!.frames[0].height),
|
||
[margin, setMargin] = useState(0),
|
||
[spacing, setSpacing] = useState(0),
|
||
[selected, setSelected] = useState(0),
|
||
[error, setError] = useState("");
|
||
const im = draft.image!,
|
||
frame = im.frames[selected] || im.frames[0];
|
||
const update = (v: any) => setDraft({ ...draft, image: { ...im, ...v } });
|
||
const modify = (v: any) =>
|
||
update({
|
||
frames: im.frames.map((f, i) => (i === selected ? { ...f, ...v } : f)),
|
||
});
|
||
return (
|
||
<div className="sprite-editor">
|
||
<div className="sprite-sheet">
|
||
<div style={{ position: "relative", width: "100%" }}>
|
||
<img
|
||
src={draft.uri}
|
||
alt={draft.name}
|
||
style={{
|
||
width: "100%",
|
||
display: "block",
|
||
imageRendering: im.filter === "nearest" ? "pixelated" : "auto",
|
||
}}
|
||
/>
|
||
{im.frames.map((f, i) => (
|
||
<button
|
||
key={i}
|
||
className={"sprite-rect " + (selected === i ? "selected" : "")}
|
||
title={"Кадр " + i}
|
||
style={{
|
||
left: (f.x / im.width) * 100 + "%",
|
||
top: (f.y / im.height) * 100 + "%",
|
||
width: (f.width / im.width) * 100 + "%",
|
||
height: (f.height / im.height) * 100 + "%",
|
||
}}
|
||
onClick={() => setSelected(i)}
|
||
>
|
||
{i}
|
||
<i
|
||
style={{
|
||
left: f.pivot[0] * 100 + "%",
|
||
top: f.pivot[1] * 100 + "%",
|
||
}}
|
||
/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<p>
|
||
{im.width} × {im.height} · {im.frames.length} кадров
|
||
</p>
|
||
<Field2D
|
||
label="Pixels Per Unit"
|
||
value={im.pixelsPerUnit}
|
||
min={0.001}
|
||
onChange={(pixelsPerUnit) => update({ pixelsPerUnit })}
|
||
/>
|
||
<label className="property">
|
||
Фильтр
|
||
<select
|
||
value={im.filter}
|
||
onChange={(e) => update({ filter: e.target.value })}
|
||
>
|
||
<option value="nearest">Pixel Art</option>
|
||
<option value="linear">Сглаживание</option>
|
||
</select>
|
||
</label>
|
||
<h3>Нарезка по сетке</h3>
|
||
<Field2D
|
||
label="Ширина кадра"
|
||
value={w}
|
||
min={1}
|
||
step={1}
|
||
onChange={setW}
|
||
/>
|
||
<Field2D
|
||
label="Высота кадра"
|
||
value={h}
|
||
min={1}
|
||
step={1}
|
||
onChange={setH}
|
||
/>
|
||
<Field2D
|
||
label="Поля"
|
||
value={margin}
|
||
min={0}
|
||
step={1}
|
||
onChange={setMargin}
|
||
/>
|
||
<Field2D
|
||
label="Интервал"
|
||
value={spacing}
|
||
min={0}
|
||
step={1}
|
||
onChange={setSpacing}
|
||
/>
|
||
<button
|
||
onClick={() => {
|
||
try {
|
||
update({
|
||
frames: sliceImage(im.width, im.height, w, h, margin, spacing),
|
||
});
|
||
setSelected(0);
|
||
setError("");
|
||
} catch (e) {
|
||
setError(String(e));
|
||
}
|
||
}}
|
||
>
|
||
Нарезать
|
||
</button>
|
||
<h3>Кадр {selected}</h3>
|
||
<label className="property">
|
||
Имя
|
||
<input
|
||
value={frame.name}
|
||
onChange={(e) => modify({ name: e.target.value })}
|
||
/>
|
||
</label>
|
||
{(["x", "y", "width", "height"] as const).map((k) => (
|
||
<Field2D
|
||
key={k}
|
||
label={k}
|
||
value={frame[k]}
|
||
step={1}
|
||
min={k === "x" || k === "y" ? 0 : 1}
|
||
onChange={(v) => modify({ [k]: v })}
|
||
/>
|
||
))}
|
||
{[0, 1].map((i) => (
|
||
<Field2D
|
||
key={i}
|
||
label={"Pivot " + (i ? "Y" : "X")}
|
||
value={frame.pivot[i]}
|
||
min={0}
|
||
onChange={(v) =>
|
||
modify({ pivot: frame.pivot.map((x, j) => (j === i ? v : x)) })
|
||
}
|
||
/>
|
||
))}
|
||
<button
|
||
onClick={() =>
|
||
update({
|
||
frames: im.frames.map((f) => ({ ...f, pivot: [...frame.pivot] })),
|
||
})
|
||
}
|
||
>
|
||
Применить pivot ко всем
|
||
</button>
|
||
<p className="small-note">
|
||
Pivot: 0–1, от верхнего левого угла. Изменения применяются ко всем
|
||
спрайтам ресурса. Если нарезка удаляет используемые кадры, сначала
|
||
исправьте клипы и Tilemap.
|
||
</p>
|
||
{error && <p role="alert">{error}</p>}
|
||
<button
|
||
className="primary"
|
||
onClick={async () => {
|
||
try {
|
||
validateImage(draft);
|
||
if (!(await save(draft)))
|
||
setError(
|
||
"Не удалось сохранить: проверьте консоль и ссылки на кадры.",
|
||
);
|
||
} catch (e) {
|
||
setError(String(e));
|
||
}
|
||
}}
|
||
>
|
||
Сохранить ресурс
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|