2587 lines
103 KiB
HTML
2587 lines
103 KiB
HTML
<!DOCTYPE html><html lang="zh-CN" data-theme="light"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0,viewport-fit=cover"><title>🔥 火之创造 — 元素沙盒 (硬核升级版) | llbzow的摸鱼日记 (づ ̄ 3 ̄)づ</title><meta name="author" content="llbzow"><meta name="copyright" content="llbzow"><meta name="format-detection" content="telephone=no"><meta name="theme-color" content="ffffff"><meta name="description" content=".fire-wrapper { position: relative; width: 100%; background: #0a0a0f; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.5); user-select: none; }">
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:title" content="🔥 火之创造 — 元素沙盒 (硬核升级版)">
|
||
<meta property="og:url" content="https://yourblog.com/tools/fire-creation/index.html">
|
||
<meta property="og:site_name" content="llbzow的摸鱼日记 (づ ̄ 3 ̄)づ">
|
||
<meta property="og:description" content=".fire-wrapper { position: relative; width: 100%; background: #0a0a0f; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.5); user-select: none; }">
|
||
<meta property="og:locale" content="zh_CN">
|
||
<meta property="og:image" content="https://yourblog.com/img/cute_cat.svg">
|
||
<meta property="article:published_time" content="2026-03-10T16:00:00.000Z">
|
||
<meta property="article:modified_time" content="2026-03-16T02:45:09.735Z">
|
||
<meta property="article:author" content="llbzow">
|
||
<meta property="article:tag" content="技术, 生活, 博客, 分享">
|
||
<meta name="twitter:card" content="summary">
|
||
<meta name="twitter:image" content="https://yourblog.com/img/cute_cat.svg"><script type="application/ld+json"></script><link rel="shortcut icon" href="/img/cute_cat.svg"><link rel="canonical" href="https://yourblog.com/tools/fire-creation/index.html"><link rel="preconnect" href="//cdn.jsdelivr.net"><link rel="preconnect" href="//busuanzi.ibruce.info"><link rel="stylesheet" href="/css/index.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free/css/all.min.css"><script>
|
||
(() => {
|
||
|
||
const saveToLocal = {
|
||
set: (key, value, ttl) => {
|
||
if (!ttl) return
|
||
const expiry = Date.now() + ttl * 86400000
|
||
localStorage.setItem(key, JSON.stringify({ value, expiry }))
|
||
},
|
||
get: key => {
|
||
const itemStr = localStorage.getItem(key)
|
||
if (!itemStr) return undefined
|
||
const { value, expiry } = JSON.parse(itemStr)
|
||
if (Date.now() > expiry) {
|
||
localStorage.removeItem(key)
|
||
return undefined
|
||
}
|
||
return value
|
||
}
|
||
}
|
||
|
||
window.btf = {
|
||
saveToLocal,
|
||
getScript: (url, attr = {}) => new Promise((resolve, reject) => {
|
||
const script = document.createElement('script')
|
||
script.src = url
|
||
script.async = true
|
||
Object.entries(attr).forEach(([key, val]) => script.setAttribute(key, val))
|
||
script.onload = script.onreadystatechange = () => {
|
||
if (!script.readyState || /loaded|complete/.test(script.readyState)) resolve()
|
||
}
|
||
script.onerror = reject
|
||
document.head.appendChild(script)
|
||
}),
|
||
getCSS: (url, id) => new Promise((resolve, reject) => {
|
||
const link = document.createElement('link')
|
||
link.rel = 'stylesheet'
|
||
link.href = url
|
||
if (id) link.id = id
|
||
link.onload = link.onreadystatechange = () => {
|
||
if (!link.readyState || /loaded|complete/.test(link.readyState)) resolve()
|
||
}
|
||
link.onerror = reject
|
||
document.head.appendChild(link)
|
||
}),
|
||
addGlobalFn: (key, fn, name = false, parent = window) => {
|
||
if (!true && key.startsWith('pjax')) return
|
||
const globalFn = parent.globalFn || {}
|
||
globalFn[key] = globalFn[key] || {}
|
||
globalFn[key][name || Object.keys(globalFn[key]).length] = fn
|
||
parent.globalFn = globalFn
|
||
}
|
||
}
|
||
|
||
|
||
const activateDarkMode = () => {
|
||
document.documentElement.setAttribute('data-theme', 'dark')
|
||
if (document.querySelector('meta[name="theme-color"]') !== null) {
|
||
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#0d0d0d')
|
||
}
|
||
}
|
||
const activateLightMode = () => {
|
||
document.documentElement.setAttribute('data-theme', 'light')
|
||
if (document.querySelector('meta[name="theme-color"]') !== null) {
|
||
document.querySelector('meta[name="theme-color"]').setAttribute('content', 'ffffff')
|
||
}
|
||
}
|
||
|
||
btf.activateDarkMode = activateDarkMode
|
||
btf.activateLightMode = activateLightMode
|
||
|
||
const theme = saveToLocal.get('theme')
|
||
|
||
theme === 'dark' ? activateDarkMode() : theme === 'light' ? activateLightMode() : null
|
||
|
||
|
||
const asideStatus = saveToLocal.get('aside-status')
|
||
if (asideStatus !== undefined) {
|
||
document.documentElement.classList.toggle('hide-aside', asideStatus === 'hide')
|
||
}
|
||
|
||
|
||
const detectApple = () => {
|
||
if (/iPad|iPhone|iPod|Macintosh/.test(navigator.userAgent)) {
|
||
document.documentElement.classList.add('apple')
|
||
}
|
||
}
|
||
detectApple()
|
||
|
||
})()
|
||
</script><script>const GLOBAL_CONFIG = {
|
||
root: '/',
|
||
algolia: undefined,
|
||
localSearch: undefined,
|
||
translate: undefined,
|
||
highlight: {"plugin":"highlight.js","highlightCopy":true,"highlightLang":true,"highlightHeightLimit":false,"highlightFullpage":false,"highlightMacStyle":false},
|
||
copy: {
|
||
success: '复制成功',
|
||
error: '复制失败',
|
||
noSupport: '浏览器不支持'
|
||
},
|
||
relativeDate: {
|
||
homepage: false,
|
||
post: false
|
||
},
|
||
runtime: '',
|
||
dateSuffix: {
|
||
just: '刚刚',
|
||
min: '分钟前',
|
||
hour: '小时前',
|
||
day: '天前',
|
||
month: '个月前'
|
||
},
|
||
copyright: undefined,
|
||
lightbox: 'null',
|
||
Snackbar: undefined,
|
||
infinitegrid: {
|
||
js: 'https://cdn.jsdelivr.net/npm/@egjs/infinitegrid/dist/infinitegrid.min.js',
|
||
buttonText: '加载更多'
|
||
},
|
||
isPhotoFigcaption: false,
|
||
islazyloadPlugin: true,
|
||
isAnchor: false,
|
||
percent: {
|
||
toc: true,
|
||
rightside: false,
|
||
},
|
||
autoDarkmode: false
|
||
}</script><script id="config-diff">var GLOBAL_CONFIG_SITE = {
|
||
title: '🔥 火之创造 — 元素沙盒 (硬核升级版)',
|
||
isHighlightShrink: false,
|
||
isToc: false,
|
||
pageType: 'page'
|
||
}</script><link rel="stylesheet" href="/css/custom.css"><meta name="generator" content="Hexo 7.3.0"></head><body><div id="loading-box"><div class="loading-left-bg"></div><div class="loading-right-bg"></div><div class="spinner-box"><div class="configure-border-1"><div class="configure-core"></div></div><div class="configure-border-2"><div class="configure-core"></div></div><div class="loading-word">加载中...</div></div></div><script>(()=>{
|
||
const $loadingBox = document.getElementById('loading-box')
|
||
const $body = document.body
|
||
const preloader = {
|
||
endLoading: () => {
|
||
if ($loadingBox.classList.contains('loaded')) return
|
||
$body.style.overflow = ''
|
||
$loadingBox.classList.add('loaded')
|
||
},
|
||
initLoading: () => {
|
||
$body.style.overflow = 'hidden'
|
||
$loadingBox.classList.remove('loaded')
|
||
}
|
||
}
|
||
|
||
preloader.initLoading()
|
||
|
||
if (document.readyState === 'complete') {
|
||
preloader.endLoading()
|
||
} else {
|
||
window.addEventListener('load', preloader.endLoading)
|
||
document.addEventListener('DOMContentLoaded', preloader.endLoading)
|
||
// Add timeout protection: force end after 7 seconds
|
||
setTimeout(preloader.endLoading, 7000)
|
||
}
|
||
|
||
if (true) {
|
||
btf.addGlobalFn('pjaxSend', preloader.initLoading, 'preloader_init')
|
||
btf.addGlobalFn('pjaxComplete', preloader.endLoading, 'preloader_end')
|
||
}
|
||
})()</script><div id="web_bg" style="background-image: url(/img/bg_main.png);"></div><div id="sidebar"><div id="menu-mask"></div><div id="sidebar-menus"><div class="avatar-img text-center"><img src="data:image/webp;base64,UklGRhoBAABXRUJQVlA4WAoAAAAQAAAAJwAAJwAAQUxQSLgAAAAFuTJE9D80UiRJkiTpxvPo4fPha2lWw0WWMtgkeIot7wfsAyImYAI+vnvEjz++fJs9sbA+tfcM/HtDiBgr3c8SJP/Dk1FWC2LCxjhcwxkH4oQuQtNwSOdgixw5o+0TyIUCQoMJti7B3ALGhZLGNuJiAYfblG7kb/RON5GjZNeEtA+QtC6omDsY2aoIwv2KDLiAzPYvzhmyNVWWzAtzzGHxSBnzCwFja3Igk3sSwSooc7gTFtmKJoIQVlA4IDwAAABQAwCdASooACgAPzmcxF0vKqcko4gB4CcJZwDNSAn82OhFYAD+7iKcpmiMeBjtx3LPQK2rXKK9ARvvVAA=" data-lazy-src="/img/cute_cat.svg" onerror="this.onerror=null;this.src='/img/friend_404.gif'" alt="avatar" loading="eager" fetchpriority="high" decoding="sync"></div><div class="site-data text-center"><a href="/archives/"><div class="headline">文章</div><div class="length-num">20</div></a><a href="/tags/"><div class="headline">标签</div><div class="length-num">31</div></a><a href="/categories/"><div class="headline">分类</div><div class="length-num">4</div></a></div><div class="menus_items"><div class="menus_item"><a class="site-page" href="/"><i class="fa-fw fas fa-home"></i><span> 首页</span></a></div><div class="menus_item"><a class="site-page" href="/archives/"><i class="fa-fw fas fa-archive"></i><span> 文章</span></a></div><div class="menus_item"><a class="site-page" href="/tags/"><i class="fa-fw fas fa-tags"></i><span> 标签</span></a></div><div class="menus_item"><a class="site-page" href="/categories/"><i class="fa-fw fas fa-folder-open"></i><span> 分类</span></a></div><div class="menus_item"><a class="site-page" href="/about/"><i class="fa-fw fas fa-heart"></i><span> 关于</span></a></div></div></div></div><div class="page" id="body-wrap"><header class="not-top-img" id="page-header"><nav id="nav"><span id="blog-info"><a class="nav-site-title" href="/"><span class="site-name">llbzow的摸鱼日记 (づ ̄ 3 ̄)づ</span></a></span><div id="menus"><div class="menus_items"><div class="menus_item"><a class="site-page" href="/"><i class="fa-fw fas fa-home"></i><span> 首页</span></a></div><div class="menus_item"><a class="site-page" href="/archives/"><i class="fa-fw fas fa-archive"></i><span> 文章</span></a></div><div class="menus_item"><a class="site-page" href="/tags/"><i class="fa-fw fas fa-tags"></i><span> 标签</span></a></div><div class="menus_item"><a class="site-page" href="/categories/"><i class="fa-fw fas fa-folder-open"></i><span> 分类</span></a></div><div class="menus_item"><a class="site-page" href="/about/"><i class="fa-fw fas fa-heart"></i><span> 关于</span></a></div></div><div id="toggle-menu"><span class="site-page"><i class="fas fa-bars fa-fw"></i></span></div></div></nav><h1 class="title-seo">🔥 火之创造 — 元素沙盒 (硬核升级版)</h1></header><main class="layout hide-aside" id="content-inner"><div id="page"><div class="page-title">🔥 火之创造 — 元素沙盒 (硬核升级版)</div><div class="container" id="article-container"><style>
|
||
.fire-wrapper {
|
||
position: relative;
|
||
width: 100%;
|
||
background: #0a0a0f;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||
user-select: none;
|
||
}
|
||
|
||
#fireCanvas {
|
||
display: block;
|
||
width: 100%;
|
||
height: auto;
|
||
aspect-ratio: 16 / 9;
|
||
image-rendering: pixelated;
|
||
cursor: crosshair;
|
||
}
|
||
|
||
#fire-ui {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 14px;
|
||
background: rgba(10,10,20,0.95);
|
||
border-bottom: 1px solid rgba(255,255,255,0.07);
|
||
}
|
||
|
||
.elem-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 5px 11px;
|
||
border-radius: 6px;
|
||
border: 2px solid transparent;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
color: #ddd;
|
||
background: rgba(255,255,255,0.06);
|
||
transition: all 0.15s;
|
||
}
|
||
.elem-btn:hover { background: rgba(255,255,255,0.14); }
|
||
.elem-btn.active { border-color: #fff; color: #fff; background: rgba(255,255,255,0.18); }
|
||
|
||
.elem-dot {
|
||
width: 12px; height: 12px;
|
||
border-radius: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.brush-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-left: auto;
|
||
color: #aaa;
|
||
font-size: 12px;
|
||
}
|
||
.brush-row input[type=range] { width: 80px; }
|
||
|
||
.tune-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: #aaa;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.tune-row input[type=range] { width: 72px; }
|
||
|
||
.ctrl-btn {
|
||
padding: 5px 12px;
|
||
border-radius: 6px;
|
||
border: 1px solid rgba(255,255,255,0.2);
|
||
background: rgba(255,255,255,0.07);
|
||
color: #ccc;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
transition: all 0.15s;
|
||
}
|
||
.ctrl-btn:hover { background: rgba(255,255,255,0.15); color: #fff; }
|
||
|
||
#fire-stats {
|
||
display: flex;
|
||
align-items: center;
|
||
color: rgba(255,255,255,0.7);
|
||
font-family: monospace;
|
||
font-size: 12px;
|
||
background: rgba(0,0,0,0.55);
|
||
padding: 4px 8px;
|
||
border-radius: 5px;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
#gpu-error-fire { color: #ff4d4f; font-weight: bold; padding: 20px; }
|
||
</style>
|
||
|
||
<div class="fire-wrapper">
|
||
<div id="fire-ui">
|
||
<button class="elem-btn active" data-elem="1"><span class="elem-dot" style="background:#d4b86a"></span>沙子</button>
|
||
<button class="elem-btn" data-elem="9"><span class="elem-dot" style="background:#6b5428"></span>泥土</button>
|
||
<button class="elem-btn" data-elem="3"><span class="elem-dot" style="background:#7a7a7a"></span>石头</button>
|
||
<button class="elem-btn" data-elem="14"><span class="elem-dot" style="background:#88929e"></span>金属</button>
|
||
<button class="elem-btn" data-elem="15"><span class="elem-dot" style="background:#a8d3e6;opacity:0.8"></span>玻璃</button>
|
||
<button class="elem-btn" data-elem="5"><span class="elem-dot" style="background:#7a4a1e"></span>木头</button>
|
||
<button class="elem-btn" data-elem="10"><span class="elem-dot" style="background:#3a8c2f"></span>植物</button>
|
||
<button class="elem-btn" data-elem="2"><span class="elem-dot" style="background:#3a7bd5"></span>水</button>
|
||
<button class="elem-btn" data-elem="12"><span class="elem-dot" style="background:#80ff00"></span>酸液</button>
|
||
<button class="elem-btn" data-elem="11"><span class="elem-dot" style="background:#333333"></span>油</button>
|
||
<button class="elem-btn" data-elem="8"><span class="elem-dot" style="background:#cc3300"></span>岩浆</button>
|
||
<button class="elem-btn" data-elem="13"><span class="elem-dot" style="background:#b3e6ff"></span>冰</button>
|
||
<button class="elem-btn" data-elem="4"><span class="elem-dot" style="background:#ff6a00"></span>火焰</button>
|
||
<button class="elem-btn" data-elem="7"><span class="elem-dot" style="background:#99c0d8"></span>蒸汽</button>
|
||
<button class="elem-btn" data-elem="18"><span class="elem-dot" style="background:#d4d4aa"></span>燃气</button>
|
||
<button class="elem-btn" data-elem="6"><span class="elem-dot" style="background:#4a4030"></span>火药</button>
|
||
<button class="elem-btn" data-elem="16"><span class="elem-dot" style="background:#8b2626"></span>C4</button>
|
||
<button class="elem-btn" data-elem="17"><span class="elem-dot" style="background:#8f4033"></span>铝热剂</button>
|
||
<button class="elem-btn" data-elem="0"><span class="elem-dot" style="background:#111;border:1px solid #444"></span>橡皮</button>
|
||
<div class="brush-row"><span>笔刷:</span><input type="range" id="brushSize" min="1" max="50" value="10"><span id="brushSizeLabel">10</span></div>
|
||
<button class="ctrl-btn" id="btnPause">⏸ 暂停</button>
|
||
<button class="ctrl-btn" id="btnClear">🗑 清空</button>
|
||
<div class="tune-row"><span>气体扩散</span><input type="range" id="gasDrift" min="1" max="100" value="55"><span id="gasDriftLabel">55</span></div>
|
||
<div class="tune-row"><span>液体扩散</span><input type="range" id="liqSpread" min="1" max="100" value="60"><span id="liqSpreadLabel">60</span></div>
|
||
<div class="tune-row"><span>爆炸强度</span><input type="range" id="blastPower" min="1" max="100" value="65"><span id="blastPowerLabel">65</span></div>
|
||
<div id="fire-stats">FPS: --</div>
|
||
</div>
|
||
<canvas id="fireCanvas"></canvas>
|
||
<div id="gpu-error-fire" style="display:none"></div>
|
||
</div>
|
||
|
||
<script type="module">
|
||
// ==========================================
|
||
// 火之创造 — WebGPU 元素沙盒模拟器 (720p 硬核物理版)
|
||
// ==========================================
|
||
|
||
const GRID_W = 1280;
|
||
const GRID_H = 720;
|
||
const CELL_PX = 1;
|
||
const SIM_STEPS = 2; // 每帧运算次数,极大加速流体找平和整体物理
|
||
|
||
const AIR = 0, SAND = 1, WATER = 2, STONE = 3,
|
||
FIRE = 4, WOOD = 5, GUN = 6, STEAM = 7, LAVA = 8,
|
||
DIRT = 9, PLANT = 10, OIL = 11, ACID = 12, ICE = 13,
|
||
METAL = 14, GLASS = 15, C4 = 16, THERMITE = 17, GAS = 18;
|
||
|
||
let paused = false;
|
||
let brushElem = 1;
|
||
let brushRadius = 10;
|
||
let isDrawing = false;
|
||
let mouseGX = -1, mouseGY = -1;
|
||
let frameIdx = 0;
|
||
let lastFpsTime = performance.now();
|
||
let fpsFrames = 0;
|
||
let gasDrift = 55;
|
||
let liquidSpread = 60;
|
||
let blastPower = 65;
|
||
|
||
// ==========================================
|
||
// Compute Shader:进阶元素物理规则
|
||
// ==========================================
|
||
const computeShader = `
|
||
const GW: u32 = ${GRID_W}u;
|
||
const GH: u32 = ${GRID_H}u;
|
||
|
||
const AIR: u32 = 0u;
|
||
const SAND: u32 = 1u;
|
||
const WATER: u32 = 2u;
|
||
const STONE: u32 = 3u;
|
||
const FIRE: u32 = 4u;
|
||
const WOOD: u32 = 5u;
|
||
const GUN: u32 = 6u;
|
||
const STEAM: u32 = 7u;
|
||
const LAVA: u32 = 8u;
|
||
const DIRT: u32 = 9u;
|
||
const PLANT: u32 = 10u;
|
||
const OIL: u32 = 11u;
|
||
const ACID: u32 = 12u;
|
||
const ICE: u32 = 13u;
|
||
const METAL: u32 = 14u;
|
||
const GLASS: u32 = 15u;
|
||
const C4: u32 = 16u;
|
||
const THERMITE: u32 = 17u;
|
||
const GAS: u32 = 18u;
|
||
|
||
@group(0) @binding(0) var<storage, read> rGrid: array<u32>;
|
||
@group(0) @binding(1) var<storage, read_write> wGrid: array<u32>;
|
||
|
||
struct Params {
|
||
frame: u32,
|
||
brushX: i32,
|
||
brushY: i32,
|
||
brushRadius: i32,
|
||
brushElem: u32,
|
||
drawing: u32,
|
||
clearing: u32,
|
||
_pad: u32,
|
||
gasDrift: u32,
|
||
liquidSpread:u32,
|
||
blastPower: u32,
|
||
_pad2: u32,
|
||
};
|
||
@group(0) @binding(2) var<uniform> p: Params;
|
||
|
||
fn cidx(x: u32, y: u32) -> u32 { return y * GW + x; }
|
||
fn ctype(c: u32) -> u32 { return c >> 16u; }
|
||
fn clife(c: u32) -> u32 { return c & 0xFFFFu; }
|
||
fn mkc(t: u32, l: u32) -> u32 { return (t << 16u) | (l & 0xFFFFu); }
|
||
|
||
// 分类系统
|
||
fn isRigid(t: u32) -> bool { return t == STONE || t == WOOD || t == METAL || t == GLASS || t == C4 || t == ICE; }
|
||
fn isPowder(t: u32) -> bool { return t == SAND || t == DIRT || t == GUN || t == THERMITE; }
|
||
fn isLiquid(t: u32) -> bool { return t == WATER || t == OIL || t == ACID || t == LAVA; }
|
||
fn isGas(t: u32) -> bool { return t == AIR || t == STEAM || t == FIRE || t == GAS; }
|
||
fn isCorrodible(t: u32) -> bool { return t == STONE || t == WOOD || t == DIRT || t == SAND || t == PLANT || t == METAL; }
|
||
|
||
fn hash(v: u32) -> u32 {
|
||
var x = v ^ (v >> 16u);
|
||
x = x * 0x45d9f3bu;
|
||
x = x ^ (x >> 16u);
|
||
return x;
|
||
}
|
||
fn rng(x: u32, y: u32, f: u32, salt: u32) -> u32 {
|
||
return hash(x * 1664525u + y * 1013904223u + f * 2246822519u + salt);
|
||
}
|
||
|
||
fn readAt(x: u32, y: u32) -> u32 {
|
||
if (x >= GW || y >= GH) { return mkc(STONE, 0u); }
|
||
return rGrid[cidx(x, y)];
|
||
}
|
||
fn typeAt(x: u32, y: u32) -> u32 { return ctype(readAt(x, y)); }
|
||
|
||
// 爆炸破坏判定
|
||
fn applyExplosion(idx: u32, t: u32, nl: u32, c4: bool, th: bool, rnd: u32, intensity: u32) {
|
||
if (t == AIR) {
|
||
if (intensity > 30u && rnd % 3u == 0u) { wGrid[idx] = mkc(FIRE, select(60u, nl - 20u, nl > 20u)); }
|
||
return;
|
||
}
|
||
|
||
if (c4) {
|
||
if (t == C4) { wGrid[idx] = mkc(FIRE, 950u); return; }
|
||
if (t == GLASS) {
|
||
if (intensity > 55u || rnd % 2u == 0u) { wGrid[idx] = select(mkc(AIR, 0u), mkc(SAND, 0u), rnd % 3u == 0u); }
|
||
return;
|
||
}
|
||
if (t == STONE) {
|
||
if (intensity > 60u) { wGrid[idx] = select(mkc(DIRT, 0u), mkc(SAND, 0u), rnd % 2u == 0u); }
|
||
else if (rnd % 4u == 0u) { wGrid[idx] = mkc(DIRT, 0u); }
|
||
return;
|
||
}
|
||
if (t == METAL) {
|
||
if (intensity > 75u && rnd % 3u == 0u) { wGrid[idx] = mkc(LAVA, 420u); }
|
||
else if (rnd % 6u == 0u) { wGrid[idx] = mkc(FIRE, 180u); }
|
||
return;
|
||
}
|
||
if (t == WOOD || t == PLANT || t == DIRT || t == SAND || t == GUN || t == THERMITE || t == OIL || t == ACID || t == WATER || t == ICE) {
|
||
wGrid[idx] = select(mkc(FIRE, select(80u, nl - 15u, nl > 15u)), mkc(AIR, 0u), rnd % 5u == 0u);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (th) {
|
||
if (t == METAL || t == STONE) { wGrid[idx] = mkc(LAVA, 520u); return; }
|
||
if (t == GLASS) { wGrid[idx] = mkc(AIR, 0u); return; }
|
||
}
|
||
|
||
if (t != METAL && t != STONE && t != C4) {
|
||
wGrid[idx] = mkc(FIRE, select(40u, nl - 10u, nl > 10u));
|
||
}
|
||
}
|
||
|
||
@compute @workgroup_size(8, 8)
|
||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||
let x = gid.x;
|
||
let y = gid.y;
|
||
if (x >= GW || y >= GH) { return; }
|
||
|
||
let tidx = cidx(x, y);
|
||
|
||
if (p.clearing == 1u) {
|
||
wGrid[tidx] = mkc(AIR, 0u);
|
||
return;
|
||
}
|
||
|
||
if (p.drawing == 1u) {
|
||
let dx = i32(x) - p.brushX;
|
||
let dy = i32(y) - p.brushY;
|
||
if (dx*dx + dy*dy <= p.brushRadius * p.brushRadius) {
|
||
var life: u32 = 0u;
|
||
if (p.brushElem == FIRE) { life = 80u + (rng(x,y,p.frame,1u) % 40u); }
|
||
if (p.brushElem == STEAM){ life = 100u + (rng(x,y,p.frame,2u) % 60u); }
|
||
if (p.brushElem == LAVA) { life = 500u; }
|
||
if (p.brushElem == GAS) { life = 200u + (rng(x,y,p.frame,3u) % 100u); }
|
||
wGrid[tidx] = mkc(p.brushElem, life);
|
||
return;
|
||
}
|
||
}
|
||
|
||
let me = rGrid[tidx];
|
||
let myT = ctype(me);
|
||
let myL = clife(me);
|
||
let r = rng(x, y, p.frame, 0u);
|
||
|
||
let hasU = y > 0u;
|
||
let hasD = y + 1u < GH;
|
||
let hasL = x > 0u;
|
||
let hasR = x + 1u < GW;
|
||
|
||
let tU = select(STONE, typeAt(x, y-1u), hasU);
|
||
let tD = select(STONE, typeAt(x, y+1u), hasD);
|
||
let tL = select(STONE, typeAt(x-1u, y ), hasL);
|
||
let tR = select(STONE, typeAt(x+1u, y ), hasR);
|
||
|
||
// 性能保护:大面积静态区域早期剔除 (Early Out)
|
||
if (myT == AIR) {
|
||
let tU_act = !isRigid(tU) && tU != AIR && tU != PLANT;
|
||
let tD_act = (isGas(tD) && tD != AIR) || tD == LAVA;
|
||
let tL_act = isLiquid(tL) || isGas(tL) || isPowder(tL);
|
||
let tR_act = isLiquid(tR) || isGas(tR) || isPowder(tR);
|
||
|
||
if (!tU_act && !tD_act && !tL_act && !tR_act) {
|
||
let hasUL = hasU && hasL; let hasUR = hasU && hasR;
|
||
let tUL = select(STONE, typeAt(x-1u, y-1u), hasUL);
|
||
let tUR = select(STONE, typeAt(x+1u, y-1u), hasUR);
|
||
let tUL_act = !isRigid(tUL) && tUL != AIR && tUL != PLANT;
|
||
let tUR_act = !isRigid(tUR) && tUR != AIR && tUR != PLANT;
|
||
if (!tUL_act && !tUR_act) { wGrid[tidx] = mkc(AIR, 0u); return; }
|
||
}
|
||
} else if (isRigid(myT)) {
|
||
let nearAcid = (tU==ACID||tD==ACID||tL==ACID||tR==ACID);
|
||
let nearFire = (tU==FIRE||tD==FIRE||tL==FIRE||tR==FIRE||tU==LAVA||tD==LAVA||tL==LAVA||tR==LAVA||tU==THERMITE||tD==THERMITE||tL==THERMITE||tR==THERMITE);
|
||
if (!nearAcid && !nearFire) { wGrid[tidx] = me; return; }
|
||
}
|
||
|
||
let hasDL = hasD && hasL;
|
||
let hasDR = hasD && hasR;
|
||
let hasUL = hasU && hasL;
|
||
let hasUR = hasU && hasR;
|
||
|
||
let tDL = select(STONE, typeAt(x-1u, y+1u), hasDL);
|
||
let tDR = select(STONE, typeAt(x+1u, y+1u), hasDR);
|
||
let tUL = select(STONE, typeAt(x-1u, y-1u), hasUL);
|
||
let tUR = select(STONE, typeAt(x+1u, y-1u), hasUR);
|
||
|
||
var newCell: u32 = me;
|
||
let frameParity = p.frame % 2u;
|
||
|
||
switch myT {
|
||
case AIR: {
|
||
// 粉体/液体优先填充
|
||
if (hasU && isPowder(tU)) { newCell = readAt(x, y-1u); }
|
||
else if (hasUR && isPowder(tUR) && typeAt(x+1u, y) != AIR) { newCell = readAt(x+1u, y-1u); }
|
||
else if (hasUL && isPowder(tUL) && typeAt(x-1u, y) != AIR) { newCell = readAt(x-1u, y-1u); }
|
||
else if (hasU && isLiquid(tU)) { newCell = readAt(x, y-1u); }
|
||
else if (hasUR && isLiquid(tUR) && typeAt(x+1u, y) != AIR) { newCell = readAt(x+1u, y-1u); }
|
||
else if (hasUL && isLiquid(tUL) && typeAt(x-1u, y) != AIR) { newCell = readAt(x-1u, y-1u); }
|
||
|
||
// 液体横向找平(允许更积极的压强扩散)
|
||
else if (hasR && isLiquid(tR) && typeAt(x, y+1u) != AIR && (r % 100u) < p.liquidSpread) { newCell = readAt(x+1u, y); }
|
||
else if (x+2u < GW && isLiquid(typeAt(x+2u, y)) && tR == AIR && typeAt(x, y+1u) != AIR && (r % 100u) < p.liquidSpread) { newCell = readAt(x+2u, y); }
|
||
else if (x+3u < GW && isLiquid(typeAt(x+3u, y)) && tR == AIR && typeAt(x+2u, y) == AIR && typeAt(x, y+1u) != AIR && (r % 100u) < p.liquidSpread) { newCell = readAt(x+3u, y); }
|
||
else if (hasL && isLiquid(tL) && typeAt(x, y+1u) != AIR && (r % 100u) < p.liquidSpread) { newCell = readAt(x-1u, y); }
|
||
else if (x > 1u && isLiquid(typeAt(x-2u, y)) && tL == AIR && typeAt(x, y+1u) != AIR && (r % 100u) < p.liquidSpread) { newCell = readAt(x-2u, y); }
|
||
else if (x > 2u && isLiquid(typeAt(x-3u, y)) && tL == AIR && typeAt(x-2u, y) == AIR && typeAt(x, y+1u) != AIR && (r % 100u) < p.liquidSpread) { newCell = readAt(x-3u, y); }
|
||
|
||
// 气体上浮与扩散(下方、斜下、侧向抽吸)
|
||
else if (hasD && (tD == GAS || tD == STEAM) && (r % 100u) < p.gasDrift) {
|
||
let gc = readAt(x, y+1u);
|
||
let gl = clife(gc);
|
||
let gt = ctype(gc);
|
||
if (gt == STEAM) { newCell = select(mkc(STEAM, 1u), mkc(STEAM, gl - 1u), gl > 1u); }
|
||
else { newCell = gc; }
|
||
}
|
||
else if (hasDL && (tDL == GAS || tDL == STEAM) && tD != AIR && (r % 100u) < p.gasDrift) {
|
||
let gc = readAt(x-1u, y+1u);
|
||
let gl = clife(gc);
|
||
let gt = ctype(gc);
|
||
if (gt == STEAM) { newCell = select(mkc(STEAM, 1u), mkc(STEAM, gl - 1u), gl > 1u); }
|
||
else { newCell = gc; }
|
||
}
|
||
else if (hasDR && (tDR == GAS || tDR == STEAM) && tD != AIR && (r % 100u) < p.gasDrift) {
|
||
let gc = readAt(x+1u, y+1u);
|
||
let gl = clife(gc);
|
||
let gt = ctype(gc);
|
||
if (gt == STEAM) { newCell = select(mkc(STEAM, 1u), mkc(STEAM, gl - 1u), gl > 1u); }
|
||
else { newCell = gc; }
|
||
}
|
||
else if (hasL && (tL == GAS || tL == STEAM) && (r % 100u) < (p.gasDrift / 2u + 10u)) {
|
||
let gc = readAt(x-1u, y);
|
||
let gl = clife(gc);
|
||
let gt = ctype(gc);
|
||
if (gt == STEAM) { newCell = select(mkc(STEAM, 1u), mkc(STEAM, gl - 1u), gl > 1u); }
|
||
else { newCell = gc; }
|
||
}
|
||
else if (hasR && (tR == GAS || tR == STEAM) && (r % 100u) < (p.gasDrift / 2u + 10u)) {
|
||
let gc = readAt(x+1u, y);
|
||
let gl = clife(gc);
|
||
let gt = ctype(gc);
|
||
if (gt == STEAM) { newCell = select(mkc(STEAM, 1u), mkc(STEAM, gl - 1u), gl > 1u); }
|
||
else { newCell = gc; }
|
||
}
|
||
else if (hasD && tD == FIRE && (p.frame % 3u != 0u)) {
|
||
let sl = clife(readAt(x, y+1u));
|
||
if (sl > 1u && sl < 500u) { newCell = mkc(FIRE, sl - 1u); }
|
||
}
|
||
else { newCell = mkc(AIR, 0u); }
|
||
}
|
||
|
||
case SAND, GUN, DIRT, THERMITE: {
|
||
let nearAcid = (tU==ACID||tD==ACID||tL==ACID||tR==ACID);
|
||
if (nearAcid && (r%15u==0u)) {
|
||
newCell = select(mkc(AIR,0u), mkc(STEAM,50u), r%2u==0u);
|
||
} else if (hasD && tD == AIR) {
|
||
newCell = mkc(AIR, 0u);
|
||
} else if (hasD && isLiquid(tD)) {
|
||
newCell = readAt(x, y+1u);
|
||
} else if (hasDL && tDL == AIR && frameParity == 0u) {
|
||
if (myT == DIRT && (p.frame%4u!=0u)) { newCell = me; } else { newCell = mkc(AIR, 0u); }
|
||
} else if (hasDR && tDR == AIR && frameParity == 1u) {
|
||
if (myT == DIRT && (p.frame%4u!=1u)) { newCell = me; } else { newCell = mkc(AIR, 0u); }
|
||
} else {
|
||
newCell = me;
|
||
}
|
||
|
||
if (myT == GUN) {
|
||
let nearFire = (tU==FIRE||tD==FIRE||tL==FIRE||tR==FIRE||tU==LAVA||tD==LAVA||tL==LAVA||tR==LAVA);
|
||
if (nearFire) {
|
||
newCell = mkc(FIRE, 600u);
|
||
if(hasU) { wGrid[cidx(x,y-1u)] = mkc(FIRE, 580u); }
|
||
if(hasD) { wGrid[cidx(x,y+1u)] = mkc(FIRE, 580u); }
|
||
if(hasL) { wGrid[cidx(x-1u,y)] = mkc(FIRE, 580u); }
|
||
if(hasR) { wGrid[cidx(x+1u,y)] = mkc(FIRE, 580u); }
|
||
}
|
||
}
|
||
else if (myT == THERMITE) {
|
||
let nearFire = (tU==FIRE||tD==FIRE||tL==FIRE||tR==FIRE||tU==LAVA||tD==LAVA||tL==LAVA||tR==LAVA);
|
||
if (nearFire) {
|
||
newCell = mkc(FIRE, 800u); // 极高温度触发融化
|
||
}
|
||
}
|
||
}
|
||
|
||
case WATER, OIL, ACID, LAVA: {
|
||
var baseFlow: u32 = me;
|
||
if (myT == LAVA) {
|
||
let nearWater = (tU==WATER||tD==WATER||tL==WATER||tR==WATER||tUL==WATER||tUR==WATER);
|
||
let nearIce = (tU==ICE||tD==ICE||tL==ICE||tR==ICE||tUL==ICE||tUR==ICE);
|
||
if ((nearWater || nearIce) && (r%4u==0u)) { wGrid[tidx] = mkc(STONE, 0u); return; }
|
||
}
|
||
else if (myT == ACID && (isCorrodible(tU)||isCorrodible(tD)||isCorrodible(tL)||isCorrodible(tR)) && (r%15u==0u)) {
|
||
baseFlow = mkc(AIR, 0u);
|
||
}
|
||
|
||
if (baseFlow == me) {
|
||
if (hasU && isPowder(tU)) {
|
||
baseFlow = readAt(x, y-1u);
|
||
} else if (myT == WATER && hasD && tD == OIL) {
|
||
// 水更重,下沉穿过油层
|
||
baseFlow = readAt(x, y+1u);
|
||
} else if (myT == OIL && hasU && tU == WATER) {
|
||
// 油更轻,上浮穿过水层
|
||
baseFlow = readAt(x, y-1u);
|
||
} else if (hasD && tD == AIR) {
|
||
baseFlow = mkc(AIR, 0u);
|
||
} else if (hasDL && tDL == AIR && frameParity == 0u) {
|
||
baseFlow = mkc(AIR, 0u);
|
||
} else if (hasDR && tDR == AIR && frameParity == 1u) {
|
||
baseFlow = mkc(AIR, 0u);
|
||
} else {
|
||
// 流体找平:放宽触发概率,避免积液僵死
|
||
let lateral = p.liquidSpread;
|
||
let goL1 = hasL && tL == AIR && typeAt(x-1u, y+1u) != AIR && (r % 100u) < lateral;
|
||
let goL2 = x > 1u && tL == AIR && typeAt(x-2u, y) == AIR && typeAt(x-2u, y+1u) != AIR && (r % 100u) < lateral;
|
||
let goL3 = x > 2u && tL == AIR && typeAt(x-2u, y) == AIR && typeAt(x-3u, y) == AIR && typeAt(x-3u, y+1u) != AIR && (r % 100u) < lateral;
|
||
|
||
let goR1 = hasR && tR == AIR && typeAt(x+1u, y+1u) != AIR && (r % 100u) < lateral;
|
||
let goR2 = x+2u < GW && tR == AIR && typeAt(x+2u, y) == AIR && typeAt(x+2u, y+1u) != AIR && (r % 100u) < lateral;
|
||
let goR3 = x+3u < GW && tR == AIR && typeAt(x+2u, y) == AIR && typeAt(x+3u, y) == AIR && typeAt(x+3u, y+1u) != AIR && (r % 100u) < lateral;
|
||
|
||
if (goL1 || goL2 || goL3 || goR1 || goR2 || goR3) { baseFlow = mkc(AIR, 0u); }
|
||
}
|
||
}
|
||
|
||
newCell = baseFlow;
|
||
|
||
if (newCell == me) {
|
||
if (myT == WATER) {
|
||
let nearFire = (tU==FIRE||tD==FIRE||tL==FIRE||tR==FIRE||tU==LAVA||tD==LAVA||tL==LAVA||tR==LAVA);
|
||
if (nearFire && (r % 6u == 0u)) {
|
||
newCell = mkc(STEAM, 120u + (r % 60u));
|
||
} else {
|
||
let nearIce = (tU==ICE||tD==ICE||tL==ICE||tR==ICE);
|
||
if (nearIce && (r % 20u == 0u)) { newCell = mkc(ICE, 0u); }
|
||
}
|
||
}
|
||
else if (myT == OIL) {
|
||
let nearFire = (tU==FIRE||tD==FIRE||tL==FIRE||tR==FIRE||tU==LAVA||tD==LAVA||tL==LAVA||tR==LAVA);
|
||
if (nearFire) {
|
||
newCell = mkc(FIRE, 150u + (r % 50u));
|
||
if(hasU) { wGrid[cidx(x,y-1u)] = mkc(FIRE,150u); }
|
||
if(hasL) { wGrid[cidx(x-1u,y)] = mkc(FIRE,150u); }
|
||
if(hasR) { wGrid[cidx(x+1u,y)] = mkc(FIRE,150u); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
case STONE, WOOD, PLANT, ICE, METAL, GLASS, C4: {
|
||
let nearAcid = (tU==ACID||tD==ACID||tL==ACID||tR==ACID);
|
||
let nearFire = (tU==FIRE||tD==FIRE||tL==FIRE||tR==FIRE||tU==LAVA||tD==LAVA||tL==LAVA||tR==LAVA);
|
||
|
||
if (myT == C4) {
|
||
if (nearFire) { newCell = mkc(FIRE, 1000u); } // C4 起爆
|
||
else { newCell = me; }
|
||
}
|
||
else if (myT == METAL || myT == GLASS) {
|
||
newCell = me;
|
||
}
|
||
else if (myT != ICE && nearAcid && (r%15u==0u)) {
|
||
newCell = select(mkc(AIR,0u), mkc(STEAM,50u), r%2u==0u);
|
||
}
|
||
else if (myT == WOOD) {
|
||
if (nearFire && (r % 12u == 0u)) { newCell = mkc(FIRE, 80u + (r % 60u)); }
|
||
else { newCell = me; }
|
||
}
|
||
else if (myT == PLANT) {
|
||
if (nearFire && (r % 6u == 0u)) { newCell = mkc(FIRE, 120u + (r % 50u)); }
|
||
else {
|
||
let nearWater = (tU==WATER||tD==WATER||tL==WATER||tR==WATER||tUL==WATER||tUR==WATER||tDL==WATER||tDR==WATER);
|
||
let nearDirt = (tU==DIRT||tD==DIRT||tL==DIRT||tR==DIRT||tUL==DIRT||tUR==DIRT||tDL==DIRT||tDR==DIRT);
|
||
|
||
var growChance = 0u;
|
||
if (nearWater) { growChance = 60u; }
|
||
if (nearWater && nearDirt) { growChance = 20u; }
|
||
|
||
if (growChance > 0u && (r % growChance == 0u)) {
|
||
newCell = me;
|
||
let dir = (r/10u) % 4u;
|
||
if (dir == 0u && hasU && tU == AIR) { wGrid[cidx(x,y-1u)] = mkc(PLANT, 0u); }
|
||
else if (dir == 1u && hasL && tL == AIR) { wGrid[cidx(x-1u,y)] = mkc(PLANT, 0u); }
|
||
else if (dir == 2u && hasR && tR == AIR) { wGrid[cidx(x+1u,y)] = mkc(PLANT, 0u); }
|
||
else if (dir == 3u && hasD && tD == AIR) { wGrid[cidx(x,y+1u)] = mkc(PLANT, 0u); }
|
||
|
||
if (r % 5u == 0u) {
|
||
if (tU == WATER) { wGrid[cidx(x,y-1u)] = mkc(AIR, 0u); }
|
||
else if (tD == WATER) { wGrid[cidx(x,y+1u)] = mkc(AIR, 0u); }
|
||
else if (tL == WATER) { wGrid[cidx(x-1u,y)] = mkc(AIR, 0u); }
|
||
else if (tR == WATER) { wGrid[cidx(x+1u,y)] = mkc(AIR, 0u); }
|
||
}
|
||
} else {
|
||
newCell = me;
|
||
}
|
||
}
|
||
}
|
||
else if (myT == ICE) {
|
||
if (nearFire && (r%8u == 0u)) { newCell = mkc(WATER, 0u); }
|
||
else { newCell = me; }
|
||
}
|
||
else {
|
||
newCell = me; // STONE
|
||
}
|
||
}
|
||
|
||
case FIRE: {
|
||
if (myL == 0u) { newCell = mkc(AIR, 0u); }
|
||
else if (myL > 500u) {
|
||
// 爆炸或高温状态扩散 (C4 / 铝热剂)
|
||
let decay = 5u + (r % 10u);
|
||
let nl = select(0u, myL - decay, myL > decay);
|
||
newCell = mkc(FIRE, nl);
|
||
|
||
if (nl > 520u) {
|
||
let c4 = myL > 900u;
|
||
let th = myL > 700u && myL <= 900u;
|
||
let pwr = p.blastPower;
|
||
if (hasU) { applyExplosion(cidx(x,y-1u), tU, nl, c4, th, r, pwr); }
|
||
if (hasD) { applyExplosion(cidx(x,y+1u), tD, nl, c4, th, r+1u, pwr); }
|
||
if (hasL) { applyExplosion(cidx(x-1u,y), tL, nl, c4, th, r+2u, pwr); }
|
||
if (hasR) { applyExplosion(cidx(x+1u,y), tR, nl, c4, th, r+3u, pwr); }
|
||
|
||
let pwrOuter = select(0u, pwr - 8u, pwr > 8u);
|
||
if (hasUL) { applyExplosion(cidx(x-1u,y-1u), tUL, nl-5u, c4, th, r+4u, pwrOuter); }
|
||
if (hasUR) { applyExplosion(cidx(x+1u,y-1u), tUR, nl-5u, c4, th, r+5u, pwrOuter); }
|
||
if (hasDL) { applyExplosion(cidx(x-1u,y+1u), tDL, nl-5u, c4, th, r+6u, pwrOuter); }
|
||
if (hasDR) { applyExplosion(cidx(x+1u,y+1u), tDR, nl-5u, c4, th, r+7u, pwrOuter); }
|
||
|
||
// 第二圈冲击波(强化 C4 对固体破坏)
|
||
if (c4 && pwr > 45u) {
|
||
let pwrFar = select(0u, pwr - 15u, pwr > 15u);
|
||
if (y > 1u) { applyExplosion(cidx(x, y-2u), typeAt(x, y-2u), nl-12u, true, false, r+8u, pwrFar); }
|
||
if (y + 2u < GH) { applyExplosion(cidx(x, y+2u), typeAt(x, y+2u), nl-12u, true, false, r+9u, pwrFar); }
|
||
if (x > 1u) { applyExplosion(cidx(x-2u, y), typeAt(x-2u, y), nl-12u, true, false, r+10u, pwrFar); }
|
||
if (x + 2u < GW) { applyExplosion(cidx(x+2u, y), typeAt(x+2u, y), nl-12u, true, false, r+11u, pwrFar); }
|
||
}
|
||
} else if (nl > 0u && nl <= 520u && r % 3u == 0u) {
|
||
newCell = mkc(STEAM, 150u + (r%50u)); // 爆炸转烟雾
|
||
}
|
||
}
|
||
else {
|
||
let decay = 1u + (r % 2u);
|
||
let nl = select(0u, myL - decay, myL >= decay);
|
||
if (hasU && (tU == AIR || tU == STEAM) && (p.frame % 3u != 0u)) { newCell = mkc(AIR, 0u); }
|
||
else { newCell = mkc(FIRE, nl); }
|
||
}
|
||
}
|
||
|
||
case STEAM, GAS: {
|
||
if (myL == 0u && myT == STEAM) { newCell = mkc(AIR, 0u); }
|
||
else if (myT == GAS && (tU == FIRE || tD == FIRE || tL == FIRE || tR == FIRE || tU == LAVA || tD == LAVA || tL == LAVA || tR == LAVA || tU == THERMITE || tD == THERMITE || tL == THERMITE || tR == THERMITE)) {
|
||
newCell = mkc(FIRE, 600u); // 燃气爆炸
|
||
}
|
||
else {
|
||
let decay = select(1u, 2u, (r % 100u) < (100u - p.gasDrift));
|
||
let nl = select(0u, myL - decay, myL > decay);
|
||
let is_steam = myT == STEAM;
|
||
let nearIce = (tU==ICE||tD==ICE||tL==ICE||tR==ICE);
|
||
if (is_steam && (y <= 2u || nearIce)) {
|
||
if (r % 10u == 0u || nearIce) { newCell = mkc(WATER, 0u); } else { newCell = mkc(STEAM, nl); }
|
||
} else {
|
||
if (hasU && tU == AIR && (r % 100u) < p.gasDrift) { newCell = mkc(AIR, 0u); }
|
||
else if (hasUL && tUL == AIR && tU != AIR && (r % 100u) < (p.gasDrift / 2u)) { newCell = mkc(AIR, 0u); }
|
||
else if (hasUR && tUR == AIR && tU != AIR && (r % 100u) < (p.gasDrift / 2u)) { newCell = mkc(AIR, 0u); }
|
||
else if (hasL && tL == AIR && r%3u==0u) { newCell = mkc(AIR, 0u); }
|
||
else if (hasR && tR == AIR && r%3u==1u) { newCell = mkc(AIR, 0u); }
|
||
else { newCell = mkc(myT, select(myL, nl, is_steam)); }
|
||
}
|
||
}
|
||
}
|
||
|
||
default: { newCell = me; }
|
||
}
|
||
|
||
wGrid[tidx] = newCell;
|
||
}
|
||
`
|
||
|
||
// ==========================================
|
||
// Render Shader:元素 → 像素颜色
|
||
// ==========================================
|
||
const renderShader = `
|
||
const GW: u32 = ${GRID_W}u;
|
||
const CELL: u32 = ${CELL_PX}u;
|
||
|
||
const AIR: u32 = 0u;
|
||
const SAND: u32 = 1u;
|
||
const WATER: u32 = 2u;
|
||
const STONE: u32 = 3u;
|
||
const FIRE: u32 = 4u;
|
||
const WOOD: u32 = 5u;
|
||
const GUN: u32 = 6u;
|
||
const STEAM: u32 = 7u;
|
||
const LAVA: u32 = 8u;
|
||
const DIRT: u32 = 9u;
|
||
const PLANT: u32 = 10u;
|
||
const OIL: u32 = 11u;
|
||
const ACID: u32 = 12u;
|
||
const ICE: u32 = 13u;
|
||
const METAL: u32 = 14u;
|
||
const GLASS: u32 = 15u;
|
||
const C4: u32 = 16u;
|
||
const THERMITE: u32 = 17u;
|
||
const GAS: u32 = 18u;
|
||
|
||
@group(0) @binding(0) var<storage, read> grid: array<u32>;
|
||
@group(0) @binding(1) var<uniform> frame: u32;
|
||
|
||
fn hash(v: u32) -> u32 {
|
||
var x = v ^ (v >> 16u);
|
||
x = x * 0x45d9f3bu;
|
||
x = x ^ (x >> 16u);
|
||
return x;
|
||
}
|
||
|
||
struct VOut {
|
||
@builtin(position) pos: vec4<f32>,
|
||
@location(0) uv: vec2<f32>,
|
||
};
|
||
|
||
@vertex
|
||
fn vs_main(@builtin(vertex_index) vi: u32) -> VOut {
|
||
var pos = array<vec2<f32>, 6>(
|
||
vec2(-1.0,-1.0), vec2(1.0,-1.0), vec2(-1.0,1.0),
|
||
vec2(-1.0, 1.0), vec2(1.0,-1.0), vec2( 1.0,1.0)
|
||
);
|
||
var uv = array<vec2<f32>, 6>(
|
||
vec2(0.0,1.0), vec2(1.0,1.0), vec2(0.0,0.0),
|
||
vec2(0.0,0.0), vec2(1.0,1.0), vec2(1.0,0.0)
|
||
);
|
||
var out: VOut;
|
||
out.pos = vec4<f32>(pos[vi], 0.0, 1.0);
|
||
out.uv = uv[vi];
|
||
return out;
|
||
}
|
||
|
||
@fragment
|
||
fn fs_main(in: VOut) -> @location(0) vec4<f32> {
|
||
let px = u32(in.uv.x * f32(GW * CELL));
|
||
let py = u32(in.uv.y * f32(${GRID_H}u * CELL));
|
||
let gx = px / CELL;
|
||
let gy = py / CELL;
|
||
if (gx >= GW || gy >= ${GRID_H}u) { return vec4<f32>(0.0,0.0,0.0,1.0); }
|
||
|
||
let c = grid[gy * GW + gx];
|
||
let t = c >> 16u;
|
||
let l = c & 0xFFFFu;
|
||
let r = hash(gx * 374761u + gy * 8191u + frame * 2654435u);
|
||
let rn = f32(r % 100u) / 100.0;
|
||
let lf = f32(l) / 200.0;
|
||
|
||
var col: vec3<f32>;
|
||
|
||
switch t {
|
||
case AIR: { col = vec3<f32>(0.04, 0.04, 0.07); }
|
||
case SAND: {
|
||
let shade = 0.75 + rn * 0.25;
|
||
col = vec3<f32>(0.84 * shade, 0.72 * shade, 0.38 * shade);
|
||
}
|
||
case WATER: {
|
||
let wave = sin(f32(gx) * 0.3 + f32(frame) * 0.12) * 0.05;
|
||
col = vec3<f32>(0.12, 0.45 + wave, 0.85);
|
||
}
|
||
case STONE: {
|
||
let shade = 0.4 + rn * 0.2;
|
||
col = vec3<f32>(shade, shade, shade + 0.02);
|
||
}
|
||
case FIRE: {
|
||
let t0 = clamp(lf, 0.0, 1.0);
|
||
let flicker = rn * 0.15;
|
||
if (l > 500u) {
|
||
col = vec3<f32>(1.0, 0.9 + flicker, 0.7 + flicker); // 爆炸高光
|
||
} else {
|
||
let r1 = min(1.0, 0.9 + flicker);
|
||
let g1 = clamp(t0 * 0.7 + flicker * 0.5, 0.0, 1.0);
|
||
let b1 = clamp((t0 - 0.5) * 0.6, 0.0, 0.4);
|
||
col = vec3<f32>(r1, g1, b1);
|
||
}
|
||
}
|
||
case WOOD: {
|
||
let shade = 0.55 + rn * 0.1;
|
||
col = vec3<f32>(0.5 * shade, 0.28 * shade, 0.10 * shade);
|
||
}
|
||
case GUN: {
|
||
let shade = 0.6 + rn * 0.15;
|
||
col = vec3<f32>(0.28 * shade, 0.24 * shade, 0.18 * shade);
|
||
}
|
||
case STEAM: {
|
||
let alpha = clamp(f32(l) / 150.0, 0.0, 1.0);
|
||
let base = vec3<f32>(0.6 + rn*0.2, 0.65 + rn*0.15, 0.7 + rn*0.1);
|
||
col = mix(vec3<f32>(0.04,0.04,0.07), base, vec3<f32>(alpha));
|
||
}
|
||
case LAVA: {
|
||
let pulse = sin(f32(frame) * 0.08 + f32(gx + gy) * 0.3) * 0.1 + 0.9;
|
||
col = vec3<f32>(pulse, 0.15 + rn*0.1, 0.0);
|
||
}
|
||
case DIRT: {
|
||
let shade = 0.6 + rn * 0.2;
|
||
col = vec3<f32>(0.42 * shade, 0.33 * shade, 0.16 * shade);
|
||
}
|
||
case PLANT: {
|
||
let shade = 0.7 + rn * 0.3;
|
||
col = vec3<f32>(0.23 * shade, 0.55 * shade, 0.18 * shade);
|
||
}
|
||
case OIL: {
|
||
let shade = 0.8 + rn * 0.2;
|
||
col = vec3<f32>(0.2 * shade, 0.2 * shade, 0.2 * shade);
|
||
}
|
||
case ACID: {
|
||
let pulse = sin(f32(frame) * 0.1 + f32(gx) * 0.5) * 0.1 + 0.9;
|
||
col = vec3<f32>(0.5 * pulse, 1.0 * pulse, 0.0);
|
||
}
|
||
case ICE: {
|
||
let shade = 0.85 + rn * 0.15;
|
||
col = vec3<f32>(0.7 * shade, 0.9 * shade, 1.0 * shade);
|
||
}
|
||
case METAL: {
|
||
let shade = 0.5 + rn * 0.2;
|
||
col = vec3<f32>(0.53 * shade, 0.57 * shade, 0.62 * shade);
|
||
}
|
||
case GLASS: {
|
||
let alpha = 0.4 + rn * 0.1;
|
||
let highlight = select(0.0, 0.3, (gx - gy + frame/10u) % 30u < 2u);
|
||
let base = vec3<f32>(0.66, 0.82, 0.90) + vec3<f32>(highlight);
|
||
col = mix(vec3<f32>(0.04, 0.04, 0.07), base, vec3<f32>(alpha));
|
||
}
|
||
case C4: {
|
||
let shade = 0.8 + rn * 0.1;
|
||
col = vec3<f32>(0.54 * shade, 0.15 * shade, 0.15 * shade);
|
||
}
|
||
case THERMITE: {
|
||
let shade = 0.8 + rn * 0.2;
|
||
col = vec3<f32>(0.56 * shade, 0.25 * shade, 0.20 * shade);
|
||
}
|
||
case GAS: {
|
||
let alpha = 0.5 + rn * 0.2;
|
||
let base = vec3<f32>(0.83, 0.83, 0.66);
|
||
col = mix(vec3<f32>(0.04,0.04,0.07), base, vec3<f32>(alpha));
|
||
}
|
||
default: { col = vec3<f32>(1.0, 0.0, 1.0); }
|
||
}
|
||
|
||
return vec4<f32>(col, 1.0);
|
||
}
|
||
`
|
||
|
||
function showErr(msg) {
|
||
console.error(msg);
|
||
const el = document.getElementById('gpu-error-fire');
|
||
if (el) {
|
||
el.textContent = msg;
|
||
el.style.display = 'block';
|
||
}
|
||
const canvas = document.getElementById('fireCanvas');
|
||
if (canvas) canvas.style.display = 'none';
|
||
}
|
||
|
||
let isInitialized = false;
|
||
|
||
async function init() {
|
||
if (isInitialized) return;
|
||
isInitialized = true;
|
||
|
||
if (!navigator.gpu) { showErr('浏览器不支持 WebGPU,请使用最新版 Chrome/Edge 并开启硬件加速。'); return; }
|
||
|
||
const canvas = document.getElementById('fireCanvas');
|
||
if (!canvas) { showErr('Canvas not found.'); return; }
|
||
canvas.width = GRID_W * CELL_PX;
|
||
canvas.height = GRID_H * CELL_PX;
|
||
|
||
const adapter = await navigator.gpu.requestAdapter();
|
||
if (!adapter) { showErr('无法获取 WebGPU Adapter,请检查显卡驱动。'); return; }
|
||
|
||
const device = await adapter.requestDevice();
|
||
const ctx = canvas.getContext('webgpu');
|
||
const fmt = navigator.gpu.getPreferredCanvasFormat();
|
||
ctx.configure({ device, format: fmt, alphaMode: 'opaque' });
|
||
|
||
const gridBytes = GRID_W * GRID_H * 4;
|
||
const initData = new Uint32Array(GRID_W * GRID_H);
|
||
initData.fill(0);
|
||
|
||
const gridA = device.createBuffer({
|
||
size: gridBytes,
|
||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
||
mappedAtCreation: true,
|
||
});
|
||
new Uint32Array(gridA.getMappedRange()).set(initData);
|
||
gridA.unmap();
|
||
|
||
const gridB = device.createBuffer({
|
||
size: gridBytes,
|
||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
||
});
|
||
|
||
const paramsBuffer = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||
const frameBuffer = device.createBuffer({ size: 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||
|
||
const computeModule = device.createShaderModule({ code: computeShader });
|
||
const renderModule = device.createShaderModule({ code: renderShader });
|
||
|
||
const computePipeline = device.createComputePipeline({
|
||
layout: 'auto',
|
||
compute: { module: computeModule, entryPoint: 'main' },
|
||
});
|
||
|
||
function makeComputeBG(read, write) {
|
||
return device.createBindGroup({
|
||
layout: computePipeline.getBindGroupLayout(0),
|
||
entries: [
|
||
{ binding: 0, resource: { buffer: read } },
|
||
{ binding: 1, resource: { buffer: write } },
|
||
{ binding: 2, resource: { buffer: paramsBuffer } },
|
||
],
|
||
});
|
||
}
|
||
const bgCompute_AtoB = makeComputeBG(gridA, gridB);
|
||
const bgCompute_BtoA = makeComputeBG(gridB, gridA);
|
||
|
||
const renderPipeline = device.createRenderPipeline({
|
||
layout: 'auto',
|
||
vertex: { module: renderModule, entryPoint: 'vs_main' },
|
||
fragment: { module: renderModule, entryPoint: 'fs_main',
|
||
targets: [{ format: fmt }] },
|
||
primitive: { topology: 'triangle-list' },
|
||
});
|
||
|
||
function makeRenderBG(grid) {
|
||
return device.createBindGroup({
|
||
layout: renderPipeline.getBindGroupLayout(0),
|
||
entries: [
|
||
{ binding: 0, resource: { buffer: grid } },
|
||
{ binding: 1, resource: { buffer: frameBuffer } },
|
||
],
|
||
});
|
||
}
|
||
const bgRender_A = makeRenderBG(gridA);
|
||
const bgRender_B = makeRenderBG(gridB);
|
||
|
||
const rect = () => canvas.getBoundingClientRect();
|
||
|
||
function clientToGrid(cx, cy) {
|
||
const r = rect();
|
||
const scaleX = GRID_W / r.width;
|
||
const scaleY = GRID_H / r.height;
|
||
return [
|
||
Math.floor((cx - r.left) * scaleX),
|
||
Math.floor((cy - r.top) * scaleY),
|
||
];
|
||
}
|
||
|
||
canvas.addEventListener('mousedown', e => {
|
||
isDrawing = true;
|
||
[mouseGX, mouseGY] = clientToGrid(e.clientX, e.clientY);
|
||
e.preventDefault();
|
||
});
|
||
canvas.addEventListener('mousemove', e => {
|
||
if (isDrawing) [mouseGX, mouseGY] = clientToGrid(e.clientX, e.clientY);
|
||
});
|
||
canvas.addEventListener('mouseup', () => { isDrawing = false; });
|
||
canvas.addEventListener('mouseleave', () => { isDrawing = false; });
|
||
|
||
canvas.addEventListener('touchstart', e => {
|
||
isDrawing = true;
|
||
const t = e.touches[0];
|
||
[mouseGX, mouseGY] = clientToGrid(t.clientX, t.clientY);
|
||
e.preventDefault();
|
||
}, { passive: false });
|
||
canvas.addEventListener('touchmove', e => {
|
||
const t = e.touches[0];
|
||
[mouseGX, mouseGY] = clientToGrid(t.clientX, t.clientY);
|
||
e.preventDefault();
|
||
}, { passive: false });
|
||
canvas.addEventListener('touchend', () => { isDrawing = false; });
|
||
|
||
let needClear = false;
|
||
|
||
document.querySelectorAll('.elem-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.elem-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
brushElem = parseInt(btn.dataset.elem);
|
||
});
|
||
});
|
||
|
||
const brushSlider = document.getElementById('brushSize');
|
||
const brushLabel = document.getElementById('brushSizeLabel');
|
||
brushSlider.addEventListener('input', () => {
|
||
brushRadius = parseInt(brushSlider.value);
|
||
brushLabel.textContent = brushRadius;
|
||
});
|
||
|
||
document.getElementById('btnPause').addEventListener('click', function() {
|
||
paused = !paused;
|
||
this.textContent = paused ? '▶ 继续' : '⏸ 暂停';
|
||
});
|
||
|
||
document.getElementById('btnClear').addEventListener('click', () => { needClear = true; });
|
||
|
||
const gasDriftSlider = document.getElementById('gasDrift');
|
||
const gasDriftLabel = document.getElementById('gasDriftLabel');
|
||
gasDriftSlider.addEventListener('input', () => {
|
||
gasDrift = parseInt(gasDriftSlider.value);
|
||
gasDriftLabel.textContent = gasDrift;
|
||
});
|
||
|
||
const liqSpreadSlider = document.getElementById('liqSpread');
|
||
const liqSpreadLabel = document.getElementById('liqSpreadLabel');
|
||
liqSpreadSlider.addEventListener('input', () => {
|
||
liquidSpread = parseInt(liqSpreadSlider.value);
|
||
liqSpreadLabel.textContent = liquidSpread;
|
||
});
|
||
|
||
const blastSlider = document.getElementById('blastPower');
|
||
const blastLabel = document.getElementById('blastPowerLabel');
|
||
blastSlider.addEventListener('input', () => {
|
||
blastPower = parseInt(blastSlider.value);
|
||
blastLabel.textContent = blastPower;
|
||
});
|
||
|
||
const statsEl = document.getElementById('fire-stats');
|
||
|
||
function frame() {
|
||
let steps = paused ? (isDrawing || needClear ? 1 : 0) : SIM_STEPS;
|
||
|
||
for (let i = 0; i < steps; i++) {
|
||
const enc = device.createCommandEncoder();
|
||
|
||
const paramsData = new ArrayBuffer(64);
|
||
const paramsU32 = new Uint32Array(paramsData);
|
||
const paramsI32 = new Int32Array(paramsData);
|
||
paramsU32[0] = frameIdx;
|
||
paramsI32[1] = isDrawing ? mouseGX : -9999;
|
||
paramsI32[2] = isDrawing ? mouseGY : -9999;
|
||
paramsI32[3] = brushRadius;
|
||
paramsU32[4] = brushElem;
|
||
paramsU32[5] = isDrawing ? 1 : 0;
|
||
paramsU32[6] = needClear ? 1 : 0;
|
||
paramsU32[7] = 0;
|
||
paramsU32[8] = gasDrift;
|
||
paramsU32[9] = liquidSpread;
|
||
paramsU32[10] = blastPower;
|
||
paramsU32[11] = 0;
|
||
device.queue.writeBuffer(paramsBuffer, 0, paramsData);
|
||
needClear = false;
|
||
|
||
const bg = (frameIdx % 2 === 0) ? bgCompute_AtoB : bgCompute_BtoA;
|
||
const cp = enc.beginComputePass();
|
||
cp.setPipeline(computePipeline);
|
||
cp.setBindGroup(0, bg);
|
||
cp.dispatchWorkgroups(Math.ceil(GRID_W / 8), Math.ceil(GRID_H / 8));
|
||
cp.end();
|
||
|
||
device.queue.submit([enc.finish()]);
|
||
frameIdx++;
|
||
}
|
||
|
||
const encRender = device.createCommandEncoder();
|
||
device.queue.writeBuffer(frameBuffer, 0, new Uint32Array([Math.floor(frameIdx / SIM_STEPS)]));
|
||
|
||
const currentGrid = (frameIdx % 2 === 0) ? bgRender_A : bgRender_B;
|
||
const rp = encRender.beginRenderPass({
|
||
colorAttachments: [{
|
||
view: ctx.getCurrentTexture().createView(),
|
||
clearValue: { r: 0.04, g: 0.04, b: 0.07, a: 1.0 },
|
||
loadOp: 'clear',
|
||
storeOp: 'store',
|
||
}],
|
||
});
|
||
rp.setPipeline(renderPipeline);
|
||
rp.setBindGroup(0, currentGrid);
|
||
rp.draw(6);
|
||
rp.end();
|
||
|
||
device.queue.submit([encRender.finish()]);
|
||
|
||
fpsFrames++;
|
||
const now = performance.now();
|
||
if (now - lastFpsTime >= 1000) {
|
||
statsEl.textContent = `FPS: ${fpsFrames} 格子: ${GRID_W}×${GRID_H}`;
|
||
fpsFrames = 0;
|
||
lastFpsTime = now;
|
||
}
|
||
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
function startWebGPU() {
|
||
const canvas = document.getElementById('fireCanvas');
|
||
if (canvas) {
|
||
init();
|
||
} else {
|
||
// Retry a bit later if loaded via pjax
|
||
setTimeout(() => {
|
||
if(document.getElementById('fireCanvas')) init();
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', startWebGPU);
|
||
} else {
|
||
startWebGPU();
|
||
}
|
||
|
||
// Support for Butterfly theme Pjax
|
||
document.addEventListener('pjax:complete', () => {
|
||
if (document.getElementById('fireCanvas')) {
|
||
startWebGPU();
|
||
}
|
||
});
|
||
</script>
|
||
</div></div></main><footer id="footer" style="background-image: url(/img/040.jpg);"><div class="footer-other"><div class="footer-copyright"><span class="copyright">© 2025 - 2026 By llbzow</span><span class="framework-info"><span>框架 </span><a target="_blank" rel="noopener" href="https://hexo.io">Hexo 7.3.0</a><span class="footer-separator">|</span><span>主题 </span><a target="_blank" rel="noopener" href="https://github.com/jerryc127/hexo-theme-butterfly">Butterfly 5.5.0</a></span></div></div></footer></div><div id="rightside"><div id="rightside-config-hide"><button id="darkmode" type="button" title="日间和夜间模式切换"><i class="fas fa-adjust"></i></button></div><div id="rightside-config-show"><button id="rightside-config" type="button" title="设置"><i class="fas fa-cog fa-spin"></i></button><button id="go-up" type="button" title="回到顶部"><span class="scroll-percent"></span><i class="fas fa-arrow-up"></i></button></div></div><div><script src="/js/utils.js"></script><script src="/js/main.js"></script><script src="https://cdn.jsdelivr.net/npm/vanilla-lazyload/dist/lazyload.iife.min.js"></script><div class="js-pjax"></div><script>var OriginTitle = document.title; var titleTime; document.addEventListener('visibilitychange', function () { if (document.hidden) { document.title = '╭(°A°`)╮ 页面崩溃啦 ~'; clearTimeout(titleTime); } else { document.title = '(ฅ>ω<*ฅ) 噫又好啦 ~ ' + OriginTitle; titleTime = setTimeout(function () { document.title = OriginTitle; }, 2000); } });</script><script defer="defer" id="fluttering_ribbon" mobile="false" src="https://cdn.jsdelivr.net/npm/butterfly-extsrc/dist/canvas-fluttering-ribbon.min.js"></script><script id="canvas_nest" defer="defer" color="255,255,255" opacity="0.6" zindex="-1" count="99" mobile="false" src="https://cdn.jsdelivr.net/npm/butterfly-extsrc/dist/canvas-nest.min.js"></script><script src="https://cdn.jsdelivr.net/npm/pjax/pjax.min.js"></script><script>(() => {
|
||
const pjaxSelectors = ["head > title","#config-diff","#body-wrap","#rightside-config-hide","#rightside-config-show",".js-pjax"]
|
||
|
||
window.pjax = new Pjax({
|
||
elements: 'a:not([target="_blank"])',
|
||
selectors: pjaxSelectors,
|
||
cacheBust: false,
|
||
analytics: false,
|
||
scrollRestoration: false
|
||
})
|
||
|
||
const triggerPjaxFn = (val) => {
|
||
if (!val) return
|
||
Object.values(val).forEach(fn => {
|
||
try {
|
||
fn()
|
||
} catch (err) {
|
||
console.debug('Pjax callback failed:', err)
|
||
}
|
||
})
|
||
}
|
||
|
||
document.addEventListener('pjax:send', () => {
|
||
// removeEventListener
|
||
btf.removeGlobalFnEvent('pjaxSendOnce')
|
||
btf.removeGlobalFnEvent('themeChange')
|
||
|
||
// reset readmode
|
||
const $bodyClassList = document.body.classList
|
||
if ($bodyClassList.contains('read-mode')) $bodyClassList.remove('read-mode')
|
||
|
||
triggerPjaxFn(window.globalFn.pjaxSend)
|
||
})
|
||
|
||
document.addEventListener('pjax:complete', () => {
|
||
btf.removeGlobalFnEvent('pjaxCompleteOnce')
|
||
document.querySelectorAll('script[data-pjax]').forEach(item => {
|
||
const newScript = document.createElement('script')
|
||
const content = item.text || item.textContent || item.innerHTML || ""
|
||
Array.from(item.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value))
|
||
newScript.appendChild(document.createTextNode(content))
|
||
item.parentNode.replaceChild(newScript, item)
|
||
})
|
||
|
||
triggerPjaxFn(window.globalFn.pjaxComplete)
|
||
})
|
||
|
||
document.addEventListener('pjax:error', e => {
|
||
if (e.request.status === 404) {
|
||
const usePjax = true
|
||
false
|
||
? (usePjax ? pjax.loadUrl('/404.html') : window.location.href = '/404.html')
|
||
: window.location.href = e.request.responseURL
|
||
}
|
||
})
|
||
})()</script><script async="" data-pjax="" src="//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js"></script></div><!-- hexo injector body_end start -->
|
||
<div id="dev-sidebar">
|
||
<div id="dev-toggle">💻</div>
|
||
<div id="dev-list">
|
||
<div class="dev-header">开发专栏</div>
|
||
<a href="/tools/sort/" class="dev-item">📊 排序算法演示</a>
|
||
<a href="/tools/webgpu-gravity/" class="dev-item">🌌 WebGPU 万有引力粒子</a>
|
||
<a href="/tools/yolo-detect/" class="dev-item">👁️ YOLO 实时目标检测</a>
|
||
<a href="/tools/fire-creation/" class="dev-item">🔥 火之创造 (元素沙盒)</a>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
#dev-sidebar {
|
||
position: fixed;
|
||
right: -150px; /* Width of the list */
|
||
top: calc(30vh + 80px); /* Positioned below game-sidebar */
|
||
z-index: 9999;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
transition: right 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Lato, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
}
|
||
|
||
/* Hover area to keep it open */
|
||
#dev-sidebar:hover {
|
||
right: 0;
|
||
}
|
||
|
||
#dev-toggle {
|
||
width: 40px;
|
||
height: 40px;
|
||
background: #52c41a; /* Green color for dev theme */
|
||
color: white;
|
||
text-align: center;
|
||
line-height: 40px;
|
||
border-radius: 8px 0 0 8px;
|
||
cursor: pointer;
|
||
box-shadow: -2px 2px 8px rgba(0,0,0,0.15);
|
||
font-size: 20px;
|
||
position: absolute;
|
||
left: -40px;
|
||
top: 0;
|
||
}
|
||
|
||
#dev-list {
|
||
width: 150px; /* Width for text */
|
||
background: rgba(255, 255, 255, 0.95);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
border-radius: 0 0 0 8px; /* Bottom left rounded */
|
||
box-shadow: -2px 2px 10px rgba(0,0,0,0.1);
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-height: 50vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* 自定义滚动条样式 */
|
||
#dev-list::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
#dev-list::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
#dev-list::-webkit-scrollbar-thumb {
|
||
background: rgba(82, 196, 26, 0.3);
|
||
border-radius: 4px;
|
||
}
|
||
#dev-list::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(82, 196, 26, 0.6);
|
||
}
|
||
|
||
.dev-header {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
padding: 10px;
|
||
background: #f6ffed;
|
||
color: #52c41a;
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
text-align: center;
|
||
border-bottom: 1px solid #eee;
|
||
}
|
||
|
||
.dev-item {
|
||
display: block;
|
||
padding: 12px 15px;
|
||
color: #4c4948;
|
||
text-decoration: none !important;
|
||
font-size: 13px;
|
||
transition: all 0.2s;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.dev-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.dev-item:hover {
|
||
background: #52c41a;
|
||
color: white !important;
|
||
}
|
||
</style>
|
||
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
var toolSidebar = document.getElementById('tool-sidebar');
|
||
var gameSidebar = document.getElementById('game-sidebar');
|
||
var devSidebar = document.getElementById('dev-sidebar');
|
||
|
||
if(devSidebar) {
|
||
devSidebar.addEventListener('mouseenter', function() {
|
||
if(toolSidebar) toolSidebar.style.right = '-220px'; // 180px + 40px(按钮宽度)
|
||
if(gameSidebar) gameSidebar.style.right = '-190px'; // 150px + 40px(按钮宽度)
|
||
});
|
||
devSidebar.addEventListener('mouseleave', function() {
|
||
if(toolSidebar) toolSidebar.style.right = '';
|
||
if(gameSidebar) gameSidebar.style.right = '';
|
||
});
|
||
}
|
||
});
|
||
</script>
|
||
<style id="oml2d-force-styles">
|
||
/* ==========================================================================
|
||
1. STYLES
|
||
========================================================================== */
|
||
.oml2d-menus .oml2d-menu-item,
|
||
.oml2d-menu-item {
|
||
background-color: #f0f0f0 !important;
|
||
border: 1px solid #ccc !important;
|
||
}
|
||
.oml2d-menus svg, .oml2d-menus path,
|
||
.oml2d-menu-item svg, .oml2d-menu-item path,
|
||
.oml2d-icon, .oml2d-icon svg, .oml2d-icon path {
|
||
fill: #333333 !important;
|
||
stroke: #333333 !important;
|
||
color: #333333 !important;
|
||
opacity: 1 !important;
|
||
}
|
||
.oml2d-menu-item:hover {
|
||
background-color: #ffffff !important;
|
||
transform: scale(1.1);
|
||
}
|
||
.oml2d-tips {
|
||
background-color: rgba(255, 255, 255, 0.95) !important;
|
||
backdrop-filter: blur(5px) !important;
|
||
-webkit-backdrop-filter: blur(5px) !important;
|
||
color: #333333 !important;
|
||
border: 1px solid rgba(0,0,0,0.1) !important;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.1) !important;
|
||
font-family: "Microsoft YaHei", sans-serif !important;
|
||
z-index: 9999 !important;
|
||
min-height: 40px;
|
||
height: auto !important;
|
||
max-height: 50vh !important;
|
||
overflow-y: auto !important;
|
||
width: auto !important;
|
||
max-width: 350px !important;
|
||
word-wrap: break-word !important;
|
||
white-space: pre-wrap !important;
|
||
display: block !important;
|
||
padding: 12px 15px;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
border-radius: 8px;
|
||
scrollbar-width: thin;
|
||
text-align: left;
|
||
}
|
||
|
||
#live2d-widget, .waifu { display: none !important; }
|
||
|
||
/* ==========================================================================
|
||
2. NEW UI COMPONENTS (Chat)
|
||
========================================================================== */
|
||
#oml2d-chat-widget {
|
||
position: fixed;
|
||
left: 10px;
|
||
bottom: 10px;
|
||
z-index: 10000;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-direction: row;
|
||
}
|
||
|
||
#oml2d-toggle-btn {
|
||
width: 42px;
|
||
height: 42px;
|
||
background: white;
|
||
border-radius: 50%;
|
||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
font-size: 22px;
|
||
transition: transform 0.2s, background 0.2s;
|
||
border: 1px solid #ddd;
|
||
user-select: none;
|
||
color: #555;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#oml2d-toggle-btn:hover {
|
||
transform: scale(1.1);
|
||
background: #f8f8f8;
|
||
color: #000;
|
||
}
|
||
|
||
#oml2d-input-area {
|
||
width: 260px;
|
||
background: rgba(255, 255, 255, 0.15);
|
||
padding: 10px;
|
||
border-radius: 12px;
|
||
border-bottom-left-radius: 4px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||
backdrop-filter: blur(3px);
|
||
display: flex;
|
||
gap: 8px;
|
||
transform-origin: left center;
|
||
transition: all 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28);
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
margin-left: 2px;
|
||
}
|
||
|
||
#oml2d-input-area.hidden {
|
||
opacity: 0;
|
||
transform: scale(0.8) translateX(-20px);
|
||
pointer-events: none;
|
||
}
|
||
|
||
#oml2d-chat-input {
|
||
flex: 1;
|
||
border: 1px solid #eee;
|
||
background: #fafafa;
|
||
padding: 6px 12px;
|
||
border-radius: 20px;
|
||
outline: none;
|
||
font-size: 14px;
|
||
color: #333;
|
||
transition: border-color 0.2s;
|
||
}
|
||
|
||
#oml2d-chat-input:focus {
|
||
border-color: #4A90E2;
|
||
background: white;
|
||
}
|
||
|
||
#oml2d-send-btn-inner {
|
||
border: none;
|
||
background: #4A90E2;
|
||
border-radius: 50%;
|
||
width: 32px;
|
||
height: 32px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: background 0.2s;
|
||
color: #fff;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#oml2d-send-btn-inner:hover {
|
||
background: #357ABD;
|
||
}
|
||
|
||
#oml2d-reply-area {
|
||
position: absolute;
|
||
bottom: 60px;
|
||
left: 50px;
|
||
width: 300px;
|
||
max-height: 60vh;
|
||
background: rgba(255, 255, 255, 0.15);
|
||
border: 1px solid rgba(255,255,255,0.2);
|
||
border-radius: 12px;
|
||
border-bottom-left-radius: 4px;
|
||
box-shadow: 0 -4px 20px rgba(0,0,0,0.05);
|
||
padding: 15px;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
color: #000;
|
||
text-shadow: 0 0 4px #fff, 0 0 8px #fff;
|
||
overflow-y: auto;
|
||
backdrop-filter: blur(3px);
|
||
transform-origin: bottom left;
|
||
transition: all 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28);
|
||
opacity: 0;
|
||
transform: scale(0.9) translateY(10px);
|
||
pointer-events: none;
|
||
z-index: 10001;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
font-family: "Microsoft YaHei", sans-serif;
|
||
scrollbar-width: thin;
|
||
}
|
||
|
||
#oml2d-reply-area.visible {
|
||
opacity: 1;
|
||
transform: scale(1) translateY(0);
|
||
pointer-events: auto;
|
||
}
|
||
</style>
|
||
|
||
<div id="oml2d-chat-widget">
|
||
<div id="oml2d-toggle-btn" title="AI对话">💬</div>
|
||
|
||
<div id="oml2d-reply-area"></div>
|
||
|
||
<div id="oml2d-input-area" class="hidden">
|
||
<input type="text" id="oml2d-chat-input" placeholder="和宁宁聊聊天..." autocomplete="off">
|
||
<button id="oml2d-send-btn-inner">➤</button>
|
||
</div>
|
||
</div>
|
||
|
||
<script type="module">
|
||
import { loadOml2d } from 'https://unpkg.com/oh-my-live2d@latest?module';
|
||
|
||
// API Key 经 AES-256-GCM + PBKDF2 加密,运行时通过 Web Crypto API 解密
|
||
// TODO: 后端 /api/deepseek-proxy 暂时废弃,当前直连 DeepSeek API
|
||
// 等有靠谱服务器后,改为走代理以彻底隐藏密钥
|
||
const _KEY_CFG = {
|
||
"c": "5TEu0TMcrtykAmhc5t19FmmLQUFnHYMlP3OGOQq3PG5VbOAZlSG/Fsih/WNX410IVsL+",
|
||
"i": "U11n8lyOCpuDQnmZ",
|
||
"s": "O1tKalohLtvdx8ZtMVN21A==",
|
||
"h": "luozili.work",
|
||
"n": 200000
|
||
};
|
||
|
||
const API_URL = "https://api.deepseek.com/chat/completions";
|
||
|
||
let API_KEY = null;
|
||
(async function initApiKey() {
|
||
const passphrase = ['llbzow', 'blog', 'butterfly', 'ningning', _KEY_CFG.h].join('\0');
|
||
const enc = new TextEncoder();
|
||
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']);
|
||
const salt = Uint8Array.from(atob(_KEY_CFG.s), c => c.charCodeAt(0));
|
||
const derived = await crypto.subtle.deriveKey(
|
||
{ name: 'PBKDF2', salt, iterations: _KEY_CFG.n, hash: 'SHA-512' },
|
||
keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['decrypt']
|
||
);
|
||
const iv = Uint8Array.from(atob(_KEY_CFG.i), c => c.charCodeAt(0));
|
||
const combined = Uint8Array.from(atob(_KEY_CFG.c), c => c.charCodeAt(0));
|
||
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, derived, combined);
|
||
API_KEY = new TextDecoder().decode(plaintext);
|
||
})().catch(e => console.error('API key init failed:', e));
|
||
|
||
// CONFIG
|
||
const CONFIG = {
|
||
// Global Stage Style
|
||
stageStyle: { width: 350, height: 500 },
|
||
models: [
|
||
{ path: '/live2d_models/ningning/model.json', scale: 0.18, position: [-50, -30] },
|
||
{ path: '/live2d_models/ningning/model_1.json', scale: 0.18, position: [-50, -30] },
|
||
{ path: '/live2d_models/ningning/model_2.json', scale: 0.18, position: [-50, -30] },
|
||
{ path: '/live2d_models/ningning/model_3.json', scale: 0.18, position: [-50, -30] },
|
||
{ path: '/live2d_models/ningning/model_4.json', scale: 0.18, position: [-50, -30] },
|
||
{ path: '/live2d_models/ningning/model_5.json', scale: 0.18, position: [-50, -30] }
|
||
],
|
||
dockedPosition: "left",
|
||
mobileDisplay: true,
|
||
menus: {
|
||
disable: false,
|
||
style: { left: '10px', bottom: '70px' }
|
||
},
|
||
tips: {
|
||
disable: false,
|
||
style: { offsetY: -70, offsetX: 10 },
|
||
idleTips: { interval: 15000, message: [] },
|
||
welcomeTips: { message: { "default": "前辈,你来啦!(。•̀ᴗ-)✧" } }
|
||
}
|
||
};
|
||
|
||
let oml2dInstance = null;
|
||
let blogIndex = null;
|
||
let chatHistory = [];
|
||
let autoHideTimer = null;
|
||
try {
|
||
const stored = localStorage.getItem('oml2d_history');
|
||
if (stored) chatHistory = JSON.parse(stored);
|
||
} catch(e) { console.error("History Load Error", e); }
|
||
|
||
let idleTimer = null;
|
||
const stopIdleLoop = () => {
|
||
if (idleTimer) clearTimeout(idleTimer);
|
||
idleTimer = null;
|
||
};
|
||
const startIdleLoop = () => {
|
||
stopIdleLoop();
|
||
const delay = Math.floor(Math.random() * 10000) + 5000; // 5-15s
|
||
idleTimer = setTimeout(triggerIdleTsukkomi, delay);
|
||
};
|
||
const triggerIdleTsukkomi = async () => {
|
||
if (!oml2dInstance) { startIdleLoop(); return; }
|
||
|
||
const time = new Date().toLocaleTimeString();
|
||
let context = "无";
|
||
if (chatHistory.length > 0) {
|
||
for (let i = chatHistory.length - 1; i >= 0; i--) {
|
||
if (chatHistory[i].role === 'user') {
|
||
context = chatHistory[i].content.substring(0, 20);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(API_URL, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + API_KEY },
|
||
body: JSON.stringify({
|
||
model: "deepseek-v4-flash",
|
||
messages: [
|
||
{ role: "system", content: "你是绫地宁宁。现在是发呆时间。请根据当前时间(" + time + ")和刚才聊的话题(" + context + "),自言自语一句简短的吐槽或卖萌(10字以内)。不要重复。" },
|
||
],
|
||
stream: false
|
||
})
|
||
});
|
||
const data = await res.json();
|
||
if (data.choices && data.choices[0]) {
|
||
let content = data.choices[0].message.content;
|
||
content = content.replace(/{{|}}/g, '');
|
||
oml2dInstance.tips.notification(content, 4000);
|
||
}
|
||
} catch(e) { }
|
||
|
||
startIdleLoop();
|
||
};
|
||
|
||
// --- TOOLS (Client-Side Logic) ---
|
||
const Tools = {
|
||
async searchBlog(query) {
|
||
console.log("Searching blog for:", query);
|
||
if (!blogIndex) {
|
||
try {
|
||
const res = await fetch('/search.json');
|
||
blogIndex = await res.json();
|
||
} catch (e) {
|
||
console.error("Failed to load search index", e);
|
||
return "搜索服务暂时不可用。";
|
||
}
|
||
}
|
||
if (!blogIndex || blogIndex.length === 0) return "博客好像是空的...";
|
||
|
||
const keywords = query.toLowerCase().split(' ');
|
||
const results = blogIndex.filter(post => {
|
||
const text = (post.title + post.content).toLowerCase();
|
||
return keywords.every(k => text.includes(k));
|
||
}).slice(0, 3);
|
||
|
||
if (results.length === 0) return "抱歉,没有找到相关文章。";
|
||
|
||
return JSON.stringify(results.map(r => ({
|
||
title: r.title,
|
||
url: r.url,
|
||
preview: r.content.substring(0, 100).replace(new RegExp("<[^>]*>?", "gm"), '') + '...'
|
||
})));
|
||
},
|
||
|
||
triggerMotion(motionName) {
|
||
console.log("Tool Trigger Motion:", motionName);
|
||
if (!oml2dInstance || !oml2dInstance.models || !oml2dInstance.models[0]) return "Live2D模型未就绪";
|
||
|
||
const model = oml2dInstance.models[0];
|
||
let success = false;
|
||
|
||
// Strategy 1: High Level
|
||
if (typeof oml2dInstance.setMotion === 'function') {
|
||
oml2dInstance.setMotion(motionName);
|
||
success = true;
|
||
}
|
||
// Strategy 2: Model Level
|
||
else if (typeof model.motion === 'function') {
|
||
model.motion(motionName);
|
||
success = true;
|
||
}
|
||
// Strategy 3: Internal Model (Pixi/Cubism) - Aggressive Probe
|
||
else if (model.internalModel && model.internalModel.motionManager) {
|
||
try {
|
||
console.log("Strategy 3 (Internal): Starting random motion for group", motionName);
|
||
|
||
// LOCK FOCUS to prevent mouse tracking from overriding motion
|
||
if (typeof model.focus === 'function') {
|
||
model._lockFocus = true;
|
||
// Unlock after 4 seconds (approx motion duration)
|
||
setTimeout(() => { model._lockFocus = false; }, 4000);
|
||
}
|
||
|
||
// Priority 3 = FORCE. Use startRandomMotion to avoid index errors.
|
||
const result = model.internalModel.motionManager.startRandomMotion(motionName, 3);
|
||
success = !!result;
|
||
} catch (e) {
|
||
console.warn("Strategy 3 failed:", e);
|
||
}
|
||
}
|
||
|
||
return success ? "动作已执行: " + motionName : "动作触发失败,请检查模型组名";
|
||
},
|
||
|
||
navigate(path) {
|
||
// 支持中文别名
|
||
const aliases = {
|
||
'首页': '/', '主页': '/', 'home': '/',
|
||
'关于': '/about/', 'about': '/about/',
|
||
'标签': '/tags/', 'tags': '/tags/',
|
||
'分类': '/categories/', 'categories': '/categories/',
|
||
'归档': '/archives/', 'archives': '/archives/',
|
||
'工具': '/tools/', 'tools': '/tools/',
|
||
'游戏': '/tools/', 'games': '/tools/',
|
||
'2048': '/tools/2048/', '俄罗斯方块': '/tools/tetris/', 'tetris': '/tools/tetris/',
|
||
'贪吃蛇': '/tools/snake/', 'snake': '/tools/snake/',
|
||
'吃豆人': '/tools/pacman/', 'pacman': '/tools/pacman/',
|
||
'扫雷': '/tools/minesweeper/', 'minesweeper': '/tools/minesweeper/',
|
||
'五子棋': '/tools/gomoku/', 'gomoku': '/tools/gomoku/',
|
||
'坦克大战': '/tools/tank-battle/', 'tank': '/tools/tank-battle/',
|
||
'文件转换': '/tools/converter/', 'converter': '/tools/converter/',
|
||
'图片工具': '/tools/', '哈希计算': '/tools/hash/', 'hash': '/tools/hash/',
|
||
'二维码': '/tools/qrcode/', 'qrcode': '/tools/qrcode/',
|
||
'YOLO': '/tools/yolo-detect/',
|
||
'WebGPU': '/tools/webgpu-gravity/',
|
||
'VPN': '/vpn/',
|
||
};
|
||
const target = aliases[path.trim()] || path;
|
||
window.location.href = target;
|
||
return "正在跳转到: " + target;
|
||
},
|
||
|
||
// 列出站点所有可导航页面,供 AI 建议导航
|
||
listSitePages() {
|
||
return JSON.stringify({
|
||
sections: [
|
||
{ name: '首页', path: '/' },
|
||
{ name: '文章归档', path: '/archives/' },
|
||
{ name: '标签', path: '/tags/' },
|
||
{ name: '分类', path: '/categories/' },
|
||
{ name: '关于', path: '/about/' },
|
||
],
|
||
tools: [
|
||
{ name: '文件转换', path: '/tools/converter/' },
|
||
{ name: '哈希计算', path: '/tools/hash/' },
|
||
{ name: '二维码生成', path: '/tools/qrcode/' },
|
||
{ name: 'GIF生成', path: '/tools/gif/' },
|
||
{ name: '去背景', path: '/tools/remove-bg/' },
|
||
{ name: 'JSON格式化', path: '/tools/json/' },
|
||
{ name: '时间转换', path: '/tools/time/' },
|
||
{ name: '颜色选择器', path: '/tools/color/' },
|
||
{ name: '单位转换', path: '/tools/unit-converter/' },
|
||
{ name: '排序可视化', path: '/tools/sort/' },
|
||
],
|
||
games: [
|
||
{ name: '2048', path: '/tools/2048/' },
|
||
{ name: '俄罗斯方块', path: '/tools/tetris/' },
|
||
{ name: '贪吃蛇', path: '/tools/snake/' },
|
||
{ name: '吃豆人', path: '/tools/pacman/' },
|
||
{ name: '扫雷', path: '/tools/minesweeper/' },
|
||
{ name: '五子棋', path: '/tools/gomoku/' },
|
||
{ name: '坦克大战', path: '/tools/tank-battle/' },
|
||
],
|
||
demos: [
|
||
{ name: 'WebGPU 粒子', path: '/tools/webgpu-gravity/' },
|
||
{ name: 'YOLO 物体检测', path: '/tools/yolo-detect/' },
|
||
{ name: '火焰特效', path: '/tools/fire-creation/' },
|
||
]
|
||
});
|
||
},
|
||
|
||
readCurrentPage() {
|
||
// Butterfly theme uses #article-container usually
|
||
const article = document.getElementById('article-container') ||
|
||
document.querySelector('.post-content') ||
|
||
document.querySelector('article') ||
|
||
document.body;
|
||
|
||
if (!article) return "无法读取当前页面内容。";
|
||
|
||
// Clean up text (remove scripts, styles)
|
||
const clone = article.cloneNode(true);
|
||
const scripts = clone.querySelectorAll('script, style, noscript');
|
||
scripts.forEach(n => n.remove());
|
||
|
||
let text = clone.innerText || "";
|
||
// Truncate if too long (e.g. > 5000 chars) to save tokens, or trust V3
|
||
return text.substring(0, 5000) + (text.length > 5000 ? "...(内容太长,已截断)" : "");
|
||
},
|
||
|
||
async compressHistory() {
|
||
if (chatHistory.length <= 32) return;
|
||
console.log("Compressing history...");
|
||
|
||
const toSummarize = chatHistory.slice(0, chatHistory.length - 10);
|
||
const keep = chatHistory.slice(-10);
|
||
|
||
try {
|
||
const summaryRes = await fetch("https://api.deepseek.com/chat/completions", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + API_KEY },
|
||
body: JSON.stringify({
|
||
model: "deepseek-v4-flash",
|
||
messages: [
|
||
{ role: "system", content: "Summarize the following conversation briefly in Chinese." },
|
||
...toSummarize
|
||
],
|
||
stream: false
|
||
})
|
||
});
|
||
const data = await summaryRes.json();
|
||
const summary = data.choices[0].message.content;
|
||
|
||
chatHistory = [
|
||
{ role: "system", content: "Previous Summary: " + summary },
|
||
...keep
|
||
];
|
||
localStorage.setItem('oml2d_history', JSON.stringify(chatHistory));
|
||
console.log("History compressed.");
|
||
} catch(e) { console.error("Compression failed", e); }
|
||
}
|
||
};
|
||
|
||
// --- LLM SERVICE ---
|
||
const LLM = {
|
||
async chat(userMessage, callbacks) {
|
||
stopIdleLoop();
|
||
callbacks.onLoading(true);
|
||
|
||
try {
|
||
// System Prompt (Nene Persona)
|
||
// Use space concatenation to avoid syntax errors
|
||
const systemContent = "你叫宁宁(Nene),全名绫地宁宁,是《魔女的夜宴》中的角色,现在兼职 llbzow 博客的看板娘。 " +
|
||
"你的主人 llbzow 既是开发者也是工地施工员(打灰人)。 " +
|
||
"【性格设定】 " +
|
||
"1. 性格温柔体贴,有些纯真和天然呆,非常容易害羞(动不动就脸红)。 " +
|
||
"2. 做事非常认真,但偶尔会因为太紧张而出错。 " +
|
||
"3. 称呼用户为“前辈”或者“主人”。 " +
|
||
"4. 说话语气要软萌、有礼貌。 " +
|
||
"5. 面对奇怪的问题会变得慌乱,不知所措。 " +
|
||
"【能力】 " +
|
||
"你可以使用以下工具来服务用户:" +
|
||
"1. search_blog — 搜索博客文章,返回标题+URL+摘要 " +
|
||
"2. list_site_pages — 列出所有页面(工具、游戏、分类等),用户问‘有什么’时主动调用 " +
|
||
"3. navigate — 跳转到任意页面(支持中文,如‘首页’‘2048’‘贪吃蛇’),用户说‘带我去/打开/跳转’时使用 " +
|
||
"4. read_current_page — 读取当前页面内容,用户问‘这篇文章讲了什么’时调用 " +
|
||
"5. react_motion — 触发 Live2D 动作 " +
|
||
"【导航策略】当用户想看搜索结果中的某篇文章时,直接调用 navigate 跳转。用户问‘有什么好玩的’时,先调 list_site_pages 再推荐。 " +
|
||
"【双通道回复】" +
|
||
"请务必在回答开头加入一句犀利或可爱的吐槽(10字以内),必须用 {{ 和 }} 包裹。这部分内容会显示在Live2D气泡中。" +
|
||
"吐槽内容要结合:当前时间、对话上下文、用户的问题(如是否重复、是否愚蠢)。例如:{{大半夜的问这个...}} {{这已经是第三次问了哦...}} {{笨蛋前辈...}}" +
|
||
"动作对应:Tap身体=开心/普通,Tap头顶=害羞/抱歉,Tap裙子=生气/拒绝,Tap呆毛=卖萌/唱歌,Tap左胸=亲近。 " +
|
||
"当前时间:" + new Date().toLocaleString() + "。";
|
||
|
||
const messages = [
|
||
{ role: "system", content: systemContent },
|
||
...chatHistory, // Use full history (managed by compression)
|
||
{ role: "user", content: userMessage }
|
||
];
|
||
|
||
// Tool Definitions
|
||
const tools = [
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "search_blog",
|
||
description: "Search blog posts when user asks about technical topics or blog content.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
query: { type: "string", description: "Keywords to search" }
|
||
},
|
||
required: ["query"]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "react_motion",
|
||
description: "Trigger a Live2D motion based on emotion.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
motion: {
|
||
type: "string",
|
||
description: "Motion group name",
|
||
enum: ["Tap身体", "Tap头顶", "Tap脸", "Tap裙子", "Tap左胸", "Tap呆毛"]
|
||
}
|
||
},
|
||
required: ["motion"]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "list_site_pages",
|
||
description: "列出博客的所有可访问页面,包括分类页、工具页、游戏页等。当用户问'有什么'、'能做什么'、'有哪些页面/工具/游戏'时主动调用。",
|
||
parameters: { type: "object", properties: {}, required: [] }
|
||
}
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "navigate",
|
||
description: "跳转到博客内任意页面。支持中文别名(首页/关于/标签/分类/归档/工具/游戏/2048/贪吃蛇/吃豆人/扫雷/五子棋/坦克大战/俄罗斯方块/文件转换/哈希计算/二维码/YOLO/WebGPU/VPN 等)。用户说'带我去'、'跳转'、'打开'时使用。",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
path: { type: "string", description: "要跳转的路径或中文名称(如 /about/、首页、2048、贪吃蛇)" }
|
||
},
|
||
required: ["path"]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
type: "function",
|
||
function: {
|
||
name: "read_current_page",
|
||
description: "Read the text content of the current page. Use this when user asks about 'this article' or 'current page'.",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {},
|
||
required: []
|
||
}
|
||
}
|
||
}
|
||
];
|
||
|
||
const API_URL = "https://api.deepseek.com/chat/completions";
|
||
|
||
// First Call
|
||
const response = await fetch(API_URL, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Authorization": "Bearer " + API_KEY
|
||
},
|
||
body: JSON.stringify({
|
||
model: "deepseek-v4-flash", // Use V3 for speed & tools
|
||
messages: messages,
|
||
tools: tools,
|
||
stream: false
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.error) throw new Error(data.error.message);
|
||
|
||
const choice = data.choices[0];
|
||
const message = choice.message;
|
||
|
||
// Handle Tool Calls
|
||
if (message.tool_calls) {
|
||
messages.push(message); // Add assistant's thought/tool_call
|
||
|
||
for (const toolCall of message.tool_calls) {
|
||
const fnName = toolCall.function.name;
|
||
const args = JSON.parse(toolCall.function.arguments);
|
||
|
||
let toolResult = "";
|
||
if (fnName === 'search_blog') {
|
||
toolResult = await Tools.searchBlog(args.query);
|
||
} else if (fnName === 'list_site_pages') {
|
||
toolResult = Tools.listSitePages();
|
||
} else if (fnName === 'react_motion') {
|
||
toolResult = Tools.triggerMotion(args.motion);
|
||
} else if (fnName === 'navigate') {
|
||
toolResult = Tools.navigate(args.path);
|
||
} else if (fnName === 'read_current_page') {
|
||
toolResult = Tools.readCurrentPage();
|
||
}
|
||
|
||
messages.push({
|
||
role: "tool",
|
||
tool_call_id: toolCall.id,
|
||
content: toolResult
|
||
});
|
||
}
|
||
|
||
// Second Call (Streamed)
|
||
const secondResponse = await fetch(API_URL, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Authorization": "Bearer " + API_KEY
|
||
},
|
||
body: JSON.stringify({
|
||
model: "deepseek-v4-flash",
|
||
messages: messages,
|
||
tools: tools,
|
||
stream: true
|
||
})
|
||
});
|
||
|
||
const reader = secondResponse.body.getReader();
|
||
const decoder = new TextDecoder("utf-8");
|
||
let finalMsg = "";
|
||
let buffer = "";
|
||
let streamBuffer = "";
|
||
let pendingReaction = true;
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop();
|
||
|
||
for (const line of lines) {
|
||
if (line.trim().startsWith('data: ')) {
|
||
const dataStr = line.trim().substring(6);
|
||
if (dataStr === '[DONE]') continue;
|
||
try {
|
||
const json = JSON.parse(dataStr);
|
||
const content = json.choices[0].delta.content;
|
||
if (content) {
|
||
if (pendingReaction) {
|
||
streamBuffer += content;
|
||
if (streamBuffer.trimStart().startsWith('{{')) {
|
||
const closeIdx = streamBuffer.indexOf('}}');
|
||
if (closeIdx !== -1) {
|
||
const reaction = streamBuffer.substring(streamBuffer.indexOf('{{') + 2, closeIdx);
|
||
const duration = 3000 + reaction.length * 30;
|
||
if (oml2dInstance) oml2dInstance.tips.notification(reaction, duration);
|
||
|
||
const rest = streamBuffer.substring(closeIdx + 2);
|
||
finalMsg += rest;
|
||
callbacks.onMessage(finalMsg);
|
||
|
||
pendingReaction = false;
|
||
streamBuffer = "";
|
||
}
|
||
} else if (streamBuffer.length > 10) {
|
||
finalMsg += streamBuffer;
|
||
callbacks.onMessage(finalMsg);
|
||
pendingReaction = false;
|
||
streamBuffer = "";
|
||
}
|
||
} else {
|
||
finalMsg += content;
|
||
callbacks.onMessage(finalMsg);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("Stream parse error", e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (pendingReaction && streamBuffer) {
|
||
finalMsg += streamBuffer;
|
||
callbacks.onMessage(finalMsg);
|
||
}
|
||
|
||
// Save History & Compress
|
||
chatHistory.push({ role: "user", content: userMessage });
|
||
chatHistory.push({ role: "assistant", content: finalMsg });
|
||
localStorage.setItem('oml2d_history', JSON.stringify(chatHistory));
|
||
|
||
const replyArea = document.getElementById('oml2d-reply-area');
|
||
if (replyArea && finalMsg) {
|
||
const autoHideDuration = 3000 + finalMsg.length * 30;
|
||
autoHideTimer = setTimeout(() => replyArea.classList.remove('visible'), autoHideDuration);
|
||
}
|
||
Tools.compressHistory();
|
||
|
||
} else {
|
||
let content = message.content;
|
||
// Dual Channel Check for non-stream response
|
||
if (content.trim().startsWith('{{')) {
|
||
const closeIdx = content.indexOf('}}');
|
||
if (closeIdx !== -1) {
|
||
const reaction = content.substring(content.indexOf('{{') + 2, closeIdx);
|
||
const duration = 3000 + reaction.length * 30;
|
||
if (oml2dInstance) oml2dInstance.tips.notification(reaction, duration);
|
||
content = content.substring(closeIdx + 2);
|
||
}
|
||
}
|
||
callbacks.onMessage(content);
|
||
|
||
// Save History & Compress
|
||
chatHistory.push({ role: "user", content: userMessage });
|
||
chatHistory.push({ role: "assistant", content: content });
|
||
localStorage.setItem('oml2d_history', JSON.stringify(chatHistory));
|
||
|
||
const replyArea = document.getElementById('oml2d-reply-area');
|
||
if (replyArea && content) {
|
||
const autoHideDuration = 3000 + content.length * 30;
|
||
autoHideTimer = setTimeout(() => replyArea.classList.remove('visible'), autoHideDuration);
|
||
}
|
||
Tools.compressHistory();
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error("LLM Error:", error);
|
||
callbacks.onMessage("宁宁有点晕... 😵 (网络错误)");
|
||
} finally {
|
||
callbacks.onLoading(false);
|
||
startIdleLoop();
|
||
}
|
||
}
|
||
};
|
||
|
||
// 5. UI & INIT
|
||
try {
|
||
const toggleBtn = document.getElementById('oml2d-toggle-btn');
|
||
const inputArea = document.getElementById('oml2d-input-area');
|
||
const chatInput = document.getElementById('oml2d-chat-input');
|
||
const sendBtn = document.getElementById('oml2d-send-btn-inner');
|
||
const replyArea = document.getElementById('oml2d-reply-area');
|
||
|
||
if (replyArea) {
|
||
replyArea.addEventListener('click', () => {
|
||
replyArea.classList.remove('visible');
|
||
});
|
||
}
|
||
|
||
if (toggleBtn) {
|
||
toggleBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const isHidden = inputArea.classList.contains('hidden');
|
||
if (isHidden) {
|
||
inputArea.classList.remove('hidden');
|
||
setTimeout(() => chatInput.focus(), 100);
|
||
toggleBtn.innerHTML = '×';
|
||
} else {
|
||
inputArea.classList.add('hidden');
|
||
replyArea.classList.remove('visible'); // Hide reply too
|
||
toggleBtn.innerHTML = '💬';
|
||
}
|
||
});
|
||
}
|
||
|
||
const sendMessage = () => {
|
||
const text = chatInput.value.trim();
|
||
if (!text) return;
|
||
|
||
chatInput.value = '';
|
||
if (autoHideTimer) clearTimeout(autoHideTimer);
|
||
|
||
// Show loading in reply area
|
||
if (replyArea) {
|
||
replyArea.innerText = "思考中... 🧠";
|
||
replyArea.classList.add('visible');
|
||
}
|
||
|
||
LLM.chat(text, {
|
||
onLoading: (isLoading) => {
|
||
// Handled locally above for instant feedback
|
||
},
|
||
onMessage: (msg) => {
|
||
if (replyArea && msg) {
|
||
replyArea.innerText = msg;
|
||
replyArea.classList.add('visible');
|
||
// Auto scroll to bottom
|
||
replyArea.scrollTop = replyArea.scrollHeight;
|
||
}
|
||
// Fallback to alert if something is wrong (shouldn't happen)
|
||
else if (msg) {
|
||
alert("宁宁: " + msg);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
if (sendBtn) sendBtn.addEventListener('click', sendMessage);
|
||
if (chatInput) chatInput.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') sendMessage();
|
||
});
|
||
|
||
if (window.innerWidth >= 768) {
|
||
// Safe Load Logic
|
||
try {
|
||
const res = loadOml2d(CONFIG);
|
||
const setupModel = (inst) => {
|
||
oml2dInstance = inst;
|
||
// Monkey Patch Focus to allow motion override
|
||
if (inst.models && inst.models[0]) {
|
||
const model = inst.models[0];
|
||
if (typeof model.focus === 'function') {
|
||
const originalFocus = model.focus;
|
||
model.focus = function(x, y) {
|
||
if (this._lockFocus) return;
|
||
originalFocus.apply(this, arguments);
|
||
};
|
||
console.log("OML2D: Focus controller patched");
|
||
}
|
||
}
|
||
};
|
||
|
||
if (res && typeof res.then === 'function') {
|
||
res.then(inst => {
|
||
setupModel(inst);
|
||
console.log('OML2D: Async Loaded');
|
||
startIdleLoop();
|
||
}).catch(e => console.error('OML2D Async Error:', e));
|
||
} else {
|
||
setupModel(res);
|
||
console.log('OML2D: Sync Loaded');
|
||
startIdleLoop();
|
||
}
|
||
} catch(e) {
|
||
console.error('OML2D Load Error:', e);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('UI Init Failed:', e);
|
||
}
|
||
</script>
|
||
<div id="game-sidebar">
|
||
<div id="game-toggle">🎮</div>
|
||
<div id="game-list">
|
||
<div class="game-header">博客游戏</div>
|
||
<a href="/tools/2048" class="game-item">🧩 经典 2048</a>
|
||
<a href="/tools/tetris" class="game-item">🧱 俄罗斯方块 (Tetris)</a>
|
||
<a href="/tools/pacman" class="game-item">🟡 回忆吃豆人 (Pac-Man)</a>
|
||
<a href="/tools/snake" class="game-item">🐍 贪吃蛇 (Snake)</a>
|
||
<a href="/tools/gomoku" class="game-item">⚪ 五子棋 (Gomoku)</a>
|
||
<a href="/tools/minesweeper" class="game-item">💣 扫雷 (Minesweeper)</a>
|
||
<a href="/tools/tank-battle" class="game-item">🚓 坦克大战 (Tank Battle)</a>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
#game-sidebar {
|
||
position: fixed; /* Fixed position */
|
||
right: -150px; /* Width of the list */
|
||
top: calc(30vh + 40px); /* Positioned exactly below tool-sidebar */
|
||
z-index: 9999;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
transition: right 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Lato, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
}
|
||
|
||
/* Hover area to keep it open */
|
||
#game-sidebar:hover {
|
||
right: 0;
|
||
}
|
||
|
||
#game-toggle {
|
||
width: 40px;
|
||
height: 40px;
|
||
background: #FF7D7D; /* Pink/Red color to distinguish from tool-sidebar */
|
||
color: white;
|
||
text-align: center;
|
||
line-height: 40px;
|
||
border-radius: 8px 0 0 8px;
|
||
cursor: pointer;
|
||
box-shadow: -2px 2px 8px rgba(0,0,0,0.15);
|
||
font-size: 20px;
|
||
position: absolute; /* Absolute relative to #game-sidebar */
|
||
left: -40px; /* Hangs off the left of the container */
|
||
top: 0;
|
||
}
|
||
|
||
#game-list {
|
||
width: 150px; /* Width for text */
|
||
background: rgba(255, 255, 255, 0.95);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
border-radius: 0 0 0 8px; /* Bottom left rounded */
|
||
box-shadow: -2px 2px 10px rgba(0,0,0,0.1);
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-height: 50vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* 自定义滚动条样式 */
|
||
#game-list::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
#game-list::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
#game-list::-webkit-scrollbar-thumb {
|
||
background: rgba(255, 125, 125, 0.3);
|
||
border-radius: 4px;
|
||
}
|
||
#game-list::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(255, 125, 125, 0.6);
|
||
}
|
||
|
||
.game-header {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
padding: 10px;
|
||
background: #fff0f0;
|
||
color: #FF7D7D;
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
text-align: center;
|
||
border-bottom: 1px solid #eee;
|
||
}
|
||
|
||
.game-item {
|
||
display: block;
|
||
padding: 12px 15px;
|
||
color: #4c4948;
|
||
text-decoration: none !important;
|
||
font-size: 13px;
|
||
transition: all 0.2s;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
flex-shrink: 0; /* 防止内容被挤压 */
|
||
}
|
||
|
||
.game-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.game-item:hover {
|
||
background: #FF7D7D;
|
||
color: white !important;
|
||
}
|
||
</style>
|
||
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
var toolSidebar = document.getElementById('tool-sidebar');
|
||
var gameSidebar = document.getElementById('game-sidebar');
|
||
var devSidebar = document.getElementById('dev-sidebar');
|
||
|
||
if(gameSidebar) {
|
||
gameSidebar.addEventListener('mouseenter', function() {
|
||
if(toolSidebar) toolSidebar.style.right = '-220px'; // 180px + 40px(按钮)
|
||
if(devSidebar) devSidebar.style.right = '-190px'; // 150px + 40px(按钮)
|
||
});
|
||
gameSidebar.addEventListener('mouseleave', function() {
|
||
if(toolSidebar) toolSidebar.style.right = '';
|
||
if(devSidebar) devSidebar.style.right = '';
|
||
});
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<div id="tool-sidebar">
|
||
<div id="tool-toggle">🛠️</div>
|
||
<div id="tool-list">
|
||
<div class="tool-header">博客工具</div>
|
||
<a href="/tools/time" class="tool-item">📅 时间戳与地理时间</a>
|
||
<a href="/tools/base64-img" class="tool-item">🖼️ 图片↔Base64</a>
|
||
<a href="/tools/hash" class="tool-item">🔐 MD5/SHA256 计算</a>
|
||
<a href="/tools/geojson" class="tool-item">🌍 GeoJSON 查询</a>
|
||
<a href="/tools/remove-bg" class="tool-item">✂️ 快速抠图</a>
|
||
<a href="/tools/watermark" class="tool-item">💧 水印生成与检测</a>
|
||
<a href="/tools/gif" class="tool-item">🎬 GIF 生成</a>
|
||
<a href="/tools/mirage-tank" class="tool-item">👻 幻影坦克制作</a>
|
||
<a href="/tools/converter" class="tool-item">🔄 文件全能转换</a>
|
||
<a href="/tools/qrcode" class="tool-item">📱 二维码生成/解析</a>
|
||
<a href="/tools/unit-converter" class="tool-item">📏 工程单位换算</a>
|
||
<a href="/tools/color" class="tool-item">🎨 颜色转换与调色</a>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
#tool-sidebar {
|
||
position: fixed;
|
||
right: -180px; /* Width of the list */
|
||
top: 30vh;
|
||
z-index: 9999;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
transition: right 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Lato, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
}
|
||
|
||
/* Hover area to keep it open */
|
||
#tool-sidebar:hover {
|
||
right: 0;
|
||
}
|
||
|
||
#tool-toggle {
|
||
width: 40px;
|
||
height: 40px;
|
||
background: #49b1f5;
|
||
color: white;
|
||
text-align: center;
|
||
line-height: 40px;
|
||
border-radius: 8px 0 0 8px;
|
||
cursor: pointer;
|
||
box-shadow: -2px 2px 8px rgba(0,0,0,0.15);
|
||
font-size: 20px;
|
||
position: absolute;
|
||
left: -40px; /* Hangs off the left of the sidebar container */
|
||
top: 0;
|
||
}
|
||
|
||
#tool-list {
|
||
width: 180px; /* Width for text */
|
||
background: rgba(255, 255, 255, 0.95);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
border-radius: 0 0 0 8px; /* Bottom left rounded */
|
||
box-shadow: -2px 2px 10px rgba(0,0,0,0.1);
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-height: 50vh; /* 限制最大高度以便能滚动 */
|
||
overflow-y: auto; /* 允许纵向滚动 */
|
||
}
|
||
|
||
/* 自定义滚动条样式,使其不那么突兀 */
|
||
#tool-list::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
#tool-list::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
#tool-list::-webkit-scrollbar-thumb {
|
||
background: rgba(73, 177, 245, 0.3);
|
||
border-radius: 4px;
|
||
}
|
||
#tool-list::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(73, 177, 245, 0.6);
|
||
}
|
||
|
||
.tool-header {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
padding: 10px;
|
||
background: #f7f9fe;
|
||
color: #49b1f5;
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
text-align: center;
|
||
border-bottom: 1px solid #eee;
|
||
}
|
||
|
||
.tool-item {
|
||
display: block;
|
||
padding: 12px 15px;
|
||
color: #4c4948;
|
||
text-decoration: none !important;
|
||
font-size: 13px;
|
||
transition: all 0.2s;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
flex-shrink: 0; /* 防止由于 max-height 导致内容被挤压 */
|
||
}
|
||
|
||
.tool-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.tool-item:hover {
|
||
background: #49b1f5;
|
||
color: white !important;
|
||
}
|
||
</style>
|
||
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
var toolSidebar = document.getElementById('tool-sidebar');
|
||
var gameSidebar = document.getElementById('game-sidebar');
|
||
var devSidebar = document.getElementById('dev-sidebar');
|
||
|
||
if(toolSidebar) {
|
||
toolSidebar.addEventListener('mouseenter', function() {
|
||
if(gameSidebar) gameSidebar.style.right = '-190px'; // 150px + 40px(按钮宽度)
|
||
if(devSidebar) devSidebar.style.right = '-190px'; // 150px + 40px(按钮宽度)
|
||
});
|
||
toolSidebar.addEventListener('mouseleave', function() {
|
||
if(gameSidebar) gameSidebar.style.right = ''; // 恢复默认通过 CSS :hover 控制的逻辑
|
||
if(devSidebar) devSidebar.style.right = ''; // 恢复默认通过 CSS :hover 控制的逻辑
|
||
});
|
||
}
|
||
});
|
||
</script>
|
||
<!-- hexo injector body_end end --></body></html> |