Files
2026-07-15 15:14:32 +08:00

849 lines
67 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FrameAnimationGraph Editor — 演示原型</title>
<style>
:root {
color-scheme: dark;
--bg: #1b1d20;
--panel: #24272b;
--panel-2: #2b2e33;
--panel-3: #32363b;
--line: #111315;
--line-soft: #3e434a;
--text: #d7d9dc;
--muted: #8f969e;
--accent: #3d91d4;
--accent-soft: #244f70;
--green: #56b97b;
--yellow: #e0b858;
--red: #df6a6a;
--purple: #a483d4;
--toolbar-h: 34px;
--bottom-h: 210px;
font-family: Inter, "Segoe UI", "Microsoft YaHei UI", sans-serif;
font-size: 12px;
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body { min-width: 1180px; min-height: 680px; background: var(--bg); color: var(--text); }
button, input, select { font: inherit; color: inherit; }
button { cursor: pointer; }
.app { width: 100vw; height: 100vh; display: flex; flex-direction: column; }
.titlebar {
height: 29px; flex: 0 0 29px; display: flex; align-items: center; gap: 9px;
padding: 0 10px; background: #202226; border-bottom: 1px solid #0f1012;
color: #e5e7e9; user-select: none;
}
.app-icon { width: 15px; height: 15px; display: grid; grid-template-columns: 1fr 1fr; gap: 2px; }
.app-icon i { background: var(--accent); border-radius: 1px; }
.titlebar .subtitle { color: var(--muted); }
.title-spacer { flex: 1; }
.demo-pill { padding: 2px 7px; border-radius: 9px; background: #3b3422; color: #f0ce78; font-size: 10px; }
.toolbar {
min-height: var(--toolbar-h); display: flex; align-items: center; gap: 5px; padding: 3px 6px;
background: linear-gradient(#35383d, #2d3034); border-bottom: 1px solid #151719; user-select: none;
}
.toolbar-group { display: flex; align-items: center; gap: 4px; }
.separator { width: 1px; height: 22px; background: #17191c; box-shadow: 1px 0 #484c52; margin: 0 3px; }
.spacer { flex: 1; }
.tool-btn, .small-btn, .icon-btn {
border: 1px solid #17191b; border-radius: 3px; background: linear-gradient(#44484e, #34373c);
box-shadow: inset 0 1px rgba(255,255,255,.06); color: #d7d9dd;
}
.tool-btn { height: 25px; padding: 0 9px; }
.small-btn { min-height: 23px; padding: 2px 7px; }
.icon-btn { width: 25px; height: 25px; padding: 0; display: inline-grid; place-items: center; }
.tool-btn:hover, .small-btn:hover, .icon-btn:hover { background: linear-gradient(#51565d, #3c4045); }
.tool-btn:active, .small-btn:active, .icon-btn:active, .tool-btn.active { background: #254e6d; border-color: #1b668f; }
.tool-btn.primary { background: linear-gradient(#3e87bc, #2a6996); border-color: #194a68; }
.tool-btn.danger { background: linear-gradient(#784a4a, #573638); }
.asset-field {
height: 25px; min-width: 205px; display: flex; align-items: center; gap: 7px; padding: 0 8px;
background: #202327; border: 1px solid #111315; border-radius: 3px;
}
.asset-cube { width: 11px; height: 11px; background: var(--purple); transform: rotate(30deg); border-radius: 2px; }
.dirty-dot { color: var(--yellow); margin-left: auto; }
.toolbar select { height: 25px; max-width: 150px; background: #25282c; border: 1px solid #151719; border-radius: 3px; padding: 0 24px 0 6px; }
.status-led { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 5px rgba(86,185,123,.5); }
.workspace { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.main { flex: 1; min-height: 0; display: flex; }
.panel { background: var(--panel); }
.left-panel { width: 286px; min-width: 240px; flex: 0 0 auto; border-right: 1px solid var(--line); display: flex; flex-direction: column; }
.right-panel { width: 338px; min-width: 290px; flex: 0 0 auto; border-left: 1px solid var(--line); display: flex; flex-direction: column; }
.panel-header {
height: 28px; flex: 0 0 28px; display: flex; align-items: center; gap: 7px; padding: 0 9px;
background: #2c2f33; border-bottom: 1px solid #141619; font-weight: 600;
}
.header-spacer { flex: 1; }
.subtle { color: var(--muted); font-weight: 400; }
.resizer { width: 4px; flex: 0 0 4px; background: #181a1d; cursor: col-resize; }
.resizer:hover { background: var(--accent); }
.tabs { display: flex; height: 30px; flex: 0 0 30px; background: #202327; border-bottom: 1px solid #131517; }
.tab { flex: 1; border: 0; background: transparent; color: #a9afb6; border-bottom: 2px solid transparent; }
.tab:hover { color: #e0e2e5; background: #292c30; }
.tab.active { color: white; background: #2b2e33; border-bottom-color: var(--accent); }
.resource-tools { padding: 7px; border-bottom: 1px solid #17191b; display: grid; gap: 6px; }
.search-wrap { position: relative; }
.search-wrap input {
width: 100%; height: 25px; padding: 0 25px 0 25px; border: 1px solid #141619; border-radius: 3px;
background: #191b1e; outline: none;
}
.search-wrap input:focus { border-color: var(--accent); }
.search-icon { position: absolute; left: 8px; top: 5px; color: var(--muted); }
.clear-search { position: absolute; right: 5px; top: 3px; border: 0; background: transparent; color: var(--muted); }
.filter-row { display: flex; gap: 5px; }
.filter-row select { min-width: 0; flex: 1; height: 24px; background: #282b30; border: 1px solid #141619; border-radius: 3px; }
.list { flex: 1; min-height: 0; overflow: auto; padding: 4px 0 14px; }
.group-title {
height: 25px; display: flex; align-items: center; gap: 5px; padding: 0 8px; color: #b8bdc3;
background: #292c31; border-top: 1px solid #34383d; border-bottom: 1px solid #1a1c1f; position: sticky; top: 0; z-index: 2;
}
.group-title .count { margin-left: auto; color: var(--muted); font-size: 10px; }
.resource-item {
min-height: 44px; padding: 5px 7px 5px 12px; border-left: 3px solid transparent; border-bottom: 1px solid #202327;
display: grid; gap: 3px; cursor: default;
}
.resource-item:hover { background: #30343a; }
.resource-item.selected { background: #254b67; border-left-color: #62b2ea; }
.resource-main { display: flex; align-items: center; gap: 6px; min-width: 0; }
.resource-name { font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.resource-id { color: #9ca3ab; margin-left: auto; font-family: Consolas, monospace; font-size: 10px; }
.resource-meta { display: flex; gap: 8px; color: var(--muted); font-size: 10px; }
.badge { display: inline-flex; align-items: center; gap: 3px; padding: 1px 5px; border-radius: 8px; font-size: 9px; line-height: 15px; white-space: nowrap; }
.badge.blue { background: #253f55; color: #8fcaef; }
.badge.green { background: #233f31; color: #83d1a0; }
.badge.yellow { background: #4a3e25; color: #f0c86f; }
.badge.red { background: #4b292c; color: #ef8d93; }
.badge.gray { background: #34383d; color: #aeb4bb; }
.badge.purple { background: #3b304b; color: #c3a8e8; }
.empty-list { padding: 30px 18px; text-align: center; color: var(--muted); }
.canvas-panel { flex: 1; min-width: 0; display: flex; flex-direction: column; background: #1e2023; }
.canvas-toolbar {
height: 31px; flex: 0 0 31px; display: flex; align-items: center; gap: 5px; padding: 0 7px;
background: #292c30; border-bottom: 1px solid #111315;
}
.canvas-toolbar .crumb { color: var(--muted); }
.canvas-toolbar .crumb strong { color: #d9dcdf; }
.zoom-label { min-width: 42px; color: var(--muted); text-align: center; font-family: Consolas, monospace; }
.canvas-viewport { flex: 1; min-height: 0; position: relative; overflow: hidden; cursor: grab; background-color: #1e2023; }
.canvas-viewport.panning { cursor: grabbing; }
.canvas-viewport::before {
content: ""; position: absolute; inset: 0; opacity: .55; pointer-events: none;
background-image: linear-gradient(#2a2d31 1px, transparent 1px), linear-gradient(90deg, #2a2d31 1px, transparent 1px),
linear-gradient(#24272a 1px, transparent 1px), linear-gradient(90deg, #24272a 1px, transparent 1px);
background-size: 80px 80px, 80px 80px, 16px 16px, 16px 16px;
}
.graph-scene { position: absolute; left: 0; top: 0; width: 1600px; height: 900px; transform-origin: 0 0; }
.edges { position: absolute; inset: 0; width: 1600px; height: 900px; overflow: visible; pointer-events: none; }
.edge { fill: none; stroke: #777f87; stroke-width: 3; opacity: .8; }
.edge.highlighted { stroke: #6bc4f2; filter: drop-shadow(0 0 3px rgba(88,184,234,.45)); opacity: 1; }
.edge.dimmed { opacity: .14; }
.edge-arrow { fill: #777f87; }
.node {
position: absolute; width: 216px; min-height: 164px; border-radius: 5px; overflow: hidden;
background: #2b2f34; border: 1px solid #121416; box-shadow: 0 6px 14px rgba(0,0,0,.32); cursor: default;
transition: opacity .15s, filter .15s, box-shadow .15s;
}
.node.selected { border-color: #69b9ed; box-shadow: 0 0 0 1px #287caf, 0 8px 18px rgba(0,0,0,.45); }
.node.active-flow { box-shadow: 0 0 0 2px #e3c05f, 0 0 18px rgba(227,192,95,.45); }
.node.dimmed { opacity: .25; filter: saturate(.35); }
.node.unused .node-head { background: #4d3e27; }
.node-head {
height: 31px; padding: 0 9px; display: flex; align-items: center; gap: 6px;
background: linear-gradient(90deg, #2e6f9b, #275b7d); border-bottom: 1px solid #131517; user-select: none; cursor: move;
}
.node-title { font-weight: 700; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.entry-mark { width: 16px; height: 16px; border-radius: 50%; display: grid; place-items: center; background: #e4b957; color: #241e11; font-size: 9px; font-weight: 800; }
.node-body { padding: 7px 9px 8px; }
.node-line { display: flex; justify-content: space-between; color: #afb5bc; margin-bottom: 5px; font-size: 10px; }
.node-line code { color: #d6d9dc; font-family: Consolas, monospace; }
.node-preview {
height: 78px; border: 1px solid #111315; border-radius: 3px; overflow: hidden; position: relative;
background-color: #6d7175;
background-image: linear-gradient(45deg,#777b7f 25%,transparent 25%),linear-gradient(-45deg,#777b7f 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#777b7f 75%),linear-gradient(-45deg,transparent 75%,#777b7f 75%);
background-size: 14px 14px; background-position: 0 0,0 7px,7px -7px,-7px 0;
}
.node-preview svg { width: 100%; height: 100%; display: block; }
.node-tags { display: flex; gap: 4px; margin-top: 6px; min-height: 16px; }
.port { position: absolute; top: 18px; width: 12px; height: 12px; border-radius: 50%; background: #abb1b7; border: 2px solid #202327; }
.port.in { left: -7px; }
.port.out { right: -7px; }
.canvas-hint {
position: absolute; left: 10px; bottom: 10px; padding: 5px 8px; border-radius: 3px;
background: rgba(20,22,24,.78); color: #858c94; pointer-events: none;
}
.inspector-scroll { flex: 1; min-height: 0; overflow: auto; padding-bottom: 18px; }
.inspector-title { padding: 11px 11px 8px; border-bottom: 1px solid #17191b; }
.inspector-title h2 { margin: 0 0 4px; font-size: 15px; font-weight: 650; }
.inspector-title code { color: #9ea5ad; font-family: Consolas, monospace; }
.section { border-bottom: 1px solid #17191b; }
.section-title { height: 27px; display: flex; align-items: center; padding: 0 10px; background: #292c30; font-weight: 600; }
.section-body { padding: 9px 10px 11px; display: grid; gap: 7px; }
.field { display: grid; grid-template-columns: 105px minmax(0,1fr); gap: 7px; align-items: center; }
.field label { color: #adb3ba; }
.field input, .field select {
width: 100%; min-width: 0; height: 24px; border: 1px solid #151719; border-radius: 3px; background: #1c1f22; padding: 0 6px;
}
.field input:focus, .field select:focus { outline: 1px solid var(--accent); }
.readonly { padding: 4px 6px; min-height: 24px; background: #202327; border: 1px solid #1a1c1f; border-radius: 3px; color: #b5bbc2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.preview-large {
height: 176px; position: relative; overflow: hidden; border: 1px solid #101214; border-radius: 4px;
background-color: #6b6f73; background-image: linear-gradient(45deg,#777b7f 25%,transparent 25%),linear-gradient(-45deg,#777b7f 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#777b7f 75%),linear-gradient(-45deg,transparent 75%,#777b7f 75%);
background-size: 18px 18px; background-position: 0 0,0 9px,9px -9px,-9px 0;
}
.preview-large svg { width: 100%; height: 100%; display: block; }
.preview-overlay { position: absolute; top: 7px; left: 7px; padding: 2px 5px; background: rgba(22,24,27,.7); border-radius: 3px; color: #d5d9dc; }
.preview-controls { display: flex; align-items: center; gap: 4px; }
.preview-controls .timeline { flex: 1; min-width: 40px; accent-color: var(--accent); }
.time-readout { display: flex; justify-content: space-between; color: var(--muted); font-family: Consolas, monospace; font-size: 10px; }
.callout { padding: 8px 9px; border-left: 3px solid var(--accent); background: #22303a; color: #bcd1df; line-height: 1.55; }
.callout.warning { border-color: var(--yellow); background: #393321; color: #e4d0a1; }
.route { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; }
.route-node { padding: 4px 6px; background: #34383d; border-radius: 3px; }
.route-arrow { color: var(--muted); }
.bottom-panel { height: var(--bottom-h); flex: 0 0 var(--bottom-h); min-height: 32px; border-top: 2px solid #111315; background: #202327; display: flex; flex-direction: column; transition: height .15s; }
.bottom-panel.collapsed { height: 32px; flex-basis: 32px; }
.bottom-head { height: 31px; flex: 0 0 31px; display: flex; align-items: center; gap: 4px; padding: 0 7px; background: #2b2e33; border-bottom: 1px solid #131517; }
.bottom-tab { height: 30px; padding: 0 12px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: #aab0b7; }
.bottom-tab.active { color: white; border-bottom-color: var(--accent); background: #24272b; }
.counter { margin-left: 4px; min-width: 16px; padding: 1px 5px; border-radius: 8px; background: #3b3f45; color: #c5cad0; font-size: 9px; }
.counter.error { background: #522c30; color: #f19ba1; }
.bottom-content { flex: 1; min-height: 0; overflow: auto; }
.result-toolbar { min-height: 31px; display: flex; align-items: center; gap: 6px; padding: 4px 8px; border-bottom: 1px solid #151719; }
.result-toolbar select { height: 23px; background: #282b30; border: 1px solid #151719; border-radius: 3px; }
.result-list { min-width: 860px; }
.result-row {
min-height: 30px; display: grid; grid-template-columns: 80px 135px 180px minmax(360px,1fr); align-items: center;
gap: 8px; padding: 4px 10px; border-bottom: 1px solid #292c30; cursor: default;
}
.result-row:hover { background: #30343a; }
.result-row.header { min-height: 26px; background: #282b30; color: var(--muted); position: sticky; top: 0; z-index: 2; }
.severity { font-weight: 700; }
.severity.error { color: var(--red); }
.severity.warning { color: var(--yellow); }
.severity.info { color: #72bde8; }
.status-added { color: #75d49a; }
.status-updated { color: #75bce6; }
.status-missing { color: #e2b961; }
.status-error { color: #e37c83; }
.status-unchanged { color: #8d949c; }
.toast {
position: fixed; right: 16px; bottom: 16px; z-index: 60; min-width: 260px; max-width: 420px;
padding: 10px 12px; background: #28333b; border: 1px solid #3e697f; border-radius: 4px; box-shadow: 0 10px 30px rgba(0,0,0,.45);
transform: translateY(20px); opacity: 0; pointer-events: none; transition: .2s;
}
.toast.show { transform: translateY(0); opacity: 1; }
.toast strong { display: block; margin-bottom: 3px; }
.modal-backdrop { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.58); display: none; place-items: center; }
.modal-backdrop.open { display: grid; }
.modal { width: 570px; max-height: 78vh; background: #282b2f; border: 1px solid #111315; box-shadow: 0 18px 60px rgba(0,0,0,.6); border-radius: 5px; overflow: hidden; }
.modal-title { height: 34px; display: flex; align-items: center; padding: 0 11px; background: #33363b; border-bottom: 1px solid #16181a; font-weight: 700; }
.modal-body { padding: 13px; line-height: 1.55; }
.modal-actions { display: flex; justify-content: flex-end; gap: 7px; padding: 9px 12px; background: #23262a; border-top: 1px solid #17191b; }
.diff-summary { display: grid; grid-template-columns: repeat(5,1fr); gap: 6px; margin: 12px 0; }
.summary-box { text-align: center; padding: 9px 4px; background: #202327; border: 1px solid #151719; border-radius: 4px; }
.summary-box strong { display: block; font-size: 19px; margin-bottom: 2px; }
.guide { position: fixed; inset: 0; z-index: 70; display: none; place-items: center; background: rgba(8,10,12,.62); }
.guide.open { display: grid; }
.guide-card { width: 620px; background: #272b2f; border: 1px solid #131517; border-radius: 6px; box-shadow: 0 20px 70px rgba(0,0,0,.65); overflow: hidden; }
.guide-head { padding: 14px 16px; background: linear-gradient(90deg,#244e6b,#2c3f50); }
.guide-head h2 { margin: 0 0 4px; font-size: 18px; }
.guide-body { padding: 14px 16px; }
.guide-steps { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.guide-step { display: grid; grid-template-columns: 27px 1fr; gap: 8px; padding: 10px; background: #202327; border: 1px solid #151719; border-radius: 4px; }
.step-num { width: 25px; height: 25px; border-radius: 50%; display: grid; place-items: center; background: var(--accent); color: white; font-weight: 700; }
.guide-actions { padding: 10px 16px; display: flex; justify-content: flex-end; background: #222529; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: #454a50; border: 2px solid #24272b; border-radius: 5px; }
::-webkit-scrollbar-track { background: #24272b; }
</style>
</head>
<body>
<div class="app">
<div class="titlebar">
<span class="app-icon"><i></i><i></i><i></i><i></i></span>
<strong>FrameAnimationGraph Editor</strong>
<span class="subtitle">AIBIS Dream / 帧动画工作台</span>
<span class="title-spacer"></span>
<span class="demo-pill">HTML DEMO · 数据不会保存</span>
</div>
<div class="toolbar">
<div class="asset-field" title="当前编辑的 FrameAnimationGraph">
<span class="asset-cube"></span>
<span>Peipei_FrameAnimationGraph</span>
<span class="dirty-dot" id="dirtyDot" title="存在未保存的演示修改" style="visibility:hidden"></span>
</div>
<button class="tool-btn" id="saveBtn">保存</button>
<span class="separator"></span>
<button class="tool-btn" id="refreshCurrentBtn">刷新当前来源</button>
<button class="tool-btn" id="refreshAllBtn">全部刷新</button>
<button class="tool-btn" id="validateBtn">校验</button>
<button class="tool-btn" id="autoLayoutBtn">自动布局</button>
<span class="separator"></span>
<div class="toolbar-group">
<span class="subtle">画布</span>
<select id="flowViewSelect" title="在同一全局图上聚焦 AnimationFlow">
<option value="all">显示全部</option>
<option value="WakeUpToIdle">聚焦 WakeUpToIdle</option>
<option value="StartTalkingToIdle">聚焦 StartTalkingToIdle</option>
</select>
<button class="icon-btn" id="playFlowBtn" title="播放当前 Flow"></button>
<button class="icon-btn" id="stopFlowBtn" title="停止 Flow"></button>
</div>
<span class="separator"></span>
<div class="toolbar-group">
<span class="subtle">节点预览</span>
<select id="previewPolicySelect">
<option value="SelectedOnly">SelectedOnly</option>
<option value="Static">Static</option>
<option value="AllVisible">AllVisible</option>
</select>
</div>
<span class="spacer"></span>
<span class="status-led"></span><span class="subtle" id="statusText">Graph 已载入</span>
<button class="tool-btn primary" id="guideBtn">演示路线</button>
</div>
<div class="workspace">
<div class="main">
<aside class="panel left-panel" id="leftPanel">
<div class="panel-header"><span>资源浏览</span><span class="header-spacer"></span><span class="subtle" id="resourceCount">7 Clips</span></div>
<div class="tabs" id="resourceTabs">
<button class="tab active" data-tab="clips">Clips</button>
<button class="tab" data-tab="flows">Flows</button>
<button class="tab" data-tab="sources">Sources</button>
</div>
<div class="resource-tools">
<div class="search-wrap"><span class="search-icon"></span><input id="resourceSearch" placeholder="搜索 id / 名称 / sourceTag"><button class="clear-search" id="clearSearch">×</button></div>
<div class="filter-row">
<select id="resourceFilter"><option value="all">全部状态</option><option value="imported">Imported</option><option value="manual">Manual</option><option value="missing">Missing</option><option value="unused">未引用</option></select>
<select id="groupMode"><option value="grouped">按来源分组</option><option value="flat">扁平列表</option></select>
</div>
</div>
<div class="list" id="resourceList"></div>
</aside>
<div class="resizer" id="leftResizer"></div>
<section class="canvas-panel">
<div class="canvas-toolbar">
<span class="crumb">Peipei_FrameAnimationGraph <strong>/ Global Graph</strong></span>
<span class="spacer"></span>
<button class="small-btn" id="focusSelectedBtn">聚焦选中</button>
<button class="small-btn" id="showAllBtn">显示全部</button>
<button class="icon-btn" id="zoomOutBtn"></button><span class="zoom-label" id="zoomLabel">100%</span><button class="icon-btn" id="zoomInBtn"></button>
</div>
<div class="canvas-viewport" id="canvasViewport">
<div class="graph-scene" id="graphScene">
<svg class="edges" id="edgesSvg" viewBox="0 0 1600 900" aria-hidden="true">
<defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto" markerUnits="strokeWidth"><path d="M0,0 L0,6 L7,3 z" class="edge-arrow"></path></marker></defs>
</svg>
<div id="nodesLayer"></div>
</div>
<div class="canvas-hint">中键/空白拖动平移 · 滚轮缩放 · 拖动节点调整位置</div>
</div>
</section>
<div class="resizer" id="rightResizer"></div>
<aside class="panel right-panel" id="rightPanel">
<div class="panel-header"><span>Inspector</span><span class="header-spacer"></span><span class="subtle" id="selectionType">FrameClip</span></div>
<div class="inspector-scroll" id="inspector"></div>
</aside>
</div>
<section class="bottom-panel" id="bottomPanel">
<div class="bottom-head">
<button class="bottom-tab" data-bottom="diff">Import Diff <span class="counter">5</span></button>
<button class="bottom-tab active" data-bottom="validation">Validation <span class="counter error" id="validationCount">4</span></button>
<span class="spacer"></span>
<span class="subtle" id="bottomSummary">1 Error · 2 Warning · 1 Info</span>
<button class="icon-btn" id="collapseBottomBtn" title="折叠/展开"></button>
</div>
<div class="bottom-content" id="bottomContent"></div>
</section>
</div>
</div>
<div class="toast" id="toast"><strong id="toastTitle"></strong><span id="toastMessage"></span></div>
<div class="modal-backdrop" id="refreshModal">
<div class="modal">
<div class="modal-title">刷新预览 · Aseprite 导入差异</div>
<div class="modal-body">
<div>系统已比较来源文件与当前 Graph。此操作仅模拟导入器,不会修改本地文件。</div>
<div class="diff-summary">
<div class="summary-box"><strong class="status-added">1</strong>新增</div>
<div class="summary-box"><strong class="status-updated">1</strong>更新</div>
<div class="summary-box"><strong class="status-missing">1</strong>Missing</div>
<div class="summary-box"><strong class="status-unchanged">4</strong>不变</div>
<div class="summary-box"><strong class="status-error">1</strong>错误</div>
</div>
<div class="callout warning"><strong>阻断项:</strong>special.json 的 Glitch_03 使用了第一版不支持的 rotated frame。应用刷新时将跳过该来源并保留现有 Graph 数据。</div>
</div>
<div class="modal-actions">
<button class="tool-btn" id="viewDiffBtn">打开 Import Diff</button>
<button class="tool-btn" id="cancelRefreshBtn">取消</button>
<button class="tool-btn primary" id="applyRefreshBtn">应用可用更新</button>
</div>
</div>
</div>
<div class="guide" id="guide">
<div class="guide-card">
<div class="guide-head"><h2>建议演示路线</h2><div>用 3–5 分钟向策划和美术说明完整工作流。</div></div>
<div class="guide-body">
<div class="guide-steps">
<div class="guide-step"><span class="step-num">1</span><div><strong>集中浏览资源</strong><br><span class="subtle">切换 Clips / Flows / Sources,查看导入来源、时长和引用状态。</span></div></div>
<div class="guide-step"><span class="step-num">2</span><div><strong>聚焦与播放 Flow</strong><br><span class="subtle">选择 WakeUpToIdle,展示共享 Idle 节点与图上直接预览。</span></div></div>
<div class="guide-step"><span class="step-num">3</span><div><strong>独立检查 Clip</strong><br><span class="subtle">选择 Blink,逐帧、拖动时间轴并调整预览速度。</span></div></div>
<div class="guide-step"><span class="step-num">4</span><div><strong>刷新与定位问题</strong><br><span class="subtle">模拟来源刷新,再从 Validation 点击定位 Missing 和旋转帧问题。</span></div></div>
</div>
</div>
<div class="guide-actions"><button class="tool-btn primary" id="closeGuideBtn">开始演示</button></div>
</div>
</div>
<script>
const clips = [
{ id:'Idle', displayName:'待机呼吸', source:'body', sourceTag:'Idle', frames:6, durations:[150,150,150,150,150,150], speed:1, end:'Loop', refs:1, status:'normal', kind:'imported', pose:'idle' },
{ id:'WakeUp', displayName:'醒来', source:'body', sourceTag:'WakeUp', frames:7, durations:[130,120,110,110,100,100,120], speed:1, end:'HoldLastFrame', refs:1, status:'normal', kind:'imported', pose:'wakeup' },
{ id:'Settle', displayName:'站稳', source:'body', sourceTag:'Settle', frames:4, durations:[120,120,140,160], speed:1, end:'HoldLastFrame', refs:1, status:'normal', kind:'imported', pose:'settle' },
{ id:'StartTalking', displayName:'开始说话', source:'face', sourceTag:'StartTalking', frames:5, durations:[110,90,100,90,130], speed:1, end:'HoldLastFrame', refs:1, status:'normal', kind:'imported', pose:'talk' },
{ id:'Blink', displayName:'眨眼', source:'face', sourceTag:'Blink', frames:5, durations:[140,70,70,80,180], speed:1, end:'HoldLastFrame', refs:1, status:'normal', kind:'imported', pose:'blink' },
{ id:'Glitch', displayName:'信号故障', source:'special', sourceTag:'Glitch', frames:6, durations:[80,60,70,60,90,110], speed:1, end:'HoldLastFrame', refs:1, status:'warning', kind:'imported', pose:'glitch', unused:true },
{ id:'MissingSmile', displayName:'微笑(来源缺失)', source:'face', sourceTag:'Smile', frames:4, durations:[120,120,120,120], speed:1, end:'HoldLastFrame', refs:0, status:'missing', kind:'imported', pose:'smile' },
{ id:'ManualPose', displayName:'手工测试姿势', source:'manual', sourceTag:'', frames:3, durations:[180,180,180], speed:.8, end:'HoldLastFrame', refs:0, status:'normal', kind:'manual', pose:'manual', unused:true }
];
const flows = [
{ id:'WakeUpToIdle', displayName:'醒来后进入待机', entry:'wake', nodes:['wake','settle','idle'], color:'#4da9df', end:'Use Node Setting' },
{ id:'StartTalkingToIdle', displayName:'开口后回到待机', entry:'talk', nodes:['talk','blink','idle'], color:'#b188d5', end:'Use Node Setting' }
];
const sources = [
{ id:'body', displayName:'Body Frames', texture:'body.png', json:'body.json', pivot:'(0.50, 0.00)', slicing:true, clips:3, status:'changed', hash:'d914…2a7c' },
{ id:'face', displayName:'Face Frames', texture:'face.png', json:'face.json', pivot:'(0.50, 0.00)', slicing:false, clips:3, status:'changed', hash:'81af…fa13' },
{ id:'special', displayName:'Special FX', texture:'special.png', json:'special.json', pivot:'(0.50, 0.50)', slicing:true, clips:1, status:'error', hash:'e03c…91db' }
];
const nodes = [
{ id:'wake', title:'Wake Up', clip:'WakeUp', x:90, y:90, speed:null, end:null, flows:['WakeUpToIdle'], entry:['WakeUpToIdle'] },
{ id:'settle', title:'Settle', clip:'Settle', x:390, y:90, speed:1.05, end:null, flows:['WakeUpToIdle'], entry:[] },
{ id:'talk', title:'Start Talking', clip:'StartTalking', x:90, y:360, speed:null, end:null, flows:['StartTalkingToIdle'], entry:['StartTalkingToIdle'] },
{ id:'blink', title:'Blink', clip:'Blink', x:390, y:360, speed:1.1, end:null, flows:['StartTalkingToIdle'], entry:[] },
{ id:'idle', title:'Shared Idle', clip:'Idle', x:720, y:220, speed:null, end:'Loop', flows:['WakeUpToIdle','StartTalkingToIdle'], entry:[] },
{ id:'glitch', title:'Glitch Preview', clip:'Glitch', x:1050, y:510, speed:.8, end:'HoldLastFrame', flows:[], entry:[], unused:true }
];
const edges = [
{ id:'e1', from:'wake', to:'settle', flows:['WakeUpToIdle'] },
{ id:'e2', from:'settle', to:'idle', flows:['WakeUpToIdle'] },
{ id:'e3', from:'talk', to:'blink', flows:['StartTalkingToIdle'] },
{ id:'e4', from:'blink', to:'idle', flows:['StartTalkingToIdle'] }
];
let validationItems = [
{ level:'Error', type:'ImportSource', object:'Special FX', message:'Glitch_03 使用 rotated frame;第一版导入器不支持旋转帧。', target:['source','special'] },
{ level:'Warning', type:'FrameClip', object:'MissingSmile', message:'来源 Tag “Smile” 已消失;保留现有 Clip 并标记 Missing。', target:['clip','MissingSmile'] },
{ level:'Warning', type:'AnimationNode', object:'Glitch Preview', message:'该节点未被任何 AnimationFlow 使用。', target:['node','glitch'] },
{ level:'Info', type:'ImportSource', object:'Body Frames', message:'来源 hash 已变化,尚未执行刷新。', target:['source','body'] }
];
const diffItems = [
{ status:'Added', source:'Face Frames', object:'SmileSoft', detail:'检测到新 Tag,将创建 Imported Clip。', target:['source','face'] },
{ status:'Updated', source:'Face Frames', object:'Blink', detail:'帧 5 → 6,总时长 560ms → 620ms。', target:['clip','Blink'] },
{ status:'Missing', source:'Face Frames', object:'MissingSmile', detail:'源中已找不到 Tag “Smile”,不会自动删除。', target:['clip','MissingSmile'] },
{ status:'Error', source:'Special FX', object:'Glitch_03', detail:'rotated=true,不支持刷新该来源。', target:['source','special'] },
{ status:'Unchanged', source:'Body Frames', object:'Idle / WakeUp / Settle', detail:'Sprite 稳定引用保持不变。', target:['source','body'] }
];
const state = {
resourceTab:'clips', filter:'all', groupMode:'grouped', search:'',
selected:{type:'clip', id:'Idle'}, flowView:'all', previewPolicy:'SelectedOnly',
zoom:1, panX:15, panY:10, bottomTab:'validation', bottomCollapsed:false,
preview:{time:0, playing:true, speed:1, last:performance.now()},
flow:{playing:false, id:null, activeNode:null, step:0, startedAt:0},
refreshed:false, dirty:false, nodePreviewLast:0
};
const $ = (id) => document.getElementById(id);
const byId = (arr,id) => arr.find(x => x.id === id);
const esc = (s) => String(s).replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
const clipTotal = clip => clip.durations.reduce((a,b)=>a+b,0);
const sourceName = id => id === 'manual' ? 'Manual Clips' : (byId(sources,id)?.displayName || id);
function frameAt(clip, timeMs) {
const total = clipTotal(clip);
let t = clip.end === 'Loop' ? ((timeMs % total) + total) % total : Math.max(0, Math.min(timeMs, total - 1));
let sum = 0;
for (let i=0;i<clip.durations.length;i++) { sum += clip.durations[i]; if (t < sum) return i; }
return clip.frames - 1;
}
function characterSvg(clip, frame) {
const n = Math.max(1, clip.frames - 1), p = frame / n;
let bob = Math.sin(p * Math.PI * 2) * 1.8;
let eye = 3.2, mouth = 1.2, tilt = 0, opacity = 1, extra = '';
if (clip.pose === 'blink') eye = frame === 2 ? .25 : (frame === 1 || frame === 3 ? 1.4 : 3.2);
if (clip.pose === 'wakeup') { eye = .35 + p * 2.85; tilt = -8 + p * 8; bob = (1-p)*8; }
if (clip.pose === 'settle') { tilt = (1-p)*5; bob = Math.sin(p*Math.PI)*3; }
if (clip.pose === 'talk') mouth = frame % 2 ? 4.5 : 1.4;
if (clip.pose === 'smile') mouth = 2.5;
if (clip.pose === 'manual') { tilt = [-5,0,5][frame] || 0; }
if (clip.pose === 'glitch') {
const shift = [0,5,-4,7,-2,2][frame%6];
extra = `<g opacity=".62"><rect x="${54+shift}" y="48" width="65" height="5" fill="#70f5ed"/><rect x="${69-shift}" y="76" width="58" height="4" fill="#ff537c"/><rect x="${49+shift}" y="91" width="48" height="3" fill="#fff36d"/></g>`;
opacity = .88; tilt = shift*.35;
}
const mouthPath = clip.pose === 'smile' ? 'M82 61 Q90 67 98 61' : `M84 62 Q90 ${62+mouth} 96 62`;
return `<svg viewBox="0 0 180 120" xmlns="http://www.w3.org/2000/svg" aria-label="${esc(clip.displayName)}${frame+1} 帧">
<ellipse cx="90" cy="111" rx="36" ry="5" fill="rgba(18,22,27,.35)"/>
<g transform="translate(0 ${bob.toFixed(1)}) rotate(${tilt.toFixed(1)} 90 68)" opacity="${opacity}">
<path d="M62 107 Q63 78 72 68 Q78 62 90 62 Q102 62 108 68 Q117 78 118 107Z" fill="#303947" stroke="#111820" stroke-width="2"/>
<path d="M69 78 Q90 89 111 78 L114 107 L66 107Z" fill="#596879" opacity=".75"/>
<rect x="84" y="65" width="12" height="11" rx="4" fill="#d6b59d"/>
<ellipse cx="90" cy="46" rx="25" ry="24" fill="#d9baa2" stroke="#12181e" stroke-width="2"/>
<path d="M65 45 Q64 18 89 17 Q115 17 117 45 Q109 34 101 31 Q82 39 65 45Z" fill="#232b35"/>
<path d="M67 42 Q59 52 66 61" fill="none" stroke="#232b35" stroke-width="7" stroke-linecap="round"/>
<path d="M113 40 Q121 50 114 61" fill="none" stroke="#232b35" stroke-width="6" stroke-linecap="round"/>
<ellipse cx="81" cy="51" rx="2.3" ry="${eye}" fill="#17212a"/>
<ellipse cx="99" cy="51" rx="2.3" ry="${eye}" fill="#17212a"/>
<path d="${mouthPath}" fill="none" stroke="#704c4c" stroke-width="1.8" stroke-linecap="round"/>
<circle cx="111" cy="55" r="3" fill="#e9a7a7" opacity=".35"/>
</g>${extra}
</svg>`;
}
function currentClipForSelection() {
if (state.selected.type === 'clip') return byId(clips,state.selected.id);
if (state.selected.type === 'node') return byId(clips,byId(nodes,state.selected.id)?.clip);
return null;
}
function selectResource(type,id, options={}) {
state.selected = {type,id};
state.preview.time = 0; state.preview.playing = type === 'clip' || type === 'node'; state.preview.last = performance.now();
if (type === 'clip') state.resourceTab = 'clips';
if (type === 'flow') { state.resourceTab = 'flows'; state.flowView = id; $('flowViewSelect').value=id; }
if (type === 'source') state.resourceTab = 'sources';
updateTabs(); renderResources(); renderInspector(); renderGraph();
if (options.focus && type === 'node') focusNode(id);
}
function updateTabs() {
document.querySelectorAll('#resourceTabs .tab').forEach(b => b.classList.toggle('active', b.dataset.tab === state.resourceTab));
}
function renderResources() {
const list = $('resourceList');
const q = state.search.toLowerCase().trim();
let html = '';
if (state.resourceTab === 'clips') {
let items = clips.filter(c => {
const text = `${c.id} ${c.displayName} ${c.sourceTag} ${sourceName(c.source)}`.toLowerCase();
const f = state.filter;
return (!q || text.includes(q)) && (f==='all' || f===c.kind || (f==='missing'&&c.status==='missing') || (f==='unused'&&c.unused));
});
$('resourceCount').textContent = `${items.length} Clips`;
if (state.groupMode === 'flat' || q) html = items.map(clipItem).join('');
else {
['body','face','special','manual'].forEach(source => {
const group = items.filter(c=>c.source===source); if (!group.length) return;
html += `<div class="group-title"><span>▾</span>${esc(sourceName(source))}<span class="count">${group.length}</span></div>${group.map(clipItem).join('')}`;
});
}
} else if (state.resourceTab === 'flows') {
const items = flows.filter(f => !q || `${f.id} ${f.displayName}`.toLowerCase().includes(q));
$('resourceCount').textContent = `${items.length} Flows`;
html = items.map(f => `<div class="resource-item ${state.selected.type==='flow'&&state.selected.id===f.id?'selected':''}" data-type="flow" data-id="${f.id}"><div class="resource-main"><span class="badge purple">FLOW</span><span class="resource-name">${esc(f.displayName)}</span><span class="resource-id">${f.id}</span></div><div class="resource-meta"><span>入口 ${byId(nodes,f.entry).title}</span><span>${f.nodes.length} 节点</span><span>共享 Idle</span></div></div>`).join('');
} else {
const items = sources.filter(s => !q || `${s.id} ${s.displayName} ${s.texture} ${s.json}`.toLowerCase().includes(q));
$('resourceCount').textContent = `${items.length} Sources`;
html = items.map(s => `<div class="resource-item ${state.selected.type==='source'&&state.selected.id===s.id?'selected':''}" data-type="source" data-id="${s.id}"><div class="resource-main"><span class="badge ${s.status==='error'?'red':s.status==='changed'?'yellow':'green'}">${s.status.toUpperCase()}</span><span class="resource-name">${esc(s.displayName)}</span><span class="resource-id">${s.id}</span></div><div class="resource-meta"><span>${s.texture}</span><span>${s.clips} Clips</span><span>${s.slicing?'管理切图':'只读 Sprite'}</span></div></div>`).join('');
}
list.innerHTML = html || '<div class="empty-list">没有符合当前条件的资源</div>';
list.querySelectorAll('.resource-item').forEach(el => el.addEventListener('click',()=>selectResource(el.dataset.type,el.dataset.id)));
}
function clipItem(c) {
const selected = state.selected.type==='clip'&&state.selected.id===c.id;
const status = c.status==='missing'?'<span class="badge red">MISSING</span>':c.status==='warning'?'<span class="badge yellow">WARNING</span>':'';
return `<div class="resource-item ${selected?'selected':''}" data-type="clip" data-id="${c.id}"><div class="resource-main"><span class="badge ${c.kind==='manual'?'gray':'blue'}">${c.kind==='manual'?'MANUAL':'IMPORTED'}</span><span class="resource-name">${esc(c.displayName)}</span>${status}<span class="resource-id">${c.id}</span></div><div class="resource-meta"><span>${c.frames} 帧</span><span>${clipTotal(c)} ms</span><span>${c.refs} 引用</span><span>${esc(c.sourceTag||'手工帧表')}</span></div></div>`;
}
function renderInspector() {
const ins = $('inspector');
const s = state.selected;
$('selectionType').textContent = ({clip:'FrameClip',node:'AnimationNode',flow:'AnimationFlow',source:'ImportSource'})[s.type] || '—';
if (s.type === 'clip') ins.innerHTML = inspectorClip(byId(clips,s.id));
else if (s.type === 'node') ins.innerHTML = inspectorNode(byId(nodes,s.id));
else if (s.type === 'flow') ins.innerHTML = inspectorFlow(byId(flows,s.id));
else if (s.type === 'source') ins.innerHTML = inspectorSource(byId(sources,s.id));
bindInspector(); updatePreview();
}
function previewBlock(clip, context) {
return `<div class="section"><div class="section-title">${context==='clip'?'独立 Clip 预览':'节点运行时等效预览'}</div><div class="section-body">
<div class="preview-large" id="largePreview"><div id="largePreviewSvg"></div><span class="preview-overlay" id="previewFrameLabel">Frame 1 / ${clip.frames}</span></div>
<div class="preview-controls"><button class="icon-btn" data-preview="first" title="从头">↶</button><button class="icon-btn" data-preview="prev" title="上一帧">◀</button><button class="icon-btn" data-preview="toggle" id="previewToggle">❚❚</button><button class="icon-btn" data-preview="next" title="下一帧">▶</button><input class="timeline" id="previewTimeline" type="range" min="0" max="${clipTotal(clip)}" value="0"></div>
<div class="time-readout"><span id="previewTime">000 / ${String(clipTotal(clip)).padStart(3,'0')} ms</span><span>Preview <select id="previewSpeed"><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option></select></span></div>
</div></div>`;
}
function inspectorClip(c) {
const issue = c.status==='missing'?'<div class="callout warning">来源中已找不到 Tag “Smile”。Clip 被保留,不会在刷新时自动删除。</div>':'';
return `<div class="inspector-title"><h2>${esc(c.displayName)}</h2><code>${c.id}</code> <span class="badge ${c.kind==='manual'?'gray':'blue'}">${c.kind==='manual'?'Manual':'Imported'}</span></div>
<div class="section"><div class="section-title">FrameClip</div><div class="section-body">${issue}
<div class="field"><label>displayName</label><input id="displayNameInput" value="${esc(c.displayName)}"></div>
<div class="field"><label>playable id</label><div class="readonly">${c.id} 🔒</div></div>
<div class="field"><label>speed</label><input id="clipSpeedInput" type="number" min="0" step="0.05" value="${c.speed}"></div>
<div class="field"><label>结束行为</label><select id="clipEndInput"><option ${c.end==='HoldLastFrame'?'selected':''}>HoldLastFrame</option><option ${c.end==='Loop'?'selected':''}>Loop</option><option ${c.end==='Hide'?'selected':''}>Hide</option></select></div>
<div class="field"><label>来源</label><div class="readonly">${c.kind==='manual'?'Manual Clip':sourceName(c.source)+' / '+c.sourceTag}</div></div>
<div class="field"><label>摘要</label><div class="readonly">${c.frames} Frames · ${clipTotal(c)} ms · ${c.refs} Node refs</div></div>
<button class="small-btn" id="locateRefsBtn" ${c.refs===0?'disabled':''}>定位引用${c.refs>1?'(选择列表)':''}</button>
</div></div>${previewBlock(c,'clip')}
<div class="section"><div class="section-title">帧表 ${c.kind==='imported'?'· 只读':''}</div><div class="section-body"><div class="readonly">#0#${c.frames-1} · Sprite / durationMs / frameName / sourceIndex</div><div class="subtle">${c.kind==='imported'?'Imported Clip 的帧表由来源刷新覆盖;如需编辑请复制为 Manual Clip。':'Manual Clip 可增删、复制和调整帧顺序。'}</div></div></div>`;
}
function inspectorNode(n) {
const c = byId(clips,n.clip);
return `<div class="inspector-title"><h2>${esc(n.title)}</h2><code>${n.id}</code> <span class="badge blue">CLIP NODE</span></div>
<div class="section"><div class="section-title">AnimationNode</div><div class="section-body">
${n.unused?'<div class="callout warning">当前节点没有被任何 AnimationFlow 使用。</div>':''}
<div class="field"><label>displayName</label><input id="nodeNameInput" value="${esc(n.title)}"></div>
<div class="field"><label>clipId</label><div class="readonly">${c.id}</div></div>
<div class="field"><label>speedOverride</label><input id="nodeSpeedInput" type="number" min="0" step="0.05" placeholder="Use Clip Speed" value="${n.speed??''}"></div>
<div class="field"><label>endOverride</label><select id="nodeEndInput"><option value="">Use Clip Setting</option><option ${n.end==='HoldLastFrame'?'selected':''}>HoldLastFrame</option><option ${n.end==='Loop'?'selected':''}>Loop</option><option ${n.end==='Hide'?'selected':''}>Hide</option></select></div>
<div class="field"><label>Flow 使用</label><div class="readonly">${n.flows.length?n.flows.join(', '):'未使用'}</div></div>
</div></div>${previewBlock(c,'node')}`;
}
function inspectorFlow(f) {
const route = f.nodes.map((id,i)=>`${i?'<span class="route-arrow">→</span>':''}<span class="route-node">${esc(byId(nodes,id).title)}${id==='idle'?' · Loop':''}</span>`).join('');
return `<div class="inspector-title"><h2>${esc(f.displayName)}</h2><code>${f.id}</code> <span class="badge purple">FLOW</span></div>
<div class="section"><div class="section-title">AnimationFlow</div><div class="section-body">
<div class="callout">Flow 不拥有独立节点图。节点范围从入口沿全局 Edge 实时推导。</div>
<div class="field"><label>displayName</label><input id="flowNameInput" value="${esc(f.displayName)}"></div>
<div class="field"><label>playable id</label><div class="readonly">${f.id} 🔒</div></div>
<div class="field"><label>entryNodeId</label><div class="readonly">${f.entry} · ${byId(nodes,f.entry).title}</div></div>
<div class="field"><label>endOverride</label><div class="readonly">${f.end}</div></div>
<div class="route">${route}</div>
<button class="tool-btn primary" id="playInspectorFlow">▶ 在全局图上播放 Flow</button>
<button class="small-btn" id="setEntryDemo">将当前选中节点设为入口(演示)</button>
</div></div>
<div class="section"><div class="section-title">共享节点</div><div class="section-body"><div class="readonly">Shared Idle · 同时属于 2 个 Flow</div><div class="subtle">从不同入口播放时,两条路径都会自然到达同一个 Idle(loop) 节点。</div></div></div>`;
}
function inspectorSource(s) {
const error = s.status==='error'?'<div class="callout warning"><strong>导入阻断:</strong>检测到 rotated frame。刷新会保留现有 Graph 数据。</div>':'';
return `<div class="inspector-title"><h2>${esc(s.displayName)}</h2><code>${s.id}</code> <span class="badge ${s.status==='error'?'red':'yellow'}">${s.status.toUpperCase()}</span></div>
<div class="section"><div class="section-title">FrameAnimationImportSource</div><div class="section-body">${error}
<div class="field"><label>displayName</label><input id="sourceNameInput" value="${esc(s.displayName)}"></div>
<div class="field"><label>internalId</label><div class="readonly">${s.id}-7b2e-4f11-9a31 🔒</div></div>
<div class="field"><label>Texture</label><div class="readonly">${s.texture}</div></div>
<div class="field"><label>Aseprite JSON</label><div class="readonly">${s.json}</div></div>
<div class="field"><label>pivot</label><input value="${s.pivot}"></div>
<div class="field"><label>管理切图</label><select><option ${s.slicing?'selected':''}>True · 写入所有者</option><option ${!s.slicing?'selected':''}>False · 只读复用</option></select></div>
<div class="field"><label>lastSourceHash</label><div class="readonly">${s.hash}</div></div>
<div class="field"><label>关联 Clip</label><div class="readonly">${s.clips}</div></div>
<button class="tool-btn primary" id="refreshSourceInspector">计算差异并刷新</button>
</div></div>`;
}
function bindInspector() {
const clip = currentClipForSelection();
document.querySelectorAll('[data-preview]').forEach(b=>b.addEventListener('click',()=>previewAction(b.dataset.preview)));
$('previewTimeline')?.addEventListener('input',e=>{state.preview.time=+e.target.value; state.preview.playing=false; updatePreview();});
$('previewSpeed')?.addEventListener('change',e=>state.preview.speed=+e.target.value);
$('displayNameInput')?.addEventListener('input',e=>{ clip.displayName=e.target.value; markDirty(); renderResources(); renderGraph(); });
$('clipSpeedInput')?.addEventListener('input',e=>{ clip.speed=Math.max(0,+e.target.value||0); markDirty(); renderGraph(); });
$('clipEndInput')?.addEventListener('change',e=>{ clip.end=e.target.value; markDirty(); renderGraph(); });
$('locateRefsBtn')?.addEventListener('click',()=>locateClipRefs(clip.id));
if (state.selected.type==='node') {
const n=byId(nodes,state.selected.id);
$('nodeNameInput')?.addEventListener('input',e=>{n.title=e.target.value;markDirty();renderGraph();});
$('nodeSpeedInput')?.addEventListener('input',e=>{n.speed=e.target.value===''?null:Math.max(0,+e.target.value);markDirty();renderGraph();});
$('nodeEndInput')?.addEventListener('change',e=>{n.end=e.target.value||null;markDirty();renderGraph();});
}
if (state.selected.type==='flow') {
const f=byId(flows,state.selected.id);
$('flowNameInput')?.addEventListener('input',e=>{f.displayName=e.target.value;markDirty();renderResources();});
$('playInspectorFlow')?.addEventListener('click',()=>startFlow(f.id));
$('setEntryDemo')?.addEventListener('click',()=>toast('演示命令','正式编辑器中,此操作会检查入口唯一性并进入 Undo。'));
}
if (state.selected.type==='source') {
const s=byId(sources,state.selected.id);
$('sourceNameInput')?.addEventListener('input',e=>{s.displayName=e.target.value;markDirty();renderResources();});
$('refreshSourceInspector')?.addEventListener('click',openRefresh);
}
}
function previewAction(action) {
const c=currentClipForSelection(); if(!c)return;
const idx=frameAt(c,state.preview.time);
if(action==='toggle') state.preview.playing=!state.preview.playing;
if(action==='first'){state.preview.time=0;state.preview.playing=true;}
if(action==='prev'){state.preview.playing=false; state.preview.time=c.durations.slice(0,Math.max(0,idx-1)).reduce((a,b)=>a+b,0);}
if(action==='next'){state.preview.playing=false; state.preview.time=c.durations.slice(0,Math.min(c.frames-1,idx+1)).reduce((a,b)=>a+b,0);}
state.preview.last=performance.now(); updatePreview();
}
function updatePreview() {
const c=currentClipForSelection(); if(!c||!$('largePreviewSvg'))return;
const total=clipTotal(c); const idx=frameAt(c,state.preview.time);
$('largePreviewSvg').innerHTML=characterSvg(c,idx);
$('previewFrameLabel').textContent=`Frame ${idx+1} / ${c.frames}`;
$('previewTime').textContent=`${String(Math.floor(state.preview.time)).padStart(3,'0')} / ${String(total).padStart(3,'0')} ms`;
$('previewTimeline').value=Math.min(state.preview.time,total);
$('previewToggle').textContent=state.preview.playing?'❚❚':'▶';
}
function renderGraph() {
const focus = state.flowView==='all'?null:byId(flows,state.flowView);
const layer=$('nodesLayer');
layer.innerHTML=nodes.map(n=>{
const c=byId(clips,n.clip); const selected=state.selected.type==='node'&&state.selected.id===n.id;
const dim=focus&&!focus.nodes.includes(n.id); const active=state.flow.activeNode===n.id;
const tags=n.flows.map(fid=>`<span class="badge ${fid==='WakeUpToIdle'?'blue':'purple'}">${fid==='WakeUpToIdle'?'WAKE':'TALK'}</span>`).join('') || '<span class="badge yellow">UNUSED</span>';
return `<article class="node ${selected?'selected':''} ${dim?'dimmed':''} ${active?'active-flow':''} ${n.unused?'unused':''}" data-node-id="${n.id}" style="left:${n.x}px;top:${n.y}px">
<span class="port in"></span><span class="port out"></span>
<div class="node-head">${n.entry.length?'<span class="entry-mark" title="Flow 入口">E</span>':''}<span class="node-title">${esc(n.title)}</span><span class="subtle">Clip</span></div>
<div class="node-body"><div class="node-line"><code>${c.id}</code><span>${n.speed??c.speed}× · ${n.end??c.end}</span></div><div class="node-preview" data-node-id="${n.id}"></div><div class="node-tags">${tags}</div></div>
</article>`;
}).join('');
bindNodes(); renderEdges(); updateNodePreviews(performance.now()); applySceneTransform();
}
function renderEdges() {
const focus=state.flowView==='all'?null:byId(flows,state.flowView);
const svg=$('edgesSvg'); const defs=svg.querySelector('defs').outerHTML;
svg.innerHTML=defs+edges.map(e=>{
const a=byId(nodes,e.from),b=byId(nodes,e.to); const x1=a.x+216,y1=a.y+18,x2=b.x,y2=b.y+18;
const dx=Math.max(70,Math.abs(x2-x1)*.48); const d=`M ${x1} ${y1} C ${x1+dx} ${y1}, ${x2-dx} ${y2}, ${x2} ${y2}`;
const cls=focus?(e.flows.includes(focus.id)?'highlighted':'dimmed'):'';
return `<path class="edge ${cls}" d="${d}" marker-end="url(#arrow)"></path>`;
}).join('');
}
function bindNodes() {
document.querySelectorAll('.node').forEach(el=>{
const id=el.dataset.nodeId; let drag=null;
el.addEventListener('pointerdown',e=>{
if(e.button!==0)return; const n=byId(nodes,id); drag={sx:e.clientX,sy:e.clientY,x:n.x,y:n.y,moved:false}; el.setPointerCapture(e.pointerId); e.stopPropagation();
});
el.addEventListener('pointermove',e=>{
if(!drag)return; const n=byId(nodes,id); const dx=(e.clientX-drag.sx)/state.zoom,dy=(e.clientY-drag.sy)/state.zoom;
if(Math.abs(dx)+Math.abs(dy)>2)drag.moved=true; n.x=Math.round(drag.x+dx);n.y=Math.round(drag.y+dy);el.style.left=n.x+'px';el.style.top=n.y+'px';renderEdges();
});
el.addEventListener('pointerup',e=>{if(!drag)return;const moved=drag.moved;drag=null;el.releasePointerCapture(e.pointerId);if(!moved)selectResource('node',id);else markDirty();});
});
}
function updateNodePreviews(now) {
document.querySelectorAll('.node-preview').forEach(el=>{
const n=byId(nodes,el.dataset.nodeId),c=byId(clips,n.clip); let t=0;
const shouldPlay = state.flow.playing ? state.flow.activeNode===n.id : state.previewPolicy==='AllVisible'||(state.previewPolicy==='SelectedOnly'&&state.selected.type==='node'&&state.selected.id===n.id);
if(shouldPlay)t=now*(n.speed??c.speed);
el.innerHTML=characterSvg(c,frameAt(c,t));
});
}
function applySceneTransform(){ $('graphScene').style.transform=`translate(${state.panX}px,${state.panY}px) scale(${state.zoom})`; $('zoomLabel').textContent=Math.round(state.zoom*100)+'%'; }
function setZoom(v,origin=null){const old=state.zoom;state.zoom=Math.max(.45,Math.min(1.55,v));if(origin){const r=$('canvasViewport').getBoundingClientRect();const mx=origin.x-r.left,my=origin.y-r.top;state.panX=mx-(mx-state.panX)*(state.zoom/old);state.panY=my-(my-state.panY)*(state.zoom/old);}applySceneTransform();}
function focusNode(id){const n=byId(nodes,id),vp=$('canvasViewport');state.zoom=Math.max(state.zoom,.8);state.panX=vp.clientWidth/2-(n.x+108)*state.zoom;state.panY=vp.clientHeight/2-(n.y+82)*state.zoom;applySceneTransform();}
function showAll(){const vp=$('canvasViewport');const minX=Math.min(...nodes.map(n=>n.x)),maxX=Math.max(...nodes.map(n=>n.x+216)),minY=Math.min(...nodes.map(n=>n.y)),maxY=Math.max(...nodes.map(n=>n.y+164));state.zoom=Math.min(1,Math.max(.48,Math.min((vp.clientWidth-90)/(maxX-minX),(vp.clientHeight-70)/(maxY-minY))));state.panX=(vp.clientWidth-(maxX-minX)*state.zoom)/2-minX*state.zoom;state.panY=(vp.clientHeight-(maxY-minY)*state.zoom)/2-minY*state.zoom;applySceneTransform();}
function autoLayout(){const positions={wake:[80,80],settle:[380,80],talk:[80,360],blink:[380,360],idle:[720,220],glitch:[1050,500]};nodes.forEach(n=>{n.x=positions[n.id][0];n.y=positions[n.id][1];});markDirty();renderGraph();showAll();toast('自动布局完成','节点位置已更新;正式编辑器中该操作会进入 Undo。');}
function locateClipRefs(id){const refs=nodes.filter(n=>n.clip===id);if(!refs.length){toast('没有节点引用',`${id} 当前没有被任何节点使用。`);return;}selectResource('node',refs[0].id,{focus:true});}
function locateTarget(target){const [type,id]=target;if(type==='node'){selectResource('node',id,{focus:true});}else selectResource(type,id);}
function startFlow(id) {
if(!id||id==='all'){id='WakeUpToIdle';state.flowView=id;$('flowViewSelect').value=id;}
const f=byId(flows,id); state.flow={playing:true,id,activeNode:f.nodes[0],step:0,startedAt:performance.now()}; state.flowView=id;$('flowViewSelect').value=id;
renderGraph(); toast('Flow 预览开始',`${f.displayName} 将直接在全局节点图上执行。`);
}
function stopFlow(){state.flow={playing:false,id:null,activeNode:null,step:0,startedAt:0};renderGraph();$('statusText').textContent='Flow 预览已停止';}
function tickFlow(now){if(!state.flow.playing)return;const f=byId(flows,state.flow.id);const n=byId(nodes,state.flow.activeNode);const c=byId(clips,n.clip);const duration=clipTotal(c)/(n.speed??c.speed);if(n.id==='idle')return;if(now-state.flow.startedAt>=duration){state.flow.step++;state.flow.activeNode=f.nodes[state.flow.step];state.flow.startedAt=now;renderGraph();$('statusText').textContent=`Flow: ${f.id} / ${byId(nodes,state.flow.activeNode).title}`;}}
function renderBottom() {
const content=$('bottomContent'); if(state.bottomCollapsed)return;
if(state.bottomTab==='validation'){
content.innerHTML=`<div class="result-toolbar"><strong>校验结果</strong><select id="severityFilter"><option value="all">全部级别</option><option>Error</option><option>Warning</option><option>Info</option></select><select id="typeFilter"><option value="all">全部对象</option><option>FrameClip</option><option>AnimationNode</option><option>ImportSource</option></select><span class="spacer"></span><button class="small-btn" id="rerunValidation">重新校验</button></div><div class="result-list" id="validationList"></div>`;
renderValidationRows(); $('severityFilter').addEventListener('change',renderValidationRows);$('typeFilter').addEventListener('change',renderValidationRows);$('rerunValidation').addEventListener('click',runValidation);
} else {
content.innerHTML=`<div class="result-toolbar"><strong>来源差异预览</strong><span class="subtle">仅在刷新来源时计算,不修改现有数据</span><span class="spacer"></span><button class="small-btn" id="diffRefreshBtn">重新计算差异</button></div><div class="result-list"><div class="result-row header"><span>状态</span><span>来源</span><span>对象</span><span>差异说明</span></div>${diffItems.map(d=>`<div class="result-row" data-target="${d.target.join(':')}"><span class="status-${d.status.toLowerCase()}">${d.status}</span><span>${esc(d.source)}</span><span>${esc(d.object)}</span><span>${esc(d.detail)}</span></div>`).join('')}</div>`;
$('diffRefreshBtn').addEventListener('click',openRefresh);bindResultTargets();
}
}
function renderValidationRows(){const l=$('validationList');if(!l)return;const sev=$('severityFilter').value,type=$('typeFilter').value;const items=validationItems.filter(v=>(sev==='all'||v.level===sev)&&(type==='all'||v.type===type));l.innerHTML=`<div class="result-row header"><span>级别</span><span>对象类型</span><span>对象</span><span>原因与处理建议</span></div>${items.map(v=>`<div class="result-row" data-target="${v.target.join(':')}"><span class="severity ${v.level.toLowerCase()}">${v.level}</span><span>${v.type}</span><span>${esc(v.object)}</span><span>${esc(v.message)}</span></div>`).join('')}`;bindResultTargets();}
function bindResultTargets(){document.querySelectorAll('.result-row[data-target]').forEach(r=>r.addEventListener('click',()=>locateTarget(r.dataset.target.split(':'))));}
function runValidation(){state.bottomTab='validation';state.bottomCollapsed=false;updateBottomTabs();renderBottom();toast('完整校验完成','发现 1 Error、2 Warning、'+(state.refreshed?'0':'1')+' Info。点击结果可定位对象。');}
function updateBottomTabs(){document.querySelectorAll('.bottom-tab').forEach(b=>b.classList.toggle('active',b.dataset.bottom===state.bottomTab));$('bottomPanel').classList.toggle('collapsed',state.bottomCollapsed);$('collapseBottomBtn').textContent=state.bottomCollapsed?'⌃':'⌄';}
function openRefresh(){ $('refreshModal').classList.add('open'); }
function closeRefresh(){ $('refreshModal').classList.remove('open'); }
function applyRefresh(){state.refreshed=true;const blink=byId(clips,'Blink');blink.frames=6;blink.durations=[140,70,70,80,80,180];const body=byId(sources,'body');body.status='normal';const face=byId(sources,'face');face.status='normal';validationItems=validationItems.filter(v=>!(v.level==='Info'&&v.object==='Body Frames'));$('validationCount').textContent=validationItems.length;closeRefresh();state.bottomTab='diff';state.bottomCollapsed=false;updateBottomTabs();renderBottom();renderResources();if(currentClipForSelection()?.id==='Blink')renderInspector();$('bottomSummary').textContent='1 Error · 2 Warning · 刷新已应用';toast('刷新完成','已更新 Blink 并记录 SmileSoftSpecial FX 因错误被安全跳过。');}
function markDirty(){state.dirty=true;$('dirtyDot').style.visibility='visible';$('statusText').textContent='存在未保存的演示修改';}
function saveDemo(){state.dirty=false;$('dirtyDot').style.visibility='hidden';toast('已模拟保存','HTML 原型不会写入磁盘;刷新页面后将恢复测试数据。');$('statusText').textContent='Graph 已保存(演示)';}
let toastTimer; function toast(title,message){$('toastTitle').textContent=title;$('toastMessage').textContent=message;$('toast').classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('toast').classList.remove('show'),3100);}
function bindStaticEvents() {
document.querySelectorAll('#resourceTabs .tab').forEach(b=>b.addEventListener('click',()=>{state.resourceTab=b.dataset.tab;state.filter='all';$('resourceFilter').value='all';updateTabs();renderResources();}));
$('resourceSearch').addEventListener('input',e=>{state.search=e.target.value;renderResources();});
$('clearSearch').addEventListener('click',()=>{state.search='';$('resourceSearch').value='';renderResources();});
$('resourceFilter').addEventListener('change',e=>{state.filter=e.target.value;renderResources();});
$('groupMode').addEventListener('change',e=>{state.groupMode=e.target.value;renderResources();});
$('flowViewSelect').addEventListener('change',e=>{state.flowView=e.target.value;renderGraph();if(e.target.value!=='all')selectResource('flow',e.target.value);});
$('previewPolicySelect').addEventListener('change',e=>{state.previewPolicy=e.target.value;});
$('playFlowBtn').addEventListener('click',()=>startFlow(state.flowView));$('stopFlowBtn').addEventListener('click',stopFlow);
$('saveBtn').addEventListener('click',saveDemo);$('refreshCurrentBtn').addEventListener('click',openRefresh);$('refreshAllBtn').addEventListener('click',openRefresh);$('validateBtn').addEventListener('click',runValidation);$('autoLayoutBtn').addEventListener('click',autoLayout);
$('focusSelectedBtn').addEventListener('click',()=>state.selected.type==='node'?focusNode(state.selected.id):toast('请选择节点','先选择画布节点,再使用“聚焦选中”。'));
$('showAllBtn').addEventListener('click',showAll);$('zoomInBtn').addEventListener('click',()=>setZoom(state.zoom+.1));$('zoomOutBtn').addEventListener('click',()=>setZoom(state.zoom-.1));
$('guideBtn').addEventListener('click',()=>$('guide').classList.add('open'));$('closeGuideBtn').addEventListener('click',()=>$('guide').classList.remove('open'));
$('cancelRefreshBtn').addEventListener('click',closeRefresh);$('applyRefreshBtn').addEventListener('click',applyRefresh);$('viewDiffBtn').addEventListener('click',()=>{closeRefresh();state.bottomTab='diff';state.bottomCollapsed=false;updateBottomTabs();renderBottom();});
$('refreshModal').addEventListener('click',e=>{if(e.target===$('refreshModal'))closeRefresh();});
document.querySelectorAll('.bottom-tab').forEach(b=>b.addEventListener('click',()=>{state.bottomTab=b.dataset.bottom;state.bottomCollapsed=false;updateBottomTabs();renderBottom();}));
$('collapseBottomBtn').addEventListener('click',()=>{state.bottomCollapsed=!state.bottomCollapsed;updateBottomTabs();renderBottom();});
const vp=$('canvasViewport');let pan=null;
vp.addEventListener('pointerdown',e=>{if(e.target.closest('.node'))return;pan={x:e.clientX,y:e.clientY,px:state.panX,py:state.panY};vp.classList.add('panning');vp.setPointerCapture(e.pointerId);});
vp.addEventListener('pointermove',e=>{if(!pan)return;state.panX=pan.px+e.clientX-pan.x;state.panY=pan.py+e.clientY-pan.y;applySceneTransform();});
vp.addEventListener('pointerup',e=>{if(!pan)return;pan=null;vp.classList.remove('panning');vp.releasePointerCapture(e.pointerId);});
vp.addEventListener('wheel',e=>{e.preventDefault();setZoom(state.zoom*(e.deltaY>0?.9:1.1),{x:e.clientX,y:e.clientY});},{passive:false});
setupResizer('leftResizer','leftPanel',1,240,430);setupResizer('rightResizer','rightPanel',-1,290,480);
window.addEventListener('keydown',e=>{if(e.key==='Escape'){closeRefresh();$('guide').classList.remove('open');}if(e.code==='Space'&&!['INPUT','SELECT'].includes(document.activeElement.tagName)){e.preventDefault();previewAction('toggle');}});
}
function setupResizer(handleId,panelId,direction,min,max){const h=$(handleId),p=$(panelId);let d=null;h.addEventListener('pointerdown',e=>{d={x:e.clientX,w:p.getBoundingClientRect().width};h.setPointerCapture(e.pointerId);});h.addEventListener('pointermove',e=>{if(!d)return;p.style.width=Math.max(min,Math.min(max,d.w+(e.clientX-d.x)*direction))+'px';});h.addEventListener('pointerup',e=>{d=null;h.releasePointerCapture(e.pointerId);});}
function loop(now) {
const c=currentClipForSelection();
if(c&&state.preview.playing){const dt=now-state.preview.last;state.preview.time+=dt*state.preview.speed*(state.selected.type==='node'?(byId(nodes,state.selected.id).speed??c.speed):c.speed);const total=clipTotal(c);if(state.preview.time>=total){const end=state.selected.type==='node'?(byId(nodes,state.selected.id).end??c.end):c.end;if(end==='Loop')state.preview.time%=total;else{state.preview.time=total-1;state.preview.playing=false;}}updatePreview();}
state.preview.last=now;tickFlow(now);if(now-state.nodePreviewLast>50){updateNodePreviews(now);state.nodePreviewLast=now;}requestAnimationFrame(loop);
}
updateTabs();renderResources();renderInspector();renderGraph();updateBottomTabs();renderBottom();bindStaticEvents();
requestAnimationFrame(()=>{showAll();requestAnimationFrame(loop);});
setTimeout(()=>$('guide').classList.add('open'),450);
</script>
</body>
</html>