Spaces:
Running
Running
File size: 43,355 Bytes
16756bd c3da5b2 16756bd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 | <!DOCTYPE html>
<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>
|