chore: 动画系统原型

This commit is contained in:
2026-07-15 15:14:32 +08:00
parent 5f8ffb7f78
commit 079d57e295
+848
View File
@@ -0,0 +1,848 @@
<!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>