Spaces:
Running
Running
Download index.html from gp01002/MemoryWeaverV2: direct link, hf CLI and curl.
- Browser
- Download file 43.4 kB
-
https://huggingface.co/spaces/gp01002/MemoryWeaverV2/resolve/main/index.html
- Command line
-
hf download hf://spaces/gp01002/MemoryWeaverV2/index.html
-
curl -L -o index.html https://huggingface.co/spaces/gp01002/MemoryWeaverV2/resolve/main/index.html
43.4 kB
| <html lang="zh-TW"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>記憶的編織 - 水墨投影系統</title> | |
| <style> | |
| /* CSS Reset and Base Styles */ | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Inter', sans-serif; /* 使用 Inter 字體 */ | |
| /* 純黑白介面: 從深灰到淺灰的漸變背景 */ | |
| background: linear-gradient(135deg, #333333 0%, #1a1a1a 100%); | |
| color: #e0e0e0; /* 淺灰色文字 */ | |
| min-height: 100vh; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| overflow: hidden; /* 防止滾動條 */ | |
| } | |
| /* Main Container for the Application */ | |
| .container { | |
| display: flex; | |
| /* 調整容器尺寸,讓整個介面框框更小 */ | |
| width: 85%; | |
| max-width: 1200px; /* 限制最大寬度 */ | |
| height: 85vh; /* 限制最大高度 */ | |
| border-radius: 15px; | |
| overflow: hidden; | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6); /* 更深的陰影 */ | |
| background: rgba(0, 0, 0, 0.4); /* 半透明黑色 */ | |
| } | |
| /* Left and Right Panels Styling */ | |
| .left-panel, .right-panel { | |
| flex: 1; /* 初始兩側面板等寬 */ | |
| padding: 20px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 15px; | |
| background: rgba(0, 0, 0, 0.3); /* 更深的半透明背景 */ | |
| backdrop-filter: blur(5px); | |
| margin: 8px; /* 容器內部內容的邊距 */ | |
| border-radius: 12px; | |
| } | |
| /* Title Styling */ | |
| .title { | |
| text-align: center; | |
| font-size: 1.8rem; | |
| font-weight: 800; | |
| color: #ffffff; /* 純白色標題 */ | |
| margin-bottom: 15px; | |
| } | |
| /* Status Panel Styling */ | |
| .status-panel, .info-panel { | |
| background: rgba(255, 255, 255, 0.05); /* 淺色透明灰,更暗 */ | |
| border-radius: 8px; | |
| padding: 12px; | |
| margin-bottom: 10px; | |
| color: #cccccc; /* 淺灰色文字 */ | |
| } | |
| .status-item { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-bottom: 8px; | |
| font-size: 0.9rem; | |
| } | |
| .status-item:last-child { | |
| margin-bottom: 0; /* 最後一個項目沒有邊距 */ | |
| } | |
| /* Controls Button Styling */ | |
| .controls { | |
| display: flex; | |
| gap: 8px; | |
| margin-bottom: 15px; | |
| } | |
| .btn { | |
| flex: 1; | |
| padding: 10px 15px; /* 稍大的內邊距以改善觸控目標 */ | |
| border: none; | |
| border-radius: 8px; /* 更圓的角 */ | |
| color: #ffffff; /* 按鈕文字為白色 */ | |
| font-weight: bold; | |
| font-size: 0.9rem; /* 稍大的字體 */ | |
| cursor: pointer; | |
| transition: transform 0.2s ease, box-shadow 0.2s ease; /* 添加陰影過渡 */ | |
| box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); /* 更深的陰影 */ | |
| } | |
| .btn:hover { | |
| transform: translateY(-2px); /* 更明顯的抬升效果 */ | |
| box-shadow: 0 6px 15px rgba(0, 0, 0, 0.4); /* 懸停時更深的陰影 */ | |
| } | |
| /* 純黑白按鈕顏色 */ | |
| .btn-primary { background: #444444; } /* 深灰 */ | |
| .btn-secondary { background: #666666; } /* 中灰 */ | |
| .btn-tertiary { background: #888888; } /* 淺灰 */ | |
| .btn-special { background: #aaaaaa; } /* 更淺的灰 */ | |
| /* Canvas Container */ | |
| .canvas-container { | |
| flex: 1; /* 讓容器佔滿可用空間 */ | |
| background: #000; /* 純黑色背景 */ | |
| border-radius: 8px; | |
| border: 2px solid #555555; /* 深灰色邊框 */ | |
| overflow: hidden; | |
| position: relative; | |
| display: flex; /* 使用 flex 來居中 canvas 內容 */ | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 300px; /* 確保 canvas 區域有最小高度 */ | |
| } | |
| canvas { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| cursor: crosshair; | |
| background-color: #f0f0f0; /* 淺灰色畫布背景,確保黑色筆觸可見 */ | |
| } | |
| .overlay { | |
| position: absolute; | |
| top: 10px; /* 調整位置 */ | |
| left: 10px; | |
| background: rgba(0, 0, 0, 0.8); /* 更深的半透明黑色 */ | |
| padding: 6px 10px; /* 稍大的內邊距 */ | |
| border-radius: 5px; /* 更圓的角 */ | |
| font-size: 0.8rem; | |
| color: #fff; | |
| } | |
| /* AI Area Styling */ | |
| .ai-area { | |
| flex: 4; /* 在右側面板佔據更多空間 */ | |
| background: rgba(255, 255, 255, 0.05); /* 淺色透明灰,更暗 */ | |
| border-radius: 8px; | |
| padding: 15px; /* 增加內邊距 */ | |
| border: 2px solid #888888; /* 中灰色邊框 */ | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 350px; /* 確保有足夠的高度 */ | |
| position: relative; /* 用於載入覆蓋 */ | |
| } | |
| .ai-area h3 { | |
| color: #ffffff; /* 純白色標題 */ | |
| margin-bottom: 10px; | |
| font-size: 1.2rem; /* 較大的字體 */ | |
| } | |
| /* Hidden by default, will be replaced by canvas */ | |
| .ai-area img { | |
| display: none; | |
| } | |
| #aiStatus { | |
| font-size: 1.1rem; /* 較大的字體 */ | |
| color: #ffffff; /* 純白色文字 */ | |
| text-align: center; | |
| padding: 20px; | |
| position: absolute; /* 在 ai-area 內居中 */ | |
| background: rgba(0, 0, 0, 0.8); /* 更深的半透明黑色 */ | |
| border-radius: 8px; | |
| width: calc(100% - 30px); /* 調整寬度以考慮內邊距 */ | |
| height: calc(100% - 30px); /* 調整高度 */ | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| top: 15px; | |
| left: 15px; | |
| } | |
| #aiCanvas { | |
| display: none; /* 預設隱藏,生成後顯示 */ | |
| border-radius: 8px; | |
| box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4); | |
| width: 100%; | |
| height: 100%; | |
| } | |
| /* Info Panel within Right Panel */ | |
| .info-panel h4 { | |
| color: #ffffff; /* 純白色文字 */ | |
| margin-bottom: 8px; | |
| font-size: 1.05rem; | |
| } | |
| .prompt-display { | |
| background: rgba(0, 0, 0, 0.6); /* 更深的半透明黑色 */ | |
| padding: 10px; | |
| border-radius: 6px; | |
| font-size: 0.85rem; | |
| word-break: break-all; | |
| min-height: 60px; /* 確保較長提示詞的可見性 */ | |
| display: flex; | |
| align-items: center; | |
| color: #eeeeee; /* 淺灰色文字 */ | |
| } | |
| /* Message Box */ | |
| .message { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| padding: 10px 20px; | |
| border-radius: 6px; | |
| color: white; | |
| font-weight: bold; | |
| z-index: 1000; | |
| transform: translateX(120%); | |
| transition: transform 0.3s ease, opacity 0.3s ease; | |
| opacity: 0; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); | |
| } | |
| .message.show { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| /* 純黑白訊息框顏色 */ | |
| .message.success { background: #444444; } /* 深灰 */ | |
| .message.error { background: #888888; } /* 中灰 */ | |
| .message.info { background: #666666; } /* 淺灰 */ | |
| /* Loading Spinner */ | |
| .spinner { | |
| border: 4px solid rgba(255, 255, 255, 0.3); /* 淺色半透明邊框 */ | |
| border-top: 4px solid #ffffff; /* 純白色旋轉部分 */ | |
| border-radius: 50%; | |
| width: 40px; | |
| height: 40px; | |
| animation: spin 1s linear infinite; | |
| margin-bottom: 10px; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| /* Responsive adjustments */ | |
| @media (max-width: 900px) { | |
| .container { | |
| flex-direction: column; | |
| height: auto; | |
| min-height: 95vh; | |
| } | |
| .left-panel, .right-panel { | |
| width: calc(100% - 16px); /* 寬度佔滿減去邊距 */ | |
| margin-bottom: 16px; | |
| } | |
| .controls { | |
| flex-wrap: wrap; /* 讓按鈕換行 */ | |
| } | |
| .btn { | |
| flex: none; | |
| width: 48%; /* 每行兩個按鈕 */ | |
| } | |
| .ai-area { | |
| min-height: 250px; /* 調整小螢幕的最小高度 */ | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="left-panel"> | |
| <h1 class="title">記憶的編織</h1> | |
| <div class="status-panel"> | |
| <div class="status-item"> | |
| <span>系統狀態:</span> | |
| <span id="systemStatus">待機</span> | |
| </div> | |
| <div class="status-item"> | |
| <span>FPS:</span> | |
| <span id="fpsDisplay">0</span> | |
| </div> | |
| <div class="status-item"> | |
| <span>漣漪數:</span> | |
| <span id="rippleCount">0</span> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <button class="btn btn-primary" onclick="startSystem()">啟動</button> | |
| <button class="btn btn-secondary" onclick="resetSystem()">重置</button> | |
| <button class="btn btn-tertiary" onclick="togglePause()">暫停</button> | |
| <button class="btn btn-special" onclick="generateCanvasArt()">生成</button> | |
| </div> | |
| <div class="canvas-container"> | |
| <canvas id="mainCanvas"></canvas> | |
| <div class="overlay">水墨投影 - 移動滑鼠體驗</div> | |
| </div> | |
| </div> | |
| <div class="right-panel"> | |
| <h1 class="title">程式化生成</h1> | |
| <div class="info-panel"> | |
| <h4>狀態信息</h4> | |
| <div class="status-item"> | |
| <span>滑鼠速度:</span> | |
| <span id="mouseSpeed">0</span> | |
| </div> | |
| <div class="status-item"> | |
| <span>生成狀態:</span> | |
| <span id="aiGenStatus">待機</span> | |
| </div> | |
| </div> | |
| <div class="info-panel"> | |
| <h4>生成描述</h4> | |
| <div class="prompt-display" id="promptText"> | |
| 點擊「生成」按鈕以產生程式化圖案... | |
| </div> | |
| </div> | |
| <div class="ai-area"> | |
| <h3>生成結果</h3> | |
| <div id="aiStatus"> | |
| <div class="spinner" style="display:none;"></div> | |
| 點擊生成按鈕開始創作 | |
| </div> | |
| <canvas id="aiCanvas" style="display: none;"></canvas> | |
| <div class="action-buttons" style="display: flex; gap: 10px; margin-top: 15px;"> | |
| <button class="btn btn-secondary" onclick="copyAICanvasImage()">複製圖片</button> | |
| <button class="btn btn-primary" onclick="downloadAICanvasImage()">下載圖片</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="message" id="messageBox"></div> | |
| <script> | |
| console.log('🎨 啟動水墨系統...'); | |
| // 全域變數 | |
| let canvas, ctx; // 主水墨畫布 | |
| let aiCanvas, aiCtx; // AI 生成畫布 | |
| let isRunning = false; | |
| let isPaused = false; | |
| let mouseX = 0, mouseY = 0, mouseSpeed = 0; | |
| let time = 0; | |
| let nodes = []; | |
| let ripples = []; | |
| let frameCount = 0; | |
| let lastFpsTime = Date.now(); | |
| // 追蹤當前 AI 生成圖案模式 | |
| let currentGenerationMode = 0; // 0 for connected dots (weaving), 1 for flow field | |
| /** | |
| * 初始化主畫布和 AI 畫布,並設置事件監聽器。 | |
| * 此函數在 DOM 完全載入後調用。 | |
| */ | |
| function init() { | |
| console.log('初始化系統...'); | |
| canvas = document.getElementById('mainCanvas'); | |
| aiCanvas = document.getElementById('aiCanvas'); | |
| if (!canvas || !aiCanvas) { | |
| console.error('找不到畫布元素'); | |
| return; | |
| } | |
| ctx = canvas.getContext('2d'); | |
| aiCtx = aiCanvas.getContext('2d'); | |
| setupCanvas(); // 設置主畫布尺寸 | |
| setupAICanvas(); // 設置 AI 畫布尺寸 | |
| setupEvents(); // 設置事件監聽 | |
| createNodes(); // 初始化水墨節點 | |
| showMessage('系統初始化完成!', 'success'); | |
| console.log('✅ 初始化完成'); | |
| } | |
| /** | |
| * 設置主畫布尺寸以匹配其父容器,並初始化滑鼠座標。 | |
| */ | |
| function setupCanvas() { | |
| const container = canvas.parentElement; | |
| const rect = container.getBoundingClientRect(); | |
| // 設置畫布尺寸以匹配容器,確保最小尺寸 | |
| canvas.width = rect.width; // 讓畫布完全填滿容器 | |
| canvas.height = rect.height; // 讓畫布完全填滿容器 | |
| // 初始將滑鼠置於中心 | |
| mouseX = canvas.width / 2; | |
| mouseY = canvas.height / 2; | |
| console.log(`主畫布: ${canvas.width}x${canvas.height}`); | |
| // 在畫布上繪製初始訊息 | |
| ctx.fillStyle = '#f0f0f0'; /* 淺色背景以呈現水墨效果,與 CSS 中的畫布背景同步 */ | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = '#333333'; /* 深灰色文字 */ | |
| ctx.font = '16px Inter'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('系統準備就緒', canvas.width/2, canvas.height/2); | |
| } | |
| /** | |
| * 設置 AI 畫布尺寸以匹配其父容器。 | |
| */ | |
| function setupAICanvas() { | |
| const aiArea = document.querySelector('.ai-area'); | |
| aiCanvas.width = aiArea.clientWidth; | |
| aiCanvas.height = aiArea.clientHeight; | |
| console.log(`AI 畫布: ${aiCanvas.width}x${aiCanvas.height}`); | |
| // 如果 AI 畫布在 resize 後需要立即重繪其內容,可以在這裡調用 drawAIAbstract() | |
| } | |
| /** | |
| * 設置滑鼠移動和點擊事件監聽器。 | |
| */ | |
| function setupEvents() { | |
| // 滑鼠移動處理器 | |
| canvas.addEventListener('mousemove', (e) => { | |
| const rect = canvas.getBoundingClientRect(); | |
| // 計算比例因子以獲取高 DPI 螢幕上的精確滑鼠座標 | |
| const scaleX = canvas.width / rect.width; | |
| const scaleY = canvas.height / rect.height; | |
| const newX = (e.clientX - rect.left) * scaleX; | |
| const newY = (e.clientY - rect.top) * scaleY; | |
| // 計算滑鼠速度以實現動態漣漪生成 | |
| mouseSpeed = Math.sqrt((newX - mouseX)**2 + (newY - mouseY)**2); | |
| mouseX = newX; | |
| mouseY = newY; | |
| // 更新滑鼠速度的 UI 顯示 | |
| document.getElementById('mouseSpeed').textContent = Math.round(mouseSpeed); | |
| // 根據滑鼠速度創建漣漪 | |
| // 僅當速度超過閾值且漣漪數量未過多時才創建新漣漪 | |
| if (mouseSpeed > 3 && ripples.length < 20) { | |
| createRipple(mouseX, mouseY, mouseSpeed); | |
| } | |
| }); | |
| // 點擊處理器以創建強烈漣漪並觸發圖案生成 | |
| canvas.addEventListener('click', () => { | |
| createRipple(mouseX, mouseY, 20); // 點擊時產生更強的漣漪 | |
| generateCanvasArt(); // 觸發畫布圖案生成 | |
| }); | |
| // 處理視窗大小調整以保持畫布響應性 | |
| window.addEventListener('resize', () => { | |
| setupCanvas(); // 調整主畫布大小 | |
| setupAICanvas(); // 調整 AI 畫布大小 | |
| }); | |
| console.log('事件設置完成'); | |
| } | |
| /** | |
| * 填充 `nodes` 陣列,它構成水墨效果的基礎。 | |
| * 節點以一種更稀疏且均勻的方式分佈。 | |
| */ | |
| function createNodes() { | |
| nodes = []; | |
| const nodeCount = 80; // 增加節點數量以獲得更明顯的編織密度 | |
| // 將節點初始分佈在一個較大的中心區域,模擬參考圖案的分散感 | |
| const centerX = canvas.width / 2; | |
| const centerY = canvas.height / 2; | |
| const initialSpreadRadius = Math.min(canvas.width, canvas.height) * 0.4; // 調整初始散佈半徑 | |
| for (let i = 0; i < nodeCount; i++) { | |
| const angle = (i / nodeCount) * Math.PI * 2; // 均勻分佈初始角度 | |
| const radius = initialSpreadRadius + (Math.random() - 0.5) * 100; // 在基礎半徑上隨機偏移,增加散佈 | |
| // 使用角度和半徑計算初始位置,增加點的規律性 | |
| let x = centerX + Math.cos(angle) * radius; | |
| let y = centerY + Math.sin(angle) * radius; | |
| // 確保節點仍然在畫布範圍內 | |
| x = Math.max(5, Math.min(canvas.width - 5, x)); | |
| y = Math.max(5, Math.min(canvas.height - 5, y)); | |
| nodes.push({ | |
| x: x, | |
| y: y, | |
| originalX: x, // 存儲原始位置以實現彈回效果 | |
| originalY: y, | |
| vx: 0, // X 速度 | |
| vy: 0, // Y 速度 | |
| phase: Math.random() * Math.PI * 2 // 隨機相位以實現獨立運動 | |
| }); | |
| } | |
| console.log(`創建了 ${nodes.length} 個節點`); | |
| } | |
| /** | |
| * 向 `ripples` 陣列添加新的漣漪效果。 | |
| * @param {number} x - 漣漪中心的 X 座標。 | |
| * @param {number} y - 漣漪中心的 Y 座標。 | |
| * @param {number} intensity - 漣漪的初始強度。 | |
| */ | |
| function createRipple(x, y, intensity) { | |
| ripples.push({ | |
| x: x, | |
| y: y, | |
| radius: 0, // 漣漪當前半徑 | |
| maxRadius: 60 + intensity * 2, // 顯著增加最大半徑,使漣漪更顯著 | |
| life: 1, // 生命週期因子,從 1(完整)到 0(消失) | |
| intensity: Math.min(intensity * 0.1, 1.0) // 視覺效果的限定強度 | |
| }); | |
| document.getElementById('rippleCount').textContent = ripples.length; | |
| } | |
| /** | |
| * 啟動水墨模擬系統。 | |
| */ | |
| function startSystem() { | |
| console.log('啟動系統'); | |
| isRunning = true; | |
| isPaused = false; | |
| document.getElementById('systemStatus').textContent = '運行中'; | |
| showMessage('水墨系統已啟動!', 'success'); | |
| animate(); // 啟動動畫循環 | |
| } | |
| /** | |
| * 將水墨模擬重置為其初始狀態。 | |
| */ | |
| function resetSystem() { | |
| console.log('重置系統'); | |
| time = 0; | |
| ripples = []; | |
| createNodes(); // 重新創建節點 | |
| document.getElementById('rippleCount').textContent = '0'; | |
| showMessage('系統已重置', 'info'); | |
| // 重置 AI 畫布區域 | |
| document.getElementById('aiCanvas').style.display = 'none'; | |
| document.getElementById('aiStatus').style.display = 'flex'; // 顯示預設狀態 | |
| document.getElementById('aiStatus').innerHTML = '點擊生成按鈕開始創作'; | |
| document.getElementById('aiGenStatus').textContent = '待機'; | |
| document.getElementById('promptText').textContent = '點擊「生成」按鈕以產生程式化圖案...'; | |
| } | |
| /** | |
| * 切換模擬的暫停狀態。 | |
| */ | |
| function togglePause() { | |
| isPaused = !isPaused; | |
| const status = isPaused ? '暫停' : '繼續'; | |
| document.getElementById('systemStatus').textContent = status; | |
| showMessage(`系統已${status}`, 'info'); | |
| } | |
| /** | |
| * 更新節點和漣漪的物理屬性。 | |
| * 包括基本波浪運動、滑鼠互動和漣漪力。 | |
| */ | |
| function updatePhysics() { | |
| // 更新節點位置和速度 | |
| nodes.forEach(node => { | |
| // 節點的基本流體運動 | |
| const waveX = Math.sin(time * 1.5 + node.phase) * 2; | |
| const waveY = Math.cos(time * 1.2 + node.phase) * 2; | |
| const targetX = node.originalX + waveX; | |
| const targetY = node.originalY + waveY; | |
| // 類似彈簧的力將節點拉回目標位置,增加彈性 | |
| node.vx += (targetX - node.x) * 0.03; | |
| node.vy += (targetY - node.y) * 0.03; | |
| // 滑鼠對節點的影響 - 更強的效果 | |
| const distToMouse = Math.sqrt((node.x - mouseX)**2 + (node.y - mouseY)**2); | |
| if (distToMouse < 200) { // 更大的影響半徑 | |
| const influence = (1 - distToMouse / 200) * 1.5; // 更強的影響力 | |
| const mouseAngle = Math.atan2(node.y - mouseY, node.x - mouseX); | |
| node.vx += Math.cos(mouseAngle) * influence * mouseSpeed * 0.03; // 影響力與滑鼠速度相關 | |
| node.vy += Math.sin(mouseAngle) * influence * mouseSpeed * 0.03; | |
| } | |
| // 漣漪對節點的影響 (水紋影響織布的核心) | |
| ripples.forEach(ripple => { | |
| const distToRipple = Math.sqrt((node.x - ripple.x)**2 + (node.y - ripple.y)**2); | |
| // 檢查節點是否在漣漪擴展邊緣附近 | |
| if (Math.abs(distToRipple - ripple.radius) < 40 && distToRipple > 0) { // 更寬的活躍帶,影響更廣 | |
| const rippleForce = ripple.intensity * ripple.life * 2.0; // 更強的漣漪力 | |
| const rippleAngle = Math.atan2(node.y - ripple.y, node.x - ripple.x); | |
| node.vx += Math.cos(rippleAngle) * rippleForce; | |
| node.vy += Math.sin(rippleAngle) * rippleForce; | |
| } | |
| }); | |
| // 施加摩擦並更新位置 | |
| node.vx *= 0.7; // 更多阻尼 | |
| node.vy *= 0.7; | |
| node.x += node.vx; | |
| node.y += node.vy; | |
| // 邊界碰撞(簡單的反彈) | |
| if (node.x < 0 || node.x > canvas.width) node.vx *= -0.8; | |
| if (node.y < 0 || node.y > canvas.height) node.vy *= -0.8; | |
| }); | |
| // 更新漣漪:擴展和衰減 | |
| ripples = ripples.filter(ripple => { | |
| ripple.radius += 3.5; // 更快的擴展 | |
| ripple.life -= 0.01; // 較慢的衰減,讓漣漪存在時間長一點 | |
| return ripple.life > 0 && ripple.radius < ripple.maxRadius; | |
| }); | |
| document.getElementById('rippleCount').textContent = ripples.length; | |
| } | |
| /** | |
| * 在畫布上渲染水墨效果。 | |
| */ | |
| function render() { | |
| if (!ctx) return; | |
| // 用淺色背景清除畫布以呈現水墨效果 | |
| ctx.fillStyle = '#f0f0f0'; /* 畫布背景色與 CSS 中的 #f0f0f0 同步 */ | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // 應用混合模式以實現墨水般的透明度 | |
| ctx.globalCompositeOperation = 'multiply'; | |
| // 渲染漣漪 | |
| ripples.forEach(ripple => { | |
| const opacity = ripple.life * ripple.intensity; | |
| ctx.strokeStyle = `rgba(0, 0, 0, ${opacity * 1.0})`; /* 漣漪完全不透明,更明顯 */ | |
| ctx.lineWidth = 3.5; /* 顯著增加漣漪線條粗細 */ | |
| ctx.beginPath(); | |
| ctx.arc(ripple.x, ripple.y, ripple.radius, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| // 內部漣漪以實現分層效果 | |
| if (ripple.radius > 10) { | |
| ctx.strokeStyle = `rgba(0, 0, 0, ${opacity * 0.6})`; /* 內部漣漪維持較淡但仍可見 */ | |
| ctx.lineWidth = 2.0; | |
| ctx.beginPath(); | |
| ctx.arc(ripple.x, ripple.y, ripple.radius * 0.7, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| } | |
| }); | |
| // 渲染節點連接(線條)- 織布效果的核心 | |
| for (let i = 0; i < nodes.length; i++) { | |
| for (let j = i + 1; j < nodes.length; j++) { | |
| const nodeA = nodes[i]; | |
| const nodeB = nodes[j]; | |
| const distance = Math.sqrt((nodeA.x - nodeB.x)**2 + (nodeA.y - nodeB.y)**2); | |
| const maxConnectDistance = 250; // 增加最大連接距離,讓更廣泛的連接可見 | |
| if (distance < maxConnectDistance) { | |
| // 線性衰減不透明度,並提高基礎值,讓織布感更明顯且不過黑 | |
| // 將不透明度基礎值顯著提高,以確保線條可見 | |
| const opacity = (1 - distance / maxConnectDistance) * 0.9; /* 調整為線性衰減,最高不透明度 0.9 */ | |
| if (opacity > 0.1) { // 設定最小可見度閾值,確保微弱線條也顯示 | |
| ctx.strokeStyle = `rgba(0, 0, 0, ${opacity})`; | |
| ctx.lineWidth = 3.0; /* 顯著增加線條粗細,強化織布感 */ | |
| ctx.beginPath(); | |
| ctx.moveTo(nodeA.x, nodeA.y); | |
| ctx.lineTo(nodeB.x, nodeB.y); | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| } | |
| /* // 移除節點圓點的繪製 | |
| nodes.forEach(node => { | |
| const opacity = 0.9 + Math.sin(time * 2 + node.phase) * 0.1; | |
| ctx.fillStyle = `rgba(0, 0, 0, ${opacity})`; | |
| ctx.beginPath(); | |
| ctx.arc(node.x, node.y, 4.5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| }); | |
| */ | |
| /* // 移除滑鼠指示器圓點 | |
| ctx.fillStyle = 'rgba(0, 0, 0, 1.0)'; | |
| ctx.beginPath(); | |
| ctx.arc(mouseX, mouseY, 7, 0, Math.PI * 2); | |
| ctx.fill(); | |
| */ | |
| /* // 移除滑鼠脈衝效果 | |
| const pulseSize = 9 + Math.sin(time * 6) * 3; | |
| ctx.strokeStyle = 'rgba(0, 0, 0, 0.9)'; | |
| ctx.lineWidth = 3.0; | |
| ctx.beginPath(); | |
| ctx.arc(mouseX, mouseY, pulseSize, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| */ | |
| // 重置混合模式和濾鏡 | |
| ctx.filter = 'none'; | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| /** | |
| * 主要動畫循環。 | |
| */ | |
| function animate() { | |
| if (!isRunning) return; | |
| if (!isPaused) { | |
| time += 0.02; // 時間前進 | |
| updatePhysics(); // 更新節點和漣漪物理 | |
| render(); // 渲染畫布 | |
| // 計算並顯示 FPS | |
| frameCount++; | |
| const now = Date.now(); | |
| if (now - lastFpsTime >= 1000) { | |
| document.getElementById('fpsDisplay').textContent = frameCount; | |
| frameCount = 0; | |
| lastFpsTime = now; | |
| } | |
| } | |
| requestAnimationFrame(animate); // 請求下一幀 | |
| } | |
| /** | |
| * 將 AI 生成的畫布內容複製到剪貼簿。 | |
| */ | |
| async function copyAICanvasImage() { | |
| try { | |
| // 將 canvas 內容轉換為 Blob | |
| aiCanvas.toBlob(async (blob) => { | |
| // 檢查瀏覽器是否支援 ClipboardItem 和 navigator.clipboard.write | |
| if (navigator.clipboard && navigator.clipboard.write) { | |
| try { | |
| const item = new ClipboardItem({ "image/png": blob }); | |
| await navigator.clipboard.write([item]); | |
| showMessage('圖片已複製到剪貼簿!', 'success'); | |
| } catch (err) { | |
| console.error('複製到剪貼簿失敗 (Clipboard API):', err); | |
| // Fallback for older browsers or restricted environments | |
| fallbackCopyImage(); | |
| } | |
| } else { | |
| // Fallback for browsers that don't support ClipboardItem | |
| fallbackCopyImage(); | |
| } | |
| }, 'image/png'); | |
| } catch (error) { | |
| console.error('複製圖片失敗:', error); | |
| showMessage('複製圖片失敗!', 'error'); | |
| } | |
| } | |
| /** | |
| * 舊版瀏覽器或受限環境的複製圖片備用方案。 | |
| * 注意:此方法可能因瀏覽器安全限制而失敗。 | |
| */ | |
| function fallbackCopyImage() { | |
| try { | |
| // 創建一個臨時的 img 元素 | |
| const img = document.createElement('img'); | |
| img.src = aiCanvas.toDataURL('image/png'); | |
| document.body.appendChild(img); // 必須添加到 DOM 才能選中 | |
| const range = document.createRange(); | |
| range.selectNode(img); | |
| window.getSelection().removeAllRanges(); | |
| window.getSelection().addRange(range); | |
| document.execCommand('copy'); | |
| window.getSelection().removeAllRanges(); | |
| document.body.removeChild(img); // 移除臨時元素 | |
| showMessage('圖片已複製到剪貼簿 (可能需要手動貼上)!', 'info'); | |
| } catch (err) { | |
| console.error('備用複製方法失敗:', err); | |
| showMessage('複製圖片失敗,請嘗試下載!', 'error'); | |
| } | |
| } | |
| /** | |
| * 下載 AI 生成的畫布圖片。 | |
| */ | |
| function downloadAICanvasImage() { | |
| try { | |
| const imageUrl = aiCanvas.toDataURL('image/png'); | |
| const a = document.createElement('a'); | |
| a.href = imageUrl; | |
| a.download = 'generated_art.png'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| showMessage('圖片已下載!', 'success'); | |
| } catch (error) { | |
| console.error('下載圖片失敗:', error); | |
| showMessage('下載圖片失敗!', 'error'); | |
| } | |
| } | |
| /** | |
| * 繪製抽象的程式化圖案到 AI 畫布上。 | |
| * 這個圖案是基於畫布的現有狀態(節點和漣漪)生成的。 | |
| */ | |
| function drawAIAbstract() { | |
| if (!aiCtx) return; | |
| aiCtx.clearRect(0, 0, aiCanvas.width, aiCanvas.height); | |
| aiCtx.fillStyle = '#f0f0f0'; /* 淺色背景,與主畫布同步 */ | |
| aiCtx.fillRect(0, 0, aiCanvas.width, aiCanvas.height); | |
| // 將主畫布的節點和漣漪資訊映射到 AI 畫布上 | |
| const scaleX = aiCanvas.width / canvas.width; | |
| const scaleY = aiCanvas.height / canvas.height; | |
| if (currentGenerationMode === 0) { | |
| // 模式 0: 連接點的「織布」風格 - 複製主畫布的編織感 | |
| document.getElementById('promptText').textContent = '基於水墨狀態的程式化網狀結構。'; | |
| // 繪製基於節點的連接線 | |
| aiCtx.strokeStyle = 'rgba(0, 0, 0, 0.9)'; /* 更深的不透明度 */ | |
| aiCtx.lineWidth = 3.0; // 匹配主畫布的線寬 | |
| nodes.forEach(nodeA => { | |
| nodes.forEach(nodeB => { | |
| if (nodeA !== nodeB) { | |
| const dist = Math.sqrt( | |
| (nodeA.x - nodeB.x) ** 2 + (nodeA.y - nodeB.y) ** 2 | |
| ); | |
| const aiMaxConnectDistance = 250; // 匹配主畫布的連接距離 | |
| if (dist < aiMaxConnectDistance) { | |
| const opacity = (1 - dist / aiMaxConnectDistance) * 0.9; // 匹配主畫布的透明度衰減 | |
| if (opacity > 0.1) { | |
| aiCtx.strokeStyle = `rgba(0, 0, 0, ${opacity})`; | |
| aiCtx.beginPath(); | |
| aiCtx.moveTo(nodeA.x * scaleX, nodeA.y * scaleY); | |
| aiCtx.lineTo(nodeB.x * scaleX, nodeB.y * scaleY); | |
| aiCtx.stroke(); | |
| } | |
| } | |
| } | |
| }); | |
| }); | |
| // 漣漪 (在模式0中以圓圈呈現) | |
| ripples.forEach(ripple => { | |
| const opacity = ripple.life * 1.0; /* 漣漪完全不透明 */ | |
| aiCtx.strokeStyle = `rgba(50, 50, 50, ${opacity})`; | |
| aiCtx.lineWidth = 3.5; // 匹配主畫布的線寬 | |
| aiCtx.beginPath(); | |
| aiCtx.arc(ripple.x * scaleX, ripple.y * scaleY, ripple.radius * scaleX * 0.6, 0, Math.PI * 2); | |
| aiCtx.stroke(); | |
| }); | |
| /* // 移除數據點 (節點) 的繪製 | |
| aiCtx.fillStyle = 'rgba(0, 0, 0, 0.8)'; | |
| nodes.forEach(node => { | |
| aiCtx.beginPath(); | |
| aiCtx.arc(node.x * scaleX, node.y * scaleY, 4.0, 0, Math.PI * 2); | |
| aiCtx.fill(); | |
| }); | |
| */ | |
| } else { | |
| // 模式 1: 粒子流動場 (全新畫面) - 提升複雜度和可見度 | |
| document.getElementById('promptText').textContent = '隨機粒子流動的抽象演算法圖案。'; | |
| const particles = []; | |
| const numParticles = 1500; // 大幅增加粒子數量以獲得更豐富的流動 | |
| const particleSpeed = 5 + mouseSpeed * 0.12; // 粒子速度受滑鼠速度影響更明顯 | |
| const particleLife = 400; // 粒子生命週期增加,軌跡更長 | |
| // 根據漣漪位置初始化一些粒子,或隨機初始化 | |
| if (ripples.length > 0) { | |
| ripples.forEach(ripple => { | |
| for(let i=0; i<numParticles/ripples.length; i++) { | |
| particles.push({ | |
| x: ripple.x * scaleX + (Math.random() - 0.5) * 200, // 從漣漪周圍開始 | |
| y: ripple.y * scaleY + (Math.random() - 0.5) * 200, | |
| vx: (Math.random() - 0.5) * particleSpeed, | |
| vy: (Math.random() - 0.5) * particleSpeed, | |
| life: particleLife, | |
| originalColor: `rgba(0, 0, 0, ${0.5 + Math.random() * 0.5})` // 基礎透明度大幅提高 | |
| }); | |
| } | |
| }); | |
| } else { | |
| for (let i = 0; i < numParticles; i++) { | |
| particles.push({ | |
| x: Math.random() * aiCanvas.width, | |
| y: Math.random() * aiCanvas.height, | |
| vx: (Math.random() - 0.5) * particleSpeed, | |
| vy: (Math.random() - 0.5) * particleSpeed, | |
| life: particleLife, | |
| originalColor: `rgba(0, 0, 0, ${0.5 + Math.random() * 0.5})` | |
| }); | |
| } | |
| } | |
| // 繪製粒子軌跡 | |
| for (let step = 0; step < 150; step++) { // 增加繪製步進,生成更密集的軌跡 | |
| aiCtx.globalAlpha = 0.3; // 提高全局透明度,讓軌跡更明顯 | |
| aiCtx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; // 軌跡顏色更深 | |
| aiCtx.lineWidth = 4.0; // 軌跡線條更粗 | |
| for (let i = 0; i < particles.length; i++) { | |
| const p = particles[i]; | |
| const currentOpacity = (p.life / particleLife) * (parseFloat(p.originalColor.split(',')[3]) || 0.8); // 調整透明度計算 | |
| aiCtx.strokeStyle = `rgba(0, 0, 0, ${currentOpacity})`; | |
| aiCtx.beginPath(); | |
| aiCtx.moveTo(p.x, p.y); | |
| // 簡單的流場:基於位置和時間影響速度 | |
| p.vx += Math.cos(p.y * 0.01 + time * 0.5) * 0.4; // 增加流場影響 | |
| p.vy += Math.sin(p.x * 0.01 + time * 0.5) * 0.4; | |
| // 摩擦力 | |
| p.vx *= 0.90; // 略微減少摩擦 | |
| p.vy *= 0.90; | |
| p.x += p.vx; | |
| p.y += p.vy; | |
| p.life--; | |
| aiCtx.lineTo(p.x, p.y); | |
| aiCtx.stroke(); | |
| // 粒子邊界反彈 | |
| if (p.x < 0 || p.x > aiCanvas.width) p.vx *= -1; | |
| if (p.y < 0 || p.y > aiCanvas.height) p.vy *= -1; | |
| // 如果粒子生命週期結束,重新初始化 | |
| if (p.life <= 0) { | |
| p.x = Math.random() * aiCanvas.width; | |
| p.y = Math.random() * aiCanvas.height; | |
| p.vx = (Math.random() - 0.5) * particleSpeed; | |
| p.vy = (Math.random() - 0.5) * particleSpeed; | |
| p.life = particleLife; | |
| p.originalColor = `rgba(0, 0, 0, ${0.5 + Math.random() * 0.5})`; | |
| } | |
| } | |
| } | |
| aiCtx.globalAlpha = 1; // 重置全局透明度 | |
| } | |
| } | |
| /** | |
| * 觸發本地畫布圖案生成。 | |
| * 不再調用外部 AI API。 | |
| */ | |
| async function generateCanvasArt() { | |
| console.log('正在生成本地畫布圖案...'); | |
| const aiStatusElement = document.getElementById('aiStatus'); | |
| const aiGenStatusDisplay = document.getElementById('aiGenStatus'); | |
| const spinnerElement = aiStatusElement.querySelector('.spinner'); | |
| // 顯示載入狀態 | |
| aiGenStatusDisplay.textContent = '生成中...'; | |
| aiCanvas.style.display = 'none'; // 隱藏 AI 畫布,顯示狀態覆蓋 | |
| aiStatusElement.style.display = 'flex'; | |
| aiStatusElement.innerHTML = `<div class="spinner"></div><p>正在生成圖案,請稍候...</p>`; | |
| // Show action buttons container | |
| document.querySelector('.action-buttons').style.display = 'none'; | |
| if (spinnerElement) { | |
| spinnerElement.style.display = 'block'; | |
| } | |
| // 模擬生成延遲 | |
| await new Promise(resolve => setTimeout(resolve, 500)); | |
| try { | |
| // 在兩種模式間切換 | |
| currentGenerationMode = (currentGenerationMode + 1) % 2; | |
| drawAIAbstract(); // 繪製抽象圖案到 AI 畫布上 | |
| aiCanvas.style.display = 'block'; // 顯示 AI 畫布 | |
| aiStatusElement.style.display = 'none'; // 隱藏載入狀態 | |
| document.querySelector('.action-buttons').style.display = 'flex'; // Show action buttons | |
| aiGenStatusDisplay.textContent = '完成'; | |
| showMessage('本地圖案生成完成!', 'success'); | |
| } catch (error) { | |
| console.error('生成本地圖案失敗:', error); | |
| aiStatusElement.style.display = 'flex'; | |
| aiStatusElement.innerHTML = `生成失敗: ${error.message}`; | |
| aiCanvas.style.display = 'none'; | |
| document.querySelector('.action-buttons').style.display = 'none'; // Hide action buttons on error | |
| aiGenStatusDisplay.textContent = '失敗'; | |
| showMessage(`生成失敗: ${error.message}`, 'error'); | |
| } finally { | |
| if (spinnerElement) { | |
| spinnerElement.style.display = 'none'; | |
| } | |
| } | |
| } | |
| /** | |
| * 向使用者顯示臨時訊息框。 | |
| * @param {string} text - 訊息文本。 | |
| * @param {string} type - 訊息類型 ('success', 'error', 'info')。 | |
| */ | |
| function showMessage(text, type) { | |
| const msg = document.getElementById('messageBox'); | |
| msg.textContent = text; | |
| msg.className = `message show ${type}`; | |
| // 訊息在 4 秒後淡出 | |
| setTimeout(() => { | |
| msg.classList.remove('show'); | |
| }, 4000); | |
| } | |
| // 當 DOM 內容完全載入時初始化系統 | |
| document.addEventListener('DOMContentLoaded', () => { | |
| console.log('DOM 載入完成'); | |
| init(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |