From ea342fa0e0caaca20adb635b230883ab7490b59a Mon Sep 17 00:00:00 2001 From: zhizhi200271 Date: Sun, 8 Mar 2026 14:18:50 +0800 Subject: [PATCH] =?UTF-8?q?DEV-004:M17=E7=8E=AF=E8=8A=825-=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E6=97=B6=E9=97=B4=E8=BD=B4=C2=B7=E5=B8=A7=E5=BA=8F?= =?UTF-8?q?=E5=88=97+=E6=92=AD=E6=94=BE=E6=8E=A7=E5=88=B6+=E5=B8=A7?= =?UTF-8?q?=E7=8E=87=E8=B0=83=E8=8A=82+=E6=95=B0=E6=8D=AE=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dynamic-comic-studio/css/style.css | 348 +++++++++++++++++ dynamic-comic-studio/index.html | 88 +++++ dynamic-comic-studio/js/app.js | 581 +++++++++++++++++++++++++++++ 3 files changed, 1017 insertions(+) create mode 100644 dynamic-comic-studio/css/style.css create mode 100644 dynamic-comic-studio/index.html create mode 100644 dynamic-comic-studio/js/app.js diff --git a/dynamic-comic-studio/css/style.css b/dynamic-comic-studio/css/style.css new file mode 100644 index 00000000..3243d659 --- /dev/null +++ b/dynamic-comic-studio/css/style.css @@ -0,0 +1,348 @@ +/* ==================== 基础重置 ==================== */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif; + background: #1a1a2e; + color: #eee; + overflow: hidden; +} + +/* ==================== 布局框架 ==================== */ +#app { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* ==================== 顶部工具栏 ==================== */ +#toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 20px; + background: #16213e; + border-bottom: 2px solid #0f3460; +} + +#toolbar h1 { + font-size: 18px; + color: #e94560; +} + +.toolbar-actions button { + margin-left: 10px; + padding: 6px 12px; + background: #0f3460; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background 0.3s; +} + +.toolbar-actions button:hover { + background: #e94560; +} + +/* ==================== 主工作区 ==================== */ +#workspace { + display: flex; + flex: 1; + overflow: hidden; +} + +/* ==================== 左侧素材面板 ==================== */ +#assets-panel { + width: 200px; + background: #16213e; + border-right: 2px solid #0f3460; + padding: 15px; + overflow-y: auto; +} + +#assets-panel h3 { + color: #e94560; + margin-bottom: 15px; + font-size: 16px; +} + +.asset-category { + margin-bottom: 20px; +} + +.asset-category h4 { + color: #533483; + font-size: 14px; + margin-bottom: 10px; + padding-left: 5px; + border-left: 3px solid #e94560; +} + +.asset-items { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.asset-item { + padding: 8px 12px; + background: #0f3460; + border-radius: 6px; + cursor: move; + font-size: 13px; + transition: all 0.3s; + user-select: none; +} + +.asset-item:hover { + background: #e94560; + transform: translateY(-2px); +} + +/* ==================== 画布区域 ==================== */ +#canvas-container { + flex: 1; + display: flex; + flex-direction: column; + background: #0f0f1a; +} + +#scene-tabs { + display: flex; + align-items: center; + padding: 10px; + background: #16213e; + border-bottom: 2px solid #0f3460; +} + +#tabs-list { + display: flex; + gap: 8px; + flex: 1; +} + +.scene-tab { + padding: 8px 16px; + background: #0f3460; + border-radius: 4px 4px 0 0; + cursor: pointer; + font-size: 14px; + position: relative; +} + +.scene-tab.active { + background: #e94560; +} + +.scene-tab .close-btn { + margin-left: 8px; + opacity: 0.6; +} + +.scene-tab .close-btn:hover { + opacity: 1; +} + +#add-scene-btn { + padding: 8px 16px; + background: #533483; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + margin-left: 10px; +} + +#canvas-wrapper { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + padding: 20px; + overflow: auto; +} + +#main-canvas { + background: #fff; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0,0,0,0.5); + cursor: crosshair; +} + +/* ==================== 底部时间轴面板 ==================== */ +#timeline-panel { + background: #16213e; + border-top: 2px solid #0f3460; + padding: 15px 20px; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* 播放控制栏 */ +#playback-controls { + display: flex; + align-items: center; + gap: 15px; +} + +#playback-controls button { + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: #0f3460; + color: #fff; + font-size: 18px; + cursor: pointer; + transition: all 0.3s; + display: flex; + align-items: center; + justify-content: center; +} + +#playback-controls button:hover { + background: #e94560; + transform: scale(1.1); +} + +#frame-counter { + font-size: 14px; + color: #aaa; + min-width: 80px; +} + +#playback-controls label { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: #aaa; +} + +#fps-slider { + width: 120px; + cursor: pointer; +} + +#fps-display { + color: #e94560; + font-weight: bold; + min-width: 50px; +} + +/* 帧缩略图时间轴 */ +#timeline-frames { + display: flex; + gap: 8px; + padding: 10px; + background: #0f0f1a; + border-radius: 6px; + overflow-x: auto; + min-height: 70px; + align-items: center; +} + +.frame-thumb { + width: 80px; + height: 60px; + background: #0f3460; + border: 2px solid #533483; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: #aaa; + position: relative; + transition: all 0.3s; + flex-shrink: 0; +} + +.frame-thumb:hover { + border-color: #e94560; + transform: translateY(-2px); +} + +.frame-thumb.active { + border-color: #e94560; + background: #533483; + box-shadow: 0 0 10px rgba(233, 69, 96, 0.5); +} + +.frame-thumb::before { + content: attr(data-frame); + position: absolute; + top: 2px; + left: 4px; + font-size: 10px; + color: #fff; + background: rgba(0,0,0,0.5); + padding: 1px 4px; + border-radius: 2px; +} + +/* 时间轴操作按钮 */ +#timeline-actions { + display: flex; + gap: 10px; +} + +#timeline-actions button { + padding: 8px 16px; + background: #0f3460; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.3s; +} + +#timeline-actions button:hover { + background: #e94560; +} + +/* ==================== 画布上的素材样式 ==================== */ +.canvas-asset { + position: absolute; + cursor: move; + user-select: none; + font-size: 48px; + filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.3)); + transition: transform 0.1s; +} + +.canvas-asset:hover { + transform: scale(1.1); +} + +.canvas-asset.selected { + outline: 2px dashed #e94560; + outline-offset: 4px; +} + +/* ==================== 滚动条样式 ==================== */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #0f0f1a; +} + +::-webkit-scrollbar-thumb { + background: #533483; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #e94560; +} diff --git a/dynamic-comic-studio/index.html b/dynamic-comic-studio/index.html new file mode 100644 index 00000000..b5be2dcb --- /dev/null +++ b/dynamic-comic-studio/index.html @@ -0,0 +1,88 @@ + + + + + + HoloLake 动态漫制作系统 - 环节5 + + + +
+ +
+

🎬 HoloLake 动态漫制作系统

+
+ + + + +
+
+ + +
+ + + + +
+
+
+ +
+
+ +
+
+
+ + +
+
+ + + 帧 1/1 + +
+
+
+ + +
+
+
+ + + + diff --git a/dynamic-comic-studio/js/app.js b/dynamic-comic-studio/js/app.js new file mode 100644 index 00000000..184d1b06 --- /dev/null +++ b/dynamic-comic-studio/js/app.js @@ -0,0 +1,581 @@ +// ==================== HoloLake 动态漫制作系统 v5.0 ==================== +// 环节5:动画时间轴(帧序列 + 播放控制 + 帧率调节) + +const STORAGE_KEY = 'hololake-comic-studio-data'; +const canvas = document.getElementById('main-canvas'); +const ctx = canvas.getContext('2d'); + +// ==================== 全局状态 ==================== +let appState = { + currentSceneId: 1, + nextSceneId: 2, + nextAssetId: 1, + nextFrameId: 1, + scenes: [], + playback: { + isPlaying: false, + fps: 4, + intervalId: null, + currentFrameIndex: 0 + } +}; + +// ==================== 初始化 ==================== +function init() { + loadFromStorage(); + if (appState.scenes.length === 0) { + createDefaultScene(); + } + setupEventListeners(); + renderSceneTabs(); + renderCanvas(); + renderTimeline(); + updatePlaybackControls(); +} + +// 创建默认场景 +function createDefaultScene() { + const defaultScene = { + id: 1, + name: '场景 1', + currentFrameIndex: 0, + frames: [{ + id: 1, + assets: [] + }], + nextFrameId: 2 + }; + appState.scenes.push(defaultScene); + appState.currentSceneId = 1; + saveToStorage(); +} + +// ==================== 存储管理 ==================== +function saveToStorage() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(appState)); +} + +function loadFromStorage() { + const data = localStorage.getItem(STORAGE_KEY); + if (data) { + const parsed = JSON.parse(data); + // 向后兼容:旧数据没有 frames 字段 + if (parsed.scenes && parsed.scenes.length > 0 && !parsed.scenes[0].frames) { + parsed.scenes = parsed.scenes.map(scene => ({ + ...scene, + currentFrameIndex: 0, + frames: [{ + id: 1, + assets: scene.assets || [] + }], + nextFrameId: 2 + })); + delete parsed.scenes[0].assets; // 删除旧的 assets 字段 + } + // 确保 playback 对象存在 + if (!parsed.playback) { + parsed.playback = { isPlaying: false, fps: 4, intervalId: null, currentFrameIndex: 0 }; + } + appState = parsed; + } +} + +// ==================== 场景管理 ==================== +function addScene() { + const newScene = { + id: appState.nextSceneId++, + name: `场景 ${appState.scenes.length + 1}`, + currentFrameIndex: 0, + frames: [{ + id: 1, + assets: [] + }], + nextFrameId: 2 + }; + appState.scenes.push(newScene); + appState.currentSceneId = newScene.id; + saveToStorage(); + renderSceneTabs(); + renderCanvas(); + renderTimeline(); +} + +function switchScene(sceneId) { + if (appState.playback.isPlaying) { + stopAnimation(); + } + appState.currentSceneId = sceneId; + saveToStorage(); + renderSceneTabs(); + renderCanvas(); + renderTimeline(); + updateFrameCounter(); +} + +function deleteScene(sceneId) { + if (appState.scenes.length <= 1) { + alert('至少保留一个场景!'); + return; + } + appState.scenes = appState.scenes.filter(s => s.id !== sceneId); + if (appState.currentSceneId === sceneId) { + appState.currentSceneId = appState.scenes[0].id; + } + saveToStorage(); + renderSceneTabs(); + renderCanvas(); + renderTimeline(); +} + +function getCurrentScene() { + return appState.scenes.find(s => s.id === appState.currentSceneId); +} + +function getCurrentFrame() { + const scene = getCurrentScene(); + if (!scene) return null; + return scene.frames[scene.currentFrameIndex]; +}// ==================== 帧管理 ==================== +function addFrame() { + const scene = getCurrentScene(); + if (!scene) return; + + // 深拷贝当前帧的素材状态 + const currentFrame = scene.frames[scene.currentFrameIndex]; + const newFrame = { + id: scene.nextFrameId++, + assets: JSON.parse(JSON.stringify(currentFrame.assets)) + }; + + // 在当前帧后插入新帧 + scene.frames.splice(scene.currentFrameIndex + 1, 0, newFrame); + scene.currentFrameIndex++; + + saveToStorage(); + renderTimeline(); + renderCanvas(); + updateFrameCounter(); +} + +function deleteFrame() { + const scene = getCurrentScene(); + if (!scene) return; + + if (scene.frames.length <= 1) { + alert('至少保留一帧!'); + return; + } + + scene.frames.splice(scene.currentFrameIndex, 1); + if (scene.currentFrameIndex >= scene.frames.length) { + scene.currentFrameIndex = scene.frames.length - 1; + } + + saveToStorage(); + renderTimeline(); + renderCanvas(); + updateFrameCounter(); +} + +function switchFrame(frameIndex) { + const scene = getCurrentScene(); + if (!scene || frameIndex < 0 || frameIndex >= scene.frames.length) return; + + // 保存当前画布状态到当前帧 + captureFrame(); + + scene.currentFrameIndex = frameIndex; + saveToStorage(); + renderTimeline(); + renderCanvas(); + updateFrameCounter(); +} + +function captureFrame() { + // 画布状态实时保存在 assets 中,无需额外操作 + saveToStorage(); +} + +// ==================== 播放引擎 ==================== +function playAnimation() { + const scene = getCurrentScene(); + if (!scene || scene.frames.length <= 1) return; + + appState.playback.isPlaying = true; + updatePlaybackControls(); + + const intervalMs = 1000 / appState.playback.fps; + + appState.playback.intervalId = setInterval(() => { + const scene = getCurrentScene(); + if (!scene) return; + + scene.currentFrameIndex++; + if (scene.currentFrameIndex >= scene.frames.length) { + scene.currentFrameIndex = 0; // 循环播放 + } + + renderCanvas(); + renderTimeline(); + updateFrameCounter(); + saveToStorage(); + }, intervalMs); +} + +function pauseAnimation() { + appState.playback.isPlaying = false; + if (appState.playback.intervalId) { + clearInterval(appState.playback.intervalId); + appState.playback.intervalId = null; + } + updatePlaybackControls(); +} + +function stopAnimation() { + pauseAnimation(); + const scene = getCurrentScene(); + if (scene) { + scene.currentFrameIndex = 0; + renderCanvas(); + renderTimeline(); + updateFrameCounter(); + saveToStorage(); + } +} + +function togglePlayback() { + if (appState.playback.isPlaying) { + pauseAnimation(); + } else { + playAnimation(); + } +} + +function setFPS(fps) { + appState.playback.fps = parseInt(fps); + document.getElementById('fps-display').textContent = fps + ' FPS'; + saveToStorage(); + + // 如果正在播放,重启定时器以应用新帧率 + if (appState.playback.isPlaying) { + pauseAnimation(); + playAnimation(); + } +} + +function updatePlaybackControls() { + const btnPlay = document.getElementById('btn-play'); + if (appState.playback.isPlaying) { + btnPlay.textContent = '⏸️'; + btnPlay.title = '暂停'; + } else { + btnPlay.textContent = '▶️'; + btnPlay.title = '播放'; + } +} + +function updateFrameCounter() { + const scene = getCurrentScene(); + if (!scene) return; + const counter = document.getElementById('frame-counter'); + counter.textContent = `帧 ${scene.currentFrameIndex + 1}/${scene.frames.length}`; +}// ==================== 渲染函数 ==================== +function renderSceneTabs() { + const tabsList = document.getElementById('tabs-list'); + tabsList.innerHTML = ''; + + appState.scenes.forEach(scene => { + const tab = document.createElement('div'); + tab.className = 'scene-tab' + (scene.id === appState.currentSceneId ? ' active' : ''); + tab.innerHTML = ` + ${scene.name} + × + `; + tab.onclick = () => switchScene(scene.id); + tabsList.appendChild(tab); + }); +} + +function renderTimeline() { + const timelineFrames = document.getElementById('timeline-frames'); + timelineFrames.innerHTML = ''; + + const scene = getCurrentScene(); + if (!scene) return; + + scene.frames.forEach((frame, index) => { + const thumb = document.createElement('div'); + thumb.className = 'frame-thumb' + (index === scene.currentFrameIndex ? ' active' : ''); + thumb.setAttribute('data-frame', index + 1); + thumb.textContent = `帧 ${index + 1}`; + thumb.onclick = () => switchFrame(index); + timelineFrames.appendChild(thumb); + }); +} + +function renderCanvas() { + // 清空画布 + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const frame = getCurrentFrame(); + if (!frame) return; + + // 绘制所有素材 + frame.assets.forEach(asset => { + ctx.font = '48px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // 绘制阴影 + ctx.shadowColor = 'rgba(0,0,0,0.3)'; + ctx.shadowBlur = 4; + ctx.shadowOffsetX = 2; + ctx.shadowOffsetY = 2; + + ctx.fillText(asset.emoji, asset.x, asset.y); + + // 重置阴影 + ctx.shadowColor = 'transparent'; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + + // 如果是选中状态,绘制边框 + if (asset.selected) { + ctx.strokeStyle = '#e94560'; + ctx.lineWidth = 2; + ctx.setLineDash([5, 5]); + ctx.strokeRect(asset.x - 30, asset.y - 30, 60, 60); + ctx.setLineDash([]); + } + }); +} + +// ==================== 素材拖放 ==================== +function setupEventListeners() { + // 素材库拖放 + const assetItems = document.querySelectorAll('.asset-item'); + assetItems.forEach(item => { + item.addEventListener('dragstart', (e) => { + e.dataTransfer.setData('type', item.dataset.type); + e.dataTransfer.setData('emoji', item.dataset.emoji); + }); + }); + + // 画布接收拖放 + canvas.addEventListener('dragover', (e) => { + e.preventDefault(); + }); + + canvas.addEventListener('drop', (e) => { + e.preventDefault(); + const type = e.dataTransfer.getData('type'); + const emoji = e.dataTransfer.getData('emoji'); + + if (emoji) { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + addAssetToCanvas(emoji, x, y); + } + }); + + // 画布点击选择/移动 + let isDragging = false; + let dragStartX, dragStartY; + let selectedAsset = null; + + canvas.addEventListener('mousedown', (e) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const frame = getCurrentFrame(); + if (!frame) return; + + // 查找点击的素材(从后往前,优先选上层的) + selectedAsset = null; + for (let i = frame.assets.length - 1; i >= 0; i--) { + const asset = frame.assets[i]; + const dist = Math.sqrt((x - asset.x) ** 2 + (y - asset.y) ** 2); + if (dist < 30) { + selectedAsset = asset; + // 更新选中状态 + frame.assets.forEach(a => a.selected = false); + asset.selected = true; + isDragging = true; + dragStartX = x - asset.x; + dragStartY = y - asset.y; + renderCanvas(); + break; + } + } + + // 如果没点到素材,取消所有选中 + if (!selectedAsset) { + frame.assets.forEach(a => a.selected = false); + renderCanvas(); + } + }); + + canvas.addEventListener('mousemove', (e) => { + if (!isDragging || !selectedAsset) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + selectedAsset.x = x - dragStartX; + selectedAsset.y = y - dragStartY; + + // 边界限制 + selectedAsset.x = Math.max(30, Math.min(canvas.width - 30, selectedAsset.x)); + selectedAsset.y = Math.max(30, Math.min(canvas.height - 30, selectedAsset.y)); + + renderCanvas(); + }); + + canvas.addEventListener('mouseup', () => { + if (isDragging) { + isDragging = false; + saveToStorage(); + } + }); + + // 键盘删除 + document.addEventListener('keydown', (e) => { + if (e.key === 'Delete' || e.key === 'Backspace') { + const frame = getCurrentFrame(); + if (!frame) return; + + const selectedIndex = frame.assets.findIndex(a => a.selected); + if (selectedIndex !== -1) { + frame.assets.splice(selectedIndex, 1); + renderCanvas(); + saveToStorage(); + } + } + }); + + // 导入文件监听 + document.getElementById('import-file').addEventListener('change', handleImport); +} + +function addAssetToCanvas(emoji, x, y) { + const frame = getCurrentFrame(); + if (!frame) return; + + // 取消其他选中 + frame.assets.forEach(a => a.selected = false); + + const newAsset = { + id: appState.nextAssetId++, + type: 'emoji', + emoji: emoji, + x: x, + y: y, + selected: true + }; + + frame.assets.push(newAsset); + renderCanvas(); + saveToStorage(); +}// ==================== 导出导入 ==================== +function exportScene() { + const dataStr = JSON.stringify(appState, null, 2); + const blob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `hololake-scene-${Date.now()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +function importScene() { + document.getElementById('import-file').click(); +} + +function handleImport(e) { + const file = e.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const imported = JSON.parse(event.target.result); + + // 验证数据结构 + if (!imported.scenes || !Array.isArray(imported.scenes)) { + throw new Error('无效的数据格式'); + } + + // 向后兼容处理 + imported.scenes = imported.scenes.map(scene => { + if (!scene.frames) { + return { + ...scene, + currentFrameIndex: 0, + frames: [{ + id: 1, + assets: scene.assets || [] + }], + nextFrameId: 2 + }; + } + return scene; + }); + + if (!imported.playback) { + imported.playback = { isPlaying: false, fps: 4, intervalId: null, currentFrameIndex: 0 }; + } + + appState = imported; + saveToStorage(); + renderSceneTabs(); + renderCanvas(); + renderTimeline(); + updatePlaybackControls(); + updateFrameCounter(); + + alert('导入成功!'); + } catch (err) { + alert('导入失败:' + err.message); + } + }; + reader.readAsText(file); + + // 清空 input,允许重复导入同一文件 + e.target.value = ''; +} + +// ==================== 截图导出 ==================== +function exportScreenshot() { + // 临时取消选中状态 + const frame = getCurrentFrame(); + if (!frame) return; + + const originalSelected = frame.assets.map(a => a.selected); + frame.assets.forEach(a => a.selected = false); + renderCanvas(); + + // 导出 PNG + const link = document.createElement('a'); + link.download = `hololake-frame-${Date.now()}.png`; + link.href = canvas.toDataURL(); + link.click(); + + // 恢复选中状态 + frame.assets.forEach((a, i) => { + a.selected = originalSelected[i]; + }); + renderCanvas(); +} + +// ==================== 启动应用 ==================== +document.addEventListener('DOMContentLoaded', init);