Add shared-scene 2D authoring, sprite animation and Rapier2D physics
CI / validate (push) Canceled after 0s
CI / validate (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user