fix: 去掉拖动改用8方向按钮+滚轮缩放, 背景固定/阴影常驻/性能优化

This commit is contained in:
李鹏宇 2026-07-24 09:52:27 +08:00
parent a24bdbcff7
commit 9e01534de9

View File

@ -36,25 +36,25 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
font-size: 15px; font-weight: 600; color: #cfe1f3; -webkit-text-fill-color: #cfe1f3; font-size: 15px; font-weight: 600; color: #cfe1f3; -webkit-text-fill-color: #cfe1f3;
margin-left: 10px; padding-left: 10px; border-left: 1px solid rgba(255,255,255,.28); margin-left: 10px; padding-left: 10px; border-left: 1px solid rgba(255,255,255,.28);
} }
#topBar .topRight { margin-left:auto; display:flex; align-items:center; gap:14px; }
#topBar .coords { font-size: 14px; color: #a8b8cc; } #topBar .coords { font-size: 14px; color: #a8b8cc; }
/* ===== 顶部右侧用户区(融合进 #topBar恒在最右侧 ===== */ /* ===== 顶部右侧用户区(融合进 #topBar恒在最右侧 ===== */
#topBar .userArea{position:relative;margin-left:auto;pointer-events:auto;} #topBar .userArea{position:relative;pointer-events:auto;}
#topBar .ua-trigger{display:flex;align-items:center;gap:8px;background:rgba(139,92,246,.18); #topBar .ua-trigger{display:flex;align-items:center;gap:8px;background:rgba(139,92,246,.18);
border:1px solid rgba(139,92,246,.5);color:#e9edf6;padding:7px 14px;border-radius:999px; border:1px solid rgba(139,92,246,.5);color:#e9edf6;padding:7px 14px;border-radius:999px;
font:inherit;font-size:14px;font-weight:500;cursor:pointer;transition:.2s;} font:inherit;font-size:14px;font-weight:500;cursor:pointer;}
#topBar .ua-trigger:hover{background:rgba(139,92,246,.32);border-color:#a78bfa;}
#topBar .ua-avatar{width:22px;height:22px;border-radius:50%; #topBar .ua-avatar{width:22px;height:22px;border-radius:50%;
background:linear-gradient(135deg,#c77dff,#4af);display:inline-flex;align-items:center;justify-content:center; background:linear-gradient(135deg,#c77dff,#4af);display:inline-flex;align-items:center;justify-content:center;
font-size:11px;color:#fff;font-weight:700;} font-size:11px;color:#fff;font-weight:700;}
#topBar .ua-name{font-weight:600;color:#c77dff;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} #topBar .ua-name{font-weight:600;color:#c77dff;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
#topBar .ua-caret{font-size:10px;color:#9aa6c4;transition:transform .2s;} #topBar .ua-caret{font-size:10px;color:#9aa6c4;}
#topBar .ua-trigger[aria-expanded="true"] .ua-caret{transform:rotate(180deg);} #topBar .ua-trigger[aria-expanded="true"] .ua-caret{transform:rotate(180deg);}
#topBar .ua-menu{position:absolute;top:calc(100% + 8px);right:0;min-width:200px; #topBar .ua-menu{position:absolute;top:calc(100% + 8px);right:0;min-width:200px;
background:#0e1428;border:1px solid rgba(139,92,246,.45);border-radius:12px;padding:6px; background:#0e1428;border:1px solid rgba(139,92,246,.45);border-radius:12px;padding:6px;
box-shadow:0 10px 28px rgba(0,0,0,.55);display:none;z-index:120;} box-shadow:0 4px 12px rgba(0,0,0,.5);z-index:120;
#topBar .ua-menu.open{display:block;animation:uaSlide .18s ease-out;} visibility:hidden;opacity:0;pointer-events:none;}
@keyframes uaSlide{from{opacity:0;transform:translateY(-6px);}to{opacity:1;transform:translateY(0);}} #topBar .ua-menu.open{visibility:visible;opacity:1;pointer-events:auto;}
#topBar .ua-item{display:flex;align-items:center;justify-content:space-between;width:100%; #topBar .ua-item{display:flex;align-items:center;justify-content:space-between;width:100%;
background:transparent;border:none;color:#cfd5ea;padding:9px 12px;border-radius:8px; background:transparent;border:none;color:#cfd5ea;padding:9px 12px;border-radius:8px;
font:inherit;font-size:14px;cursor:pointer;text-align:left;transition:.15s;} font:inherit;font-size:14px;cursor:pointer;text-align:left;transition:.15s;}
@ -118,7 +118,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
} }
#microHeader .backBtn:hover { background: rgba(100,200,255,0.25); } #microHeader .backBtn:hover { background: rgba(100,200,255,0.25); }
#microHeader .microTitle { margin-left: 16px; font-size: 16px; font-weight: bold; color: #e0e0e0; } #microHeader .microTitle { margin-left: 16px; font-size: 16px; font-weight: bold; color: #e0e0e0; }
#microHeader .microSubtitle { margin-left: 12px; font-size: 12px; color: #8899aa; } #microHeader .microSubtitle { display: none; }
#microBody { flex: 1; display: flex; flex-direction: column; position: relative; overflow: hidden; } #microBody { flex: 1; display: flex; flex-direction: column; position: relative; overflow: hidden; }
#microCanvas { flex: 1; display: block; } #microCanvas { flex: 1; display: block; }
@ -346,7 +346,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
border:1px solid rgba(255,255,255,.22);cursor:default;user-select:none;transition:background-color .9s ease,border-color .2s; border:1px solid rgba(255,255,255,.22);cursor:default;user-select:none;transition:background-color .9s ease,border-color .2s;
box-shadow:0 6px 22px rgba(0,0,0,.6);} box-shadow:0 6px 22px rgba(0,0,0,.6);}
#clockWidget svg{display:block;} #clockWidget svg{display:block;}
#clockWidget #clockLabel{font-size:15px;font-weight:700;color:#ffffff;font-variant-numeric:tabular-nums;white-space:nowrap;text-shadow:0 1px 4px rgba(0,0,0,.95),0 0 2px rgba(0,0,0,.8);} #clockWidget #clockLabel{font-size:18px;font-weight:700;color:#ffffff;font-variant-numeric:tabular-nums;white-space:nowrap;text-shadow:0 1px 4px rgba(0,0,0,.95),0 0 2px rgba(0,0,0,.8);}
#clockWidget #calendarLabel{font-size:13px;font-weight:700;color:#ffffff;font-variant-numeric:tabular-nums;white-space:nowrap;letter-spacing:.4px;text-shadow:0 1px 4px rgba(0,0,0,.95),0 0 2px rgba(0,0,0,.8);} #clockWidget #calendarLabel{font-size:13px;font-weight:700;color:#ffffff;font-variant-numeric:tabular-nums;white-space:nowrap;letter-spacing:.4px;text-shadow:0 1px 4px rgba(0,0,0,.95),0 0 2px rgba(0,0,0,.8);}
#clockWidget:hover{border-color:rgba(139,92,246,.5);} #clockWidget:hover{border-color:rgba(139,92,246,.5);}
#sceneImage{position:fixed;inset:0;width:100%;height:100%;object-fit:cover;z-index:-1;} #sceneImage{position:fixed;inset:0;width:100%;height:100%;object-fit:cover;z-index:-1;}
@ -374,6 +374,29 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
transition:opacity .2s ease,transform .2s ease;} transition:opacity .2s ease,transform .2s ease;}
#worldNav .wn-item:hover .wn-text{opacity:1;transform:translateY(-50%) translateX(0);} #worldNav .wn-item:hover .wn-text{opacity:1;transform:translateY(-50%) translateX(0);}
/* 右下角导航控件:方向 + 缩放(白色半透、大气精致) */
#navControls{position:fixed;bottom:120px;right:22px;z-index:50;
display:grid;grid-template-columns:repeat(3,68px);grid-template-rows:repeat(3,68px);gap:7px;}
#navControls button{width:68px;height:68px;border:none;border-radius:17px;
background:rgba(255,255,255,0.18);color:#fff;font-size:28px;cursor:pointer;
display:flex;align-items:center;justify-content:center;
backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
border:1px solid rgba(255,255,255,0.3);
box-shadow:0 4px 16px rgba(0,0,0,0.25),inset 0 1px 0 rgba(255,255,255,0.2);
transition:background .15s,transform .12s;user-select:none;}
#navControls button:hover{background:rgba(255,255,255,0.32);transform:scale(1.08);}
#navControls button:active{background:rgba(255,255,255,0.44);transform:scale(0.94);}
#navControls .nc-up{grid-column:2;grid-row:1;}
#navControls .nc-nw{grid-column:1;grid-row:1;}
#navControls .nc-ne{grid-column:3;grid-row:1;}
#navControls .nc-left{grid-column:1;grid-row:2;}
#navControls .nc-center{grid-column:2;grid-row:2;background:rgba(255,255,255,0.06)!important;
cursor:default;border-color:transparent;box-shadow:none;}
#navControls .nc-right{grid-column:3;grid-row:2;}
#navControls .nc-sw{grid-column:1;grid-row:3;}
#navControls .nc-down{grid-column:2;grid-row:3;}
#navControls .nc-se{grid-column:3;grid-row:3;}
/* ===== 登录闸门 ===== */ /* ===== 登录闸门 ===== */
#authSplash{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center; #authSplash{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;
background:linear-gradient(160deg,#080c1a,#0e1730 48%,#15213f);color:#9aa6c4; background:linear-gradient(160deg,#080c1a,#0e1730 48%,#15213f);color:#9aa6c4;
@ -400,6 +423,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<div id="topBar"> <div id="topBar">
<span class="title"><img src="assets/ui/logo_planet_48.png" alt=""><span class="brand">宇森</span><span class="worldName" id="worldName">大地</span></span> <span class="title"><img src="assets/ui/logo_planet_48.png" alt=""><span class="brand">宇森</span><span class="worldName" id="worldName">大地</span></span>
<button id="buildToggle">🔨 建造</button> <button id="buildToggle">🔨 建造</button>
<div class="topRight">
<span class="coords" id="coords">坐标: (0, 0)</span> <span class="coords" id="coords">坐标: (0, 0)</span>
<div class="userArea" id="userArea" hidden> <div class="userArea" id="userArea" hidden>
<button class="ua-trigger" id="uaTrigger" type="button" aria-haspopup="true" aria-expanded="false"> <button class="ua-trigger" id="uaTrigger" type="button" aria-haspopup="true" aria-expanded="false">
@ -414,6 +438,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<button class="ua-item ua-item--danger" id="uaLogout" type="button" role="menuitem">退出登录</button> <button class="ua-item ua-item--danger" id="uaLogout" type="button" role="menuitem">退出登录</button>
</div> </div>
</div> </div>
</div>
</div> </div>
<!-- 主沙盘底部功能框架(预留区,放功能按钮 / 素材槽) --> <!-- 主沙盘底部功能框架(预留区,放功能按钮 / 素材槽) -->
@ -432,23 +457,23 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<!-- 时间线右上角闹钟4 阶段彩色外环 + 走动指针 + 阶段名标签) --> <!-- 时间线右上角闹钟4 阶段彩色外环 + 走动指针 + 阶段名标签) -->
<div id="clockWidget" title="当前时间"> <div id="clockWidget" title="当前时间">
<svg id="clockSvg" viewBox="0 0 100 100" width="132" height="132"> <svg id="clockSvg" viewBox="0 0 100 100" width="132" height="132">
<!-- 底盘:深色实心圆 + 内描边 --> <!-- 4 段扇形色块铺满整圆JS 初始化时填 dfill 扇形从圆心到外弧) -->
<circle cx="50" cy="50" r="44" fill="rgba(14,20,36,.92)" stroke="rgba(255,255,255,.32)" stroke-width="1.5"/> <g id="clockRing" style="filter:drop-shadow(0 0 4px rgba(0,0,0,.45));">
<!-- 4 段时间彩色外环JS 初始化时填 d --> <path id="segDawn" fill="#FFB300" data-stage="0"/>
<g id="clockRing" stroke-width="18" fill="none" stroke-linecap="butt" style="filter:drop-shadow(0 0 3px rgba(0,0,0,.55));"> <path id="segDay" fill="#00E5FF" data-stage="1"/>
<path id="segDawn" stroke="rgba(255,158,94,.92)" data-stage="0"/> <path id="segDusk" fill="#FF3D00" data-stage="2"/>
<path id="segDay" stroke="rgba(90,176,255,.92)" data-stage="1"/> <path id="segNight" fill="#3D3D4A" data-stage="3"/>
<path id="segDusk" stroke="rgba(255,122,77,.92)" data-stage="2"/>
<path id="segNight" stroke="rgba(95,125,210,.85)" data-stage="3"/>
</g> </g>
<!-- 表盘边框 -->
<circle cx="50" cy="50" r="44" fill="none" stroke="rgba(255,255,255,.35)" stroke-width="1.5"/>
<!-- 24h 刻度 --> <!-- 24h 刻度 -->
<g id="clockTicks"></g> <g id="clockTicks"></g>
<!-- 4 阶段名5/12/18/21 位置) --> <!-- 4 阶段名5/12/18/21 位置) -->
<g id="stageLabels" font-size="8" font-weight="700" text-anchor="middle" dominant-baseline="central" style="pointer-events:none;display:none;"> <g id="stageLabels" font-size="8" font-weight="700" text-anchor="middle" dominant-baseline="central" style="pointer-events:none;display:none;">
<text x="50" y="50" data-stage="0" fill="#ffd9a0">清晨</text> <text x="50" y="50" data-stage="0" fill="#FFB300">清晨</text>
<text x="50" y="50" data-stage="1" fill="#bfe0ff">正午</text> <text x="50" y="50" data-stage="1" fill="#00E5FF">正午</text>
<text x="50" y="50" data-stage="2" fill="#ffc59a">黄昏</text> <text x="50" y="50" data-stage="2" fill="#FF3D00">黄昏</text>
<text x="50" y="50" data-stage="3" fill="#a8b6e8">黑夜</text> <text x="50" y="50" data-stage="3" fill="#8a8a98">黑夜</text>
</g> </g>
<!-- 时针(粗,米白带阴影) --> <!-- 时针(粗,米白带阴影) -->
<line id="hourHand" x1="50" y1="50" x2="50" y2="24" stroke="#fff5d9" stroke-width="4.6" stroke-linecap="round" style="filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.85));"/> <line id="hourHand" x1="50" y1="50" x2="50" y2="24" stroke="#fff5d9" stroke-width="4.6" stroke-linecap="round" style="filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.85));"/>
@ -513,7 +538,6 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<button class="mv-tool" id="microGridBtn" title="显示矩形网格(仅浮岛内)">🔲 网格</button> <button class="mv-tool" id="microGridBtn" title="显示矩形网格(仅浮岛内)">🔲 网格</button>
<button class="mv-tool mv-tool--reserved" id="microBuildBtn" title="建造功能(预留,待接入)">🔨 建造 <span class="mv-soon">soon</span></button> <button class="mv-tool mv-tool--reserved" id="microBuildBtn" title="建造功能(预留,待接入)">🔨 建造 <span class="mv-soon">soon</span></button>
</div> </div>
<div class="mv-top-hint">微观世界 · 工具栏</div>
</div> </div>
<div id="microStage"> <div id="microStage">
<canvas id="microCanvas"></canvas> <canvas id="microCanvas"></canvas>
@ -770,10 +794,10 @@ const TIME_KEYFRAMES = [
// 四个时间节点的代表色(与 TIME_KEYFRAMES 顺序一致:清晨/正午/黄昏/黑夜) // 四个时间节点的代表色(与 TIME_KEYFRAMES 顺序一致:清晨/正午/黄昏/黑夜)
// 用于时钟背景与表盘外环着色,让玩家一眼看出当前处于哪个时间节点 // 用于时钟背景与表盘外环着色,让玩家一眼看出当前处于哪个时间节点
const NODE_RGB = [ const NODE_RGB = [
[255, 158, 94], // 清晨:暖橙 [255, 179, 0], // 清晨:琥珀金 #FFB300
[90, 176, 255], // 正午:天蓝 [0, 229, 255], // 正午:亮青 #00E5FF
[255, 122, 77], // 黄昏:红橙 [255, 61, 0], // 黄昏:烈焰红 #FF3D00
[58, 79, 138], // 黑夜:深蓝 [61, 61, 74], // 黑夜:深灰 #3D3D4A
]; ];
// ============ 游戏日历(纪 / 年 / 季节) ============ // ============ 游戏日历(纪 / 年 / 季节) ============
@ -1141,7 +1165,7 @@ const BOTTOM_BAR_H = 72; // 主沙盘底部功能栏高度(与 #bottomBar CS
const canvas = document.getElementById('gameCanvas'); const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
let W, H; let W, H;
let camera = { x: 0, y: 0, zoom: 1.6 }; let camera = { x: 0, y: 0, zoom: 1.17 }; // 主世界默认缩放;原 1.6 偏近按三档滚轮缩小×0.9³≈0.729)降到 1.17,进入时沙盘更开阔
let hoveredTile = null; let hoveredTile = null;
let selectedTile = null; let selectedTile = null;
@ -1157,6 +1181,11 @@ let _camIntT = null; // 缩放交互的 debounce 定时器
let _cachedSorted = null; // 按 currentLayer 缓存的绘制顺序地块(交互期复用,静止帧每帧重建) let _cachedSorted = null; // 按 currentLayer 缓存的绘制顺序地块(交互期复用,静止帧每帧重建)
let _cachedSortedLayer = null; let _cachedSortedLayer = null;
let shadowFade = 1; // 阴影投影强度淡入淡出交互中→0松手/停滚→1渐入避免明暗硬跳变 let shadowFade = 1; // 阴影投影强度淡入淡出交互中→0松手/停滚→1渐入避免明暗硬跳变
let worldCache = null, worldCacheCtx = null; // 交互期离屏世界快照:静止帧把世界烘焙进此画布,拖动/缩放时仅 blitO(1)),纹理不丢、不掉帧
let worldCacheValid = false;
const _cachedCam = { x: 0, y: 0, zoom: 1 }; // 快照对应的相机状态,交互期按相机增量平移/缩放 blit
let releaseBlend = 0; // 松手过渡淡出0=不混合,>0 时在静止帧上方叠快照globalAlpha 衰减),平滑 blit→全量的亚像素跳
const MIN_ZOOM = 0.7, MAX_ZOOM = 7; // 缩放下限抬高(别太小)+ 上限 +3 单位(原 0.4 / 4
let tiles = []; let tiles = [];
@ -1404,31 +1433,8 @@ function drawHexPrism(cx, cyTop, topR, wallH, topColor, hover, tile) {
hover ? 'rgba(120,210,255,0.55)' : null, hover ? 1.5 : 0); hover ? 'rgba(120,210,255,0.55)' : null, hover ? 1.5 : 0);
return; return;
} }
// 交互期轻量路径:跳过 paintWallTexture(6面贴图)/paintHexTexture(顶面)/getTileFast(邻居)/径向渐变, // 注:交互期不再走"纯色兜底"分支——改由 render() 把世界烘焙进离屏快照,拖动/缩放只 blit 快照,
// 用纯色兜底最大化每帧吞吐camInteracting 由松手 200ms 后自动置 false下帧恢复完整绘制 // 纹理全程可见且不掉帧(见 render() 的交互快速路径)。完整绘制始终走下方路径。
if (camInteracting && tile) {
const top = prismTopPoints(cx, cyTop, topR);
ctx.save();
// 6 面合并为一个 path纯色 fill
ctx.fillStyle = shadeColor(base, Math.max(-20, dim * 0.7 - 6));
ctx.beginPath();
ctx.moveTo(top[0].x, top[0].y);
for (let i = 0; i < SIDES; i++) {
const a = top[i], b = top[(i + 1) % SIDES];
ctx.lineTo(b.x, b.y);
ctx.lineTo(b.x, b.y + wallH);
}
ctx.closePath();
ctx.fill();
// 顶面覆盖(单色,比 createRadialGradient 轻得多)
ctx.fillStyle = shadeColor(base, Math.max(0, dim * 0.5) + 3);
ctx.beginPath();
for (let i = 0; i < SIDES; i++) ctx.lineTo(top[i].x, top[i].y);
ctx.closePath();
ctx.fill();
ctx.restore();
return;
}
const sun = currentPalette.sun; const sun = currentPalette.sun;
const top = prismTopPoints(cx, cyTop, topR); const top = prismTopPoints(cx, cyTop, topR);
// 高度阴影:整块投下柔和投影,强化起伏层次(夜/低光下投影略重,但不超 0.30 // 高度阴影:整块投下柔和投影,强化起伏层次(夜/低光下投影略重,但不超 0.30
@ -1504,22 +1510,58 @@ function drawHexPrism(cx, cyTop, topR, wallH, topColor, hover, tile) {
} }
// ============ 渲染 ============ // ============ 渲染 ============
// 把当前主画布(已全细节渲染的世界)烘焙进离屏快照,供交互期 blitO(1) 单图绘制,纹理不丢、不掉帧)
function snapshotWorld() {
if (!worldCache) { worldCache = document.createElement('canvas'); worldCacheCtx = worldCache.getContext('2d'); }
if (worldCache.width !== W || worldCache.height !== H) { worldCache.width = W; worldCache.height = H; }
worldCacheCtx.setTransform(1, 0, 0, 1, 0, 0);
worldCacheCtx.clearRect(0, 0, W, H);
worldCacheCtx.drawImage(canvas, 0, 0);
_cachedCam.x = camera.x; _cachedCam.y = camera.y; _cachedCam.zoom = camera.zoom;
}
function render() { function render() {
if (microViewOpen) { requestAnimationFrame(render); return; } // 微观视图打开时主世界暂停重绘,把 CPU 让给微观渲染循环 if (microViewOpen) { requestAnimationFrame(render); return; } // 微观视图打开时主世界暂停重绘,把 CPU 让给微观渲染循环
const interacting = camInteracting; // 拖动/缩放进行中:关阴影+复用地块缓存,松手后自动恢复 const interacting = camInteracting; // 拖动/缩放进行中:复用世界快照,松手后自动恢复
// 阴影投影强度平滑过渡:交互中朝 0 收敛(关),松手/停滚后朝 1 渐入(~12 帧到位),消除松开瞬间的明暗硬跳变 // 阴影投影常驻 1交互期走快照 blit不再每帧重绘带模糊卷积固定 1松手帧与拖动帧像素级一致。
const targetShadow = interacting ? 0 : 1; const targetShadow = 1;
shadowFade += (targetShadow - shadowFade) * 0.14; shadowFade += (targetShadow - shadowFade) * 0.14;
if (shadowFade < 0.003) shadowFade = 0; if (shadowFade < 0.003) shadowFade = 0;
// ---- 交互快速路径:只 blit 快照,跳过时间/UI/调色板/全量重绘 ----
// 交互期 timeOfDay 已冻结syncTimeUI/refreshWorldVideo/getSky 的结果不变,跑了纯空转;
// 鼠标快速移动时每帧只做一次 drawImage → 跟手不卡。
if (interacting) {
if (!worldCacheValid) { snapshotWorld(); worldCacheValid = true; }
const sc = camera.zoom / (_cachedCam.zoom || 1);
const tx = (W / 2 + camera.x) - sc * (W / 2 + _cachedCam.x);
const ty = (H / 2 + camera.y) - sc * (H / 2 + _cachedCam.y);
// 拖动期保持画布透明(与静止帧完全一致),底层 CSS 场景图无缝透出、固定不跳;
// 非场景图层无 CSS 底图,补调色板渐变兜底(用上一次 currentPalette时间冻结所以不变
if (useImageBg) {
ctx.clearRect(0, 0, W, H);
} else {
const _pal = currentPalette;
const _g = ctx.createLinearGradient(0, 0, 0, H);
_g.addColorStop(0, _pal.top); _g.addColorStop(0.55, _pal.mid); _g.addColorStop(1, _pal.bot);
ctx.fillStyle = _g; ctx.fillRect(0, 0, W, H);
}
ctx.drawImage(worldCache, 0, 0, W, H, tx, ty, W * sc, H * sc);
requestAnimationFrame(render);
return;
}
// ---- 静止帧:时间推进 + UI 刷新 + 调色板 + 全量重绘 ----
animTime += 0.016; animTime += 0.016;
if (timePlaying) { if (timePlaying) {
const prevT = timeOfDay; const prevT = timeOfDay;
timeOfDay = nowHours(); // 每帧同步系统真实时间:时钟=本地真实时刻,与墙钟 100% 一致 timeOfDay = nowHours(); // 静止帧同步系统真实时间
if (timeOfDay < prevT) gameDay++; // 跨过午夜游戏天数 +1驱动日历/季节 if (timeOfDay < prevT) gameDay++; // 跨过午夜游戏天数 +1驱动日历/季节
} }
syncTimeUI(); // 每帧刷新时钟(即便暂停也刷);秒针由真实时间驱动持续扫动,体现时间在走 syncTimeUI(); // 每帧刷新时钟(即便暂停也刷);秒针由真实时间驱动持续扫动
refreshWorldVideo(); // 时间节点变化则切换背景视频(其余帧直接跳过) refreshWorldVideo(); // 时间节点变化则切换背景视频(其余帧直接跳过)
currentPalette = getSky(currentLayer, timeOfDay); currentPalette = getSky(currentLayer, timeOfDay);
worldCacheValid = false; // 静止帧:下次交互重新烘焙快照(世界可能已变化)
ctx.clearRect(0, 0, W, H); ctx.clearRect(0, 0, W, H);
drawBackground(currentLayer); drawBackground(currentLayer);
// 时间色调叠加:仅大地/天空随昼夜变色调(星空/地下/地心不染时间色),不切换视频本身 // 时间色调叠加:仅大地/天空随昼夜变色调(星空/地下/地心不染时间色),不切换视频本身
@ -1616,6 +1658,20 @@ function render() {
} }
} }
// 松手过渡淡出:在上方叠放拖动的快照(同 camera 变换 + 衰减 alpha平滑 blit→全量 的亚像素/插值差异,
// 2-3 帧消隐过渡无跳。仅场景图层useImageBg=透明画布)做过渡,非场景图层直接切。
if (releaseBlend > 0.003) {
ctx.save();
ctx.globalAlpha = Math.min(releaseBlend, 1);
const sc = camera.zoom / (_cachedCam.zoom || 1);
const tx = (W / 2 + camera.x) - sc * (W / 2 + _cachedCam.x);
const ty = (H / 2 + camera.y) - sc * (H / 2 + _cachedCam.y);
ctx.drawImage(worldCache, 0, 0, W, H, tx, ty, W * sc, H * sc);
ctx.restore();
releaseBlend *= 0.4; // 快速衰减1→0.4→0.16→0.06→0.034 帧 ~67ms基本不可感知
if (releaseBlend < 0.004) releaseBlend = 0;
}
requestAnimationFrame(render); requestAnimationFrame(render);
} }
@ -1873,7 +1929,7 @@ function startMicroRender() {
microSwipe = null; // 回弹到位,清除预览 microSwipe = null; // 回弹到位,清除预览
} }
} }
if (microSwipe) microCtx.translate(microSwipe.x - microSwipe.x0, microSwipe.y - microSwipe.y0); if (microSwipe) microCtx.translate(Math.round(microSwipe.x - microSwipe.x0), Math.round(microSwipe.y - microSwipe.y0));
} }
// 背景 + 地形场为静态缓存;逐帧只贴图(+ 滑动切换过渡,带方向性平移) // 背景 + 地形场为静态缓存;逐帧只贴图(+ 滑动切换过渡,带方向性平移)
@ -1885,8 +1941,8 @@ function startMicroRender() {
// 旧图:从松手预览偏移继续朝滑动方向滑出(消除"弹回原位再滑"的跳变) // 旧图:从松手预览偏移继续朝滑动方向滑出(消除"弹回原位再滑"的跳变)
if (tr.from) { if (tr.from) {
microCtx.save(); microCtx.save();
microCtx.globalAlpha = 1 - e * 0.35; microCtx.globalAlpha = 1 - e; // 旧图平滑淡出到 0过渡收尾与下一帧纯新图无缝衔接消除两侧阴影闪烁
microCtx.translate(tr.sx * (1 - e) + tr.ux * e * D, tr.sy * (1 - e) + tr.uy * e * D); microCtx.translate(Math.round(tr.sx * (1 - e) + tr.ux * e * D), Math.round(tr.sy * (1 - e) + tr.uy * e * D));
microCtx.drawImage(tr.from, 0, 0); microCtx.drawImage(tr.from, 0, 0);
microCtx.restore(); microCtx.restore();
} }
@ -1894,7 +1950,7 @@ function startMicroRender() {
if (microGridCache) { if (microGridCache) {
microCtx.save(); microCtx.save();
microCtx.globalAlpha = Math.min(1, e * 1.6); microCtx.globalAlpha = Math.min(1, e * 1.6);
microCtx.translate(-tr.sx * (1 - e) + tr.ux * (e - 1) * D, -tr.sy * (1 - e) + tr.uy * (e - 1) * D); microCtx.translate(Math.round(-tr.sx * (1 - e) + tr.ux * (e - 1) * D), Math.round(-tr.sy * (1 - e) + tr.uy * (e - 1) * D));
microCtx.drawImage(microGridCache, 0, 0); microCtx.drawImage(microGridCache, 0, 0);
microCtx.restore(); microCtx.restore();
} }
@ -2050,10 +2106,20 @@ function onMicroMove(e) {
} }
if (!microSwipe || !microSwipe.active) return; if (!microSwipe || !microSwipe.active) return;
const p = getMicroPoint(e); const p = getMicroPoint(e);
// 边界约束:跟手最大 ±320px足以判断切地块方向防止画面被拖到浮岛外 // 跟手最大 ±320px足以判断切地块方向防止画面被拖太远
const SWIPE_MAX = 320; const SWIPE_MAX = 320;
microSwipe.x = Math.max(microSwipe.x0 - SWIPE_MAX, Math.min(microSwipe.x0 + SWIPE_MAX, p.x)); let ox = Math.max(-SWIPE_MAX, Math.min(SWIPE_MAX, p.x - microSwipe.x0));
microSwipe.y = Math.max(microSwipe.y0 - SWIPE_MAX, Math.min(microSwipe.y0 + SWIPE_MAX, p.y)); let oy = Math.max(-SWIPE_MAX, Math.min(SWIPE_MAX, p.y - microSwipe.y0));
// 边缘即墙:当前拖动方向没有相邻地块 → 橡皮筋阻尼,岛不真正移动(不循环/不越界)
const t = microState.tile;
const [dq, dr] = MICRO_DIRS[microHexDirOfVector(ox, oy)];
if (!getTile(t.q + dq, t.r + dr)) {
const RUBBER = 0.18, RUBBER_MAX = 46;
ox = Math.max(-RUBBER_MAX, Math.min(RUBBER_MAX, ox * RUBBER));
oy = Math.max(-RUBBER_MAX, Math.min(RUBBER_MAX, oy * RUBBER));
}
microSwipe.x = microSwipe.x0 + ox;
microSwipe.y = microSwipe.y0 + oy;
} }
function onMicroUp(e) { function onMicroUp(e) {
if (!microState) return; if (!microState) return;
@ -2067,32 +2133,43 @@ function onMicroUp(e) {
// 左键:判断是切地块还是点击 // 左键:判断是切地块还是点击
if (!microSwipe) return; if (!microSwipe) return;
const dx = microSwipe.x - microSwipe.x0, dy = microSwipe.y - microSwipe.y0; const dx = microSwipe.x - microSwipe.x0, dy = microSwipe.y - microSwipe.y0;
const active = microSwipe.active; microSwipe.active = false; microSwipe.active = false;
microCanvas.style.cursor = ''; microCanvas.style.cursor = '';
if (!active) return;
if (Math.hypot(dx, dy) < MICRO_SWIPE_MIN) { if (Math.hypot(dx, dy) < MICRO_SWIPE_MIN) {
// 未达阈值:保留 microSwipeactive=false,由 loop 弹性回弹,避免生硬瞬回 // 未达阈值:保留 microSwipe由 loop 弹性回弹,避免生硬瞬回
return; return;
} }
microSwipe = null; // 超阈值走 transition起点偏移已存入 tr.sx/sy预览可清 // 超阈值尝试切相邻地块若边缘即墙无相邻地块navigateMicro 返回 false
navigateMicro(dx, dy); // 仍保留 microSwipe 由 loop 弹性回弹——避免"瞬回原位"造成的闪烁/循环感
const moved = navigateMicro(dx, dy);
if (moved) microSwipe = null; // 已切换,预览可清(起点偏移已存入 tr.sx/sy
} }
// 选滑动方向夹角最接近的相邻六边形地块(空间一致,像在沙盘里平移显微镜) // 六边形六个相邻方向的轴向偏移axial与 hexToPixel 的线性映射一致
const MICRO_DIRS = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]];
// 由拖动向量求"最匹配"的六边形方向角标(只看方向,去掉缩放/居中偏移)
function microHexDirOfVector(dx, dy) {
const dirs = MICRO_DIRS.map(([dq, dr]) => ({
dq, dr,
vx: Math.sqrt(3) * dq + Math.sqrt(3) / 2 * dr, // 与 hexToPixel 线性部分一致
vy: 3 / 2 * dr
}));
const sl = Math.hypot(dx, dy) || 1;
let bestI = 0, bestDot = -Infinity;
for (let i = 0; i < dirs.length; i++) {
const d = dirs[i], vl = Math.hypot(d.vx, d.vy) || 1;
const dot = (dx * d.vx + dy * d.vy) / sl / vl; // 余弦相似度
if (dot > bestDot) { bestDot = dot; bestI = i; }
}
return bestI;
}
// 朝某方向拖动 → 若该方向有相邻地块则切入它(像平移显微镜);否则不切换(边缘即墙)
function navigateMicro(dx, dy) { function navigateMicro(dx, dy) {
const t = microState.tile; const t = microState.tile;
const o = hexToPixel(t.q, t.r); const di = microHexDirOfVector(dx, dy);
const DIRS = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]]; const [dq, dr] = MICRO_DIRS[di];
let best = null, bestDot = -Infinity;
const sl = Math.hypot(dx, dy) || 1;
for (const [dq, dr] of DIRS) {
const nb = getTile(t.q + dq, t.r + dr); const nb = getTile(t.q + dq, t.r + dr);
if (!nb) continue; if (nb) { transitionMicroTo(nb, dx, dy); return true; }
const p = hexToPixel(t.q + dq, t.r + dr); return false; // 边缘即墙:不切换,由 onMicroUp 保留 microSwipe 让 loop 弹性回弹
const vx = p.x - o.x, vy = p.y - o.y, vl = Math.hypot(vx, vy) || 1;
const dot = (dx * vx + dy * vy) / sl / vl; // 余弦相似度
if (dot > bestDot) { bestDot = dot; best = nb; }
}
if (best) transitionMicroTo(best, dx, dy);
} }
// 切换当前微观地块(更新状态 + 侧栏 + 标题) // 切换当前微观地块(更新状态 + 侧栏 + 标题)
function setMicroTile(tile) { function setMicroTile(tile) {
@ -2545,22 +2622,15 @@ function shadeColor(color, percent) {
// ============ 事件(修复版)============ // ============ 事件(修复版)============
canvas.addEventListener('wheel', e => { canvas.addEventListener('wheel', e => {
// 建造 · 捏高度工具:滚轮微调悬停格,不触发相机缩放 // 建造 · 捏高度工具:滚轮微调悬停格
if (buildMode && buildTool === 'height' && currentLayer === 'earth' && hoveredTile) { if (buildMode && buildTool === 'height' && currentLayer === 'earth' && hoveredTile) {
e.preventDefault(); e.preventDefault();
applyHeight(hoveredTile, e.deltaY < 0 ? 1 : -1, true); // 滚轮离散事件跳过笔触去重守卫可连续升降到 0~12 全程 applyHeight(hoveredTile, e.deltaY < 0 ? 1 : -1, true);
return; return;
} }
e.preventDefault(); e.preventDefault();
const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1; const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1;
camera.zoom = Math.max(0.4, Math.min(4, camera.zoom * zoomDelta)); camera.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, camera.zoom * zoomDelta));
// 缩放交互标志:连续滚轮期间保持真(关阴影+缓存复用),最后一次滚动 200ms 后复位
camInteracting = true;
clearTimeout(_camIntT);
_camIntT = setTimeout(() => { camInteracting = false; }, 200);
scheduleSettingsSave(); // 相机缩放后防抖存后端
// 更新光标状态
canvas.classList.add('canDrag');
}, { passive: false }); }, { passive: false });
canvas.addEventListener('contextmenu', e => e.preventDefault()); canvas.addEventListener('contextmenu', e => e.preventDefault());
@ -2576,16 +2646,10 @@ canvas.addEventListener('mousedown', e => {
const hex = pixelToHex(e.clientX - rect.left, e.clientY - rect.top); const hex = pixelToHex(e.clientX - rect.left, e.clientY - rect.top);
const t = getTile(hex.q, hex.r); const t = getTile(hex.q, hex.r);
if (t) applyHeight(t, heightBrush); if (t) applyHeight(t, heightBrush);
canvas.classList.add('dragging');
return; return;
} }
if (e.button !== 0) return; if (e.button !== 0) return;
isMouseDown = true; isMouseDown = true;
hasDragged = false;
mouseDownX = e.clientX;
mouseDownY = e.clientY;
dragCamStartX = camera.x;
dragCamStartY = camera.y;
// 建造 · 移动工具:按住即进入"搬运交换",不平移相机 // 建造 · 移动工具:按住即进入"搬运交换",不平移相机
if (buildMode && buildTool === 'move' && currentLayer === 'earth') { if (buildMode && buildTool === 'move' && currentLayer === 'earth') {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
@ -2593,7 +2657,6 @@ canvas.addEventListener('mousedown', e => {
dragSwapFrom = getTile(hex.q, hex.r); dragSwapFrom = getTile(hex.q, hex.r);
draggingSwap = !!dragSwapFrom; draggingSwap = !!dragSwapFrom;
} }
canvas.classList.add('dragging');
}); });
canvas.addEventListener('mousemove', e => { canvas.addEventListener('mousemove', e => {
@ -2610,23 +2673,11 @@ canvas.addEventListener('mousemove', e => {
if (editingHeight) { if (editingHeight) {
const t = getTile(hex.q, hex.r); const t = getTile(hex.q, hex.r);
if (t) applyHeight(t, heightBrush); if (t) applyHeight(t, heightBrush);
return; // 捏高度笔触中:只刷高度,不平移相机 return; // 捏高度笔触中:只刷高度
} }
if (draggingSwap) { if (draggingSwap) {
const rect = canvas.getBoundingClientRect(); hoveredTile = getTile(hex.q, hex.r);
const hx = pixelToHex(e.clientX - rect.left, e.clientY - rect.top); return; // 搬运中:只更新目标高亮
hoveredTile = getTile(hx.q, hx.r);
return; // 搬运中:只更新目标高亮,不平移相机
}
const dx = e.clientX - mouseDownX;
const dy = e.clientY - mouseDownY;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
hasDragged = true;
camInteracting = true; // 平移拖动开始:触发关阴影+缓存复用,提升拖动流畅度
}
if (hasDragged) {
camera.x = dragCamStartX + (e.clientX - mouseDownX);
camera.y = dragCamStartY + (e.clientY - mouseDownY);
} }
} else { } else {
// 只有没按下鼠标时才检测悬停 // 只有没按下鼠标时才检测悬停
@ -2644,9 +2695,6 @@ canvas.addEventListener('mousemove', e => {
canvas.addEventListener('mouseup', e => { canvas.addEventListener('mouseup', e => {
if (!isMouseDown) return; if (!isMouseDown) return;
isMouseDown = false; isMouseDown = false;
camInteracting = false; // 松手:恢复阴影投影与每帧重建(静止帧细节回到位)
if (hasDragged) scheduleSettingsSave(); // 平移过则防抖存相机位
canvas.classList.remove('dragging');
// 建造模式 · 移动工具:拖拽/点击交换板块 // 建造模式 · 移动工具:拖拽/点击交换板块
if (draggingSwap) { if (draggingSwap) {
@ -2907,7 +2955,7 @@ function applyWorldSettings(s) {
const c = s.camera; const c = s.camera;
if (typeof c.x === 'number') camera.x = c.x; if (typeof c.x === 'number') camera.x = c.x;
if (typeof c.y === 'number') camera.y = c.y; if (typeof c.y === 'number') camera.y = c.y;
if (typeof c.zoom === 'number') camera.zoom = c.zoom; if (typeof c.zoom === 'number') camera.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, c.zoom));
} }
function scheduleSettingsSave() { function scheduleSettingsSave() {
if (settingsTimer) clearTimeout(settingsTimer); if (settingsTimer) clearTimeout(settingsTimer);
@ -3030,7 +3078,7 @@ function syncTimeUI() {
const ni = currentNodeIndex(timeOfDay); const ni = currentNodeIndex(timeOfDay);
const col = NODE_RGB[ni]; const col = NODE_RGB[ni];
const cw = document.getElementById('clockWidget'); const cw = document.getElementById('clockWidget');
if (cw) cw.style.background = `rgba(${col[0]},${col[1]},${col[2]},.42)`; if (cw) cw.style.background = `rgba(14,20,36,.85)`; // 统一深底,不额外着色;四段弧的颜色已足够区分阶段
// 当前阶段的彩色弧高亮(不透明度 .55 → 1、其他段压暗其它 stage 名变淡 // 当前阶段的彩色弧高亮(不透明度 .55 → 1、其他段压暗其它 stage 名变淡
const segIds = ['segDawn', 'segDay', 'segDusk', 'segNight']; const segIds = ['segDawn', 'segDay', 'segDusk', 'segNight'];
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
@ -3165,23 +3213,24 @@ function init() {
ln.setAttribute('stroke-width', isMajor ? 2.0 : 1.2); ln.setAttribute('stroke-width', isMajor ? 2.0 : 1.2);
ticks.appendChild(ln); ticks.appendChild(ln);
} }
// 时钟外环4 段时间彩色弧(按 TIME_KEYFRAMES 的小时边界分割 24h 圆周 // 时钟外环4 段填充扇形(铺满整圆,从圆心到外弧的 pie wedge
(function buildClockRing() { (function buildClockRing() {
const segs = TIME_KEYFRAMES; // [{h:5},{h:12},{h:18},{h:21}] const segs = TIME_KEYFRAMES; // [{h:5},{h:12},{h:18},{h:21}]
const ids = ['segDawn', 'segDay', 'segDusk', 'segNight']; const ids = ['segDawn', 'segDay', 'segDusk', 'segNight'];
const R = 34; // 弧半径(与 stroke-width=18 配合,弧再向内扩+加粗,更内敛醒目 const R = 44; // 外半径(铺满整个表盘区域
const cx = 50, cy = 50; const cx = 50, cy = 50;
const pt = (h) => { // 小时 → 圆周坐标0h 在 12 点钟24h 顺时针回到顶部 const pt = (h) => { // 小时 → 圆周坐标0h 在 12 点钟)
const rad = (h / 24) * Math.PI * 2 - Math.PI / 2; const rad = (h / 24) * Math.PI * 2 - Math.PI / 2;
return { x: cx + R * Math.cos(rad), y: cy + R * Math.sin(rad) }; return { x: cx + R * Math.cos(rad), y: cy + R * Math.sin(rad) };
}; };
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const h0 = segs[i].h; // 段起(含首段从 0 起、末段绕回 24 const h0 = segs[i].h;
const h1 = (i === 3) ? 24 : segs[i + 1].h; const h1 = (i === 3) ? 24 : segs[i + 1].h;
const p0 = pt(h0), p1 = pt(h1); const p0 = pt(h0), p1 = pt(h1);
const span = h1 - h0; const span = h1 - h0;
const large = span > 12 ? 1 : 0; // 跨 >180° 用大弧 const large = span > 12 ? 1 : 0;
const d = `M ${p0.x} ${p0.y} A ${R} ${R} 0 ${large} 1 ${p1.x} ${p1.y}`; // 扇形路径:圆心 → 弧起点 → 弧(顺时针) → 弧终点 → 回圆心闭合
const d = `M ${cx} ${cy} L ${p0.x} ${p0.y} A ${R} ${R} 0 ${large} 1 ${p1.x} ${p1.y} Z`;
const el = document.getElementById(ids[i]); const el = document.getElementById(ids[i]);
if (el) el.setAttribute('d', d); if (el) el.setAttribute('d', d);
} }
@ -3253,12 +3302,43 @@ function init() {
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
W = canvas.width = window.innerWidth; W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight - BOTTOM_BAR_H; H = canvas.height = window.innerHeight - BOTTOM_BAR_H;
worldCacheValid = false; // 画布尺寸变化,交互快照失效,下次交互重新烘焙
}); });
loadSavedRatio(); // 应用上次保存的地表比例 loadSavedRatio(); // 应用上次保存的地表比例
generateMap(); generateMap();
// 注意:地表改动不再走 localStorage统一由登录闸门拉取后端存档 applyWorldState 覆盖 // 注意:地表改动不再走 localStorage统一由登录闸门拉取后端存档 applyWorldState 覆盖
init(); init();
</script>
<!-- 右下角导航控件:方向 + 缩放(替代原鼠标拖拽+滚轮) -->
<div id="navControls">
<button class="nc-nw" data-cmd="nw"></button>
<button class="nc-up" data-cmd="up"></button>
<button class="nc-ne" data-cmd="ne"></button>
<button class="nc-left" data-cmd="left"></button>
<div class="nc-center"></div>
<button class="nc-right" data-cmd="right"></button>
<button class="nc-sw" data-cmd="sw"></button>
<button class="nc-down" data-cmd="down"></button>
<button class="nc-se" data-cmd="se"></button>
</div>
<script>
// 右下角导航控件事件
const PAN_STEP = 120; // [PLACEHOLDER] 每按一步平移像素量
document.getElementById('navControls').addEventListener('click', e => {
const btn = e.target.closest('button');
if (!btn) return;
switch (btn.dataset.cmd) {
case 'up': camera.y -= PAN_STEP; break;
case 'down': camera.y += PAN_STEP; break;
case 'left': camera.x -= PAN_STEP; break;
case 'right': camera.x += PAN_STEP; break;
case 'nw': camera.x -= PAN_STEP; camera.y -= PAN_STEP; break;
case 'ne': camera.x += PAN_STEP; camera.y -= PAN_STEP; break;
case 'sw': camera.x -= PAN_STEP; camera.y += PAN_STEP; break;
case 'se': camera.x += PAN_STEP; camera.y += PAN_STEP; break;
}
});
</script> </script>
<script src="assets/game-api.js"></script> <script src="assets/game-api.js"></script>
<script> <script>