<!DOCTYPE html><html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Toon Boom Shrimp Harmony</title>
<style>
:root {
--bg:#111318;
--panel:#1b1e26;
--panel2:#242833;
--line:#343947;
--text:#f4f5f8;
--muted:#a2a8b8;
--accent:#ff783f;
--accent2:#ff9b60;
--danger:#e84e5b;
}
* { box-sizing:border-box; }
body {
margin:0;
background:var(--bg);
color:var(--text);
font-family:Arial,sans-serif;
min-height:100vh;
}
button,input,textarea,select { font:inherit; }
button {
border:1px solid var(--line);
border-radius:9px;
padding:10px 13px;
background:var(--panel2);
color:var(--text);
cursor:pointer;
}
button:hover { border-color:var(--accent); }
button.active,.primary {
background:var(--accent);
color:#171717;
border-color:var(--accent);
font-weight:bold;
}
button.danger { color:#ff8b91; }
input,textarea,select {
width:100%;
padding:11px;
color:var(--text);
background:var(--bg);
border:1px solid var(--line);
border-radius:9px;
outline:none;
}
input:focus,textarea:focus { border-color:var(--accent); }
textarea { resize:vertical; min-height:75px; }
header {
min-height:65px;
padding:12px 16px;
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
background:var(--panel);
border-bottom:1px solid var(--line);
}
.brand {
display:flex;
align-items:center;
gap:10px;
font-weight:bold;
font-size:16px;
}
.logo {
width:38px;
height:38px;
display:grid;
place-items:center;
border-radius:11px;
background:var(--accent);
color:#171717;
font-size:23px;
}
.version { color:var(--muted); font-size:11px; }
.top-actions { display:flex; gap:7px; }
nav {
display:flex;
gap:7px;
padding:10px 14px;
overflow-x:auto;
background:var(--panel);
border-bottom:1px solid var(--line);
}
nav button { white-space:nowrap; }
main { max-width:1200px; margin:auto; padding:18px; }
.page { display:none; }
.page.active { display:block; }
h1 { font-size:26px; margin:4px 0 10px; }
h2 { font-size:19px; margin:0 0 12px; }
h3 { font-size:15px; margin:0 0 8px; }
p { color:var(--muted); line-height:1.5; }
.hero {
padding:25px;
border:1px solid #49372f;
border-radius:18px;
background:linear-gradient(135deg,#29211f,#191c24 65%);
margin-bottom:20px;
}
.hero p { max-width:650px; }
.row { display:flex; flex-wrap:wrap; gap:9px; align-items:center; }
.grid {
display:grid;
grid-template-columns:repeat(auto-fill,minmax(210px,1fr));
gap:13px;
}
.card {
background:var(--panel);
border:1px solid var(--line);
border-radius:13px;
padding:15px;
min-width:0;
}
.card p { font-size:13px; }
.card-actions { display:flex; gap:7px; margin-top:12px; }
.card-actions button { flex:1; }
.section { margin:22px 0; }
.empty {
padding:25px;
border:1px dashed var(--line);
border-radius:12px;
text-align:center;
color:var(--muted);
}
.small { font-size:12px; color:var(--muted); }
.field { margin:12px 0; }
.field label {
display:block;
font-size:13px;
color:var(--muted);
margin-bottom:6px;
}
.character-color {
width:42px;
height:42px;
border-radius:50%;
border:2px solid var(--line);
}
.editor-page {
max-width:none;
padding:10px;
}
.editor-heading {
display:flex;
flex-wrap:wrap;
align-items:center;
justify-content:space-between;
gap:10px;
margin-bottom:10px;
}
.editor-layout {
display:grid;
grid-template-columns:190px minmax(0,1fr) 190px;
gap:10px;
align-items:start;
}
.side-panel {
background:var(--panel);
border:1px solid var(--line);
border-radius:12px;
padding:10px;
min-width:0;
}
.tool-grid {
display:grid;
grid-template-columns:1fr 1fr;
gap:6px;
}
.tool-grid button {
padding:9px 4px;
font-size:12px;
}
.tool-grid button.active {
background:var(--accent);
color:#171717;
}
.canvas-panel {
min-width:0;
background:var(--panel);
border:1px solid var(--line);
border-radius:12px;
padding:10px;
}
.canvas-toolbar {
display:flex;
flex-wrap:wrap;
align-items:center;
gap:7px;
margin-bottom:10px;
}
.canvas-toolbar button { padding:8px 10px; }
.canvas-wrap {
width:100%;
aspect-ratio:4/3;
min-height:240px;
max-height:65vh;
display:flex;
align-items:center;
justify-content:center;
overflow:hidden;
border-radius:8px;
background-color:#d8d8d8;
background-image:
linear-gradient(45deg,#c5c5c5 25%,transparent 25%),
linear-gradient(-45deg,#c5c5c5 25%,transparent 25%),
linear-gradient(45deg,transparent 75%,#c5c5c5 75%),
linear-gradient(-45deg,transparent 75%,#c5c5c5 75%);
background-size:20px 20px;
background-position:0 0,0 10px,10px -10px,-10px 0;
}
#drawCanvas {
display:block;
width:100%;
height:100%;
object-fit:contain;
touch-action:none;
cursor:crosshair;
}
.timeline {
margin-top:10px;
padding:10px;
background:var(--panel);
border:1px solid var(--line);
border-radius:10px;
}
.timeline-head {
display:flex;
justify-content:space-between;
align-items:center;
flex-wrap:wrap;
gap:8px;
margin-bottom:9px;
}
.frames {
display:flex;
gap:8px;
overflow-x:auto;
padding:3px 2px 8px;
}
.frame-item {
min-width:72px;
width:72px;
padding:5px;
border:1px solid var(--line);
border-radius:8px;
background:var(--bg);
}
.frame-item.active { border:2px solid var(--accent); }
.frame-thumb {
width:100%;
aspect-ratio:4/3;
background:white;
border-radius:4px;
display:block;
}
.frame-label { font-size:11px; text-align:center; margin-top:5px; }
.layer-row {
display:flex;
align-items:center;
gap:5px;
margin:7px 0;
}
.layer-row button { padding:7px 8px; font-size:12px; }
.layer-row .layer-select { flex:1; text-align:left; min-width:0; }
.layer-row .layer-select.active { background:var(--accent); color:#171717; }
#brushSize { padding:0; }
#colorPicker { padding:3px; height:38px; }
hr { border:0; border-top:1px solid var(--line); margin:14px 0; }
#toast {
position:fixed;
left:50%;
bottom:20px;
transform:translateX(-50%);
padding:12px 18px;
border-radius:10px;
background:#f4f5f8;
color:#111318;
font-size:13px;
box-shadow:0 5px 25px #0005;
display:none;
z-index:20;
max-width:90%;
}
@media(max-width:850px) {
.editor-layout { grid-template-columns:1fr; }
.side-panel { order:2; }
.canvas-panel { order:1; }
.editor-layout .right-panel { order:3; }
.tool-grid { grid-template-columns:repeat(4,minmax(0,1fr)); }
.canvas-wrap { min-height:230px; max-height:55vh; }
}
@media(max-width:480px) {
header { padding:10px; }
.brand { font-size:13px; }
main { padding:12px; }
.editor-page { padding:7px; }
.hero { padding:18px; }
.tool-grid button { font-size:11px; }
.top-actions button { padding:8px; font-size:12px; }
}
</style>
</head>
<body><header>
<div class="brand">
<div class="logo">🦐</div>
<div>
<div>Toon Boom Shrimp Harmony</div>
<div class="version">STUDIO 0.4</div>
</div>
</div>
<div class="top-actions">
<button onclick="showPage('home')">Главная</button>
<button class="primary" onclick="newProject()">+ Проект</button>
</div>
</header><nav>
<button class="active" data-page="home" onclick="showPage('home')">Главная</button>
<button data-page="projects" onclick="showPage('projects')">Проекты</button>
<button data-page="characters" onclick="showPage('characters')">Персонажи</button>
<button data-page="editor" onclick="showPage('editor')">Редактор</button>
</nav><main id="home" class="page active">
<section class="hero">
<div class="small">ТВОЯ АНИМАЦИОННАЯ СТУДИЯ</div>
<h1>Создавай собственные мультфильмы</h1>
<p>Рисуй на холсте, создавай кадры анимации, придумывай персонажей и сохраняй проекты прямо в браузере.</p>
<div class="row">
<button class="primary" onclick="newProject()">+ Новый проект</button>
<button onclick="showPage('projects')">Мои проекты</button>
<button onclick="showPage('characters')">Мои персонажи</button>
</div>
</section> <section class="section">
<h2>Быстрый доступ</h2>
<div class="grid">
<div class="card">
<h3>🎨 Редактор</h3>
<p>Рисуй кистью, используй фигуры и работай с кадрами.</p>
<button onclick="openCurrentProject()">Открыть редактор</button>
</div>
<div class="card">
<h3>📁 Проекты</h3>
<p>Создавай и продолжай свои анимационные проекты.</p>
<button onclick="showPage('projects')">Перейти к проектам</button>
</div>
<div class="card">
<h3>🧑 Персонажи</h3>
<p>Собирай собственную библиотеку персонажей.</p>
<button onclick="showPage('characters')">Открыть персонажей</button>
</div>
</div>
</section> <section class="section">
<h2>Последние проекты</h2>
<div id="recentProjects" class="grid"></div>
</section>
</main><main id="projects" class="page">
<div class="row" style="justify-content:space-between;margin-bottom:18px">
<div>
<h1>Мои проекты</h1>
<p>Открой проект и продолжи рисование.</p>
</div>
<button class="primary" onclick="newProject()">+ Создать проект</button>
</div>
<div id="projectList" class="grid"></div>
</main><main id="characters" class="page">
<div class="row" style="justify-content:space-between;margin-bottom:18px">
<div>
<h1>Персонажи</h1>
<p>Твоя библиотека героев для будущих мультфильмов.</p>
</div>
<button class="primary" onclick="newCharacter()">+ Добавить</button>
</div>
<div id="characterList" class="grid"></div>
</main><main id="editor" class="page editor-page">
<div class="editor-heading">
<div>
<h2 id="editorTitle">Редактор</h2>
<div class="small">Рисование и покадровая анимация</div>
</div>
<div class="row">
<button onclick="showPage('projects')">Все проекты</button>
<button class="primary" onclick="saveProject()">Сохранить</button>
</div>
</div> <div class="editor-layout">
<aside class="side-panel">
<h3>Инструменты</h3>
<div class="tool-grid">
<button class="active" data-tool="brush" onclick="setTool('brush')">✎ Кисть</button>
<button data-tool="eraser" onclick="setTool('eraser')">⌫ Ластик</button>
<button data-tool="fill" onclick="setTool('fill')">▧ Заливка</button>
<button data-tool="line" onclick="setTool('line')">/ Линия</button>
<button data-tool="rect" onclick="setTool('rect')">□ Прямоуг.</button>
<button data-tool="ellipse" onclick="setTool('ellipse')">○ Эллипс</button>
</div> <hr>
<div class="field">
<label>Цвет кисти</label>
<input id="colorPicker" type="color" value="#222222">
</div>
<div class="field">
<label>Размер кисти: <span id="sizeLabel">5</span> px</label>
<input id="brushSize" type="range" min="1" max="40" value="5">
</div>
<div class="field">
<label>Скорость анимации (FPS)</label>
<select id="fpsSelect">
<option value="2">2 FPS</option>
<option value="4">4 FPS</option>
<option value="6" selected>6 FPS</option>
<option value="8">8 FPS</option>
<option value="12">12 FPS</option>
<option value="24">24 FPS</option>
</select>
</div>
<button style="width:100%" onclick="clearCurrentLayer()">Очистить слой</button>
<button style="width:100%;margin-top:7px" onclick="exportPNG()">Экспорт PNG</button>
<button style="width:100%;margin-top:7px" onclick="exportProject()">Экспорт проекта JSON</button>
<button style="width:100%;margin-top:7px" onclick="document.getElementById('importFile').click()">Импорт проекта JSON</button>
<input id="importFile" type="file" accept=".json,application/json" hidden>
</aside>
<section class="canvas-panel">
<div class="canvas-toolbar">
<button onclick="undo()">↶ Отменить</button>
<button onclick="redo()">↷ Повторить</button>
<button id="onionButton" onclick="toggleOnion()">Onion Skin: выкл.</button>
<button onclick="resizeCanvas()">Сброс холста</button>
</div>
<div class="canvas-wrap">
<canvas id="drawCanvas" width="640" height="480"></canvas>
</div>
<div class="small" style="margin-top:8px">Холст 640 × 480 px. На телефоне рисуй пальцем.</div>
<div class="timeline">
<div class="timeline-head">
<h3>Таймлайн</h3>
<div class="row">
<button onclick="addFrame()">+ Кадр</button>
<button onclick="duplicateFrame()">Дублировать</button>
<button onclick="deleteFrame()">Удалить</button>
</div>
</div>
<div id="frameList" class="frames"></div>
<div class="row">
<button class="primary" id="playButton" onclick="togglePlayback()">▶ Воспроизвести</button>
<span class="small" id="frameInfo">Кадр 1</span>
</div>
</div>
</section>
<aside class="side-panel right-panel">
<h3>Слои</h3>
<p class="small">Выбери слой для рисования.</p>
<div id="layerList"></div>
<button style="width:100%;margin-top:8px" onclick="addLayer()">+ Добавить слой</button>
<button style="width:100%;margin-top:7px" onclick="renameLayer()">Переименовать</button>
<button style="width:100%;margin-top:7px" onclick="toggleLayerVisibility()">Показать / скрыть</button>
<button class="danger" style="width:100%;margin-top:7px" onclick="deleteLayer()">Удалить слой</button>
<hr>
<h3>О проекте</h3>
<div class="small" id="projectInfo">Проект не выбран</div>
<hr>
<button style="width:100%" onclick="saveProject()">Сохранить сейчас</button>
</aside>
</div>
</main><div id="toast"></div><script>
const STORAGE_KEY = "shrimpHarmonyStudio04";
const W = 640;
const H = 480;
let projects = [];
let characters = [];
let currentProjectId = null;
let project = null;
let currentFrame = 0;
let currentLayer = 0;
let tool = "brush";
let onionSkin = false;
let playing = false;
let playTimer = null;
let drawing = false;
let startPoint = null;
let lastPoint = null;
let undoStack = [];
let redoStack = [];
let toastTimer = null;
const canvas = document.getElementById("drawCanvas");
const ctx = canvas.getContext("2d", {willReadFrequently:true});
function uid() {
return Date.now().toString(36) + Math.random().toString(36).slice(2,8);
}
function notify(message) {
const el = document.getElementById("toast");
el.textContent = message;
el.style.display = "block";
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.style.display = "none", 2400);
}
function loadDatabase() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const db = JSON.parse(raw);
projects = Array.isArray(db.projects) ? db.projects : [];
characters = Array.isArray(db.characters) ? db.characters : [];
}
} catch(e) {
notify("Не удалось прочитать сохранённые данные.");
}
}
function saveDatabase() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
projects,
characters
}));
return true;
} catch(e) {
notify("Не хватает места для сохранения. Экспортируй проект в JSON.");
return false;
}
}
function makeBlankLayer() {
const c = document.createElement("canvas");
c.width = W;
c.height = H;
return c;
}
function blankFrame(layerCount) {
return Array.from({length:layerCount}, () => "");
}
function makeProject(name) {
return {
id:uid(),
name,
created:new Date().toISOString(),
updated:new Date().toISOString(),
width:W,
height:H,
layers:[
{id:uid(),name:"Слой 1",visible:true},
{id:uid(),name:"Слой 2",visible:true}
],
frames:[blankFrame(2)]
};
}
function findProject(id) {
return projects.find(p => p.id === id);
}
function currentFrameData() {
if (!project || !project.frames[currentFrame]) return [];
return project.frames[currentFrame];
}
function ensureFrameStructure() {
if (!project.frames[currentFrame]) {
project.frames[currentFrame] = blankFrame(project.layers.length);
}
while (project.frames[currentFrame].length < project.layers.length) {
project.frames[currentFrame].push("");
}
}
function loadLayerImage(data) {
return new Promise(resolve => {
const c = makeBlankLayer();
if (!data) return resolve(c);
const img = new Image();
img.onload = () => {
c.getContext("2d").drawImage(img,0,0,W,H);
resolve(c);
};
img.onerror = () => resolve(c);
img.src = data;
});
}
let layerCanvases = [];
async function loadFrameLayers() {
if (!project) return;
ensureFrameStructure();
const data = currentFrameData();
layerCanvases = await Promise.all(
project.layers.map((layer,i) => loadLayerImage(data[i] || ""))
);
currentLayer = Math.min(currentLayer, project.layers.length - 1);
resetHistory();
renderAll();
renderLayerList();
renderTimeline();
}
function saveFrameLayers() {
if (!project) return;
ensureFrameStructure();
project.frames[currentFrame] = layerCanvases.map(c => c.toDataURL("image/png"));
}
function persistCurrentProject() {
if (!project) return;
saveFrameLayers();
project.updated = new Date().toISOString();
const i = projects.findIndex(p => p.id === project.id);
if (i >= 0) projects[i] = project;
else projects.unshift(project);
saveDatabase();
renderProjects();
}
function showPage(name) {
stopPlayback();
document.querySelectorAll(".page").forEach(el => {
el.classList.toggle("active", el.id === name);
});
document.querySelectorAll("nav button[data-page]").forEach(el => {
el.classList.toggle("active", el.dataset.page === name);
});
if (name === "home") renderHome();
if (name === "projects") renderProjects();
if (name === "characters") renderCharacters();
if (name === "editor") {
if (!project) {
if (projects.length) openProject(projects[0].id);
else {
const p = makeProject("Мой первый мультфильм");
projects.unshift(p);
project = p;
currentProjectId = p.id;
currentFrame = 0;
currentLayer = 0;
saveDatabase();
loadFrameLayers().then(() => {
document.getElementById("editorTitle").textContent = project.name;
});
}
} else {
renderAll();
}
}
}
function newProject() {
const name = prompt("Название нового проекта:", "Мой мультфильм");
if (name === null) return;
const clean = name.trim() || "Новый проект";
const p = makeProject(clean);
projects.unshift(p);
project = p;
currentProjectId = p.id;
currentFrame = 0;
currentLayer = 0;
saveDatabase();
loadFrameLayers().then(() => {
updateEditorInfo();
showPage("editor");
notify("Проект создан. Можно рисовать!");
});
}
function openProject(id) {
const p = findProject(id);
if (!p) {
notify("Проект не найден.");
return;
}
stopPlayback();
project = p;
currentProjectId = p.id;
currentFrame = 0;
currentLayer = 0;
if (!Array.isArray(project.layers) || !project.layers.length) {
project.layers = [{id:uid(),name:"Слой 1",visible:true}];
}
if (!Array.isArray(project.frames) || !project.frames.length) {
project.frames = [blankFrame(project.layers.length)];
}
project.frames.forEach(frame => {
while (frame.length < project.layers.length) frame.push("");
});
loadFrameLayers().then(() => {
updateEditorInfo();
showPage("editor");
notify("Проект открыт.");
});
}
function openCurrentProject() {
if (project) {
openProject(project.id);
} else if (projects.length) {
openProject(projects[0].id);
} else {
newProject();
}
}
function deleteProject(id) {
const p = findProject(id);
if (!p) return;
if (!confirm('Удалить проект "' + p.name + '"?')) return;
projects = projects.filter(x => x.id !== id);
if (project && project.id === id) {
project = null;
currentProjectId = null;
layerCanvases = [];
}
saveDatabase();
renderProjects();
renderHome();
notify("Проект удалён.");
}
function renameProject(id) {
const p = findProject(id);
if (!p) return;
const name = prompt("Новое название проекта:",p.name);
if (name === null || !name.trim()) return;
p.name = name.trim();
p.updated = new Date().toISOString();
if (project && project.id === id) {
project.name = p.name;
updateEditorInfo();
}
saveDatabase();
renderProjects();
renderHome();
}
function renderProjects() {
const box = document.getElementById("projectList");
box.innerHTML = "";
if (!projects.length) {
box.innerHTML = '<div class="empty">Пока нет проектов. Создай первый мультфильм!</div>';
return;
}
projects.forEach(p => {
const card = document.createElement("div");
card.className = "card";
const title = document.createElement("h3");
title.textContent = "🎬 " + p.name;
const desc = document.createElement("p");
desc.textContent = (p.frames ? p.frames.length : 1) + " кадр(ов) · " +
(p.layers ? p.layers.length : 1) + " слой(ёв)";
const actions = document.createElement("div");
actions.className = "card-actions";
const open = document.createElement("button");
open.className = "primary";
open.textContent = "Открыть";
open.onclick = () => openProject(p.id);
const more = document.createElement("button");
more.textContent = "Переименовать";
more.onclick = () => renameProject(p.id);
const del = document.createElement("button");
del.className = "danger";
del.textContent = "Удалить";
del.onclick = () => deleteProject(p.id);
actions.append(open,more,del);
card.append(title,desc,actions);
box.append(card);
});
}
function renderHome() {
const box = document.getElementById("recentProjects");
box.innerHTML = "";
if (!projects.length) {
box.innerHTML = '<div class="empty">Здесь появятся твои проекты.</div>';
return;
}
projects.slice(0,3).forEach(p => {
const card = document.createElement("div");
card.className = "card";
const h = document.createElement("h3");
h.textContent = "🎬 " + p.name;
const desc = document.createElement("p");
desc.textContent = (p.frames ? p.frames.length : 1) + " кадров";
const btn = document.createElement("button");
btn.className = "primary";
btn.textContent = "Открыть";
btn.onclick = () => openProject(p.id);
card.append(h,desc,btn);
box.append(card);
});
}
function newCharacter() {
const name = prompt("Имя персонажа:");
if (name === null || !name.trim()) return;
const description = prompt("Описание персонажа:", "") || "";
characters.unshift({
id:uid(),
name:name.trim(),
description,
color:"#ff783f"
});
saveDatabase();
renderCharacters();
notify("Персонаж добавлен.");
}
function editCharacter(id) {
const c = characters.find(x => x.id === id);
if (!c) return;
const name = prompt("Имя персонажа:",c.name);
if (name === null || !name.trim()) return;
const description = prompt("Описание персонажа:",c.description);
if (description === null) return;
c.name = name.trim();
c.description = description;
saveDatabase();
renderCharacters();
}
function deleteCharacter(id) {
if (!confirm("Удалить этого персонажа?")) return;
characters = characters.filter(c => c.id !== id);
saveDatabase();
renderCharacters();
}
function renderCharacters() {
const box = document.getElementById("characterList");
box.innerHTML = "";
if (!characters.length) {
box.innerHTML = '<div class="empty">Персонажей пока нет. Нажми «Добавить».</div>';
return;
}
characters.forEach(c => {
const card = document.createElement("div");
card.className = "card";
const top = document.createElement("div");
top.className = "row";
const circle = document.createElement("div");
circle.className = "character-color";
circle.style.background = c.color || "#ff783f";
const title = document.createElement("h3");
title.textContent = c.name;
top.append(circle,title);
const desc = document.createElement("p");
desc.textContent = c.description || "Описание не добавлено.";
const actions = document.createElement("div");
actions.className = "card-actions";
const edit = document.createElement("button");
edit.textContent = "Изменить";
edit.onclick = () => editCharacter(c.id);
const del = document.createElement("button");
del.className = "danger";
del.textContent = "Удалить";
del.onclick = () => deleteCharacter(c.id);
actions.append(edit,del);
card.append(top,desc,actions);
box.append(card);
});
}
function updateEditorInfo() {
if (!project) return;
document.getElementById("editorTitle").textContent = project.name;
document.getElementById("projectInfo").textContent =
project.name + "\nКадров: " + project.frames.length +
"\nСлоёв: " + project.layers.length;
}
function saveProject() {
if (!project) {
notify("Сначала создай проект.");
return;
}
persistCurrentProject();
updateEditorInfo();
renderHome();
notify("Проект сохранён.");
}
function setTool(name) {
tool = name;
document.querySelectorAll("[data-tool]").forEach(b => {
b.classList.toggle("active",b.dataset.tool === name);
});
}
document.getElementById("brushSize").addEventListener("input",e => {
document.getElementById("sizeLabel").textContent = e.target.value;
});
function getPos(e) {
const r = canvas.getBoundingClientRect();
return {
x:(e.clientX-r.left)*W/r.width,
y:(e.clientY-r.top)*H/r.height
};
}
function activeCanvas() {
return layerCanvases[currentLayer];
}
function renderAll() {
if (!project) return;
ctx.clearRect(0,0,W,H);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0,0,W,H);
if (onionSkin && currentFrame > 0) {
const previous = project.frames[currentFrame-1] || [];
previous.forEach((data,i) => {
if (!project.layers[i] || !project.layers[i].visible || !data) return;
const img = new Image();
img.onload = () => {
if (!project || currentFrame === 0) return;
ctx.save();
ctx.globalAlpha = 0.20;
ctx.drawImage(img,0,0,W,H);
ctx.restore();
drawVisibleLayers();
};
img.src = data;
});
}
drawVisibleLayers();
updateEditorInfo();
}
function drawVisibleLayers() {
if (!project) return;
ctx.clearRect(0,0,W,H);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0,0,W,H);
layerCanvases.forEach((c,i) => {
if (project.layers[i] && project.layers[i].visible) ctx.drawImage(c,0,0);
});
}
function snapshotLayer() {
if (!activeCanvas()) return "";
return activeCanvas().toDataURL("image/png");
}
function resetHistory() {
undoStack = [];
redoStack = [];
}
function rememberUndo() {
if (!activeCanvas()) return;
undoStack.push(snapshotLayer());
if (undoStack.length > 30) undoStack.shift();
redoStack = [];
}
async function restoreLayer(data) {
if (!activeCanvas()) return;
const img = new Image();
await new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve;
img.src = data;
});
const c = activeCanvas();
const cctx = c.getContext("2d");
cctx.clearRect(0,0,W,H);
if (img.complete && img.naturalWidth) cctx.drawImage(img,0,0,W,H);
saveFrameLayers();
renderAll();
}
async function undo() {
if (!undoStack.length) return notify("Нечего отменять.");
redoStack.push(snapshotLayer());
const data = undoStack.pop();
await restoreLayer(data);
}
async function redo() {
if (!redoStack.length) return notify("Нечего повторять.");
undoStack.push(snapshotLayer());
const data = redoStack.pop();
await restoreLayer(data);
}
function drawShapeOn(context,kind,a,b) {
const x = Math.min(a.x,b.x);
const y = Math.min(a.y,b.y);
const w = Math.abs(b.x-a.x);
const h = Math.abs(b.y-a.y);
context.strokeStyle = document.getElementById("colorPicker").value;
context.fillStyle = context.strokeStyle;
context.lineWidth = Number(document.getElementById("brushSize").value);
context.lineCap = "round";
context.lineJoin = "round";
if (kind === "line") {
context.beginPath();
context.moveTo(a.x,a.y);
context.lineTo(b.x,b.y);
context.stroke();
} else if (kind === "rect") {
context.strokeRect(x,y,w,h);
} else if (kind === "ellipse") {
context.beginPath();
context.ellipse(x+w/2,y+h/2,Math.max(w/2,0.5),Math.max(h/2,0.5),0,0,Math.PI*2);
context.stroke();
}
}
function pointerDown(e) {
if (!project || playing) return;
if (e.button !== undefined && e.button !== 0) return;
if (!activeCanvas()) return;
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
const p = getPos(e);
drawing = true;
startPoint = p;
lastPoint = p;
rememberUndo();
if (tool === "fill") {
floodFill(Math.floor(p.x),Math.floor(p.y),
document.getElementById("colorPicker").value);
drawing = false;
finishDrawing();
return;
}
if (tool === "brush" || tool === "eraser") {
const c = activeCanvas().getContext("2d");
c.beginPath();
c.moveTo(p.x,p.y);
c.lineTo(p.x+0.01,p.y+0.01);
c.lineWidth = tool === "eraser" ?
Number(document.getElementById("brushSize").value)*3 :
Number(document.getElementById("brushSize").value);
c.lineCap = "round";
c.lineJoin = "round";
c.strokeStyle = tool === "eraser" ? "#ffffff" :
document.getElementById("colorPicker").value;
c.stroke();
renderAll();
}
}
function pointerMove(e) {
if (!drawing || !project) return;
e.preventDefault();
const p = getPos(e);
if (tool === "brush" || tool === "eraser") {
const c = activeCanvas().getContext("2d");
c.beginPath();
c.moveTo(lastPoint.x,lastPoint.y);
c.lineTo(p.x,p.y);
c.lineWidth = tool === "eraser" ?
Number(document.getElementById("brushSize").value)*3 :
Number(document.getElementById("brushSize").value);
c.lineCap = "round";
c.lineJoin = "round";
c.strokeStyle = tool === "eraser" ? "#ffffff" :
document.getElementById("colorPicker").value;
c.stroke();
lastPoint = p;
renderAll();
} else {
renderAll();
drawShapeOn(ctx,tool,startPoint,p);
}
}
function pointerUp(e) {
if (!drawing) return;
if (tool === "line" || tool === "rect" || tool === "ellipse") {
const p = getPos(e);
drawShapeOn(activeCanvas().getContext("2d"),tool,startPoint,p);
}
drawing = false;
finishDrawing();
}
function finishDrawing() {
if (!project) return;
saveFrameLayers();
renderAll();
updateEditorInfo();
}
canvas.addEventListener("pointerdown",pointerDown);
canvas.addEventListener("pointermove",pointerMove);
canvas.addEventListener("pointerup",pointerUp);
canvas.addEventListener("pointercancel",pointerUp);
function hexToRgba(hex) {
const value = hex.replace("#","");
return [
parseInt(value.slice(0,2),16),
parseInt(value.slice(2,4),16),
parseInt(value.slice(4,6),16),
255
];
}
function floodFill(x,y,hex) {
const c = activeCanvas();
const cctx = c.getContext("2d",{willReadFrequently:true});
const image = cctx.getImageData(0,0,W,H);
const d = image.data;
const start = (y*W+x)*4;
const target = [d[start],d[start+1],d[start+2],d[start+3]];
const replacement = hexToRgba(hex);
if (target.every((v,i) => v === replacement[i])) return;
const stack = [x,y];
let count = 0;
const maxPixels = W*H;
while (stack.length && count < maxPixels) {
const py = stack.pop();
const px = stack.pop();
if (px < 0 || px >= W || py < 0 || py >= H) continue;
const idx = (py*W+px)*4;
if (
d[idx] !== target[0] ||
d[idx+1] !== target[1] ||
d[idx+2] !== target[2] ||
d[idx+3] !== target[3]
) continue;
d[idx] = replacement[0];
d[idx+1] = replacement[1];
d[idx+2] = replacement[2];
d[idx+3] = 255;
stack.push(px+1,py,px-1,py,px,py+1,px,py-1);
count++;
}
cctx.putImageData(image,0,0);
}
function clearCurrentLayer() {
if (!project || !activeCanvas()) return;
if (!confirm("Очистить выбранный слой в этом кадре?")) return;
rememberUndo();
activeCanvas().getContext("2d").clearRect(0,0,W,H);
finishDrawing();
}
function resizeCanvas() {
canvas.width = W;
canvas.height = H;
renderAll();
notify("Размер холста: 640 × 480.");
}
function exportPNG() {
if (!project) return notify("Сначала открой проект.");
const out = document.createElement("canvas");
out.width = W;
out.height = H;
const octx = out.getContext("2d");
octx.fillStyle = "#ffffff";
octx.fillRect(0,0,W,H);
layerCanvases.forEach((c,i) => {
if (project.layers[i] && project.layers[i].visible) octx.drawImage(c,0,0);
});
const a = document.createElement("a");
a.download = safeFilename(project.name) + "-frame-" + (currentFrame+1) + ".png";
a.href = out.toDataURL("image/png");
a.click();
notify("PNG готов.");
}
function safeFilename(name) {
return name.replace(/[\\/:*?"<>|]/g,"_").slice(0,80) || "project";
}
function exportProject() {
if (!project) return notify("Сначала открой проект.");
persistCurrentProject();
const blob = new Blob([JSON.stringify(project,null,2)],{type:"application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = safeFilename(project.name) + ".json";
a.click();
setTimeout(() => URL.revokeObjectURL(url),1500);
notify("Проект экспортирован.");
}
document.getElementById("importFile").addEventListener("change",async e => {
const file = e.target.files[0];
if (!file) return;
try {
const imported = JSON.parse(await file.text());
if (!imported || !Array.isArray(imported.frames) ||
!Array.isArray(imported.layers)) {
throw new Error("Неверный формат");
}
imported.id = uid();
imported.name = (imported.name || "Импортированный проект") + " (импорт)";
imported.width = W;
imported.height = H;
imported.frames.forEach(frame => {
while (frame.length < imported.layers.length) frame.push("");
});
projects.unshift(imported);
project = imported;
currentProjectId = imported.id;
currentFrame = 0;
currentLayer = 0;
saveDatabase();
await loadFrameLayers();
showPage("editor");
notify("Проект импортирован.");
} catch(err) {
notify("Не удалось импортировать этот JSON-файл.");
}
e.target.value = "";
});
function addLayer() {
if (!project) return;
const name = prompt("Название слоя:", "Слой " + (project.layers.length+1));
if (name === null) return;
project.layers.push({id:uid(),name:name.trim() || "Новый слой",visible:true});
project.frames.forEach(frame => frame.push(""));
currentLayer = project.layers.length-1;
layerCanvases.push(makeBlankLayer());
persistCurrentProject();
renderLayerList();
renderAll();
notify("Слой добавлен.");
}
function renderLayerList() {
const box = document.getElementById("layerList");
box.innerHTML = "";
if (!project) return;
project.layers.forEach((layer,i) => {
const row = document.createElement("div");
row.className = "layer-row";
const select = document.createElement("button");
select.className = "layer-select" + (i===currentLayer ? " active" : "");
select.textContent = (layer.visible ? "◉ " : "○ ") + layer.name;
select.onclick = () => {
saveFrameLayers();
currentLayer = i;
renderLayerList();
};
row.append(select);
box.append(row);
});
}
function renameLayer() {
if (!project) return;
const layer = project.layers[currentLayer];
const name = prompt("Название слоя:",layer.name);
if (name === null || !name.trim()) return;
layer.name = name.trim();
persistCurrentProject();
renderLayerList();
}
function toggleLayerVisibility() {
if (!project) return;
project.layers[currentLayer].visible = !project.layers[currentLayer].visible;
persistCurrentProject();
renderLayerList();
renderAll();
}
function deleteLayer() {
if (!project || project.layers.length <= 1) {
notify("В проекте должен остаться хотя бы один слой.");
return;
}
if (!confirm("Удалить выбранный слой во всех кадрах?")) return;
project.layers.splice(currentLayer,1);
project.frames.forEach(frame => frame.splice(currentLayer,1));
currentLayer = Math.max(0,currentLayer-1);
loadFrameLayers().then(() => {
persistCurrentProject();
notify("Слой удалён.");
});
}
function renderTimeline() {
const box = document.getElementById("frameList");
box.innerHTML = "";
if (!project) return;
project.frames.forEach((frame,i) => {
const item = document.createElement("button");
item.className = "frame-item" + (i===currentFrame ? " active" : "");
const thumb = document.createElement("canvas");
thumb.className = "frame-thumb";
thumb.width = 160;
thumb.height = 120;
const tctx = thumb.getContext("2d");
tctx.fillStyle = "#fff";
tctx.fillRect(0,0,160,120);
frame.forEach((data,j) => {
if (!data || !project.layers[j] || !project.layers[j].visible) return;
const img = new Image();
img.onload = () => tctx.drawImage(img,0,0,160,120);
img.src = data;
});
const label = document.createElement("div");
label.className = "frame-label";
label.textContent = "Кадр " + (i+1);
item.append(thumb,label);
item.onclick = () => selectFrame(i);
box.append(item);
});
document.getElementById("frameInfo").textContent =
"Кадр " + (currentFrame+1) + " из " + project.frames.length;
}
async function selectFrame(i) {
if (!project || i < 0 || i >= project.frames.length) return;
if (drawing) return;
saveFrameLayers();
currentFrame = i;
await loadFrameLayers();
renderTimeline();
}
async function addFrame() {
if (!project) return;
saveFrameLayers();
project.frames.push(blankFrame(project.layers.length));
currentFrame = project.frames.length-1;
await loadFrameLayers();
persistCurrentProject();
notify("Пустой кадр добавлен.");
}
async function duplicateFrame() {
if (!project) return;
saveFrameLayers();
const copy = [...project.frames[currentFrame]];
project.frames.splice(currentFrame+1,0,copy);
currentFrame++;
await loadFrameLayers();
persistCurrentProject();
notify("Кадр дублирован.");
}
async function deleteFrame() {
if (!project || project.frames.length <= 1) {
notify("Нельзя удалить последний кадр.");
return;
}
if (!confirm("Удалить текущий кадр?")) return;
project.frames.splice(currentFrame,1);
currentFrame = Math.min(currentFrame,project.frames.length-1);
await loadFrameLayers();
persistCurrentProject();
notify("Кадр удалён.");
}
function toggleOnion() {
onionSkin = !onionSkin;
document.getElementById("onionButton").textContent =
"Onion Skin: " + (onionSkin ? "вкл." : "выкл.");
renderAll();
}
function stopPlayback() {
playing = false;
if (playTimer) clearInterval(playTimer);
playTimer = null;
const btn = document.getElementById("playButton");
if (btn) btn.textContent = "▶ Воспроизвести";
}
function togglePlayback() {
if (!project) return;
if (playing) {
stopPlayback();
renderAll();
return;
}
saveFrameLayers();
playing = true;
document.getElementById("playButton").textContent = "■ Стоп";
let playIndex = currentFrame;
const fps = Number(document.getElementById("fpsSelect").value) || 6;
playTimer = setInterval(async () => {
if (!project || !playing) return;
playIndex = (playIndex+1) % project.frames.length;
currentFrame = playIndex;
await loadFrameLayers();
renderTimeline();
},1000/fps);
}
window.addEventListener("beforeunload",() => {
if (project) {
try {
saveFrameLayers();
const i = projects.findIndex(p => p.id === project.id);
if (i >= 0) projects[i] = project;
localStorage.setItem(STORAGE_KEY,JSON.stringify({projects,characters}));
} catch(e) {}
}
});
loadDatabase();
renderHome();
renderProjects();
renderCharacters();
if (projects.length) {
project = findProject(currentProjectId) || projects[0];
currentProjectId = project.id;
loadFrameLayers();
} else {
renderAll();
}
</script></body>
</html>