From 9e01534de9846cd67515603d6576642f3f85647a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=B9=8F=E5=AE=87?= Date: Fri, 24 Jul 2026 09:52:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8E=BB=E6=8E=89=E6=8B=96=E5=8A=A8?= =?UTF-8?q?=E6=94=B9=E7=94=A88=E6=96=B9=E5=90=91=E6=8C=89=E9=92=AE+?= =?UTF-8?q?=E6=BB=9A=E8=BD=AE=E7=BC=A9=E6=94=BE,=20=E8=83=8C=E6=99=AF?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A/=E9=98=B4=E5=BD=B1=E5=B8=B8=E9=A9=BB/?= =?UTF-8?q?=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- world.html | 332 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 206 insertions(+), 126 deletions(-) diff --git a/world.html b/world.html index d4da67f..bb1f115 100644 --- a/world.html +++ b/world.html @@ -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; 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,恒在最右侧) ===== */ -#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); 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;} -#topBar .ua-trigger:hover{background:rgba(139,92,246,.32);border-color:#a78bfa;} + font:inherit;font-size:14px;font-weight:500;cursor:pointer;} #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; 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-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-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; - box-shadow:0 10px 28px rgba(0,0,0,.55);display:none;z-index:120;} -#topBar .ua-menu.open{display:block;animation:uaSlide .18s ease-out;} -@keyframes uaSlide{from{opacity:0;transform:translateY(-6px);}to{opacity:1;transform:translateY(0);}} + box-shadow:0 4px 12px rgba(0,0,0,.5);z-index:120; + visibility:hidden;opacity:0;pointer-events:none;} +#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%; background:transparent;border:none;color:#cfd5ea;padding:9px 12px;border-radius:8px; 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 .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; } #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; box-shadow:0 6px 22px rgba(0,0,0,.6);} #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:hover{border-color:rgba(139,92,246,.5);} #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;} #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; background:linear-gradient(160deg,#080c1a,#0e1730 48%,#15213f);color:#9aa6c4; @@ -400,6 +423,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
宇森大地 +
坐标: (0, 0)
+
@@ -432,23 +457,23 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
- - - - - - - - + + + + + + + + @@ -513,7 +538,6 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa soon
-
微观世界 · 工具栏
@@ -770,10 +794,10 @@ const TIME_KEYFRAMES = [ // 四个时间节点的代表色(与 TIME_KEYFRAMES 顺序一致:清晨/正午/黄昏/黑夜) // 用于时钟背景与表盘外环着色,让玩家一眼看出当前处于哪个时间节点 const NODE_RGB = [ - [255, 158, 94], // 清晨:暖橙 - [90, 176, 255], // 正午:天蓝 - [255, 122, 77], // 黄昏:红橙 - [58, 79, 138], // 黑夜:深蓝 + [255, 179, 0], // 清晨:琥珀金 #FFB300 + [0, 229, 255], // 正午:亮青 #00E5FF + [255, 61, 0], // 黄昏:烈焰红 #FF3D00 + [61, 61, 74], // 黑夜:深灰 #3D3D4A ]; // ============ 游戏日历(纪 / 年 / 季节) ============ @@ -1141,7 +1165,7 @@ const BOTTOM_BAR_H = 72; // 主沙盘底部功能栏高度(与 #bottomBar CS const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); 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 selectedTile = null; @@ -1157,6 +1181,11 @@ let _camIntT = null; // 缩放交互的 debounce 定时器 let _cachedSorted = null; // 按 currentLayer 缓存的绘制顺序地块(交互期复用,静止帧每帧重建) let _cachedSortedLayer = null; let shadowFade = 1; // 阴影投影强度淡入淡出:交互中→0(关),松手/停滚→1(渐入),避免明暗硬跳变 +let worldCache = null, worldCacheCtx = null; // 交互期离屏世界快照:静止帧把世界烘焙进此画布,拖动/缩放时仅 blit(O(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 = []; @@ -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); return; } - // 交互期轻量路径:跳过 paintWallTexture(6面贴图)/paintHexTexture(顶面)/getTileFast(邻居)/径向渐变, - // 用纯色兜底最大化每帧吞吐;camInteracting 由松手 200ms 后自动置 false,下帧恢复完整绘制 - 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; - } + // 注:交互期不再走"纯色兜底"分支——改由 render() 把世界烘焙进离屏快照,拖动/缩放只 blit 快照, + // 纹理全程可见且不掉帧(见 render() 的交互快速路径)。完整绘制始终走下方路径。 const sun = currentPalette.sun; const top = prismTopPoints(cx, cyTop, topR); // 高度阴影:整块投下柔和投影,强化起伏层次(夜/低光下投影略重,但不超 0.30) @@ -1504,22 +1510,58 @@ function drawHexPrism(cx, cyTop, topR, wallH, topColor, hover, tile) { } // ============ 渲染 ============ +// 把当前主画布(已全细节渲染的世界)烘焙进离屏快照,供交互期 blit(O(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() { if (microViewOpen) { requestAnimationFrame(render); return; } // 微观视图打开时主世界暂停重绘,把 CPU 让给微观渲染循环 - const interacting = camInteracting; // 拖动/缩放进行中:关阴影+复用地块缓存,松手后自动恢复 - // 阴影投影强度平滑过渡:交互中朝 0 收敛(关),松手/停滚后朝 1 渐入(~12 帧到位),消除松开瞬间的明暗硬跳变 - const targetShadow = interacting ? 0 : 1; + const interacting = camInteracting; // 拖动/缩放进行中:复用世界快照,松手后自动恢复 + // 阴影投影常驻 1:交互期走快照 blit(不再每帧重绘带模糊卷积),固定 1,松手帧与拖动帧像素级一致。 + const targetShadow = 1; shadowFade += (targetShadow - shadowFade) * 0.14; 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; if (timePlaying) { const prevT = timeOfDay; - timeOfDay = nowHours(); // 每帧同步系统真实时间:时钟=本地真实时刻,与墙钟 100% 一致 + timeOfDay = nowHours(); // 静止帧同步系统真实时间 if (timeOfDay < prevT) gameDay++; // 跨过午夜,游戏天数 +1(驱动日历/季节) } - syncTimeUI(); // 每帧刷新时钟(即便暂停也刷);秒针由真实时间驱动持续扫动,体现时间在走 + syncTimeUI(); // 每帧刷新时钟(即便暂停也刷);秒针由真实时间驱动持续扫动 refreshWorldVideo(); // 时间节点变化则切换背景视频(其余帧直接跳过) currentPalette = getSky(currentLayer, timeOfDay); + worldCacheValid = false; // 静止帧:下次交互重新烘焙快照(世界可能已变化) + ctx.clearRect(0, 0, W, H); 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.03(4 帧 ~67ms,基本不可感知) + if (releaseBlend < 0.004) releaseBlend = 0; + } + requestAnimationFrame(render); } @@ -1873,7 +1929,7 @@ function startMicroRender() { 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) { microCtx.save(); - microCtx.globalAlpha = 1 - e * 0.35; - microCtx.translate(tr.sx * (1 - e) + tr.ux * e * D, tr.sy * (1 - e) + tr.uy * e * D); + microCtx.globalAlpha = 1 - e; // 旧图平滑淡出到 0,过渡收尾与下一帧纯新图无缝衔接,消除两侧阴影闪烁 + 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.restore(); } @@ -1894,7 +1950,7 @@ function startMicroRender() { if (microGridCache) { microCtx.save(); 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.restore(); } @@ -2050,10 +2106,20 @@ function onMicroMove(e) { } if (!microSwipe || !microSwipe.active) return; const p = getMicroPoint(e); - // 边界约束:跟手最大 ±320px(足以判断切地块方向),防止画面被拖到浮岛外 + // 跟手最大 ±320px(足以判断切地块方向),防止画面被拖太远 const SWIPE_MAX = 320; - microSwipe.x = Math.max(microSwipe.x0 - SWIPE_MAX, Math.min(microSwipe.x0 + SWIPE_MAX, p.x)); - microSwipe.y = Math.max(microSwipe.y0 - SWIPE_MAX, Math.min(microSwipe.y0 + SWIPE_MAX, p.y)); + let ox = Math.max(-SWIPE_MAX, Math.min(SWIPE_MAX, p.x - microSwipe.x0)); + 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) { if (!microState) return; @@ -2067,32 +2133,43 @@ function onMicroUp(e) { // 左键:判断是切地块还是点击 if (!microSwipe) return; const dx = microSwipe.x - microSwipe.x0, dy = microSwipe.y - microSwipe.y0; - const active = microSwipe.active; microSwipe.active = false; + microSwipe.active = false; microCanvas.style.cursor = ''; - if (!active) return; if (Math.hypot(dx, dy) < MICRO_SWIPE_MIN) { - // 未达阈值:保留 microSwipe(active=false),由 loop 弹性回弹,避免生硬瞬回 + // 未达阈值:保留 microSwipe,由 loop 弹性回弹,避免生硬瞬回 return; } - microSwipe = null; // 超阈值走 transition,起点偏移已存入 tr.sx/sy,预览可清 - navigateMicro(dx, dy); + // 超阈值:尝试切相邻地块;若边缘即墙(无相邻地块)navigateMicro 返回 false, + // 仍保留 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) { const t = microState.tile; - const o = hexToPixel(t.q, t.r); - const DIRS = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]]; - 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); - if (!nb) continue; - const p = hexToPixel(t.q + dq, t.r + dr); - 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); + const di = microHexDirOfVector(dx, dy); + const [dq, dr] = MICRO_DIRS[di]; + const nb = getTile(t.q + dq, t.r + dr); + if (nb) { transitionMicroTo(nb, dx, dy); return true; } + return false; // 边缘即墙:不切换,由 onMicroUp 保留 microSwipe 让 loop 弹性回弹 } // 切换当前微观地块(更新状态 + 侧栏 + 标题) function setMicroTile(tile) { @@ -2545,22 +2622,15 @@ function shadeColor(color, percent) { // ============ 事件(修复版)============ canvas.addEventListener('wheel', e => { - // 建造 · 捏高度工具:滚轮微调悬停格,不触发相机缩放 + // 建造 · 捏高度工具:滚轮微调悬停格 if (buildMode && buildTool === 'height' && currentLayer === 'earth' && hoveredTile) { e.preventDefault(); - applyHeight(hoveredTile, e.deltaY < 0 ? 1 : -1, true); // 滚轮离散事件:跳过笔触去重守卫,可连续升降到 0~12 全程 + applyHeight(hoveredTile, e.deltaY < 0 ? 1 : -1, true); return; } e.preventDefault(); const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1; - camera.zoom = Math.max(0.4, Math.min(4, camera.zoom * zoomDelta)); - // 缩放交互标志:连续滚轮期间保持真(关阴影+缓存复用),最后一次滚动 200ms 后复位 - camInteracting = true; - clearTimeout(_camIntT); - _camIntT = setTimeout(() => { camInteracting = false; }, 200); - scheduleSettingsSave(); // 相机缩放后防抖存后端 - // 更新光标状态 - canvas.classList.add('canDrag'); + camera.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, camera.zoom * zoomDelta)); }, { passive: false }); 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 t = getTile(hex.q, hex.r); if (t) applyHeight(t, heightBrush); - canvas.classList.add('dragging'); return; } if (e.button !== 0) return; isMouseDown = true; - hasDragged = false; - mouseDownX = e.clientX; - mouseDownY = e.clientY; - dragCamStartX = camera.x; - dragCamStartY = camera.y; // 建造 · 移动工具:按住即进入"搬运交换",不平移相机 if (buildMode && buildTool === 'move' && currentLayer === 'earth') { const rect = canvas.getBoundingClientRect(); @@ -2593,7 +2657,6 @@ canvas.addEventListener('mousedown', e => { dragSwapFrom = getTile(hex.q, hex.r); draggingSwap = !!dragSwapFrom; } - canvas.classList.add('dragging'); }); canvas.addEventListener('mousemove', e => { @@ -2610,23 +2673,11 @@ canvas.addEventListener('mousemove', e => { if (editingHeight) { const t = getTile(hex.q, hex.r); if (t) applyHeight(t, heightBrush); - return; // 捏高度笔触中:只刷高度,不平移相机 + return; // 捏高度笔触中:只刷高度 } if (draggingSwap) { - const rect = canvas.getBoundingClientRect(); - const hx = pixelToHex(e.clientX - rect.left, e.clientY - rect.top); - 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); + hoveredTile = getTile(hex.q, hex.r); + return; // 搬运中:只更新目标高亮 } } else { // 只有没按下鼠标时才检测悬停 @@ -2644,9 +2695,6 @@ canvas.addEventListener('mousemove', e => { canvas.addEventListener('mouseup', e => { if (!isMouseDown) return; isMouseDown = false; - camInteracting = false; // 松手:恢复阴影投影与每帧重建(静止帧细节回到位) - if (hasDragged) scheduleSettingsSave(); // 平移过则防抖存相机位 - canvas.classList.remove('dragging'); // 建造模式 · 移动工具:拖拽/点击交换板块 if (draggingSwap) { @@ -2907,7 +2955,7 @@ function applyWorldSettings(s) { const c = s.camera; if (typeof c.x === 'number') camera.x = c.x; 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() { if (settingsTimer) clearTimeout(settingsTimer); @@ -3030,7 +3078,7 @@ function syncTimeUI() { const ni = currentNodeIndex(timeOfDay); const col = NODE_RGB[ni]; 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 名变淡 const segIds = ['segDawn', 'segDay', 'segDusk', 'segNight']; for (let i = 0; i < 4; i++) { @@ -3165,23 +3213,24 @@ function init() { ln.setAttribute('stroke-width', isMajor ? 2.0 : 1.2); ticks.appendChild(ln); } - // 时钟外环:4 段时间彩色弧(按 TIME_KEYFRAMES 的小时边界分割 24h 圆周) + // 时钟外环:4 段填充扇形(铺满整圆,从圆心到外弧的 pie wedge) (function buildClockRing() { const segs = TIME_KEYFRAMES; // [{h:5},{h:12},{h:18},{h:21}] const ids = ['segDawn', 'segDay', 'segDusk', 'segNight']; - const R = 34; // 弧半径(与 stroke-width=18 配合,弧再向内扩+加粗,更内敛醒目) + const R = 44; // 外半径(铺满整个表盘区域) 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; return { x: cx + R * Math.cos(rad), y: cy + R * Math.sin(rad) }; }; 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 p0 = pt(h0), p1 = pt(h1); const span = h1 - h0; - const large = span > 12 ? 1 : 0; // 跨 >180° 用大弧 - const d = `M ${p0.x} ${p0.y} A ${R} ${R} 0 ${large} 1 ${p1.x} ${p1.y}`; + const large = span > 12 ? 1 : 0; + // 扇形路径:圆心 → 弧起点 → 弧(顺时针) → 弧终点 → 回圆心闭合 + 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]); if (el) el.setAttribute('d', d); } @@ -3253,12 +3302,43 @@ function init() { window.addEventListener('resize', () => { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight - BOTTOM_BAR_H; + worldCacheValid = false; // 画布尺寸变化,交互快照失效,下次交互重新烘焙 }); loadSavedRatio(); // 应用上次保存的地表比例 generateMap(); // 注意:地表改动不再走 localStorage,统一由登录闸门拉取后端存档 applyWorldState 覆盖 init(); + + + +