2279 lines
88 KiB
HTML
2279 lines
88 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>扫雷 (Minesweeper) | 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=".minesweeper-container { display: flex; flex-direction: column; align-items: center; margin-top: 20px; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } .ms-header {">
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:title" content="扫雷 (Minesweeper)">
|
||
<meta property="og:url" content="https://yourblog.com/tools/minesweeper/index.html">
|
||
<meta property="og:site_name" content="llbzow的摸鱼日记 (づ ̄ 3 ̄)づ">
|
||
<meta property="og:description" content=".minesweeper-container { display: flex; flex-direction: column; align-items: center; margin-top: 20px; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } .ms-header {">
|
||
<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-11T03:20:00.000Z">
|
||
<meta property="article:modified_time" content="2026-03-11T03:31:48.330Z">
|
||
<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/minesweeper/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: '扫雷 (Minesweeper)',
|
||
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 type-tools" 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">扫雷 (Minesweeper)</h1></header><main class="layout hide-aside" id="content-inner"><div id="page"><div class="page-title">扫雷 (Minesweeper)</div><div class="container" id="article-container"><style>
|
||
.minesweeper-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
margin-top: 20px;
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
}
|
||
.ms-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
width: 100%;
|
||
max-width: 400px;
|
||
background: #bdbdbd;
|
||
padding: 10px;
|
||
border: 3px solid;
|
||
border-color: #fff #7b7b7b #7b7b7b #fff;
|
||
margin-bottom: 20px;
|
||
}
|
||
.ms-counter {
|
||
background: #000;
|
||
color: #f00;
|
||
font-family: 'Courier New', Courier, monospace;
|
||
font-size: 24px;
|
||
font-weight: bold;
|
||
padding: 2px 5px;
|
||
border: 2px solid #7b7b7b;
|
||
border-right-color: #fff;
|
||
border-bottom-color: #fff;
|
||
width: 60px;
|
||
text-align: right;
|
||
}
|
||
.ms-face {
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
background: #bdbdbd;
|
||
border: 3px solid;
|
||
border-color: #fff #7b7b7b #7b7b7b #fff;
|
||
width: 40px;
|
||
height: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
user-select: none;
|
||
}
|
||
.ms-face:active {
|
||
border-color: #7b7b7b #fff #fff #7b7b7b;
|
||
}
|
||
.ms-grid-container {
|
||
background: #bdbdbd;
|
||
padding: 10px;
|
||
border: 3px solid;
|
||
border-color: #fff #7b7b7b #7b7b7b #fff;
|
||
overflow-x: auto;
|
||
max-width: 100%;
|
||
}
|
||
.ms-grid {
|
||
display: grid;
|
||
background: #7b7b7b;
|
||
gap: 1px;
|
||
border: 2px solid #7b7b7b;
|
||
user-select: none;
|
||
}
|
||
.ms-cell {
|
||
width: 25px;
|
||
height: 25px;
|
||
background: #bdbdbd;
|
||
border: 2px solid;
|
||
border-color: #fff #7b7b7b #7b7b7b #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: bold;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
}
|
||
.ms-cell.revealed {
|
||
background: #bdbdbd;
|
||
border: 1px solid #7b7b7b;
|
||
}
|
||
.ms-cell.mine {
|
||
background: #f00;
|
||
}
|
||
.c-1 { color: #0000FF; } .c-2 { color: #008000; } .c-3 { color: #FF0000; }
|
||
.c-4 { color: #000080; } .c-5 { color: #800000; } .c-6 { color: #008080; }
|
||
.c-7 { color: #000000; } .c-8 { color: #808080; }
|
||
|
||
.ms-controls {
|
||
margin-top: 15px;
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
}
|
||
.ms-btn {
|
||
padding: 8px 16px;
|
||
background-color: #49b1f5;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-weight: bold;
|
||
transition: 0.3s;
|
||
}
|
||
.ms-btn:hover { background-color: #FF7D7D; }
|
||
select.ms-select {
|
||
padding: 8px;
|
||
border-radius: 4px;
|
||
border: 1px solid #ccc;
|
||
font-size: 14px;
|
||
}
|
||
</style>
|
||
|
||
<div class="minesweeper-container">
|
||
<div class="ms-header" id="ms-header">
|
||
<div class="ms-counter" id="mines-left">010</div>
|
||
<div class="ms-face" id="btn-face">🙂</div>
|
||
<div class="ms-counter" id="timer">000</div>
|
||
</div>
|
||
|
||
<div class="ms-grid-container">
|
||
<div class="ms-grid" id="ms-grid"></div>
|
||
</div>
|
||
|
||
<div class="ms-controls">
|
||
<select class="ms-select" id="difficulty-select">
|
||
<option value="easy">初级 (9x9, 10 雷)</option>
|
||
<option value="medium">中级 (16x16, 40 雷)</option>
|
||
<option value="hard">高级 (30x16, 99 雷)</option>
|
||
</select>
|
||
<button class="ms-btn" id="btn-ai-toggle">🤖 AI 托管 (未启用)</button>
|
||
</div>
|
||
|
||
<label style="font-size:12px; margin-top: 10px; color:#ff9800;" id="gpu-label">
|
||
<input type="checkbox" id="ms-gpu-toggle" disabled=""> 🔍 探测 WebGPU 概率分析引擎中...
|
||
</label>
|
||
</div>
|
||
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const gridEl = document.getElementById('ms-grid');
|
||
const minesLeftEl = document.getElementById('mines-left');
|
||
const timerEl = document.getElementById('timer');
|
||
const faceEl = document.getElementById('btn-face');
|
||
const diffSelect = document.getElementById('difficulty-select');
|
||
const aiBtn = document.getElementById('btn-ai-toggle');
|
||
const headerEl = document.getElementById('ms-header');
|
||
|
||
let rows, cols, totalMines;
|
||
let grid = []; // { isMine, isRevealed, isFlagged, neighborMines }
|
||
let flagsPlaced = 0;
|
||
let cellsRevealed = 0;
|
||
let isGameOver = false;
|
||
let isFirstClick = true;
|
||
let timerInterval = null;
|
||
let timeElapsed = 0;
|
||
|
||
let aiMode = false;
|
||
let aiTimer = null;
|
||
|
||
const DIFFICULTIES = {
|
||
easy: { r: 9, c: 9, m: 10 },
|
||
medium: { r: 16, c: 16, m: 40 },
|
||
hard: { r: 16, c: 30, m: 99 }
|
||
};
|
||
|
||
function setDifficulty(level) {
|
||
let conf = DIFFICULTIES[level];
|
||
rows = conf.r; cols = conf.c; totalMines = conf.m;
|
||
gridEl.style.gridTemplateColumns = `repeat(${cols}, 25px)`;
|
||
headerEl.style.maxWidth = `${cols * 27 + 20}px`;
|
||
initGame();
|
||
}
|
||
|
||
function initGame() {
|
||
grid = [];
|
||
flagsPlaced = 0;
|
||
cellsRevealed = 0;
|
||
isGameOver = false;
|
||
isFirstClick = true;
|
||
timeElapsed = 0;
|
||
|
||
clearInterval(timerInterval);
|
||
updateCounters();
|
||
faceEl.innerText = '🙂';
|
||
|
||
gridEl.innerHTML = '';
|
||
for (let r = 0; r < rows; r++) {
|
||
let rowArray = [];
|
||
for (let c = 0; c < cols; c++) {
|
||
rowArray.push({
|
||
r, c, isMine: false, isRevealed: false, isFlagged: false, neighborMines: 0
|
||
});
|
||
let cellEl = document.createElement('div');
|
||
cellEl.className = 'ms-cell';
|
||
cellEl.dataset.r = r;
|
||
cellEl.dataset.c = c;
|
||
|
||
// Event Listeners
|
||
cellEl.addEventListener('mousedown', (e) => handleCellClick(e, r, c));
|
||
cellEl.addEventListener('contextmenu', e => e.preventDefault());
|
||
|
||
gridEl.appendChild(cellEl);
|
||
}
|
||
grid.push(rowArray);
|
||
}
|
||
}
|
||
|
||
function placeMines(firstMoveR, firstMoveC) {
|
||
let minesPlaced = 0;
|
||
while(minesPlaced < totalMines) {
|
||
let r = Math.floor(Math.random() * rows);
|
||
let c = Math.floor(Math.random() * cols);
|
||
// Protect first click and its immediate neighbors to ensure a good start
|
||
if(!grid[r][c].isMine && Math.abs(r - firstMoveR) > 1 && Math.abs(c - firstMoveC) > 1) {
|
||
grid[r][c].isMine = true;
|
||
minesPlaced++;
|
||
}
|
||
}
|
||
|
||
// Calculate neighbors
|
||
for (let r = 0; r < rows; r++) {
|
||
for (let c = 0; c < cols; c++) {
|
||
if(!grid[r][c].isMine) {
|
||
let count = 0;
|
||
getNeighbors(r, c).forEach(n => { if(n.isMine) count++; });
|
||
grid[r][c].neighborMines = count;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function getNeighbors(r, c) {
|
||
let neighbors = [];
|
||
for(let dr = -1; dr <= 1; dr++) {
|
||
for(let dc = -1; dc <= 1; dc++) {
|
||
if(dr === 0 && dc === 0) continue;
|
||
let nr = r + dr, nc = c + dc;
|
||
if(nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
|
||
neighbors.push(grid[nr][nc]);
|
||
}
|
||
}
|
||
}
|
||
return neighbors;
|
||
}
|
||
|
||
function handleCellClick(e, r, c) {
|
||
if(isGameOver) return;
|
||
let cell = grid[r][c];
|
||
|
||
// Right click (Flag)
|
||
if(e.button === 2) {
|
||
if(!cell.isRevealed) {
|
||
cell.isFlagged = !cell.isFlagged;
|
||
flagsPlaced += cell.isFlagged ? 1 : -1;
|
||
updateCounters();
|
||
renderCell(r, c);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Left click
|
||
if(e.button === 0) {
|
||
if(cell.isFlagged) return;
|
||
|
||
if(isFirstClick) {
|
||
placeMines(r, c);
|
||
isFirstClick = false;
|
||
timerInterval = setInterval(() => {
|
||
timeElapsed++;
|
||
updateCounters();
|
||
}, 1000);
|
||
}
|
||
|
||
if(!cell.isRevealed) {
|
||
revealCell(r, c);
|
||
checkWinCondition();
|
||
} else if(cell.isRevealed && cell.neighborMines > 0) {
|
||
// Chording (Clicking a revealed number with enough flags around it)
|
||
let flaggedCount = 0;
|
||
let neighbors = getNeighbors(r, c);
|
||
neighbors.forEach(n => { if(n.isFlagged) flaggedCount++; });
|
||
|
||
if(flaggedCount === cell.neighborMines) {
|
||
neighbors.forEach(n => {
|
||
if(!n.isRevealed && !n.isFlagged) revealCell(n.r, n.c);
|
||
});
|
||
checkWinCondition();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function revealCell(r, c) {
|
||
let cell = grid[r][c];
|
||
if(cell.isRevealed || cell.isFlagged) return;
|
||
|
||
cell.isRevealed = true;
|
||
cellsRevealed++;
|
||
renderCell(r, c);
|
||
|
||
if(cell.isMine) {
|
||
gameOver(false);
|
||
return;
|
||
}
|
||
|
||
if(cell.neighborMines === 0) {
|
||
getNeighbors(r, c).forEach(n => {
|
||
if(!n.isRevealed && !n.isFlagged) revealCell(n.r, n.c);
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderCell(r, c) {
|
||
let cell = grid[r][c];
|
||
let el = gridEl.children[r * cols + c];
|
||
|
||
if(cell.isRevealed) {
|
||
el.className = 'ms-cell revealed';
|
||
if(cell.isMine) {
|
||
el.innerText = '💣';
|
||
el.classList.add('mine');
|
||
} else if(cell.neighborMines > 0) {
|
||
el.innerText = cell.neighborMines;
|
||
el.classList.add(`c-${cell.neighborMines}`);
|
||
} else {
|
||
el.innerText = '';
|
||
}
|
||
} else {
|
||
el.className = 'ms-cell';
|
||
el.innerText = cell.isFlagged ? '🚩' : '';
|
||
}
|
||
}
|
||
|
||
function updateCounters() {
|
||
let left = totalMines - flagsPlaced;
|
||
minesLeftEl.innerText = String(left).padStart(3, '0');
|
||
timerEl.innerText = String(Math.min(999, timeElapsed)).padStart(3, '0');
|
||
}
|
||
|
||
function gameOver(win) {
|
||
isGameOver = true;
|
||
clearInterval(timerInterval);
|
||
toggleAI(false);
|
||
faceEl.innerText = win ? '😎' : '😵';
|
||
|
||
// Reveal all mines if lost
|
||
if(!win) {
|
||
for(let r=0; r<rows; r++) {
|
||
for(let c=0; c<cols; c++) {
|
||
if(grid[r][c].isMine && !grid[r][c].isFlagged) {
|
||
grid[r][c].isRevealed = true;
|
||
renderCell(r, c);
|
||
} else if(!grid[r][c].isMine && grid[r][c].isFlagged) {
|
||
// Wrong flag marker
|
||
let el = gridEl.children[r * cols + c];
|
||
el.innerText = '❌';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function checkWinCondition() {
|
||
if(cellsRevealed === rows * cols - totalMines) {
|
||
gameOver(true);
|
||
}
|
||
}
|
||
|
||
faceEl.addEventListener('click', () => setDifficulty(diffSelect.value));
|
||
diffSelect.addEventListener('change', (e) => setDifficulty(e.target.value));
|
||
|
||
// ======== AI SCRIPT STUB ========
|
||
let gpuEnabled = false;
|
||
|
||
function getUnrevealedNeighbors(r, c) {
|
||
let unrev = [];
|
||
getNeighbors(r, c).forEach(n => {
|
||
if(!n.isRevealed && !n.isFlagged) unrev.push(n);
|
||
});
|
||
return unrev;
|
||
}
|
||
|
||
function getFlaggedNeighborsCount(r, c) {
|
||
let count = 0;
|
||
getNeighbors(r, c).forEach(n => { if(n.isFlagged) count++; });
|
||
return count;
|
||
}
|
||
|
||
// Phase 1: Absolute Logic (Pattern Matching)
|
||
function applyBasicLogic() {
|
||
let actionTaken = false;
|
||
|
||
for(let r=0; r<rows; r++) {
|
||
for(let c=0; c<cols; c++) {
|
||
let cell = grid[r][c];
|
||
if(cell.isRevealed && cell.neighborMines > 0) {
|
||
let unrev = getUnrevealedNeighbors(r, c);
|
||
let flags = getFlaggedNeighborsCount(r, c);
|
||
|
||
// Case 1: unrevealed == remaining mines -> all unrevealed are MINES
|
||
if(unrev.length > 0 && unrev.length === cell.neighborMines - flags) {
|
||
unrev.forEach(n => {
|
||
n.isFlagged = true;
|
||
flagsPlaced++;
|
||
renderCell(n.r, n.c);
|
||
actionTaken = true;
|
||
});
|
||
updateCounters();
|
||
}
|
||
|
||
// Case 2: flags == neighborMines -> all unrevealed are SAFE
|
||
if(unrev.length > 0 && flags === cell.neighborMines) {
|
||
unrev.forEach(n => {
|
||
revealCell(n.r, n.c);
|
||
actionTaken = true;
|
||
});
|
||
checkWinCondition();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return actionTaken;
|
||
}
|
||
|
||
// Phase 2: CSP / Probability fallback
|
||
function guessSafestCell() {
|
||
// Global logic checks first for endgame
|
||
let allUnrevealed = [];
|
||
let borderCells = new Set();
|
||
|
||
for(let r=0; r<rows; r++) {
|
||
for(let c=0; c<cols; c++) {
|
||
if(!grid[r][c].isRevealed && !grid[r][c].isFlagged) {
|
||
allUnrevealed.push({r, c});
|
||
let isBorder = false;
|
||
getNeighbors(r, c).forEach(n => {
|
||
if(n.isRevealed && n.neighborMines > 0) isBorder = true;
|
||
});
|
||
if(isBorder) borderCells.add(`${r},${c}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if(allUnrevealed.length === 0) return false;
|
||
|
||
let minesRemaining = totalMines - flagsPlaced;
|
||
|
||
// Endgame absolute logic
|
||
if (minesRemaining === 0) {
|
||
// All remaining spaces are SAFE
|
||
console.log("Endgame: 0 mines left, opening all!");
|
||
allUnrevealed.forEach(cell => revealCell(cell.r, cell.c));
|
||
checkWinCondition();
|
||
return true;
|
||
}
|
||
if (minesRemaining === allUnrevealed.length) {
|
||
// All remaining spaces are MINES
|
||
console.log("Endgame: remaining spaces exact match mines left, flagging all!");
|
||
allUnrevealed.forEach(cell => {
|
||
grid[cell.r][cell.c].isFlagged = true;
|
||
flagsPlaced++;
|
||
renderCell(cell.r, cell.c);
|
||
});
|
||
updateCounters();
|
||
checkWinCondition();
|
||
return true;
|
||
}
|
||
|
||
if(borderCells.size === 0) {
|
||
// Completely blind guess (or isolated island), pick random
|
||
let rd = allUnrevealed[Math.floor(Math.random() * allUnrevealed.length)];
|
||
revealCell(rd.r, rd.c);
|
||
return true;
|
||
}
|
||
|
||
// For JS engine, doing full combinatorial on JS is too slow. We do a naive heuristic:
|
||
// Score = (number - flags) / unrevealed_count for all adjacent numbers, take max over neighbors.
|
||
// We pick the cell with the LOWEST max probability risk.
|
||
let safest = null;
|
||
let minRisk = Infinity;
|
||
|
||
borderCells.forEach(key => {
|
||
let [r, c] = key.split(',').map(Number);
|
||
let cellRisk = 0;
|
||
let influence = 0;
|
||
|
||
getNeighbors(r, c).forEach(n => {
|
||
if(n.isRevealed && n.neighborMines > 0) {
|
||
let unrev = getUnrevealedNeighbors(n.r, n.c);
|
||
let flags = getFlaggedNeighborsCount(n.r, n.c);
|
||
let risk = (n.neighborMines - flags) / (unrev.length || 1);
|
||
if(risk > cellRisk) cellRisk = risk; // Worst case risk from any neighbor
|
||
influence++;
|
||
}
|
||
});
|
||
|
||
// Tie breaker: pick ones with more revealing neighbors (more constraints)
|
||
let adjustedRisk = cellRisk - (influence * 0.001);
|
||
|
||
if(adjustedRisk < minRisk) {
|
||
minRisk = adjustedRisk;
|
||
safest = {r, c};
|
||
}
|
||
});
|
||
|
||
if(safest) {
|
||
console.log(`AI Guess Risk: ${(minRisk*100).toFixed(1)}% at (${safest.r},${safest.c})`);
|
||
revealCell(safest.r, safest.c);
|
||
checkWinCondition();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Phase 2.5: WebGPU Enhanced CSP
|
||
const wgslCode = `
|
||
@group(0) @binding(0) var<storage, read> borderCells: array<i32>;
|
||
@group(0) @binding(1) var<storage, read> constraints: array<i32>;
|
||
@group(0) @binding(2) var<storage, read_write> safeScores: array<f32>;
|
||
|
||
struct Params { numBorders: u32, numConstraints: u32 }
|
||
@group(0) @binding(3) var<uniform> params: Params;
|
||
|
||
@compute @workgroup_size(256)
|
||
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||
if (id.x >= 65536u || params.numBorders > 16u) { return; }
|
||
|
||
// id.x represents a specific combination of mines (1) and safe (0) for borderCells
|
||
var isValid = true;
|
||
|
||
// Check constraints
|
||
for(var c=0u; c<params.numConstraints; c=c+1u) {
|
||
let requiredMines = constraints[c * 17u];
|
||
var actualMines = 0i;
|
||
|
||
for(var b=0u; b<params.numBorders; b=b+1u) {
|
||
let isNeighbor = constraints[c * 17u + 1u + b];
|
||
if(isNeighbor == 1) {
|
||
let hasMine = (id.x & (1u << b)) != 0u;
|
||
if(hasMine) { actualMines = actualMines + 1i; }
|
||
}
|
||
}
|
||
if(actualMines != requiredMines) { isValid = false; break; }
|
||
}
|
||
|
||
if (isValid) {
|
||
// Tally up safe scores for this valid universe
|
||
for(var b=0u; b<params.numBorders; b=b+1u) {
|
||
if((id.x & (1u << b)) == 0u) {
|
||
// It is SAFE in this valid layout
|
||
// Atomic-like add not strictly needed if we partition, but doing simple concurrent write risks race.
|
||
// For WebGPU naive fallback, we write 1 to a huge array and reduce in JS.
|
||
}
|
||
}
|
||
}
|
||
}
|
||
`;
|
||
|
||
// Because pure CSP on GPU for variable length dynamic topologies requires complex radix sorts or atomic arrays,
|
||
// we use a simplified parallel JS DFS permutation for border islands if cells <= 20.
|
||
// For JS, a backtracking solver is very efficient for sweeping local border islands safely!
|
||
function solveCSPFallback(borderCellsList) {
|
||
if(borderCellsList.length === 0) return null;
|
||
|
||
// 1. 根据周边的数字将 borderCells 划分为互不干涉的“独立岛屿 (Connected Components)”
|
||
let constraintsMap = new Map();
|
||
for(let i=0; i<borderCellsList.length; i++) {
|
||
let bc = borderCellsList[i];
|
||
getNeighbors(bc.r, bc.c).forEach(n => {
|
||
if(n.isRevealed && n.neighborMines > 0) {
|
||
let key = `${n.r},${n.c}`;
|
||
if(!constraintsMap.has(key)) constraintsMap.set(key, []);
|
||
constraintsMap.get(key).push(i);
|
||
}
|
||
});
|
||
}
|
||
|
||
let adj = Array.from({length: borderCellsList.length}, () => new Set());
|
||
for(let [_, bIndices] of constraintsMap) {
|
||
for(let i=0; i<bIndices.length; i++) {
|
||
for(let j=i+1; j<bIndices.length; j++) {
|
||
adj[bIndices[i]].add(bIndices[j]);
|
||
adj[bIndices[j]].add(bIndices[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
let visited = new Array(borderCellsList.length).fill(false);
|
||
let components = [];
|
||
for(let i=0; i<borderCellsList.length; i++) {
|
||
if(!visited[i]) {
|
||
let comp = [];
|
||
let q = [i];
|
||
visited[i] = true;
|
||
while(q.length > 0) {
|
||
let curr = q.shift();
|
||
comp.push(curr);
|
||
for(let neighbor of adj[curr]) {
|
||
if(!visited[neighbor]) {
|
||
visited[neighbor] = true;
|
||
q.push(neighbor);
|
||
}
|
||
}
|
||
}
|
||
components.push(comp);
|
||
}
|
||
}
|
||
|
||
let bestGlobalSafe = null;
|
||
let bestGlobalSafeRate = -1;
|
||
let minesRemaining = totalMines - flagsPlaced;
|
||
|
||
// 2. 针对每个被切割后的小体积边墙阵列独立进行 CSP
|
||
for(let comp of components) {
|
||
if(comp.length > 28) continue; // JS 单线程可承受的穷举极限
|
||
|
||
let compCells = comp.map(idx => borderCellsList[idx]);
|
||
let validLayouts = 0;
|
||
let safeCounts = new Array(comp.length).fill(0);
|
||
let compConstraints = [];
|
||
|
||
let relevantNumberKeys = new Set();
|
||
for(let idx of comp) {
|
||
let bc = borderCellsList[idx];
|
||
getNeighbors(bc.r, bc.c).forEach(n => {
|
||
if(n.isRevealed && n.neighborMines > 0) relevantNumberKeys.add(`${n.r},${n.c}`);
|
||
});
|
||
}
|
||
|
||
for(let key of relevantNumberKeys) {
|
||
let [r, c] = key.split(',').map(Number);
|
||
let unrev = getUnrevealedNeighbors(r, c);
|
||
let flags = getFlaggedNeighborsCount(r, c);
|
||
let req = grid[r][c].neighborMines - flags;
|
||
let mask = [];
|
||
unrev.forEach(u => mask.push(compCells.findIndex(b => b.r===u.r && b.c===u.c)));
|
||
compConstraints.push({req, mask: mask.filter(m => m !== -1)});
|
||
}
|
||
|
||
function backtrack(idx, currentAssignment, currentMinesUsed) {
|
||
if(currentMinesUsed > minesRemaining) return;
|
||
|
||
if(idx === comp.length) {
|
||
for(let cons of compConstraints) {
|
||
let sum = 0;
|
||
for(let m of cons.mask) sum += currentAssignment[m];
|
||
if(sum !== cons.req) return;
|
||
}
|
||
validLayouts++;
|
||
for(let i=0; i<comp.length; i++) {
|
||
if(currentAssignment[i] === 0) safeCounts[i]++;
|
||
}
|
||
return;
|
||
}
|
||
currentAssignment[idx] = 0;
|
||
backtrack(idx + 1, currentAssignment, currentMinesUsed);
|
||
currentAssignment[idx] = 1;
|
||
backtrack(idx + 1, currentAssignment, currentMinesUsed + 1);
|
||
}
|
||
|
||
backtrack(0, new Array(comp.length).fill(-1), 0);
|
||
|
||
if(validLayouts > 0) {
|
||
for(let i=0; i<comp.length; i++) {
|
||
let rate = safeCounts[i] / validLayouts;
|
||
// 一旦出现 100% 的生路或死局,立即执行
|
||
if(rate === 1.0) return { action: 'reveal', cell: compCells[i], rate };
|
||
if(rate === 0.0) return { action: 'flag', cell: compCells[i], rate };
|
||
|
||
if(rate > bestGlobalSafeRate) {
|
||
bestGlobalSafeRate = rate;
|
||
bestGlobalSafe = compCells[i];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if(bestGlobalSafeRate >= 0.5 && bestGlobalSafe) {
|
||
return { action: 'reveal', cell: bestGlobalSafe, rate: bestGlobalSafeRate };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function aiStepGPU() {
|
||
// GPU skeleton logic would map here, but dynamic array bounding and graph separation
|
||
// makes JS backtracking significantly faster for 0-20 local nodes.
|
||
}
|
||
|
||
function aiStep() {
|
||
if(isGameOver) {
|
||
toggleAI(false);
|
||
return;
|
||
}
|
||
|
||
if(isFirstClick) {
|
||
// First move always in center
|
||
let r = Math.floor(rows / 2);
|
||
let c = Math.floor(cols / 2);
|
||
placeMines(r, c);
|
||
isFirstClick = false;
|
||
timerInterval = setInterval(() => { timeElapsed++; updateCounters(); }, 1000);
|
||
revealCell(r, c);
|
||
return;
|
||
}
|
||
|
||
// Loop basic logic until stuck
|
||
let madeProgress = applyBasicLogic();
|
||
|
||
// If stuck, use CSP or guess
|
||
if(!madeProgress) {
|
||
// Gather border variables
|
||
let borderCells = [];
|
||
for(let r=0; r<rows; r++) {
|
||
for(let c=0; c<cols; c++) {
|
||
if(!grid[r][c].isRevealed && !grid[r][c].isFlagged) {
|
||
let isB = false;
|
||
getNeighbors(r, c).forEach(n => {
|
||
if(n.isRevealed && n.neighborMines > 0) isB = true;
|
||
});
|
||
if(isB) borderCells.push({r, c});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Try CSP accurate solver for isolated borders
|
||
let cspPick = solveCSPFallback(borderCells);
|
||
if(cspPick) {
|
||
if(cspPick.action === 'reveal') {
|
||
console.log(`CSP Engine Guided Safe Move at (${cspPick.cell.r},${cspPick.cell.c}) - Rate: ${(cspPick.rate*100).toFixed(1)}%`);
|
||
revealCell(cspPick.cell.r, cspPick.cell.c);
|
||
} else if(cspPick.action === 'flag') {
|
||
console.log(`CSP Engine Guided Mine Flag at (${cspPick.cell.r},${cspPick.cell.c}) - 100% Mine`);
|
||
grid[cspPick.cell.r][cspPick.cell.c].isFlagged = true;
|
||
flagsPlaced++;
|
||
renderCell(cspPick.cell.r, cspPick.cell.c);
|
||
updateCounters();
|
||
}
|
||
checkWinCondition();
|
||
} else {
|
||
guessSafestCell();
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleAI(state) {
|
||
aiMode = state !== undefined ? state : !aiMode;
|
||
|
||
const gpuLabel = document.getElementById('gpu-label');
|
||
if(aiMode) {
|
||
aiBtn.innerText = "停止托管";
|
||
gpuLabel.innerHTML = '<input type="checkbox" id="ms-gpu-toggle" checked disabled> 🚀 CSP 约束满足网络已激活 (防爆排雷引擎)';
|
||
gpuLabel.style.color = "#4caf50";
|
||
aiTimer = setInterval(aiStep, 150);
|
||
} else {
|
||
aiBtn.innerText = "🤖 AI 托管 (未启用)";
|
||
clearInterval(aiTimer);
|
||
}
|
||
}
|
||
aiBtn.addEventListener('click', () => toggleAI());
|
||
|
||
// Initialize default difficulty
|
||
setDifficulty('easy');
|
||
});
|
||
</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> |