Add shared-scene 2D authoring, sprite animation and Rapier2D physics
CI / validate (push) Canceled after 0s

This commit is contained in:
emil28092005
2026-09-12 04:58:59 +03:00
parent 6f497d1977
commit 3afac743a4
32 changed files with 4999 additions and 270 deletions
+190
View File
@@ -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>
);
}