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:
+676
-129
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,588 @@
|
||||
import React, { useState } from "react";
|
||||
import type { Asset, Entity, Project } from "../engine/schema.ts";
|
||||
export function Field2D({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
step = 0.1,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
min?: number;
|
||||
step?: number;
|
||||
}) {
|
||||
return (
|
||||
<label className="property">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
key={value}
|
||||
aria-label={label}
|
||||
type="number"
|
||||
defaultValue={value}
|
||||
min={min}
|
||||
step={step}
|
||||
onBlur={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (Number.isFinite(v) && v !== value) onChange(v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
export function FrameThumb({
|
||||
asset,
|
||||
index,
|
||||
size = 40,
|
||||
}: {
|
||||
asset?: Asset;
|
||||
index: number;
|
||||
size?: number;
|
||||
}) {
|
||||
const f = asset?.image?.frames[index],
|
||||
im = asset?.image;
|
||||
if (!im || !f) return <span>{index}</span>;
|
||||
const scale = size / Math.max(f.width, f.height);
|
||||
return (
|
||||
<span
|
||||
className="frame-thumb"
|
||||
style={{
|
||||
width: f.width * scale,
|
||||
height: f.height * scale,
|
||||
backgroundImage: `url("${asset!.uri}")`,
|
||||
backgroundSize: `${im.width * scale}px ${im.height * scale}px`,
|
||||
backgroundPosition: `${-f.x * scale}px ${-f.y * scale}px`,
|
||||
imageRendering: im.filter === "nearest" ? "pixelated" : "auto",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export function Inspector2D({
|
||||
type,
|
||||
c,
|
||||
node,
|
||||
project,
|
||||
change,
|
||||
editImage,
|
||||
preview,
|
||||
brush,
|
||||
}: {
|
||||
type: string;
|
||||
c: any;
|
||||
node: Entity;
|
||||
project: Project;
|
||||
change: (v: any) => void;
|
||||
editImage: (id: string) => void;
|
||||
preview: (name: string) => void;
|
||||
brush: (frame: number | null, fill?: boolean) => void;
|
||||
}) {
|
||||
const [paintMode, setPaintMode] = useState("brush");
|
||||
const set = (key: string, value: any) => change({ ...c, [key]: value });
|
||||
const num = (
|
||||
key: string,
|
||||
label: string,
|
||||
fallback = 0,
|
||||
min?: number,
|
||||
step = 0.1,
|
||||
) => (
|
||||
<Field2D
|
||||
key={key}
|
||||
label={label}
|
||||
value={c[key] ?? fallback}
|
||||
onChange={(v) => set(key, v)}
|
||||
min={min}
|
||||
step={step}
|
||||
/>
|
||||
);
|
||||
const bool = (key: string, label: string, fallback = false) => (
|
||||
<label className="property" key={key}>
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={c[key] ?? fallback}
|
||||
onChange={(e) => set(key, e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
const select = (
|
||||
key: string,
|
||||
label: string,
|
||||
options: [string, string][],
|
||||
fallback = "",
|
||||
) => (
|
||||
<label className="property">
|
||||
<span>{label}</span>
|
||||
<select
|
||||
aria-label={label}
|
||||
value={c[key] ?? fallback}
|
||||
onChange={(e) => set(key, e.target.value)}
|
||||
>
|
||||
{options.map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
const pair = (
|
||||
key: string,
|
||||
label: string,
|
||||
fallback = [0, 0],
|
||||
min?: number,
|
||||
) => (
|
||||
<div className="pair2d" key={key}>
|
||||
{[0, 1].map((i) => (
|
||||
<Field2D
|
||||
key={i}
|
||||
label={label + " " + ["X", "Y"][i]}
|
||||
value={(c[key] || fallback)[i]}
|
||||
min={min}
|
||||
onChange={(v) => {
|
||||
const a = [...(c[key] || fallback)];
|
||||
a[i] = v;
|
||||
set(key, a);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const asset = project.assets.find((a) => a.id === c.assetId),
|
||||
frames = asset?.image?.frames || [];
|
||||
const imageSelect = (
|
||||
<>
|
||||
<label className="property">
|
||||
<span>Изображение</span>
|
||||
<select
|
||||
aria-label="Изображение спрайта"
|
||||
value={c.assetId || ""}
|
||||
onChange={(e) =>
|
||||
change({
|
||||
...c,
|
||||
assetId: e.target.value,
|
||||
...(type === "sprite" ? { frame: 0 } : { cells: [] }),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Белый квадрат</option>
|
||||
{project.assets
|
||||
.filter((a) => a.kind === "image")
|
||||
.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{asset && (
|
||||
<button onClick={() => editImage(asset.id)}>Нарезка и pivot…</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const sorting = (
|
||||
<>
|
||||
{num("layer", "Слой", 0, undefined, 1)}
|
||||
{num("order", "Порядок в слое", 0, undefined, 1)}
|
||||
{bool("sortY", "Сортировать по Y")}
|
||||
<label className="property">
|
||||
<span>Цвет</span>
|
||||
<input
|
||||
type="color"
|
||||
value={c.color || "#ffffff"}
|
||||
onChange={(e) => set("color", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{num("alpha", "Непрозрачность", 1, 0)}
|
||||
{bool("lit", "Освещение 3D")}
|
||||
</>
|
||||
);
|
||||
if (type === "sprite")
|
||||
return (
|
||||
<>
|
||||
{imageSelect}
|
||||
<label className="property">
|
||||
<span>Кадр</span>
|
||||
<select
|
||||
aria-label="Кадр спрайта"
|
||||
value={c.frame || 0}
|
||||
onChange={(e) => set("frame", +e.target.value)}
|
||||
>
|
||||
{(frames.length ? frames : [{ name: "Квадрат" }]).map((f, i) => (
|
||||
<option key={i} value={i}>
|
||||
{i}: {f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{pair(
|
||||
"size",
|
||||
"Размер",
|
||||
frames[c.frame || 0]
|
||||
? [
|
||||
frames[c.frame || 0].width / asset!.image!.pixelsPerUnit,
|
||||
frames[c.frame || 0].height / asset!.image!.pixelsPerUnit,
|
||||
]
|
||||
: [1, 1],
|
||||
0.001,
|
||||
)}
|
||||
{c.size && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const { size, ...rest } = c;
|
||||
change(rest);
|
||||
}}
|
||||
>
|
||||
Размер из PPU
|
||||
</button>
|
||||
)}
|
||||
{bool("flipX", "Отразить X")}
|
||||
{bool("flipY", "Отразить Y")}
|
||||
{sorting}
|
||||
</>
|
||||
);
|
||||
if (type === "tilemap")
|
||||
return (
|
||||
<>
|
||||
{imageSelect}
|
||||
{pair("tileSize", "Тайл", [1, 1], 0.001)}
|
||||
{num("width", "Столбцы", 32, 1, 1)}
|
||||
{num("height", "Строки", 18, 1, 1)}
|
||||
{bool("collisions", "Коллизии тайлов")}
|
||||
{sorting}
|
||||
<p className="small-note">
|
||||
Рисование в виде 2D. Один мазок — один шаг Undo. Начало карты: нижний
|
||||
левый угол.
|
||||
</p>
|
||||
<div className="tile-tools">
|
||||
{[
|
||||
["brush", "Кисть"],
|
||||
["fill", "Заливка"],
|
||||
["erase", "Ластик"],
|
||||
].map(([v, l]) => (
|
||||
<button
|
||||
key={v}
|
||||
className={paintMode === v ? "active" : ""}
|
||||
onClick={() => {
|
||||
setPaintMode(v);
|
||||
brush(v === "erase" ? null : 0, v === "fill");
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="tile-palette">
|
||||
{(frames.length ? frames : [{ name: "Квадрат" }]).map((f, i) => (
|
||||
<button
|
||||
key={i}
|
||||
title={`${i}: ${f.name}`}
|
||||
onClick={() =>
|
||||
brush(paintMode === "erase" ? null : i, paintMode === "fill")
|
||||
}
|
||||
>
|
||||
<FrameThumb asset={asset} index={i} />
|
||||
<small>{i}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => set("cells", [])}>Очистить карту</button>
|
||||
<p className="small-note">
|
||||
{c.cells.length} тайлов. Выберите инструмент Q/W/E/R, чтобы закончить
|
||||
рисование.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
if (type === "spriteAnimator") {
|
||||
const clips = c.clips || [];
|
||||
return (
|
||||
<>
|
||||
{select("autoplay", "Автозапуск", [
|
||||
["", "Нет"],
|
||||
...clips.map((cl: any) => [cl.name, cl.name] as [string, string]),
|
||||
])}
|
||||
{bool("autoStates", "Авто: idle / run / jump")}
|
||||
<p className="small-note">
|
||||
Автоматические состояния используют скорость и grounded из
|
||||
Character2D.
|
||||
</p>
|
||||
{clips.map((cl: any, i: number) => {
|
||||
const update = (v: any) =>
|
||||
set(
|
||||
"clips",
|
||||
clips.map((old: any, j: number) =>
|
||||
j === i ? { ...old, ...v } : old,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<div className="clip2d" key={i}>
|
||||
<label className="property">
|
||||
<span>Название</span>
|
||||
<input
|
||||
defaultValue={cl.name}
|
||||
key={cl.name}
|
||||
onBlur={(e) => {
|
||||
const name = e.target.value;
|
||||
change({
|
||||
...c,
|
||||
autoplay: c.autoplay === cl.name ? name : c.autoplay,
|
||||
clips: clips.map((old: any, j: number) =>
|
||||
j === i ? { ...old, name } : old,
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="property">
|
||||
<span>Кадры через запятую</span>
|
||||
<input
|
||||
aria-label={"Кадры " + cl.name}
|
||||
key={cl.frames.join(",")}
|
||||
defaultValue={cl.frames.join(",")}
|
||||
onBlur={(e) =>
|
||||
update({
|
||||
frames: e.target.value
|
||||
.split(",")
|
||||
.map((s) => Number(s.trim())),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Field2D
|
||||
label={"FPS " + cl.name}
|
||||
value={cl.fps}
|
||||
min={0.1}
|
||||
onChange={(fps) => update({ fps })}
|
||||
/>
|
||||
<label className="property">
|
||||
Цикл
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cl.loop !== false}
|
||||
onChange={(e) => update({ loop: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
<button onClick={() => preview(cl.name)}>▶ Просмотр</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
change({
|
||||
...c,
|
||||
autoplay: c.autoplay === cl.name ? "" : c.autoplay,
|
||||
clips: clips.filter((_: any, j: number) => j !== i),
|
||||
})
|
||||
}
|
||||
>
|
||||
Удалить клип
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => {
|
||||
let name = "clip";
|
||||
for (let i = 1; clips.some((cl: any) => cl.name === name); i++)
|
||||
name = "clip" + i;
|
||||
set("clips", [...clips, { name, frames: [0], fps: 8, loop: true }]);
|
||||
}}
|
||||
>
|
||||
Добавить клип
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (type === "rigidbody2d")
|
||||
return (
|
||||
<>
|
||||
{select(
|
||||
"type",
|
||||
"Тип тела",
|
||||
[
|
||||
["fixed", "Неподвижное"],
|
||||
["dynamic", "Динамическое"],
|
||||
["kinematic", "Кинематическое"],
|
||||
],
|
||||
"dynamic",
|
||||
)}
|
||||
{num("mass", "Масса", 1, 0)}
|
||||
{num("friction", "Трение", 0.5, 0)}
|
||||
{num("restitution", "Упругость", 0, 0)}
|
||||
{num("gravityScale", "Множитель гравитации", 1)}
|
||||
{num("linearDamping", "Сопротивление", 0, 0)}
|
||||
{num("angularDamping", "Угловое сопротивление", 0, 0)}
|
||||
{bool("lockRotation", "Фиксировать вращение", true)}
|
||||
{bool("ccd", "Непрерывные коллизии", true)}
|
||||
</>
|
||||
);
|
||||
if (type === "collider2d")
|
||||
return (
|
||||
<>
|
||||
<label className="property">
|
||||
<span>Форма</span>
|
||||
<select
|
||||
value={c.shape}
|
||||
aria-label="Форма Collider2D"
|
||||
onChange={(e) =>
|
||||
change({
|
||||
...c,
|
||||
shape: e.target.value,
|
||||
...(e.target.value === "polygon" && !c.points
|
||||
? {
|
||||
points: [
|
||||
[-0.5, -0.5],
|
||||
[0.5, -0.5],
|
||||
[0.5, 0.5],
|
||||
[-0.5, 0.5],
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
{[
|
||||
["box", "Прямоугольник"],
|
||||
["circle", "Круг"],
|
||||
["capsule", "Капсула"],
|
||||
["polygon", "Выпуклый полигон"],
|
||||
].map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{c.shape === "box" && pair("size", "Размер", [1, 1], 0.001)}
|
||||
{["circle", "capsule"].includes(c.shape) &&
|
||||
num("radius", "Радиус", 0.5, 0.001)}
|
||||
{c.shape === "capsule" && num("height", "Высота", 1.8, 0.001)}
|
||||
{c.shape === "polygon" && (
|
||||
<>
|
||||
<p className="small-note">Вершины выпуклого контура по порядку.</p>
|
||||
{c.points.map((p: number[], i: number) => (
|
||||
<div key={i}>
|
||||
{[0, 1].map((j) => (
|
||||
<Field2D
|
||||
key={j}
|
||||
label={`Точка ${i} ${j ? "Y" : "X"}`}
|
||||
value={p[j]}
|
||||
onChange={(v) =>
|
||||
set(
|
||||
"points",
|
||||
c.points.map((old: number[], k: number) =>
|
||||
k === i ? old.map((x, l) => (l === j ? v : x)) : old,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{c.points.length > 3 && (
|
||||
<button
|
||||
onClick={() =>
|
||||
set(
|
||||
"points",
|
||||
c.points.filter((_: any, k: number) => k !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
Удалить точку {i}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
const a = c.points.at(-1),
|
||||
b = c.points[0];
|
||||
set("points", [
|
||||
...c.points,
|
||||
[(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Добавить точку
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{pair("offset", "Смещение")}
|
||||
{bool("sensor", "Триггер")}
|
||||
{bool("oneWay", "Односторонняя платформа")}
|
||||
{num("membership", "Группа (битовая маска)", 1, 0, 1)}
|
||||
{num("mask", "Взаимодействует с", 65535, 0, 1)}
|
||||
</>
|
||||
);
|
||||
if (type === "character2d")
|
||||
return (
|
||||
<>
|
||||
{select(
|
||||
"mode",
|
||||
"Игра",
|
||||
[
|
||||
["platformer", "Платформер"],
|
||||
["topDown", "Вид сверху"],
|
||||
],
|
||||
"platformer",
|
||||
)}
|
||||
{bool("controls", "WASD / стрелки / касание", true)}
|
||||
{num("speed", "Скорость", 5, 0)}
|
||||
{c.mode !== "topDown" && (
|
||||
<>
|
||||
{num("jumpSpeed", "Скорость прыжка", 8, 0)}
|
||||
{num("gravity", "Гравитация персонажа", 20, 0)}
|
||||
{num("autostep", "Высота ступени", 0.2, 0)}
|
||||
</>
|
||||
)}
|
||||
<p className="small-note">
|
||||
Прыжок: пробел. При собственном скрипте отключите встроенное
|
||||
управление.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
if (type === "joint2d")
|
||||
return (
|
||||
<>
|
||||
{select(
|
||||
"type",
|
||||
"Соединение",
|
||||
[
|
||||
["revolute", "Шарнир"],
|
||||
["fixed", "Жёсткое"],
|
||||
["rope", "Верёвка"],
|
||||
["spring", "Пружина"],
|
||||
],
|
||||
"revolute",
|
||||
)}
|
||||
{select(
|
||||
"targetId",
|
||||
"Второе тело",
|
||||
project.scenes
|
||||
.find((s) => s.id === project.activeSceneId)!
|
||||
.entities.filter(
|
||||
(n) => n.id !== node.id && n.components.rigidbody2d,
|
||||
)
|
||||
.map((n) => [n.id, n.name]),
|
||||
)}
|
||||
{pair("anchor", "Крепление")}
|
||||
{pair("targetAnchor", "Крепление цели")}
|
||||
{["rope", "spring"].includes(c.type) &&
|
||||
num("length", "Длина", 1, 0.001)}
|
||||
{c.type === "spring" && (
|
||||
<>
|
||||
{num("stiffness", "Жёсткость", 50, 0)}
|
||||
{num("damping", "Затухание", 5, 0)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
if (type === "camera2d")
|
||||
return (
|
||||
<>
|
||||
{num("orthoWidth", "Ширина кадра", 20, 0.1)}
|
||||
{bool("pixelPerfect", "Pixel Perfect")}
|
||||
{num("pixelsPerUnit", "Пикселей на единицу", 100, 0.1)}
|
||||
{pair("offset", "Смещение", [0, 0])}
|
||||
</>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2227,3 +2227,93 @@ button:hover:not(:disabled) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
/* Shared-scene 2D authoring tools */
|
||||
.property input[type="number"] {
|
||||
min-width: 0;
|
||||
width: 95px;
|
||||
}
|
||||
.pair2d .property {
|
||||
padding: 2px 0;
|
||||
}
|
||||
.tile-tools {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.tile-palette {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
|
||||
gap: 4px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
.tile-palette button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
min-height: 52px;
|
||||
background: #25292b;
|
||||
}
|
||||
.frame-thumb {
|
||||
display: inline-block;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.clip2d {
|
||||
border: 1px solid var(--line, #42484a);
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.sprite-editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1fr) 290px;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
max-height: 75vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.sprite-sheet {
|
||||
align-self: start;
|
||||
background: repeating-conic-gradient(#25292b 0% 25%, #363c3f 0% 50%) 50%/24px
|
||||
24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sprite-rect {
|
||||
position: absolute;
|
||||
border: 1px solid #69c9ff !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
font-size: 10px;
|
||||
text-align: left;
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px black;
|
||||
}
|
||||
.sprite-rect.selected {
|
||||
border: 2px solid #ffe48a !important;
|
||||
}
|
||||
.sprite-rect i {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #ffe48a;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.sprite-editor h3 {
|
||||
margin: 18px 0 8px;
|
||||
}
|
||||
.sprite-editor .property input {
|
||||
max-width: 145px;
|
||||
}
|
||||
.sprite-editor button {
|
||||
margin: 3px 0;
|
||||
}
|
||||
.asset-art.image {
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.sprite-editor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user