3864 lines
141 KiB
HTML
3864 lines
141 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>坦克大战 (Tank Battle) - 终极困难版 | 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=":root { --bg: #0a0e14; --card: #151b23; --border: #30363d; --blue: #58a6ff; --red: #f85149; --green: #3fb950; --yellow: #d29922; --text: #c9d1d9; --text2: #8b949e; } body { backgro">
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:title" content="坦克大战 (Tank Battle) - 终极困难版">
|
||
<meta property="og:url" content="https://yourblog.com/tools/tank-battle/index.html">
|
||
<meta property="og:site_name" content="llbzow的摸鱼日记 (づ ̄ 3 ̄)づ">
|
||
<meta property="og:description" content=":root { --bg: #0a0e14; --card: #151b23; --border: #30363d; --blue: #58a6ff; --red: #f85149; --green: #3fb950; --yellow: #d29922; --text: #c9d1d9; --text2: #8b949e; } body { backgro">
|
||
<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-16T02:00:00.000Z">
|
||
<meta property="article:modified_time" content="2026-03-19T08:11:22.612Z">
|
||
<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/tank-battle/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: '坦克大战 (Tank Battle) - 终极困难版',
|
||
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">坦克大战 (Tank Battle) - 终极困难版</h1></header><main class="layout hide-aside" id="content-inner"><div id="page"><div class="page-title">坦克大战 (Tank Battle) - 终极困难版</div><div class="container" id="article-container">
|
||
<style>
|
||
:root {
|
||
--bg: #0a0e14;
|
||
--card: #151b23;
|
||
--border: #30363d;
|
||
--blue: #58a6ff;
|
||
--red: #f85149;
|
||
--green: #3fb950;
|
||
--yellow: #d29922;
|
||
--text: #c9d1d9;
|
||
--text2: #8b949e;
|
||
}
|
||
body { background: var(--bg); color: var(--text); margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; overflow-x: hidden; }
|
||
.tank-app { display: flex; flex-direction: column; min-height: 100vh; padding: 12px; box-sizing: border-box; gap: 12px; }
|
||
.tank-header { display: flex; gap: 10px; flex-wrap: wrap; align-items: stretch; }
|
||
.tank-setup { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 12px; flex: 1; min-width: 140px; display: flex; flex-direction: column; gap: 8px; }
|
||
.tank-setup-title { font-size: 11px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; }
|
||
.tank-setup-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||
.tank-select { flex: 1; min-width: 70px; padding: 6px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 5px; color: var(--text); font-size: 12px; }
|
||
.tank-btn { padding: 8px 14px; border: none; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; transition: 0.2s; white-space: nowrap; }
|
||
.tank-btn-primary { background: linear-gradient(135deg, #238636, #2ea043); color: white; }
|
||
.tank-btn-secondary { background: var(--border); color: var(--text); }
|
||
.tank-main { display: flex; gap: 12px; flex: 1; flex-wrap: wrap; justify-content: center; }
|
||
.tank-canvas-wrap { position: relative; flex: 1; min-width: 300px; max-width: 900px; }
|
||
#tank-canvas { width: 100%; height: auto; aspect-ratio: 4/3; background: #050810; border: 2px solid var(--border); border-radius: 8px; display: block; }
|
||
.tank-side { width: 240px; display: flex; flex-direction: column; gap: 10px; min-width: 200px; }
|
||
.tank-stats, .tank-controls, .tank-tanklist, .tank-engine, .tank-hint { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 12px; }
|
||
.tank-stats-title { font-size: 11px; color: var(--text2); text-transform: uppercase; margin-bottom: 10px; font-weight: 600; }
|
||
.tank-faction-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
|
||
.tank-faction-row:last-child { border: none; }
|
||
.tank-faction-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; }
|
||
.tank-faction-name { flex: 1; }
|
||
.tank-faction-stat { color: var(--text); font-weight: 600; }
|
||
.tank-control-row { display: flex; gap: 6px; margin-bottom: 8px; }
|
||
.tank-control-row:last-child { margin: 0; }
|
||
.tank-control-row .tank-btn { flex: 1; }
|
||
.tank-tanklist { max-height: 220px; overflow-y: auto; }
|
||
.tank-tankitem { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 11px; transition: 0.15s; margin-bottom: 4px; }
|
||
.tank-tankitem:hover { background: rgba(88,166,255,0.1); }
|
||
.tank-tankitem.controlled { background: rgba(88,166,255,0.2); border: 1px solid var(--blue); }
|
||
.tank-tankitem-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||
.tank-tankitem-hp { width: 44px; height: 4px; background: #333; border-radius: 2px; overflow: hidden; }
|
||
.tank-tankitem-hpfill { height: 100%; background: var(--green); }
|
||
.tank-tankitem-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tank-engine-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 11px; }
|
||
.tank-badge { padding: 2px 6px; border-radius: 8px; font-size: 9px; font-weight: 700; background: linear-gradient(135deg, #f78166, #ffa657); color: #fff; }
|
||
.tank-hint { font-size: 10px; color: var(--text2); line-height: 1.5; }
|
||
.tank-minimap { position: absolute; bottom: 10px; left: 10px; width: 120px; height: 120px; background: rgba(0,0,0,0.75); border: 1px solid var(--border); border-radius: 6px; }
|
||
.tank-mobile { display: none; }
|
||
.tank-topbar { display:flex; gap:8px; margin-bottom:8px; flex-wrap:wrap; }
|
||
.tank-chip { background: linear-gradient(135deg,#1a2534,#18202c); border:1px solid #334155; color:#cfe7ff; border-radius:999px; padding:5px 10px; font-size:11px; }
|
||
.tank-legend { display:grid; grid-template-columns: repeat(3,minmax(70px,1fr)); gap:6px; font-size:11px; margin-top:6px; }
|
||
.tank-legend-item { display:flex; align-items:center; gap:5px; }
|
||
.tank-legend-dot { width:10px; height:10px; border-radius:2px; display:inline-block; }
|
||
@media (max-width: 768px) {
|
||
.tank-app { padding: 8px; }
|
||
.tank-main { flex-direction: column; }
|
||
.tank-canvas-wrap { min-width: 100%; max-width: 100%; }
|
||
.tank-side { width: 100%; flex-direction: row; flex-wrap: wrap; }
|
||
.tank-stats, .tank-controls, .tank-tanklist, .tank-engine, .tank-hint { flex: 1; min-width: 150px; }
|
||
.tank-mobile { display: flex; gap: 6px; justify-content: center; margin-top: 8px; }
|
||
}
|
||
.tank-ctrl { width: 50px; height: 50px; background: var(--card); border: 2px solid var(--border); border-radius: 10px; font-size: 18px; color: var(--blue); cursor: pointer; }
|
||
</style>
|
||
|
||
<div class="tank-app">
|
||
<div class="tank-header">
|
||
<div class="tank-setup">
|
||
<div class="tank-setup-title">⚔️ 阵营数量</div>
|
||
<div class="tank-setup-row">
|
||
<select class="tank-select" id="faction-count">
|
||
<option value="2">2 阵营</option>
|
||
<option value="3">3 阵营</option>
|
||
<option value="4" selected="">4 阵营</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="tank-setup">
|
||
<div class="tank-setup-title">👥 每阵人数</div>
|
||
<div class="tank-setup-row">
|
||
<select class="tank-select" id="tanks-per-faction">
|
||
<option value="5">5 / 阵营</option>
|
||
<option value="10" selected="">10 / 阵营</option>
|
||
<option value="20">20 / 阵营</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="tank-setup">
|
||
<div class="tank-setup-title">🧠 AI 难度</div>
|
||
<div class="tank-setup-row">
|
||
<select class="tank-select" id="ai-difficulty" disabled="">
|
||
<option value="ultimate" selected="">终极困难(固定)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="tank-setup" style="flex:0;">
|
||
<div class="tank-setup-title">▶️ 开局</div>
|
||
<div class="tank-setup-row">
|
||
<button class="tank-btn tank-btn-primary" id="btn-start">开始</button>
|
||
<button class="tank-btn tank-btn-secondary" id="btn-reset">重置</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tank-main">
|
||
<div class="tank-canvas-wrap">
|
||
<div class="tank-topbar" id="battle-topbar"></div>
|
||
<canvas id="tank-canvas"></canvas>
|
||
<canvas id="minimap" class="tank-minimap" width="120" height="120"></canvas>
|
||
<div class="tank-mobile">
|
||
<button class="tank-ctrl" id="m-up">⬆️</button>
|
||
<button class="tank-ctrl" id="m-left">⬅️</button>
|
||
<button class="tank-ctrl" id="m-down">⬇️</button>
|
||
<button class="tank-ctrl" id="m-right">➡️</button>
|
||
<button class="tank-ctrl" id="m-fire" style="background:var(--red);border-color:var(--red);color:white;">🔫</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tank-side">
|
||
<div class="tank-stats">
|
||
<div class="tank-stats-title">📊 战场状态(基地制)</div>
|
||
<div id="faction-stats"></div>
|
||
</div>
|
||
|
||
<div class="tank-controls">
|
||
<div class="tank-stats-title">🎮 控制</div>
|
||
<div class="tank-control-row">
|
||
<label style="font-size:11px;color:var(--text2);">缩放</label>
|
||
<input id="zoom-range" type="range" min="0.6" max="2.2" step="0.1" value="1" style="flex:1;">
|
||
</div>
|
||
<div class="tank-control-row">
|
||
<select class="tank-select" id="follow-mode">
|
||
<option value="auto" selected="">跟随: 自动</option>
|
||
<option value="player">跟随: 玩家</option>
|
||
<option value="center">跟随: 战场中心</option>
|
||
<option value="free">跟随: 自由视角</option>
|
||
</select>
|
||
</div>
|
||
<div class="tank-control-row">
|
||
<button class="tank-btn tank-btn-secondary" id="btn-view-center" style="flex:1;">📍 回到中心</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tank-tanklist">
|
||
<div class="tank-stats-title">🚩 接管坦克 (蓝方)</div>
|
||
<div id="tank-list"></div>
|
||
</div>
|
||
|
||
<div class="tank-engine">
|
||
<div class="tank-stats-title">⚡ 引擎指标</div>
|
||
<div id="engine-display"></div>
|
||
</div>
|
||
|
||
<div class="tank-hint">
|
||
地图 720×720,坦克 2×2,基地 16×16。<br>
|
||
河流不可通行,只有桥可过河。炮弹射程 60,存在飞行时间,可被规避。<br>
|
||
地形速度:平原x1,公路x3,沼泽x0.2。<br>
|
||
操作:WASD/方向键移动,空格射击。
|
||
<div class="tank-stats-title" style="margin-top:8px;">🗺 地形图例</div>
|
||
<div class="tank-legend">
|
||
<div class="tank-legend-item"><span class="tank-legend-dot" style="background:#11151d"></span>平原</div>
|
||
<div class="tank-legend-item"><span class="tank-legend-dot" style="background:#7a7f88"></span>公路</div>
|
||
<div class="tank-legend-item"><span class="tank-legend-dot" style="background:#1f4e86"></span>河流</div>
|
||
<div class="tank-legend-item"><span class="tank-legend-dot" style="background:#b08968"></span>桥</div>
|
||
<div class="tank-legend-item"><span class="tank-legend-dot" style="background:#4a5d3c"></span>沼泽</div>
|
||
<div class="tank-legend-item"><span class="tank-legend-dot" style="background:#2f343d"></span>障碍</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function() {
|
||
const canvas = document.getElementById('tank-canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const miniCanvas = document.getElementById('minimap');
|
||
const miniCtx = miniCanvas.getContext('2d');
|
||
|
||
// ==================== Constants ====================
|
||
const WORLD = 720;
|
||
const TILE = 2;
|
||
const NAV = 6;
|
||
let VIEW_W = 900, VIEW_H = 675;
|
||
|
||
const TEAM_COLORS = {0:'#58a6ff',1:'#f85149',2:'#3fb950',3:'#d29922'};
|
||
const TEAM_NAMES = ['蓝','红','绿','黄'];
|
||
const TEAM_MAX = 4;
|
||
|
||
const TANK_HALF = 1; // 坦克 2x2
|
||
const BASE_HALF = 8; // 基地 16x16
|
||
|
||
const MAX_HP = 120;
|
||
const FIRE_CD = 28;
|
||
const RESPAWN_T = 300;
|
||
const BASE_HP = 1800;
|
||
|
||
const BULLET_SPEED = 3.2;
|
||
const BULLET_RANGE = 100;
|
||
const BULLET_DAMAGE = 20;
|
||
const BULLET_AOE_RADIUS = 4;
|
||
const BARRAGE_COUNT = 5;
|
||
const BARRAGE_SPREAD = 0.52;
|
||
const BARRAGE_SCATTER = 0.08;
|
||
const FORCE_RATIO = { tank: 0.6, artillery: 0.2, bomber: 0.2 };
|
||
|
||
const UNIT_CFG = {
|
||
tank: { hp: MAX_HP, speedMul: 1.0, range: BULLET_RANGE, projectileSpeed: BULLET_SPEED, damage: BULLET_DAMAGE, aoe: BULLET_AOE_RADIUS, canFire: true },
|
||
artillery: { hp: 5, speedMul: 0.5, range: 200, projectileSpeed: BULLET_SPEED * 0.5, damage: 26, aoe: 10, canFire: true },
|
||
bomber: { hp: 3, speedMul: 2.25, range: 34, projectileSpeed: BULLET_SPEED, damage: 42, aoe: 20, canFire: false }
|
||
};
|
||
const BASE_DAMAGE = 28;
|
||
const BASE_HIT_SCORE = 15;
|
||
const BASE_DESTROY_SCORE = 2200;
|
||
|
||
const AI_TICK_BUDGET = 26;
|
||
const FOCUS_LIMIT = 3;
|
||
const DANGER_RADIUS = 90;
|
||
const GPU_MAX_TANKS = 128;
|
||
|
||
const TERRAIN = {
|
||
PLAIN: 0,
|
||
ROAD: 1,
|
||
RIVER: 2,
|
||
BRIDGE: 3,
|
||
WALL: 4,
|
||
BASE: 5,
|
||
SWAMP: 6
|
||
};
|
||
const TERRAIN_COLOR = {
|
||
0: '#11151d',
|
||
1: '#7a7f88',
|
||
2: '#1f4e86',
|
||
3: '#b08968',
|
||
4: '#2f343d',
|
||
5: '#3a2f46',
|
||
6: '#4a5d3c'
|
||
};
|
||
|
||
// ==================== State ====================
|
||
let factions = 4, perTeam = 10;
|
||
let gameRunning = false;
|
||
let controlledTank = null;
|
||
let keys = {};
|
||
let camera = {x: WORLD / 2, y: WORLD / 2};
|
||
|
||
let tanks = [];
|
||
let bullets = [];
|
||
let stats = {};
|
||
let bases = [];
|
||
|
||
let terrain = [];
|
||
let navGrid = [];
|
||
let frameNo = 0;
|
||
let aiCursor = 0;
|
||
let teamFocus = {};
|
||
let teamRespawnState = {};
|
||
const pathCache = new Map();
|
||
let zoom = 1;
|
||
let followMode = 'auto';
|
||
let dragging = false;
|
||
let lastDrag = null;
|
||
let teamTactic = {};
|
||
let roadNodes = [];
|
||
let roadAdj = [];
|
||
let webgpu = {
|
||
supported: false,
|
||
ready: false,
|
||
device: null,
|
||
queue: null,
|
||
pipeline: null,
|
||
bindGroup: null,
|
||
tankBuf: null,
|
||
paramBuf: null,
|
||
outBuf: null,
|
||
readBuf: null,
|
||
tankData: null,
|
||
outData: null,
|
||
pending: false,
|
||
enabled: true
|
||
};
|
||
|
||
const metrics = {
|
||
frameMsAvg: 0,
|
||
replan: 0,
|
||
evade: 0,
|
||
riverBlocked: 0,
|
||
riverWaypoints: 0,
|
||
bridgeCross: 0,
|
||
jpsHits: 0,
|
||
jpsMiss: 0,
|
||
hierPath: 0,
|
||
riverDetour: 0
|
||
};
|
||
|
||
// ==================== Helpers ====================
|
||
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
||
function rand(a, b) { return a + Math.random() * (b - a); }
|
||
function keyOf(x, y) { return `${x},${y}`; }
|
||
function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
|
||
function worldToCell(x, y) {
|
||
return { x: clamp(Math.floor(x / NAV), 0, Math.floor(WORLD / NAV) - 1), y: clamp(Math.floor(y / NAV), 0, Math.floor(WORLD / NAV) - 1) };
|
||
}
|
||
|
||
function resize() {
|
||
const wrap = canvas.parentElement;
|
||
const w = Math.min(wrap.clientWidth, 900);
|
||
canvas.width = w;
|
||
canvas.height = w * 0.75;
|
||
VIEW_W = canvas.width;
|
||
VIEW_H = canvas.height;
|
||
}
|
||
window.addEventListener('resize', resize);
|
||
resize();
|
||
|
||
function worldToScr(wx, wy) {
|
||
const s = TILE * zoom;
|
||
return { x: (wx - camera.x) * s + VIEW_W / 2, y: (wy - camera.y) * s + VIEW_H / 2 };
|
||
}
|
||
|
||
function terrainAt(x, y) {
|
||
const gx = Math.floor(x), gy = Math.floor(y);
|
||
if (gx < 0 || gx >= WORLD || gy < 0 || gy >= WORLD) return TERRAIN.WALL;
|
||
return terrain[gy][gx];
|
||
}
|
||
|
||
function nearBridge(x, y, r = 3) {
|
||
for (let oy = -r; oy <= r; oy++) {
|
||
for (let ox = -r; ox <= r; ox++) {
|
||
if (terrainAt(x + ox, y + oy) === TERRAIN.BRIDGE) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function isTerrainPassable(t) {
|
||
return t === TERRAIN.PLAIN || t === TERRAIN.ROAD || t === TERRAIN.BRIDGE || t === TERRAIN.BASE;
|
||
}
|
||
|
||
function speedByTerrain(t) {
|
||
if (t === TERRAIN.ROAD) return 3;
|
||
if (t === TERRAIN.SWAMP) return 0.2;
|
||
if (t === TERRAIN.BRIDGE) return 1.0;
|
||
if (t === TERRAIN.BASE) return 0.95;
|
||
return 1.0;
|
||
}
|
||
|
||
function costByTerrain(t) {
|
||
if (t === TERRAIN.ROAD) return 0.35;
|
||
if (t === TERRAIN.SWAMP) return 5.2;
|
||
if (t === TERRAIN.BRIDGE) return 1.0;
|
||
return 1.0;
|
||
}
|
||
|
||
function refineLocalMove(tank, vx, vy) {
|
||
const x = tank.x, y = tank.y;
|
||
const len = Math.hypot(vx, vy) || 1;
|
||
const dx = vx / len, dy = vy / len;
|
||
const candidates = [
|
||
{x: dx, y: dy},
|
||
{x: Math.sign(dx), y: 0},
|
||
{x: 0, y: Math.sign(dy)},
|
||
{x: -dy, y: dx},
|
||
{x: dy, y: -dx}
|
||
];
|
||
let best = {x: 0, y: 0, score: -1e9};
|
||
for (const c of candidates) {
|
||
const l = Math.hypot(c.x, c.y) || 1;
|
||
const ux = c.x / l, uy = c.y / l;
|
||
const nx = x + ux * 1.0, ny = y + uy * 1.0;
|
||
const t = terrainAt(nx, ny);
|
||
const blocked = !isTerrainPassable(t);
|
||
let s = ux * dx + uy * dy;
|
||
s -= costByTerrain(t) * 0.35;
|
||
if (t === TERRAIN.ROAD || t === TERRAIN.BRIDGE) s += 0.35;
|
||
if (t === TERRAIN.RIVER) s -= 4;
|
||
if (tank.ctrlDir) s += (ux * tank.ctrlDir.x + uy * tank.ctrlDir.y) * 0.45;
|
||
if (blocked) s -= 8;
|
||
if (s > best.score) best = {x: ux, y: uy, score: s};
|
||
}
|
||
return {x: best.x, y: best.y};
|
||
}
|
||
|
||
function stabilizeMoveVector(tank, vx, vy) {
|
||
const len = Math.hypot(vx, vy) || 1;
|
||
let tx = vx / len, ty = vy / len;
|
||
const prev = tank.ctrlDir || {x: 0, y: 0};
|
||
const prevLen = Math.hypot(prev.x, prev.y);
|
||
|
||
if (prevLen > 0.01) {
|
||
const dot = prev.x * tx + prev.y * ty;
|
||
if (dot < 0.15) tank.turnHold = 3;
|
||
}
|
||
if ((tank.turnHold || 0) > 0 && prevLen > 0.01) {
|
||
tx = prev.x * 0.75 + tx * 0.25;
|
||
ty = prev.y * 0.75 + ty * 0.25;
|
||
tank.turnHold = (tank.turnHold || 0) - 1;
|
||
} else {
|
||
tx = prev.x * 0.62 + tx * 0.38;
|
||
ty = prev.y * 0.62 + ty * 0.38;
|
||
}
|
||
|
||
const outLen = Math.hypot(tx, ty) || 1;
|
||
tank.ctrlDir = {x: tx / outLen, y: ty / outLen};
|
||
return tank.ctrlDir;
|
||
}
|
||
|
||
function rectHitBlocked(x, y, half) {
|
||
const pts = [
|
||
{x: x - half, y: y - half},
|
||
{x: x + half, y: y - half},
|
||
{x: x - half, y: y + half},
|
||
{x: x + half, y: y + half}
|
||
];
|
||
for (const p of pts) {
|
||
const t = terrainAt(p.x, p.y);
|
||
if (!isTerrainPassable(t)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function isRoadLike(t) {
|
||
return t === TERRAIN.ROAD || t === TERRAIN.BRIDGE;
|
||
}
|
||
|
||
function buildRoadBridgeGraph() {
|
||
roadNodes = [];
|
||
roadAdj = [];
|
||
const nodeMap = new Map();
|
||
const stride = 18;
|
||
|
||
function addNode(x, y) {
|
||
const k = `${x},${y}`;
|
||
if (nodeMap.has(k)) return nodeMap.get(k);
|
||
const id = roadNodes.length;
|
||
roadNodes.push({id, x, y});
|
||
roadAdj.push([]);
|
||
nodeMap.set(k, id);
|
||
return id;
|
||
}
|
||
|
||
// 取桥面与道路交叉点为节点,避免“断桥不可用”
|
||
for (let y = 4; y < WORLD - 4; y++) {
|
||
for (let x = 4; x < WORLD - 4; x++) {
|
||
const t = terrain[y][x];
|
||
if (!isRoadLike(t)) continue;
|
||
const isBridge = t === TERRAIN.BRIDGE;
|
||
const cross = isRoadLike(terrain[y - 1][x]) && isRoadLike(terrain[y + 1][x]) && isRoadLike(terrain[y][x - 1]) && isRoadLike(terrain[y][x + 1]);
|
||
const sampled = (x % stride === 0 && y % stride === 0);
|
||
if (isBridge || cross || sampled) addNode(x, y);
|
||
}
|
||
}
|
||
|
||
function roadLineClear(x0, y0, x1, y1) {
|
||
const dx = x1 - x0, dy = y1 - y0;
|
||
const st = Math.max(Math.abs(dx), Math.abs(dy));
|
||
for (let i = 0; i <= st; i++) {
|
||
const t = i / Math.max(1, st);
|
||
const x = Math.round(x0 + dx * t), y = Math.round(y0 + dy * t);
|
||
if (!isRoadLike(terrainAt(x, y))) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// 四向连边(同 x 或同 y 的最近节点)
|
||
const bucketsX = new Map();
|
||
const bucketsY = new Map();
|
||
for (const n of roadNodes) {
|
||
if (!bucketsX.has(n.x)) bucketsX.set(n.x, []);
|
||
if (!bucketsY.has(n.y)) bucketsY.set(n.y, []);
|
||
bucketsX.get(n.x).push(n);
|
||
bucketsY.get(n.y).push(n);
|
||
}
|
||
|
||
function linkLine(list, horizontal) {
|
||
list.sort((a, b) => horizontal ? a.x - b.x : a.y - b.y);
|
||
for (let i = 0; i < list.length - 1; i++) {
|
||
const a = list[i], b = list[i + 1];
|
||
const gap = horizontal ? Math.abs(b.x - a.x) : Math.abs(b.y - a.y);
|
||
if (gap > 120) continue;
|
||
if (!roadLineClear(a.x, a.y, b.x, b.y)) continue;
|
||
const w = Math.hypot(b.x - a.x, b.y - a.y);
|
||
roadAdj[a.id].push({to: b.id, w});
|
||
roadAdj[b.id].push({to: a.id, w});
|
||
}
|
||
}
|
||
|
||
for (const [, list] of bucketsX) linkLine(list, false);
|
||
for (const [, list] of bucketsY) linkLine(list, true);
|
||
}
|
||
|
||
function nearestRoadNode(x, y, maxR = 90) {
|
||
let best = -1, bestD = Infinity;
|
||
for (const n of roadNodes) {
|
||
const d = Math.hypot(n.x - x, n.y - y);
|
||
if (d < bestD && d <= maxR) { bestD = d; best = n.id; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function findRoadPath(startId, goalId) {
|
||
if (startId < 0 || goalId < 0) return null;
|
||
if (startId === goalId) return [roadNodes[startId]];
|
||
const dist = new Float32Array(roadNodes.length);
|
||
dist.fill(Infinity);
|
||
const prev = new Int32Array(roadNodes.length);
|
||
prev.fill(-1);
|
||
const used = new Uint8Array(roadNodes.length);
|
||
dist[startId] = 0;
|
||
|
||
for (let iter = 0; iter < roadNodes.length; iter++) {
|
||
let u = -1, best = Infinity;
|
||
for (let i = 0; i < roadNodes.length; i++) {
|
||
if (!used[i] && dist[i] < best) { best = dist[i]; u = i; }
|
||
}
|
||
if (u < 0) break;
|
||
if (u === goalId) break;
|
||
used[u] = 1;
|
||
for (const e of roadAdj[u]) {
|
||
const nd = dist[u] + e.w;
|
||
if (nd < dist[e.to]) { dist[e.to] = nd; prev[e.to] = u; }
|
||
}
|
||
}
|
||
if (prev[goalId] < 0) return null;
|
||
const path = [];
|
||
let cur = goalId;
|
||
while (cur >= 0) {
|
||
path.push(roadNodes[cur]);
|
||
cur = prev[cur];
|
||
}
|
||
path.reverse();
|
||
return path;
|
||
}
|
||
|
||
async function initWebGPUAI() {
|
||
if (!navigator.gpu) return;
|
||
try {
|
||
const adapter = await navigator.gpu.requestAdapter();
|
||
if (!adapter) return;
|
||
const device = await adapter.requestDevice();
|
||
const shader = device.createShaderModule({
|
||
code: `
|
||
struct TankData {
|
||
p0: vec4<f32>, // x y dirx diry
|
||
p1: vec4<f32>, // hp team alive cd
|
||
}
|
||
|
||
@group(0) @binding(0) var<storage, read> tanks: array<TankData>;
|
||
@group(0) @binding(1) var<storage, read_write> outv: array<vec4<f32>>;
|
||
@group(0) @binding(2) var<uniform> params: vec4<f32>; // n, world, t, reserved
|
||
|
||
@compute @workgroup_size(64)
|
||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||
let i = gid.x;
|
||
let n = u32(params.x);
|
||
if (i >= n) { return; }
|
||
|
||
let me = tanks[i];
|
||
if (me.p1.z < 0.5) {
|
||
outv[i] = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
||
return;
|
||
}
|
||
|
||
let myTeam = me.p1.y;
|
||
var best = 1e9;
|
||
var tx = me.p0.x;
|
||
var ty = me.p0.y;
|
||
|
||
var j: u32 = 0u;
|
||
loop {
|
||
if (j >= n) { break; }
|
||
let ot = tanks[j];
|
||
if (ot.p1.z > 0.5 && ot.p1.y != myTeam) {
|
||
let dx = ot.p0.x - me.p0.x;
|
||
let dy = ot.p0.y - me.p0.y;
|
||
let d2 = dx * dx + dy * dy;
|
||
if (d2 < best) {
|
||
best = d2;
|
||
tx = ot.p0.x;
|
||
ty = ot.p0.y;
|
||
}
|
||
}
|
||
j = j + 1u;
|
||
}
|
||
|
||
var vx = 0.0;
|
||
var vy = 0.0;
|
||
let dx = tx - me.p0.x;
|
||
let dy = ty - me.p0.y;
|
||
let d = sqrt(max(best, 0.0001));
|
||
|
||
if (d > 14.0) {
|
||
vx = dx / d;
|
||
vy = dy / d;
|
||
} else {
|
||
vx = -dy / d;
|
||
vy = dx / d;
|
||
}
|
||
|
||
let canFire = select(0.0, 1.0, d <= 60.0 && me.p1.w <= 0.0);
|
||
outv[i] = vec4<f32>(vx, vy, canFire, d);
|
||
}`
|
||
});
|
||
|
||
const pipeline = device.createComputePipeline({
|
||
layout: 'auto',
|
||
compute: { module: shader, entryPoint: 'main' }
|
||
});
|
||
|
||
const tankBuf = device.createBuffer({ size: GPU_MAX_TANKS * 32, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
||
const outBuf = device.createBuffer({ size: GPU_MAX_TANKS * 16, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
|
||
const readBuf = device.createBuffer({ size: GPU_MAX_TANKS * 16, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
|
||
const paramBuf = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||
|
||
const bindGroup = device.createBindGroup({
|
||
layout: pipeline.getBindGroupLayout(0),
|
||
entries: [
|
||
{ binding: 0, resource: { buffer: tankBuf } },
|
||
{ binding: 1, resource: { buffer: outBuf } },
|
||
{ binding: 2, resource: { buffer: paramBuf } }
|
||
]
|
||
});
|
||
|
||
webgpu.supported = true;
|
||
webgpu.ready = true;
|
||
webgpu.device = device;
|
||
webgpu.queue = device.queue;
|
||
webgpu.pipeline = pipeline;
|
||
webgpu.bindGroup = bindGroup;
|
||
webgpu.tankBuf = tankBuf;
|
||
webgpu.outBuf = outBuf;
|
||
webgpu.readBuf = readBuf;
|
||
webgpu.paramBuf = paramBuf;
|
||
webgpu.tankData = new Float32Array(GPU_MAX_TANKS * 8);
|
||
webgpu.outData = new Float32Array(GPU_MAX_TANKS * 4);
|
||
} catch (e) {
|
||
webgpu.ready = false;
|
||
}
|
||
}
|
||
|
||
function dispatchWebGPUAI() {
|
||
if (!webgpu.ready || webgpu.pending || !webgpu.enabled) return;
|
||
const n = Math.min(tanks.length, GPU_MAX_TANKS);
|
||
|
||
for (let i = 0; i < n; i++) {
|
||
const t = tanks[i];
|
||
const o = i * 8;
|
||
webgpu.tankData[o + 0] = t.x;
|
||
webgpu.tankData[o + 1] = t.y;
|
||
webgpu.tankData[o + 2] = t.dir.x;
|
||
webgpu.tankData[o + 3] = t.dir.y;
|
||
webgpu.tankData[o + 4] = t.hp;
|
||
webgpu.tankData[o + 5] = t.team;
|
||
webgpu.tankData[o + 6] = t.alive ? 1 : 0;
|
||
webgpu.tankData[o + 7] = t.cd;
|
||
}
|
||
|
||
webgpu.queue.writeBuffer(webgpu.tankBuf, 0, webgpu.tankData, 0, n * 8);
|
||
const params = new Float32Array([n, WORLD, frameNo, 0]);
|
||
webgpu.queue.writeBuffer(webgpu.paramBuf, 0, params);
|
||
|
||
const encoder = webgpu.device.createCommandEncoder();
|
||
const pass = encoder.beginComputePass();
|
||
pass.setPipeline(webgpu.pipeline);
|
||
pass.setBindGroup(0, webgpu.bindGroup);
|
||
pass.dispatchWorkgroups(Math.ceil(n / 64));
|
||
pass.end();
|
||
encoder.copyBufferToBuffer(webgpu.outBuf, 0, webgpu.readBuf, 0, n * 16);
|
||
webgpu.queue.submit([encoder.finish()]);
|
||
|
||
webgpu.pending = true;
|
||
webgpu.readBuf.mapAsync(GPUMapMode.READ).then(() => {
|
||
const view = new Float32Array(webgpu.readBuf.getMappedRange());
|
||
webgpu.outData.set(view.subarray(0, n * 4), 0);
|
||
webgpu.readBuf.unmap();
|
||
webgpu.pending = false;
|
||
}).catch(() => { webgpu.pending = false; });
|
||
}
|
||
|
||
function applyWebGPUAIActions() {
|
||
if (!webgpu.ready || !webgpu.outData) return;
|
||
const n = Math.min(tanks.length, GPU_MAX_TANKS);
|
||
for (let i = 0; i < n; i++) {
|
||
const t = tanks[i];
|
||
if (!t.alive || t === controlledTank) continue;
|
||
const o = i * 4;
|
||
const vx = webgpu.outData[o + 0] || 0;
|
||
const vy = webgpu.outData[o + 1] || 0;
|
||
const fire = webgpu.outData[o + 2] || 0;
|
||
const refined = refineLocalMove(t, vx, vy);
|
||
const stable = stabilizeMoveVector(t, refined.x, refined.y);
|
||
t.move(stable.x, stable.y);
|
||
if (fire > 0.5) {
|
||
const tx = t.x + t.dir.x * 20;
|
||
const ty = t.y + t.dir.y * 20;
|
||
t.fire(tx, ty);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== Terrain Generation ====================
|
||
function lineDraw(x0, y0, x1, y1, width, type) {
|
||
const dx = x1 - x0, dy = y1 - y0;
|
||
const steps = Math.max(1, Math.floor(Math.hypot(dx, dy)));
|
||
for (let i = 0; i <= steps; i++) {
|
||
const t = i / steps;
|
||
const x = Math.floor(x0 + dx * t);
|
||
const y = Math.floor(y0 + dy * t);
|
||
for (let oy = -width; oy <= width; oy++) {
|
||
for (let ox = -width; ox <= width; ox++) {
|
||
const nx = x + ox, ny = y + oy;
|
||
if (nx < 0 || nx >= WORLD || ny < 0 || ny >= WORLD) continue;
|
||
terrain[ny][nx] = type;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function makeTerrain() {
|
||
terrain = Array.from({length: WORLD}, () => Array(WORLD).fill(TERRAIN.PLAIN));
|
||
|
||
// 边界墙
|
||
for (let y = 0; y < WORLD; y++) {
|
||
for (let x = 0; x < WORLD; x++) {
|
||
if (x < 3 || y < 3 || x > WORLD - 4 || y > WORLD - 4) terrain[y][x] = TERRAIN.WALL;
|
||
}
|
||
}
|
||
|
||
// 公路/河流/桥系统暂时禁用(后续逐步恢复)
|
||
|
||
// 障碍块已禁用
|
||
// for (let i = 0; i < 180; i++) {
|
||
// const cx = Math.floor(rand(30, WORLD - 30));
|
||
// const cy = Math.floor(rand(30, WORLD - 30));
|
||
// const r = Math.floor(rand(3, 8));
|
||
// for (let y = -r; y <= r; y++) {
|
||
// for (let x = -r; x <= r; x++) {
|
||
// const nx = cx + x, ny = cy + y;
|
||
// if (nx < 4 || ny < 4 || nx >= WORLD - 4 || ny >= WORLD - 4) continue;
|
||
// if (Math.hypot(x, y) <= r && Math.random() > 0.25) {
|
||
// if (terrain[ny][nx] !== TERRAIN.RIVER && terrain[ny][nx] !== TERRAIN.BRIDGE) terrain[ny][nx] = TERRAIN.WALL;
|
||
// }
|
||
// }
|
||
// }
|
||
// }
|
||
|
||
// 沼泽区已禁用
|
||
// for (let i = 0; i < 80; i++) {
|
||
// const cx = Math.floor(rand(20, WORLD - 20));
|
||
// const cy = Math.floor(rand(20, WORLD - 20));
|
||
// const rx = Math.floor(rand(6, 16));
|
||
// const ry = Math.floor(rand(6, 14));
|
||
// for (let y = -ry; y <= ry; y++) {
|
||
// for (let x = -rx; x <= rx; x++) {
|
||
// const nx = cx + x, ny = cy + y;
|
||
// if (nx < 4 || ny < 4 || nx >= WORLD - 4 || ny >= WORLD - 4) continue;
|
||
// const inEllipse = (x * x) / (rx * rx) + (y * y) / (ry * ry) <= 1;
|
||
// if (!inEllipse) continue;
|
||
// if (terrain[ny][nx] === TERRAIN.PLAIN && Math.random() > 0.12) terrain[ny][nx] = TERRAIN.SWAMP;
|
||
// }
|
||
// }
|
||
// }
|
||
}
|
||
|
||
console.log('[makeTerrain] terrain initialized, size:', terrain.length, 'x', (terrain[0] ? terrain[0].length : 0));
|
||
|
||
function paintBaseArea(base) {
|
||
for (let y = Math.floor(base.y - BASE_HALF); y <= Math.floor(base.y + BASE_HALF); y++) {
|
||
for (let x = Math.floor(base.x - BASE_HALF); x <= Math.floor(base.x + BASE_HALF); x++) {
|
||
if (x < 1 || y < 1 || x >= WORLD - 1 || y >= WORLD - 1) continue;
|
||
terrain[y][x] = TERRAIN.BASE;
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildNavGrid() {
|
||
const CW = Math.floor(WORLD / NAV);
|
||
const CH = Math.floor(WORLD / NAV);
|
||
navGrid = Array.from({length: CH}, () => Array(CW).fill(0));
|
||
for (let cy = 0; cy < CH; cy++) {
|
||
for (let cx = 0; cx < CW; cx++) {
|
||
let blocked = 0;
|
||
for (let oy = 0; oy < NAV; oy += 2) {
|
||
for (let ox = 0; ox < NAV; ox += 2) {
|
||
const tx = cx * NAV + ox;
|
||
const ty = cy * NAV + oy;
|
||
const t = terrain[ty][tx];
|
||
if (!isTerrainPassable(t)) blocked++;
|
||
}
|
||
}
|
||
navGrid[cy][cx] = blocked >= 3 ? 1 : 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
function isCellFree(x, y) {
|
||
return y >= 0 && y < navGrid.length && x >= 0 && x < navGrid[0].length && !navGrid[y][x];
|
||
}
|
||
|
||
function nearestFreeCell(cell, maxR = 8) {
|
||
if (isCellFree(cell.x, cell.y)) return {x: cell.x, y: cell.y};
|
||
for (let r = 1; r <= maxR; r++) {
|
||
for (let oy = -r; oy <= r; oy++) {
|
||
for (let ox = -r; ox <= r; ox++) {
|
||
if (Math.abs(ox) !== r && Math.abs(oy) !== r) continue;
|
||
const nx = cell.x + ox, ny = cell.y + oy;
|
||
if (isCellFree(nx, ny)) return {x: nx, y: ny};
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getRespawnState(team) {
|
||
if (!teamRespawnState[team]) {
|
||
teamRespawnState[team] = { cooldown: 0, pressure: 0, recentDeaths: 0 };
|
||
}
|
||
return teamRespawnState[team];
|
||
}
|
||
|
||
function calcRespawnTime(team, tank) {
|
||
const st = getRespawnState(team);
|
||
const alive = tanks.filter(x => x.alive && x.team === team).length;
|
||
const deadCount = Math.max(0, perTeam - alive);
|
||
const t = RESPAWN_T + 180 + (tank?.respawnExtra || 0)
|
||
+ st.cooldown * 1.35
|
||
+ st.pressure * 260
|
||
+ deadCount * 52;
|
||
return Math.floor(clamp(t, 420, 12000));
|
||
}
|
||
|
||
function onTeamDeathPenalty(team) {
|
||
const st = getRespawnState(team);
|
||
st.recentDeaths += 1;
|
||
st.pressure = clamp(st.pressure + 2.8, 0, 60);
|
||
st.cooldown = clamp(st.cooldown + 55 + st.recentDeaths * 14, 0, 3200);
|
||
}
|
||
|
||
function octile(a, b) {
|
||
const dx = Math.abs(a.x - b.x), dy = Math.abs(a.y - b.y);
|
||
return dx + dy + (Math.SQRT2 - 2) * Math.min(dx, dy);
|
||
}
|
||
|
||
// ==================== Jump Point Search (JPS) ====================
|
||
|
||
const JPS_DIRS_4 = [[1,0],[-1,0],[0,1],[0,-1]];
|
||
const JPS_DIRS_DIAG = [[1,1],[1,-1],[-1,1],[-1,-1]];
|
||
const JPS_DIRS_8 = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
||
|
||
function jpsCanPass(x, y) {
|
||
return y >= 0 && y < navGrid.length && x >= 0 && x < navGrid[0].length && !navGrid[y][x];
|
||
}
|
||
|
||
function jpsCanMoveStraight(x, y, dx, dy) {
|
||
const nx = x + dx, ny = y + dy;
|
||
if (dx !== 0 && dy !== 0) {
|
||
return jpsCanPass(nx, y) && jpsCanPass(x, ny);
|
||
}
|
||
return jpsCanPass(nx, ny);
|
||
}
|
||
|
||
function jpsJump(x, y, dx, dy, goal, maxJump) {
|
||
let px = x - dx, py = y - dy;
|
||
let steps = 0;
|
||
const isDiag = dx !== 0 && dy !== 0;
|
||
const stepCost = isDiag ? 1.414 : 1.0;
|
||
|
||
while (steps < maxJump) {
|
||
steps++;
|
||
const nx = x + dx * steps;
|
||
const ny = y + dy * steps;
|
||
|
||
if (!jpsCanPass(nx, ny)) return [];
|
||
|
||
if (nx === goal.x && ny === goal.y) {
|
||
return [{ x: nx, y: ny, g: steps * stepCost }];
|
||
}
|
||
|
||
if (isDiag) {
|
||
if (!jpsCanPass(nx - dx, ny) && jpsCanPass(nx - dx, ny - dy)) return [{ x: nx, y: ny, g: steps * stepCost }];
|
||
if (!jpsCanPass(nx, ny - dy) && jpsCanPass(nx - dx, ny - dy)) return [{ x: nx, y: ny, g: steps * stepCost }];
|
||
} else {
|
||
if (dx !== 0 && !jpsCanPass(nx, ny - 1) && jpsCanPass(nx, ny + 1)) return [{ x: nx, y: ny, g: steps * stepCost }];
|
||
if (dy !== 0 && !jpsCanPass(nx - 1, ny) && jpsCanPass(nx + 1, ny)) return [{ x: nx, y: ny, g: steps * stepCost }];
|
||
}
|
||
|
||
if (isDiag) {
|
||
const hx = nx, hy = ny - dy;
|
||
const vx = nx - dx, vy = ny;
|
||
if (!jpsCanPass(hx, hy) || !jpsCanPass(vx, vy)) return [];
|
||
}
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function jpsGetNeighbors(x, y, pdx, pdy) {
|
||
const nb = [];
|
||
const isDiag = pdx !== 0 && pdy !== 0;
|
||
|
||
if (pdx === 0 && pdy === 0) {
|
||
for (const [dx, dy] of JPS_DIRS_8) {
|
||
if (jpsCanMoveStraight(x, y, dx, dy)) nb.push({ x: x + dx, y: y + dy, dx, dy });
|
||
}
|
||
} else if (isDiag) {
|
||
for (const [dx, dy] of JPS_DIRS_4) {
|
||
if (jpsCanMoveStraight(x, y, dx, dy)) nb.push({ x: x + dx, y: y + dy, dx, dy });
|
||
}
|
||
if (jpsCanMoveStraight(x, y, pdx, pdy)) nb.push({ x: x + pdx, y: y + pdy, dx: pdx, dy: pdy });
|
||
} else {
|
||
if (jpsCanMoveStraight(x, y, pdx, pdy)) nb.push({ x: x + pdx, y: y + pdy, dx: pdx, dy: pdy });
|
||
const fdx = -pdy, fdy = pdx;
|
||
if (jpsCanMoveStraight(x, y, fdx, fdy)) nb.push({ x: x + fdx, y: y + fdy, dx: fdx, dy: fdy });
|
||
if (jpsCanMoveStraight(x, y, -fdx, -fdy)) nb.push({ x: x - fdx, y: y - fdy, dx: -fdx, dy: -fdy });
|
||
if (jpsCanMoveStraight(x, y, pdx + fdx, pdy + fdy)) nb.push({ x: x + pdx + fdx, y: y + pdy + fdy, dx: pdx + fdx, dy: pdy + fdy });
|
||
}
|
||
return nb;
|
||
}
|
||
|
||
const jpsCache = new Map();
|
||
const JPS_MAX_JUMP = 4000;
|
||
|
||
function jpsFindPath(start, goal) {
|
||
// 使用优化的 A* 代替复杂 JPS,保持兼容性
|
||
if (!start || !goal || !jpsCanPass(start.x, start.y) || !jpsCanPass(goal.x, goal.y)) return null;
|
||
const sK = keyOf(start.x, start.y), gK = keyOf(goal.x, goal.y);
|
||
if (sK === gK) return [{ x: start.x, y: start.y }];
|
||
|
||
const open = new MinHeap();
|
||
const gScore = new Map([[sK, 0]]);
|
||
const parent = new Map();
|
||
const used = new Set();
|
||
|
||
open.push({ x: start.x, y: start.y, f: octile(start, goal), g: 0 });
|
||
|
||
let iterations = 0;
|
||
const maxIter = 3000;
|
||
|
||
while (open.size > 0 && iterations < maxIter) {
|
||
iterations++;
|
||
const cur = open.pop();
|
||
const ck = keyOf(cur.x, cur.y);
|
||
if (used.has(ck)) continue;
|
||
used.add(ck);
|
||
|
||
if (ck === gK) {
|
||
const path = [{ x: cur.x, y: cur.y }];
|
||
let k = ck;
|
||
while (parent.has(k)) {
|
||
k = parent.get(k);
|
||
if (k === null || k === undefined) break;
|
||
const [px, py] = k.split(',').map(Number);
|
||
path.unshift({ x: px, y: py });
|
||
}
|
||
return path;
|
||
}
|
||
|
||
for (const [dx, dy] of DIRS) {
|
||
const nx = cur.x + dx, ny = cur.y + dy;
|
||
if (!jpsCanPass(nx, ny)) continue;
|
||
if (dx !== 0 && dy !== 0) {
|
||
if (!jpsCanPass(cur.x + dx, cur.y) || !jpsCanPass(cur.x, cur.y + dy)) continue;
|
||
}
|
||
const nk = keyOf(nx, ny);
|
||
if (used.has(nk)) continue;
|
||
const stepCost = (dx !== 0 && dy !== 0) ? 1.414 : 1;
|
||
const ng = (gScore.get(ck) ?? Infinity) + stepCost;
|
||
if (ng < (gScore.get(nk) ?? Infinity)) {
|
||
gScore.set(nk, ng);
|
||
parent.set(nk, ck);
|
||
open.push({ x: nx, y: ny, f: ng + octile({ x: nx, y: ny }, goal), g: ng });
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getJPSPathCached(start, goal) {
|
||
const k = `${start.x},${start.y}->${goal.x},${goal.y}`;
|
||
const cached = jpsCache.get(k);
|
||
if (cached && frameNo - cached.t < 180) {
|
||
cached.t = frameNo;
|
||
metrics.jpsHits++;
|
||
return cached.p;
|
||
}
|
||
metrics.jpsMiss++;
|
||
let p = jpsFindPath(start, goal);
|
||
if (!p) return null;
|
||
p = smoothPath(p).map(n => ({ x: n.x * NAV + NAV / 2, y: n.y * NAV + NAV / 2 }));
|
||
jpsCache.set(k, { p, t: frameNo });
|
||
if (jpsCache.size > 800) {
|
||
let oldK = null, oldT = Infinity;
|
||
for (const [kk, vv] of jpsCache) if (vv.t < oldT) { oldT = vv.t; oldK = kk; }
|
||
if (oldK) jpsCache.delete(oldK);
|
||
}
|
||
return p;
|
||
}
|
||
|
||
function lineIntersectsRiver(ax, ay, bx, by) {
|
||
const dx = bx - ax, dy = by - ay;
|
||
const d = Math.hypot(dx, dy);
|
||
if (d < 1) return false;
|
||
const steps = Math.max(1, Math.floor(d));
|
||
for (let i = 0; i <= steps; i++) {
|
||
const t = i / steps;
|
||
const x = Math.floor(ax + dx * t), y = Math.floor(ay + dy * t);
|
||
if (terrainAt(x, y) === TERRAIN.RIVER) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function validatePathAvoidRiver(path) {
|
||
if (!path || path.length < 2) return path;
|
||
const clean = [path[0]];
|
||
for (let i = 1; i < path.length; i++) {
|
||
const prev = clean[clean.length - 1];
|
||
const curr = path[i];
|
||
if (lineIntersectsRiver(prev.x, prev.y, curr.x, curr.y)) {
|
||
metrics.riverDetour++;
|
||
const midX = (prev.x + curr.x) / 2, midY = (prev.y + curr.y) / 2;
|
||
let found = false;
|
||
for (let r = 1; r < 30 && !found; r++) {
|
||
for (const [ox, oy] of [[r,0],[-r,0],[0,r],[0,-r],[r,r],[-r,r],[r,-r],[-r,-r]]) {
|
||
const tx = Math.floor(midX + ox), ty = Math.floor(midY + oy);
|
||
if (terrainAt(tx, ty) === TERRAIN.BRIDGE) {
|
||
clean.push({ x: tx, y: ty });
|
||
clean.push(curr);
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!found) break;
|
||
} else {
|
||
clean.push(curr);
|
||
}
|
||
}
|
||
return clean;
|
||
}
|
||
|
||
function getHierarchicalPath(start, goal) {
|
||
const sc = worldToCell(start.x, start.y);
|
||
const gc = worldToCell(goal.x, goal.y);
|
||
const d = octile(sc, gc) * NAV;
|
||
if (d < 200) {
|
||
let p = getJPSPathCached(sc, gc);
|
||
return p ? validatePathAvoidRiver(p) : null;
|
||
}
|
||
metrics.hierPath++;
|
||
const sId = nearestRoadNode(start.x, start.y);
|
||
const gId = nearestRoadNode(goal.x, goal.y);
|
||
if (sId < 0 || gId < 0) {
|
||
let p = getJPSPathCached(sc, gc);
|
||
return p ? validatePathAvoidRiver(p) : null;
|
||
}
|
||
const roadPath = findRoadPath(sId, gId);
|
||
if (!roadPath || !roadPath.length) {
|
||
let p = getJPSPathCached(sc, gc);
|
||
return p ? validatePathAvoidRiver(p) : null;
|
||
}
|
||
const merged = [];
|
||
const startJP = getJPSPathCached(sc, { x: roadPath[0].x / NAV | 0, y: roadPath[0].y / NAV | 0 });
|
||
if (startJP) merged.push(...startJP);
|
||
merged.push(...roadPath.map(n => ({ x: n.x, y: n.y })));
|
||
const endJP = getJPSPathCached({ x: roadPath[roadPath.length - 1].x / NAV | 0, y: roadPath[roadPath.length - 1].y / NAV | 0 }, gc);
|
||
if (endJP) merged.push(...endJP);
|
||
return validatePathAvoidRiver(smoothPath(merged));
|
||
}
|
||
|
||
class MinHeap {
|
||
constructor() { this.a = []; }
|
||
get size() { return this.a.length; }
|
||
push(v) {
|
||
const a = this.a; a.push(v);
|
||
let i = a.length - 1;
|
||
while (i > 0) {
|
||
const p = (i - 1) >> 1;
|
||
if (a[p].f <= a[i].f) break;
|
||
[a[p], a[i]] = [a[i], a[p]];
|
||
i = p;
|
||
}
|
||
}
|
||
pop() {
|
||
const a = this.a;
|
||
if (!a.length) return null;
|
||
const top = a[0], t = a.pop();
|
||
if (a.length) {
|
||
a[0] = t;
|
||
let i = 0;
|
||
while (true) {
|
||
let l = i * 2 + 1, r = l + 1, s = i;
|
||
if (l < a.length && a[l].f < a[s].f) s = l;
|
||
if (r < a.length && a[r].f < a[s].f) s = r;
|
||
if (s === i) break;
|
||
[a[s], a[i]] = [a[i], a[s]];
|
||
i = s;
|
||
}
|
||
}
|
||
return top;
|
||
}
|
||
}
|
||
|
||
const DIRS = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
||
function findPath(start, goal, maxExpand = 2600) {
|
||
if (!start || !goal || !isCellFree(start.x, start.y) || !isCellFree(goal.x, goal.y)) return null;
|
||
const sK = keyOf(start.x, start.y), gK = keyOf(goal.x, goal.y);
|
||
if (sK === gK) return [start];
|
||
const open = new MinHeap();
|
||
open.push({x:start.x, y:start.y, f:octile(start, goal)});
|
||
const gs = new Map([[sK, 0]]);
|
||
const from = new Map();
|
||
const closed = new Set();
|
||
let ex = 0;
|
||
|
||
while (open.size && ex < maxExpand) {
|
||
const cur = open.pop();
|
||
const ck = keyOf(cur.x, cur.y);
|
||
if (closed.has(ck)) continue;
|
||
closed.add(ck); ex++;
|
||
if (ck === gK) {
|
||
const p = [{x: goal.x, y: goal.y}];
|
||
let k = ck;
|
||
while (from.has(k)) {
|
||
const q = from.get(k);
|
||
p.push({x: q.x, y: q.y});
|
||
k = keyOf(q.x, q.y);
|
||
}
|
||
p.reverse();
|
||
return p;
|
||
}
|
||
const g0 = gs.get(ck) ?? Infinity;
|
||
for (const [dx, dy] of DIRS) {
|
||
const nx = cur.x + dx, ny = cur.y + dy;
|
||
if (!isCellFree(nx, ny)) continue;
|
||
if (dx && dy && (!isCellFree(cur.x + dx, cur.y) || !isCellFree(cur.x, cur.y + dy))) continue;
|
||
const nk = keyOf(nx, ny);
|
||
if (closed.has(nk)) continue;
|
||
const tx = nx * NAV + (NAV >> 1), ty = ny * NAV + (NAV >> 1);
|
||
const tg = terrainAt(tx, ty);
|
||
const stepCost = (dx && dy ? 1.414 : 1) * costByTerrain(tg);
|
||
const ng = g0 + stepCost;
|
||
if (ng < (gs.get(nk) ?? Infinity)) {
|
||
gs.set(nk, ng);
|
||
from.set(nk, {x: cur.x, y: cur.y});
|
||
open.push({x: nx, y: ny, f: ng + octile({x:nx, y:ny}, goal)});
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function smoothPath(path) {
|
||
if (!path || path.length <= 2) return path;
|
||
const out = [path[0]];
|
||
let i = 0;
|
||
while (i < path.length - 1) {
|
||
let j = path.length - 1;
|
||
for (; j > i + 1; j--) {
|
||
const a = {x: path[i].x * NAV + NAV / 2, y: path[i].y * NAV + NAV / 2};
|
||
const b = {x: path[j].x * NAV + NAV / 2, y: path[j].y * NAV + NAV / 2};
|
||
if (rayPassable(a.x, a.y, b.x, b.y)) break;
|
||
}
|
||
out.push(path[j]);
|
||
i = j;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function getPathCached(start, goal) {
|
||
const k = `${start.x},${start.y}->${goal.x},${goal.y}`;
|
||
const got = pathCache.get(k);
|
||
if (got && frameNo - got.t < 160) {
|
||
got.t = frameNo;
|
||
return got.p;
|
||
}
|
||
let p = findPath(start, goal);
|
||
if (!p) return null;
|
||
p = smoothPath(p).map(n => ({x: n.x * NAV + NAV / 2, y: n.y * NAV + NAV / 2}));
|
||
pathCache.set(k, {p, t: frameNo});
|
||
if (pathCache.size > 600) {
|
||
let oldK = null, oldT = Infinity;
|
||
for (const [kk, vv] of pathCache) if (vv.t < oldT) { oldT = vv.t; oldK = kk; }
|
||
if (oldK) pathCache.delete(oldK);
|
||
}
|
||
metrics.replan++;
|
||
return p;
|
||
}
|
||
|
||
function rayPassable(ax, ay, bx, by) {
|
||
const dx = bx - ax, dy = by - ay;
|
||
const st = Math.max(1, Math.floor(Math.hypot(dx, dy) / 1.2));
|
||
for (let i = 0; i <= st; i++) {
|
||
const t = i / st;
|
||
const x = ax + dx * t, y = ay + dy * t;
|
||
if (!isTerrainPassable(terrainAt(x, y))) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function lineOfFire(ax, ay, bx, by, maxD = BULLET_RANGE) {
|
||
const d = Math.hypot(bx - ax, by - ay);
|
||
if (d > maxD) return false;
|
||
const st = Math.max(1, Math.floor(d));
|
||
for (let i = 1; i <= st; i++) {
|
||
const t = i / st;
|
||
const x = ax + (bx - ax) * t;
|
||
const y = ay + (by - ay) * t;
|
||
const tr = terrainAt(x, y);
|
||
if (tr === TERRAIN.WALL || tr === TERRAIN.RIVER) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function predictAimPoint(shooter, target, bulletSpeed = BULLET_SPEED) {
|
||
const tx = target.x - shooter.x;
|
||
const ty = target.y - shooter.y;
|
||
const tvx = (target.x - (target.lastX ?? target.x));
|
||
const tvy = (target.y - (target.lastY ?? target.y));
|
||
|
||
const a = tvx * tvx + tvy * tvy - bulletSpeed * bulletSpeed;
|
||
const b = 2 * (tx * tvx + ty * tvy);
|
||
const c = tx * tx + ty * ty;
|
||
|
||
let t = 0;
|
||
if (Math.abs(a) < 1e-6) {
|
||
t = (Math.abs(b) < 1e-6) ? 0 : -c / b;
|
||
} else {
|
||
const disc = b * b - 4 * a * c;
|
||
if (disc >= 0) {
|
||
const t1 = (-b - Math.sqrt(disc)) / (2 * a);
|
||
const t2 = (-b + Math.sqrt(disc)) / (2 * a);
|
||
t = Math.min(t1 > 0 ? t1 : Infinity, t2 > 0 ? t2 : Infinity);
|
||
if (!isFinite(t)) t = 0;
|
||
}
|
||
}
|
||
|
||
t = clamp(t, 0, 20);
|
||
return {
|
||
x: clamp(target.x + tvx * t, 2, WORLD - 2),
|
||
y: clamp(target.y + tvy * t, 2, WORLD - 2)
|
||
};
|
||
}
|
||
|
||
// ==================== Entities ====================
|
||
class Base {
|
||
constructor(team, x, y) {
|
||
this.team = team;
|
||
this.x = x;
|
||
this.y = y;
|
||
this.hp = BASE_HP;
|
||
this.alive = true;
|
||
}
|
||
hit(dmg) {
|
||
if (!this.alive) return;
|
||
this.hp -= dmg;
|
||
if (this.hp <= 0) {
|
||
this.hp = 0;
|
||
this.alive = false;
|
||
stats[this.team].baseAlive = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
class Tank {
|
||
constructor(id, team, x, y, role, unitType = 'tank') {
|
||
this.id = id;
|
||
this.team = team;
|
||
this.x = x; this.y = y;
|
||
this.dir = {x: 0, y: -1};
|
||
this.unitType = unitType;
|
||
this.cfg = UNIT_CFG[unitType] || UNIT_CFG.tank;
|
||
this.maxHp = this.cfg.hp;
|
||
this.hp = this.maxHp;
|
||
this.alive = true;
|
||
this.cd = 0;
|
||
this.respawnT = 0;
|
||
this.stuck = 0;
|
||
this.lastX = x; this.lastY = y;
|
||
this.path = [];
|
||
this.pathIdx = 0;
|
||
this.replanCd = 0;
|
||
this.evadeT = 0;
|
||
this.role = role;
|
||
this.respawnExtra = Math.floor(rand(0, 120));
|
||
this.behaviorState = 'base-rush';
|
||
this.stateTimer = 0;
|
||
this.switchCd = 0;
|
||
this.orbitScore = 0;
|
||
this.lastBaseTeam = -1;
|
||
this.breakOrbitT = 0;
|
||
this.aiStride = 1;
|
||
this.aiOffset = id % 4;
|
||
this.ctrlDir = {x: 0, y: 0};
|
||
this.turnHold = 0;
|
||
this.roadPath = null;
|
||
this.roadIdx = 0;
|
||
this.stateTimer = 0;
|
||
this.switchCd = 0;
|
||
this.orbitScore = 0;
|
||
this.behaviorState = 'base-rush';
|
||
this.lastBaseTeam = -1;
|
||
this.breakOrbitT = 0;
|
||
}
|
||
update() {
|
||
if (!this.alive) {
|
||
this.respawnT--;
|
||
if (this.respawnT <= 0) this.respawnIfPossible();
|
||
return;
|
||
}
|
||
if (this.cd > 0) this.cd--;
|
||
const d = Math.abs(this.x - this.lastX) + Math.abs(this.y - this.lastY);
|
||
if (d < 0.04) this.stuck++; else this.stuck = 0;
|
||
this.lastX = this.x; this.lastY = this.y;
|
||
}
|
||
respawnIfPossible() {
|
||
const b = bases[this.team];
|
||
if (!b || !b.alive) {
|
||
// 基地被炸毁后永久淘汰
|
||
this.respawnT = 999999;
|
||
return;
|
||
}
|
||
const p = spawnNearBase(this.team);
|
||
if (!p) {
|
||
this.respawnT = 120;
|
||
return;
|
||
}
|
||
this.x = p.x; this.y = p.y;
|
||
this.hp = this.maxHp;
|
||
this.alive = true;
|
||
this.path = [];
|
||
this.pathIdx = 0;
|
||
this.evadeT = 0;
|
||
this.dir = {x: 0, y: -1};
|
||
this.ctrlDir = {x: 0, y: 0};
|
||
this.turnHold = 0;
|
||
this.roadPath = null;
|
||
this.roadIdx = 0;
|
||
}
|
||
move(vx, vy) {
|
||
if (!this.alive) return;
|
||
const n = Math.hypot(vx, vy) || 1;
|
||
vx /= n; vy /= n;
|
||
const t = terrainAt(this.x, this.y);
|
||
const s = 0.48 * speedByTerrain(t) * (this.cfg.speedMul || 1);
|
||
const nx = this.x + vx * s;
|
||
const ny = this.y + vy * s;
|
||
const nt = terrainAt(nx, ny);
|
||
if (nt === TERRAIN.BRIDGE) metrics.bridgeCross++;
|
||
if (vx || vy) this.dir = {x: vx, y: vy};
|
||
// 尝试滑墙
|
||
if (!rectHitBlocked(nx, ny, TANK_HALF)) { this.x = nx; this.y = ny; return; }
|
||
if (nt === TERRAIN.RIVER || (this.stuck > 12 && nearBridge(this.x, this.y, 4))) {
|
||
metrics.riverBlocked++;
|
||
if (metrics.riverBlocked % 120 === 0) {
|
||
console.warn('[AI-DEBUG] river blocked / near bridge jitter', {
|
||
id: this.id,
|
||
team: this.team,
|
||
x: this.x.toFixed(1),
|
||
y: this.y.toFixed(1),
|
||
nx: nx.toFixed(1),
|
||
ny: ny.toFixed(1),
|
||
terrain: nt,
|
||
stuck: this.stuck
|
||
});
|
||
}
|
||
}
|
||
if (!rectHitBlocked(nx, this.y, TANK_HALF)) { this.x = nx; return; }
|
||
if (!rectHitBlocked(this.x, ny, TANK_HALF)) { this.y = ny; return; }
|
||
}
|
||
fire(tx, ty, opts = null) {
|
||
if (!this.alive || this.cd > 0 || !this.cfg.canFire) return;
|
||
const dx = tx - this.x, dy = ty - this.y;
|
||
const len = Math.hypot(dx, dy) || 1;
|
||
const baseUx = dx / len, baseUy = dy / len;
|
||
this.dir = {x: baseUx, y: baseUy};
|
||
|
||
const barrage = opts && opts.count > 1;
|
||
const count = barrage ? opts.count : 1;
|
||
const spread = barrage ? (opts.spread ?? BARRAGE_SPREAD) : 0;
|
||
const scatter = barrage ? (opts.scatter ?? BARRAGE_SCATTER) : 0;
|
||
const half = (count - 1) * 0.5;
|
||
const baseAng = Math.atan2(baseUy, baseUx);
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const rel = (i - half) / Math.max(1, half);
|
||
const ang = baseAng + rel * spread + rand(-scatter, scatter);
|
||
const ux = Math.cos(ang), uy = Math.sin(ang);
|
||
bullets.push({
|
||
x: this.x + ux * 1.3,
|
||
y: this.y + uy * 1.3,
|
||
dx: ux,
|
||
dy: uy,
|
||
team: this.team,
|
||
from: this.id,
|
||
unitType: this.unitType,
|
||
speed: this.cfg.projectileSpeed,
|
||
damage: this.cfg.damage,
|
||
aoe: this.cfg.aoe,
|
||
travel: 0,
|
||
maxTravel: this.cfg.range
|
||
});
|
||
}
|
||
|
||
const cdMul = barrage ? (opts.cdMul ?? 1.45) : 1;
|
||
this.cd = Math.floor(FIRE_CD * cdMul);
|
||
}
|
||
}
|
||
|
||
// ==================== Spawn / Setup ====================
|
||
function basePos(team) {
|
||
const m = 34;
|
||
const ps = [
|
||
{x: m, y: m},
|
||
{x: WORLD - m, y: m},
|
||
{x: m, y: WORLD - m},
|
||
{x: WORLD - m, y: WORLD - m}
|
||
];
|
||
return ps[team];
|
||
}
|
||
|
||
function spawnNearBase(team) {
|
||
const b = bases[team];
|
||
if (!b || !b.alive) return null;
|
||
for (let i = 0; i < 80; i++) {
|
||
// 优先在基地外圈出生,避免单位在基地内部拥堵
|
||
const ang = rand(0, Math.PI * 2);
|
||
const r = rand(18, 34);
|
||
const x = b.x + Math.cos(ang) * r;
|
||
const y = b.y + Math.sin(ang) * r;
|
||
if (x < 5 || x > WORLD - 5 || y < 5 || y > WORLD - 5) continue;
|
||
if (!rectHitBlocked(x, y, TANK_HALF)) return {x, y};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function roleFor(idx, total) {
|
||
const hardBackdoor = clamp(Math.floor(total * 0.35), 3, 4);
|
||
const roamerCount = Math.max(1, Math.floor(total * 0.2));
|
||
const attackerCount = Math.max(1, Math.floor(total * 0.18));
|
||
const sniperCount = Math.max(1, Math.floor(total * 0.1));
|
||
if (idx < hardBackdoor) return 'backdoor';
|
||
if (idx < hardBackdoor + roamerCount) return 'roamer';
|
||
if (idx < hardBackdoor + roamerCount + attackerCount) return 'attacker';
|
||
if (idx < hardBackdoor + roamerCount + attackerCount + sniperCount) return 'sniper';
|
||
if (idx % 3 === 0) return 'strike';
|
||
if (idx % 2 === 0) return 'eliminate';
|
||
return 'line-defend';
|
||
}
|
||
|
||
function unitTypeFor(idx, total) {
|
||
const tankCount = Math.max(1, Math.floor(total * FORCE_RATIO.tank));
|
||
const artilleryCount = Math.max(1, Math.floor(total * FORCE_RATIO.artillery));
|
||
if (idx < tankCount) return 'tank';
|
||
if (idx < tankCount + artilleryCount) return 'artillery';
|
||
return 'bomber';
|
||
}
|
||
|
||
function killUnit(unit, killerTeam = null) {
|
||
if (!unit || !unit.alive) return;
|
||
unit.alive = false;
|
||
onTeamDeathPenalty(unit.team);
|
||
unit.respawnT = calcRespawnTime(unit.team, unit);
|
||
if (killerTeam !== null && stats[killerTeam]) stats[killerTeam].kills++;
|
||
}
|
||
|
||
function applyExplosion(x, y, attackerTeam, radius, damage) {
|
||
for (const t of tanks) {
|
||
if (!t.alive || t.team === attackerTeam) continue;
|
||
if (Math.hypot(t.x - x, t.y - y) <= radius) {
|
||
t.hp -= damage;
|
||
if (t.hp <= 0) killUnit(t, attackerTeam);
|
||
}
|
||
}
|
||
for (const b of bases) {
|
||
if (!b.alive || b.team === attackerTeam) continue;
|
||
if (Math.hypot(b.x - x, b.y - y) <= radius + BASE_HALF) {
|
||
b.hit(Math.max(10, Math.floor(damage * 0.7)));
|
||
}
|
||
}
|
||
}
|
||
|
||
function initWorld() {
|
||
makeTerrain();
|
||
bases = [];
|
||
for (let t = 0; t < factions; t++) {
|
||
const p = basePos(t);
|
||
const b = new Base(t, p.x, p.y);
|
||
bases.push(b);
|
||
paintBaseArea(b);
|
||
}
|
||
buildRoadBridgeGraph();
|
||
buildNavGrid();
|
||
}
|
||
|
||
function spawnAll() {
|
||
tanks = [];
|
||
bullets = [];
|
||
stats = {};
|
||
teamRespawnState = {};
|
||
pathCache.clear();
|
||
frameNo = 0;
|
||
aiCursor = 0;
|
||
for (let t = 0; t < factions; t++) {
|
||
stats[t] = { alive: 0, kills: 0, score: 0, baseAlive: true };
|
||
for (let i = 0; i < perTeam; i++) {
|
||
const p = spawnNearBase(t) || basePos(t);
|
||
const unitType = unitTypeFor(i, perTeam);
|
||
tanks.push(new Tank(t * perTeam + i, t, p.x, p.y, roleFor(i, perTeam), unitType));
|
||
}
|
||
}
|
||
camera.x = WORLD / 2; camera.y = WORLD / 2;
|
||
controlledTank = null;
|
||
}
|
||
|
||
// ==================== Ultimate AI ====================
|
||
function incomingThreat(tank) {
|
||
for (const b of bullets) {
|
||
if (b.team === tank.team) continue;
|
||
const rx = tank.x - b.x, ry = tank.y - b.y;
|
||
const proj = rx * b.dx + ry * b.dy;
|
||
if (proj < 0 || proj > 10) continue;
|
||
const px = b.x + b.dx * proj;
|
||
const py = b.y + b.dy * proj;
|
||
const d = Math.hypot(tank.x - px, tank.y - py);
|
||
if (d < 2.5) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function nearestEnemyToBase(team) {
|
||
const b = bases[team];
|
||
if (!b || !b.alive) return null;
|
||
let target = null, best = Infinity;
|
||
for (const t of tanks) {
|
||
if (!t.alive || t.team === team) continue;
|
||
const d = Math.hypot(t.x - b.x, t.y - b.y);
|
||
if (d < best) { best = d; target = t; }
|
||
}
|
||
return best < DANGER_RADIUS ? target : null;
|
||
}
|
||
|
||
function weakestEnemyBase(team) {
|
||
let best = null;
|
||
for (const b of bases) {
|
||
if (!b.alive || b.team === team) continue;
|
||
if (!best || b.hp < best.hp) best = b;
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function teamForwardPoint(team) {
|
||
const b = bases[team];
|
||
if (!b) return {x: WORLD / 2, y: WORLD / 2};
|
||
const dirs = [
|
||
{x: 1, y: 1}, // 蓝 -> 中央
|
||
{x: -1, y: 1}, // 红 -> 中央
|
||
{x: 1, y: -1}, // 绿 -> 中央
|
||
{x: -1, y: -1} // 黄 -> 中央
|
||
];
|
||
const d = dirs[team] || {x: 0, y: 0};
|
||
return {
|
||
x: clamp(b.x + d.x * 120, 20, WORLD - 20),
|
||
y: clamp(b.y + d.y * 120, 20, WORLD - 20)
|
||
};
|
||
}
|
||
|
||
function chooseEnemyTarget(tank) {
|
||
let best = null;
|
||
let scoreBest = -Infinity;
|
||
for (const e of tanks) {
|
||
if (!e.alive || e.team === tank.team) continue;
|
||
const d = Math.hypot(e.x - tank.x, e.y - tank.y);
|
||
const los = lineOfFire(tank.x, tank.y, e.x, e.y) ? 1 : 0;
|
||
const hpScore = 1 - e.hp / MAX_HP;
|
||
const fk = `${tank.team}:${e.id}`;
|
||
const fN = teamFocus[fk] || 0;
|
||
let s = 0;
|
||
s += Math.max(0, 72 - d) * 0.04;
|
||
s += los * 2.3;
|
||
s += hpScore * 2;
|
||
s -= fN * 1.5;
|
||
if (s > scoreBest) { scoreBest = s; best = e; }
|
||
}
|
||
if (best) {
|
||
const fk = `${tank.team}:${best.id}`;
|
||
teamFocus[fk] = (teamFocus[fk] || 0) + 1;
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function aiMoveTo(tank, tx, ty) {
|
||
// 使用分层寻路:JPS + 道路网 + 河流绕行
|
||
const sc = nearestFreeCell(worldToCell(tank.x, tank.y));
|
||
const gc = nearestFreeCell(worldToCell(tx, ty));
|
||
if (!sc || !gc) return {x: 0, y: 0};
|
||
|
||
tank.replanCd--;
|
||
if (!tank.path.length || tank.pathIdx >= tank.path.length || tank.replanCd <= 0 || tank.stuck > 14) {
|
||
const p = getHierarchicalPath({x: tank.x, y: tank.y}, {x: tx, y: ty});
|
||
if (p && p.length) { tank.path = p; tank.pathIdx = 0; }
|
||
tank.replanCd = 14 + Math.floor(Math.random() * 10);
|
||
}
|
||
|
||
let gx = tx, gy = ty;
|
||
if (tank.path.length && tank.pathIdx < tank.path.length) {
|
||
const wp = tank.path[tank.pathIdx];
|
||
const terrainSpeed = speedByTerrain(terrainAt(tank.x, tank.y));
|
||
const reach = clamp(2.1 + terrainSpeed * 1.0, 2.3, 4.8);
|
||
if (Math.hypot(wp.x - tank.x, wp.y - tank.y) < reach) tank.pathIdx++;
|
||
if (tank.pathIdx < tank.path.length) {
|
||
gx = tank.path[tank.pathIdx].x;
|
||
gy = tank.path[tank.pathIdx].y;
|
||
}
|
||
}
|
||
|
||
const dx = gx - tank.x, dy = gy - tank.y;
|
||
const l = Math.hypot(dx, dy) || 1;
|
||
return {x: dx / l, y: dy / l};
|
||
}
|
||
|
||
function doTankAI(tank) {
|
||
// 状态机AI:ambush / defense / base-rush / sniper
|
||
if (!tank.alive || tank === controlledTank) return;
|
||
const home = bases[tank.team];
|
||
if (!home || !home.alive) return;
|
||
|
||
const primaryBase = weakestEnemyBase(tank.team) || home;
|
||
const enemy = chooseEnemyTarget(tank);
|
||
const intruder = nearestEnemyToBase(tank.team);
|
||
const hardRush = (tank.role === 'backdoor' || tank.role === 'attacker');
|
||
|
||
if (tank.switchCd > 0) tank.switchCd--;
|
||
if (tank.stateTimer > 0) tank.stateTimer--;
|
||
|
||
// 对转检测:在目标基地附近但径向推进不足,判定为环绕
|
||
const bx = primaryBase.x - tank.x;
|
||
const by = primaryBase.y - tank.y;
|
||
const bl = Math.hypot(bx, by) || 1;
|
||
const baseDir = {x: bx / bl, y: by / bl};
|
||
const mvDot = (tank.ctrlDir?.x || 0) * baseDir.x + (tank.ctrlDir?.y || 0) * baseDir.y;
|
||
if (bl < 80 && mvDot < 0.22) tank.orbitScore += 1.35;
|
||
else tank.orbitScore = Math.max(0, tank.orbitScore - 0.9);
|
||
|
||
if (tank.orbitScore > 28 && tank.switchCd <= 0) {
|
||
tank.behaviorState = 'base-rush';
|
||
tank.stateTimer = 120;
|
||
tank.switchCd = 90;
|
||
tank.orbitScore = 0;
|
||
tank.breakOrbitT = 90;
|
||
}
|
||
|
||
// 强制破环阶段:无条件直切基地,暂时无视大部分侧向行为
|
||
if (tank.breakOrbitT > 0) {
|
||
tank.breakOrbitT--;
|
||
const dx = primaryBase.x - tank.x;
|
||
const dy = primaryBase.y - tank.y;
|
||
const l = Math.hypot(dx, dy) || 1;
|
||
const ux = dx / l, uy = dy / l;
|
||
tank.move(ux, uy);
|
||
|
||
if (tank.cd <= 0 && l <= BULLET_RANGE && lineOfFire(tank.x, tank.y, primaryBase.x, primaryBase.y)) {
|
||
tank.fire(primaryBase.x, primaryBase.y, { count: BARRAGE_COUNT, spread: BARRAGE_SPREAD, scatter: BARRAGE_SCATTER, cdMul: 1.35 });
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 状态选择(带驻留时间,避免抖动)
|
||
if (tank.stateTimer <= 0) {
|
||
if (hardRush) {
|
||
tank.behaviorState = 'base-rush';
|
||
tank.stateTimer = 180;
|
||
} else if (tank.role === 'sniper') {
|
||
tank.behaviorState = 'sniper';
|
||
tank.stateTimer = 160;
|
||
} else if (intruder && tank.role === 'line-defend') {
|
||
tank.behaviorState = 'defense';
|
||
tank.stateTimer = 120;
|
||
} else if (tank.role === 'roamer') {
|
||
tank.behaviorState = 'ambush';
|
||
tank.stateTimer = 120;
|
||
} else {
|
||
tank.behaviorState = 'base-rush';
|
||
tank.stateTimer = 140;
|
||
}
|
||
}
|
||
|
||
let targetX = primaryBase.x;
|
||
let targetY = primaryBase.y;
|
||
|
||
if (tank.behaviorState === 'base-rush') {
|
||
const laneSlot = (tank.id % 4);
|
||
const angBase = [-2.3, -0.8, 0.8, 2.3][laneSlot];
|
||
const ang = angBase + Math.sin(frameNo * 0.003 + tank.id * 0.37) * 0.22;
|
||
const ring = hardRush ? 0.6 : (3 + (tank.id % 3));
|
||
targetX = clamp(primaryBase.x + Math.cos(ang) * ring, 6, WORLD - 6);
|
||
targetY = clamp(primaryBase.y + Math.sin(ang) * ring, 6, WORLD - 6);
|
||
if (bl > 36) {
|
||
targetX = primaryBase.x;
|
||
targetY = primaryBase.y;
|
||
}
|
||
} else if (tank.behaviorState === 'ambush') {
|
||
const ambX = (home.x + primaryBase.x) * 0.5 + ((tank.id % 2) ? 22 : -22);
|
||
const ambY = (home.y + primaryBase.y) * 0.5 + ((tank.id % 3) - 1) * 24;
|
||
targetX = clamp(ambX, 6, WORLD - 6);
|
||
targetY = clamp(ambY, 6, WORLD - 6);
|
||
} else if (tank.behaviorState === 'defense') {
|
||
if (intruder) {
|
||
targetX = intruder.x;
|
||
targetY = intruder.y;
|
||
} else {
|
||
targetX = clamp((home.x + WORLD / 2) * 0.5, 6, WORLD - 6);
|
||
targetY = clamp((home.y + WORLD / 2) * 0.5, 6, WORLD - 6);
|
||
}
|
||
} else if (tank.behaviorState === 'sniper') {
|
||
const sx = primaryBase.x - home.x;
|
||
const sy = primaryBase.y - home.y;
|
||
const sl = Math.hypot(sx, sy) || 1;
|
||
const desired = BULLET_RANGE * 0.88;
|
||
targetX = clamp(primaryBase.x - (sx / sl) * desired + ((tank.id % 2) ? 10 : -10), 6, WORLD - 6);
|
||
targetY = clamp(primaryBase.y - (sy / sl) * desired + ((tank.id % 3) - 1) * 8, 6, WORLD - 6);
|
||
}
|
||
|
||
const mv = aiMoveTo(tank, targetX, targetY);
|
||
let vx = mv.x, vy = mv.y;
|
||
|
||
// AOE 环境下强分散:半径内强制排斥,防止越走越近
|
||
let sepX = 0, sepY = 0;
|
||
let nearestMate = Infinity;
|
||
for (const mate of tanks) {
|
||
if (!mate.alive || mate.team !== tank.team || mate.id === tank.id) continue;
|
||
const dx = tank.x - mate.x;
|
||
const dy = tank.y - mate.y;
|
||
const d = Math.hypot(dx, dy);
|
||
if (d > 0.01) nearestMate = Math.min(nearestMate, d);
|
||
if (d > 0.01 && d < 24) {
|
||
const w = ((24 - d) / 24) ** 1.5;
|
||
sepX += (dx / d) * w;
|
||
sepY += (dy / d) * w;
|
||
}
|
||
}
|
||
vx = vx * 0.2 + sepX * 0.8;
|
||
vy = vy * 0.2 + sepY * 0.8;
|
||
if (nearestMate < 8) {
|
||
vx += sepX * 0.9;
|
||
vy += sepY * 0.9;
|
||
}
|
||
|
||
// 强制偷塔位:始终给基地吸引,不允许被队形排斥完全拉走
|
||
if (hardRush && primaryBase && primaryBase.alive) {
|
||
const bx = primaryBase.x - tank.x;
|
||
const by = primaryBase.y - tank.y;
|
||
const bl = Math.hypot(bx, by) || 1;
|
||
const pux = bx / bl, puy = by / bl;
|
||
vx = vx * 0.45 + pux * 0.55;
|
||
vy = vy * 0.45 + puy * 0.55;
|
||
}
|
||
|
||
// 坦克保持中距离炮战:避免贴脸格斗
|
||
if (enemy) {
|
||
const ex = tank.x - enemy.x;
|
||
const ey = tank.y - enemy.y;
|
||
const ed = Math.hypot(ex, ey) || 1;
|
||
const desiredMin = 34;
|
||
const desiredMax = 72;
|
||
|
||
if (ed < desiredMin) {
|
||
// 太近:后撤拉距
|
||
vx = vx * 0.2 + (ex / ed) * 0.8;
|
||
vy = vy * 0.2 + (ey / ed) * 0.8;
|
||
} else if (ed <= desiredMax) {
|
||
// 在炮战距离内:沿切线机动找角度
|
||
const tx = -ey / ed;
|
||
const ty = ex / ed;
|
||
const sgn = (tank.id % 2 === 0) ? 1 : -1;
|
||
vx = vx * 0.55 + tx * 0.45 * sgn;
|
||
vy = vy * 0.55 + ty * 0.45 * sgn;
|
||
}
|
||
}
|
||
|
||
// 仅防守位保留规避,进攻态不因规避偏离推塔线
|
||
if (tank.role === 'line-defend' && incomingThreat(tank)) {
|
||
if (enemy) {
|
||
const ex = enemy.x - tank.x, ey = enemy.y - tank.y;
|
||
const l = Math.hypot(ex, ey) || 1;
|
||
vx = vx * 0.75 + (-ey / l) * 0.25;
|
||
vy = vy * 0.75 + (ex / l) * 0.25;
|
||
}
|
||
}
|
||
|
||
const refined = refineLocalMove(tank, vx, vy);
|
||
const stable = stabilizeMoveVector(tank, refined.x, refined.y);
|
||
tank.move(stable.x, stable.y);
|
||
|
||
// 射击优先级:偷塔/攻击位一律基地优先
|
||
if (tank.cd <= 0) {
|
||
const mateCount = tanks.filter(m => m.alive && m.team === tank.team && m.id !== tank.id && Math.hypot(m.x - tank.x, m.y - tank.y) < 72).length;
|
||
const suppressive = tank.unitType === 'tank' && (mateCount >= 2 || tank.role === 'backdoor' || tank.role === 'attacker');
|
||
const fireOpts = suppressive ? { count: BARRAGE_COUNT, spread: BARRAGE_SPREAD, scatter: BARRAGE_SCATTER, cdMul: 1.55 } : null;
|
||
|
||
const baseDist = Math.hypot(primaryBase.x - tank.x, primaryBase.y - tank.y);
|
||
if (primaryBase && primaryBase.alive && baseDist <= BULLET_RANGE
|
||
&& lineOfFire(tank.x, tank.y, primaryBase.x, primaryBase.y)) {
|
||
const aimX = primaryBase.x + rand(-BASE_HALF * 0.75, BASE_HALF * 0.75);
|
||
const aimY = primaryBase.y + rand(-BASE_HALF * 0.75, BASE_HALF * 0.75);
|
||
const shotOpts = (tank.behaviorState === 'sniper' || tank.unitType === 'artillery') ? null : fireOpts;
|
||
tank.fire(aimX, aimY, shotOpts);
|
||
return;
|
||
}
|
||
|
||
if (enemy) {
|
||
const d = Math.hypot(enemy.x - tank.x, enemy.y - tank.y);
|
||
if (d >= 18 && d <= BULLET_RANGE && lineOfFire(tank.x, tank.y, enemy.x, enemy.y)) {
|
||
const lead = predictAimPoint(tank, enemy, BULLET_SPEED);
|
||
if (lineOfFire(tank.x, tank.y, lead.x, lead.y)) tank.fire(lead.x, lead.y, fireOpts);
|
||
else tank.fire(enemy.x, enemy.y, fireOpts);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function doArtilleryAI(tank) {
|
||
if (!tank.alive || tank === controlledTank) return;
|
||
const home = bases[tank.team];
|
||
if (!home || !home.alive) return;
|
||
const primaryBase = weakestEnemyBase(tank.team) || home;
|
||
const enemy = chooseEnemyTarget(tank);
|
||
|
||
// 1) 近敌先直线后撤(不走A*,避免对转)
|
||
if (enemy) {
|
||
const d = Math.hypot(enemy.x - tank.x, enemy.y - tank.y);
|
||
if (d < 135) {
|
||
const rx = tank.x - enemy.x;
|
||
const ry = tank.y - enemy.y;
|
||
const rl = Math.hypot(rx, ry) || 1;
|
||
tank.move(rx / rl, ry / rl);
|
||
}
|
||
}
|
||
|
||
// 2) 维持“基地外圈炮击环”
|
||
const lane = (tank.id % 4);
|
||
const laneAng = [-2.2, -0.7, 0.7, 2.2][lane];
|
||
const desiredR = 140;
|
||
const anchorX = clamp(primaryBase.x + Math.cos(laneAng) * desiredR, 6, WORLD - 6);
|
||
const anchorY = clamp(primaryBase.y + Math.sin(laneAng) * desiredR, 6, WORLD - 6);
|
||
const curR = Math.hypot(primaryBase.x - tank.x, primaryBase.y - tank.y);
|
||
|
||
if (curR < 110 || curR > 175) {
|
||
const mv = aiMoveTo(tank, anchorX, anchorY);
|
||
tank.move(mv.x, mv.y);
|
||
}
|
||
|
||
if (tank.cd <= 0) {
|
||
const baseDist = Math.hypot(primaryBase.x - tank.x, primaryBase.y - tank.y);
|
||
if (primaryBase.alive && baseDist >= 100 && baseDist <= tank.cfg.range && lineOfFire(tank.x, tank.y, primaryBase.x, primaryBase.y, tank.cfg.range)) {
|
||
tank.fire(primaryBase.x, primaryBase.y, null);
|
||
return;
|
||
}
|
||
if (enemy) {
|
||
const d = Math.hypot(enemy.x - tank.x, enemy.y - tank.y);
|
||
if (d >= 100 && d <= tank.cfg.range && lineOfFire(tank.x, tank.y, enemy.x, enemy.y, tank.cfg.range)) {
|
||
const lead = predictAimPoint(tank, enemy, tank.cfg.projectileSpeed);
|
||
tank.fire(lead.x, lead.y, null);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function doBomberAI(tank) {
|
||
if (!tank.alive || tank === controlledTank) return;
|
||
const home = bases[tank.team];
|
||
if (!home || !home.alive) return;
|
||
const primaryBase = weakestEnemyBase(tank.team) || home;
|
||
const enemy = chooseEnemyTarget(tank);
|
||
const flankAng = ((tank.id % 3) - 1) * 0.95 + Math.sin(frameNo * 0.025 + tank.id * 1.7) * 0.55;
|
||
const toBaseX = primaryBase.x - tank.x;
|
||
const toBaseY = primaryBase.y - tank.y;
|
||
const toBaseD = Math.hypot(toBaseX, toBaseY) || 1;
|
||
|
||
// 先绕侧翼找角度,再高速扎入
|
||
let targetX = primaryBase.x;
|
||
let targetY = primaryBase.y;
|
||
if (toBaseD > 34) {
|
||
const ang = Math.atan2(toBaseY, toBaseX) + flankAng;
|
||
const radius = 30 + (tank.id % 3) * 8;
|
||
targetX = clamp(primaryBase.x + Math.cos(ang) * radius, 6, WORLD - 6);
|
||
targetY = clamp(primaryBase.y + Math.sin(ang) * radius, 6, WORLD - 6);
|
||
}
|
||
|
||
const mv = aiMoveTo(tank, targetX, targetY);
|
||
tank.move(mv.x * 1.45, mv.y * 1.45);
|
||
|
||
const nearEnemy = enemy && Math.hypot(enemy.x - tank.x, enemy.y - tank.y) < 12;
|
||
const nearBase = Math.hypot(primaryBase.x - tank.x, primaryBase.y - tank.y) < 18;
|
||
if (nearEnemy || nearBase) {
|
||
applyExplosion(tank.x, tank.y, tank.team, UNIT_CFG.bomber.aoe, UNIT_CFG.bomber.damage);
|
||
killUnit(tank, null);
|
||
}
|
||
}
|
||
|
||
function doAi(tank) {
|
||
if (!tank || !tank.alive || tank === controlledTank) return;
|
||
if (tank.unitType === 'artillery') return doArtilleryAI(tank);
|
||
if (tank.unitType === 'bomber') return doBomberAI(tank);
|
||
return doTankAI(tank);
|
||
}
|
||
|
||
function updateTeamTactic() {
|
||
for (let t = 0; t < factions; t++) {
|
||
const ownBase = bases[t];
|
||
const alive = tanks.filter(x => x.alive && x.team === t).length;
|
||
// 不再用“所有敌军总数”对比(多阵营会导致永远防守)
|
||
let maxEnemyAlive = 0;
|
||
for (let e = 0; e < factions; e++) {
|
||
if (e === t) continue;
|
||
const c = tanks.filter(x => x.alive && x.team === e).length;
|
||
if (c > maxEnemyAlive) maxEnemyAlive = c;
|
||
}
|
||
const pressure = nearestEnemyToBase(t);
|
||
if (!ownBase || !ownBase.alive || pressure) {
|
||
teamTactic[t] = 'defense';
|
||
continue;
|
||
}
|
||
if (alive > maxEnemyAlive * 1.15) {
|
||
teamTactic[t] = (frameNo % 240 < 110) ? 'eliminate' : 'ambush';
|
||
} else if (alive < maxEnemyAlive * 0.75) {
|
||
teamTactic[t] = 'defense';
|
||
} else {
|
||
teamTactic[t] = 'strike';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== Game Loop ====================
|
||
function allEnemiesDead(team) {
|
||
for (const t of tanks) if (t.alive && t.team !== team) return false;
|
||
for (const b of bases) if (b.team !== team && b.alive) return false;
|
||
return true;
|
||
}
|
||
|
||
function update() {
|
||
if (!gameRunning) return;
|
||
const t0 = performance.now();
|
||
frameNo++;
|
||
teamFocus = {};
|
||
if (frameNo % 45 === 0) updateTeamTactic();
|
||
|
||
// 玩家控制
|
||
if (controlledTank && controlledTank.alive) {
|
||
let dx = 0, dy = 0;
|
||
if (keys['w'] || keys['arrowup']) dy = -1;
|
||
if (keys['s'] || keys['arrowdown']) dy = 1;
|
||
if (keys['a'] || keys['arrowleft']) dx = -1;
|
||
if (keys['d'] || keys['arrowright']) dx = 1;
|
||
if (dx || dy) controlledTank.move(dx, dy);
|
||
if (keys[' '] || keys['j']) {
|
||
const tx = controlledTank.x + controlledTank.dir.x * 12;
|
||
const ty = controlledTank.y + controlledTank.dir.y * 12;
|
||
controlledTank.fire(tx, ty);
|
||
}
|
||
}
|
||
|
||
// AI 调度(WebGPU优先,CPU兜底)
|
||
if (webgpu.ready && webgpu.enabled) {
|
||
dispatchWebGPUAI();
|
||
applyWebGPUAIActions();
|
||
} else {
|
||
let acted = 0;
|
||
for (let i = 0; i < tanks.length; i++) {
|
||
const idx = (aiCursor + i) % tanks.length;
|
||
const t = tanks[idx];
|
||
if (!t.alive || t === controlledTank) continue;
|
||
const dCam = Math.hypot(t.x - camera.x, t.y - camera.y);
|
||
t.aiStride = dCam < 120 ? 1 : (dCam < 220 ? 2 : 3);
|
||
if ((frameNo + t.aiOffset) % t.aiStride !== 0) continue;
|
||
if (acted >= AI_TICK_BUDGET) break;
|
||
doAi(t);
|
||
acted++;
|
||
}
|
||
aiCursor = (aiCursor + 1) % Math.max(1, tanks.length);
|
||
}
|
||
|
||
// 更新坦克
|
||
for (const t of tanks) t.update();
|
||
|
||
// 子弹更新(飞行时间+射程)
|
||
bullets = bullets.filter(b => {
|
||
const sp = b.speed || BULLET_SPEED;
|
||
const dmg = b.damage || BULLET_DAMAGE;
|
||
const aoe = b.aoe || BULLET_AOE_RADIUS;
|
||
b.x += b.dx * sp;
|
||
b.y += b.dy * sp;
|
||
b.travel += sp;
|
||
if (b.travel > b.maxTravel) return false;
|
||
|
||
const tr = terrainAt(b.x, b.y);
|
||
if (tr === TERRAIN.WALL || tr === TERRAIN.RIVER) return false;
|
||
|
||
// 打基地(仅炮弹可伤害基地)
|
||
for (const base of bases) {
|
||
if (!base.alive || base.team === b.team) continue;
|
||
if (Math.abs(b.x - base.x) <= BASE_HALF && Math.abs(b.y - base.y) <= BASE_HALF) {
|
||
applyExplosion(b.x, b.y, b.team, aoe, dmg);
|
||
// 战术奖励已注释: stats[b.team].score += BASE_HIT_SCORE;
|
||
// 战术奖励已注释: if (wasAlive && !base.alive) {
|
||
// 战术奖励已注释: stats[b.team].score += BASE_DESTROY_SCORE;
|
||
// 战术奖励已注释: }
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 打坦克(命中后 4 格 AOE)
|
||
for (const t of tanks) {
|
||
if (!t.alive || t.team === b.team) continue;
|
||
if (Math.abs(b.x - t.x) <= TANK_HALF && Math.abs(b.y - t.y) <= TANK_HALF) {
|
||
applyExplosion(b.x, b.y, b.team, aoe, dmg);
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
});
|
||
|
||
// 摄像机
|
||
if (followMode === 'player' && controlledTank && controlledTank.alive) {
|
||
const fx = controlledTank.x + controlledTank.dir.x * 8;
|
||
const fy = controlledTank.y + controlledTank.dir.y * 8;
|
||
camera.x += (fx - camera.x) * 0.14;
|
||
camera.y += (fy - camera.y) * 0.14;
|
||
} else if (followMode === 'center') {
|
||
const alive = tanks.filter(t => t.alive);
|
||
if (alive.length) {
|
||
const cx = alive.reduce((s, t) => s + t.x, 0) / alive.length;
|
||
const cy = alive.reduce((s, t) => s + t.y, 0) / alive.length;
|
||
camera.x += (cx - camera.x) * 0.08;
|
||
camera.y += (cy - camera.y) * 0.08;
|
||
}
|
||
} else if (followMode === 'auto') {
|
||
if (controlledTank && controlledTank.alive) {
|
||
camera.x += ((controlledTank.x + controlledTank.dir.x * 6) - camera.x) * 0.12;
|
||
camera.y += ((controlledTank.y + controlledTank.dir.y * 6) - camera.y) * 0.12;
|
||
} else {
|
||
const alive = tanks.filter(t => t.alive);
|
||
if (alive.length) {
|
||
const cx = alive.reduce((s, t) => s + t.x, 0) / alive.length;
|
||
const cy = alive.reduce((s, t) => s + t.y, 0) / alive.length;
|
||
camera.x += (cx - camera.x) * 0.06;
|
||
camera.y += (cy - camera.y) * 0.06;
|
||
}
|
||
}
|
||
}
|
||
camera.x = clamp(camera.x, 0, WORLD);
|
||
camera.y = clamp(camera.y, 0, WORLD);
|
||
|
||
// 基地复活CD与死亡惩罚衰减
|
||
for (let t = 0; t < factions; t++) {
|
||
const st = getRespawnState(t);
|
||
st.cooldown = Math.max(0, st.cooldown - 0.9);
|
||
st.pressure = Math.max(0, st.pressure - 0.02);
|
||
st.recentDeaths = Math.max(0, st.recentDeaths - 0.05);
|
||
}
|
||
|
||
for (let t = 0; t < factions; t++) {
|
||
stats[t].alive = tanks.filter(x => x.team === t && x.alive).length;
|
||
stats[t].baseAlive = !!bases[t]?.alive;
|
||
}
|
||
|
||
// 终局
|
||
let winner = null;
|
||
for (let t = 0; t < factions; t++) {
|
||
if (allEnemiesDead(t)) { winner = t; break; }
|
||
}
|
||
if (winner !== null) gameRunning = false;
|
||
|
||
draw();
|
||
updateStats(winner);
|
||
|
||
const dt = performance.now() - t0;
|
||
metrics.frameMsAvg = metrics.frameMsAvg * 0.92 + dt * 0.08;
|
||
setTimeout(update, 16);
|
||
}
|
||
|
||
// ==================== Render ====================
|
||
function drawTerrain() {
|
||
const s = TILE * zoom;
|
||
const wx0 = Math.floor(camera.x - VIEW_W / s / 2 - 2);
|
||
const wy0 = Math.floor(camera.y - VIEW_H / s / 2 - 2);
|
||
const wx1 = Math.ceil(camera.x + VIEW_W / s / 2 + 2);
|
||
const wy1 = Math.ceil(camera.y + VIEW_H / s / 2 + 2);
|
||
for (let y = Math.max(0, wy0); y < Math.min(WORLD, wy1); y++) {
|
||
for (let x = Math.max(0, wx0); x < Math.min(WORLD, wx1); x++) {
|
||
const p = worldToScr(x, y);
|
||
ctx.fillStyle = TERRAIN_COLOR[terrain[y][x]];
|
||
ctx.fillRect(p.x, p.y, s + 0.2, s + 0.2);
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawBases() {
|
||
for (const b of bases) {
|
||
if (!b) continue;
|
||
const p = worldToScr(b.x, b.y);
|
||
const s = BASE_HALF * TILE * 2;
|
||
ctx.fillStyle = b.alive ? TEAM_COLORS[b.team] : '#333';
|
||
ctx.globalAlpha = 0.35;
|
||
ctx.fillRect(p.x - s / 2, p.y - s / 2, s, s);
|
||
ctx.globalAlpha = 1;
|
||
ctx.strokeStyle = '#fff';
|
||
ctx.lineWidth = 1.2;
|
||
ctx.strokeRect(p.x - s / 2, p.y - s / 2, s, s);
|
||
const hp = b.hp / BASE_HP;
|
||
ctx.fillStyle = '#111';
|
||
ctx.fillRect(p.x - 12, p.y - s / 2 - 7, 24, 3);
|
||
ctx.fillStyle = hp > 0.5 ? '#3fb950' : (hp > 0.25 ? '#d29922' : '#f85149');
|
||
ctx.fillRect(p.x - 12, p.y - s / 2 - 7, 24 * hp, 3);
|
||
}
|
||
}
|
||
|
||
function drawTanks() {
|
||
for (const t of tanks) {
|
||
if (!t.alive) continue;
|
||
const p = worldToScr(t.x, t.y);
|
||
if (p.x < -20 || p.y < -20 || p.x > VIEW_W + 20 || p.y > VIEW_H + 20) continue;
|
||
const s = TANK_HALF * TILE * 2;
|
||
ctx.fillStyle = TEAM_COLORS[t.team];
|
||
ctx.fillRect(p.x - s / 2, p.y - s / 2, s, s);
|
||
ctx.strokeStyle = '#111';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(p.x, p.y);
|
||
ctx.lineTo(p.x + t.dir.x * 6, p.y + t.dir.y * 6);
|
||
ctx.stroke();
|
||
|
||
const hp = Math.max(0, t.hp / MAX_HP);
|
||
ctx.fillStyle = '#111';
|
||
ctx.fillRect(p.x - 6, p.y - 8, 12, 2);
|
||
ctx.fillStyle = hp > 0.5 ? '#3fb950' : (hp > 0.25 ? '#d29922' : '#f85149');
|
||
ctx.fillRect(p.x - 6, p.y - 8, 12 * hp, 2);
|
||
|
||
if (t === controlledTank) {
|
||
ctx.strokeStyle = '#fff';
|
||
ctx.strokeRect(p.x - s / 2 - 2, p.y - s / 2 - 2, s + 4, s + 4);
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawBullets() {
|
||
for (const b of bullets) {
|
||
const p = worldToScr(b.x, b.y);
|
||
if (p.x < 0 || p.y < 0 || p.x > VIEW_W || p.y > VIEW_H) continue;
|
||
ctx.fillStyle = TEAM_COLORS[b.team];
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
function drawMinimap() {
|
||
miniCtx.fillStyle = 'rgba(0,0,0,0.85)';
|
||
miniCtx.fillRect(0, 0, 120, 120);
|
||
const sc = 120 / WORLD;
|
||
|
||
for (let y = 0; y < WORLD; y += 6) {
|
||
for (let x = 0; x < WORLD; x += 6) {
|
||
const t = terrain[y][x];
|
||
if (t === TERRAIN.PLAIN) continue;
|
||
miniCtx.fillStyle = TERRAIN_COLOR[t];
|
||
miniCtx.fillRect(x * sc, y * sc, 1, 1);
|
||
}
|
||
}
|
||
|
||
for (const b of bases) {
|
||
miniCtx.fillStyle = b.alive ? TEAM_COLORS[b.team] : '#333';
|
||
miniCtx.fillRect((b.x - 4) * sc, (b.y - 4) * sc, 8 * sc, 8 * sc);
|
||
}
|
||
for (const t of tanks) {
|
||
if (!t.alive) continue;
|
||
miniCtx.fillStyle = TEAM_COLORS[t.team];
|
||
miniCtx.fillRect(t.x * sc, t.y * sc, 1.5, 1.5);
|
||
}
|
||
|
||
miniCtx.strokeStyle = '#fff';
|
||
miniCtx.lineWidth = 1;
|
||
const s = TILE * zoom;
|
||
miniCtx.strokeRect((camera.x - VIEW_W / s / 2) * sc, (camera.y - VIEW_H / s / 2) * sc, (VIEW_W / s) * sc, (VIEW_H / s) * sc);
|
||
}
|
||
|
||
function draw() {
|
||
ctx.fillStyle = '#050810';
|
||
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
|
||
drawTerrain();
|
||
drawBases();
|
||
drawTanks();
|
||
drawBullets();
|
||
drawMinimap();
|
||
renderTankList();
|
||
}
|
||
|
||
// ==================== UI ====================
|
||
function renderTankList() {
|
||
const el = document.getElementById('tank-list');
|
||
let html = '';
|
||
for (const t of tanks) {
|
||
if (t.team !== 0) continue;
|
||
const cls = t === controlledTank ? 'tank-tankitem controlled' : 'tank-tankitem';
|
||
const hp = Math.max(0, t.hp / MAX_HP) * 100;
|
||
html += `<div class="${cls}" data-id="${t.id}">
|
||
<span class="tank-tankitem-dot" style="background:${TEAM_COLORS[t.team]}"></span>
|
||
<span class="tank-tankitem-name">T${t.id + 1} ${t.role}/${t.unitType}</span>
|
||
<div class="tank-tankitem-hp"><div class="tank-tankitem-hpfill" style="width:${hp}%"></div></div>
|
||
</div>`;
|
||
}
|
||
el.innerHTML = html;
|
||
el.querySelectorAll('.tank-tankitem').forEach(item => {
|
||
item.onclick = () => {
|
||
const id = parseInt(item.dataset.id);
|
||
const t = tanks.find(x => x.id === id);
|
||
if (t && t.alive) controlledTank = (controlledTank === t) ? null : t;
|
||
};
|
||
});
|
||
}
|
||
|
||
function updateStats(winner) {
|
||
const el = document.getElementById('faction-stats');
|
||
let html = '';
|
||
for (let i = 0; i < factions; i++) {
|
||
const s = stats[i] || {alive:0,kills:0,baseAlive:false};
|
||
const b = bases[i];
|
||
const rs = getRespawnState(i);
|
||
const cdPct = clamp((rs.cooldown / 1200) * 100, 0, 100);
|
||
html += `<div class="tank-faction-row">
|
||
<span class="tank-faction-dot" style="background:${TEAM_COLORS[i]}"></span>
|
||
<span class="tank-faction-name">${TEAM_NAMES[i]}方 基地:${b?.alive ? Math.floor(b.hp) : '已毁'} 压力:${rs.pressure.toFixed(1)}</span>
|
||
<span class="tank-faction-stat">${s.alive}/${perTeam}</span>
|
||
</div>
|
||
<div class="tank-faction-row" style="padding-top:2px;padding-bottom:8px;">
|
||
<span class="tank-faction-name" style="font-size:10px;color:var(--text2);">基地复活CD</span>
|
||
<span class="tank-faction-stat" style="min-width:110px;display:inline-block;">
|
||
<span style="display:inline-block;width:88px;height:6px;background:#2d333b;border-radius:3px;overflow:hidden;vertical-align:middle;">
|
||
<span style="display:block;height:6px;background:${TEAM_COLORS[i]};width:${cdPct}%;"></span>
|
||
</span>
|
||
<span style="font-size:10px;color:var(--text2);margin-left:6px;">${Math.floor(rs.cooldown)}</span>
|
||
</span>
|
||
</div>`;
|
||
}
|
||
if (winner !== null) {
|
||
html += `<div class="tank-faction-row"><span>🏁 胜利</span><span class="tank-faction-stat" style="color:${TEAM_COLORS[winner]}">${TEAM_NAMES[winner]}方</span></div>`;
|
||
}
|
||
el.innerHTML = html;
|
||
|
||
const top = document.getElementById('battle-topbar');
|
||
const tacticText = Array.from({length:factions}).map((_,i)=>`${TEAM_NAMES[i]}:${teamTactic[i]||'strike'}`).join(' | ');
|
||
top.innerHTML = `<span class="tank-chip">🧠 战术 ${tacticText}</span><span class="tank-chip">🔭 缩放 x${zoom.toFixed(1)}</span><span class="tank-chip">🎯 跟随 ${followMode}</span>`;
|
||
|
||
const eng = document.getElementById('engine-display');
|
||
eng.innerHTML = `
|
||
<div class="tank-engine-row"><span>AI</span><span class="tank-badge">${webgpu.ready && webgpu.enabled ? 'WebGPU' : 'CPU'}</span></div>
|
||
<div class="tank-engine-row"><span>地图</span><span>${WORLD}×${WORLD}</span></div>
|
||
<div class="tank-engine-row"><span>帧耗时</span><span>${metrics.frameMsAvg.toFixed(1)} ms</span></div>
|
||
<div class="tank-engine-row"><span>重规划/帧</span><span>${metrics.replan}</span></div>
|
||
<div class="tank-engine-row"><span>规避触发/帧</span><span>${metrics.evade}</span></div>
|
||
<div class="tank-engine-row"><span>地形倍率</span><span>平原1 公路3 沼泽0.2</span></div>
|
||
<div class="tank-engine-row"><span>河道阻塞</span><span>${metrics.riverBlocked}</span></div>
|
||
<div class="tank-engine-row"><span>河道路点</span><span>${metrics.riverWaypoints}</span></div>
|
||
<div class="tank-engine-row"><span>过桥计数</span><span>${metrics.bridgeCross}</span></div>
|
||
<div class="tank-engine-row"><span>JPS命中</span><span>${metrics.jpsHits}</span></div>
|
||
<div class="tank-engine-row"><span>JPS未中</span><span>${metrics.jpsMiss}</span></div>
|
||
<div class="tank-engine-row"><span>分层路径</span><span>${metrics.hierPath}</span></div>
|
||
<div class="tank-engine-row"><span>河流绕行</span><span>${metrics.riverDetour}</span></div>
|
||
`;
|
||
metrics.replan = 0;
|
||
metrics.evade = 0;
|
||
metrics.jpsHits = 0;
|
||
metrics.jpsMiss = 0;
|
||
metrics.hierPath = 0;
|
||
metrics.riverDetour = 0;
|
||
}
|
||
|
||
// ==================== Input ====================
|
||
document.addEventListener('keydown', e => {
|
||
keys[e.key.toLowerCase()] = true;
|
||
if (e.key === ' ') e.preventDefault();
|
||
});
|
||
document.addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; });
|
||
['up','down','left','right','fire'].forEach(k => {
|
||
const b = document.getElementById('m-' + k);
|
||
if (!b) return;
|
||
const map = {up:'arrowup',down:'arrowdown',left:'arrowleft',right:'arrowright',fire:' '};
|
||
b.ontouchstart = e => { e.preventDefault(); keys[map[k]] = true; };
|
||
b.ontouchend = e => { e.preventDefault(); keys[map[k]] = false; };
|
||
});
|
||
|
||
// ==================== Controls ====================
|
||
document.getElementById('btn-start').onclick = () => {
|
||
if (!gameRunning) {
|
||
factions = parseInt(document.getElementById('faction-count').value);
|
||
perTeam = parseInt(document.getElementById('tanks-per-faction').value);
|
||
initWorld();
|
||
spawnAll();
|
||
gameRunning = true;
|
||
update();
|
||
}
|
||
};
|
||
|
||
document.getElementById('btn-reset').onclick = () => {
|
||
gameRunning = false;
|
||
initWorld();
|
||
spawnAll();
|
||
draw();
|
||
updateStats(null);
|
||
};
|
||
|
||
document.getElementById('btn-view-center').onclick = () => {
|
||
camera.x = WORLD / 2;
|
||
camera.y = WORLD / 2;
|
||
};
|
||
|
||
document.getElementById('zoom-range').oninput = (e) => {
|
||
zoom = clamp(parseFloat(e.target.value), 0.6, 2.2);
|
||
};
|
||
document.getElementById('follow-mode').onchange = (e) => {
|
||
followMode = e.target.value;
|
||
};
|
||
canvas.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
zoom = clamp(zoom + (e.deltaY < 0 ? 0.1 : -0.1), 0.6, 2.2);
|
||
document.getElementById('zoom-range').value = String(zoom);
|
||
}, {passive:false});
|
||
canvas.addEventListener('mousedown', (e) => {
|
||
if (followMode !== 'free') return;
|
||
dragging = true;
|
||
lastDrag = {x:e.clientX, y:e.clientY};
|
||
});
|
||
window.addEventListener('mouseup', () => { dragging = false; });
|
||
window.addEventListener('mousemove', (e) => {
|
||
if (!dragging || followMode !== 'free' || !lastDrag) return;
|
||
const dx = e.clientX - lastDrag.x;
|
||
const dy = e.clientY - lastDrag.y;
|
||
lastDrag = {x:e.clientX, y:e.clientY};
|
||
camera.x -= dx / (TILE * zoom);
|
||
camera.y -= dy / (TILE * zoom);
|
||
camera.x = clamp(camera.x, 0, WORLD);
|
||
camera.y = clamp(camera.y, 0, WORLD);
|
||
});
|
||
|
||
// init
|
||
initWebGPUAI();
|
||
initWorld();
|
||
spawnAll();
|
||
draw();
|
||
updateStats(null);
|
||
})();
|
||
</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> |