world.html: 接入 yt_world 后端地表记忆(增量持久化)

- 登录闸门拉取后端世界存档 applyWorldState,叠加到确定性底图
- 保存按钮改为增量 flushSave(防抖 800ms),不再写 localStorage
- 相机位/缩放经 scheduleSettingsSave 落库(API.saveSettings)
- handleBuildClick/swapTiles/applyHeight/onHeightSlide/flatten/wheel/mouseup 标脏
- game-api.js 对齐 yt_world 接口 world()/saveTiles()/saveSettings()
This commit is contained in:
李鹏宇 2026-07-23 21:46:51 +08:00
parent a00ea237eb
commit a24bdbcff7
2 changed files with 199 additions and 38 deletions

View File

@ -1,6 +1,8 @@
// 宇森游戏 · 前端与 Odoo 通信层 // 宇森游戏 · 前端与 Odoo 通信层
// 本地调试:前端 8888 -> Odoo 8069跨域带调试 CORS 头) // 本地调试:前端 8888 -> Odoo 8069跨域带调试 CORS 头)
// 生产部署:前端与 Odoo 同域,由 nginx 代理 /game/*。 // 生产部署:前端与 Odoo 同域,由 nginx 代理 /game/*。
// 说明odoo.conf 已 db_name=yt_game 钉死库URL 上的 ?db= 形同虚设,
// 请求一律落到 yt_game下列 game/* 沿用统一 API_CONFIGyt_world/* 单独带 ?db=yt_game。
const API_CONFIG = { const API_CONFIG = {
base: (location.hostname === '127.0.0.1' && location.port === '8888') base: (location.hostname === '127.0.0.1' && location.port === '8888')
@ -60,17 +62,17 @@ const API = {
}, },
// ---------- 沙盘记忆yt_world 模块) ---------- // ---------- 沙盘记忆yt_world 模块) ----------
async world() { async world() {
return (await fetch('/yt_world/api/world?db=game', { credentials: 'same-origin' })).json(); return (await fetch('/yt_world/api/world?db=yt_game', { credentials: 'same-origin' })).json();
}, },
async saveTiles(tiles) { async saveTiles(tiles) {
return (await fetch('/yt_world/api/tiles/save?db=game', { return (await fetch('/yt_world/api/tiles/save?db=yt_game', {
method: 'POST', credentials: 'same-origin', method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tiles }), body: JSON.stringify({ tiles }),
})).json(); })).json();
}, },
async saveSettings(settings) { async saveSettings(settings) {
return (await fetch('/yt_world/api/settings?db=game', { return (await fetch('/yt_world/api/settings?db=yt_game', {
method: 'POST', credentials: 'same-origin', method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings }), body: JSON.stringify({ settings }),

View File

@ -17,25 +17,52 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
#gameCanvas.canDrag { cursor: grab; } #gameCanvas.canDrag { cursor: grab; }
#topBar { #topBar {
position: fixed; top: 0; left: 0; right: 0; height: 44px; position: fixed; top: 0; left: 0; right: 0; height: 58px;
background: linear-gradient(180deg, rgba(10,14,23,0.95) 0%, rgba(10,14,23,0.7) 100%); background: #0a0e17;
display: flex; align-items: center; padding: 0 16px; display: flex; align-items: center; padding: 0 18px;
border-bottom: 1px solid rgba(100,200,255,0.2); border-bottom: 1px solid rgba(139,92,246,.35);
z-index: 100; backdrop-filter: blur(8px); z-index: 100;
pointer-events: none; pointer-events: none;
color: #e9edf6; font-family: "Noto Sans SC", system-ui, sans-serif;
} }
#topBar .title { #topBar .title {
font-size: 15px; font-weight: bold; font-size: 18px; font-weight: 700;
background: linear-gradient(90deg, #4af, #a8e6cf); background: linear-gradient(90deg, #7cc7ff, #b6f0d4);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; -webkit-background-clip: text; -webkit-text-fill-color: transparent;
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 10px;
} }
#topBar .title img { width: 30px; height: 30px; border-radius: 6px; box-shadow: 0 0 10px rgba(74,175,255,0.45); } #topBar .title img { width: 34px; height: 34px; border-radius: 7px; box-shadow: 0 0 12px rgba(74,175,255,.5); }
#topBar .title .worldName { #topBar .title .worldName {
font-size: 13px; font-weight: 600; color: #bcd0e6; -webkit-text-fill-color: #bcd0e6; font-size: 15px; font-weight: 600; color: #cfe1f3; -webkit-text-fill-color: #cfe1f3;
margin-left: 8px; padding-left: 8px; border-left: 1px solid rgba(255,255,255,0.25); margin-left: 10px; padding-left: 10px; border-left: 1px solid rgba(255,255,255,.28);
} }
#topBar .coords { margin-left: auto; font-size: 12px; color: #8899aa; } #topBar .coords { font-size: 14px; color: #a8b8cc; }
/* ===== 顶部右侧用户区(融合进 #topBar恒在最右侧 ===== */
#topBar .userArea{position:relative;margin-left:auto;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;}
#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-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);}}
#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;}
#topBar .ua-item:not(:disabled):hover{background:rgba(139,92,246,.2);color:#fff;}
#topBar .ua-item:disabled{opacity:.5;cursor:not-allowed;}
#topBar .ua-item--danger:not(:disabled):hover{background:rgba(239,68,68,.22);color:#ffb4b4;}
#topBar .ua-sep{height:1px;background:rgba(255,255,255,.08);margin:5px 4px;}
#topBar .ua-soon{font-size:10px;color:#778;background:rgba(255,255,255,.06);padding:1px 6px;border-radius:8px;}
/* 主沙盘底部功能框架(预留区:放功能按钮 / 素材槽,比微观边框更高) */ /* 主沙盘底部功能框架(预留区:放功能按钮 / 素材槽,比微观边框更高) */
#bottomBar { #bottomBar {
@ -351,18 +378,12 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
#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;
font-family:"Noto Sans SC",system-ui,sans-serif;font-size:15px;letter-spacing:3px;} font-family:"Noto Sans SC",system-ui,sans-serif;font-size:15px;letter-spacing:3px;}
#authBar{position:fixed;top:8px;right:16px;z-index:110;display:none;align-items:center;gap:8px; /* #authBar 已融合进 #topBar .userArea */
padding:5px 12px;border-radius:999px;background:rgba(10,16,30,.85);backdrop-filter:blur(10px);
border:1px solid rgba(255,255,255,.14);font-family:"Noto Sans SC",system-ui,sans-serif;font-size:12px;color:#e9edf6;}
#authBar .ab-name{color:#c77dff;font-weight:500;}
#authBar .ab-logout{cursor:pointer;color:#9aa6c4;border:1px solid rgba(255,255,255,.18);background:transparent;border-radius:8px;padding:4px 10px;font-family:inherit;font-size:12px;transition:.2s;}
#authBar .ab-logout:hover{color:#fff;border-color:#8b5cf6;}
</style> </style>
</head> </head>
<body> <body>
<div id="authSplash">登录校验中…</div> <div id="authSplash">登录校验中…</div>
<div id="authBar"><span class="ab-name"></span><button class="ab-logout" type="button">退出</button></div>
<!-- 加载界面 --> <!-- 加载界面 -->
<div id="loadingScreen"> <div id="loadingScreen">
@ -380,6 +401,19 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<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>
<span class="coords" id="coords">坐标: (0, 0)</span> <span class="coords" id="coords">坐标: (0, 0)</span>
<div class="userArea" id="userArea" hidden>
<button class="ua-trigger" id="uaTrigger" type="button" aria-haspopup="true" aria-expanded="false">
<span class="ua-avatar"></span>
<span class="ua-name" id="uaName">用户</span>
<span class="ua-caret"></span>
</button>
<div class="ua-menu" id="uaMenu" role="menu">
<button class="ua-item" type="button" role="menuitem" disabled>设置 <span class="ua-soon">soon</span></button>
<button class="ua-item" type="button" role="menuitem" disabled>消息 <span class="ua-soon">soon</span></button>
<div class="ua-sep"></div>
<button class="ua-item ua-item--danger" id="uaLogout" type="button" role="menuitem">退出登录</button>
</div>
</div>
</div> </div>
<!-- 主沙盘底部功能框架(预留区,放功能按钮 / 素材槽) --> <!-- 主沙盘底部功能框架(预留区,放功能按钮 / 素材槽) -->
@ -487,7 +521,6 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<div class="panel-section"> <div class="panel-section">
<div class="panel-label">地块信息</div> <div class="panel-label">地块信息</div>
<div class="panel-value" id="detailTerrain">🟢 平原</div> <div class="panel-value" id="detailTerrain">🟢 平原</div>
<div class="panel-value" id="detailCoords">坐标: (0, 0)</div>
</div> </div>
<div class="panel-section"> <div class="panel-section">
<div class="panel-label">建筑</div> <div class="panel-label">建筑</div>
@ -519,7 +552,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<!-- 背景切换面板 --> <!-- 背景切换面板 -->
<style> <style>
#buildToggle{margin-left:14px;background:rgba(100,200,255,.12);border:1px solid rgba(100,200,255,.3);color:#9fd;padding:5px 12px;border-radius:8px;font-size:13px;cursor:pointer;pointer-events:auto;transition:.2s;} #buildToggle{margin-left:14px;background:rgba(100,200,255,.12);border:1px solid rgba(100,200,255,.3);color:#9fd;padding:7px 14px;border-radius:8px;font-size:14px;cursor:pointer;pointer-events:auto;transition:.2s;}
#buildToggle:hover{background:rgba(100,200,255,.22);} #buildToggle:hover{background:rgba(100,200,255,.22);}
#buildToggle.active{background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;border-color:transparent;box-shadow:0 0 14px rgba(100,170,255,.5);} #buildToggle.active{background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;border-color:transparent;box-shadow:0 0 14px rgba(100,170,255,.5);}
#buildPanel{position:fixed;left:16px;top:64px;z-index:60;width:440px;max-height:82vh;overflow-y:auto;background:linear-gradient(180deg,rgba(12,16,28,.98),rgba(16,24,44,.98));border:1px solid rgba(120,200,255,.28);border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.55);color:#e8eef6;font-size:15px;padding:20px;backdrop-filter:blur(8px);} #buildPanel{position:fixed;left:16px;top:64px;z-index:60;width:440px;max-height:82vh;overflow-y:auto;background:linear-gradient(180deg,rgba(12,16,28,.98),rgba(16,24,44,.98));border:1px solid rgba(120,200,255,.28);border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.55);color:#e8eef6;font-size:15px;padding:20px;backdrop-filter:blur(8px);}
@ -676,6 +709,7 @@ function applyHeight(tile, dir, skipStroke) {
const target = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, old + dir)); const target = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, old + dir));
if (target === old) return; // 已到上限/下限,无变化 if (target === old) return; // 已到上限/下限,无变化
setHeight(tile, target, true); // 玩家改高度→随机一张对应地形贴图 setHeight(tile, target, true); // 玩家改高度→随机一张对应地形贴图
markDirty(tile.q, tile.r);
// 高度工具下,左右键/滚轮升降时实时同步滑块与标签(修复:升降时滑块不跟随) // 高度工具下,左右键/滚轮升降时实时同步滑块与标签(修复:升降时滑块不跟随)
const sl = document.getElementById('bpHeightSlider'); const sl = document.getElementById('bpHeightSlider');
const lb = document.getElementById('bpHeightVal'); const lb = document.getElementById('bpHeightVal');
@ -1370,6 +1404,31 @@ 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(邻居)/径向渐变,
// 用纯色兜底最大化每帧吞吐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;
}
const sun = currentPalette.sun; const sun = currentPalette.sun;
const top = prismTopPoints(cx, cyTop, topR); const top = prismTopPoints(cx, cyTop, topR);
// 高度阴影:整块投下柔和投影,强化起伏层次(夜/低光下投影略重,但不超 0.30 // 高度阴影:整块投下柔和投影,强化起伏层次(夜/低光下投影略重,但不超 0.30
@ -1795,8 +1854,9 @@ function startMicroRender() {
const mW = microCanvas.width, mH = microCanvas.height; const mW = microCanvas.width, mH = microCanvas.height;
const terrain = TERRAIN[microState.tile.terrain]; const terrain = TERRAIN[microState.tile.terrain];
// ---- 清空画布 ---- // ---- 不透明兜底:避免 clearRect 透出 body 黑底造成"黑影闪" ----
microCtx.clearRect(0, 0, mW, mH); microCtx.fillStyle = '#0a0e17';
microCtx.fillRect(0, 0, mW, mH);
// ---- 套缩放+平移变换(场景内容随 zoom/pan 移动)---- // ---- 套缩放+平移变换(场景内容随 zoom/pan 移动)----
microCtx.save(); microCtx.save();
@ -1990,7 +2050,10 @@ function onMicroMove(e) {
} }
if (!microSwipe || !microSwipe.active) return; if (!microSwipe || !microSwipe.active) return;
const p = getMicroPoint(e); const p = getMicroPoint(e);
microSwipe.x = p.x; microSwipe.y = p.y; // 边界约束:跟手最大 ±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));
} }
function onMicroUp(e) { function onMicroUp(e) {
if (!microState) return; if (!microState) return;
@ -2495,6 +2558,7 @@ canvas.addEventListener('wheel', e => {
camInteracting = true; camInteracting = true;
clearTimeout(_camIntT); clearTimeout(_camIntT);
_camIntT = setTimeout(() => { camInteracting = false; }, 200); _camIntT = setTimeout(() => { camInteracting = false; }, 200);
scheduleSettingsSave(); // 相机缩放后防抖存后端
// 更新光标状态 // 更新光标状态
canvas.classList.add('canDrag'); canvas.classList.add('canDrag');
}, { passive: false }); }, { passive: false });
@ -2581,6 +2645,7 @@ canvas.addEventListener('mouseup', e => {
if (!isMouseDown) return; if (!isMouseDown) return;
isMouseDown = false; isMouseDown = false;
camInteracting = false; // 松手:恢复阴影投影与每帧重建(静止帧细节回到位) camInteracting = false; // 松手:恢复阴影投影与每帧重建(静止帧细节回到位)
if (hasDragged) scheduleSettingsSave(); // 平移过则防抖存相机位
canvas.classList.remove('dragging'); canvas.classList.remove('dragging');
// 建造模式 · 移动工具:拖拽/点击交换板块 // 建造模式 · 移动工具:拖拽/点击交换板块
@ -2637,6 +2702,7 @@ function handleBuildClick(tile) {
tile.terrain = buildBrush; tile.terrain = buildBrush;
tile.height = terrainToHeight(buildBrush); tile.height = terrainToHeight(buildBrush);
tile.tier = heightToTier(tile.height); // tier 从 height 派生,与渲染一致 tile.tier = heightToTier(tile.height); // tier 从 height 派生,与渲染一致
markDirty(tile.q, tile.r);
} }
// 素材变体random=按坐标确定性取一张(固定到该格,刷新一致);指定=用玩家选的下标 // 素材变体random=按坐标确定性取一张(固定到该格,刷新一致);指定=用玩家选的下标
const arr = TERRAIN_ASSETS[buildBrush]; const arr = TERRAIN_ASSETS[buildBrush];
@ -2659,6 +2725,7 @@ function swapTiles(a, b) {
tile.terrain = heightToTerrain(tile.height); tile.terrain = heightToTerrain(tile.height);
tile.tier = heightToTier(tile.height); tile.tier = heightToTier(tile.height);
}); });
markDirty(a.q, a.r); markDirty(b.q, b.r);
swapFlashes.push({ a: { q: a.q, r: a.r }, b: { q: b.q, r: b.r }, life: 1 }); swapFlashes.push({ a: { q: a.q, r: a.r }, b: { q: b.q, r: b.r }, life: 1 });
} }
@ -2720,6 +2787,7 @@ function setupBuildUI() {
bpHeightVal.textContent = heightLabel(heightTarget); bpHeightVal.textContent = heightLabel(heightTarget);
if (buildMode && buildTool === 'height' && currentLayer === 'earth' && hoveredTile) { if (buildMode && buildTool === 'height' && currentLayer === 'earth' && hoveredTile) {
setHeight(hoveredTile, heightTarget, true); // 滑块拖动改高度→随机一张贴图 setHeight(hoveredTile, heightTarget, true); // 滑块拖动改高度→随机一张贴图
markDirty(hoveredTile.q, hoveredTile.r);
} }
}; };
bpHeightSlider.addEventListener('input', onHeightSlide); bpHeightSlider.addEventListener('input', onHeightSlide);
@ -2742,7 +2810,7 @@ function setupBuildUI() {
const bpFlatten = document.getElementById('bpFlatten'); const bpFlatten = document.getElementById('bpFlatten');
bpFlatten.onclick = () => { bpFlatten.onclick = () => {
if (!confirm('平铺把所有地块高度设为最低1当前起伏将丢失。')) return; if (!confirm('平铺把所有地块高度设为最低1当前起伏将丢失。')) return;
for (const t of tiles) setHeight(t, MIN_HEIGHT); for (const t of tiles) { setHeight(t, MIN_HEIGHT); markDirty(t.q, t.r); }
heightTarget = MIN_HEIGHT; heightTarget = MIN_HEIGHT;
bpHeightSlider.value = String(MIN_HEIGHT); bpHeightSlider.value = String(MIN_HEIGHT);
bpHeightVal.textContent = heightLabel(MIN_HEIGHT); bpHeightVal.textContent = heightLabel(MIN_HEIGHT);
@ -2752,11 +2820,9 @@ function setupBuildUI() {
} }
// 保存 / 恢复沙盘(地形与板块交换结果持久化到 localStorage // 保存 / 恢复沙盘(地形与板块交换结果持久化到 localStorage
// 后端记忆:保存按钮直接触发增量 flushflushSave 内部自带 ✅/⚠ 反馈)
function saveWorld(btn) { function saveWorld(btn) {
try { flushSave(btn);
localStorage.setItem('world_sandbox_tiles', JSON.stringify(tiles));
if (btn) { const t = btn.textContent; btn.textContent = '✅ 已保存'; setTimeout(() => { btn.textContent = t; }, 1200); }
} catch(e) { if (btn) { btn.textContent = '⚠ 失败'; setTimeout(() => { btn.textContent = '保存'; }, 1400); } }
} }
function loadSavedWorld() { function loadSavedWorld() {
try { try {
@ -2778,6 +2844,81 @@ function loadSavedWorld() {
} catch(e) {} } catch(e) {}
} }
// ============ 后端记忆地块增量持久化yt_world 模块) ============
// 底图由坐标确定性生成generateMap只把"玩家改动的地块"增量存后端。
let dirtyTiles = new Set(); // "q,r" 集合:待保存的改动地块
let saveTimer = null;
let settingsTimer = null;
function markDirty(q, r) {
dirtyTiles.add(q + ',' + r);
scheduleSave();
}
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => flushSave(), 800);
}
async function flushSave(btn) {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
if (!dirtyTiles.size) {
if (btn) { btn.textContent = '✅ 已保存'; setTimeout(() => { btn.textContent = '保存'; }, 1200); }
return;
}
// 收集改动地块(仅大地层)
const byKey = new Map();
for (const t of tiles) if (t.layer === 'earth') byKey.set(t.q + ',' + t.r, t);
const batch = [];
for (const key of dirtyTiles) {
const t = byKey.get(key);
if (!t) continue;
batch.push({ q: t.q, r: t.r, terrain: t.terrain || null, height: t.height, flags: t.flags || {} });
}
dirtyTiles.clear();
const oldText = btn ? btn.textContent : null;
try {
await API.saveTiles(batch);
if (btn) { btn.textContent = '✅ 已保存'; setTimeout(() => { btn.textContent = '保存'; }, 1200); }
} catch (e) {
for (const b of batch) dirtyTiles.add(b.q + ',' + b.r); // 失败重试:重新标脏
if (btn) { btn.textContent = '⚠ 失败'; setTimeout(() => { btn.textContent = oldText || '保存'; }, 1400); }
}
}
// 启动:用后端存档覆盖底图(地形/高度/flags 原地覆盖,不重跑建筑随机)
function applyWorldState(ws) {
if (!ws) return;
if (ws.settings) applyWorldSettings(ws.settings);
if (!Array.isArray(ws.tiles)) return;
const byKey = new Map();
for (const t of tiles) if (t.layer === 'earth') byKey.set(t.q + ',' + t.r, t);
for (const ov of ws.tiles) {
const t = byKey.get(ov.q + ',' + ov.r);
if (!t) continue;
if (ov.terrain) t.terrain = ov.terrain;
if (ov.height != null) { t.height = ov.height; t.tier = heightToTier(ov.height); }
if (ov.flags) t.flags = ov.flags;
}
}
// 相机/UI 偏好收集 + 防抖保存
function collectWorldSettings() {
return { camera: { x: camera.x, y: camera.y, zoom: camera.zoom } };
}
function applyWorldSettings(s) {
if (!s || !s.camera) return;
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;
}
function scheduleSettingsSave() {
if (settingsTimer) clearTimeout(settingsTimer);
settingsTimer = setTimeout(flushSettings, 1000);
}
async function flushSettings() {
settingsTimer = null;
try { await API.saveSettings(collectWorldSettings()); }
catch (e) { /* 设置非关键,静默 */ }
}
// ESC退出微观世界 // ESC退出微观世界
document.addEventListener('keydown', e => { if (e.key === 'Escape') exitMicroWorld(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') exitMicroWorld(); });
document.getElementById('backBtn').addEventListener('click', exitMicroWorld); document.getElementById('backBtn').addEventListener('click', exitMicroWorld);
@ -3116,7 +3257,7 @@ window.addEventListener('resize', () => {
loadSavedRatio(); // 应用上次保存的地表比例 loadSavedRatio(); // 应用上次保存的地表比例
generateMap(); generateMap();
loadSavedWorld(); // 有存档则恢复玩家建造结果 // 注意:地表改动不再走 localStorage统一由登录闸门拉取后端存档 applyWorldState 覆盖
init(); init();
</script> </script>
<script src="assets/game-api.js"></script> <script src="assets/game-api.js"></script>
@ -3124,16 +3265,34 @@ init();
// ===== 登录闸门:未登录跳登录页;已登录显示昵称+退出 ===== // ===== 登录闸门:未登录跳登录页;已登录显示昵称+退出 =====
(async () => { (async () => {
const splash = document.getElementById('authSplash'); const splash = document.getElementById('authSplash');
const bar = document.getElementById('authBar'); const area = document.getElementById('userArea');
const trigger = document.getElementById('uaTrigger');
const menu = document.getElementById('uaMenu');
try { try {
const me = await API.me(); const me = await API.me();
if (!me || !me.uid) { location.replace('login.html?next=world.html'); return; } if (!me || !me.uid) { location.replace('login.html?next=world.html'); return; }
bar.querySelector('.ab-name').textContent = me.name || me.login; document.getElementById('uaName').textContent = me.name || me.login;
bar.style.display = 'flex'; area.hidden = false;
bar.querySelector('.ab-logout').onclick = async () => { // 拉取后端地表记忆yt_world确定性底图 + 玩家增量覆盖,叠加到当前 tiles
try {
const ws = await API.world();
applyWorldState(ws);
} catch (e) {
console.warn('加载世界存档失败,使用确定性底图', e);
}
trigger.addEventListener('click', (ev) => {
ev.stopPropagation();
const open = menu.classList.toggle('open');
trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
});
document.addEventListener('click', () => {
menu.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false');
});
document.getElementById('uaLogout').addEventListener('click', async () => {
try { await API.logout(); } catch (e) {} try { await API.logout(); } catch (e) {}
location.replace('login.html'); location.replace('login.html');
}; });
} catch (e) { } catch (e) {
location.replace('login.html?next=world.html'); return; location.replace('login.html?next=world.html'); return;
} finally { } finally {