<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<title>粒界 · 小世界粒子沙盘</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&family=ZCOOL+QingKe+HuangYou&display=swap" rel="stylesheet">
<style>
:root{
--bg1:#2a1f36;--bg2:#46283c;--panel:#332846;--panel2:#413458;
--text:#f4e9d8;--muted:#b3a3c2;--accent:#f6a821;--accent2:#ef6a3f;
--line:rgba(255,225,180,.10);
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{height:100%}
body{
font-family:'Noto Sans SC',system-ui,sans-serif;color:var(--text);
overflow:hidden;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;
transition:color .4s;
}
body::before{
content:'';position:fixed;inset:0;z-index:-2;transition:background .6s;
background:
radial-gradient(60% 55% at 10% -5%, color-mix(in srgb,var(--accent) 18%, transparent), transparent 70%),
radial-gradient(55% 45% at 100% 105%, color-mix(in srgb,var(--accent2) 16%, transparent), transparent 70%),
linear-gradient(163deg,var(--bg1),var(--bg2));
}
.spk{position:fixed;border-radius:50%;pointer-events:none;z-index:-1;filter:blur(1px);opacity:.32;
animation:drift var(--d,10s) ease-in-out infinite alternate}
@keyframes drift{from{transform:translate(0,0)}to{transform:translate(20px,-30px)}}
#app{position:relative;z-index:1;height:100dvh;display:grid;
grid-template-rows:auto 1fr auto;grid-template-areas:"top" "main" "dock"}
/* ---------- 顶栏 ---------- */
#top{grid-area:top;display:flex;align-items:center;gap:12px;padding:9px 14px;
border-bottom:1px solid var(--line);position:relative;z-index:30;
background:color-mix(in srgb,var(--panel) 78%, transparent);backdrop-filter:blur(8px)}
.brand{display:flex;align-items:center;gap:10px}
.logo{display:grid;grid-template-columns:1fr 1fr;gap:3px;width:26px;height:26px}
.logo i{border-radius:3px;animation:lg 3.2s ease-in-out infinite}
.logo i:nth-child(1){background:var(--accent)}
.logo i:nth-child(2){background:var(--accent2);animation-delay:.4s}
.logo i:nth-child(3){background:var(--muted);animation-delay:.8s}
.logo i:nth-child(4){background:var(--text);animation-delay:1.2s}
@keyframes lg{0%,100%{transform:scale(1)}50%{transform:scale(.68)}}
.ttl h1{font-family:'ZCOOL QingKe HuangYou';font-size:23px;font-weight:400;letter-spacing:4px;line-height:1}
.ttl span{display:block;font-size:9px;letter-spacing:3.5px;color:var(--muted);margin-top:3px}
.stats{display:flex;align-items:center;gap:10px;margin-left:6px;font-size:9px;letter-spacing:1px;color:var(--muted)}
.stats b{font-family:'ZCOOL QingKe HuangYou';font-weight:400;font-size:16px;color:var(--accent);margin-right:2px;font-variant-numeric:tabular-nums}
.acts{margin-left:auto;display:flex;align-items:center;gap:7px}
.ibtn{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:34px;min-width:34px;
padding:0 8px;border-radius:9px;border:1px solid var(--line);background:var(--panel2);color:var(--text);
cursor:pointer;transition:transform .15s,border-color .2s,box-shadow .2s;touch-action:manipulation}
.ibtn svg{width:17px;height:17px}
.ibtn:hover{border-color:var(--accent);transform:translateY(-1px);box-shadow:0 4px 14px color-mix(in srgb,var(--accent) 25%,transparent)}
.ibtn:active{transform:scale(.9)}
.ibtn.wide{padding:0 12px;font-size:12px;letter-spacing:2px;font-family:'ZCOOL QingKe HuangYou'}
button{font-family:inherit}
/* ---------- 元素坞 ---------- */
#dock{grid-area:dock;display:flex;gap:8px;overflow-x:auto;padding:10px 12px calc(10px + env(safe-area-inset-bottom));
border-top:1px solid var(--line);background:color-mix(in srgb,var(--panel) 78%, transparent);
scrollbar-width:none;touch-action:pan-x;backdrop-filter:blur(8px)}
#dock::-webkit-scrollbar{display:none}
.chip{flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:8px 12px 8px 9px;border-radius:10px;
border:1px solid var(--line);background:var(--panel);color:var(--text);cursor:pointer;
transition:transform .15s,border-color .2s,background .25s,box-shadow .2s;touch-action:manipulation}
.chip:hover{transform:translateY(-2px)}
.chip:active{transform:scale(.93)}
.chip .dot{width:16px;height:16px;border-radius:4px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.2)}
.chip span{font-family:'ZCOOL QingKe HuangYou';font-size:14px;letter-spacing:1.5px}
.chip em{font-style:normal;font-size:9px;color:var(--muted);border:1px solid var(--line);border-radius:4px;padding:1px 4px}
.chip.sel{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 15%,var(--panel));
box-shadow:0 0 0 1px var(--accent),0 4px 16px color-mix(in srgb,var(--accent) 30%,transparent)}
.chip.sel .dot{animation:pop .3s}
@keyframes pop{50%{transform:scale(1.4) rotate(8deg)}}
.chip .dot.era{background:repeating-linear-gradient(45deg,var(--muted) 0 3px,transparent 3px 6px)!important}
/* ---------- 主区 ---------- */
#main{grid-area:main;display:flex;flex-direction:column;gap:10px;padding:12px;min-height:0;min-width:0}
#stage{position:relative;flex:1;min-height:0;border-radius:14px;border:1px solid var(--line);overflow:hidden;
background:#000;box-shadow:0 14px 44px rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.05)}
#stage.shake{animation:shk .28s}
@keyframes shk{20%{transform:translate(3px,-2px)}40%{transform:translate(-3px,2px)}60%{transform:translate(2px,2px)}80%{transform:translate(-2px,-1px)}}
#sim{position:absolute;inset:0;width:100%;height:100%;image-rendering:pixelated;image-rendering:crisp-edges;
touch-action:none;cursor:crosshair}
#vig{position:absolute;inset:0;pointer-events:none;box-shadow:inset 0 0 70px rgba(0,0,0,.22)}
#ring{position:absolute;pointer-events:none;border:1.5px solid var(--accent);border-radius:50%;
transform:translate(-50%,-50%);opacity:0;transition:opacity .15s;
box-shadow:0 0 14px color-mix(in srgb,var(--accent) 55%,transparent)}
#hint{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;
pointer-events:none;transition:opacity .7s}
#hint.off{opacity:0}
#hint b{font-family:'ZCOOL QingKe HuangYou';font-weight:400;font-size:clamp(22px,5vw,34px);letter-spacing:8px;
color:var(--text);text-shadow:0 2px 20px rgba(0,0,0,.55);animation:pulse 2.2s ease-in-out infinite}
#hint span{font-size:12px;letter-spacing:1px;background:rgba(0,0,0,.38);color:#fff;padding:6px 14px;border-radius:20px;backdrop-filter:blur(4px)}
@keyframes pulse{50%{transform:scale(1.05);opacity:.82}}
#pbadge{position:absolute;top:12px;left:50%;transform:translateX(-50%) translateY(-8px);
background:var(--panel);border:1px solid var(--line);padding:4px 16px;border-radius:20px;
font-family:'ZCOOL QingKe HuangYou';font-size:12px;letter-spacing:4px;opacity:0;transition:.25s;pointer-events:none}
#stage.paused #pbadge{opacity:1;transform:translateX(-50%) translateY(0)}
/* ---------- 控制条 ---------- */
#bar{display:flex;align-items:center;flex-wrap:wrap;gap:10px 16px;padding:9px 14px;
border:1px solid var(--line);border-radius:12px;background:color-mix(in srgb,var(--panel) 82%, transparent)}
.ctl{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--muted);white-space:nowrap;letter-spacing:1px}
.ctl b{font-family:'ZCOOL QingKe HuangYou';font-weight:400;color:var(--accent);font-size:14px;min-width:18px;text-align:center}
input[type=range]{-webkit-appearance:none;appearance:none;width:92px;height:4px;border-radius:2px;background:var(--line);outline:none;cursor:pointer}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:15px;height:15px;border-radius:50%;
background:var(--accent);border:2.5px solid var(--panel);box-shadow:0 0 8px color-mix(in srgb,var(--accent) 60%,transparent);transition:transform .15s}
input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.25)}
input[type=range]::-moz-range-thumb{width:13px;height:13px;border-radius:50%;background:var(--accent);border:2.5px solid var(--panel)}
#info{margin-left:auto;display:flex;align-items:baseline;gap:9px;min-width:0}
#elName{font-family:'ZCOOL QingKe HuangYou';font-size:17px;letter-spacing:3px;color:var(--accent2)}
#elDesc{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ---------- 主题面板 / 提示 ---------- */
#themePanel{position:fixed;right:12px;top:56px;z-index:40;width:232px;background:var(--panel);
border:1px solid var(--line);border-radius:14px;padding:12px;
box-shadow:0 20px 55px rgba(0,0,0,.38);opacity:0;transform:translateY(-8px) scale(.97);
pointer-events:none;transition:.22s}
#themePanel.open{opacity:1;transform:none;pointer-events:auto}
#themePanel h3{font-family:'ZCOOL QingKe HuangYou';font-weight:400;font-size:13px;letter-spacing:4px;color:var(--muted);margin:0 4px 10px}
.tcard{display:flex;align-items:center;gap:11px;width:100%;padding:7px 8px;border-radius:10px;
border:1px solid transparent;background:transparent;color:var(--text);cursor:pointer;transition:.15s;margin-bottom:3px;text-align:left}
.tcard:hover{background:var(--panel2);transform:translateX(2px)}
.tcard.on{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent)}
.strip{display:flex;border-radius:6px;overflow:hidden;flex:0 0 auto;box-shadow:inset 0 0 0 1px rgba(0,0,0,.15)}
.strip i{width:10px;height:26px}
.tmeta b{font-family:'ZCOOL QingKe HuangYou';font-weight:400;font-size:14px;letter-spacing:2px;display:block}
.tmeta small{font-size:10px;color:var(--muted)}
#toast{position:fixed;left:50%;bottom:112px;transform:translateX(-50%) translateY(12px);
background:var(--text);color:var(--bg1);padding:7px 20px;border-radius:20px;font-size:12px;font-weight:700;
letter-spacing:2px;opacity:0;transition:.25s;z-index:50;pointer-events:none}
#toast.show{opacity:1;transform:translateX(-50%)}
/* ---------- 桌面布局 ---------- */
@media(min-width:880px){
#app{grid-template-columns:196px 1fr;grid-template-areas:"top top" "dock main"}
#dock{flex-direction:column;overflow-y:auto;overflow-x:hidden;border-top:none;border-right:1px solid var(--line);
padding:14px 12px;touch-action:pan-y}
.chip{width:100%}
.chip em{margin-left:auto}
#main{padding:16px}
}
@media(max-width:430px){ .stats span:last-child{display:none} .ibtn.wide span{display:none} }
</style>
</head>
<body>
<span class="spk" style="left:5%;top:20%;width:9px;height:9px;background:var(--accent);--d:9s"></span>
<span class="spk" style="left:14%;top:72%;width:6px;height:6px;background:var(--accent2);--d:12s;animation-delay:-3s"></span>
<span class="spk" style="left:88%;top:16%;width:7px;height:7px;background:var(--accent2);--d:10s;animation-delay:-5s"></span>
<span class="spk" style="left:93%;top:64%;width:10px;height:10px;background:var(--accent);--d:13s;animation-delay:-2s"></span>
<span class="spk" style="left:50%;top:88%;width:5px;height:5px;background:var(--accent);--d:8s;animation-delay:-6s"></span>
<div id="app">
<header id="top">
<div class="brand">
<div class="logo"><i></i><i></i><i></i><i></i></div>
<div class="ttl"><h1>粒界</h1><span>PARTICLE WORLD · 掌上沙盘</span></div>
</div>
<div class="stats">
<span><b id="fps">0</b>FPS</span>
<span><b id="cnt">0</b>粒子</span>
</div>
<div class="acts">
<button id="btnPause" class="ibtn" aria-label="暂停/继续"></button>
<button id="btnShot" class="ibtn" aria-label="截图保存">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/></svg>
</button>
<button id="btnClear" class="ibtn" aria-label="清空">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>
</button>
<button id="btnTheme" class="ibtn wide" aria-label="主题">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3a9 9 0 100 18c1.5 0 2-1 1.5-2s0-2 1.5-2H17a4 4 0 004-4c0-5-4-10-9-10z"/><circle cx="8" cy="10" r="1.3" fill="currentColor" stroke="none"/><circle cx="12.5" cy="7.5" r="1.3" fill="currentColor" stroke="none"/><circle cx="16.5" cy="10.5" r="1.3" fill="currentColor" stroke="none"/></svg>
<span>主题</span>
</button>
</div>
</header>
<nav id="dock" aria-label="元素坞"></nav>
<main id="main">
<div id="stage">
<canvas id="sim"></canvas>
<div id="vig"></div>
<div id="pbadge">已 暂 停</div>
<div id="hint"><b>点 按 · 拖 拽</b><span>选择元素,在画布中绘制你的小世界</span></div>
<div id="ring"></div>
</div>
<div id="bar">
<label class="ctl">笔刷 <input type="range" id="brushR" min="1" max="14" value="4"><b id="brushV">4</b></label>
<label class="ctl">风力 <input type="range" id="windR" min="-5" max="5" value="0"><b id="windV">0</b></label>
<div id="info"><b id="elName">沙</b><span id="elDesc">坠落堆积 · 沉入水底</span></div>
</div>
</main>
</div>
<div id="themePanel"></div>
<div id="toast"></div>
<script>
'use strict';
/* ================= 元素定义 ================= */
const E={EMPTY:0,WALL:1,SAND:2,WATER:3,OIL:4,FIRE:5,WOOD:6,PLANT:7,LAVA:8,ACID:9,TNT:10,ICE:11,SEED:12,VIRUS:13,CLONE:14,SMOKE:15,STEAM:16,GLASS:17};
const ERASE=255;
const CAT=new Uint8Array(18); // 1静态 2粉末 3液体 4气体
CAT[E.WALL]=CAT[E.GLASS]=CAT[E.WOOD]=CAT[E.PLANT]=CAT[E.ICE]=CAT[E.CLONE]=1;
CAT[E.SAND]=CAT[E.SEED]=CAT[E.TNT]=2;
CAT[E.WATER]=CAT[E.OIL]=CAT[E.ACID]=CAT[E.LAVA]=3;
CAT[E.FIRE]=CAT[E.SMOKE]=CAT[E.STEAM]=4;
const DENS=new Uint8Array(18); DENS[E.WATER]=3;DENS[E.OIL]=2;DENS[E.ACID]=3;DENS[E.LAVA]=5;
const PLACE=new Float32Array(256);
[E.WALL,E.WOOD,E.PLANT,E.ICE,E.CLONE].forEach(id=>PLACE[id]=1);
[E.SAND,E.SEED,E.TNT].forEach(id=>PLACE[id]=.55);
[E.WATER,E.OIL,E.ACID].forEach(id=>PLACE[id]=.8);
PLACE[E.FIRE]=.5; PLACE[ERASE]=1;
const NX=[0,0,-1,1],NY=[-1,1,0,0];
const ELEMENTS=[
[E.SAND ,'沙','1','坠落堆积 · 沉入水底'],
[E.WATER,'水','2','流动扩散 · 熄灭火焰'],
[E.WALL ,'石','3','坚固屏障 · 酸液无法腐蚀'],
[E.WOOD ,'木','4','静止建材 · 可以点燃'],
[E.FIRE ,'火','5','向上蔓延 · 燃尽成烟'],
[E.OIL ,'油','6','浮于水面 · 极度易燃'],
[E.PLANT,'植物','7','触碰水即生长蔓延'],
[E.LAVA ,'熔岩','8','灼烧万物 · 遇水成石'],
[E.ACID ,'酸液','9','溶解万物 · 自我消耗'],
[E.TNT ,'火药','0','遇火即爆 · 连锁反应'],
[E.ICE ,'冰','','冻结流水 · 遇热化水'],
[E.SEED ,'种子','','落水发芽 · 长成植物'],
[E.VIRUS,'病毒','','侵蚀感染 · 自行衰亡'],
[E.CLONE,'克隆','','复制首个触碰的元素'],
[ERASE ,'橡皮','','擦除一切粒子'],
];
const PALKEY={1:'wall',2:'sand',3:'water',4:'oil',5:'fire',6:'wood',7:'plant',8:'lava',9:'acid',10:'tnt',11:'ice',12:'seed',13:'virus',14:'clone',17:'glass'};
/* ================= 五套主题 ================= */
const THEMES={
dusk:{name:'暮野',desc:'大漠黄昏 · 经典沙盘',sky:['#3a3161','#8a5163'],
ui:{bg1:'#2a1f36',bg2:'#46283c',panel:'#332846',panel2:'#413458',text:'#f4e9d8',muted:'#b3a3c2',accent:'#f6a821',accent2:'#ef6a3f',line:'rgba(255,225,180,.10)'},
pal:{wall:['#8b90a5','#7a8095','#697085','#969bb0'],sand:['#ecc26e','#dcae55','#cb9a44','#f4d285'],
water:['#4f86d4','#3f72c0','#6097e2','#3a68b4'],oil:['#5d5a35','#4e4b2b','#6c683f','#443f22'],
fire:['#ffe66b','#ff8f2e','#e8432a'],wood:['#8f5d34','#7d4f2a','#9c6a3d','#6f4526'],
plant:['#4fb06f','#3f9c5e','#62c481','#37874f'],lava:['#ff7a1f','#f2570f','#ffa044','#e04a0c'],
acid:['#a4f542','#86dd2c','#bef968','#74c922'],tnt:['#d24a44','#b93a36','#e2605a','#a32f2c'],
ice:['#c4e8ff','#a9d9f6','#dcf3ff','#93cbee'],seed:['#cfa54e','#bb9140','#e0b860','#a87f36'],
virus:['#c653de','#ad3cc7','#da6ff0','#9730b0'],clone:['#d9d9e2','#c2c2cf','#ececf4','#abaaba'],
glass:['#cfe2ec','#bcd2e0','#e0eef6','#a9c2d2'],smoke:'#8a8798',steam:'#c3d7e6'}},
neon:{name:'夜城',desc:'霓虹电子 · 高饱和',sky:['#070c20','#1a2c50'],
ui:{bg1:'#0a1224',bg2:'#0d2434',panel:'#12203a',panel2:'#1a2c4c',text:'#e6f1ff',muted:'#8fa6c8',accent:'#35e0c8',accent2:'#ff5c8a',line:'rgba(140,200,255,.12)'},
pal:{wall:['#5a6a8f','#4c5b7d','#68789f','#3f4d6b'],sand:['#ffd23f','#f0be2e','#ffe066','#d9a822'],
water:['#2fa4ff','#1f8ce8','#57baff','#1878cc'],oil:['#4a4a6e','#3d3d5c','#585880','#33334e'],
fire:['#fff06b','#ff9a2e','#ff4d33'],wood:['#b07a3f','#9c6a35','#c08a4c','#8a5c2e'],
plant:['#3dff8c','#22e878','#66ffa6','#12cc64'],lava:['#ff5c1f','#ff3d0f','#ff8444','#e0320c'],
acid:['#b4ff2e','#98e81a','#ccff66','#80cc12'],tnt:['#ff2e5c','#e01f4c','#ff5c80','#c01640'],
ice:['#7df3ff','#57e0f0','#a5f8ff','#3ccbe0'],seed:['#e8c257','#d4ae44','#f6d47a','#c09a36'],
virus:['#ff3df0','#e022d4','#ff6ef6','#c012b8'],clone:['#9aa5c0','#8691ad','#b0bad4','#727d99'],
glass:['#7fa8cc','#6b96bc','#93bade','#5a84aa'],smoke:'#5c627e',steam:'#8fb0d8'}},
ink:{name:'纸墨',desc:'宣纸水墨 · 素雅',sky:['#f7f2e5','#e2d6bc'],
ui:{bg1:'#f0e7d4',bg2:'#ddcdb2',panel:'#faf5e8',panel2:'#f0e9d8',text:'#2b241a',muted:'#84765e',accent:'#c2372b',accent2:'#276785',line:'rgba(75,58,35,.16)'},
pal:{wall:['#3c3a38','#4a4845','#2f2d2b','#575450'],sand:['#b39a5e','#a08a50','#c4ab6e','#8f7a44'],
water:['#4a7a9c','#3d6c8e','#5a8aac','#315e7e'],oil:['#4c463c','#3f3a31','#5a5347','#332e26'],
fire:['#e8b13c','#d96a24','#c03a18'],wood:['#5c4630','#4e3b28','#6b523a','#42311f'],
plant:['#4c7a4e','#3f6b41','#5c8c5e','#335c35'],lava:['#c04a20','#a83a16','#d45c2c','#92300f'],
acid:['#7a8c2e','#6b7c24','#8a9c3c','#5c6c1c'],tnt:['#8c3030','#7a2626','#9c3c3c','#681e1e'],
ice:['#9db8c6','#8aa8b8','#b0c8d4','#7898aa'],seed:['#8f7a3c','#7d6a32','#a08a48','#6b5a2a'],
virus:['#6a4a8c','#5c3d7c','#7a589c','#4e326c'],clone:['#8c8880','#7a766e','#9c9890','#68645c'],
glass:['#c6d2d4','#b4c2c6','#d6e0e2','#a2b2b6'],smoke:'#8a867c',steam:'#c8c2b4'}},
abyss:{name:'深海',desc:'深渊幽光 · 冷调',sky:['#03101c','#0d3546'],
ui:{bg1:'#051521',bg2:'#0a2f42',panel:'#0b2130',panel2:'#103044',text:'#d7eff4',muted:'#7ba6b2',accent:'#3fd8c2',accent2:'#ffb454',line:'rgba(120,220,235,.12)'},
pal:{wall:['#3f5a66','#35505b','#4a6874','#2b444e'],sand:['#d8c9a0','#c8b88e','#e6d8b2','#b8a87c'],
water:['#2e9fd8','#2289c2','#45b2e6','#1a77ac'],oil:['#3a4a52','#30404a','#465860','#28363e'],
fire:['#ffd96b','#ff9a3c','#ff5c3c'],wood:['#7a5a3a','#6a4c30','#8a6844','#5c4028'],
plant:['#2ee8a0','#1ccc8c','#57f6b4','#12b47a'],lava:['#ff6a24','#f04e10','#ff8c4c','#dc420c'],
acid:['#8cf03c','#74d82a','#a4f866','#60c020'],tnt:['#e04848','#c83838','#f06060','#b02c2c'],
ice:['#a8e8ff','#8cd8f6','#c4f2ff','#74c8ea'],seed:['#c8a850','#b49644','#dabb62','#a0843a'],
virus:['#d86ef0','#c054dc','#e88cf8','#a844c4'],clone:['#7a9aa8','#68889a','#8cacc0','#587888'],
glass:['#9cc4d8','#88b2c8','#b0d4e4','#76a0b6'],smoke:'#4a6270',steam:'#7aa4b8'}},
candy:{name:'糖霜',desc:'马卡龙 · 甜系',sky:['#fdf0f6','#e3d9f4'],
ui:{bg1:'#ffe9f1',bg2:'#dceeff',panel:'#fff7fa',panel2:'#fdeaf3',text:'#4c3550',muted:'#a488ac',accent:'#ff6fa5',accent2:'#4fb2e8',line:'rgba(160,90,140,.16)'},
pal:{wall:['#b8a8cc','#a898bc','#c8b8da','#9888ac'],sand:['#ffd98c','#f6c878','#ffe4a6','#e8b666'],
water:['#7ab8ff','#66a6f6','#92c8ff','#5294e8'],oil:['#8f7aa8','#7f6a98','#9f8ab8','#6f5a88'],
fire:['#ffcf5c','#ff9a5c','#ff6b6b'],wood:['#c89a6a','#b8895c','#d6aa7a','#a87a4e'],
plant:['#6edc8c','#5acc7c','#84eaa0','#48bc6c'],lava:['#ff8a5c','#f6744a','#ffa070','#e8623c'],
acid:['#b8e84a','#a4d83a','#c8f466','#90c42e'],tnt:['#ff7a7a','#f66666','#ff9090','#e85454'],
ice:['#cfeeff','#b8e2f8','#e2f6ff','#a2d6f0'],seed:['#e0b45c','#cca04e','#eec670','#b89042'],
virus:['#c88aff','#b474f0','#d8a4ff','#a060e0'],clone:['#e8dcec','#d8cce0','#f4eaf8','#c8bcd0'],
glass:['#dfe6f6','#cdd6ec','#edf1fb','#bcc7de'],smoke:'#b8a8c0',steam:'#dcd2e8'}},
};
/* ================= 全局状态 ================= */
const canvas=document.getElementById('sim'),ctx=canvas.getContext('2d');
const stage=document.getElementById('stage'),ring=document.getElementById('ring'),hint=document.getElementById('hint');
const fpsEl=document.getElementById('fps'),cntEl=document.getElementById('cnt');
const elName=document.getElementById('elName'),elDesc=document.getElementById('elDesc');
const tp=document.getElementById('themePanel'),toastEl=document.getElementById('toast');
let W=0,H=0,grid,life,shade,aux,stamp,img,bgRow,CT,FIREC,SMKC,STMC;
let f=0,curStamp=0,running=true,sel=E.SAND,brush=4,wind=0,hasDrawn=false,frames=0,curTheme='dusk';
const rnd=Math.random;
const hex2rgb=h=>{const n=parseInt(h.slice(1),16);return[n>>16&255,n>>8&255,n&255];};
/* ================= 画布尺寸(多设备自适应) ================= */
function sizeSim(){
const r=stage.getBoundingClientRect();
let s=r.width<520?3:4;
let w=Math.max(80,Math.floor(r.width/s)),h=Math.max(80,Math.floor(r.height/s));
while(w*h>75000){s+=.5;w=Math.floor(r.width/s);h=Math.floor(r.height/s);}
W=w;H=h;canvas.width=W;canvas.height=H;
const n=W*H;
grid=new Uint8Array(n);life=new Uint8Array(n);aux=new Uint8Array(n);stamp=new Uint8Array(n);
shade=new Uint8Array(n);for(let i=0;i<n;i++)shade[i]=(rnd()*256)|0;
img=ctx.createImageData(W,H);
}
/* ================= 颜色表 ================= */
function buildColors(t){
CT=new Uint8Array(18*4*3);
for(const id in PALKEY){
const arr=t.pal[PALKEY[id]];
for(let s2=0;s2<4;s2++){
const c=hex2rgb(arr[s2%arr.length]),o=(id*4+s2)*3;
CT[o]=c[0];CT[o+1]=c[1];CT[o+2]=c[2];
}
}
FIREC=t.pal.fire.map(hex2rgb);SMKC=hex2rgb(t.pal.smoke);STMC=hex2rgb(t.pal.steam);
const top=hex2rgb(t.sky[0]),bot=hex2rgb(t.sky[1]);
bgRow=new Uint8Array(H*3);
for(let y=0;y<H;y++){
const tt=H>1?y/(H-1):0,e=tt*tt*(3-2*tt);
bgRow[y*3]=top[0]+(bot[0]-top[0])*e;
bgRow[y*3+1]=top[1]+(bot[1]-top[1])*e;
bgRow[y*3+2]=top[2]+(bot[2]-top[2])*e;
}
}
function applyTheme(key){
curTheme=key;const t=THEMES[key],s=document.documentElement.style;
for(const k in t.ui)s.setProperty('--'+k,t.ui[k]);
buildColors(t);
document.querySelectorAll('.chip').forEach(ch=>{
const id=+ch.dataset.id;if(id===ERASE)return;
const arr=t.pal[PALKEY[id]];
ch.querySelector('.dot').style.background=`linear-gradient(135deg,${arr[0]},${arr[2]||arr[1]})`;
});
document.querySelectorAll('.tcard').forEach(c=>c.classList.toggle('on',c.dataset.t===key));
try{localStorage.setItem('pw-theme',key);}catch(e){}
}
/* ================= 物理模拟 ================= */
function spawnLife(id){
if(id===E.FIRE)return 60+(rnd()*70|0);
if(id===E.SMOKE)return 80+(rnd()*80|0);
if(id===E.STEAM)return 60+(rnd()*70|0);
if(id===E.ACID)return 240;
if(id===E.VIRUS)return 150+(rnd()*90|0);
return 0;
}
function spawnAt(i,id){grid[i]=id;aux[i]=0;life[i]=spawnLife(id);}
function swap(i,j){
let t=grid[i];grid[i]=grid[j];grid[j]=t;
t=life[i];life[i]=life[j];life[j]=t;
t=aux[i];aux[i]=aux[j];aux[j]=t;
stamp[j]=curStamp;
}
function ignite(j){grid[j]=E.FIRE;life[j]=50+(rnd()*70|0);}
function flow(i,x,y,rate){
const dir=rnd()<.5?1:-1;
for(let k=0;k<2;k++){
const d=k===0?dir:-dir;let target=-1;
for(let s2=1;s2<=rate;s2++){
const nx=x+d*s2;if(nx<0||nx>=W)break;
const j=i+d*s2;
if(grid[j]===E.EMPTY)target=j;else break;
}
if(target>=0){swap(i,target);return true;}
}
return false;
}
function powder(i,x,y){
if(y+1>=H)return;
const d=i+W,b=grid[d];
if(b===E.EMPTY||CAT[b]===4){swap(i,d);return;}
if(CAT[b]===3&&rnd()<.55){swap(i,d);return;}
const dir=rnd()<.5?1:-1;
for(let k=0;k<2;k++){
const dx=k===0?dir:-dir,nx=x+dx;if(nx<0||nx>=W)continue;
const j=d+dx,g=grid[j];
if(g===E.EMPTY||CAT[g]===4){swap(i,j);return;}
if(CAT[g]===3&&rnd()<.35){swap(i,j);return;}
}
}
function liquid(i,x,y,rate,dens){
if(y+1<H){
const d=i+W,b=grid[d];
if(b===E.EMPTY||CAT[b]===4){swap(i,d);return;}
if(CAT[b]===3&&DENS[b]<dens&&rnd()<.4){swap(i,d);return;}
const dir=rnd()<.5?1:-1;
for(let k=0;k<2;k++){
const dx=k===0?dir:-dir,nx=x+dx;if(nx<0||nx>=W)continue;
const j=d+dx,g=grid[j];
if(g===E.EMPTY||CAT[g]===4){swap(i,j);return;}
if(CAT[g]===3&&DENS[g]<dens&&rnd()<.25){swap(i,j);return;}
}
}
flow(i,x,y,rate);
}
function fireUpd(i,x,y){
for(let k=0;k<4;k++){
const nx=x+NX[k],ny=y+NY[k];if(nx<0||ny<0||nx>=W||ny>=H)continue;
if(grid[ny*W+nx]===E.WATER){grid[i]=E.STEAM;life[i]=50+(rnd()*60|0);return;}
}
if(--life[i]<=0){
if(rnd()<.3){grid[i]=E.SMOKE;life[i]=70+(rnd()*70|0);}else grid[i]=E.EMPTY;return;
}
for(let k=0;k<4;k++){
const nx=x+NX[k],ny=y+NY[k];if(nx<0||ny<0||nx>=W||ny>=H)continue;
const j=ny*W+nx,g=grid[j];
if(g===E.WOOD){if(rnd()<.05)ignite(j);}
else if(g===E.PLANT){if(rnd()<.14)ignite(j);}
else if(g===E.OIL){if(rnd()<.45)ignite(j);}
else if(g===E.SEED){if(rnd()<.1)ignite(j);}
else if(g===E.TNT){life[j]=1;}
else if(g===E.ICE){if(rnd()<.12)grid[j]=E.WATER;}
else if(g===E.VIRUS){if(rnd()<.5){grid[j]=rnd()<.4?E.SMOKE:E.EMPTY;if(grid[j]===E.SMOKE)life[j]=50;}}
}
if(y>0&&grid[i-W]===E.EMPTY&&rnd()<.07){grid[i-W]=E.SMOKE;life[i-W]=60+(rnd()*70|0);stamp[i-W]=curStamp;}
if(rnd()<.8&&y>0){
const d=rnd()<.5?1:-1;
if(grid[i-W]===E.EMPTY)swap(i,i-W);
else{const nx=x+d;if(nx>=0&&nx<W&&grid[i-W+d]===E.EMPTY)swap(i,i-W+d);}
}
}
function lavaUpd(i,x,y){
for(let k=0;k<4;k++){
const nx=x+NX[k],ny=y+NY[k];if(nx<0||ny<0||nx>=W||ny>=H)continue;
const j=ny*W+nx,g=grid[j];
if(g===E.WATER){grid[j]=E.STEAM;life[j]=60+(rnd()*60|0);grid[i]=E.WALL;return;}
else if(g===E.ICE){grid[j]=E.WATER;}
else if(g===E.SAND&&rnd()<.02){grid[j]=E.GLASS;}
else if(g===E.WOOD&&rnd()<.2)ignite(j);
else if(g===E.PLANT&&rnd()<.35)ignite(j);
else if(g===E.OIL&&rnd()<.6)ignite(j);
else if(g===E.SEED&&rnd()<.2)ignite(j);
else if(g===E.TNT){life[j]=1;}
else if(g===E.VIRUS){grid[j]=E.EMPTY;}
}
if(y>0&&grid[i-W]===E.EMPTY&&rnd()<.04){grid[i-W]=E.FIRE;life[i-W]=20+(rnd()*25|0);stamp[i-W]=curStamp;}
if(rnd()<.5&&y+1<H){
const d=i+W,b=grid[d];
if(b===E.EMPTY||CAT[b]===4){swap(i,d);return;}
const dir=rnd()<.5?1:-1,nx=x+dir;
if(nx>=0&&nx<W&&grid[d+dir]===E.EMPTY){swap(i,d+dir);return;}
}else if(rnd()<.3)flow(i,x,y,1);
}
function acidUpd(i,x,y){
if(life[i]<70){grid[i]=E.EMPTY;return;}
for(let k=0;k<4;k++){
const nx=x+NX[k],ny=y+NY[k];if(nx<0||ny<0||nx>=W||ny>=H)continue;
const j=ny*W+nx,g=grid[j];
if(g===E.EMPTY||g===E.ACID||g===E.WALL||g===E.GLASS||g===E.CLONE||g===E.WATER||g===E.OIL)continue;
if(rnd()<.25){
if(rnd()<.35){grid[j]=E.SMOKE;life[j]=40+(rnd()*50|0);}else grid[j]=E.EMPTY;
life[i]-=55;
if(life[i]<70){grid[i]=E.EMPTY;return;}
}
}
liquid(i,x,y,2,3);
}
function gasUpd(i,x,y,id){
if(--life[i]<=0){
if(id===E.STEAM&&rnd()<.15)grid[i]=E.WATER;else grid[i]=E.EMPTY;return;
}
let dx;
if(wind!==0&&rnd()<Math.abs(wind)*.12)dx=wind>0?1:-1;else dx=rnd()<.5?1:-1;
if(y>0&&rnd()<.85&&grid[i-W]===E.EMPTY){swap(i,i-W);return;}
const nx=x+dx;
if(nx>=0&&nx<W&&y>0&&grid[i-W+dx]===E.EMPTY){swap(i,i-W+dx);return;}
if(nx>=0&&nx<W&&grid[i+dx]===E.EMPTY&&rnd()<.5)swap(i,i+dx);
}
function iceUpd(i,x,y){
for(let k=0;k<4;k++){
const nx=x+NX[k],ny=y+NY[k];if(nx<0||ny<0||nx>=W||ny>=H)continue;
const j=ny*W+nx,g=grid[j];
if(g===E.WATER&&rnd()<.04)grid[j]=E.ICE;
else if((g===E.FIRE||g===E.LAVA)&&rnd()<.2){grid[i]=E.WATER;return;}
}
}
function plantUpd(i,x,y){
for(let k=0;k<4;k++){
const nx=x+NX[k],ny=y+NY[k];if(nx<0||ny<0||nx>=W||ny>=H)continue;
const j=ny*W+nx;
if(grid[j]===E.WATER&&rnd()<.25){grid[j]=E.PLANT;stamp[j]=curStamp;}
}
}
function seedCheck(i,x,y){
for(let k=0;k<4;k++){
const nx=x+NX[k],ny=y+NY[k];if(nx<0||ny<0||nx>=W||ny>=H)continue;
if(grid[ny*W+nx]===E.WATER&&rnd()<.15){grid[i]=E.PLANT;return true;}
}
return false;
}
function virusUpd(i,x,y){
if(--life[i]<=0){
if(rnd()<.25){grid[i]=E.SMOKE;life[i]=50;}else grid[i]=E.EMPTY;return;
}
for(let k=0;k<4;k++){
const nx=x+NX[k],ny=y+NY[k];if(nx<0||ny<0||nx>=W||ny>=H)continue;
const j=ny*W+nx,g=grid[j];
if(g===E.EMPTY||g===E.WALL||g===E.GLASS||g===E.CLONE||g===E.VIRUS||
g===E.FIRE||g===E.SMOKE||g===E.STEAM||g===E.LAVA||g===E.ACID)continue;
if(rnd()<.12){grid[j]=E.VIRUS;life[j]=130+(rnd()*90|0);stamp[j]=curStamp;}
}
}
function cloneUpd(i,x,y){
if(aux[i]===0){
for(let k=0;k<4;k++){
const nx=x+NX[k],ny=y+NY[k];if(nx<0||ny<0||nx>=W||ny>=H)continue;
const g=grid[ny*W+nx];
if(g!==E.EMPTY&&g!==E.CLONE){aux[i]=g;break;}
}
}else if(rnd()<.5){
const st=(rnd()*4)|0;
for(let k=0;k<4;k++){
const kk=(st+k)%4,nx=x+NX[kk],ny=y+NY[kk];
if(nx<0||ny<0||nx>=W||ny>=H)continue;
const j=ny*W+nx;
if(grid[j]===E.EMPTY){spawnAt(j,aux[i]);stamp[j]=curStamp;break;}
}
}
}
function explode(x,y){
const r=6+((rnd()*3)|0);
stage.classList.remove('shake');void stage.offsetWidth;stage.classList.add('shake');
for(let dy=-r;dy<=r;dy++){
const yy=y+dy;if(yy<0||yy>=H)continue;
for(let dx=-r;dx<=r;dx++){
const xx=x+dx;if(xx<0||xx>=W)continue;
const d2=dx*dx+dy*dy;if(d2>r*r)continue;
const j=yy*W+xx,g=grid[j];
if(g===E.WALL||g===E.CLONE)continue;
if(g===E.TNT){life[j]=1;stamp[j]=curStamp;continue;}
if(d2<r*r*.45){grid[j]=E.FIRE;life[j]=25+(rnd()*40|0);}
else if(rnd()<.6){
if(rnd()<.5){grid[j]=E.FIRE;life[j]=20+(rnd()*35|0);}
else{grid[j]=E.SMOKE;life[j]=50+(rnd()*60|0);}
}else grid[j]=E.EMPTY;
stamp[j]=curStamp;
}
}
}
function upd(i,x,y){
const id=grid[i];
switch(id){
case E.EMPTY:case E.WALL:case E.GLASS:case E.WOOD:return;
case E.SAND:powder(i,x,y);break;
case E.SEED:if(!seedCheck(i,x,y))powder(i,x,y);break;
case E.TNT:if(life[i]===1){explode(x,y);return;}powder(i,x,y);break;
case E.WATER:liquid(i,x,y,4,3);break;
case E.OIL:liquid(i,x,y,3,2);break;
case E.ACID:acidUpd(i,x,y);break;
case E.LAVA:lavaUpd(i,x,y);break;
case E.FIRE:fireUpd(i,x,y);break;
case E.SMOKE:gasUpd(i,x,y,E.SMOKE);break;
case E.STEAM:gasUpd(i,x,y,E.STEAM);break;
case E.ICE:iceUpd(i,x,y);break;
case E.PLANT:plantUpd(i,x,y);break;
case E.VIRUS:virusUpd(i,x,y);break;
case E.CLONE:cloneUpd(i,x,y);break;
}
}
function step(){
const ltr=(f&1)===0;
for(let y=H-1;y>=0;y--){
const row=y*W;
if(ltr){for(let x=0;x<W;x++){const i=row+x;if(stamp[i]===curStamp)continue;upd(i,x,y);}}
else{for(let x=W-1;x>=0;x--){const i=row+x;if(stamp[i]===curStamp)continue;upd(i,x,y);}}
}
}
/* ================= 渲染 ================= */
function render(){
const d=img.data;let p=0;
for(let y=0;y<H;y++){
const br=bgRow[y*3],bg2=bgRow[y*3+1],bb=bgRow[y*3+2];
for(let x=0;x<W;x++,p+=4){
const i=y*W+x,id=grid[i];
let r,g,b;
if(id===0){
const n=(shade[i]&7)-3;r=br+n;g=bg2+n;b=bb+n;
}else if(id===E.FIRE){
const c=FIREC[life[i]>85?0:life[i]>40?1:2];r=c[0];g=c[1];b=c[2];
}else if(id===E.SMOKE||id===E.STEAM){
const c=id===E.SMOKE?SMKC:STMC,a=Math.min(1,life[i]/(id===E.SMOKE?150:110));
r=c[0]*a+br*(1-a);g=c[1]*a+bg2*(1-a);b=c[2]*a+bb*(1-a);
}else{
let sh=shade[i]&3;
if(id===E.WATER||id===E.ACID)sh=(shade[i]+(f>>2))&3;
const o=(id*4+sh)*3;r=CT[o];g=CT[o+1];b=CT[o+2];
if(id===E.CLONE&&aux[i]!==0){
const co=(aux[i]*4)*3;
r=(r+CT[co])>>1;g=(g+CT[co+1])>>1;b=(b+CT[co+2])>>1;
}
}
d[p]=r;d[p+1]=g;d[p+2]=b;d[p+3]=255;
}
}
ctx.putImageData(img,0,0);
}
/* ================= 绘制交互(多点触控) ================= */
const ptrs=new Map();
function toSim(e){
const r=canvas.getBoundingClientRect();
return[(e.clientX-r.left)/r.width*W,(e.clientY-r.top)/r.height*H];
}
function paintDot(cx,cy){
const r=brush,er=sel===ERASE;
for(let dy=-r;dy<=r;dy++){
for(let dx=-r;dx<=r;dx++){
if(dx*dx+dy*dy>r*r)continue;
const x=Math.round(cx+dx),y=Math.round(cy+dy);
if(x<0||y<0||x>=W||y>=H)continue;
const i=y*W+x;
if(er){grid[i]=E.EMPTY;continue;}
if(grid[i]!==E.EMPTY)continue;
if(rnd()<PLACE[sel])spawnAt(i,sel);
}
}
}
function paintLine(x0,y0,x1,y1){
const dx=x1-x0,dy=y1-y0,steps=Math.max(Math.abs(dx),Math.abs(dy),1);
for(let s2=0;s2<=steps;s2++)paintDot(x0+dx*s2/steps,y0+dy*s2/steps);
}
function moveRing(e){
const r=stage.getBoundingClientRect();
ring.style.left=(e.clientX-r.left)+'px';
ring.style.top=(e.clientY-r.top)+'px';
const d2=brush*2*(r.width/W);
ring.style.width=ring.style.height=d2+'px';
ring.style.opacity=1;
}
canvas.addEventListener('pointerdown',e=>{
e.preventDefault();canvas.setPointerCapture(e.pointerId);
hint.classList.add('off');hasDrawn=true;
const[x,y]=toSim(e);ptrs.set(e.pointerId,{x,y});
paintDot(x,y);moveRing(e);
});
canvas.addEventListener('pointermove',e=>{
moveRing(e);
const p=ptrs.get(e.pointerId);if(!p)return;
const[x,y]=toSim(e);paintLine(p.x,p.y,x,y);p.x=x;p.y=y;
});
const pEnd=e=>ptrs.delete(e.pointerId);
canvas.addEventListener('pointerup',pEnd);
canvas.addEventListener('pointercancel',pEnd);
canvas.addEventListener('pointerleave',()=>ring.style.opacity=0);
canvas.addEventListener('contextmenu',e=>e.preventDefault());
document.addEventListener('gesturestart',e=>e.preventDefault());
/* ================= 初始演示场景 ================= */
function put(x,y,id){
if(x<0||y<0||x>=W||y>=H)return;
const i=y*W+x;grid[i]=id;life[i]=spawnLife(id);
}
function clearAll(){grid.fill(0);life.fill(0);aux.fill(0);}
function seedDemo(){
clearAll();
const top=x=>H-6-Math.round(5*Math.sin(x*.045)+3*Math.sin(x*.011+1.7));
for(let x=0;x<W;x++){const t=top(x);for(let y=t;y<H;y++)put(x,y,E.SAND);}
const p0=W*.30|0,p1=W*.56|0,pf=H-3,ph=11;
for(let x=p0;x<=p1;x++)for(let y=pf-ph-1;y<H;y++)put(x,y,E.EMPTY);
for(let y=pf-ph-1;y<H;y++){put(p0,y,E.WALL);put(p1,y,E.WALL);}
for(let x=p0;x<=p1;x++){put(x,pf,E.WALL);put(x,Math.min(H-1,pf+1),E.WALL);}
for(let x=p0+1;x<p1;x++){
for(let y=pf-4;y<pf;y++)put(x,y,E.WATER);
if(x%3)put(x,pf-5,E.OIL);
}
const sy=H*.5|0,sx0=W*.64|0,sx1=W*.94|0;
for(let x=sx0;x<=sx1;x++){put(x,sy,E.WALL);put(x,sy+1,E.WALL);}
for(let y=sy+2;y<=sy+8;y++){put(sx0+1,y,E.WALL);put(sx1-1,y,E.WALL);}
for(let x=sx0+4;x<=sx0+7;x++)for(let y=sy-4;y<sy;y++)put(x,y,E.WOOD);
for(let x=sx1-6;x<=sx1-4;x++){put(x,sy-2,E.ICE);put(x,sy-1,E.ICE);}
const px=W*.86|0;
for(let k=0;k<4;k++)put(px+k,top(px+k)-1,E.PLANT);
put(px+1,top(px+1)-2,E.PLANT);
const cx=W*.43|0,cy=H*.2|0;
for(let dy=-6;dy<=6;dy++)for(let dx=-6;dx<=6;dx++)if(dx*dx+dy*dy<=36)put(cx+dx,cy+dy,E.SAND);
const wx=W*.8|0,wy=H*.28|0;
for(let dy=-3;dy<=3;dy++)for(let dx=-3;dx<=3;dx++)if(dx*dx+dy*dy<=9)put(wx+dx,wy+dy,E.WATER);
}
/* ================= UI 构建 ================= */
function buildDock(){
const dock=document.getElementById('dock');
ELEMENTS.forEach(en=>{
const[id,name,key,desc]=en;
const b=document.createElement('button');
b.className='chip';b.dataset.id=id;
b.innerHTML=`<i class="dot${id===ERASE?' era':''}"></i><span>${name}</span>${key?`<em>${key}</em>`:''}`;
b.addEventListener('click',()=>selectEl(id));
dock.appendChild(b);
});
}
function selectEl(id){
sel=id;
document.querySelectorAll('.chip').forEach(c=>c.classList.toggle('sel',+c.dataset.id===id));
const en=ELEMENTS.find(e=>e[0]===id);
if(en){elName.textContent=en[1];elDesc.textContent=en[3];}
}
function buildThemePanel(){
let html='<h3>色彩主题 THEMES</h3>';
for(const k in THEMES){
const t=THEMES[k];
html+=`<button class="tcard" data-t="${k}">
<span class="strip"><i style="background:${t.sky[1]}"></i><i style="background:${t.ui.accent}"></i><i style="background:${t.ui.accent2}"></i><i style="background:${t.pal.sand[0]}"></i></span>
<span class="tmeta"><b>${t.name}</b><small>${t.desc}</small></span></button>`;
}
tp.innerHTML=html;
tp.addEventListener('click',e=>{
const c=e.target.closest('.tcard');
if(c){applyTheme(c.dataset.t);toast('主题 · '+THEMES[c.dataset.t].name);}
});
}
/* ================= 控制 ================= */
const ICO_PAUSE='<svg viewBox="0 0 24 24"><path d="M7 5h4v14H7zM13 5h4v14h-4z" fill="currentColor"/></svg>';
const ICO_PLAY='<svg viewBox="0 0 24 24"><path d="M8 5l11 7-11 7z" fill="currentColor"/></svg>';
const btnPause=document.getElementById('btnPause');
function togglePause(){
running=!running;
btnPause.innerHTML=running?ICO_PAUSE:ICO_PLAY;
stage.classList.toggle('paused',!running);
toast(running?'继续模拟':'已暂停');
}
btnPause.innerHTML=ICO_PAUSE;
btnPause.addEventListener('click',togglePause);
document.getElementById('btnClear').addEventListener('click',()=>{clearAll();toast('世界已清空');});
document.getElementById('btnShot').addEventListener('click',()=>{
const s=Math.max(2,Math.round(600/W)),c=document.createElement('canvas');
c.width=W*s;c.height=H*s;
const g=c.getContext('2d');g.imageSmoothingEnabled=false;
g.drawImage(canvas,0,0,c.width,c.height);
const a=document.createElement('a');a.download='particle-world.png';a.href=c.toDataURL('image/png');a.click();
toast('已保存截图 ✦');
});
const btnTheme=document.getElementById('btnTheme');
btnTheme.addEventListener('click',e=>{e.stopPropagation();tp.classList.toggle('open');});
document.addEventListener('pointerdown',e=>{
if(!tp.contains(e.target))tp.classList.remove('open');
});
const brushR=document.getElementById('brushR'),brushV=document.getElementById('brushV');
function setBrush(v){
brush=Math.max(1,Math.min(14,v));
brushR.value=brush;brushV.textContent=brush;
}
brushR.addEventListener('input',()=>setBrush(+brushR.value));
const windR=document.getElementById('windR'),windV=document.getElementById('windV');
windR.addEventListener('input',()=>{wind=+windR.value;windV.textContent=wind;});
addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT')return;
const k=e.key;
if(k>='0'&&k<='9'){const it=ELEMENTS[k==='0'?9:(+k-1)];if(it)selectEl(it[0]);}
else if(k==='[')setBrush(brush-1);
else if(k===']')setBrush(brush+1);
else if(k===' '){e.preventDefault();togglePause();}
else if(k==='c'||k==='C'){clearAll();toast('世界已清空');}
else if(k==='t'||k==='T')tp.classList.toggle('open');
else if(k==='Escape')tp.classList.remove('open');
});
let tt;
function toast(msg){
toastEl.textContent=msg;toastEl.classList.add('show');
clearTimeout(tt);tt=setTimeout(()=>toastEl.classList.remove('show'),1500);
}
/* ================= 主循环 ================= */
function loop(){
requestAnimationFrame(loop);
frames++;
if(running){f++;curStamp=f&255;step();}
render();
}
setInterval(()=>{
fpsEl.textContent=frames*2;frames=0;
let c=0;for(let i=0;i<grid.length;i++)if(grid[i])c++;
cntEl.textContent=c.toLocaleString();
},500);
let rto;
new ResizeObserver(()=>{
clearTimeout(rto);
rto=setTimeout(()=>{
if(!W)return;
sizeSim();buildColors(THEMES[curTheme]);
if(!hasDrawn)seedDemo();else toast('窗口变化 · 世界已重置');
},250);
}).observe(stage);
function init(){
buildDock();buildThemePanel();
sizeSim();
let saved='dusk';try{saved=localStorage.getItem('pw-theme')||'dusk';}catch(e){}
applyTheme(THEMES[saved]?saved:'dusk');
selectEl(E.SAND);
seedDemo();
requestAnimationFrame(loop);
}
requestAnimationFrame(()=>requestAnimationFrame(init));
</script>
</body>
</html>
index.html
index.html