7871 lines
412 KiB
HTML
7871 lines
412 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||
<title>宇森 - 微观场景</title>
|
||
<!-- Favicon:精细像素风小星球,v=20260723 强制浏览器绕开旧缓存重载 -->
|
||
<link rel="icon" type="image/png" sizes="48x48" href="assets/ui/logo_planet_48.png?v=20260723">
|
||
<link rel="icon" type="image/png" sizes="256x256" href="assets/ui/logo_planet_256.png?v=20260723">
|
||
<link rel="apple-touch-icon" href="assets/ui/logo_planet_256.png?v=20260723">
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sans-serif; color: #e0e0e0; height: 100dvh; }
|
||
|
||
#gameCanvas { display: block; cursor: default; position: fixed; top: 0; left: 0; width: 100%; bottom: 72px; }
|
||
#gameCanvas.dragging { cursor: grabbing; }
|
||
#gameCanvas.canDrag { cursor: grab; }
|
||
|
||
#topBar {
|
||
position: fixed; top: 0; left: 0; right: 0; height: calc(58px + env(safe-area-inset-top));
|
||
background: #0a0e17;
|
||
display: flex; align-items: center; padding: env(safe-area-inset-top) max(18px, env(safe-area-inset-left)) 0 max(18px, env(safe-area-inset-right));
|
||
border-bottom: 1px solid rgba(139,92,246,.35);
|
||
z-index: 100;
|
||
pointer-events: none;
|
||
color: #e9edf6; font-family: "Noto Sans SC", system-ui, sans-serif;
|
||
}
|
||
#topBar .title {
|
||
font-size: 18px; font-weight: 700;
|
||
background: linear-gradient(90deg, #7cc7ff, #b6f0d4);
|
||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
#topBar .title img { width: 34px; height: 34px; border-radius: 7px; box-shadow: 0 0 12px rgba(74,175,255,.5); }
|
||
#topBar .title .worldName {
|
||
font-size: 22px; font-weight: 700; color: #cfe1f3; -webkit-text-fill-color: #cfe1f3;
|
||
pointer-events: auto;
|
||
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
|
||
white-space: nowrap;
|
||
padding: 4px 14px; border: 1.5px solid rgba(139,92,246,.55); border-radius: 8px;
|
||
background: rgba(10,14,23,.75); box-shadow: 0 0 12px rgba(139,92,246,.2);
|
||
}
|
||
#topBar .title .worldName.editable { cursor: pointer; padding: 2px 8px; border-radius: 5px; transition: background .15s; }
|
||
#topBar .title .worldName.editable:hover { background: rgba(255,255,255,.08); }
|
||
.worldName-input {
|
||
background: transparent; border: 1px solid rgba(124,199,255,.6); color: #cfe1f3;
|
||
font-size: 15px; font-weight: 600; padding: 2px 6px; border-radius: 5px; width: 180px;
|
||
font-family: inherit; outline: none;
|
||
}
|
||
#topBar .topRight { margin-left:auto; display:flex; align-items:center; gap:14px; }
|
||
#topBar .coords { font-size: 16px; font-weight: 700; color: #e6f0fb; font-variant-numeric: tabular-nums; letter-spacing: .3px; text-shadow: 0 1px 3px rgba(0,0,0,.7); }
|
||
|
||
/* ===== 顶部右侧用户区(融合进 #topBar,恒在最右侧) ===== */
|
||
#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;user-select:none;transition:background .18s,border-color .18s;}
|
||
#topBar .ua-trigger:hover{background:rgba(139,92,246,.32);border-color:rgba(139,92,246,.7);}
|
||
#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;}
|
||
/* 右侧滑出面板(玩家中心):参考左侧 #worldNav 的毛玻璃质感,从右侧整屏展开 */
|
||
#uaBackdrop{position:fixed;inset:0;z-index:199;background:rgba(4,7,15,.5);
|
||
backdrop-filter:blur(2px);opacity:0;visibility:hidden;transition:opacity .3s ease,visibility .3s ease;}
|
||
#uaBackdrop.open{opacity:1;visibility:visible;}
|
||
#topBar .ua-menu{position:fixed;top:0;right:0;height:100vh;width:340px;max-width:86vw;z-index:210;
|
||
background:linear-gradient(180deg,rgba(17,23,42,.98),rgba(10,14,26,.98));
|
||
border-left:1px solid rgba(139,92,246,.35);box-shadow:-14px 0 44px rgba(0,0,0,.55);
|
||
transform:translateX(106%);transition:transform .34s cubic-bezier(.22,.61,.36,1);
|
||
display:flex;flex-direction:column;overflow:hidden;}
|
||
#topBar .ua-menu.open{transform:translateX(0);}
|
||
#topBar .ua-menu *{box-sizing:border-box;}
|
||
|
||
/* 头部 */
|
||
.ua-head{display:flex;align-items:center;gap:12px;padding:18px 18px 14px;
|
||
background:linear-gradient(135deg,rgba(139,92,246,.22),rgba(74,175,255,.12));
|
||
border-bottom:1px solid rgba(255,255,255,.08);}
|
||
.ua-head-main{display:flex;align-items:center;gap:12px;flex:1;min-width:0;}
|
||
.ua-avatar-lg{width:48px;height:48px;border-radius:50%;flex:0 0 auto;
|
||
background:linear-gradient(135deg,#c77dff,#4af);display:flex;align-items:center;justify-content:center;
|
||
font-size:20px;font-weight:700;color:#fff;box-shadow:0 0 16px rgba(199,125,255,.5);}
|
||
.ua-head-info{min-width:0;}
|
||
.ua-head-name{font-size:17px;font-weight:700;color:#eef2fb;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.ua-head-sub{font-size:12px;color:#9fb0c8;margin-top:2px;display:flex;align-items:center;gap:6px;}
|
||
.ua-dot{width:7px;height:7px;border-radius:50%;background:#46e07a;box-shadow:0 0 8px #46e07a;}
|
||
.ua-close{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid rgba(255,255,255,.14);
|
||
background:rgba(255,255,255,.05);color:#cdd8e6;font-size:14px;cursor:pointer;transition:.15s;}
|
||
.ua-close:hover{background:rgba(239,68,68,.2);color:#ffb4b4;border-color:rgba(239,68,68,.4);}
|
||
|
||
/* 滚动区 */
|
||
.ua-scroll{flex:1;overflow-y:auto;padding:16px 14px;display:flex;flex-direction:column;gap:14px;}
|
||
.ua-scroll::-webkit-scrollbar{width:6px;}
|
||
.ua-scroll::-webkit-scrollbar-thumb{background:rgba(139,92,246,.4);border-radius:3px;}
|
||
|
||
/* 卡片通用 */
|
||
.ua-card{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:14px;padding:14px;}
|
||
.ua-card-h{font-size:14px;font-weight:700;color:#dbe4f5;margin-bottom:10px;display:flex;align-items:center;gap:6px;}
|
||
|
||
/* 缔造者等级卡 */
|
||
.ua-card-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;}
|
||
.ua-lv{font-size:13px;color:#cdd8e6;}
|
||
.ua-lv b{font-size:20px;color:#c77dff;font-weight:800;margin-left:2px;}
|
||
.ua-title{font-size:12px;color:#9fb0c8;padding:3px 10px;border-radius:999px;background:rgba(139,92,246,.18);border:1px solid rgba(139,92,246,.35);}
|
||
.ua-exp{height:8px;background:rgba(255,255,255,.08);border-radius:4px;overflow:hidden;}
|
||
.ua-exp-fill{height:100%;background:linear-gradient(90deg,#8b5cf6,#4af);border-radius:4px;}
|
||
.ua-exp-txt{font-size:11px;color:#8899aa;margin-top:5px;}
|
||
|
||
/* 我的世界概览 */
|
||
.ua-world-name{font-size:16px;font-weight:700;color:#eaf4ff;margin-bottom:12px;
|
||
padding:8px 10px;border-radius:10px;background:rgba(139,92,246,.12);border:1px solid rgba(139,92,246,.3);
|
||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.ua-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px;}
|
||
.ua-stat{background:rgba(255,255,255,.03);border-radius:10px;padding:8px 10px;text-align:center;}
|
||
.ua-stat-v{font-size:15px;font-weight:700;color:#e6f0fb;}
|
||
.ua-stat-l{font-size:11px;color:#8899aa;margin-top:2px;}
|
||
.ua-res{display:flex;flex-direction:column;gap:7px;}
|
||
.ua-res-row{display:flex;align-items:center;gap:8px;font-size:12px;color:#cdd8e6;}
|
||
.ua-res-row>span:first-child{width:62px;flex:0 0 auto;}
|
||
.ua-res-bar{flex:1;height:6px;background:rgba(255,255,255,.08);border-radius:3px;overflow:hidden;}
|
||
.ua-res-bar i{display:block;height:100%;border-radius:3px;background:linear-gradient(90deg,#8b5cf6,#4af);}
|
||
.ua-res-row b{width:42px;text-align:right;color:#e6f0fb;font-size:12px;}
|
||
|
||
/* 成就 */
|
||
.ua-ach{display:flex;flex-direction:column;gap:8px;}
|
||
.ua-ach-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:10px;background:rgba(255,255,255,.03);}
|
||
.ua-ach-item.done{background:rgba(70,224,122,.1);border:1px solid rgba(70,224,122,.25);}
|
||
.ua-ach-item.locked{opacity:.55;}
|
||
.ua-ach-ic{font-size:18px;flex:0 0 auto;}
|
||
.ua-ach-t{font-size:13px;font-weight:600;color:#e6f0fb;}
|
||
.ua-ach-d{font-size:11px;color:#8899aa;margin-top:2px;}
|
||
.ua-ach-bar{height:5px;background:rgba(255,255,255,.08);border-radius:3px;overflow:hidden;margin-top:4px;}
|
||
.ua-ach-bar i{display:block;height:100%;background:linear-gradient(90deg,#8b5cf6,#4af);border-radius:3px;}
|
||
|
||
/* 快捷操作 + 底部 */
|
||
.ua-actions{display:flex;flex-direction:column;gap:6px;}
|
||
#topBar .ua-item{display:flex;align-items:center;justify-content:space-between;width:100%;
|
||
background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);color:#cfd5ea;
|
||
padding:11px 14px;border-radius:11px;font:inherit;font-size:14px;cursor:pointer;text-align:left;transition:.15s;}
|
||
#topBar .ua-item:not(:disabled):hover{background:rgba(139,92,246,.22);color:#fff;border-color:rgba(139,92,246,.4);}
|
||
#topBar .ua-item:disabled{opacity:.5;cursor:not-allowed;}
|
||
#topBar .ua-item--danger{background:rgba(239,68,68,.12);border-color:rgba(239,68,68,.3);color:#ffb4b4;
|
||
width:100%;justify-content:center;}
|
||
#topBar .ua-item--danger:not(:disabled):hover{background:rgba(239,68,68,.25);color:#fff;}
|
||
.ua-badge{font-size:11px;font-weight:700;color:#fff;background:#ef4444;border-radius:999px;padding:1px 7px;}
|
||
.ua-foot{padding:14px;border-top:1px solid rgba(255,255,255,.08);}
|
||
#topBar .ua-sep{display:none;}
|
||
#topBar .ua-soon{font-size:10px;color:#778;background:rgba(255,255,255,.06);padding:1px 6px;border-radius:8px;}
|
||
|
||
/* 场景悬浮工具条:浮于场景画面、紧贴顶部栏下方 */
|
||
#sceneToolbar{
|
||
position:fixed; top:calc(58px + env(safe-area-inset-top) + 12px); right:max(18px, env(safe-area-inset-right)); z-index:90;
|
||
display:flex; align-items:center; gap:10px; pointer-events:auto;
|
||
padding:9px 11px; border-radius:16px;
|
||
background:rgba(12,18,32,.78); border:1px solid rgba(139,92,246,.35);
|
||
backdrop-filter:blur(10px); box-shadow:0 8px 28px rgba(0,0,0,.42), inset 0 1px 0 rgba(255,255,255,.06);
|
||
}
|
||
.top-icon{
|
||
position:relative; width:46px; height:46px; border-radius:13px; flex-shrink:0;
|
||
display:flex; align-items:center; justify-content:center;
|
||
background:rgba(255,255,255,.08); border:1.5px solid rgba(255,255,255,.13);
|
||
color:#e6f0fb; font-size:22px; cursor:pointer; transition:.18s; pointer-events:auto;
|
||
text-shadow:0 1px 3px rgba(0,0,0,.45);
|
||
}
|
||
.top-icon:hover{background:linear-gradient(135deg, rgba(139,92,246,.28), rgba(74,175,255,.22)); border-color:rgba(139,92,246,.55); color:#fff; transform:translateY(-2px); box-shadow:0 6px 18px rgba(80,120,255,.35);}
|
||
.top-icon:active{transform:translateY(0);}
|
||
.top-icon-badge{
|
||
position:absolute; top:-3px; right:-3px; min-width:18px; height:18px; padding:0 5px;
|
||
font-size:11px; font-weight:800; color:#fff; background:#ef4444; border-radius:999px;
|
||
display:flex; align-items:center; justify-content:center; box-shadow:0 2px 8px rgba(239,68,68,.5);
|
||
}
|
||
|
||
/* 世界等级经验条(位于 bottomBar 内最左侧,加大加清晰) */
|
||
#worldExpBarWrap{
|
||
flex:0 0 auto; width:520px; height:100%;
|
||
display:flex; flex-direction:column; justify-content:center; gap:7px;
|
||
padding:0 16px 0 0; background:transparent; border:none;
|
||
}
|
||
.we-info{display:flex; align-items:center; gap:12px; flex-shrink:0; color:#e6f0fb;}
|
||
.we-lv{font-size:16px; font-weight:800; color:#c4b5fd; text-shadow:0 0 10px rgba(139,92,246,.5);}
|
||
.we-title{font-size:13px; color:#9fb0c4; max-width:130px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
|
||
.we-exp-txt{margin-left:auto; font-size:13px; font-weight:600; color:#cfe1f3;}
|
||
.we-pct{margin-left:2px; font-size:13px; font-weight:700; color:#c4b5fd; min-width:34px; text-align:right;}
|
||
.we-bar{width:100%; height:14px; background:rgba(255,255,255,.10); border-radius:7px; overflow:hidden;}
|
||
.we-fill{height:100%; width:0%; background:linear-gradient(90deg,#8b5cf6,#4af); border-radius:7px; transition:width .3s ease; box-shadow:0 0 12px rgba(139,92,246,.45);}
|
||
|
||
/* 主沙盘底部功能框架(预留区:放功能按钮 / 素材槽,比微观边框更高) */
|
||
#bottomBar {
|
||
position: fixed; left: 0; right: 0; bottom: 0; height: calc(72px + env(safe-area-inset-bottom)); z-index: 120;
|
||
display: flex; align-items: center; gap: 14px; padding: 0 max(18px, env(safe-area-inset-left)) env(safe-area-inset-bottom) max(18px, env(safe-area-inset-right));
|
||
background-image:
|
||
url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='46'%20height='52'%20viewBox='0%200%2046%2052'%3E%3Cpolygon%20points='23,4%2041,15%2041,37%2023,48%205,37%205,15'%20fill='none'%20stroke='%234a6fa5'%20stroke-opacity='0.12'%20stroke-width='1.4'/%3E%3C/svg%3E"),
|
||
linear-gradient(0deg, rgba(12,18,32,0.98), rgba(8,12,22,0.98));
|
||
background-repeat: repeat, no-repeat;
|
||
border-top: 2px solid rgba(100,200,255,0.35);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
#bottomBar .bb-label { font-size: 11px; color: #667788; text-transform: uppercase; letter-spacing: 1px; }
|
||
#bottomBar .bb-btn {
|
||
background: linear-gradient(135deg, rgba(139,92,246,.9), rgba(74,175,255,.72));
|
||
color: #fff; border: none; border-radius: 10px; padding: 9px 14px;
|
||
font-size: 13px; font-weight: 700; cursor: pointer;
|
||
box-shadow: 0 4px 16px rgba(80,120,255,.35); white-space: nowrap;
|
||
}
|
||
#bottomBar .bb-btn:hover { filter: brightness(1.12); }
|
||
#bottomBar .bb-slots { display: flex; gap: 10px; align-items: center; flex: 1; overflow-x: auto; }
|
||
#bottomBar .bb-slot {
|
||
width: 52px; height: 52px; border-radius: 10px; flex-shrink: 0;
|
||
display: flex; align-items: center; justify-content: center; font-size: 18px;
|
||
}
|
||
#bottomBar .bb-slot--empty {
|
||
border: 1.5px dashed rgba(120,170,210,0.25); color: #44566a;
|
||
background: rgba(255,255,255,0.02);
|
||
}
|
||
|
||
/* 微观世界视图 —— 直接切换,无渐变过渡:加载完即时显示/隐藏,进出皆为硬切 */
|
||
#microView {
|
||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||
background: #0a0e17; z-index: 200;
|
||
display: flex; flex-direction: column;
|
||
opacity: 0; visibility: hidden; pointer-events: none;
|
||
}
|
||
#microView.active {
|
||
opacity: 1; visibility: visible; pointer-events: auto;
|
||
}
|
||
|
||
#microHeader {
|
||
position: fixed; top: 0; left: 0; right: 0; height: 50px;
|
||
display: flex; align-items: center; padding: 0 16px;
|
||
background: linear-gradient(90deg, rgba(10,14,23,0.98), rgba(20,30,50,0.98));
|
||
border-bottom: 1px solid rgba(100,200,255,0.2);
|
||
z-index: 30; pointer-events: auto;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,.35);
|
||
}
|
||
#microHeader .backBtn {
|
||
background: rgba(100,200,255,0.15); border: 1px solid rgba(100,200,255,0.3);
|
||
color: #4af; padding: 6px 16px; border-radius: 6px; cursor: pointer;
|
||
font-size: 13px; transition: all 0.2s;
|
||
}
|
||
#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.editable { cursor: pointer; padding: 2px 8px; border-radius: 5px; transition: background .15s; }
|
||
#microHeader .microTitle.editable:hover { background: rgba(255,255,255,.08); }
|
||
#microHeader .microSubtitle { display: none; }
|
||
|
||
#microBody { margin-top: 96px; flex: 1; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
||
#microCanvas { flex: 1; display: block; }
|
||
|
||
/* 微观世界右侧面板 */
|
||
#microSidebar {
|
||
width: 280px; background: linear-gradient(270deg, rgba(10,14,23,0.98) 0%, rgba(10,14,23,0.85) 100%);
|
||
border-left: 1px solid rgba(100,200,255,0.15);
|
||
padding: 16px; overflow-y: auto; flex-shrink: 0;
|
||
pointer-events: auto;
|
||
}
|
||
#microSidebar::-webkit-scrollbar { width: 4px; }
|
||
#microSidebar::-webkit-scrollbar-thumb { background: rgba(100,200,255,0.3); border-radius: 2px; }
|
||
|
||
/* ===== 微观世界上下边框:功能栏 / 素材栏预留区 ===== */
|
||
/* 主题色随当前地块的 atmo.glow 自适应(--mv-accent),暗底 + 六边形暗纹呼应“地块/瓷砖”风格 */
|
||
#microStage { flex: 1; display: flex; min-height: 0; position: relative; overflow: hidden; }
|
||
|
||
#microTopBar {
|
||
position: fixed; top: 50px; left: 0; right: 0; height: 46px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
padding: 0 14px;
|
||
border-top: 2px solid var(--mv-accent, #4aafff);
|
||
border-bottom: 1px solid rgba(120,200,255,0.12);
|
||
background-image:
|
||
url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='46'%20height='52'%20viewBox='0%200%2046%2052'%3E%3Cpolygon%20points='23,4%2041,15%2041,37%2023,48%205,37%205,15'%20fill='none'%20stroke='%234a6fa5'%20stroke-opacity='0.16'%20stroke-width='1.4'/%3E%3C/svg%3E"),
|
||
linear-gradient(180deg, rgba(12,18,32,0.97), rgba(8,12,22,0.97));
|
||
background-repeat: repeat, no-repeat;
|
||
box-shadow: inset 0 0 24px rgba(0,0,0,0.5), 0 6px 18px rgba(0,0,0,.3);
|
||
z-index: 29; pointer-events: auto;
|
||
}
|
||
#microBuildTips {
|
||
display: flex; align-items: center; justify-content: flex-start; gap: 8px;
|
||
width: 100%; height: 100%;
|
||
padding-left: 14px;
|
||
color: #c8e0ff; font-size: 13px; letter-spacing: .3px;
|
||
text-align: left; pointer-events: none;
|
||
transition: opacity .25s ease;
|
||
}
|
||
#microBuildTips[hidden] { display: none; }
|
||
#microBuildTips .mv-tip-icon { opacity: .85; font-size: 14px; filter: drop-shadow(0 0 4px rgba(100,200,255,.4)); }
|
||
#microBuildTipText { transition: opacity .25s ease; }
|
||
#microBottomBar {
|
||
min-height: 52px; flex-shrink: 0; padding: 6px 14px;
|
||
border-bottom: 2px solid var(--mv-accent, #4aafff);
|
||
background-image:
|
||
url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='46'%20height='52'%20viewBox='0%200%2046%2052'%3E%3Cpolygon%20points='23,4%2041,15%2041,37%2023,48%205,37%205,15'%20fill='none'%20stroke='%234a6fa5'%20stroke-opacity='0.14'%20stroke-width='1.4'/%3E%3C/svg%3E"),
|
||
linear-gradient(0deg, rgba(12,18,32,0.97), rgba(8,12,22,0.97));
|
||
background-repeat: repeat, no-repeat;
|
||
box-shadow: 0 -6px 24px rgba(0,0,0,.45);
|
||
}
|
||
|
||
/* 顶部工具按钮 */
|
||
.mv-tools { display: flex; gap: 8px; }
|
||
.mv-tool {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
background: rgba(100,200,255,0.10); border: 1px solid rgba(120,200,255,0.32);
|
||
color: #cfe6ff; padding: 6px 13px; border-radius: 8px;
|
||
font-size: 13px; cursor: pointer; transition: all .18s;
|
||
}
|
||
.mv-tool:hover { background: rgba(100,200,255,0.20); }
|
||
.mv-tool.active {
|
||
background: linear-gradient(135deg, var(--mv-accent, #4aafff), #8b5cf6);
|
||
color: #fff; border-color: transparent; box-shadow: 0 0 14px rgba(100,170,255,0.45);
|
||
}
|
||
.mv-tool--reserved { opacity: 0.62; cursor: default; }
|
||
.mv-tool:disabled { opacity: 0.35; cursor: not-allowed; background: rgba(100,200,255,0.06); border-color: rgba(120,200,255,0.16); }
|
||
.mv-tool--reserved:hover { background: rgba(100,200,255,0.10); }
|
||
.mv-soon { font-size: 10px; padding: 1px 5px; border-radius: 6px; background: rgba(255,255,255,0.14); color: #bcd; letter-spacing: .5px; }
|
||
.mv-top-hint { font-size: 11px; color: #6a7d92; letter-spacing: 1px; }
|
||
|
||
/* 底部素材托盘(预留位) */
|
||
.mv-tray-label { font-size: 11px; color: #667788; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }
|
||
.mv-tray { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.mv-slot {
|
||
width: 46px; height: 46px; border-radius: 9px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 20px; color: #56708c;
|
||
background: rgba(255,255,255,0.04); border: 1px dashed rgba(120,200,255,0.28);
|
||
}
|
||
.mv-slot--empty:hover { border-color: var(--mv-accent, #4aafff); color: var(--mv-accent, #4aafff); }
|
||
|
||
/* 建造素材按钮(预留,兼容旧结构) */
|
||
.mv-slot.mv-build { font-size: 24px; color: #dfe8f2; background: rgba(255,255,255,.06); border: 1.5px solid rgba(120,200,255,.28); cursor: pointer; transition: .15s; padding: 0; }
|
||
.mv-slot.mv-build:hover { border-color: rgba(120,200,255,.6); background: rgba(120,200,255,.12); }
|
||
.mv-slot.mv-build.active { border-color: #ffd54f; box-shadow: 0 0 10px rgba(255,213,79,.5); background: rgba(255,213,79,.12); }
|
||
|
||
/* ===== 微观世界:底部建造面板(浮层覆盖舞台底部,不挤压画布 → 无闪烁) ===== */
|
||
#microBottomBar { position: relative; }
|
||
#microBuildPanel {
|
||
position: absolute; left: 0; right: 280px; bottom: 100%;
|
||
margin-bottom: 0; padding: 8px 14px 4px;
|
||
display: flex; flex-direction: column; gap: 6px;
|
||
background-image:
|
||
url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='46'%20height='52'%20viewBox='0%200%2046%2052'%3E%3Cpolygon%20points='23,4%2041,15%2041,37%2023,48%205,37%205,15'%20fill='none'%20stroke='%234a6fa5'%20stroke-opacity='0.14'%20stroke-width='1.4'/%3E%3C/svg%3E"),
|
||
linear-gradient(0deg, rgba(12,18,32,.99), rgba(8,12,22,.99));
|
||
background-repeat: repeat, no-repeat;
|
||
border-radius: 14px 14px 0 0;
|
||
box-shadow: 0 -10px 36px rgba(0,0,0,.55);
|
||
transform: translateY(24px); opacity: 0; visibility: hidden;
|
||
transition: transform .22s cubic-bezier(.2,.8,.2,1), opacity .22s ease, visibility .22s;
|
||
}
|
||
#microBuildPanel.open { transform: translateY(0); opacity: 1; visibility: visible; }
|
||
#microCats { display: flex; gap: 8px; justify-content: flex-start; flex-wrap: wrap; }
|
||
.mv-cats-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||
.mv-cats-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
||
.mv-cat {
|
||
background: rgba(100,200,255,.08); border: 1px solid rgba(120,200,255,.26);
|
||
color: #bcd; padding: 6px 16px; border-radius: 999px; font-size: 13px; cursor: pointer; transition: .15s;
|
||
}
|
||
.mv-cat:hover { background: rgba(100,200,255,.16); }
|
||
.mv-cat.active { background: linear-gradient(135deg, var(--mv-accent,#4aafff), #8b5cf6); color:#fff; border-color: transparent; box-shadow: 0 0 12px rgba(100,170,255,.4); }
|
||
#microGrid9 {
|
||
display: flex; align-items: stretch; gap: 10px;
|
||
height: 80px; /* 固定一行素材高度:72px 格子 + 上下 padding */
|
||
overflow-x: auto; overflow-y: hidden;
|
||
padding: 2px; cursor: grab;
|
||
scroll-behavior: smooth;
|
||
-webkit-overflow-scrolling: touch;
|
||
scrollbar-width: none; /* Firefox:隐藏滚动条,避免切换页签时闪烁 */
|
||
-ms-overflow-style: none; /* 旧 Edge */
|
||
}
|
||
#microGrid9.dragging { cursor: grabbing; scroll-behavior: auto; }
|
||
#microGrid9::-webkit-scrollbar { display: none; } /* WebKit/Chromium:完全隐藏 */
|
||
.mv-cell {
|
||
flex: 0 0 auto;
|
||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
|
||
width: 72px; height: 72px;
|
||
background: rgba(255,255,255,.05); border: 1px solid rgba(120,200,255,.24);
|
||
border-radius: 10px; cursor: pointer; transition: .15s; color: #dfe8f2; pointer-events: auto; user-select: none;
|
||
}
|
||
.mv-cell:hover { background: rgba(120,200,255,.14); border-color: rgba(120,200,255,.55); }
|
||
.mv-cell.active { background: rgba(255,213,79,.14); border-color: #ffd54f; box-shadow: 0 0 10px rgba(255,213,79,.45); }
|
||
.mv-cell .mv-ico { font-size: 30px; line-height: 1; pointer-events: none; }
|
||
.mv-cell .mv-ico-img { width: 44px; height: 44px; object-fit: contain; pointer-events: none; border-radius: 6px; }
|
||
.mv-cell .mv-ico-char { width: 44px; height: 44px; background-repeat: no-repeat; background-position: center 0; background-size: auto 100%; pointer-events: none; border-radius: 6px; image-rendering: pixelated; }
|
||
.mv-cell .mv-nm { font-size: 11px; color: #9fb3c8; pointer-events: none; }
|
||
.mv-cost { font-size: 10px; color: #ffd54f; pointer-events: none; font-weight: 600; }
|
||
.mv-cost.free { color: #7fdca0; }
|
||
.mv-empty-cat { height: 100%; display: flex; align-items: center; justify-content: center; padding: 0 14px; color: #6c8098; font-size: 13px; text-align: center; }
|
||
.mv-clear {
|
||
background: rgba(255,90,90,.1); border: 1px solid rgba(255,90,90,.35);
|
||
color: #ff9f9f; padding: 7px 14px; border-radius: 8px; font-size: 12px;
|
||
cursor: pointer; pointer-events: auto; transition: .15s; white-space: nowrap;
|
||
}
|
||
.mv-clear:hover { background: rgba(255,90,90,.2); border-color: rgba(255,90,90,.55); }
|
||
.mv-search {
|
||
width: 120px; background: rgba(100,200,255,.08); border: 1px solid rgba(120,200,255,.3);
|
||
color: #dfe8f2; padding: 6px 10px; border-radius: 8px; font-size: 12px;
|
||
outline: none; transition: border-color .15s, background .15s;
|
||
}
|
||
.mv-search::placeholder { color: #7a9ab8; }
|
||
.mv-search:focus { border-color: rgba(100,200,255,.7); background: rgba(100,200,255,.14); }
|
||
.mv-bottom-actions { display: flex; align-items: center; justify-content: flex-start; gap: 8px; padding: 4px 12px; }
|
||
.mv-bottom-actions .mv-top-hint { font-size: 11px; color: #5a6d82; letter-spacing: .5px; }
|
||
|
||
.panel-section { margin-bottom: 20px; }
|
||
.panel-label { font-size: 11px; color: #667788; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||
.panel-value { font-size: 14px; color: #e0e0e0; margin-bottom: 4px; }
|
||
|
||
.resource-bar { height: 18px; background: rgba(255,255,255,0.08); border-radius: 9px; overflow: hidden; margin: 6px 0; position: relative; }
|
||
.resource-fill { height: 100%; border-radius: 9px; transition: width 0.5s ease; position: relative; }
|
||
.resource-fill::after {
|
||
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 50%;
|
||
background: linear-gradient(180deg, rgba(255,255,255,0.2), transparent);
|
||
border-radius: 9px 9px 0 0;
|
||
}
|
||
|
||
.unit-card {
|
||
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
|
||
border-radius: 8px; padding: 10px; margin-bottom: 8px;
|
||
cursor: pointer; transition: all 0.2s;
|
||
}
|
||
.unit-card:hover { background: rgba(100,200,255,0.1); border-color: rgba(100,200,255,0.3); }
|
||
.unit-card .unit-name { font-size: 13px; font-weight: bold; color: #e0e0e0; }
|
||
.unit-card .unit-role { font-size: 11px; color: #8899aa; }
|
||
.unit-card .unit-status { font-size: 11px; margin-top: 4px; }
|
||
.status-working { color: #8bc34a; }
|
||
.status-resting { color: #ff9800; }
|
||
.status-moving { color: #4af; }
|
||
|
||
/* 飞入遮罩 —— 底部提示药丸 + 全宽进度条 */
|
||
/* ===== 飞入过渡:随机动漫插画作背景 + 进度条/提示在前景 ===== */
|
||
#flyInOverlay {
|
||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||
background: transparent; z-index: 300;
|
||
display: flex; flex-direction: column; justify-content: flex-end;
|
||
padding: 0 24px 48px 24px;
|
||
opacity: 0; visibility: hidden; pointer-events: none;
|
||
}
|
||
/* 背景插画层:铺满 + 放大遮水印,置于内容之下 */
|
||
#flyInOverlay .flyBg {
|
||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||
object-fit: cover; transform: scale(1.2);
|
||
z-index: 0;
|
||
}
|
||
/* 深色压暗层,保证前景文字/进度条可读 */
|
||
#flyInOverlay::before {
|
||
content: ''; position: absolute; inset: 0; z-index: 1;
|
||
background: linear-gradient(180deg, rgba(10,14,23,.35) 0%, rgba(10,14,23,.65) 100%);
|
||
}
|
||
#flyInOverlay > *:not(.flyBg) { position: relative; z-index: 2; }
|
||
#flyInOverlay.active { opacity: 1; visibility: visible; pointer-events: auto; }
|
||
#flyInOverlay .flyLine {
|
||
display: flex; align-items: center; gap: 14px;
|
||
width: 100%;
|
||
}
|
||
#flyInOverlay .flyBar { flex: 1; height: 10px; background: rgba(255,255,255,0.08); border-radius: 5px; overflow: hidden; }
|
||
#flyInOverlay .flyFill { height: 100%; width: 0%; background: linear-gradient(90deg, #4af, #a8e6cf); border-radius: 5px; }
|
||
#flyInOverlay .flyPct { font-size: 16px; color: #4af; font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
||
#flyInOverlay .flyTip {
|
||
align-self: flex-start;
|
||
margin-bottom: 12px;
|
||
padding: 10px 18px;
|
||
border-radius: 999px;
|
||
background: rgba(255,255,255,0.08);
|
||
border: 1px solid rgba(255,255,255,0.10);
|
||
font-size: 18px; font-weight: 600; color: rgba(255,255,255,0.88);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
|
||
/* 加载界面 */
|
||
#loadingScreen {
|
||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||
background: transparent; overflow: hidden; z-index: 500;
|
||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||
padding: 0 24px 56px 24px;
|
||
}
|
||
/* 随机过渡图作背景,放大遮挡水印,置于内容之下 */
|
||
#loadingScreen .loadBg {
|
||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||
object-fit: cover; transform: scale(1.2); z-index: 0;
|
||
}
|
||
/* 深色压暗层,保证前景文字/进度条可读 */
|
||
#loadingScreen::before {
|
||
content: ''; position: absolute; inset: 0; z-index: 1;
|
||
background: linear-gradient(180deg, rgba(10,14,23,.55) 0%, rgba(10,14,23,.82) 100%);
|
||
}
|
||
#loadingScreen > *:not(.loadBg) { position: relative; z-index: 2; }
|
||
#loadingScreen .loadLine {
|
||
display: flex; align-items: center; justify-content: flex-start; gap: 4px;
|
||
margin-top: auto; margin-bottom: 8px;
|
||
padding-left: 32px; padding-right: 0; /* 与左下角 tips 框左缘 32px 对齐;右 padding 0 让 bar 贴到屏右 */
|
||
width: 100%; /* 父级 #loadingScreen 用 align-items:center 会让本行收缩到内容宽,撑开才能让 bar 的 flex:1 生效 */
|
||
}
|
||
#loadingScreen .loadBar { flex: 1; max-width: none; height: 12px; background: rgba(255,255,255,0.1); border-radius: 6px; overflow: hidden; }
|
||
#loadingScreen .loadFill { height: 100%; width: 0%; background: linear-gradient(90deg, #4af, #a8e6cf); border-radius: 6px; transition: width 0.3s; }
|
||
#loadingScreen .loadPct { font-size: 20px; color: #4af; font-variant-numeric: tabular-nums; letter-spacing: 1px; }
|
||
#loadingScreen .loadTip {
|
||
position: absolute; bottom: 10vh; left: 32px;
|
||
font-size: 20px; color: #a8b8c8; max-width: 460px; text-align: left;
|
||
line-height: 1.6; min-height: 50px; transition: opacity 0.35s; opacity: 1;
|
||
padding: 12px 18px; border-left: 2px solid rgba(74,170,255,0.5);
|
||
background: rgba(20,28,42,0.5); border-radius: 0 6px 6px 0;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
|
||
/* 单位详情弹窗 */
|
||
#unitModal {
|
||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||
background: rgba(0,0,0,0.7); z-index: 400; display: none;
|
||
justify-content: center; align-items: center;
|
||
}
|
||
#unitModal.active { display: flex; }
|
||
#unitModal .modalContent {
|
||
background: #1a2030; border: 1px solid rgba(100,200,255,0.3); border-radius: 12px;
|
||
padding: 24px; max-width: 340px; width: 90%; pointer-events: auto;
|
||
}
|
||
#unitModal .modalTitle { font-size: 16px; font-weight: bold; color: #e0e0e0; margin-bottom: 16px; }
|
||
#unitModal .statRow { display: flex; justify-content: space-between; align-items: center; margin: 6px 0; font-size: 12px; }
|
||
#unitModal .statLabel { color: #8899aa; }
|
||
#unitModal .statValue { color: #e0e0e0; }
|
||
#unitModal .statBar { flex: 1; height: 8px; background: rgba(255,255,255,0.08); border-radius: 4px; margin: 0 8px; overflow: hidden; }
|
||
#unitModal .statFill { height: 100%; border-radius: 4px; }
|
||
#unitModal .cmdBtn {
|
||
display: block; width: 100%; margin-top: 8px; padding: 8px;
|
||
background: rgba(100,200,255,0.1); border: 1px solid rgba(100,200,255,0.25);
|
||
color: #cce; border-radius: 6px; cursor: pointer; font-size: 12px; text-align: center;
|
||
transition: all 0.2s;
|
||
}
|
||
#unitModal .cmdBtn:hover { background: rgba(100,200,255,0.2); }
|
||
#unitModal .cmdGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||
#unitModal .cmdGrid .cmdBtn { margin-top: 0; }
|
||
/* 装备槽 */
|
||
#unitModal .equipWrap { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
|
||
#unitModal .equipSlot { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; padding: 7px; }
|
||
#unitModal .equipSlotName { font-size: 11px; color: #8899aa; margin-bottom: 5px; }
|
||
#unitModal .equipOpt { font-size: 12px; color: #cfe; padding: 5px 6px; margin-top: 4px; background: rgba(100,200,255,0.1); border-radius: 6px; cursor: pointer; }
|
||
#unitModal .equipOpt:hover { background: rgba(100,200,255,0.22); }
|
||
#unitModal .equipCur { font-size: 12px; color: #eaf2ff; padding: 5px 2px; }
|
||
#unitModal .equipBonus { color: #8bc34a; font-size: 10px; margin-left: 3px; }
|
||
#unitModal .equipX { color: #ff7b7b; cursor: pointer; margin-left: 4px; font-size: 11px; }
|
||
|
||
/* 微观建筑悬停提示 tooltip */
|
||
#microTip {
|
||
position: fixed; z-index: 60; pointer-events: none; display: none;
|
||
background: rgba(14,20,32,0.94); border: 1px solid rgba(255,255,255,0.18);
|
||
border-radius: 10px; padding: 8px 11px; color: #eaf2ff; font-size: 12px; line-height: 1.4;
|
||
max-width: 240px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); transform: translateZ(0);
|
||
}
|
||
#microTip .tipName { font-weight: 700; font-size: 13px; display: flex; align-items: center; gap: 6px; }
|
||
#microTip .tipName img { width: 22px; height: 22px; object-fit: contain; }
|
||
#microTip .tipDesc { margin-top: 4px; color: rgba(234,242,255,0.72); }
|
||
#unitModal .closeBtn {
|
||
margin-top: 16px; width: 100%; padding: 8px; background: rgba(255,80,80,0.15);
|
||
border: 1px solid rgba(255,80,80,0.3); color: #f88; border-radius: 6px;
|
||
cursor: pointer; font-size: 13px;
|
||
}
|
||
|
||
/* 通用信息弹窗(个人信息/设置/邮件) */
|
||
#appModal {
|
||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||
background: rgba(0,0,0,0.7); z-index: 400; display: none;
|
||
justify-content: center; align-items: center;
|
||
}
|
||
#appModal.active { display: flex; }
|
||
#appModal .modalContent {
|
||
background: #1a2030; border: 1px solid rgba(100,200,255,0.3); border-radius: 12px;
|
||
padding: 24px; max-width: 360px; width: 90%; max-height: 80vh; pointer-events: auto;
|
||
display: flex; flex-direction: column;
|
||
}
|
||
#appModal .modalTitle { font-size: 16px; font-weight: bold; color: #e0e0e0; margin-bottom: 16px; flex-shrink: 0; }
|
||
#appModal .modalBody {
|
||
color: #cfd5ea; font-size: 14px; line-height: 1.6; overflow-y: auto; padding-right: 4px;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(100,180,255,0.35) rgba(255,255,255,0.05);
|
||
}
|
||
#appModal .modalBody::-webkit-scrollbar { width: 6px; }
|
||
#appModal .modalBody::-webkit-scrollbar-track {
|
||
background: rgba(255,255,255,0.05); border-radius: 4px; margin: 4px 0;
|
||
}
|
||
#appModal .modalBody::-webkit-scrollbar-thumb {
|
||
background: rgba(100,180,255,0.35); border-radius: 4px;
|
||
}
|
||
#appModal .modalBody::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(100,200,255,0.55);
|
||
}
|
||
#appModal .infoRow { display: flex; justify-content: space-between; margin: 8px 0; font-size: 13px; }
|
||
#appModal .infoLabel { color: #8899aa; }
|
||
#appModal .infoValue { color: #e0e0e0; }
|
||
#appModal .mailItem {
|
||
background: rgba(255,255,255,0.05); border: 1px solid rgba(100,200,255,0.15);
|
||
border-radius: 8px; padding: 10px; margin: 8px 0; font-size: 13px;
|
||
}
|
||
#appModal .mailTitle { color: #7cc7ff; font-weight: 600; margin-bottom: 4px; }
|
||
#appModal .mailMeta { color: #778; font-size: 11px; margin-bottom: 4px; }
|
||
#appModal .settingRow { display: flex; justify-content: space-between; align-items: center; margin: 10px 0; font-size: 13px; }
|
||
#appModal .toggleSwitch { position: relative; width: 42px; height: 22px; border-radius: 11px; background: rgba(255,255,255,.12); cursor: pointer; transition: background .2s; }
|
||
#appModal .toggleSwitch.active { background: linear-gradient(135deg,#8b5cf6,#4aafff); }
|
||
#appModal .toggleSwitch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform .2s; }
|
||
#appModal .toggleSwitch.active::after { transform: translateX(20px); }
|
||
#appModal .mailTabs { display: flex; gap: 6px; margin-bottom: 12px; }
|
||
#appModal .mailTab { flex: 1; text-align: center; padding: 6px 0; font-size: 13px; color: #8899aa; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); border-radius: 8px; cursor: pointer; transition: .15s; }
|
||
#appModal .mailTab.active { color: #fff; background: rgba(139,92,246,.25); border-color: rgba(139,92,246,.5); }
|
||
#appModal .mailEmpty { color: #8899aa; font-size: 13px; text-align: center; padding: 20px 0; }
|
||
#appModal .achItem { display: flex; align-items: center; gap: 12px; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); border-radius: 10px; padding: 10px 12px; margin: 8px 0; }
|
||
#appModal .achItem.done { border-color: rgba(199,125,255,.5); background: rgba(139,92,246,.12); }
|
||
#appModal .achIcon { width: 36px; height: 36px; border-radius: 9px; background: rgba(255,255,255,.08); display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
|
||
#appModal .achIcon.locked { filter: grayscale(1); opacity: .6; }
|
||
#appModal .achMain { flex: 1; min-width: 0; }
|
||
#appModal .achName { font-size: 14px; font-weight: 600; color: #e0e0e0; }
|
||
#appModal .achDesc { font-size: 12px; color: #8899aa; margin-top: 2px; }
|
||
#appModal .achBar { height: 6px; background: rgba(255,255,255,.08); border-radius: 3px; overflow: hidden; margin-top: 6px; }
|
||
#appModal .achFill { height: 100%; background: linear-gradient(90deg,#8b5cf6,#4af); border-radius: 3px; }
|
||
#appModal .achState { font-size: 12px; color: #9fd; flex-shrink: 0; }
|
||
#appModal .achItem.done .achState { color: #c77dff; font-weight: 600; }
|
||
#appModal .closeBtn {
|
||
display:none;
|
||
}
|
||
#appModal .modalBtns{display:flex;gap:10px;margin-top:18px;justify-content:flex-end;}
|
||
#appModal .btn-cancel,#appModal .btn-confirm{padding:8px 18px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;border:1px solid transparent;transition:opacity .15s,transform .12s;}
|
||
#appModal .btn-cancel{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.14);color:#aab4cc;}
|
||
#appModal .btn-cancel:hover{background:rgba(255,255,255,.12);}
|
||
#appModal .btn-confirm{background:linear-gradient(135deg,#6b5ce7,#4a90d9);color:#fff;}
|
||
#appModal .btn-confirm:hover{opacity:.88;transform:scale(1.03);}
|
||
#appModal .btn-confirm:active{transform:scale(.96);}
|
||
|
||
/* ===== 世界内导航与数据面板 ===== */
|
||
#dataPanel{position:fixed;top:0;right:0;width:min(540px,94vw);height:100%;background:linear-gradient(180deg,rgba(10,14,23,.98),rgba(14,23,48,.98));border-left:1px solid rgba(100,200,255,.25);z-index:260;transform:translateX(100%);transition:transform .28s ease;display:flex;flex-direction:column;box-shadow:-12px 0 40px rgba(0,0,0,.5);}
|
||
#dataPanel.open{transform:translateX(0);}
|
||
#dpHeader{height:54px;flex:0 0 auto;display:flex;align-items:center;padding:0 18px;border-bottom:1px solid rgba(100,200,255,.18);}
|
||
#dpTitle{font-size:18px;font-weight:bold;color:#e0e0e0;}
|
||
#dpClose{margin-left:auto;background:rgba(255,80,80,.15);border:1px solid rgba(255,80,80,.3);color:#f88;padding:6px 14px;border-radius:8px;cursor:pointer;font-size:13px;}
|
||
#dpBody{flex:1;overflow-y:auto;padding:18px;}
|
||
.dp-loading,.dp-empty{color:#8899aa;font-size:14px;padding:20px;text-align:center;}
|
||
.dp-block{margin-bottom:26px;}
|
||
.dp-block h3{font-size:18px;color:#fff;letter-spacing:1px;margin-bottom:12px;border-left:3px solid #4af;padding-left:10px;}
|
||
.dp-sublabel{font-size:12px;color:#4af;margin:10px 0 6px;}
|
||
.dp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;}
|
||
.dp-card{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);border-radius:12px;overflow:hidden;transition:.2s;}
|
||
.dp-card:hover{border-color:#4af;box-shadow:0 0 18px rgba(100,200,255,.25);}
|
||
.dp-thumb{width:100%;aspect-ratio:1/1;overflow:hidden;background:linear-gradient(135deg,rgba(139,92,246,.22),rgba(76,201,240,.10));}
|
||
.dp-thumb img,.dp-thumb svg{width:100%;height:100%;object-fit:cover;display:block;}
|
||
.dp-card-body{padding:9px 11px;}
|
||
.dp-nm{font-size:14px;color:#fff;font-weight:500;}
|
||
.dp-meta{display:flex;justify-content:space-between;margin-top:4px;font-size:11px;}
|
||
.dp-sub{color:#4af;}.dp-tier{color:#e7b85c;}
|
||
.dp-ds{font-size:11px;color:#9aa6c4;line-height:1.5;margin-top:5px;}
|
||
.dp-rows{display:flex;flex-direction:column;gap:8px;}
|
||
.dp-row{display:grid;grid-template-columns:64px 96px 1fr;gap:10px;align-items:center;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:9px 12px;}
|
||
.dp-lvl{font-family:monospace;font-size:11px;color:#4af;}
|
||
.dp-rname{font-size:14px;color:#fff;}
|
||
.dp-rdesc{font-size:12px;color:#9aa6c4;line-height:1.5;}
|
||
.dp-threads{display:flex;flex-direction:column;gap:12px;}
|
||
.dp-thread{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:14px;}
|
||
.dp-thread-head{display:flex;align-items:center;gap:8px;margin-bottom:8px;}
|
||
.dp-cat{font-size:11px;color:#4af;background:rgba(100,200,255,.1);padding:2px 8px;border-radius:10px;}
|
||
.dp-pin{font-size:10px;color:#e7b85c;}
|
||
.dp-tt{font-size:15px;color:#fff;font-weight:500;}
|
||
.dp-author{margin-left:auto;font-size:12px;color:#9aa6c4;}
|
||
.dp-thread-body{font-size:13px;color:#e0e0e0;line-height:1.6;}
|
||
.dp-replies{margin-top:10px;border-top:1px solid rgba(255,255,255,.08);padding-top:10px;display:flex;flex-direction:column;gap:8px;}
|
||
.dp-reply{background:rgba(255,255,255,.03);border-radius:8px;padding:8px 10px;}
|
||
.dp-reply-head{display:flex;justify-content:space-between;font-size:12px;color:#9aa6c4;margin-bottom:4px;}
|
||
.dp-reply-body{font-size:13px;color:#e0e0e0;line-height:1.5;}
|
||
.dp-notes{display:flex;flex-direction:column;gap:12px;}
|
||
.dp-note{border-left:3px solid #8b5cf6;padding:10px 14px;background:rgba(255,255,255,.04);border-radius:0 10px 10px 0;}
|
||
.dp-note.imp{border-left-color:#4af;}.dp-note.urgent{border-left-color:#ff6b6b;}
|
||
.dp-date{font-family:monospace;font-size:10px;color:#4af;margin-bottom:6px;}
|
||
.dp-note-body{font-size:13px;color:#e0e0e0;line-height:1.6;}
|
||
|
||
/* 顶栏时间信息(钟表已移走,只保留文字两行) */
|
||
.timeInfo { display: flex; flex-direction: column; align-items: flex-end; line-height: 1.2; pointer-events: none; }
|
||
#topBar .timeInfo #clockLabel { font-size: 16px; 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); }
|
||
#topBar .timeInfo #calendarLabel { font-size: 12px; font-weight: 600; color: #cfe1f3; font-variant-numeric: tabular-nums; white-space: nowrap; letter-spacing: .4px; text-shadow: 0 1px 3px rgba(0,0,0,.85); }
|
||
#sceneImage{position:fixed;inset:0;width:100%;height:100%;object-fit:cover;z-index:-1;}
|
||
/* 主世界简单游戏风边框(覆盖层,不拦截鼠标,避让顶/底栏) */
|
||
#worldFrame{
|
||
position:fixed; top:calc(58px + env(safe-area-inset-top)); left:0; right:0;
|
||
bottom:calc(72px + env(safe-area-inset-bottom)); pointer-events:none; z-index:5;
|
||
border:1.5px solid rgba(139,92,246,.45);
|
||
box-shadow:inset 0 0 28px rgba(100,200,255,.16), inset 0 0 0 1px rgba(255,255,255,.06);
|
||
}
|
||
#worldFrame span{position:absolute;width:20px;height:20px;border:2px solid rgba(199,125,255,.9);box-shadow:0 0 8px rgba(199,125,255,.5);}
|
||
#worldFrame .wf-tl{top:7px;left:7px;border-right:none;border-bottom:none;}
|
||
#worldFrame .wf-tr{top:7px;right:7px;border-left:none;border-bottom:none;}
|
||
#worldFrame .wf-bl{bottom:7px;left:7px;border-right:none;border-top:none;}
|
||
#worldFrame .wf-br{bottom:7px;right:7px;border-left:none;border-top:none;}
|
||
/* 世界切换导航:横向步骤条(圆点 + 连线),hover 出气泡提示 */
|
||
#worldNav{position:fixed;top:70px;left:18px;z-index:30;
|
||
display:flex;flex-direction:row;padding:9px 10px;border-radius:16px;
|
||
background:rgba(10,16,30,.55);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,.12);}
|
||
#worldNav .wn-item{position:relative;display:flex;align-items:center;justify-content:center;
|
||
width:38px;height:38px;margin:0 8px;border-radius:50%;
|
||
color:#9fb3cc;font-size:17px;cursor:pointer;user-select:none;line-height:1;
|
||
background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.16);
|
||
transition:background-color .22s ease,color .22s ease,box-shadow .22s ease,border-color .22s ease,transform .15s ease;}
|
||
/* 连接线:除最后一项外,从本点右侧向右延伸,串起相邻点 */
|
||
#worldNav .wn-item:not(:last-child)::after{content:'';position:absolute;left:38px;top:50%;
|
||
transform:translateY(-50%);width:18px;height:2px;background:rgba(255,255,255,.20);}
|
||
#worldNav .wn-item:hover{background:rgba(139,92,246,.22);color:#eaf4ff;transform:scale(1.06);
|
||
box-shadow:0 0 0 1px rgba(139,92,246,.45) inset,0 0 14px rgba(139,92,246,.25);}
|
||
#worldNav .wn-item.active{background:linear-gradient(135deg,rgba(139,92,246,.45),rgba(74,175,255,.25));
|
||
color:#fff;box-shadow:0 0 0 1px rgba(139,92,246,.55) inset,0 0 16px rgba(139,92,246,.3);}
|
||
/* 气泡提示:hover 时浮在图标下方 */
|
||
#worldNav .wn-text{position:absolute;top:calc(100% + 8px);left:50%;
|
||
transform:translateX(-50%) translateY(-6px);opacity:0;pointer-events:none;
|
||
background:rgba(10,16,30,.92);padding:5px 9px;border-radius:7px;white-space:nowrap;
|
||
font-size:12px;color:#eaf4ff;border:1px solid rgba(255,255,255,.16);
|
||
transition:opacity .2s ease,transform .2s ease;}
|
||
#worldNav .wn-item:hover .wn-text{opacity:1;transform:translateX(-50%) translateY(0);}
|
||
|
||
/* 右下角导航控件:方向 + 缩放(白色半透、大气精致) */
|
||
#navControls{display:none;}
|
||
|
||
/* ===== 登录闸门(小型居中转圈,不遮挡主屏幕) ===== */
|
||
#authSplash{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;
|
||
background:rgba(8,12,26,.45);backdrop-filter:blur(4px);color:#cdd8e6;
|
||
font-family:"Noto Sans SC",system-ui,sans-serif;font-size:13px;letter-spacing:2px;}
|
||
#authSplash .auth-spinner{
|
||
display:flex;flex-direction:column;align-items:center;gap:14px;
|
||
padding:28px 36px;border-radius:16px;
|
||
background:rgba(14,23,48,.75);border:1px solid rgba(139,92,246,.25);
|
||
box-shadow:0 8px 32px rgba(0,0,0,.5);}
|
||
#authSpinner{width:32px;height:32px;border:3px solid rgba(139,92,246,.25);
|
||
border-top-color:#a78bfa;border-radius:50%;
|
||
animation:authSpin .7s linear infinite;}
|
||
@keyframes authSpin{to{transform:rotate(360deg);}}
|
||
/* #authBar 已融合进 #topBar .userArea */
|
||
|
||
/* ===== 移动端适配 ===== */
|
||
@media (max-width:768px){
|
||
#topBar .coords{display:none;}
|
||
#topBar .title{font-size:16px;}
|
||
#topBar .title .worldName{font-size:13px;}
|
||
#topBar .title img{width:28px;height:28px;}
|
||
#bottomBar{gap:8px;padding-left:max(10px,env(safe-area-inset-left));padding-right:max(10px,env(safe-area-inset-right));}
|
||
#bottomBar .bb-btn{padding:8px 11px;font-size:12px;}
|
||
#navControls{bottom:calc(96px + env(safe-area-inset-bottom));right:14px;
|
||
grid-template-columns:repeat(3,60px);grid-template-rows:repeat(3,60px);gap:6px;}
|
||
#navControls button{width:60px;height:60px;font-size:24px;border-radius:15px;}
|
||
#sceneToolbar{right:12px;gap:7px;padding:8px 10px;}
|
||
#sceneToolbar .top-icon{width:40px;height:40px;font-size:19px;}
|
||
}
|
||
@media (max-width:420px){
|
||
#topBar .title .worldName{display:none;}
|
||
#bottomBar .bb-btn{padding:7px 9px;font-size:11px;}
|
||
#worldExpBarWrap{width:280px;gap:5px;}
|
||
#worldExpBarWrap .we-title{display:none;}
|
||
#sceneToolbar{right:8px;gap:5px;padding:6px 7px;top:calc(58px + env(safe-area-inset-top) + 6px);}
|
||
#sceneToolbar .top-icon{width:34px;height:34px;font-size:16px;}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="authSplash"><div class="auth-spinner"><div id="authSpinner"></div><span>登录校验中…</span></div></div>
|
||
|
||
<!-- 加载界面 -->
|
||
<div id="loadingScreen">
|
||
<img class="loadBg" id="loadBg" alt="">
|
||
<div class="loadTip" id="loadTip"></div>
|
||
<div class="loadLine">
|
||
<div class="loadBar"><div class="loadFill" id="loadFill"></div></div>
|
||
<div class="loadPct" id="loadPct">0%</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主视图 -->
|
||
<canvas id="gameCanvas"></canvas>
|
||
<div id="worldFrame"><span class="wf-tl"></span><span class="wf-tr"></span><span class="wf-bl"></span><span class="wf-br"></span></div>
|
||
|
||
<div id="topBar">
|
||
<span class="title"><span class="brand">宇森</span><span class="worldName editable" id="worldName" title="点击编辑世界名称">加载中…</span></span>
|
||
<div class="topRight">
|
||
<span class="coords" id="coords">坐标: (0, 0)</span>
|
||
<div class="timeInfo" title="世界时间">
|
||
<div id="clockLabel">清晨 07:30</div>
|
||
<div id="calendarLabel">星元 1年 · 春</div>
|
||
</div>
|
||
<div class="userArea" id="userArea" hidden>
|
||
<div class="ua-trigger" id="uaTrigger">
|
||
<span class="ua-avatar">●</span>
|
||
<span class="ua-name" id="uaName">用户</span>
|
||
</div>
|
||
<div class="ua-menu" id="uaMenu" role="dialog" aria-modal="false" aria-label="玩家面板">
|
||
<!-- 头部 -->
|
||
<div class="ua-head">
|
||
<div class="ua-head-main">
|
||
<div class="ua-avatar-lg" id="uaAvatarLg">M</div>
|
||
<div class="ua-head-info">
|
||
<div class="ua-head-name" id="uaName2">用户</div>
|
||
<div class="ua-head-sub"><span class="ua-dot"></span>在线 · UID <span id="uaUid">10086001</span></div>
|
||
</div>
|
||
</div>
|
||
<button class="ua-close" id="uaClose" type="button" aria-label="收起面板">✕</button>
|
||
</div>
|
||
|
||
<div class="ua-scroll">
|
||
<!-- 缔造者等级卡(数据从 me() 动态填充) -->
|
||
<section class="ua-card">
|
||
<div class="ua-card-top">
|
||
<span class="ua-lv">Lv.<b id="uaLv">--</b></span>
|
||
<span class="ua-title" id="uaTitle">--</span>
|
||
</div>
|
||
<div class="ua-exp"><div class="ua-exp-fill" id="uaExpBar" style="width:0%"></div></div>
|
||
<div class="ua-exp-txt" id="uaExpTxt">EXP -- / --</div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<div class="ua-foot">
|
||
<button class="ua-item ua-item--danger" id="uaLogout" type="button">🚪 退出登录</button>
|
||
</div>
|
||
</div>
|
||
<div id="uaBackdrop"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 场景悬浮工具条:顶部栏正下方、浮于场景画面上 -->
|
||
<div id="sceneToolbar">
|
||
<button class="top-icon" id="btnTask" type="button" title="任务">📜</button>
|
||
<button class="top-icon" id="btnMall" type="button" title="商城">🛒</button>
|
||
<button class="top-icon" id="btnMail" type="button" title="邮箱"><span class="ti-label">✉️</span><span class="top-icon-badge" id="topMailBadge" style="display:none;">0</span></button>
|
||
<button class="top-icon" id="btnSettings" type="button" title="设置">⚙️</button>
|
||
<button class="top-icon" id="btnWebsite" type="button" title="官网">🌐</button>
|
||
<button class="top-icon" id="btnNotice" type="button" title="公告">📢</button>
|
||
<button class="top-icon" id="btnRank" type="button" title="排行榜">🏅</button>
|
||
<button class="top-icon" id="btnCalendar" type="button" title="日历">📅</button>
|
||
<button class="top-icon" id="btnAuction" type="button" title="拍卖行">🔨</button>
|
||
</div>
|
||
|
||
<!-- 主沙盘底部功能框架(世界等级经验条置于最右侧) -->
|
||
<div id="bottomBar">
|
||
<div class="bb-slots">
|
||
<button class="bb-btn" id="btnData" type="button">📊 数据</button>
|
||
<button class="bb-btn" id="btnInventory" type="button">🎒 包裹</button>
|
||
<button class="bb-btn" id="btnTerrain" type="button">🌿 地形</button>
|
||
<button class="bb-btn" id="btnMove" type="button">✋ 移动</button>
|
||
<button class="bb-btn" id="btnHeight" type="button">⛰️ 高度</button>
|
||
<button class="bb-btn" id="btnManage" type="button" style="display:none;">⚙️ 管理</button>
|
||
<button class="bb-btn" id="btnTech" type="button">🔬 科技</button>
|
||
<button class="bb-btn" id="btnFriends" type="button">👥 好友<span class="bb-badge" id="friendBadge" hidden>0</span></button>
|
||
<button class="bb-btn" id="btnAchievements" type="button">🏆 成就<span class="bb-badge" id="achBadge" hidden></span></button>
|
||
</div>
|
||
<div id="worldExpBarWrap">
|
||
<div class="we-info">
|
||
<span class="we-lv">世界 Lv.<b id="weLv">--</b></span>
|
||
<span class="we-title" id="weTitle">--</span>
|
||
<span class="we-exp-txt" id="weExpTxt">EXP -- / --</span>
|
||
<span class="we-pct" id="weExpPct">--%</span>
|
||
</div>
|
||
<div class="we-bar"><div class="we-fill" id="weExpFill" style="width:0%"></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 世界切换导航(五层:星空 / 天空 / 大地 / 地下 / 地心) -->
|
||
<div id="worldNav">
|
||
<div class="wn-item" data-world="star"><span class="wn-icon">✦</span><span class="wn-text">星空</span></div>
|
||
<div class="wn-item" data-world="sky"><span class="wn-icon">☁</span><span class="wn-text">天空</span></div>
|
||
<div class="wn-item active" data-world="earth"><span class="wn-icon">◈</span><span class="wn-text">大地</span></div>
|
||
<div class="wn-item" data-world="under"><span class="wn-icon">◎</span><span class="wn-text">地下</span></div>
|
||
<div class="wn-item" data-world="core"><span class="wn-icon">◆</span><span class="wn-text">地心</span></div>
|
||
</div>
|
||
|
||
<!-- 世界管理已并入 buildPanel 的"管理" tab -->
|
||
<style>
|
||
/* 复用 .mp-row 系列样式给 buildPanel 内的管理区(滑杆行) */
|
||
.mp-row{display:flex;align-items:center;gap:12px;}
|
||
.mp-swatch{width:18px;height:18px;border-radius:4px;flex:0 0 auto;box-shadow:0 0 0 1px rgba(255,255,255,.18) inset;}
|
||
.mp-name{width:44px;flex:0 0 auto;color:#cdd8e6;font-size:14px;}
|
||
.mp-row input[type=range]{flex:1;accent-color:#8b5cf6;height:24px;cursor:pointer;}
|
||
.mp-val{width:40px;text-align:right;color:#bcd;font-variant-numeric:tabular-nums;font-size:14px;font-weight:700;}
|
||
</style>
|
||
<!-- 场景背景图层:五层世界各一张静态背景图 -->
|
||
<img id="sceneImage" style="display:none;" alt="">
|
||
|
||
|
||
<div id="dataPanel" style="display:none;">
|
||
<div id="dpHeader"><span id="dpTitle">面板</span><button id="dpClose">关闭</button></div>
|
||
<div id="dpBody"></div>
|
||
</div>
|
||
|
||
|
||
<!-- 飞入过渡 -->
|
||
<div id="flyInOverlay">
|
||
<img class="flyBg" id="flyBg" alt="">
|
||
<div class="flyTip" id="flyTip">💡 拖拽空白处可平移视角,滚轮缩放沙盘</div>
|
||
<div class="flyLine">
|
||
<div class="flyBar"><div class="flyFill" id="flyFill"></div></div>
|
||
<div class="flyPct" id="flyPct">0%</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 微观世界视图 -->
|
||
<div id="microView">
|
||
<div id="microHeader">
|
||
<button class="backBtn" id="backBtn">← 返回世界</button>
|
||
<span class="microTitle" id="microTitle">平原村落</span>
|
||
<span class="microSubtitle" id="microSubtitle">微观世界视角</span>
|
||
<div id="charHud" style="display:none;margin-left:auto;min-width:200px;padding:6px 12px;background:rgba(0,0,0,.35);border:1px solid rgba(100,200,255,.3);border-radius:10px;font-size:13px;color:#e0e8f2;"></div>
|
||
</div>
|
||
<div id="microBody">
|
||
<div id="microTopBar">
|
||
<div id="microBuildTips" hidden>
|
||
<span class="mv-tip-icon">💡</span>
|
||
<span id="microBuildTipText"></span>
|
||
</div>
|
||
</div>
|
||
<div id="microStage">
|
||
<canvas id="microCanvas"></canvas>
|
||
<div id="microSidebar">
|
||
<div class="panel-section">
|
||
<div class="panel-label">地块信息</div>
|
||
<div class="panel-value" id="detailTerrain">🟢 平原</div>
|
||
</div>
|
||
<div class="panel-section">
|
||
<div class="panel-label">建筑</div>
|
||
<div id="detailBuilding">无</div>
|
||
</div>
|
||
<div class="panel-section">
|
||
<div class="panel-label">资源产出</div>
|
||
<div id="detailResources"></div>
|
||
</div>
|
||
<div class="panel-section">
|
||
<div class="panel-label">人口</div>
|
||
<div id="detailPopulation"></div>
|
||
</div>
|
||
<div class="panel-section">
|
||
<div class="panel-label">角色</div>
|
||
<div id="charPlacePanel" style="margin-bottom:8px;"></div>
|
||
<div id="charPanel" style="display:none;padding:8px;background:rgba(100,170,255,0.06);border:1px solid rgba(100,170,255,0.18);border-radius:6px;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="microBottomBar">
|
||
<div id="microBuildPanel">
|
||
<div class="mv-cats-row">
|
||
<div id="microCats"></div>
|
||
<div class="mv-cats-actions">
|
||
<button id="microClearBuilds" type="button" class="mv-clear">🗑 清空当前地块布置</button>
|
||
<input id="microSearchBuild" type="search" class="mv-search" placeholder="搜索..." autocomplete="off">
|
||
</div>
|
||
</div>
|
||
<div id="microGrid9"></div>
|
||
</div>
|
||
<div class="mv-bottom-actions">
|
||
<button class="mv-tool" id="microBuildBtn" title="建造模式:左键放置 / 右键删除 / 拖拽平移">🔨 建造</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 背景切换面板 -->
|
||
<style>
|
||
#btnBuild{background:rgba(100,200,255,.12);border:1px solid rgba(100,200,255,.3);color:#9fd;padding:9px 14px;border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;pointer-events:auto;transition:.2s;box-shadow:0 4px 16px rgba(80,120,255,.35);}
|
||
#btnBuild:hover{filter:brightness(1.12);}
|
||
#btnBuild.active{background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;border-color:transparent;box-shadow:0 0 14px rgba(100,170,255,.5);}
|
||
#btnTech{background:rgba(120,255,200,.12);border:1px solid rgba(120,255,200,.3);color:#9ff;padding:9px 14px;border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;pointer-events:auto;transition:.2s;box-shadow:0 4px 16px rgba(80,255,180,.3);}
|
||
#btnTech:hover{filter:brightness(1.12);}
|
||
#btnTech.active{background:linear-gradient(135deg,#10b981,#4aafff);color:#fff;border-color:transparent;box-shadow:0 0 14px rgba(100,220,180,.5);}
|
||
.bb-btn.active{outline:2px solid rgba(255,255,255,.75);box-shadow:0 0 18px rgba(140,180,255,.6);}
|
||
|
||
/* ===== 统一左侧面板(数据 / 包裹 / 建造 三合一) ===== */
|
||
#sidePanel{
|
||
position:fixed;top:58px;left:0;bottom:72px;width:420px;z-index:150;
|
||
background:linear-gradient(180deg,rgba(10,14,23,.97),rgba(14,22,40,.97));
|
||
border-right:1px solid rgba(100,200,255,.22);
|
||
display:flex;flex-direction:column;
|
||
transform:translateX(-100%);transition:transform .18s ease-out;
|
||
box-shadow:8px 0 40px rgba(0,0,0,.45);
|
||
will-change:transform;backface-visibility:hidden;
|
||
}
|
||
#sidePanel.open{transform:translateX(0);}
|
||
.sp-body{flex:1;overflow-y:auto;overflow-x:hidden;padding:14px 16px;display:none;min-height:0;}
|
||
.sp-body.active{display:block;}
|
||
.sp-body::-webkit-scrollbar{width:4px;}
|
||
.sp-body::-webkit-scrollbar-thumb{background:rgba(100,200,255,.28);border-radius:2px;}
|
||
|
||
/* 侧栏展开时浮于主世界之上,主画布保持原位不平移(不再挤压) */
|
||
#gameCanvas{transition:transform .28s ease;}
|
||
|
||
/* ----- 包裹:7列 × 行(物资分类 + 小格子 + 滚动条) ----- */
|
||
.inv-cat{font-size:11px;color:#8b5cf6;letter-spacing:1.5px;text-transform:uppercase;margin:14px 0 6px;
|
||
padding-bottom:4px;border-bottom:1px solid rgba(139,92,246,.2);}
|
||
.inv-cat:first-child{margin-top:0;}
|
||
.inv-grid-7{display:grid;grid-template-columns:repeat(7,1fr);gap:3px;}
|
||
.inv-slot{
|
||
aspect-ratio:1;border-radius:5px;display:flex;align-items:center;justify-content:center;
|
||
font-size:15px;color:#56708c;background:rgba(255,255,255,.04);
|
||
border:1px dashed rgba(120,200,255,.18);cursor:pointer;transition:.15s;position:relative;
|
||
}
|
||
.inv-slot:hover{border-color:rgba(120,200,255,.5);background:rgba(120,200,255,.08);color:#cfe6ff;}
|
||
.inv-slot.has-item{border-style:solid;border-color:rgba(120,200,255,.25);background:rgba(120,200,255,.06);}
|
||
.inv-slot .inv-ct{position:absolute;bottom:1px;right:2px;font-size:8px;color:#8aa0b8;line-height:1;
|
||
background:rgba(0,0,0,.45);padding:0 3px;border-radius:3px;}
|
||
|
||
/* ----- 建造(嵌入侧面板,保留4子Tab) ----- */
|
||
#buildPanel{position:static;width:auto;max-height:none;overflow:visible;
|
||
background:transparent;border:none;border-radius:0;box-shadow:none;padding:0;
|
||
display:none;height:100%;flex-direction:column;}
|
||
#buildPanel.active{display:flex;}
|
||
.bp-head{margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid rgba(100,200,255,.12);}
|
||
.bp-tools{gap:4px;margin-bottom:10px;}
|
||
.bp-tool{padding:7px 2px;font-size:12px;}
|
||
.bp-brush{gap:4px;min-height:32px;}
|
||
.bp-chip{padding:6px 9px;font-size:12px;}
|
||
.bp-hint{font-size:13px;margin-bottom:10px;}
|
||
.bp-hctl{margin-bottom:10px;}
|
||
.bp-hctl-row{font-size:13px;}
|
||
.bp-flatten,.bp-manage{padding:9px;font-size:13px;}
|
||
.bp-mgmt{gap:10px;margin-bottom:10px;}
|
||
.bp-variants{max-height:160px;grid-template-columns:repeat(4,1fr);}
|
||
.bp-head{display:flex;justify-content:space-between;align-items:center;font-weight:700;font-size:17px;margin-bottom:14px;}
|
||
#bpClose{background:none;border:none;color:#9fb0c4;font-size:22px;cursor:pointer;}
|
||
#bpClose:hover{color:#fff;}
|
||
.bp-tools{display:flex;gap:8px;margin-bottom:14px;}
|
||
.bp-tool{flex:1;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.12);color:#dfe8f2;border-radius:10px;padding:10px 4px;font-size:14px;cursor:pointer;transition:.2s;}
|
||
.bp-tool:hover{background:rgba(255,255,255,.12);}
|
||
.bp-tool.active{background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;border-color:transparent;}
|
||
.bp-brush{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;min-height:44px;}
|
||
.bp-chip{border:1px solid rgba(255,255,255,.18);border-radius:10px;padding:9px 12px;font-size:14px;color:#fff;cursor:pointer;display:flex;align-items:center;gap:6px;text-shadow:0 1px 2px rgba(0,0,0,.6);transition:.2s;}
|
||
.bp-chip:hover{filter:brightness(1.12);}
|
||
.bp-chip.active{outline:2px solid #ffd54f;outline-offset:2px;}
|
||
.bp-variants{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:12px;max-height:200px;overflow-y:auto;padding-right:4px;}
|
||
.bp-variant{position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;cursor:pointer;border:2px solid transparent;background:#222;padding:0;}
|
||
.bp-variant img{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;}
|
||
.bp-variant.active{border-color:#ffd54f;box-shadow:0 0 8px rgba(255,213,79,.5);}
|
||
.bp-variant.rand{grid-column:1 / -1;display:flex;align-items:center;justify-content:center;aspect-ratio:auto;padding:8px;font-size:14px;color:#dfe8f2;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);}
|
||
.bp-variant.rand.active{background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;border-color:transparent;}
|
||
.bp-hint{font-size:17px;color:#cdd9e8;line-height:1.6;margin-bottom:14px;}
|
||
.bp-hctl{margin-bottom:14px;}
|
||
.bp-hctl-row{display:flex;align-items:center;gap:10px;font-size:15px;color:#dfe8f2;}
|
||
.bp-hctl-row input[type=range]{flex:1;accent-color:#8b5cf6;height:26px;cursor:pointer;}
|
||
#bpHeightVal{min-width:90px;text-align:right;color:#ffd54f;font-weight:700;}
|
||
.bp-flatten{width:100%;margin-top:10px;background:rgba(138,92,246,.18);border:1px solid rgba(138,92,246,.45);color:#d9c8ff;border-radius:10px;padding:12px;font-weight:700;cursor:pointer;font-size:15px;transition:.2s;}
|
||
.bp-flatten:hover{background:rgba(138,92,246,.32);}
|
||
.bp-manage{width:100%;margin-top:10px;background:rgba(74,175,255,.15);border:1px solid rgba(74,175,255,.45);color:#aecfff;border-radius:10px;padding:12px;font-weight:700;cursor:pointer;font-size:15px;transition:.2s;}
|
||
.bp-manage:hover{background:rgba(74,175,255,.3);}
|
||
.bp-mgmt{display:flex;flex-direction:column;gap:14px;margin-bottom:14px;}
|
||
.bp-mgmt-body{display:flex;flex-direction:column;gap:14px;}
|
||
.bp-mgmt-foot{display:flex;flex-direction:column;gap:12px;padding-top:14px;border-top:1px solid rgba(255,255,255,.08);}
|
||
.bp-mgmt-presets{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font-size:14px;color:#9fb0c4;}
|
||
.bp-mgmt-presets span{flex:0 0 auto;}
|
||
.bp-mgmt-presets button{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.12);color:#dfe8f2;border-radius:8px;padding:6px 12px;font-size:14px;cursor:pointer;transition:.2s;}
|
||
.bp-mgmt-presets button:hover{background:rgba(139,92,246,.3);}
|
||
#bpMgmtApply{background:linear-gradient(135deg,#8b5cf6,#4aafff);border:none;color:#fff;border-radius:11px;padding:13px;font-weight:700;cursor:pointer;font-size:15px;transition:.2s;}
|
||
#bpMgmtApply:hover{filter:brightness(1.08);}
|
||
|
||
/* 包裹面板(复用 buildPanel 弹窗风格,从左侧滑出) */
|
||
#inventoryPanel{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);}
|
||
#inventoryPanel.hidden{display:none;}
|
||
.inv-hint{font-size:12px;color:#8aa0b8;margin-bottom:14px;line-height:1.5;}
|
||
.demo-tag{display:inline-block;background:rgba(255,180,80,.18);border:1px solid rgba(255,180,80,.4);color:#ffcf86;font-size:11px;padding:1px 7px;border-radius:6px;margin-left:4px;}
|
||
.inv-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;}
|
||
.inv-item{background:rgba(255,255,255,.04);border:1px solid rgba(120,200,255,.18);border-radius:10px;padding:10px;display:flex;flex-direction:column;align-items:center;gap:6px;transition:.15s;}
|
||
.inv-item:hover{border-color:rgba(120,200,255,.5);background:rgba(120,200,255,.08);}
|
||
.inv-item .ico{font-size:26px;}
|
||
.inv-item .nm{font-size:12px;color:#dce6f2;text-align:center;}
|
||
.inv-item .ct{font-size:11px;color:#8aa0b8;}
|
||
.inv-item .ct.limited{color:#ffcf86;}
|
||
|
||
/* 数据面板子项 */
|
||
#dpBody .dp-section{margin-bottom:18px;}
|
||
#dpBody .dp-h{font-size:12px;color:#7f93a8;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;border-bottom:1px solid rgba(120,200,255,.15);padding-bottom:5px;}
|
||
#dpBody .dp-row{display:flex;justify-content:space-between;font-size:14px;color:#dbe6f2;padding:5px 0;border-bottom:1px dashed rgba(255,255,255,.06);}
|
||
#dpBody .dp-note{font-size:12px;color:#8aa0b8;margin-top:10px;line-height:1.5;}
|
||
#dpBody code{background:rgba(255,255,255,.08);padding:1px 5px;border-radius:4px;font-size:11px;}
|
||
|
||
/* ===== 科技树(演示 + 预留 /yt_world/api/tech) ===== */
|
||
.tech-pool{display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(16,185,129,.1);
|
||
border:1px solid rgba(16,185,129,.3);border-radius:10px;padding:9px 12px;margin-bottom:14px;}
|
||
.tech-pool .tp-label{font-size:12px;color:#9ff;letter-spacing:1px;}
|
||
.tech-pool .tp-val{font-size:20px;font-weight:800;color:#5eead4;}
|
||
.tech-pool .tp-reset{background:none;border:1px solid rgba(255,255,255,.18);color:#9fb0c4;border-radius:7px;
|
||
padding:4px 9px;font-size:11px;cursor:pointer;transition:.15s;}
|
||
.tech-pool .tp-reset:hover{color:#fff;border-color:rgba(255,120,120,.5);}
|
||
.tech-tier{margin-bottom:16px;}
|
||
.tech-tier-h{font-size:11px;color:#10b981;letter-spacing:1.5px;text-transform:uppercase;margin:0 0 8px;
|
||
padding-bottom:4px;border-bottom:1px solid rgba(16,185,129,.22);}
|
||
.tech-nodes{display:flex;flex-wrap:wrap;gap:8px;}
|
||
.tech-node{position:relative;width:calc(50% - 4px);box-sizing:border-box;background:rgba(255,255,255,.04);
|
||
border:1px solid rgba(120,255,200,.18);border-radius:11px;padding:10px 11px;cursor:default;transition:.18s;}
|
||
.tech-node:hover{border-color:rgba(120,255,200,.45);background:rgba(120,255,200,.07);}
|
||
.tech-node .tn-top{display:flex;align-items:center;gap:7px;margin-bottom:5px;}
|
||
.tech-node .tn-ico{font-size:20px;}
|
||
.tech-node .tn-name{font-size:13px;font-weight:700;color:#e8f4ef;}
|
||
.tech-node .tn-desc{font-size:11px;color:#9fb0c4;line-height:1.45;margin-bottom:6px;min-height:30px;}
|
||
.tech-node .tn-pre{font-size:10px;color:#7f93a8;margin-bottom:7px;}
|
||
.tech-node .tn-pre b{color:#ffb454;font-weight:600;}
|
||
.tech-node .tn-pre .met{color:#5eead4;}
|
||
.tech-node .tn-cost{font-size:11px;color:#9ff;font-weight:700;}
|
||
.tech-node .tn-btn{width:100%;margin-top:7px;border:none;border-radius:8px;padding:7px;font-size:12px;font-weight:700;
|
||
cursor:pointer;transition:.15s;background:rgba(16,185,129,.18);color:#5eead4;border:1px solid rgba(16,185,129,.4);}
|
||
.tech-node .tn-btn:hover{filter:brightness(1.15);}
|
||
.tech-node.locked{opacity:.5;}
|
||
.tech-node.locked .tn-btn{background:rgba(255,255,255,.05);color:#7f93a8;border-color:rgba(255,255,255,.12);cursor:not-allowed;}
|
||
.tech-node.done{background:rgba(16,185,129,.14);border-color:rgba(16,185,129,.55);}
|
||
.tech-node.done .tn-btn{background:rgba(16,185,129,.28);color:#d6fff2;cursor:default;}
|
||
.tech-node.afford .tn-btn{background:linear-gradient(135deg,#10b981,#4aafff);color:#fff;border-color:transparent;
|
||
box-shadow:0 0 10px rgba(16,185,129,.35);}
|
||
.tech-note{font-size:11px;color:#8aa0b8;margin-top:8px;line-height:1.5;}
|
||
</style>
|
||
<!-- ===== 统一左侧面板(数据 / 包裹 / 建造 / 科技,无顶部标签栏) ===== -->
|
||
<div id="sidePanel">
|
||
<!-- 数据 Tab -->
|
||
<div class="sp-body" id="spDataBody"></div>
|
||
<!-- 包裹 Tab(7×50 格 + 物资分类) -->
|
||
<div class="sp-body" id="spInvBody">
|
||
<div class="inv-cat">笔刷 · Brush</div>
|
||
<div class="inv-grid-7" id="invGridBrush"></div>
|
||
<div class="inv-cat">模板 · Template</div>
|
||
<div class="inv-grid-7" id="invGridTemplate"></div>
|
||
<div class="inv-cat">事件卡 · Event</div>
|
||
<div class="inv-grid-7" id="invGridEvent"></div>
|
||
</div>
|
||
<!-- 建造 Tab(4 子Tab:移动/地形/高度/管理) -->
|
||
<div class="sp-body" id="spBuildBody">
|
||
<div id="buildPanel">
|
||
<div class="bp-brush" id="bpBrush"></div>
|
||
<div class="bp-hctl" id="bpHeightControl" style="display:none;">
|
||
<div class="bp-hctl-row">
|
||
<span>高度</span>
|
||
<input type="range" id="bpHeightSlider" min="1" max="12" step="1" value="3">
|
||
<span id="bpHeightVal">3 · 森林</span>
|
||
</div>
|
||
<button id="bpFlatten" class="bp-flatten">⤓ 平铺到最低高度</button>
|
||
</div>
|
||
<div class="bp-mgmt" id="bpMgmt" style="display:none;">
|
||
<div class="bp-mgmt-body" id="bpMgmtBody"></div>
|
||
<div class="bp-mgmt-foot">
|
||
<div class="bp-mgmt-presets">
|
||
<span>预设</span>
|
||
<button data-preset="default">默认</button>
|
||
<button data-preset="island">小岛</button>
|
||
<button data-preset="peaks">雪峰</button>
|
||
<button data-preset="water">水乡</button>
|
||
</div>
|
||
<button id="bpMgmtApply">应用</button>
|
||
</div>
|
||
</div>
|
||
<div class="bp-hint" id="bpHint"></div>
|
||
</div>
|
||
</div>
|
||
<!-- 科技树 Tab -->
|
||
<div class="sp-body" id="spTechBody"></div>
|
||
</div>
|
||
|
||
<!-- 单位详情弹窗 -->
|
||
<div id="unitModal">
|
||
<div class="modalContent">
|
||
<div class="modalTitle" id="modalTitle">👤 单位详情</div>
|
||
<div id="modalBody"></div>
|
||
<button class="closeBtn" onclick="closeUnitModal()">关闭</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 通用信息弹窗(个人信息/设置/邮件) -->
|
||
<div id="appModal">
|
||
<div class="modalContent">
|
||
<div class="modalTitle" id="appModalTitle">标题</div>
|
||
<div class="modalBody" id="appModalBody"></div>
|
||
<button class="closeBtn" onclick="closeAppModal()">关闭</button>
|
||
</div>
|
||
</div>
|
||
|
||
<audio id="bgmAudio" src="assets/audio/bgm.mp3" loop preload="auto" style="display:none;"></audio>
|
||
|
||
<script>
|
||
// ============ 配置 ============
|
||
const MAP_RADIUS = 2; // 主世界六边形半径:比原始(3)缩小一圈,最外层锁定为边界环
|
||
// 轴向(q,r)对应的六边形环号(到中心距离),用于判定是否在 MAP_RADIUS 内
|
||
function hexRing(q, r) { return Math.max(Math.abs(q), Math.abs(r), Math.abs(-q - r)); }
|
||
const POLY_SIZE = 36; // 六边形基础尺寸(屏幕 px @ zoom=1),原 52 缩小使网格更紧凑
|
||
const SIDES = 6; // 正六边形
|
||
let currentLayer = 'earth';
|
||
// 顶栏 / 浏览器标签显示的层名(与 worldNav 的 data-world 对应)
|
||
const WORLD_NAMES = { earth: '大地', under: '地下', core: '地心', sky: '天空', star: '星空' };
|
||
|
||
// 视觉增强:地块高程 / 分层背景(A+B+C+D+E 原型)
|
||
const SANDBOX_DEPTH = 16; // 沙盘底板厚度(屏幕 px,zoom=1)—— 适度加厚,块有"实体感"但不显重
|
||
// 高程改为 5 个离散档(-2/-1/0/1/2),档间高度差固定(用户要求:平衡、去掉连续噪声的"不平衡"感)
|
||
const TIER_STEP = 4; // 固定档间高度差(屏幕 px @ zoom=1):顶边齐平,落差靠侧壁厚度体现,清晰但不抢眼
|
||
const TERRAIN_TIER = { // 各地貌对应档位(-2..2)
|
||
mountain: 2, snow: 1, forest: 0, plain: 0, desert: 0, water: -1
|
||
};
|
||
|
||
// ===== 高度驱动地形:捏高度 → 自动映射地块/景色/墙高(沙盘重构核心) =====
|
||
// 玩家只操作 height 一个维度,terrain / tier / variant 全部由 height 派生
|
||
const MAX_HEIGHT = 12; // 高度层数上限(已放开,原 6)
|
||
const MIN_HEIGHT = 1; // 高度下限:从 1 开始(取消 0 档,最低档=水)
|
||
let editingHeight = false; // 当前是否处于"捏高度"笔触中
|
||
let heightBrush = 1; // 本次笔触方向:+1 升 / -1 降
|
||
const heightStroke = new Set(); // 本次笔触已处理过的格,避免拖过即爆
|
||
let heightTarget = 3; // 滑块目标高度(绝对):拖动滑块即把悬停格设为该高度
|
||
function heightLabel(h) { return h + ' · ' + TERRAIN[heightToTerrain(h)].name; }
|
||
|
||
// height → 地块类型:水位线 1 为水(最低档),越高依次为荒漠/平原/森林/山地/雪地
|
||
function heightToTerrain(h) {
|
||
if (h <= 1) return 'water'; // 1(最低档)
|
||
if (h <= 3) return 'desert'; // 2-3
|
||
if (h <= 5) return 'plain'; // 4-5
|
||
if (h <= 7) return 'forest'; // 6-7
|
||
if (h <= 10) return 'mountain'; // 8-10
|
||
return 'snow'; // 11-12
|
||
}
|
||
// height → 视觉档位:随高度线性递增,捏高即变立体(与 TERRAIN_TIER 解耦,更直观)
|
||
function heightToTier(h) { return h - 1; } // h1→0(水最低) … h12→11(雪最高)
|
||
// 地形 → 初始高度:让随机生成的地图 height 与现有地形大致对应,作为编辑起点
|
||
function terrainToHeight(t) {
|
||
return ({ water: 1, desert: 2, plain: 4, forest: 6, mountain: 8, snow: 11 })[t] || 1;
|
||
}
|
||
// 设定某格高度并派生 terrain/tier/variant(高度是唯一真值,其余查表)
|
||
// randomizeVariant=true:玩家改高度时随机选一张对应地形贴图(存为确定下标,避免逐帧闪烁)
|
||
function setHeight(tile, h, randomizeVariant) {
|
||
if (tile.locked) return; // 锁定边界环:未激活前不可改高度
|
||
h = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, h));
|
||
tile.height = h;
|
||
tile.terrain = heightToTerrain(h);
|
||
tile.tier = heightToTier(h);
|
||
if (randomizeVariant) {
|
||
const raw = terrainTex[tile.terrain];
|
||
const valid = [];
|
||
if (Array.isArray(raw)) {
|
||
for (let k = 0; k < raw.length; k++) {
|
||
if (raw[k] && raw[k].complete && raw[k].naturalWidth) valid.push(k);
|
||
}
|
||
}
|
||
tile.variant = valid.length ? valid[Math.floor(Math.random() * valid.length)] : -1;
|
||
} else {
|
||
tile.variant = -1; // 初始/非玩家变更:按坐标确定性变体,保持自然成片
|
||
}
|
||
}
|
||
|
||
// 应用一次高度变更:更新 height,并实时派生 terrain/tier(每格独立,不联动周边,无坡度限制)
|
||
function applyHeight(tile, dir, skipStroke) {
|
||
if (tile.locked) return; // 锁定边界环:未激活前不可编辑
|
||
const key = tile.q + ',' + tile.r;
|
||
if (!skipStroke) { // 拖动用去重守卫;滚轮是离散单点事件,不走守卫
|
||
if (heightStroke.has(key)) return; // 同一次笔触内不重复,避免拖过即爆
|
||
heightStroke.add(key);
|
||
}
|
||
const old = (tile.height != null) ? tile.height : 0;
|
||
const target = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, old + dir));
|
||
if (target === old) return; // 已到上限/下限,无变化
|
||
setHeight(tile, target, true); // 玩家改高度→随机一张对应地形贴图
|
||
markDirty(tile.q, tile.r);
|
||
// 高度工具下,左右键/滚轮升降时实时同步滑块与标签(修复:升降时滑块不跟随)
|
||
const sl = document.getElementById('bpHeightSlider');
|
||
const lb = document.getElementById('bpHeightVal');
|
||
if (sl && lb && buildMode && buildTool === 'height') {
|
||
sl.value = String(target);
|
||
lb.textContent = heightLabel(target);
|
||
}
|
||
}
|
||
const LAYER_BG = {
|
||
surface: { top:'#0a1426', mid:'#102038', bot:'#16314f', stars:true, silhouette:true, embers:false }, // 大地兜底
|
||
earth: { top:'#0a1426', mid:'#102038', bot:'#16314f', stars:true, silhouette:true, embers:false },
|
||
sky: { top:'#1a2a4a', mid:'#3a5a8a', bot:'#86b4d8', stars:false, silhouette:false, embers:false },
|
||
star: { top:'#05060f', mid:'#0a0e1f', bot:'#101a33', stars:true, silhouette:false, embers:false },
|
||
under: { top:'#160f06', mid:'#1f1509', bot:'#2a1d0e', stars:false, silhouette:false, embers:true },
|
||
core: { top:'#2a0805', mid:'#4a1206', bot:'#5a1a06', stars:false, silhouette:false, embers:true },
|
||
};
|
||
|
||
// 五层世界(自上而下):星空 / 天空 / 大地 / 地下 / 地心
|
||
// 大地、天空按四个时间节点各配一段视频;星空、地下、地心各一段、不随时间变
|
||
// 每个世界固定一段背景视频;时间线只改色调(currentPalette.tint 叠加),不切换视频
|
||
const WORLDS = [
|
||
{ id:'star', name:'星空', icon:'✦', image:'assets/bg/bg_star.webp' },
|
||
{ id:'sky', name:'天空', icon:'🌤', image:'assets/bg/bg_sky.webp' },
|
||
{ id:'earth', name:'大地', icon:'🌍', image:'assets/bg/bg_earth.webp', sandbox:true },
|
||
{ id:'under', name:'地下', icon:'🕳', image:'assets/bg/bg_under.webp' },
|
||
{ id:'core', name:'地心', icon:'🔥', image:'assets/bg/bg_core.webp' },
|
||
];
|
||
|
||
|
||
// ============ 时间轴 / 昼夜系统(E 原型) ============
|
||
// 单天 24h 在真实秒里的推进速度(调试用,可改)
|
||
const TIME_SPEED = 1 / 3600; // 1 现实秒 = 1 游戏秒(→ 24游戏小时=24现实小时,与现实时间 1:1)
|
||
// 真实墙钟小时:直接取系统本地时刻(时+分/60+秒/3600),时钟与真实时间 100% 同步
|
||
function nowHours() {
|
||
const d = new Date();
|
||
return d.getHours() + d.getMinutes() / 60 + d.getSeconds() / 3600;
|
||
}
|
||
let timeOfDay = nowHours(); // 启动即取真实当前时刻(时分秒=系统本地时间),与真实时间同步
|
||
let timePlaying = true; // 自动推进
|
||
let useImageBg = false; // 场景背景图接入后由 loadSceneImage() 置 true
|
||
|
||
// 调色板关键帧:清晨 / 正午 / 黄昏 / 黑夜
|
||
const TIME_KEYFRAMES = [
|
||
{ h:5, label:'清晨', scene:'dawn',
|
||
sky:['#16243f','#3a4a6a','#e89a63'], ambient:'#ffd9a0', amb:0.28, sun:-Math.PI*0.78, star:0.30, light:0.90,
|
||
tint:[255,180,120], tintA:0.12 },
|
||
{ h:12, label:'正午', scene:'day',
|
||
sky:['#2a5a9a','#3f7fc0','#86bce8'], ambient:'#ffffff', amb:0.06, sun:-Math.PI/2, star:0.00, light:1.00,
|
||
tint:[180,210,255], tintA:0.05 },
|
||
{ h:18, label:'黄昏', scene:'dusk',
|
||
sky:['#2a2350','#6a3a6a','#e8703a'], ambient:'#ffb070', amb:0.32, sun:-Math.PI*0.22, star:0.18, light:0.82,
|
||
tint:[255,140,80], tintA:0.18 },
|
||
{ h:21, label:'黑夜', scene:'night',
|
||
sky:['#05060f','#0a0e1f','#101a33'], ambient:'#3a4a7a', amb:0.42, sun:-Math.PI/2, star:0.92, light:0.62,
|
||
tint:[40,70,150], tintA:0.30 },
|
||
];
|
||
|
||
// 四个时间节点的代表色(与 TIME_KEYFRAMES 顺序一致:清晨/正午/黄昏/黑夜)
|
||
// 用于时钟背景与表盘外环着色,让玩家一眼看出当前处于哪个时间节点
|
||
const NODE_RGB = [
|
||
[255, 179, 0], // 清晨:琥珀金 #FFB300
|
||
[0, 229, 255], // 正午:亮青 #00E5FF
|
||
[255, 61, 0], // 黄昏:烈焰红 #FF3D00
|
||
[61, 61, 74], // 黑夜:深灰 #3D3D4A
|
||
];
|
||
|
||
// ============ 游戏日历(纪 / 年 / 季节) ============
|
||
// 与真实秒推进的 24h 日循环解耦:每跨过一次午夜(timeOfDay 回环)记一天,
|
||
// 再由天数推出 纪·年·季节。下面数值为可调假设的 [PLACEHOLDER],按手感改。
|
||
const ERA_NAME = '星元'; // 纪名(游戏内虚构纪元)
|
||
const SEASONS = ['春', '夏', '秋', '冬']; // 四季
|
||
const SEASON_ICONS = ['🌸', '☀', '🍂', '❄']; // 季节图标(装饰)
|
||
const DAYS_PER_YEAR = 48; // 每年天数 [PLACEHOLDER]:48天=4季×12天
|
||
const DAYS_PER_SEASON = DAYS_PER_YEAR / 4; // 每季天数(自动算出,勿手改)
|
||
let gameDay = 0; // 已过去的游戏天数(跨午夜 +1)
|
||
|
||
// 当前处于哪个时间节点(返回 0~3,对应 NODE_RGB / TIME_KEYFRAMES)
|
||
function currentNodeIndex(t) {
|
||
let tt = t < TIME_KEYFRAMES[0].h ? t + 24 : t;
|
||
let i = 0;
|
||
while (i < TIME_KEYFRAMES.length - 1 && tt > TIME_KEYFRAMES[i + 1].h) i++;
|
||
return i;
|
||
}
|
||
|
||
// 由累计天数推导 纪·年·季节
|
||
function getCalendar() {
|
||
const year = Math.floor(gameDay / DAYS_PER_YEAR) + 1;
|
||
const dayOfYear = gameDay % DAYS_PER_YEAR;
|
||
const seasonIdx = Math.floor(dayOfYear / DAYS_PER_SEASON) % 4;
|
||
const dayOfSeason = (dayOfYear % DAYS_PER_SEASON) + 1;
|
||
return { year, seasonIdx, season: SEASONS[seasonIdx], seasonIcon: SEASON_ICONS[seasonIdx], dayOfSeason };
|
||
}
|
||
|
||
function hexToRgb(h){ const n = parseInt(h.replace('#',''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
|
||
function colorLerp(c1, c2, f){
|
||
const a = hexToRgb(c1), b = hexToRgb(c2);
|
||
const r = Math.round(a[0] + (b[0] - a[0]) * f);
|
||
const g = Math.round(a[1] + (b[1] - a[1]) * f);
|
||
const bl = Math.round(a[2] + (b[2] - a[2]) * f);
|
||
return `rgb(${r},${g},${bl})`;
|
||
}
|
||
function mixColor(hex, ambHex, f){ return colorLerp(hex, ambHex, f); }
|
||
|
||
// 采样当前时间对应的天空/光照调色板(含跨午夜回环)
|
||
function sampleTime(t){
|
||
let tt = t < TIME_KEYFRAMES[0].h ? t + 24 : t;
|
||
let i = 0;
|
||
while (i < TIME_KEYFRAMES.length - 1 && tt > TIME_KEYFRAMES[i + 1].h) i++;
|
||
const a = TIME_KEYFRAMES[i];
|
||
const b = (i === TIME_KEYFRAMES.length - 1) ? { ...TIME_KEYFRAMES[0], h: TIME_KEYFRAMES[0].h + 24 } : TIME_KEYFRAMES[i + 1];
|
||
const span = (b.h - a.h) || 1;
|
||
const f = Math.min(1, Math.max(0, (tt - a.h) / span));
|
||
return {
|
||
top: colorLerp(a.sky[0], b.sky[0], f),
|
||
mid: colorLerp(a.sky[1], b.sky[1], f),
|
||
bot: colorLerp(a.sky[2], b.sky[2], f),
|
||
ambient: f < 0.5 ? a.ambient : b.ambient,
|
||
amb: a.amb + (b.amb - a.amb) * f,
|
||
sun: a.sun + (b.sun - a.sun) * f,
|
||
star: a.star + (b.star - a.star) * f,
|
||
light: a.light + (b.light - a.light) * f,
|
||
tint: [ Math.round(a.tint[0] + (b.tint[0] - a.tint[0]) * f),
|
||
Math.round(a.tint[1] + (b.tint[1] - a.tint[1]) * f),
|
||
Math.round(a.tint[2] + (b.tint[2] - a.tint[2]) * f) ],
|
||
tintA: a.tintA + (b.tintA - a.tintA) * f,
|
||
scene: f < 0.5 ? a.scene : b.scene,
|
||
label: f < 0.5 ? a.label : b.label,
|
||
};
|
||
}
|
||
|
||
// 每层天空:地表走时间轴;地下/太空保持原静态基调;所有层都带时间色调(用于视频叠加,不切换视频)
|
||
function getSky(layer, t){
|
||
const tintPal = sampleTime(t); // 时间色调始终按时间算
|
||
if (layer === 'earth') return tintPal;
|
||
const bg = LAYER_BG[layer] || LAYER_BG.surface;
|
||
return { top:bg.top, mid:bg.mid, bot:bg.bot, ambient:'#ffffff', amb:0.05,
|
||
sun:-Math.PI/2, star: bg.stars ? 0.7 : 0, scene:'static', label:'', light:1.0,
|
||
tint: tintPal.tint, tintA: tintPal.tintA };
|
||
}
|
||
|
||
// 当前帧调色板(render 每帧写入,供 drawHexPrism / drawStars 读取)
|
||
let currentPalette = getSky('earth', timeOfDay);
|
||
|
||
// ============ 地形 ============
|
||
const TERRAIN = {
|
||
plain: { name: '平原', color: '#6fa45c', microBg: '#5f9150', groundColor: '#7bb368', scene: 'village', atmo: { haze: '#3f7a3a', glow: '#bfe89a', particle: 'leaf', pcol: '#cdeb8f' } },
|
||
forest: { name: '森林', color: '#4a8550', microBg: '#3c6e42', groundColor: '#589a5c', scene: 'forest', atmo: { haze: '#1f5a32', glow: '#76e0a0', particle: 'firefly', pcol: '#b6f0a0' } },
|
||
mountain: { name: '山脉', color: '#938d85', microBg: '#827c74', groundColor: '#a39c93', scene: 'mountain', atmo: { haze: '#5b5566', glow: '#c3b8e0', particle: 'sparkle', pcol: '#dcd2f0' } },
|
||
water: { name: '水域', color: '#3f86b0', microBg: '#2f6f96', groundColor: '#4f97bd', scene: 'water', atmo: { haze: '#124a7e', glow: '#5fb8e8', particle: 'bubble', pcol: '#a8e6ff' } },
|
||
desert: { name: '荒漠', color: '#d2b277', microBg: '#c0a065', groundColor: '#e0c187', scene: 'desert', atmo: { haze: '#a8803a', glow: '#ffd28a', particle: 'dust', pcol: '#ffe0a8' } },
|
||
snow: { name: '雪地', color: '#c4d6e2', microBg: '#b3c8d6', groundColor: '#d6e4ee', scene: 'snow', atmo: { haze: '#88a6bc', glow: '#dceefb', particle: 'snow', pcol: '#eaf4ff' } }
|
||
};
|
||
|
||
// ============ 沙盘地表 AI 插画贴图 + 卡通道具(无素材自动回退纯色) ============
|
||
// 素材由用户在 assets/terrain/ 下放置(命名见 terrain_asset_spec.md);缺失则 onerror→null,绘制时跳过
|
||
const terrainTex = {}; // 地形 id -> Image[] 或 Image(顶面静态贴图兜底)
|
||
const terrainVid = {}; // 地形 id -> Video(动态纹理,用户提供的动画素材)
|
||
const terrainVidReady = {};
|
||
// 地表素材清单(统一取自 assets/terrain/,按地形分类;文件名即素材库真实存在的 png)
|
||
// 顺序即变体索引:tile.variant 存该数组下标,渲染时按索引精确取图
|
||
// 运行时优先以 assets/terrain/manifest.json 为准(若存在),实现「统一从这里取值」;此处为兜底清单
|
||
let TERRAIN_ASSETS = {
|
||
plain: ['plain_field.jpg','plain_2.png','plain_4.png','plain_5.png','plain_8.png','plain_12.png','plain_13.png','plain_14.png','plain_16.png'],
|
||
forest: ['forest_2.png','forest_6.png','forest_7.png','forest_8.png','forest_9.png','forest_13.png','forest_16.png'],
|
||
mountain: ['mountain_2.png','mountain_10.png','mountain_16.png'],
|
||
water: ['water_2.png','water_3.png','water_4.png','water_5.png','water_6.png','water_7.png','water_8.png','water_9.png','water_10.png','water_11.png','water_12.png','water_13.png','water_14.png','water_15.png'],
|
||
desert: ['desert_2.png','desert_4.png','desert_7.png','desert_13.png','desert_14.png'],
|
||
snow: ['snow_2.png','snow_3.png','snow_14.png']
|
||
};
|
||
function loadTerrainAssets(onProgress){
|
||
// ---- A. 多变体动态视频(当前未启用:草地改用 PNG 图片,避免视频编码兼容性风险)----
|
||
// 如需恢复视频纹理,把地形 id 加回此对象即可(例:{ plain: [2,4,5,8,12,13,14] })
|
||
const VIDEO_VARIANTS = {};
|
||
|
||
let vidRoot = document.getElementById('vidRoot');
|
||
if (!vidRoot) {
|
||
vidRoot = document.createElement('div');
|
||
vidRoot.id = 'vidRoot';
|
||
vidRoot.style.cssText = 'position:fixed;left:0;top:0;width:180px;height:180px;opacity:0.01;pointer-events:none;z-index:-1;overflow:hidden;';
|
||
document.body.appendChild(vidRoot);
|
||
}
|
||
const kickPlay = () => Object.values(terrainVid).forEach(arr => {
|
||
if (!Array.isArray(arr)) return;
|
||
arr.forEach(v => { if (v._ready && v.readyState>=2){const p=v.play();if(p&&p.catch)p.catch(()=>{});} });
|
||
});
|
||
|
||
Object.keys(VIDEO_VARIANTS).forEach(id => {
|
||
terrainVid[id] = []; // 数组:每元素是一个独立 video(不同裁切区域=不同草地质感)
|
||
terrainVidReady[id] = 0; // 已就绪的视频数
|
||
const variants = VIDEO_VARIANTS[id];
|
||
variants.forEach(idx => {
|
||
const v = document.createElement('video');
|
||
v.muted = true; v.loop = true; v.playsInline = true; v.preload = 'auto'; v.autoplay = true;
|
||
v.setAttribute('playsinline',''); v.setAttribute('webkit-playsinline','');
|
||
v.src = 'assets/terrain/' + id + '_' + idx + '.mp4';
|
||
v._ready = false;
|
||
v.onerror = () => { v._ready = false; };
|
||
v.oncanplaythrough = () => {
|
||
v._ready = true;
|
||
terrainVidReady[id]++;
|
||
const p = v.play(); if (p && p.catch) p.catch(() => {}); // autoplay policy 兜底
|
||
};
|
||
vidRoot.appendChild(v);
|
||
terrainVid[id].push(v);
|
||
});
|
||
});
|
||
// 兜底:即便 muted 自动播放被浏览器策略拦,用户首次点击/按键时补播
|
||
const onceKick = () => { kickPlay(); window.removeEventListener('pointerdown', onceKick); window.removeEventListener('keydown', onceKick); };
|
||
window.addEventListener('pointerdown', onceKick);
|
||
window.addEventListener('keydown', onceKick);
|
||
// ---- B. 静态 PNG 贴图:先以 manifest.json 为权威清单(单次加载,杜绝二次加载闪烁),全部 decode 后 resolve ----
|
||
return fetch('assets/terrain/manifest.json')
|
||
.then(r => (r && r.ok) ? r.json() : null)
|
||
.catch(() => null)
|
||
.then(manifest => {
|
||
if (manifest && typeof manifest === 'object') {
|
||
const next = {};
|
||
Object.keys(TERRAIN).forEach(id => { if (Array.isArray(manifest[id])) next[id] = manifest[id]; });
|
||
if (Object.keys(next).length) TERRAIN_ASSETS = Object.assign({}, TERRAIN_ASSETS, next);
|
||
}
|
||
const all = [];
|
||
Object.keys(TERRAIN_ASSETS).forEach(id => {
|
||
terrainTex[id] = [];
|
||
TERRAIN_ASSETS[id].forEach(fname => {
|
||
const im = new Image();
|
||
im.onerror = () => {};
|
||
im.src = 'assets/terrain/' + fname;
|
||
terrainTex[id].push(im);
|
||
all.push(im);
|
||
});
|
||
});
|
||
let done = 0; const total = all.length;
|
||
return new Promise(resolve => {
|
||
if (!total) { resolve(); return; }
|
||
const tick = () => {
|
||
done++;
|
||
if (onProgress) onProgress(done / total);
|
||
if (done >= total) resolve();
|
||
};
|
||
all.forEach(im => {
|
||
const fin = () => { if (im._fin) return; im._fin = true; tick(); };
|
||
if (im.complete && im.naturalWidth) { fin(); return; }
|
||
im.onload = fin;
|
||
im.onerror = fin;
|
||
if (im.decode) { try { im.decode().then(fin, fin); } catch (e) { fin(); } }
|
||
});
|
||
});
|
||
});
|
||
}
|
||
// 从多变体中按地块坐标确定性选取一张(仅限已成功加载的变体,被删除/404 的自动跳过,杜绝空格子)
|
||
function pickVariant(texArr, q, r){
|
||
if (!texArr || !texArr.length) return null;
|
||
const valid = [];
|
||
for (let k = 0; k < texArr.length; k++) {
|
||
const im = texArr[k];
|
||
if (im && im.complete && im.naturalWidth) valid.push(im);
|
||
}
|
||
if (!valid.length) return null;
|
||
const idx = Math.floor(_vnoise(q, r, TERRAIN_NOISE_SCALE) * valid.length) % valid.length;
|
||
return valid[idx];
|
||
}
|
||
// 与 pickVariant 同逻辑,但返回「已加载变体的数组下标」(用于把玩家选定的素材固定到 tile.variant)
|
||
function pickVariantIndex(texArr, q, r, h){
|
||
if (!texArr || !texArr.length) return -1;
|
||
const valid = [];
|
||
for (let k = 0; k < texArr.length; k++) {
|
||
const im = texArr[k];
|
||
if (im && im.complete && im.naturalWidth) valid.push(k);
|
||
}
|
||
if (!valid.length) return -1;
|
||
// 把高度掺入确定性噪声:同格不同高度→落到不同变体图,升降时贴图明显切换(不闪、每高度稳定)
|
||
const n = _vnoise(q, r + (h || 0) * 3.137, TERRAIN_NOISE_SCALE);
|
||
const idx = Math.floor(n * valid.length) % valid.length;
|
||
return valid[idx];
|
||
}
|
||
// 全局光影统一层:顶面与侧壁共用,使所有地表(含动态视频)随晨昏夜同步变暗/染色,归为一个世界
|
||
function applyTerrainLighting(x, y, w, h) {
|
||
const L = (currentPalette && currentPalette.light) || 1.0; // 0.62(夜)..1.0(正午)
|
||
const ambHex = (currentPalette && currentPalette.ambient) || '#ffffff';
|
||
const ambA = (currentPalette && currentPalette.amb) || 0.1;
|
||
const L_FLOOR = 0.90; // 夜部最亮保底(原 0.78 偏暗,提亮到 0.90)
|
||
const gv = Math.round(255 * Math.max(L_FLOOR, L));
|
||
ctx.globalCompositeOperation = 'multiply';
|
||
ctx.fillStyle = `rgb(${gv},${gv},${gv})`;
|
||
ctx.fillRect(x, y, w, h);
|
||
ctx.globalCompositeOperation = 'overlay';
|
||
const GRADE_A = 0.40; // 染色强度(原 0.55 偏重,降到 0.40 减少压暗)
|
||
ctx.globalAlpha = Math.min(0.7, ambA * GRADE_A * 2.0);
|
||
ctx.fillStyle = ambHex;
|
||
ctx.fillRect(x, y, w, h);
|
||
ctx.globalAlpha = 1;
|
||
ctx.globalCompositeOperation = 'source-over';
|
||
}
|
||
// 解析某格地形纹理源(动态视频优先,静态 PNG 兜底);顶面/侧壁共用,避免重复逻辑
|
||
function resolveTerrainSrc(tile) {
|
||
if (!tile || !tile.terrain) return null;
|
||
const vArr = terrainVid[tile.terrain];
|
||
if (Array.isArray(vArr) && vArr.length > 0) {
|
||
const vi = Math.floor(_vnoise(tile.q, tile.r, TERRAIN_NOISE_SCALE) * vArr.length) % vArr.length;
|
||
const vid = vArr[vi];
|
||
if (vid && vid._ready && vid.readyState >= 2) return vid;
|
||
}
|
||
const raw = terrainTex[tile.terrain];
|
||
if (Array.isArray(raw) && raw.length) {
|
||
let vi = (typeof tile.variant === 'number' && tile.variant >= 0 && tile.variant < raw.length) ? tile.variant : -1;
|
||
if (vi < 0 || !(raw[vi] && raw[vi].complete && raw[vi].naturalWidth)) {
|
||
vi = pickVariantIndex(raw, tile.q, tile.r, tile.height);
|
||
}
|
||
if (vi >= 0 && raw[vi] && raw[vi].complete && raw[vi].naturalWidth) return raw[vi];
|
||
} else if (raw && raw.complete && raw.naturalWidth) {
|
||
return raw;
|
||
}
|
||
return null;
|
||
}
|
||
// 侧壁贴图:复用顶面同一张地形图(世界坐标对齐),裁剪到单个墙面 → 像一块被竖向挤出的实心地形方块
|
||
// 顶面显示六边形 crop,侧壁显示同图在它下方的延续部分;升降时整块看起来"有厚度",而非贴图薄盖
|
||
function paintWallTexture(cx, cyTop, topR, wallH, src, terrain, a, b, lit) {
|
||
const isVideo = (src && src.tagName === 'VIDEO');
|
||
const S = topR * 2;
|
||
let tw, th;
|
||
if (isVideo) { tw = src.videoWidth || 180; th = src.videoHeight || 180; }
|
||
else { tw = src.naturalWidth || 480; th = src.naturalHeight || 480; }
|
||
const tilePx = S * 1.5;
|
||
const sc = tilePx / tw;
|
||
const dw = tw * sc, dh = th * sc;
|
||
const ox = cx - dw / 2, oy = cyTop - dh / 2; // 与顶面同一世界坐标锚点 → 自然延续
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(b.x, b.y + wallH); ctx.lineTo(a.x, a.y + wallH);
|
||
ctx.closePath();
|
||
ctx.clip();
|
||
// 墙面包围盒(用绝对值,避免左向墙 b.x<a.x 时 fillRect 负宽不生效)
|
||
const minX = Math.min(a.x, b.x) - 2, minY = Math.min(a.y, b.y) - 2;
|
||
const wW = Math.max(a.x, b.x) - minX + 2, wH = (Math.max(a.y, b.y) + wallH) - minY + 2;
|
||
// 底色兜底(贴图未完全覆盖处不露背景)
|
||
ctx.fillStyle = (currentPalette && currentPalette.ambient) || '#000';
|
||
ctx.fillRect(minX, minY, wW, wH);
|
||
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high';
|
||
ctx.globalAlpha = (terrain === 'water') ? 0.92 : 0.82;
|
||
const cols = Math.ceil(S / dw) + 1, rows = Math.ceil(S / dh) + 1;
|
||
for (let r = -1; r <= rows; r++) {
|
||
for (let c = -1; c <= cols; c++) ctx.drawImage(src, ox + c * dw, oy + r * dh, dw, dh);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
// 全局晨昏夜光影(与顶面共用 applyTerrainLighting)
|
||
applyTerrainLighting(minX, minY, wW, wH);
|
||
// 方向受光:朝太阳的侧壁更亮、背光的更暗;顶→底再轻微压暗,强化立体
|
||
ctx.globalCompositeOperation = 'multiply';
|
||
const d = Math.max(-1, Math.min(1, lit));
|
||
const topM = Math.max(0.70, 0.90 + d * 0.10);
|
||
const botM = Math.max(0.60, 0.80 + d * 0.10);
|
||
const g = ctx.createLinearGradient(0, a.y, 0, a.y + wallH);
|
||
g.addColorStop(0, `rgb(${Math.round(255 * topM)},${Math.round(255 * topM)},${Math.round(255 * topM)})`);
|
||
g.addColorStop(1, `rgb(${Math.round(255 * botM)},${Math.round(255 * botM)},${Math.round(255 * botM)})`);
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(minX, minY, wW, wH);
|
||
ctx.globalCompositeOperation = 'source-over';
|
||
ctx.restore();
|
||
}
|
||
// 顶面贴图/视频纹理:裁剪进六边形;支持 Image(静态)和 Video(动态)两种源
|
||
function paintHexTexture(cx, cyTop, topR, src, terrain){
|
||
const isVideo = (src && src.tagName === 'VIDEO');
|
||
const isWater = terrain === 'water';
|
||
const S = topR * 2;
|
||
// Video 用 videoWidth/videoHeight,Image 用 naturalWidth/naturalHeight
|
||
let tw, th;
|
||
if (isVideo) {
|
||
tw = src.videoWidth || 180; th = src.videoHeight || 180;
|
||
} else {
|
||
tw = src.naturalWidth || 480; th = src.naturalHeight || 480;
|
||
}
|
||
// 每块显示贴图约1.5倍六边形宽度 → 大块色域+清晰细节,不稀碎不模糊
|
||
const tilePx = S * 1.5;
|
||
const sc = tilePx / tw;
|
||
const dw = tw * sc, dh = th * sc;
|
||
const ox = cx - dw / 2, oy = cyTop - dh / 2;
|
||
const cols = Math.ceil(S / dw) + 1, rows = Math.ceil(S / dh) + 1;
|
||
const hp = prismTopPoints(cx, cyTop, topR);
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
hp.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
|
||
ctx.closePath();
|
||
ctx.clip();
|
||
ctx.imageSmoothingEnabled = true;
|
||
ctx.imageSmoothingQuality = 'high';
|
||
// 半透明融合:让贴图与底色/光照自然混合,不完全盖死
|
||
ctx.globalAlpha = isWater ? 0.90 : 0.75;
|
||
for (let r = -1; r <= rows; r++) {
|
||
for (let c = -1; c <= cols; c++) {
|
||
ctx.drawImage(src, ox + c * dw, oy + r * dh, dw, dh);
|
||
}
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
// 全局光影统一层(抽成 applyTerrainLighting,与侧壁共用,保证顶面/侧壁随晨昏夜同步)
|
||
applyTerrainLighting(cx - topR * 1.3, cyTop - topR * 1.3, topR * 2.6, topR * 2.6);
|
||
ctx.restore();
|
||
}
|
||
// 贴图加载已合并到 loadTerrainAssets(onProgress),在 init() 中带进度闸门调用
|
||
|
||
// manifest 二次加载已移除:权威清单改为在 loadTerrainAssets() 内一次性 fetch + 加载,避免贴图闪烁
|
||
|
||
// ============ 建筑 ============
|
||
const BUILDINGS = {
|
||
farm: { name: '农场', icon: '🌾', color: '#8bc34a', yield: { food: 8 } },
|
||
lumber: { name: '伐木场', icon: '🪓', color: '#795548', yield: { wood: 6 } },
|
||
mine: { name: '矿场', icon: '⛏', color: '#ff9800', yield: { metal: 5 } },
|
||
market: { name: '集市', icon: '🏪', color: '#4dd0e1', yield: { gold: 6 } },
|
||
temple: { name: '神庙', icon: '⛩', color: '#fff176', yield: { culture: 3 } },
|
||
barracks: { name: '兵营', icon: '⚔', color: '#ef5350', yield: { military: 8 } },
|
||
library: { name: '图书馆', icon: '📚', color: '#ab47bc', yield: { research: 4 } }
|
||
};
|
||
|
||
// ============ 单位 ============
|
||
const UNITS = {
|
||
farmer: { name: '农夫', icon: '👨🌾', role: 'farm' },
|
||
lumberjack:{ name: '伐木工', icon: '🪓', role: 'lumber' },
|
||
miner: { name: '矿工', icon: '⛏️', role: 'mine' },
|
||
merchant: { name: '商人', icon: '🧑💼', role: 'trade' },
|
||
guard: { name: '守卫', icon: '💂', role: 'defense' },
|
||
scholar: { name: '学者', icon: '📖', role: 'research' },
|
||
priest: { name: '祭司', icon: '🧙', role: 'culture' },
|
||
child: { name: '孩童', icon: '👶', role: 'none' },
|
||
fisher: { name: '渔民', icon: '🎣', role: 'fish' }
|
||
};
|
||
|
||
// ============ 全局状态 ============
|
||
const BOTTOM_BAR_H = 72; // 主沙盘底部功能栏高度(与 #bottomBar CSS 保持一致)
|
||
const canvas = document.getElementById('gameCanvas');
|
||
let ctx = canvas.getContext('2d'); // let:烘焙超采样平移快照时临时指向缓存画布,绘制完即还原
|
||
let W, H;
|
||
let camera = { x: 0, y: 0, zoom: 1.17 }; // 主世界默认缩放;原 1.6 偏近,按三档滚轮缩小(×0.9³≈0.729)降到 1.17,进入时沙盘更开阔
|
||
let hoveredTile = null;
|
||
let selectedTile = null;
|
||
|
||
// 拖拽状态(修复版)
|
||
let isMouseDown = false;
|
||
let hasDragged = false;
|
||
let mouseDownX = 0;
|
||
let mouseDownY = 0;
|
||
let dragCamStartX = 0;
|
||
let dragCamStartY = 0;
|
||
let camInteracting = false; // 相机平移/缩放进行中:真时关阴影投影并复用地块缓存,提升拖动/缩放流畅度
|
||
let _camIntT = null; // 缩放交互的 debounce 定时器
|
||
let _cachedSorted = null; // 按 currentLayer 缓存的绘制顺序地块(交互期复用,静止帧每帧重建)
|
||
let _cachedSortedLayer = null;
|
||
let shadowFade = 0; // 沙盘投影已关闭(用户反馈无用 + 拖拽时渐变 distracting + 逐格 shadowBlur 费性能)
|
||
let worldCache = null, worldCacheCtx = null; // 交互期离屏世界快照:静止帧把世界烘焙进此画布,拖动/缩放时仅 blit(O(1)),纹理不丢、不掉帧
|
||
let worldCacheValid = false;
|
||
let worldBaseCache = null, worldBaseCtx = null; // 静态帧基础世界缓存(背景+地块,不含动态粒子/悬停),每帧只 blit + 叠动态层,省去逐地块阴影/贴图重绘
|
||
let worldBaseValid = false;
|
||
let grassBgImg = null; // 草地背景图(替换默认渐变)
|
||
let forestBgImg = null; // 森林微观背景图
|
||
let mountainBgImg = null; // 山地微观背景图
|
||
let desertBgImg = null; // 荒漠微观背景图
|
||
let snowBgImg = null; // 雪地微观背景图
|
||
let waterBgImg = null; // 水域微观背景图
|
||
let _lastPaletteKey = ''; // 调色板指纹:时间色调明显变化时才重建基础缓存
|
||
const _cachedCam = { x: 0, y: 0, zoom: 1 }; // 快照对应的相机状态,交互期按相机增量平移/缩放 blit
|
||
let _camBoundX = { min: -Infinity, max: Infinity }; // 当前层相机 X 允许范围(clampCamera 写入)
|
||
let _camBoundY = { min: -Infinity, max: Infinity }; // 当前层相机 Y 允许范围
|
||
let releaseBlend = 0; // 松手过渡淡出:0=不混合,>0 时在静止帧上方叠快照(globalAlpha 衰减),平滑 blit→全量的亚像素跳
|
||
const MIN_ZOOM = 0.7, MAX_ZOOM = 7; // 缩放下限抬高(别太小)+ 上限 +3 单位(原 0.4 / 4)
|
||
|
||
let tiles = [];
|
||
|
||
// ============ 建造模式状态 ============
|
||
let buildMode = false; // 建造模式总开关
|
||
let buildTool = 'terrain'; // 当前工具:'terrain' | 'move'
|
||
let buildBrush = 'plain'; // 当前画笔:地形 id(移动工具不用)
|
||
let buildVariant = 'random'; // 当前素材变体:'random' 或 TERRAIN_ASSETS[id] 的下标
|
||
let swapFirst = null; // 移动工具:已选的第一块(待交换)
|
||
let dragSwapFrom = null; // 移动工具:拖拽起点
|
||
let draggingSwap = false; // 移动工具:正在拖拽交换
|
||
let swapFlashes = []; // 交换完成的金色闪光(淡出)
|
||
|
||
let animTime = 0;
|
||
let lastClockUpdate = 0; // syncTimeUI 节流:最多每 100ms 刷一次时钟 SVG,省 CPU
|
||
let stars = [];
|
||
let embers = [];
|
||
let ambParticles = [];
|
||
|
||
// 微观世界
|
||
let microState = null;
|
||
let microNames = {}; // 地块 key("q,r") -> 该微观世界自定义名称(后端持久化,记忆)
|
||
let microBuildRevealAt = 0; // 建筑显示时间戳:过渡结束 + 200ms 后才浮现
|
||
// 游戏化系统全局状态(与 Odoo 后端打通)
|
||
let microChar = null; // 玩家角色:{name,level,exp,gold,inventory,equip}
|
||
let microCreatureOverlay = {}; // cid -> 已保存生物状态(等级/装备/经验)
|
||
let microOpenBuilding = null; // 当前打开的建筑交互对象(用于升级等更新)
|
||
let microCanvas, microCtx;
|
||
let microAnimId = null;
|
||
let microGridCache = null; // 微观世界:背景 + 20×20 离屏缓存(静态,逐帧只贴图)
|
||
let microGridOn = false; // 微观世界:顶部"网格"开关 —— 叠加矩形网格(仅浮岛内)
|
||
let microZoom = 1; // 缩放倍率(1 = 初始适配画布)
|
||
let microPanX = 0, microPanY = 0; // 平移偏量(画布空间像素)
|
||
let microPanning = false; // 中键拖拽平移中?
|
||
let microPanStart = null; // 平移起点 {x,y}
|
||
let microViewOpen = false; // 微观视图是否打开:打开时主世界 render 暂停重绘,避免双循环抢 CPU
|
||
let microHoverB = null; // 鼠标当前悬停的建筑(已过滤 road/ground);非空时在渲染层额外绘高亮 footprint
|
||
|
||
// 建造面板展开时:世界整体上移 + 等比缩小,腾出底部给面板(仅改变换,不缩放画布本身 → 无闪烁)
|
||
let microBuildOpenT = 0; // 0=收起 1=展开(每帧缓动逼近)
|
||
let microBuildScale = 1; // 当前世界缩放系数(建造展开时 <1)
|
||
let microBuildLift = 0; // 当前世界上移像素(= 面板高度/2)
|
||
let microBuildPanelH = 150; // 建造面板实际高度(展开时测量)
|
||
|
||
// 微观世界:建造模式
|
||
// 道路/地面贴图分类(仅平原、森林可用)
|
||
// 页签分类('all' 由面板自动前置);建筑/设施来自 Odoo 目录或下方兜底常量
|
||
const MICRO_BUILD_CATS = [
|
||
{ id: 'build', name: '建筑' },
|
||
{ id: 'business', name: '经营' },
|
||
{ id: 'facility', name: '设施' },
|
||
{ id: 'road', name: '道路' },
|
||
{ id: 'ground', name: '地面' },
|
||
{ id: 'bio', name: '生物' },
|
||
{ id: 'miracle', name: '神迹' },
|
||
{ id: 'decor', name: '装饰' },
|
||
{ id: 'nature', name: '自然' },
|
||
];
|
||
const MICRO_BUILD_ITEMS = [
|
||
{ id: 'road_auto', cat: 'road', icon: 'assets/roads/5.png', name: '道路', autoRoad: true, costGold: 1 },
|
||
{ id: 'road_t', cat: 'road', icon: 'assets/roads/1.png', name: 'T字路口', hidden: true },
|
||
{ id: 'road_cross', cat: 'road', icon: 'assets/roads/2.png', name: '十字路口', hidden: true },
|
||
{ id: 'road_corner', cat: 'road', icon: 'assets/roads/4.png', name: '弯道', hidden: true },
|
||
{ id: 'road_straight', cat: 'road', icon: 'assets/roads/5.png', name: '直路', hidden: true },
|
||
{ id: 'ground_grass_a',cat: 'ground', icon: 'assets/roads/3.png', name: '草地A', terrains: ['plain','forest'], hidden: true },
|
||
{ id: 'ground_grass_b',cat: 'ground', icon: 'assets/roads/6.png', name: '草地B', terrains: ['plain','forest'], hidden: true },
|
||
{ id: 'ground_grass_c',cat: 'ground', icon: 'assets/roads/7.png', name: '草地C', terrains: ['plain','forest'], hidden: true },
|
||
];
|
||
// 建筑 / 设施 目录(兜底:未登录或 Odoo 未提供时直接用此;登录后由 /yt_world/api/micro/catalog 覆盖)。
|
||
// flat=true 表示平面俯视,岛上压成菱形贴地;否则为 3/4 立绘,竖直站立。
|
||
const MICRO_BUILD_EXTRA = [
|
||
{ id: 'cottage', cat: 'build', icon: 'assets/buildings/spring/cottage/cottage_l.png?v=6', name: '农舍', desc: '村民起居的基本居所,提供人口上限与夜间休息。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 120, statYield: { population: 4 }, costGold: 20 },
|
||
{ id: 'windmill', cat: 'build', icon: 'assets/buildings/spring/windmill/windmill_l.png?v=5', name: '风车', desc: '利用风力磨粉与提水,提升粮食加工效率。', flat: false, w: 3, h: 3, statLevel: 1, statHp: 110, statYield: { flour: 2 }, costGold: 60 },
|
||
{ id: 'blacksmith', cat: 'build', icon: 'assets/buildings/spring/blacksmith/blacksmith_l.png?v=5',name: '铁匠铺', desc: '锻造工具与武器,产出金属制品。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 130, statYield: { metal: 2 }, costGold: 80 },
|
||
{ id: 'grocery', cat: 'build', icon: 'assets/buildings/spring/grocery/grocery_l.png?v=5', name: '杂货铺', desc: '交易日用物资的小型商铺,促进经济流通。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 90, statYield: { coin: 2 }, costGold: 50 },
|
||
{ id: 'temple', cat: 'build', icon: 'assets/buildings/spring/temple/temple_l.png?v=4', name: '神庙', desc: '供奉信仰的场所,提升居民精神状态与领地声望。', flat: false, w: 3, h: 3, statLevel: 1, statHp: 160, statYield: { prestige: 3 }, costGold: 100 },
|
||
{ id: 'clinic', cat: 'build', icon: 'assets/buildings/spring/clinic/clinic_l.png?v=5', name: '医务室', desc: '诊治伤病,恢复居民生命值。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 100, statYield: { heal: 2 }, costGold: 55 },
|
||
{ id: 'restaurant', cat: 'build', icon: 'assets/buildings/spring/restaurant/restaurant_l.png?v=5',name: '酒馆', desc: '烹制料理,为居民提供饱食与心情加成。', flat: false, w: 2, h: 3, statLevel: 1, statHp: 95, statYield: { satiety: 3 }, costGold: 45 },
|
||
{ id: 'pasture', cat: 'business', icon: 'assets/buildings/spring/pasture/pasture_l.png?v=13', name: '牧场', desc: '放养牲畜,稳定产出肉奶类资源。', flat: false, w: 4, h: 4, statLevel: 1, statHp: 105, statYield: { meat: 2, milk: 1 }, costGold: 70 },
|
||
{ id: 'blackmarket', cat: 'build', icon: 'assets/buildings/spring/blackmarket/blackmarket_l.png?v=7',name:'商城', desc: '灰色交易点,可换取稀有物资但伴随风险。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 80, statYield: { rare: 1 }, costGold: 120 },
|
||
{ id: 'farmland', cat: 'business', icon: 'assets/buildings/spring/farmland/farmland.png', name: '农田', desc: '春季播种的耕地,定期产出粮食作物。', flat: true, w: 2, h: 2, statLevel: 1, statHp: 60, statYield: { food: 3 }, costGold: 10 },
|
||
{ id: 'well', cat: 'build', icon: 'assets/buildings/default/props/well_l.png', name: '水井', desc: '汲取地下水的圆井,保障饮水与灌溉。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 90, statYield: { water: 2 }, costGold: 15 },
|
||
{ id: 'school', cat: 'build', icon: 'assets/buildings/spring/school/school_l.png?v=6', name: '学堂', desc: '授业解惑之所,研读典籍提升领地科研与基础教学产出。', flat: false, w: 3, h: 2, statLevel: 1, statHp: 100, statYield: { research: 3, prestige: 1 }, costGold: 90 },
|
||
{ id: 'library', cat: 'build', icon: 'assets/buildings/spring/library/library_l.png?v=5', name: '图书馆', desc: '收藏典籍与卷轴,提升领地科研与知识产出。', flat: false, w: 2, h: 3, statLevel: 1, statHp: 110, statYield: { research: 4 }, costGold: 110 },
|
||
{ id: 'barracks', cat: 'build', icon: 'assets/buildings/spring/barracks/barracks_l.png?v=6', name: '兵营', desc: '训练士兵与储备武备的军事建筑,提升领地军事产出。', flat: false, w: 3, h: 3, statLevel: 1, statHp: 130, statYield: { military: 4 }, costGold: 130 },
|
||
{ id: 'guild', cat: 'build', icon: 'assets/buildings/spring/guild/guild_l.png?v=4', name: '公会', desc: '冒险者与住民集结之所,可发布委托与招募成员。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 140, statYield: { prestige: 2, coin: 1 }, costGold: 100 },
|
||
{ id: 'village', cat: 'build', icon: 'assets/buildings/spring/village/village_l.png?v=5', name: '村庄', desc: '族群聚落的核心,提升人口上限与基础产出。', flat: false, w: 3, h: 3, statLevel: 1, statHp: 150, statYield: { population: 6, food: 1 }, costGold: 200 },
|
||
// 生物分类:9 种族角色(点击进入"点地面放置"模式,不进入建筑模式)
|
||
{ id: 'race_human', cat: 'bio', icon: 'assets/characters/human/human_thumb.png', sprite: 'assets/characters/human/human_idle_attack_1x6.png', name: '人族', isChar: true, race: 'human' },
|
||
{ id: 'race_demon', cat: 'bio', icon: 'assets/characters/demon/demon_thumb.png', sprite: 'assets/characters/demon/demon_idle_attack_1x6.png', name: '妖族', isChar: true, race: 'demon' },
|
||
{ id: 'race_dwarf', cat: 'bio', icon: 'assets/characters/dwarf/dwarf_thumb.png', sprite: 'assets/characters/dwarf/dwarf_idle_attack_1x6.png', name: '矮人', isChar: true, race: 'dwarf' },
|
||
{ id: 'race_elf', cat: 'bio', icon: 'assets/characters/elf/elf_thumb.png', sprite: 'assets/characters/elf/elf_idle_attack_1x6.png', name: '精灵', isChar: true, race: 'elf' },
|
||
{ id: 'race_dragon', cat: 'bio', icon: 'assets/characters/dragon/dragon_thumb.png', sprite: 'assets/characters/dragon/dragon_idle_attack_1x6.png', name: '龙族', isChar: true, race: 'dragon' },
|
||
{ id: 'race_undead', cat: 'bio', icon: 'assets/characters/undead/undead_thumb.png', sprite: 'assets/characters/undead/undead_idle_attack_1x6.png', name: '不死族', isChar: true, race: 'undead' },
|
||
{ id: 'race_orc', cat: 'bio', icon: 'assets/characters/orc/orc_thumb.png', sprite: 'assets/characters/orc/orc_idle_attack_1x6.png', name: '兽人', isChar: true, race: 'orc' },
|
||
{ id: 'race_angel', cat: 'bio', icon: 'assets/characters/angel/angel_thumb.png', sprite: 'assets/characters/angel/angel_idle_attack_1x6.png', name: '天使', isChar: true, race: 'angel' },
|
||
{ id: 'race_werewolf',cat: 'bio', icon: 'assets/characters/werewolf/werewolf_thumb.png', sprite: 'assets/characters/werewolf/werewolf_idle_attack_1x6.png', name: '狼人', isChar: true, race: 'werewolf' },
|
||
{ id: 'race_golem', cat: 'bio', icon: 'assets/characters/golem/golem_thumb.png', sprite: 'assets/characters/golem/golem_idle_attack_1x6.png', name: '石傀儡', isChar: true, race: 'golem' },
|
||
];
|
||
|
||
// 建筑交互配置(数据驱动,与后端 BUILD_EFFECTS 的 (code,action) 对应)。
|
||
// 换素材 / 调文案只改这里,不碰逻辑。所有建筑(9 建筑 + 6 设施)均覆盖。
|
||
const BUILD_INTERACT = {
|
||
cottage: { title: '农舍', tagline: '村民起居与村落治理', actions: [
|
||
{ action: 'employ', label: '发布任职', icon: '📜', desc: '发布村长任职委托,吸引居民就任' },
|
||
{ action: 'recruit', label: '招募村民', icon: '🧑', desc: '招募新村民,人口 +1' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '提升农舍等级,扩大人口上限' },
|
||
] },
|
||
windmill: { title: '风车', tagline: '风力磨粉与提水', actions: [
|
||
{ action: 'collect', label: '领取面粉', icon: '🌾', desc: '领取已磨好的面粉' },
|
||
{ action: 'produce', label: '加工粮食', icon: '⚙️', desc: '消耗面粉加工为粮食' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '提升风车效率' },
|
||
] },
|
||
blacksmith: { title: '铁匠铺', tagline: '锻造兵器与工具', actions: [
|
||
{ action: 'forge', label: '锻造', icon: '🔨', desc: '锻造金属制品' },
|
||
{ action: 'commission', label: '发布委托', icon: '📋', desc: '发布装备打造委托' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级铁匠铺' },
|
||
] },
|
||
grocery: { title: '杂货铺', tagline: '日用物资交易', actions: [
|
||
{ action: 'sell', label: '售卖', icon: '💰', desc: '售货获得金币' },
|
||
{ action: 'restock', label: '进货', icon: '📦', desc: '花费金币补充货物' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级杂货铺' },
|
||
] },
|
||
temple: { title: '神庙', tagline: '信仰与声望', actions: [
|
||
{ action: 'pray', label: '祈祷', icon: '🙏', desc: '祈祷获得声望' },
|
||
{ action: 'donate', label: '捐赠', icon: '💝', desc: '捐赠金币换取大量声望' },
|
||
{ action: 'upgrade',label: '升级', icon: '⬆️', desc: '升级神庙' },
|
||
] },
|
||
clinic: { title: '医务室', tagline: '诊治与恢复', actions: [
|
||
{ action: 'heal', label: '治疗全员', icon: '💊', desc: '恢复本村所有生物生命' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级医务室' },
|
||
] },
|
||
restaurant: { title: '饭馆', tagline: '料理与饱食', actions: [
|
||
{ action: 'cook', label: '烹饪', icon: '🍲', desc: '消耗粮食烹制料理,提升饱食' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级饭馆' },
|
||
] },
|
||
pasture: { title: '牧场', tagline: '畜牧产出', actions: [
|
||
{ action: 'breed', label: '畜牧', icon: '🐄', desc: '放养牲畜产出肉奶' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级牧场' },
|
||
] },
|
||
blackmarket:{ title: '黑市', tagline: '灰色交易(有风险)', actions: [
|
||
{ action: 'trade', label: '交易', icon: '🃏', desc: '花费金币换取稀有物资' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级黑市' },
|
||
] },
|
||
farmland: { title: '农田', tagline: '春播秋收', actions: [
|
||
{ action: 'harvest', label: '收获', icon: '🌽', desc: '收获粮食' },
|
||
{ action: 'sow', label: '播种', icon: '🌱', desc: '花费金币播种' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级农田' },
|
||
] },
|
||
fence: { title: '栅栏', tagline: '圈定边界', actions: [
|
||
{ action: 'repair', label: '修缮', icon: '🔧', desc: '消耗木材修缮栅栏' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级栅栏' },
|
||
] },
|
||
wall: { title: '围墙', tagline: '防御与分隔', actions: [
|
||
{ action: 'reinforce', label: '加固', icon: '🧱', desc: '消耗石料加固围墙' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级围墙' },
|
||
] },
|
||
storage: { title: '存储箱', tagline: '本地仓储', actions: [
|
||
{ action: 'deposit', label: '存取', icon: '🗃️', desc: '打开仓储,存取背包物资' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级存储箱' },
|
||
] },
|
||
well: { title: '水井', tagline: '饮水与灌溉', actions: [
|
||
{ action: 'draw', label: '取水', icon: '💧', desc: '汲取地下水' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级水井' },
|
||
] },
|
||
market: { title: '集市', tagline: '露天贸易', actions: [
|
||
{ action: 'stall', label: '摆摊', icon: '🏪', desc: '摆摊获得金币' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级集市' },
|
||
] },
|
||
school: { title: '学堂', tagline: '授业与研读', actions: [
|
||
{ action: 'study', label: '研读', icon: '📖', desc: '研读典籍,获取科研点' },
|
||
{ action: 'lecture', label: '讲学', icon: '🎓', desc: '讲授知识,提升领地声望' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级学堂' },
|
||
] },
|
||
library: { title: '图书馆', tagline: '典籍与知识', actions: [
|
||
{ action: 'study', label: '研读', icon: '📖', desc: '研读典籍,获取科研点' },
|
||
{ action: 'archive', label: '整理卷轴', icon: '🗂', desc: '整理卷轴,提升知识产出' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级图书馆' },
|
||
] },
|
||
barracks: { title: '兵营', tagline: '训练与征召', actions: [
|
||
{ action: 'recruit', label: '招募', icon: '🛡️', desc: '招募新兵,补充兵员' },
|
||
{ action: 'drill', label: '操练', icon: '⚔️', desc: '操练部队,提升军事产出' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级兵营' },
|
||
] },
|
||
guild: { title: '公会', tagline: '委托与集结', actions: [
|
||
{ action: 'quest', label: '发布委托', icon: '📜', desc: '发布冒险委托,获取声望与物资' },
|
||
{ action: 'recruit', label: '招募成员', icon: '🧑🤝🧑', desc: '招募冒险者,提升公会影响力' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '升级公会大厅' },
|
||
] },
|
||
village: { title: '村庄', tagline: '聚落与民生', actions: [
|
||
{ action: 'settle', label: '定居', icon: '🏠', desc: '吸引流民定居,增加人口' },
|
||
{ action: 'collect', label: '收缴税赋', icon: '🪙', desc: '收取粮食与金币' },
|
||
{ action: 'upgrade', label: '升级', icon: '⬆️', desc: '扩建村庄' },
|
||
] }
|
||
};
|
||
|
||
// 装备目录:每个槽位的候选装备(图标 + 简短加成,仅作展示/穿戴)。
|
||
// 穿戴写入生物 equip(持久化到 Odoo yt.world.creature),并联动角色背包。
|
||
const EQUIP_CATALOG = {
|
||
weapon: [
|
||
{ code: 'iron_sword', name: '铁剑', icon: '🗡️', bonus: '攻+5' },
|
||
{ code: 'wood_bow', name: '木弓', icon: '🏹', bonus: '攻+3' },
|
||
{ code: 'stone_axe', name: '石斧', icon: '🪓', bonus: '攻+2' },
|
||
],
|
||
armor: [
|
||
{ code: 'leather_armor', name: '皮甲', icon: '🛡️', bonus: '防+4' },
|
||
{ code: 'cloth_robe', name: '布袍', icon: '🥋', bonus: '防+2' },
|
||
{ code: 'iron_plate', name: '铁甲', icon: '🪖', bonus: '防+6' },
|
||
],
|
||
accessory: [
|
||
{ code: 'lucky_charm', name: '护符', icon: '🔮', bonus: '运+3' },
|
||
{ code: 'speed_boots', name: '疾行靴', icon: '👢', bonus: '速+3' },
|
||
{ code: 'power_ring', name: '力量戒', icon: '💍', bonus: '力+4' },
|
||
],
|
||
};
|
||
|
||
// 全部可建造项(道路/地面 + 建筑/设施),作为面板/渲染的单一来源
|
||
function microAllBuildItems() { return MICRO_BUILD_ITEMS.concat(MICRO_BUILD_EXTRA); }
|
||
// 从 Odoo 目录覆盖建筑/设施(登录后调用;失败则保留兜底常量)
|
||
let microCatalogLoaded = false;
|
||
async function microLoadCatalog() {
|
||
if (microCatalogLoaded) return;
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/micro/catalog'), { credentials: _wcred });
|
||
if (!r.ok) return;
|
||
const j = await r.json();
|
||
if (!j || !Array.isArray(j.items)) return;
|
||
const byCode = {};
|
||
MICRO_BUILD_EXTRA.forEach(it => { byCode[it.id] = it; });
|
||
j.items.forEach(it => {
|
||
const t = byCode[it.code];
|
||
if (!t) return;
|
||
t.name = it.name || t.name;
|
||
t.icon = it.icon || t.icon;
|
||
t.desc = it.desc || t.desc;
|
||
if (it.w != null && t.w == null) t.w = it.w; // 前端兜底已定义的尺寸优先,避免后端旧数据把 2×2 顶成 3×3
|
||
if (it.h != null && t.h == null) t.h = it.h;
|
||
if (it.flat != null) t.flat = it.flat;
|
||
if (it.statLevel) t.statLevel = it.statLevel;
|
||
if (it.statHp) t.statHp = it.statHp;
|
||
if (it.costGold != null) t.costGold = it.costGold; // 后端价格优先(单一数据源)
|
||
if (it.statYield && Object.keys(it.statYield).length) t.statYield = it.statYield;
|
||
if (Array.isArray(it.actions) && it.actions.length) t.actions = it.actions; // 后端动作优先
|
||
});
|
||
microCatalogLoaded = true;
|
||
microLoadIcons(); // 目录覆盖 icon 后,用最终(干净 URL)的图标重新预加载,避免带 ?v= 缓存号与后端干净 URL 不一致导致建筑不显示
|
||
} catch (e) { /* 未登录/网络不可达:保留兜底常量 */ }
|
||
}
|
||
|
||
// ============ 游戏化系统:角色 / 生物(与 Odoo 后端打通) ============
|
||
async function microLoadGame() {
|
||
try {
|
||
const [c, cr] = await Promise.all([API.char(), API.creatures()]);
|
||
microChar = (c && c.ok !== false) ? c : (c || null);
|
||
if (microChar && !microChar.equip) microChar.equip = { weapon: null, armor: null, accessory: null };
|
||
if (microChar && !microChar.inventory) microChar.inventory = {};
|
||
microCreatureOverlay = {};
|
||
if (cr && cr.ok && Array.isArray(cr.creatures)) {
|
||
cr.creatures.forEach(x => { if (x.cid) microCreatureOverlay[x.cid] = x; });
|
||
}
|
||
if (microState && microState.units) {
|
||
microState.units.forEach(u => {
|
||
const ov = microCreatureOverlay[u.id];
|
||
if (ov) {
|
||
u.level = ov.level != null ? ov.level : u.level;
|
||
u.exp = ov.exp != null ? ov.exp : u.exp;
|
||
u.hp = ov.hp != null ? ov.hp : u.hp;
|
||
if (ov.equip) u.equip = ov.equip;
|
||
if (ov.job) u.job = ov.job;
|
||
}
|
||
});
|
||
}
|
||
microRenderCharHud();
|
||
} catch (e) { /* 离线兜底:不阻塞微观世界 */ }
|
||
}
|
||
|
||
// 持久化当前地块全部单位的角色化状态(等级/经验/装备/生命)到 Odoo
|
||
async function microSaveCreatures() {
|
||
if (!microState || !microState.units) return;
|
||
// 注意:_knownUnitCids 在 setMicroTile 加载服务端居民时统一种子,
|
||
// 不要在这里用 microState.units 做延迟种子,否则刚放置的新角色 cid
|
||
// 会被提前标记为"已知",导致 char_recruited 事件漏发。
|
||
if (!microState.units.length) return;
|
||
const list = microState.units.map(u => ({
|
||
cid: u.id, name: u.name, species: u.role || 'none',
|
||
level: u.level, exp: u.exp, hp: u.hp, hp_max: u.hp_max || 120,
|
||
energy: u.energy, mood: u.mood, job: u.job || 'none',
|
||
equip: u.equip || { weapon: null, armor: null, accessory: null },
|
||
tile_ref: microState.tile ? { q: microState.tile.q, r: microState.tile.r } : {},
|
||
state: u.status || 'resting',
|
||
}));
|
||
const res = await API.saveCreatures(list);
|
||
if (res && res.ok && Array.isArray(res.creatures)) {
|
||
const roleByCid = {};
|
||
list.forEach(u => { if (u.cid) roleByCid[u.cid] = u.role || u.species; });
|
||
res.creatures.forEach(x => {
|
||
if (x.cid) microCreatureOverlay[x.cid] = x;
|
||
if (x.cid && !_knownUnitCids.has(x.cid)) {
|
||
_knownUnitCids.add(x.cid);
|
||
emitTaskEvent('char_recruited', { race: roleByCid[x.cid] || 'human' });
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 角色 HUD:金币 / 等级(微观视图右上角)
|
||
function microRenderCharHud() {
|
||
const el = document.getElementById('charHud');
|
||
if (!el) return;
|
||
if (!microChar) { el.style.display = 'none'; return; }
|
||
el.style.display = '';
|
||
const gold = microChar.gold != null ? microChar.gold : 0;
|
||
const lv = microChar.level || 1;
|
||
const exp = microChar.exp != null ? microChar.exp : 0;
|
||
const expMax = microChar.exp_max && microChar.exp_max > 0 ? microChar.exp_max : 100;
|
||
const pct = Math.max(0, Math.min(100, Math.round(exp / expMax * 100)));
|
||
const name = microChar.name ? microChar.name : '';
|
||
el.innerHTML = `
|
||
<div style="display:flex;align-items:center;gap:8px;white-space:nowrap;">
|
||
<span>🪙 <b>${gold}</b></span>
|
||
<span style="color:#ffd27f;">⭐ Lv.${lv}</span>
|
||
${name ? `<span style="color:#a9c7e0;">${name}</span>` : ''}
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:6px;margin-top:5px;">
|
||
<div style="position:relative;flex:1;height:7px;background:rgba(255,255,255,.12);border-radius:5px;overflow:hidden;">
|
||
<div style="position:absolute;left:0;top:0;bottom:0;width:${pct}%;background:linear-gradient(90deg,#3a7afe,#46e0c8);border-radius:5px;transition:width .35s ease;"></div>
|
||
</div>
|
||
<span style="font-size:11px;color:#9fb0c0;white-space:nowrap;">${exp}/${expMax} (${pct}%)</span>
|
||
</div>`;
|
||
}
|
||
|
||
// ============ 任务系统(新手主线,后端驱动) ============
|
||
let taskData = null; // {chapters, tasks, char}
|
||
let _knownUnitCids = new Set(); // 已记录的单位 cid,用于检测"新入驻居民"
|
||
// 建筑动作 → 产出的资源(用于发射 resource_collected 事件)
|
||
const TASK_RES = {
|
||
'farmland_harvest': 'food', 'farmland_sow': 'food', 'farmland_upgrade': 'food',
|
||
'windmill_collect': 'flour', 'windmill_produce': 'food',
|
||
'pasture_breed': 'meat', 'well_draw': 'water', 'blacksmith_forge': 'metal',
|
||
'grocery_restock': 'food', 'market_stall': 'gold',
|
||
};
|
||
|
||
// 发射游戏事件到服务端事件总线,推进任务进度;返回后刷新本地任务数据与(开着的)面板
|
||
async function emitTaskEvent(event, payload) {
|
||
try {
|
||
const r = await API.taskProgress(event, payload || {});
|
||
if (r && r.ok && r.tasks) {
|
||
taskData = { chapters: r.chapters, tasks: r.tasks, char: r.char };
|
||
if (microChar && r.char) {
|
||
microChar.level = r.char.level; microChar.exp = r.char.exp;
|
||
microChar.exp_max = r.char.exp_max; microChar.gold = r.char.gold;
|
||
microRenderCharHud();
|
||
}
|
||
const body = document.getElementById('taskPanelBody');
|
||
if (body) {
|
||
body.innerHTML = renderTaskPanel();
|
||
document.querySelectorAll('.task-claim-btn').forEach(b => {
|
||
b.onclick = () => claimTask(b.getAttribute('data-code'), b);
|
||
});
|
||
}
|
||
(r.claimable || []).forEach(code => {
|
||
const t = (r.tasks || []).find(x => x.code === code);
|
||
toast('🎯 新任务可领取:' + (t ? t.title : code));
|
||
});
|
||
}
|
||
} catch (e) { /* 离线兜底 */ }
|
||
}
|
||
|
||
async function fetchTasks() {
|
||
try {
|
||
const r = await API.tasks();
|
||
if (r && r.ok) {
|
||
taskData = r;
|
||
if (r.char) {
|
||
// 主世界登录时 microChar 可能尚未创建,这里兜底初始化,避免个人信息面板 fallback 到旧数据
|
||
if (!microChar) {
|
||
microChar = {
|
||
name: r.char.name, level: r.char.level, exp: r.char.exp,
|
||
exp_max: r.char.exp_max, gold: r.char.gold,
|
||
equip: { weapon: null, armor: null, accessory: null },
|
||
inventory: {}
|
||
};
|
||
} else {
|
||
microChar.name = r.char.name;
|
||
microChar.level = r.char.level;
|
||
microChar.exp = r.char.exp;
|
||
microChar.exp_max = r.char.exp_max;
|
||
microChar.gold = r.char.gold;
|
||
}
|
||
microRenderCharHud();
|
||
}
|
||
}
|
||
} catch (e) { /* 离线兜底 */ }
|
||
}
|
||
|
||
function renderTaskPanel() {
|
||
if (!taskData || !taskData.tasks) {
|
||
return '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">任务加载中…</div>';
|
||
}
|
||
const stateLabel = {
|
||
done: '✅ 已完成', claimable: '🎁 可领取',
|
||
in_progress: '🟢 进行中', locked: '🔒 未解锁',
|
||
};
|
||
let html = '';
|
||
taskData.chapters.forEach(ch => {
|
||
const list = taskData.tasks.filter(t => t.chapter === ch);
|
||
if (!list.length) return;
|
||
html += `<div style="margin:14px 0 6px;font-weight:600;color:#cdd9e5;">第 ${ch} 章</div>`;
|
||
list.forEach(t => {
|
||
const locked = t.state === 'locked';
|
||
const done = t.state === 'done';
|
||
const claimable = t.state === 'claimable';
|
||
const rewards = [];
|
||
if (t.reward_exp) rewards.push(`⭐${t.reward_exp} 经验`);
|
||
if (t.reward_res && t.reward_res.gold) rewards.push(`🪙${t.reward_res.gold} 金币`);
|
||
// 目标进度
|
||
let objHtml = '';
|
||
(t.objectives || []).forEach(o => {
|
||
const need = o.need || 1;
|
||
const have = Math.min(o.have || 0, need);
|
||
const pct = need ? Math.round(have / need * 100) : 100;
|
||
objHtml += `
|
||
<div style="margin:6px 0;">
|
||
<div style="display:flex;justify-content:space-between;font-size:12px;color:#9fb0c0;">
|
||
<span>${o.label || o.type}${o.hint ? ` <span style="color:#66788a;">(${o.hint})</span>` : ''}</span>
|
||
<span>${have}/${need}</span>
|
||
</div>
|
||
<div style="height:6px;background:#1d2733;border-radius:4px;overflow:hidden;margin-top:3px;">
|
||
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,#3a7afe,#38d0c8);border-radius:4px;transition:width .35s;"></div>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
const btnLabel = done ? '已完成' : (claimable ? '领取奖励' : (locked ? '未解锁' : '进行中'));
|
||
const btnColor = done ? '#33414f' : (claimable ? '#3a7afe' : '#33414f');
|
||
const btnCursor = (done || locked || !claimable) ? 'not-allowed' : 'pointer';
|
||
html += `
|
||
<div style="border:1px solid #2a3645;border-radius:10px;padding:10px 12px;margin:8px 0;background:#16202c;${locked ? 'opacity:.5;' : ''}">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||
<span style="font-weight:600;color:#e8eef4;">${t.title}</span>
|
||
<span style="font-size:12px;color:#8899aa;">${stateLabel[t.state] || t.state}</span>
|
||
</div>
|
||
<div style="font-size:12px;color:#9fb0c0;margin:4px 0;">${t.desc || ''}</div>
|
||
${objHtml}
|
||
${rewards.length ? `<div style="font-size:12px;color:#ffd27f;margin:2px 0;">奖励:${rewards.join(' · ')}</div>` : ''}
|
||
${done ? '' : `<button class="task-claim-btn" data-code="${t.code}"
|
||
${claimable ? '' : 'disabled'}
|
||
style="margin-top:6px;padding:5px 14px;border:none;border-radius:8px;cursor:${btnCursor};
|
||
background:${btnColor};color:#fff;font-size:13px;">${btnLabel}</button>`}
|
||
</div>`;
|
||
});
|
||
});
|
||
return html;
|
||
}
|
||
|
||
async function openTaskPanel() {
|
||
// 首次进入可能任务数据尚未拉取,先确保就绪;再发射"打开面板"事件(ch1_welcome 由此变可领取)
|
||
if (!taskData) await fetchTasks();
|
||
await emitTaskEvent('panel_opened');
|
||
const body = `<div id="taskPanelBody">${renderTaskPanel()}</div>
|
||
<div style="text-align:center;color:#66788a;font-size:12px;margin-top:10px;">完成主线任务可获经验与金币,推动世界成长。</div>`;
|
||
openAppModal('📜 任务', body);
|
||
// 绑定领取按钮(setTimeout 等待 modal 注入 DOM)
|
||
setTimeout(() => {
|
||
document.querySelectorAll('.task-claim-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => claimTask(btn.getAttribute('data-code'), btn));
|
||
});
|
||
}, 30);
|
||
}
|
||
|
||
async function claimTask(code, btn) {
|
||
try {
|
||
if (btn) { btn.disabled = true; btn.textContent = '领取中…'; }
|
||
const beforeLv = microChar ? microChar.level : 1;
|
||
const r = await API.taskComplete(code);
|
||
if (r && r.ok && r.char) {
|
||
// 回写角色状态到 HUD
|
||
if (microChar) {
|
||
microChar.name = r.char.name;
|
||
microChar.level = r.char.level;
|
||
microChar.exp = r.char.exp;
|
||
microChar.exp_max = r.char.exp_max;
|
||
microChar.gold = r.char.gold;
|
||
microRenderCharHud();
|
||
}
|
||
const gained = [];
|
||
if (r.reward_exp) gained.push(`⭐${r.reward_exp} 经验`);
|
||
if (r.reward_res && r.reward_res.gold) gained.push(`🪙${r.reward_res.gold} 金币`);
|
||
// 刷新面板
|
||
taskData = await API.tasks();
|
||
const body = document.getElementById('taskPanelBody');
|
||
if (body) body.innerHTML = renderTaskPanel();
|
||
// 重新绑定领取按钮
|
||
document.querySelectorAll('.task-claim-btn').forEach(b => {
|
||
b.addEventListener('click', () => claimTask(b.getAttribute('data-code'), b));
|
||
});
|
||
if (gained.length && !r.already) {
|
||
toast(`任务完成!获得 ${gained.join(' · ')}`);
|
||
}
|
||
// 升级事件 → 推进"升至 X 级"类任务
|
||
if (r.char.level > beforeLv) {
|
||
emitTaskEvent('level_up', { level: r.char.level });
|
||
}
|
||
} else if (r && r.error === 'prereq_unmet') {
|
||
if (btn) { btn.disabled = false; btn.textContent = '未解锁'; }
|
||
toast('请先完成前置任务');
|
||
} else if (r && r.error === 'not_claimable') {
|
||
if (btn) { btn.disabled = false; btn.textContent = '进行中'; }
|
||
toast('任务尚未完成');
|
||
} else {
|
||
// 未知错误:恢复按钮并提示,避免卡死
|
||
if (btn) { btn.disabled = false; btn.textContent = '领取'; }
|
||
toast('领取失败:' + (r && (r.error || r.detail) ? (r.error || r.detail) : '未知错误'));
|
||
}
|
||
} catch (e) {
|
||
if (btn) { btn.disabled = false; btn.textContent = '领取'; }
|
||
toast('领取异常:' + (e && e.message ? e.message : String(e)));
|
||
}
|
||
}
|
||
const MICRO_ICON_IMGS = {}; // 贴图缓存:path -> Image
|
||
function microLoadIcons() {
|
||
microAllBuildItems().forEach(it => {
|
||
const srcs = [it.icon];
|
||
// 3/4 建筑有左右两个视角,预加载另一个视角避免切换时闪烁
|
||
if (typeof it.icon === 'string') {
|
||
if (it.icon.includes('_l.png')) srcs.push(it.icon.replace('_l.png', '_r.png'));
|
||
else if (it.icon.includes('_r.png')) srcs.push(it.icon.replace('_r.png', '_l.png'));
|
||
}
|
||
srcs.forEach(src => {
|
||
if (typeof src === 'string' && /\.(png|webp|jpg|jpeg)(\?.*)?$/i.test(src) && !MICRO_ICON_IMGS[src]) {
|
||
const img = new Image();
|
||
img.src = src;
|
||
MICRO_ICON_IMGS[src] = img;
|
||
}
|
||
});
|
||
});
|
||
// 自动道路/栅栏的「子贴图」(straight/curve/t/cross)不出现在建造面板,需主动预加载,避免首次绘制时闪烁/占位
|
||
[ROAD_AUTO_ICON].forEach(map => {
|
||
Object.values(map).forEach(src => {
|
||
if (typeof src === 'string' && /\.(png|webp|jpg|jpeg)(\?.*)?$/i.test(src) && !MICRO_ICON_IMGS[src]) {
|
||
const img = new Image();
|
||
img.src = src;
|
||
MICRO_ICON_IMGS[src] = img;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
// 根据朝向选择 3/4 建筑素材:默认统一朝左,0/180° 用 _l,90/270° 用 _r
|
||
function microIconForRot(baseIcon, rot) {
|
||
if (typeof baseIcon !== 'string') return baseIcon;
|
||
const useLeft = (rot === 0 || rot === 180);
|
||
if (baseIcon.includes('_l.png')) {
|
||
return useLeft ? baseIcon : baseIcon.replace('_l.png', '_r.png');
|
||
}
|
||
if (baseIcon.includes('_r.png')) {
|
||
return useLeft ? baseIcon.replace('_r.png', '_l.png') : baseIcon;
|
||
}
|
||
return baseIcon;
|
||
}
|
||
function microDrawIcon(ctx, icon, size) {
|
||
const img = typeof icon === 'string' ? MICRO_ICON_IMGS[icon] : null;
|
||
if (img && img.complete && img.naturalWidth) {
|
||
ctx.drawImage(img, -size / 2, -size / 2, size, size);
|
||
return true;
|
||
}
|
||
// 图片路径类图标(加载失败/未就绪)不画文件名,画一个低调的占位方块
|
||
if (typeof icon === 'string' && (icon.includes('/') || icon.includes('\\'))) {
|
||
ctx.fillStyle = 'rgba(255,255,255,0.12)';
|
||
ctx.fillRect(-size / 2, -size / 2, size, size);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(-size / 2 + 1, -size / 2 + 1, size - 2, size - 2);
|
||
return false;
|
||
}
|
||
// 表情/字符类图标保持原样
|
||
ctx.font = Math.round(size * 0.82) + 'px sans-serif';
|
||
if (ctx.textAlign !== 'center') ctx.textAlign = 'center';
|
||
if (ctx.textBaseline !== 'middle') ctx.textBaseline = 'middle';
|
||
ctx.fillText(icon || '□', 0, 0);
|
||
return false;
|
||
}
|
||
// 在已 translate 到格心的坐标系下绘制一个已放置/预览素材(等距感知)
|
||
// flat → 压成菱形贴地;road → 按朝向旋转+菱形裁剪;3/4 立绘 → 竖直站立,按 rot 切换 _r/_l 视角
|
||
function microDrawPlaced(ctx, b, it, cell, ew, eh) {
|
||
const isRoad = it && it.cat === 'road';
|
||
const flat = !!(it && it.flat);
|
||
const rot = (b && b.rot) || 0;
|
||
const baseIcon = (b && b.sub) || (it && it.icon);
|
||
const icon = (!isRoad && !flat) ? microIconForRot(baseIcon, rot) : baseIcon;
|
||
const img = (typeof icon === 'string') ? (MICRO_ICON_IMGS[icon] || null) : null;
|
||
if (img && img.complete && img.naturalWidth) {
|
||
if (isRoad) {
|
||
// 道路:与 flat 一致,先「45°旋转 + 纵向压扁」投影到等距菱形,再按连接 rot 取向;
|
||
// 菱形 clip 保证落在该格内、不串格。rot 为 90° 倍数时屏幕旋转 == 地面旋转,方向正确。
|
||
ctx.save();
|
||
const hw = cell / 2, hh = cell / 4;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, -eh * hh);
|
||
ctx.lineTo(ew * hw, 0);
|
||
ctx.lineTo(0, eh * hh);
|
||
ctx.lineTo(-ew * hw, 0);
|
||
ctx.closePath();
|
||
ctx.clip();
|
||
const s = cell * Math.max(ew, eh) * 1.65;
|
||
ctx.transform(1, 0, 0, 0.5, 0, 0); // 纵向压扁(iso 高度比 1:2)
|
||
ctx.rotate(Math.PI / 4 + rot * Math.PI / 180); // 等距投影基角 + 连接朝向
|
||
ctx.drawImage(img, -s / 2, -s / 2, s, s);
|
||
ctx.restore();
|
||
} else if (flat) {
|
||
// 平面俯视:把方形贴图经「缩放 + 45°旋转 + 纵向压扁」投影到 footprint 菱形内
|
||
// s 取 cell*max(ew,eh)/sqrt(2)≈0.71,让方形旋转后宽≈占地宽,再 clip 到 footprint 防溢出
|
||
ctx.save();
|
||
const hw = cell / 2, hh = cell / 4;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, -eh * hh);
|
||
ctx.lineTo(ew * hw, 0);
|
||
ctx.lineTo(0, eh * hh);
|
||
ctx.lineTo(-ew * hw, 0);
|
||
ctx.closePath();
|
||
ctx.clip();
|
||
const s = cell * Math.max(ew, eh) * 0.72;
|
||
ctx.transform(1, 0, 0, 0.5, 0, 0);
|
||
ctx.rotate(Math.PI / 4);
|
||
ctx.drawImage(img, -s / 2, -s / 2, s, s);
|
||
ctx.restore();
|
||
} else {
|
||
// 3/4 立绘建筑:按 rot 切换 _r/_l 视角;180/270 做水平翻转表示背面,取消 canvas 整体旋转
|
||
// 尺寸贴合占位地块:底宽 = 占位格宽,与格子边缘对齐;
|
||
// 图片脚底对齐 footprint 菱形最下方顶点,避免建筑“浮”在占地左上角。
|
||
const dim = Math.max(ew, eh);
|
||
const w = cell * dim; // 建筑底宽 = 占位格宽
|
||
const h = w / 0.85; // 维持原宽高比(建筑立绘偏高)
|
||
const flip = (rot === 180 || rot === 270);
|
||
ctx.save();
|
||
ctx.scale(flip ? -1 : 1, 1);
|
||
ctx.drawImage(img, -w / 2, eh * cell / 4 - h, w, h);
|
||
ctx.restore();
|
||
}
|
||
return;
|
||
}
|
||
// 占位方块(无图时)
|
||
ctx.fillStyle = 'rgba(255,255,255,0.12)';
|
||
ctx.fillRect(-cell * ew / 2, -cell * eh / 2, cell * ew, cell * eh);
|
||
}
|
||
// 以当前原点为格心,描出 ew×eh 占位的菱形外框
|
||
function microIsoFootprintStroke(ctx, ew, eh, cell) {
|
||
const g = microBuildGeom();
|
||
const hw = g.hw, hh = g.hh;
|
||
const corners = [[0, 0], [ew, 0], [ew, eh], [0, eh]];
|
||
ctx.beginPath();
|
||
corners.forEach(([ci, cj], k) => {
|
||
const dgi = ci - ew / 2, dgj = cj - eh / 2;
|
||
const x = (dgi - dgj) * hw, y = (dgi + dgj) * hh;
|
||
if (k === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||
});
|
||
ctx.closePath();
|
||
ctx.stroke();
|
||
}
|
||
// 在绝对网格(i,j)处描出 ew×eh 占位的菱形外框
|
||
function microIsoFootprintAbs(ctx, i, j, ew, eh) {
|
||
const corners = [[i, j], [i + ew, j], [i + ew, j + eh], [i, j + eh]];
|
||
ctx.beginPath();
|
||
corners.forEach(([ci, cj], k) => {
|
||
const p = microCellToScreen(ci, cj);
|
||
if (k === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
|
||
});
|
||
ctx.closePath();
|
||
ctx.stroke();
|
||
}
|
||
let microBuildCat = 'all'; // 当前建造分类('all' = 全部)
|
||
let microBuildRot = 0; // 当前放置朝向(0/90/180/270,单位°)
|
||
let microSearchTerm = ''; // 素材搜索关键词
|
||
// 拼音检索:覆盖建造素材名用到的汉字(无外部依赖,单文件内置)
|
||
const MICRO_PINYIN = {
|
||
'农':'nong','舍':'she','风':'feng','车':'che','铁':'tie','匠':'jiang','铺':'pu',
|
||
'杂':'za','货':'huo','神':'shen','庙':'miao','医':'yi','务':'wu','室':'shi',
|
||
'饭':'fan','馆':'guan','牧':'mu','场':'chang','黑':'hei','市':'shi','田':'tian',
|
||
'栅':'zha','栏':'lan','围':'wei','墙':'qiang','存':'cun','储':'chu','箱':'xiang',
|
||
'水':'shui','井':'jing','集':'ji','道':'dao','路':'lu','弯':'wan','直':'zhi',
|
||
'草':'cao','地':'di','字':'zi','十':'shi','口':'kou'
|
||
};
|
||
// 生成检索串:原名 + 全拼 + 首字母(支持中文 / 全拼 / 首字母匹配)
|
||
function microItemSearchKey(it) {
|
||
const name = it.name || '';
|
||
let full = '', init = '';
|
||
for (const ch of name) {
|
||
const py = MICRO_PINYIN[ch];
|
||
if (py) { full += py; init += py[0]; } else { full += ch; init += ch; }
|
||
}
|
||
return (name + ' ' + full + ' ' + init).toLowerCase();
|
||
}
|
||
function microBuildItemById(id) {
|
||
return microAllBuildItems().find(x => x.id === id) || { icon: '❓', name: id };
|
||
}
|
||
// 自动道路:连接掩码 → 贴图 + 旋转
|
||
const ROAD_MASK = { N: 1, E: 2, S: 4, W: 8 };
|
||
const ROAD_AUTO_ICON = {
|
||
straight: 'assets/roads/5.png',
|
||
curve: 'assets/roads/4.png',
|
||
t: 'assets/roads/1.png',
|
||
cross: 'assets/roads/2.png'
|
||
};
|
||
// 索引 = 邻接掩码(N=1 E=2 S=4 W=8);值 = [贴图key, 旋转角度]
|
||
// ROAD_CURVE_OFFSET:弯道(curve)整体旋转偏移。等距投影后 local 轴向已精确对齐网格 compass,
|
||
// 但弯道贴图默认连接朝向与表内假设相反(实测 L 弯"拐反"),统一 +90° 校正。
|
||
// 若某个 L 弯仍拐反,把这里改成 0 / 180 / 270 即可(仅影响弯道)。
|
||
const ROAD_CURVE_OFFSET = 90;
|
||
const ROAD_AUTO_TABLE = [
|
||
['straight', 0], // 0 孤立
|
||
['straight', 90], // 1 N
|
||
['straight', 0], // 2 E
|
||
['curve', 270], // 3 N+E
|
||
['straight', 90], // 4 S
|
||
['straight', 90], // 5 N+S
|
||
['curve', 0], // 6 E+S
|
||
['t', 270], // 7 N+E+S
|
||
['straight', 0], // 8 W
|
||
['curve', 180], // 9 N+W
|
||
['straight', 0], //10 E+W
|
||
['t', 180], //11 N+E+W
|
||
['curve', 90], //12 S+W
|
||
['t', 90], //13 N+S+W
|
||
['t', 0], //14 E+S+W
|
||
['cross', 0] //15
|
||
];
|
||
function microIsRoadAuto(b) { return b && b.type === 'road_auto'; }
|
||
function microAutoRoadCells() {
|
||
const list = microBuildList();
|
||
const roadKeys = new Set();
|
||
list.forEach((b, idx) => { if (microIsRoadAuto(b)) roadKeys.add(b.i + ',' + b.j); });
|
||
list.forEach((b, idx) => {
|
||
if (!microIsRoadAuto(b)) return;
|
||
let mask = 0;
|
||
if (roadKeys.has(b.i + ',' + (b.j - 1))) mask |= ROAD_MASK.N;
|
||
if (roadKeys.has((b.i + 1) + ',' + b.j)) mask |= ROAD_MASK.E;
|
||
if (roadKeys.has(b.i + ',' + (b.j + 1))) mask |= ROAD_MASK.S;
|
||
if (roadKeys.has((b.i - 1) + ',' + b.j)) mask |= ROAD_MASK.W;
|
||
const [key, rot] = ROAD_AUTO_TABLE[mask];
|
||
b.rot = (key === 'curve') ? (((rot + ROAD_CURVE_OFFSET) % 360) + 360) % 360 : rot;
|
||
b.sub = ROAD_AUTO_ICON[key];
|
||
});
|
||
}
|
||
// ---- 居民沿道路巡逻(缓动插值 + 行走 bob + 朝向翻转)----
|
||
const MICRO_PATROL_SPEED = 0.55; // 格/秒,越小越悠闲
|
||
function microRoadKeySet() {
|
||
const set = new Set();
|
||
microBuildList().forEach(b => { if (microIsRoadAuto(b)) set.add(b.i + ',' + b.j); });
|
||
return set;
|
||
}
|
||
function microCellCenter(i, j) { return microCellToScreen(i + 0.5, j + 0.5); }
|
||
function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
|
||
function microInitPatrol(u, roadSet) {
|
||
const keys = Array.from(roadSet);
|
||
const start = keys[microHash(u.id) % keys.length].split(',').map(Number);
|
||
return { ci: start[0], cj: start[1], nci: start[0], ncj: start[1], t: (microHash(u.id + 't') % 100) / 100, face: 1 };
|
||
}
|
||
function microPatrolArrive(p, roadSet, u) {
|
||
const fromI = p.ci, fromJ = p.cj;
|
||
p.ci = p.nci; p.cj = p.ncj;
|
||
const mask = (roadSet.has(p.ci + ',' + (p.cj - 1)) ? 1 : 0)
|
||
| (roadSet.has((p.ci + 1) + ',' + p.cj) ? 2 : 0)
|
||
| (roadSet.has(p.ci + ',' + (p.cj + 1)) ? 4 : 0)
|
||
| (roadSet.has((p.ci - 1) + ',' + p.cj) ? 8 : 0);
|
||
const revI = fromI - p.ci, revJ = fromJ - p.cj; // 来路方向,避免立刻折返
|
||
const opts = [];
|
||
if ((mask & 1) && !(0 === revI && -1 === revJ)) opts.push([0, -1]);
|
||
if ((mask & 2) && !(1 === revI && 0 === revJ)) opts.push([1, 0]);
|
||
if ((mask & 4) && !(0 === revI && 1 === revJ)) opts.push([0, 1]);
|
||
if ((mask & 8) && !(-1 === revI && 0 === revJ)) opts.push([-1, 0]);
|
||
const pool = opts.length ? opts : [[0, 0]]; // 死胡同/孤立格:原地待命
|
||
const seed = microHash(u.id + ':' + p.ci + ':' + p.cj + ':' + Math.floor(microState.animTime * 2) + ':' + (u.icon || ''));
|
||
const pick = pool[seed % pool.length];
|
||
p.nci = p.ci + pick[0]; p.ncj = p.cj + pick[1];
|
||
}
|
||
let microBuildMode = false; // 是否处于建造模式
|
||
let microBuildSel = null; // 当前选中素材 id(用于放置新副本)
|
||
let microBuildings = {}; // 按地块存:{ 'q,r': [{i,j,type,rot,w,h}] },避免跨地块串味
|
||
let microGhost = { i: 0, j: 0 }; // 放置预览锚点格(左上角),随鼠标浮动
|
||
let microEditRef = null; // 正在编辑(可移动/旋转/删除)的已放置建筑
|
||
let microEditDrag = false; // 拖拽移动已放置建筑中?
|
||
let buildSaveTimer = null; // 建造布置防抖保存定时器
|
||
let microActionBtns = []; // 当前浮动操作按钮 [{id,x,y,r,label,color}](屏幕坐标)
|
||
let microActionHover = null; // 当前悬停的浮动按钮 id
|
||
let microRoadPainting = false; // 自动道路刷子左键拖拽铺路中
|
||
let microRoadErasing = false; // 自动道路刷子右键拖拽擦除中
|
||
let microRoadLastCell = null; // 上一次铺/擦的格子 key(避免同格重复)
|
||
let microRoadPaintBefore = 0; // 本次道路刷子绘画前的 road_auto 数量(用于结算费用与撤销)
|
||
let microGhostFrozen = false; // 普通素材放置:点击后 ghost 是否已冻结(静止),需点按钮操作
|
||
|
||
// ============ 地图生成 ============
|
||
function buildTile(cell, terrain) {
|
||
const { q, r, s } = cell;
|
||
const hasBuilding = Math.random() > 0.7;
|
||
const buildingType = hasBuilding ? Object.keys(BUILDINGS)[Math.floor(Math.random() * Object.keys(BUILDINGS).length)] : null;
|
||
const unitCount = hasBuilding ? Math.floor(Math.random() * 4) + 1 : (Math.random() > 0.55 ? Math.floor(Math.random() * 2) + 1 : 0);
|
||
const units = [];
|
||
for (let i = 0; i < unitCount; i++) {
|
||
const uKeys = Object.keys(UNITS);
|
||
units.push({
|
||
...UNITS[uKeys[Math.floor(Math.random() * uKeys.length)]],
|
||
id: Math.random().toString(36).substr(2, 6),
|
||
status: Math.random() > 0.3 ? 'working' : 'resting',
|
||
hp: 80 + Math.floor(Math.random() * 40),
|
||
hp_max: 120,
|
||
energy: 60 + Math.floor(Math.random() * 40),
|
||
mood: ['😊','😐','😄'][Math.floor(Math.random() * 3)],
|
||
level: Math.floor(Math.random() * 3) + 1,
|
||
exp: 0,
|
||
equip: { weapon: null, armor: null, accessory: null }
|
||
});
|
||
}
|
||
// 高程:地形决定基础档位 + 确定性噪声在带内微抖(+0/+1,雪地 +0..+2)
|
||
// 同类地块不再完全齐平 → 自然起伏,但跨种类的层差仍然清晰可读
|
||
const baseH = terrainToHeight(terrain);
|
||
const jitterRange = terrain === 'snow' ? 3 : (terrain === 'water' ? 1 : 2);
|
||
const jit = jitterRange > 1 ? Math.floor(_vnoise(q, r, 3.0) * jitterRange) : 0;
|
||
const h = Math.min(MAX_HEIGHT, baseH + jit);
|
||
const tier = heightToTier(h);
|
||
tiles.push({ q, r, s, terrain, layer: 'earth', hasBuilding, buildingType,
|
||
buildingLevel: hasBuilding ? Math.floor(Math.random() * 3) + 1 : 0,
|
||
units, unitCount, explored: Math.random() > 0.2,
|
||
tier,
|
||
height: h
|
||
});
|
||
}
|
||
|
||
// 锁定边界环地块:预设地形/高度,locked=true(已解锁则 false),无建筑/单位(待激活解锁)
|
||
function buildLockedTile(cell, terrain, height, unlocked) {
|
||
const { q, r, s } = cell;
|
||
tiles.push({ q, r, s, terrain, layer: 'earth', hasBuilding: false, buildingType: null,
|
||
buildingLevel: 0, units: [], unitCount: 0, explored: false,
|
||
tier: heightToTier(height), height, locked: !unlocked, variant: -1 });
|
||
}
|
||
|
||
function generateMap() {
|
||
tiles = [];
|
||
// 缩圈后:剔除已解锁集合中超出新边界环(>MAP_RADIUS+1)的残余 key
|
||
for (const key of unlockedRing) {
|
||
const p = key.split(','); const q = +p[0], r = +p[1];
|
||
if (hexRing(q, r) > MAP_RADIUS + 1) unlockedRing.delete(key);
|
||
}
|
||
const all = [];
|
||
for (let q = -MAP_RADIUS; q <= MAP_RADIUS; q++) {
|
||
for (let r = -MAP_RADIUS; r <= MAP_RADIUS; r++) {
|
||
const s = -q - r;
|
||
if (Math.abs(s) > MAP_RADIUS) continue;
|
||
const dist = Math.sqrt(q * q + r * r) / MAP_RADIUS;
|
||
// 平滑群系场:径向偏置 + 低频噪声斑块 → 同种类聚簇成片、中心→外缘有序分层
|
||
const blob = _vnoise(q + 100, r + 100, BIOME_SCALE); // 有机斑块场 [0,1]
|
||
let field = BIOME_RADIAL * dist + (1 - BIOME_RADIAL) * blob; // 中心低→外缘高,整体有序
|
||
field = Math.max(0, Math.min(1, field));
|
||
// 点缀类型各自的噪声场(用于按数量精确挑选成片)
|
||
const waterScore = _vnoise(q + 777, r + 777, BIOME_SCALE * 1.3);
|
||
const desertScore = _vnoise(q - 333, r - 333, BIOME_SCALE * 1.1);
|
||
all.push({ q, r, s, field, waterScore, desertScore });
|
||
}
|
||
}
|
||
const N = all.length;
|
||
// 归一化比例 → 各类型目标数量(最大余数法保证整数且总和=N)
|
||
let sum = 0; for (const k in TERRAIN_RATIO) sum += Math.max(0, TERRAIN_RATIO[k]);
|
||
if (sum <= 0) sum = 1;
|
||
const counts = {}; const frac = {}; let assigned = 0;
|
||
for (const k in TERRAIN_RATIO) {
|
||
const c = (Math.max(0, TERRAIN_RATIO[k]) / sum) * N;
|
||
counts[k] = Math.floor(c); frac[k] = c - counts[k]; assigned += counts[k];
|
||
}
|
||
let rem = N - assigned;
|
||
const remOrder = Object.keys(frac).sort((a, b) => frac[b] - frac[a]);
|
||
for (let i = 0; i < rem; i++) counts[remOrder[i % remOrder.length]]++;
|
||
|
||
const taken = new Set();
|
||
// 点缀类型(水/荒漠):按各自噪声场挑出 top-N 个地块 → 成片/盆地感,数量精确
|
||
const carve = (type, n, key) => {
|
||
if (n <= 0) return;
|
||
const pool = all.map((t, idx) => ({ idx, v: t[key] })).sort((a, b) => b.v - a.v);
|
||
let placed = 0;
|
||
for (const p of pool) {
|
||
if (placed >= n) break;
|
||
if (taken.has(p.idx)) continue;
|
||
taken.add(p.idx);
|
||
buildTile(all[p.idx], type); // 关键:真正把该地块生成成对应地形(原代码漏了这一步)
|
||
placed++;
|
||
}
|
||
};
|
||
carve('water', counts.water, 'waterScore');
|
||
carve('desert', counts.desert, 'desertScore');
|
||
|
||
// 主体陆地(平原→森林→山脉→雪地 沿场值由低到高排布):按归一化区间切分 → 精确比例 + 自然分层
|
||
const mainOrder = ['plain', 'forest', 'mountain', 'snow'];
|
||
const remaining = all.map((t, idx) => idx).filter(i => !taken.has(i)).sort((a, b) => all[a].field - all[b].field);
|
||
let cursor = 0;
|
||
for (const t of mainOrder) {
|
||
for (let i = 0; i < counts[t] && cursor < remaining.length; i++, cursor++) {
|
||
taken.add(remaining[cursor]);
|
||
buildTile(all[remaining[cursor]], t);
|
||
}
|
||
}
|
||
// 四舍五入残余(极少量)→ 平原兜底
|
||
for (; cursor < remaining.length; cursor++) buildTile(all[remaining[cursor]], 'plain');
|
||
// 安全校验:确保每个有效位置都有 tile(防止分配逻辑遗漏导致空格子)
|
||
if (tiles.length < N) console.warn(`[世界] 地图覆盖不完整: ${tiles.length}/${N},已自动补全平原`);
|
||
const generated = new Set(tiles.map(t => t.q + ',' + t.r));
|
||
for (const pos of all) {
|
||
const k = pos.q + ',' + pos.r;
|
||
if (!generated.has(k)) buildTile(pos, 'plain');
|
||
}
|
||
// ===== 锁定边界环:外扩一圈,上半雪地 / 下半水池,locked 待激活 =====
|
||
const RING_R = MAP_RADIUS + 1;
|
||
for (let q = -RING_R; q <= RING_R; q++) {
|
||
for (let r = -RING_R; r <= RING_R; r++) {
|
||
const s = -q - r;
|
||
if (Math.abs(s) > RING_R) continue; // 保持六边形外壳
|
||
if (Math.max(Math.abs(q), Math.abs(r), Math.abs(s)) !== RING_R) continue; // 仅最外圈
|
||
const isTop = r < 0; // 上半圈(屏幕上方)= 雪地
|
||
buildLockedTile({ q, r, s }, isTop ? 'snow' : 'water', isTop ? MAX_HEIGHT : MIN_HEIGHT, unlockedRing.has(q + ',' + r));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============ 坐标工具 ============
|
||
function hexToPixel(q, r) {
|
||
const x = POLY_SIZE * (Math.sqrt(3) * q + Math.sqrt(3) / 2 * r);
|
||
const y = POLY_SIZE * (3 / 2 * r);
|
||
let wx = x * camera.zoom;
|
||
let wy = y * camera.zoom;
|
||
return { x: wx + W / 2 + camera.x, y: wy + H / 2 + camera.y };
|
||
}
|
||
|
||
function pixelToHex(px, py) {
|
||
const size = POLY_SIZE * camera.zoom;
|
||
let wx = px - W / 2 - camera.x;
|
||
let wy = py - H / 2 - camera.y;
|
||
const x = wx / size;
|
||
const y = wy / size;
|
||
let q = (Math.sqrt(3) / 3 * x - 1 / 3 * y);
|
||
let r = (2 / 3 * y);
|
||
const fq = Math.round(q), fr = Math.round(r);
|
||
return { q: fq, r: fr, s: -fq - fr };
|
||
}
|
||
|
||
function getTile(q, r) { return tiles.find(t => t.q === q && t.r === r && t.layer === currentLayer); }
|
||
// 每帧构建一次的地块索引,供 drawHexPrism 判断相邻地形(O(1) 查找,避免逐墙 O(N) 搜索)
|
||
let _tileGrid = null;
|
||
function buildTileGrid() {
|
||
_tileGrid = new Map();
|
||
for (const t of tiles) if (t.layer === currentLayer) _tileGrid.set(t.q + ',' + t.r, t);
|
||
}
|
||
function getTileFast(q, r) { return _tileGrid ? _tileGrid.get(q + ',' + r) : null; }
|
||
|
||
// ============ 绘制12边形 ============
|
||
function drawPoly(x, y, size, fillColor, strokeColor, lineWidth) {
|
||
ctx.beginPath();
|
||
for (let i = 0; i < SIDES; i++) {
|
||
const angle = (Math.PI * 2 / SIDES) * i - Math.PI / 2;
|
||
const px = x + size * Math.cos(angle);
|
||
const py = y + size * Math.sin(angle);
|
||
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
||
}
|
||
ctx.closePath();
|
||
if (fillColor) { ctx.fillStyle = fillColor; ctx.fill(); }
|
||
if (strokeColor) { ctx.strokeStyle = strokeColor; ctx.lineWidth = lineWidth || 1; ctx.stroke(); }
|
||
}
|
||
|
||
// ============ 六棱柱(A 伪3D extrusion) ============
|
||
function prismTopPoints(cx, cy, topR) {
|
||
const pts = [];
|
||
for (let i = 0; i < SIDES; i++) {
|
||
const angle = (Math.PI * 2 / SIDES) * i - Math.PI / 2;
|
||
pts.push({ x: cx + topR * Math.cos(angle), y: cy + topR * Math.sin(angle) });
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
// 六棱柱(柱体):顶面中心在 (cx, cyTop),向下延伸到共同底面(cyTop + wallH)
|
||
// hover=true 时显示明亮六边形轮廓;否则柔和、无生硬白网格线
|
||
// tile 用于判断相邻地块地形:同地形交界弱化缝、异地形交界保留软边界(去网格感,向 WorldBox 风格靠拢)
|
||
const NB_BY_WALL = [ [1,-1], [1,0], [0,1], [-1,1], [-1,0], [0,-1] ]; // 墙 i 对应的轴向邻居方向
|
||
// 确定性散列:相同 (q,r,k) 始终返回同一 [0,1),保证像素噪声不闪烁、不重样
|
||
function _hash2(q, r, k) {
|
||
let h = (q * 374761393 + r * 668265263 + k * 2147483647) | 0;
|
||
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||
h ^= h >>> 16;
|
||
return ((h >>> 0) % 100000) / 100000;
|
||
}
|
||
// 地形变体噪声频率:[PLACEHOLDER] 控制"草地斑块"大小。值大=斑块大、更聚拢;值小=细碎。刷新后凭手感调
|
||
const TERRAIN_NOISE_SCALE = 4.5; // [PLACEHOLDER] 草地斑块聚簇尺度:越大越紧挨成片(之前 3.0 偏碎)
|
||
// 生物群系聚簇(地形种类分布):低频噪声斑块 + 轻微径向偏置 → 同种类紧挨成片、按海拔有序分层、边界有机
|
||
const BIOME_SCALE = 2.2; // [PLACEHOLDER] 聚簇尺度:越小地块越大片
|
||
const BIOME_RADIAL = 0.55; // [PLACEHOLDER] 径向偏置(0=纯斑块,1=纯同心圆):控制"层次"是否明显
|
||
|
||
// ============ 地表比例:创作者自调,应用后重构地表 ============
|
||
// 归一化后驱动生成;保证「精确比例 + 平滑场聚簇(同类成片、中心→外缘分层)」
|
||
// [PLACEHOLDER] 以下默认值为当前手调好的沙盘配比,创作者可随意改
|
||
const VALID_TERRAINS = ['plain', 'forest', 'mountain', 'snow', 'water', 'desert'];
|
||
let TERRAIN_RATIO = { plain: 4, forest: 3, mountain: 2, snow: 2, water: 1, desert: 0 };
|
||
const TERRAIN_RATIO_PRESETS = {
|
||
default: { plain: 4, forest: 3, mountain: 2, snow: 2, water: 1, desert: 0 },
|
||
island: { plain: 5, forest: 3, mountain: 1, snow: 1, water: 2, desert: 0 },
|
||
peaks: { plain: 2, forest: 2, mountain: 5, snow: 4, water: 1, desert: 0 },
|
||
water: { plain: 3, forest: 2, mountain: 2, snow: 1, water: 5, desert: 0 }
|
||
};
|
||
function loadSavedRatio() {
|
||
try {
|
||
const raw = localStorage.getItem('terrainRatio');
|
||
if (raw) { const o = JSON.parse(raw); for (const k in TERRAIN_RATIO) if (typeof o[k] === 'number') TERRAIN_RATIO[k] = o[k]; }
|
||
} catch (e) {}
|
||
// 清理废弃 key(已被移除的地形),防止分配数泄露导致空格子
|
||
for (const k in TERRAIN_RATIO) if (!VALID_TERRAINS.includes(k)) delete TERRAIN_RATIO[k];
|
||
}
|
||
// 低频值噪声:返回 [0,1),相邻坐标值接近 → 自然形成连续"草地斑块",比逐格哈希有序、不杂乱
|
||
function _vnoise(q, r, scale){
|
||
const x = q / scale, y = r / scale;
|
||
const x0 = Math.floor(x), y0 = Math.floor(y);
|
||
const fx = x - x0, fy = y - y0;
|
||
const sx = fx*fx*(3 - 2*fx), sy = fy*fy*(3 - 2*fy); // smoothstep 平滑插值
|
||
const n00 = _hash2(x0, y0, 131);
|
||
const n10 = _hash2(x0+1, y0, 131);
|
||
const n01 = _hash2(x0, y0+1, 131);
|
||
const n11 = _hash2(x0+1, y0+1, 131);
|
||
const nx0 = n00 + (n10 - n00) * sx;
|
||
const nx1 = n01 + (n11 - n01) * sx;
|
||
return nx0 + (nx1 - nx0) * sy;
|
||
}
|
||
function drawHexPrism(cx, cyTop, topR, wallH, topColor, hover, tile) {
|
||
const L = (currentPalette && currentPalette.light) || 1.0; // 亮度保底因子 0.62..1.0
|
||
let dim = (L - 1) * 60; // 全局压暗量(夜晚约 -23)
|
||
if (!tile || !tile.locked) dim += 10; // 未锁定格整体提亮,与锁定灰罩拉开对比
|
||
const base = mixColor(topColor, currentPalette.ambient, 0.12); // 时间色调(弱叠加,不压黑)
|
||
const src = (tile && tile.terrain) ? resolveTerrainSrc(tile) : null; // 及早解析纹理,侧壁/顶面共用
|
||
if (wallH < 1) {
|
||
drawPoly(cx, cyTop, topR, shadeColor(base, Math.max(-14, dim)),
|
||
hover ? 'rgba(120,210,255,0.55)' : null, hover ? 1.5 : 0);
|
||
if (tile && tile.locked) drawLockedOverlay(cx, cyTop, topR, wallH);
|
||
return;
|
||
}
|
||
// 注:交互期不再走"纯色兜底"分支——改由 render() 把世界烘焙进离屏快照,拖动/缩放只 blit 快照,
|
||
// 纹理全程可见且不掉帧(见 render() 的交互快速路径)。完整绘制始终走下方路径。
|
||
const sun = currentPalette.sun;
|
||
const top = prismTopPoints(cx, cyTop, topR);
|
||
// 高度阴影:整块投下柔和投影,强化起伏层次(夜/低光下投影略重,但不超 0.30)
|
||
const shA = 0.20 + 0.10 * (1 - L);
|
||
ctx.save();
|
||
if (shadowFade > 0.003) { // 拖动/缩放时 shadowFade 收敛到 0(关投影省模糊卷积);松手后由 render 渐入回 1,不再硬跳变
|
||
ctx.shadowColor = `rgba(0,0,0,${(shA * shadowFade).toFixed(3)})`;
|
||
ctx.shadowBlur = 7 * camera.zoom * shadowFade;
|
||
ctx.shadowOffsetY = 4 * camera.zoom * shadowFade;
|
||
}
|
||
// 侧壁:柔和竖直渐变(顶微亮→底微暗),对比调弱 ~30% 更透气(不像重 3D 块)
|
||
for (let i = 0; i < SIDES; i++) {
|
||
const a = top[i], b = top[(i + 1) % SIDES];
|
||
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||
const ang = Math.atan2(mid.y - cyTop, mid.x - cx);
|
||
const lit = Math.cos(ang - sun); // 朝向太阳的边更亮:-1..1
|
||
if (src) {
|
||
// 侧壁贴图:复用顶面同一张地形图,裁剪到该墙面 → 一块被竖向挤出的实心地形
|
||
paintWallTexture(cx, cyTop, topR, wallH, src, tile.terrain, a, b, lit);
|
||
} else {
|
||
// 兜底:无贴图时用纯色竖直渐变(原行为)
|
||
let s = lit * 8 + dim;
|
||
s = Math.max(-34, Math.min(6, s)); // 亮度保底:最暗不过 -34%
|
||
const wt = shadeColor(base, Math.min(4, s + 6)); // 壁顶受光(调柔)
|
||
const wb = shadeColor(base, Math.max(-30, s - 11)); // 壁底入影(调柔)
|
||
const g = ctx.createLinearGradient(0, a.y, 0, a.y + wallH);
|
||
g.addColorStop(0, wt);
|
||
g.addColorStop(1, wb);
|
||
ctx.beginPath();
|
||
ctx.moveTo(a.x, a.y);
|
||
ctx.lineTo(b.x, b.y);
|
||
ctx.lineTo(b.x, b.y + wallH);
|
||
ctx.lineTo(a.x, a.y + wallH);
|
||
ctx.closePath();
|
||
ctx.fillStyle = g;
|
||
ctx.fill();
|
||
}
|
||
// 边界缝:极柔,弱化外边框的"硬"感(异地形交界只留极淡分界,同地形几乎无痕)
|
||
let seam = 'rgba(0,0,0,0.015)';
|
||
if (tile) {
|
||
const nb = getTileFast(tile.q + NB_BY_WALL[i][0], tile.r + NB_BY_WALL[i][1]);
|
||
if (!nb || nb.terrain !== tile.terrain) seam = 'rgba(0,0,0,0.05)';
|
||
}
|
||
ctx.strokeStyle = seam;
|
||
ctx.lineWidth = 0.5;
|
||
ctx.stroke();
|
||
}
|
||
// 顶面:柔和径向渐变(中心微亮 → 边缘微暗),对比调弱更温润
|
||
const rad = ctx.createRadialGradient(cx, cyTop - topR * 0.3, topR * 0.1, cx, cyTop, topR * 1.05);
|
||
rad.addColorStop(0, shadeColor(base, Math.max(0, dim * 0.5) + 5));
|
||
rad.addColorStop(1, shadeColor(base, Math.max(-14, dim - 10)));
|
||
// 轮廓:仅悬停/选中时显示,避免“生硬”的常驻网格
|
||
drawPoly(cx, cyTop, topR, rad,
|
||
hover ? 'rgba(120,210,255,0.55)' : null, hover ? 1.5 : 0);
|
||
ctx.restore(); // 结束高度阴影(高光描边与像素颗粒不再带影,保持清爽)
|
||
// 顶面纹理:复用 drawHexPrism 开头已解析的 src(与侧壁同源),动态视频优先,PNG 兜底
|
||
if (src) {
|
||
paintHexTexture(cx, cyTop, topR, src, tile.terrain);
|
||
}
|
||
// 顶面边缘描边:邻居同地形则跳过该边(去掉相同板块间的内部网格线,板块无缝融合)
|
||
// 仅异地形交界 / 地图边缘 留极淡白边,作为柔和的生态分界,不抢眼
|
||
for (let i = 0; i < SIDES; i++) {
|
||
const nb = tile ? getTileFast(tile.q + NB_BY_WALL[i][0], tile.r + NB_BY_WALL[i][1]) : null;
|
||
if (!tile || !nb || nb.terrain !== tile.terrain) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(top[i].x, top[i].y);
|
||
ctx.lineTo(top[(i + 1) % SIDES].x, top[(i + 1) % SIDES].y);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.030)';
|
||
ctx.lineWidth = 0.6;
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
// 锁定遮罩:灰色 50% 半透明 + 锁图标,与正常格子区分
|
||
if (tile && tile.locked) drawLockedOverlay(cx, cyTop, topR, wallH);
|
||
}
|
||
|
||
// 锁定地块视觉:深色半透明罩覆盖顶面+侧面,与正常格子区分(无图标)
|
||
function drawLockedOverlay(cx, cyTop, topR, wallH) {
|
||
const top = prismTopPoints(cx, cyTop, topR);
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.6;
|
||
ctx.fillStyle = '#4b5563';
|
||
// 顶面
|
||
ctx.beginPath();
|
||
top.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
// 侧面(wallH >= 1 才有实际高度)
|
||
if (wallH >= 1) {
|
||
for (let i = 0; i < SIDES; i++) {
|
||
const a = top[i], b = top[(i + 1) % SIDES];
|
||
ctx.beginPath();
|
||
ctx.moveTo(a.x, a.y);
|
||
ctx.lineTo(b.x, b.y);
|
||
ctx.lineTo(b.x, b.y + wallH);
|
||
ctx.lineTo(a.x, a.y + wallH);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// ============ 渲染 ============
|
||
// 把当前主画布(已全细节渲染的世界)烘焙进离屏快照,供交互期 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;
|
||
}
|
||
|
||
// 平移专用的超采样快照:烤一张 (W+2M)×(H+2M) 的快照,世界置于中心,四周留 M 边距。
|
||
// 平移时只 blit 视口对应区域(中心 ± delta),M 内任意平移都零露缝;delta 接近 M 时由调用方触发重烤。
|
||
// 背景:场景图层清透明(CSS 底图透出),非场景层在绘制前由 render() 补调色板渐变兜底(露出的边距区同底色,无灰棕带)。
|
||
function bakePanSnapshot(M) {
|
||
if (!worldCache) { worldCache = document.createElement('canvas'); worldCacheCtx = worldCache.getContext('2d'); }
|
||
if (worldCache.width !== W + 2 * M || worldCache.height !== H + 2 * M) {
|
||
worldCache.width = W + 2 * M; worldCache.height = H + 2 * M;
|
||
}
|
||
let sortedTiles;
|
||
if (_cachedSorted && _cachedSortedLayer === currentLayer) sortedTiles = _cachedSorted;
|
||
else { sortedTiles = tiles.filter(t => t.layer === currentLayer).sort((a, b) => (a.r + a.q * 0.5) - (b.r + b.q * 0.5)); _cachedSorted = sortedTiles; _cachedSortedLayer = currentLayer; }
|
||
const size = POLY_SIZE * camera.zoom;
|
||
const savedCtx = ctx; // 临时把全局绘制目标指向超采样缓存
|
||
ctx = worldCacheCtx;
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.clearRect(0, 0, W + 2 * M, H + 2 * M); // 整张清掉(含边距),避免上一帧残留
|
||
ctx.save();
|
||
ctx.translate(M, M); // 世界绘到快照中心,四周留 overscan 边距
|
||
renderBaseWorld(sortedTiles, size);
|
||
ctx.restore();
|
||
ctx = savedCtx; // 还原主画布 ctx
|
||
}
|
||
function render() {
|
||
if (microViewOpen) { requestAnimationFrame(render); return; } // 微观视图打开时主世界暂停重绘,把 CPU 让给微观渲染循环
|
||
// 阴影投影已关闭(shadowFade 恒为 0):交互/静止均无逐格模糊、无渐变跳变。
|
||
const targetShadow = 0;
|
||
shadowFade += (targetShadow - shadowFade) * 0.14;
|
||
if (shadowFade < 0.003) shadowFade = 0;
|
||
|
||
// ---- 交互快速路径 ----
|
||
// 平移(PAN):超采样快照 blit(每帧 1 次 drawImage,最快、零露缝、无灰棕带)
|
||
// 缩放(ZOOM):不进快路径,落到下方静止帧全量重绘(缩放快照会露缝,且全量重绘在阴影关闭后开销可忽略)
|
||
if (camInteracting) {
|
||
const zoomChanged = Math.abs(camera.zoom - _cachedCam.zoom) > 1e-4;
|
||
if (!zoomChanged) {
|
||
const M = Math.min(Math.max(W, H), 1600); // 超采样边距(单方向),覆盖一次拖拽的全部平移;封顶避免超大画布
|
||
const pdx = Math.abs(camera.x - _cachedCam.x), pdy = Math.abs(camera.y - _cachedCam.y);
|
||
// delta 接近边距上限时重烤(重置 _cachedCam),避免超出 overscan 露缝
|
||
if (!worldCacheValid || pdx > M * 0.95 || pdy > M * 0.95) {
|
||
bakePanSnapshot(M);
|
||
_cachedCam.x = camera.x; _cachedCam.y = camera.y; _cachedCam.zoom = camera.zoom;
|
||
worldCacheValid = true;
|
||
}
|
||
// 背景:与静止帧一致。场景图层清透明(CSS 底图透出);非场景层补调色板渐变,
|
||
// 即便边距区露出透明也同底色 → 无灰棕遮罩带。
|
||
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);
|
||
}
|
||
// blit 视口对应区域:快照中心(M,M)对应视口(0,0),相机位移 delta 直接偏移源起点
|
||
const dx = camera.x - _cachedCam.x, dy = camera.y - _cachedCam.y;
|
||
ctx.drawImage(worldCache, M - dx, M - dy, W, H, 0, 0, W, H);
|
||
requestAnimationFrame(render);
|
||
return;
|
||
}
|
||
// 缩放路径:fall-through 到下方静止帧全量重绘(不 return)
|
||
}
|
||
|
||
// ---- 主渲染(静止帧):时间推进 + UI 刷新 + 调色板 + 全量重绘 ----
|
||
animTime += 0.016;
|
||
if (timePlaying) {
|
||
const prevT = timeOfDay;
|
||
timeOfDay = nowHours(); // 静止帧同步系统真实时间
|
||
if (timeOfDay < prevT) gameDay++; // 跨过午夜,游戏天数 +1(驱动日历/季节)
|
||
}
|
||
syncTimeUI(); // 每帧刷新时钟(即便暂停也刷);秒针由真实时间驱动持续扫动
|
||
refreshWorldVideo(); // 时间节点变化则切换背景视频(其余帧直接跳过)
|
||
currentPalette = getSky(currentLayer, timeOfDay);
|
||
worldCacheValid = false; // 静止帧:下次交互重新烘焙快照(世界可能已变化)
|
||
|
||
// 调色板(时间色调)明显变化 → 基座缓存失效,重建背景 + 地块层
|
||
// 注意:top/mid/bot 是连续插值的颜色,每帧微量变动,必须粗化到色阶桶,否则缓存每帧失效
|
||
const r3 = s => { const m = /(\d+),(\d+),(\d+)/.exec(s); return m ? `${(+m[1]>>3)},${(+m[2]>>3)},${(+m[3]>>3)}` : s; };
|
||
const pk = r3(currentPalette.top) + '|' + r3(currentPalette.mid) + '|' + r3(currentPalette.bot) + '|' + (currentPalette.tintA || 0).toFixed(1);
|
||
if (pk !== _lastPaletteKey) { _lastPaletteKey = pk; worldBaseValid = false; }
|
||
|
||
const size = POLY_SIZE * camera.zoom;
|
||
// 地块绘制顺序:每次重建(tiles 集合与相邻关系不变,但 O(N log N) 很轻,且保证建造结果即时可见)
|
||
let sortedTiles;
|
||
if (_cachedSorted && _cachedSortedLayer === currentLayer) {
|
||
sortedTiles = _cachedSorted;
|
||
} else {
|
||
sortedTiles = tiles.filter(t => t.layer === currentLayer).sort((a, b) => {
|
||
return (a.r + a.q * 0.5) - (b.r + b.q * 0.5);
|
||
});
|
||
_cachedSorted = sortedTiles;
|
||
_cachedSortedLayer = currentLayer;
|
||
}
|
||
if (!_tileGrid) buildTileGrid(); // 相邻判断索引:仅在首次/失效时重建
|
||
|
||
// ---- 基座层:背景 + 全部地块(无悬停/选中高亮),缓存到离屏画布,每帧只 blit 不走逐格阴影/贴图重绘 ----
|
||
if (!worldBaseValid || !worldBaseCache) {
|
||
if (!worldBaseCache) { worldBaseCache = document.createElement('canvas'); worldBaseCtx = worldBaseCache.getContext('2d'); }
|
||
if (worldBaseCache.width !== W || worldBaseCache.height !== H) { worldBaseCache.width = W; worldBaseCache.height = H; }
|
||
// 先把基座渲染到主画布,再拷进缓存(避免改所有绘制函数签名)
|
||
renderBaseWorld(sortedTiles, size);
|
||
worldBaseCtx.setTransform(1, 0, 0, 1, 0, 0);
|
||
worldBaseCtx.clearRect(0, 0, W, H);
|
||
worldBaseCtx.drawImage(canvas, 0, 0);
|
||
worldBaseValid = true;
|
||
}
|
||
|
||
// ---- 主画布:blit 基座 + 叠动态特效 + 悬停/选中高亮 ----
|
||
ctx.clearRect(0, 0, W, H);
|
||
ctx.drawImage(worldBaseCache, 0, 0);
|
||
// 时间色调叠加已移除(用户反馈:整个页面有棕褐色滤镜,不需要)
|
||
// 原逻辑:仅大地/天空随昼夜叠一层 currentPalette.tint(日间 [255,180,120]@0.12 = 橙棕遮罩)
|
||
drawBackgroundFx(currentLayer);
|
||
|
||
// 悬停/选中/交换高亮(仅受影响的格,逐帧轻量重绘,不碰地块主体)
|
||
sortedTiles.forEach(tile => {
|
||
const { x, y } = hexToPixel(tile.q, tile.r);
|
||
const ez = heightToTier(tile.height != null ? tile.height : terrainToHeight(tile.terrain)) * TIER_STEP * camera.zoom;
|
||
const colTop = y - ez, colBot = y + ez + SANDBOX_DEPTH * camera.zoom;
|
||
if (x < -size || x > W + size) return;
|
||
if (colBot < -size || colTop > H + size) return;
|
||
const cyTop = y - ez;
|
||
const isHovered = hoveredTile && hoveredTile.q === tile.q && hoveredTile.r === tile.r;
|
||
const isSelected = selectedTile && selectedTile.q === tile.q && selectedTile.r === tile.r;
|
||
const isSwapFirst = swapFirst && swapFirst.q === tile.q && swapFirst.r === tile.r;
|
||
const isSwapFrom = draggingSwap && dragSwapFrom && dragSwapFrom.q === tile.q && dragSwapFrom.r === tile.r;
|
||
const isSwapTarget = draggingSwap && hoveredTile && hoveredTile.q === tile.q && hoveredTile.r === tile.r && (!dragSwapFrom || !(dragSwapFrom.q === tile.q && dragSwapFrom.r === tile.r));
|
||
if (isSwapFirst || isSwapFrom) drawPoly(x, cyTop, size * 1.0, null, '#ffd54f', 2.5);
|
||
else if (isSwapTarget) drawPoly(x, cyTop, size * 1.0, null, '#ffb74d', 2);
|
||
else if (isSelected) drawPoly(x, cyTop, size * 1.0, null, '#4af', 2.5);
|
||
else if (isHovered) drawPoly(x, cyTop, size * 1.0, null, 'rgba(100,200,255,0.6)', 1.5);
|
||
if (isHovered) {
|
||
ctx.save();
|
||
ctx.shadowColor = '#4af'; ctx.shadowBlur = 14;
|
||
drawPoly(x, cyTop, size * 1.0, 'rgba(100,200,255,0.10)', null, 0);
|
||
ctx.restore();
|
||
}
|
||
if (isSelected) {
|
||
const p = 0.5 + 0.5 * Math.sin(animTime * 4);
|
||
ctx.save();
|
||
ctx.strokeStyle = `rgba(100,200,255,${0.3 + 0.3 * p})`;
|
||
ctx.lineWidth = 2; ctx.setLineDash([4 * camera.zoom, 4 * camera.zoom]);
|
||
drawPoly(x, cyTop, size * 1.0, null, `rgba(100,200,255,${0.3 + 0.3 * p})`, 2);
|
||
ctx.setLineDash([]);
|
||
ctx.restore();
|
||
}
|
||
});
|
||
|
||
// 松手过渡淡出:在上方叠放拖动的快照(同 camera 变换 + 衰减 alpha),平滑 blit→全量 的亚像素/插值差异,
|
||
// 2-3 帧消隐,过渡无跳。仅场景图层(useImageBg=透明画布)做过渡,非场景图层直接切。
|
||
if (releaseBlend > 0.003 && worldCache) {
|
||
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);
|
||
// 同交互快路径:缩小时中心裁切填满,避免过渡帧边缘露缝
|
||
if (sc < 0.995) {
|
||
const fs = 1 / sc;
|
||
const sx = (W - W * fs) / 2, sy = (H - H * fs) / 2;
|
||
const sw = W * fs, sh = H * fs;
|
||
const dx = tx - (W - W * sc) / 2, dy = ty - (H - H * sc) / 2;
|
||
ctx.drawImage(worldCache, sx, sy, sw, sh, dx, dy, W, H);
|
||
} else {
|
||
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;
|
||
}
|
||
|
||
// 静止帧末尾同步相机基准(缩放/平移结束后),供下次交互的 zoomChanged 判断与快照重烤使用
|
||
_cachedCam.x = camera.x; _cachedCam.y = camera.y; _cachedCam.zoom = camera.zoom;
|
||
|
||
requestAnimationFrame(render);
|
||
}
|
||
|
||
// 渲染基座层(背景 + 全地块,不含悬停/选中高亮)到主画布,供缓存拷贝
|
||
function renderBaseWorld(sortedTiles, size) {
|
||
ctx.clearRect(0, 0, W, H);
|
||
drawBackgroundBase(currentLayer);
|
||
|
||
// ===== [临时注释] 六边形底色基底:沙盘下方垫一块正六边形实心底板 ======
|
||
// 以地图中心为圆心,算最远 tile 距离 + 外扩圈数,用等角度 6 顶点画正六边形(与沙盘网格同心对齐)
|
||
if (false && sortedTiles.length) {
|
||
// 地图中心:所有 tile 屏幕位置的均值(比取单格更稳)
|
||
let cx = 0, cy = 0;
|
||
for (const t of sortedTiles) { const p = hexToPixel(t.q, t.r); cx += p.x; cy += p.y; }
|
||
cx /= sortedTiles.length; cy /= sortedTiles.length;
|
||
// 最远 tile 中心距离(含自身半径 ≈ 一个 hex 宽度)
|
||
let maxDist = 0;
|
||
for (const t of sortedTiles) {
|
||
const p = hexToPixel(t.q, t.r);
|
||
const d = Math.hypot(p.x - cx, p.y - cy);
|
||
if (d > maxDist) maxDist = d;
|
||
}
|
||
// 外扩:当前地图外扩 1.4 圈
|
||
const EXTRA_RINGS = 1.4;
|
||
const baseR = (maxDist + size) * (1 + EXTRA_RINGS * size / Math.max(1, maxDist));
|
||
// 每边独立边距系数(按顶点顺序,flat-top 角度 0/60/120/180/240/300°)
|
||
// 想单独调某条边:对应值 >1 外扩、<1 内收;每个值作用到该顶点,相邻两条边随之变化
|
||
const EDGE_MARGINS = [0.9, 0.9, 0.9, 0.9, 1.0, 1.0]; // 仅上边(v4–v5)=1.0,其余=0.9
|
||
// 用 flat-top 角度系(0 起)生成正六边形顶点——与沙盘网格外轮廓对齐
|
||
const hexVerts = [];
|
||
for (let i = 0; i < SIDES; i++) {
|
||
const ang = (Math.PI * 2 / SIDES) * i; // flat-top:平边在上/下
|
||
const r = baseR * (EDGE_MARGINS[i] != null ? EDGE_MARGINS[i] : 1.0);
|
||
hexVerts.push({ x: cx + r * Math.cos(ang), y: cy + r * Math.sin(ang) });
|
||
}
|
||
const fillHex = () => {
|
||
ctx.beginPath();
|
||
hexVerts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
};
|
||
// 单层:柔和冷蓝灰径向渐变 + 羽化外发光
|
||
ctx.save();
|
||
const grad = ctx.createRadialGradient(cx, cy - baseR * 0.15, baseR * 0.08, cx, cy, baseR * 1.06);
|
||
grad.addColorStop(0.00, 'rgba(120,200,150,0.4)');
|
||
grad.addColorStop(0.55, 'rgba(92,168,120,0.4)');
|
||
grad.addColorStop(1.00, 'rgba(66,138,92,0.4)'); // 40% 透明亮绿色实心底色
|
||
ctx.fillStyle = grad;
|
||
ctx.shadowColor = 'rgba(132,172,232,0.30)';
|
||
ctx.shadowBlur = 30;
|
||
fillHex();
|
||
ctx.restore();
|
||
}
|
||
|
||
sortedTiles.forEach(tile => {
|
||
const { x, y } = hexToPixel(tile.q, tile.r);
|
||
const ez = heightToTier(tile.height != null ? tile.height : terrainToHeight(tile.terrain)) * TIER_STEP * camera.zoom;
|
||
const colTop = y - ez, colBot = y + ez + SANDBOX_DEPTH * camera.zoom;
|
||
if (x < -size || x > W + size) return;
|
||
if (colBot < -size || colTop > H + size) return;
|
||
const terrain = TERRAIN[tile.terrain] || TERRAIN.plain; // 防御:未知地形回退平原
|
||
const cyTop = y - ez;
|
||
const wallH = Math.max(2, ez + SANDBOX_DEPTH * camera.zoom);
|
||
drawHexPrism(x, cyTop, size * 1.0, wallH, terrain.color, false, tile);
|
||
});
|
||
|
||
// (边框光晕已移除——改用六边形底色基底分离边界,见上方 drawBackgroundBase 之后)
|
||
}
|
||
|
||
// ============ 分层背景(D) ============
|
||
function initStars() {
|
||
stars = [];
|
||
for (let i = 0; i < 100; i++) {
|
||
stars.push({ x: Math.random() * 2000, y: Math.random() * 2000,
|
||
r: Math.random() * 1.2 + 0.3, speed: Math.random() * 0.3 + 0.1,
|
||
alpha: Math.random() * 0.5 + 0.2 });
|
||
}
|
||
}
|
||
|
||
function initEmbers() {
|
||
embers = [];
|
||
for (let i = 0; i < 55; i++) {
|
||
embers.push({ x: Math.random() * 2000, y: Math.random() * 2000,
|
||
r: Math.random() * 1.5 + 0.5, speed: Math.random() * 0.4 + 0.1 });
|
||
}
|
||
}
|
||
|
||
function drawStars() {
|
||
const aMul = currentPalette.star || 0;
|
||
if (aMul <= 0.01) return;
|
||
stars.forEach(s => {
|
||
s.y += s.speed * 0.3;
|
||
if (s.y > H + 10) { s.y = -10; s.x = Math.random() * W; }
|
||
const twinkle = (s.alpha + 0.15 * Math.sin(animTime * 2 + s.x)) * aMul;
|
||
if (twinkle <= 0.01) return;
|
||
ctx.fillStyle = `rgba(200,215,255,${twinkle})`;
|
||
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
|
||
});
|
||
}
|
||
|
||
function drawSilhouette() {
|
||
// 远景山影(仅地表层),随当前时间调色板染环境色
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.7;
|
||
ctx.fillStyle = mixColor('#0a1422', currentPalette.ambient, Math.min(0.5, currentPalette.amb));
|
||
const baseY = H * 0.82;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, H);
|
||
ctx.lineTo(0, baseY);
|
||
const peaks = 10;
|
||
for (let i = 0; i <= peaks; i++) {
|
||
const px = (W / peaks) * i;
|
||
const py = baseY - Math.abs(Math.sin(i * 0.9 + 1.2)) * 55 - 15;
|
||
ctx.lineTo(px, py);
|
||
}
|
||
ctx.lineTo(W, H);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawEmbers() {
|
||
// 地下火星上浮粒子(仅地下层)
|
||
embers.forEach(e => {
|
||
e.y -= e.speed;
|
||
if (e.y < -10) { e.y = H + 10; e.x = Math.random() * W; }
|
||
const a = 0.35 + 0.4 * Math.sin(animTime * 3 + e.x);
|
||
ctx.fillStyle = `rgba(255,140,50,${a})`;
|
||
ctx.beginPath(); ctx.arc(e.x, e.y, e.r, 0, Math.PI * 2); ctx.fill();
|
||
});
|
||
}
|
||
|
||
function drawBackgroundBase(layer) {
|
||
const pal = currentPalette;
|
||
// 场景背景图接入:透明画布,让底层 <img> 透出(图片层自行呈现背景)
|
||
if (useImageBg) {
|
||
ctx.clearRect(0, 0, W, H);
|
||
return;
|
||
}
|
||
// 草地背景图(替换默认渐变)
|
||
if (grassBgImg && grassBgImg.naturalWidth > 0) {
|
||
// 平铺填充整个画布
|
||
const iw = grassBgImg.naturalWidth, ih = grassBgImg.naturalHeight;
|
||
for (let y = 0; y < H; y += ih) {
|
||
for (let x = 0; x < W; x += iw) {
|
||
ctx.drawImage(grassBgImg, x, y, iw, ih);
|
||
}
|
||
}
|
||
} else {
|
||
// 降级:原始渐变
|
||
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);
|
||
}
|
||
if (layer === 'earth') {
|
||
drawTimeAmbiance(pal); // 时间辉光随调色板变化,属静态基座层(在地形之下)
|
||
drawSilhouette(); // 山影属静态基座层
|
||
}
|
||
}
|
||
// 动态特效层:星点/火星逐帧绘制在基础缓存之上(轻微叠在地形上,作为氛围粒子可接受)
|
||
function drawBackgroundFx(layer) {
|
||
const pal = currentPalette;
|
||
if (layer === 'earth') {
|
||
if (pal.star > 0.05) drawStars();
|
||
} else {
|
||
if (layer === 'star' && pal.star > 0.05) drawStars();
|
||
if (layer === 'under') drawEmbers();
|
||
}
|
||
}
|
||
|
||
// 随时间变化的环境光(太阳/月亮辉光 + 阶段粒子)
|
||
function drawTimeAmbiance(pal) {
|
||
if (pal.scene === 'static') return;
|
||
ctx.save();
|
||
ctx.globalCompositeOperation = 'lighter';
|
||
const sx = W * (0.5 + 0.42 * Math.cos(pal.sun));
|
||
const sy = H * (0.95 + 0.55 * Math.sin(pal.sun));
|
||
const glow = pal.scene === 'night' ? 'rgba(150,180,255,'
|
||
: pal.scene === 'dusk' ? 'rgba(255,140,70,'
|
||
: 'rgba(255,210,150,';
|
||
const r = pal.scene === 'night' ? 170 : 230;
|
||
const rg = ctx.createRadialGradient(sx, sy, 0, sx, sy, r);
|
||
rg.addColorStop(0, glow + '0.5)');
|
||
rg.addColorStop(1, glow + '0)');
|
||
ctx.fillStyle = rg;
|
||
ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2); ctx.fill();
|
||
ctx.restore();
|
||
|
||
// 阶段粒子:黄昏萤火 / 黑夜冷光浮尘
|
||
if (pal.scene === 'dusk' || pal.scene === 'night') {
|
||
ambParticles.forEach(p => {
|
||
p.x += p.vx; p.y += p.vy;
|
||
if (p.x < 0) p.x = W; if (p.x > W) p.x = 0;
|
||
if (p.y < 0) p.y = H; if (p.y > H) p.y = 0;
|
||
const fl = 0.4 + 0.4 * Math.sin(animTime * 2 + p.x);
|
||
ctx.fillStyle = pal.scene === 'dusk'
|
||
? `rgba(255,220,140,${fl * 0.6})`
|
||
: `rgba(140,200,255,${fl * 0.5})`;
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill();
|
||
});
|
||
}
|
||
}
|
||
|
||
// ============ 随机过场插画(飞入过渡背景用) ============
|
||
const TRANSITION_IMAGES = [
|
||
'assets/art/transitions/transition_dungeon.webp',
|
||
'assets/art/transitions/transition_adventure.webp',
|
||
'assets/art/transitions/transition_sky.webp',
|
||
'assets/art/transitions/transition_forest.webp',
|
||
'assets/art/transitions/transition_night.webp',
|
||
'assets/art/transitions/transition_dawn.webp',
|
||
'assets/art/transitions/transition_coast.webp',
|
||
'assets/art/transitions/transition_skycity.webp',
|
||
];
|
||
let _lastTransitionIdx = -1; // 避免连续两次同一张
|
||
function pickTransitionImage() {
|
||
let idx;
|
||
do { idx = Math.floor(Math.random() * TRANSITION_IMAGES.length); } while (idx === _lastTransitionIdx && TRANSITION_IMAGES.length > 1);
|
||
_lastTransitionIdx = idx;
|
||
return TRANSITION_IMAGES[idx];
|
||
}
|
||
|
||
// ============ 微观世界标题:有自定义名显示「层 · 自定义名」,否则默认「层 · 地形村落」 ============
|
||
function microTitleText(tile) {
|
||
const key = tile.q + ',' + tile.r;
|
||
const custom = microNames[key];
|
||
const layerLabel = { earth: '🌍 大地', under: '🕳 地下', core: '🔥 地心', sky: '🌤 天空', star: '✦ 星空' }[tile.layer] || '';
|
||
if (custom) return `${layerLabel} · ${custom}`;
|
||
return `${layerLabel} · ${TERRAIN[tile.terrain].name}村落`;
|
||
}
|
||
|
||
// ============ 进入微观世界 ============
|
||
function enterMicroWorld(tile) {
|
||
if (!tile) return;
|
||
const overlay = document.getElementById('flyInOverlay');
|
||
const microView = document.getElementById('microView');
|
||
// 0) 随机图作飞入遮罩背景(铺满 + 放大遮水印),进度条/提示仍在前景
|
||
const flyBg = document.getElementById('flyBg');
|
||
if (flyBg) flyBg.src = pickTransitionImage();
|
||
// 1) 先盖屏,立刻在遮罩下把微观世界建好并跑起来:重活消化在幕后,
|
||
// 揭幕那一帧不再有主线程尖峰 → 消除"加载完却一顿"的卡顿
|
||
overlay.classList.add('active');
|
||
const flyFill = document.getElementById('flyFill');
|
||
const flyPct = document.getElementById('flyPct');
|
||
if (flyFill) flyFill.style.width = '0%';
|
||
if (flyPct) flyPct.textContent = '0%';
|
||
// 进度条动画:与最短停留时长保持同步,给玩家明确的加载反馈
|
||
const MICRO_ENTER_HOLD = 1500; // [PLACEHOLDER] 体感测试:最短 1.5s,快机可调到 1100
|
||
const startT = performance.now();
|
||
let rafId;
|
||
function tick(now) {
|
||
if (!overlay.classList.contains('active')) return;
|
||
const p = Math.min(100, ((now - startT) / MICRO_ENTER_HOLD) * 100);
|
||
if (flyFill) flyFill.style.width = p + '%';
|
||
if (flyPct) flyPct.textContent = Math.round(p) + '%';
|
||
if (p < 100) rafId = requestAnimationFrame(tick);
|
||
}
|
||
rafId = requestAnimationFrame(tick);
|
||
microState = {
|
||
tile,
|
||
buildings: tile.hasBuilding ? [{ ...BUILDINGS[tile.buildingType], type: tile.buildingType, level: tile.buildingLevel, hp: 80 + tile.buildingLevel * 10 }] : [],
|
||
units: (tile.units || []).map(u => ({ ...u, patrol: null })),
|
||
resources: calcResources(tile),
|
||
animTime: 0,
|
||
particles: []
|
||
};
|
||
microBuildRevealAt = performance.now() + 200; // 首次进入:200ms 后显示建筑
|
||
// 重置缩放与平移
|
||
microZoom = 1; microPanX = 0; microPanY = 0;
|
||
microViewOpen = true; // 暂停主世界重绘
|
||
microView.classList.add('active'); // 入场动画(缩放+去模糊)在遮罩掩护下播放,揭幕时早已归位
|
||
document.getElementById('microTitle').textContent = microTitleText(tile);
|
||
document.getElementById('microSubtitle').textContent = `坐标(${tile.q}, ${tile.r}) · ${microState.units.length}位居民`;
|
||
applyMicroAccent(tile.terrain);
|
||
updateSidebar();
|
||
microLoadIcons(); // 进入微观视图即预加载建造贴图,避免已放置建筑先显示文字
|
||
microLoadCatalog(); // 拉取 Odoo 后台建造目录(未登录时保留前端兜底常量),hover/弹窗即时生效
|
||
microLoadGame(); // 拉取角色 / 生物 / 订单(Odoo 后端),覆盖单位状态
|
||
fetchTasks(); // 拉取任务定义 + 角色进度(新手主线)
|
||
// 初始化角色动画系统
|
||
if (typeof preloadAllSheets === 'function') preloadAllSheets();
|
||
if (typeof updateCharPlacePanel === 'function') updateCharPlacePanel();
|
||
if (typeof updateCharPanel === 'function') updateCharPanel();
|
||
if (typeof loadMicroChars === 'function') loadMicroChars(); // 从后端拉取该用户放置的角色(刷新/重进不丢)
|
||
// 注册角色障碍避让:随机游走 / 用户点击都被吸附到非建筑空地
|
||
if (typeof setCharAvoid === 'function') {
|
||
setCharAvoid({
|
||
isBlocked: (wx, wy) => {
|
||
const sp = worldToScreen(wx, wy);
|
||
const cell = microCellAt(sp);
|
||
return !!(cell && microBuildingAt(cell));
|
||
},
|
||
// 螺旋搜索最近空地:从目标点向外 16 方向 × 12 圈,最多 192 个候选点
|
||
findClear: (wx, wy) => {
|
||
const isFree = (x, y) => {
|
||
const sp = worldToScreen(x, y);
|
||
const cell = microCellAt(sp);
|
||
return !(!cell || microBuildingAt(cell));
|
||
};
|
||
for (let r = 0; r < 12; r++) {
|
||
for (let i = 0; i < 16; i++) {
|
||
const ang = (i / 16) * Math.PI * 2;
|
||
const rad = r * 6;
|
||
const x = wx + Math.cos(ang) * rad;
|
||
const y = wy + Math.sin(ang) * rad;
|
||
if (isFree(x, y)) return { x, y };
|
||
}
|
||
}
|
||
return { x: wx, y: wy }; // 实在找不到就原样返回(极端兜底)
|
||
},
|
||
});
|
||
}
|
||
initMicroCanvas();
|
||
startMicroRender();
|
||
microSanitizeBuildings(); // 进入即清理本地块越界建筑(登录期 microState 未建,setMicroTile 不跑初始地块)
|
||
// 2) 至少保持最短时长(遮罩掩护重活 + 入场动画兜底),到点纯淡出揭幕:无尖峰、无 pop-in
|
||
setTimeout(() => {
|
||
if (rafId) cancelAnimationFrame(rafId);
|
||
if (flyFill) flyFill.style.width = '100%';
|
||
if (flyPct) flyPct.textContent = '100%';
|
||
overlay.classList.remove('active');
|
||
}, MICRO_ENTER_HOLD);
|
||
}
|
||
|
||
function calcResources(tile) {
|
||
const res = {};
|
||
if (tile.hasBuilding && BUILDINGS[tile.buildingType]) {
|
||
for (const [k, v] of Object.entries(BUILDINGS[tile.buildingType].yield)) res[k] = v * tile.buildingLevel;
|
||
}
|
||
return res;
|
||
}
|
||
|
||
function updateSidebar() {
|
||
const tile = microState.tile;
|
||
document.getElementById('detailTerrain').textContent = `${TERRAIN[tile.terrain].name} · 坐标(${tile.q}, ${tile.r})`;
|
||
const buildingDiv = document.getElementById('detailBuilding');
|
||
if (tile.hasBuilding) {
|
||
const b = BUILDINGS[tile.buildingType];
|
||
buildingDiv.innerHTML = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||
<span style="font-size:22px;">${b.icon}</span>
|
||
<div><div style="font-size:14px;font-weight:bold;color:#e0e0e0;">${b.name}</div>
|
||
<div style="font-size:11px;color:#8899aa;">Lv.${tile.buildingLevel}</div></div></div>`;
|
||
} else { buildingDiv.innerHTML = '<div style="color:#445566;font-size:12px;">—</div>'; }
|
||
|
||
const resDiv = document.getElementById('detailResources');
|
||
const res = microState.resources;
|
||
if (Object.keys(res).length > 0) {
|
||
const resNames = { food:'🍖 食物', wood:'🪵 木材', metal:'⛏ 金属', gold:'💰 金币', culture:'🏛 文化', research:'🔬 科研', defense:'🛡 防御', military:'⚔ 军事', pop:'👥 人口' };
|
||
resDiv.innerHTML = Object.entries(res).map(([k,v]) => `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0;">
|
||
<span style="font-size:12px;color:#ccd;">${resNames[k]||k}</span>
|
||
<div class="resource-bar" style="width:100px;">
|
||
<div class="resource-fill" style="width:${Math.min(v*12,100)}%;background:linear-gradient(90deg,#4af,#a8e6cf);"></div></div>
|
||
<span style="font-size:12px;color:#8bc34a;">+${v}</span></div>`).join('');
|
||
} else { resDiv.innerHTML = '<div style="color:#667788;font-size:12px;">无产出</div>'; }
|
||
|
||
const popDiv = document.getElementById('detailPopulation');
|
||
if (microState.units.length > 0) {
|
||
popDiv.innerHTML = microState.units.map(u => `<div class="unit-card" onclick="showUnitModal('${u.id}')">
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<span style="font-size:18px;">${u.icon}</span>
|
||
<div><div class="unit-name">${u.name}</div><div class="unit-role">${u.status === 'working' ? '🟢' : u.status === 'resting' ? '🟡' : '🔵'} ${u.status}</div></div>
|
||
</div></div>`).join('');
|
||
} else { popDiv.innerHTML = '<div style="color:#667788;font-size:12px;">暂无居民</div>'; }
|
||
}
|
||
|
||
// ============ 微观世界 Canvas 渲染 ============
|
||
function initMicroCanvas() {
|
||
microCanvas = document.getElementById('microCanvas');
|
||
const stage = document.getElementById('microStage');
|
||
microCanvas.width = stage.clientWidth - 280;
|
||
microCanvas.height = stage.clientHeight;
|
||
microCtx = microCanvas.getContext('2d');
|
||
microCanvas.style.touchAction = 'none'; // 触屏滑动切换时不触发页面滚动
|
||
if (!microInputBound) { bindMicroInput(); microInputBound = true; }
|
||
buildMicroGridCache(); // 构建背景 + 地形场离屏缓存(静态,逐帧只贴图)
|
||
// 舞台高度随建造面板展开/收起变化 → 自动重设画布,避免面板挤压后画布不重绘
|
||
if (typeof ResizeObserver !== 'undefined' && !initMicroCanvas._ro) {
|
||
initMicroCanvas._ro = new ResizeObserver(() => {
|
||
const w = stage.clientWidth - 280, h = stage.clientHeight;
|
||
if (w > 0 && h > 0 && (microCanvas.width !== w || microCanvas.height !== h)) {
|
||
microCanvas.width = w; microCanvas.height = h;
|
||
if (typeof buildMicroGridCache === 'function') buildMicroGridCache();
|
||
}
|
||
});
|
||
initMicroCanvas._ro.observe(stage);
|
||
}
|
||
}
|
||
|
||
function startMicroRender() {
|
||
if (!microState) return;
|
||
if (microAnimId) cancelAnimationFrame(microAnimId);
|
||
|
||
function loop() {
|
||
if (!microState) return;
|
||
microAnimId = requestAnimationFrame(loop);
|
||
const now = performance.now();
|
||
const dt = Math.min((now - (loop._last || now)) / 1000, 0.05);
|
||
loop._last = now;
|
||
microState.animTime += dt;
|
||
const mW = microCanvas.width, mH = microCanvas.height;
|
||
const terrain = TERRAIN[microState.tile.terrain];
|
||
|
||
// ---- 更新角色动画与位置 ----
|
||
if (typeof updateChars === 'function') updateChars(dt);
|
||
|
||
// 建造面板展开时:世界整体上移 + 等比缩小,腾出底部给面板(仅改变换,画布尺寸不变 → 无闪烁)
|
||
const boTarget = microBuildMode ? 1 : 0;
|
||
microBuildOpenT += (boTarget - microBuildOpenT) * 0.18;
|
||
if (Math.abs(boTarget - microBuildOpenT) < 0.002) microBuildOpenT = boTarget;
|
||
const boFit = Math.max(0.4, (mH - microBuildPanelH) / (mH * 0.86)); // 缩到刚好放进面板上方
|
||
microBuildScale = 1 + (boFit - 1) * microBuildOpenT;
|
||
microBuildLift = (microBuildPanelH / 2) * microBuildOpenT;
|
||
|
||
// ---- 不透明兜底:避免 clearRect 透出 body 黑底造成"黑影闪" ----
|
||
microCtx.fillStyle = '#0a0e17';
|
||
microCtx.fillRect(0, 0, mW, mH);
|
||
|
||
// ---- 套缩放+平移变换(场景内容随 zoom/pan 移动;建造展开时叠加上移+缩小)----
|
||
microCtx.save();
|
||
microCtx.translate(mW / 2 + microPanX, mH / 2 + microPanY - microBuildLift);
|
||
microCtx.scale(microZoom * microBuildScale, microZoom * microBuildScale);
|
||
microCtx.translate(-mW / 2, -mH / 2);
|
||
// 初始大小(zoom=1)下拖动 = 滑动切地块:实时跟手预览;松手未达阈值时弹性回弹;过渡中不打扰
|
||
if (microSwipe && microZoom <= 1.001 && !microState.transition) {
|
||
if (!microSwipe.active) { // 松手回弹:朝原点缓动,避免生硬瞬回
|
||
microSwipe.x += (microSwipe.x0 - microSwipe.x) * 0.3;
|
||
microSwipe.y += (microSwipe.y0 - microSwipe.y) * 0.3;
|
||
if (Math.abs(microSwipe.x - microSwipe.x0) < 0.5 && Math.abs(microSwipe.y - microSwipe.y0) < 0.5) {
|
||
microSwipe.x = microSwipe.x0; microSwipe.y = microSwipe.y0;
|
||
microSwipe = null; // 回弹到位,清除预览
|
||
}
|
||
}
|
||
if (microSwipe) microCtx.translate(Math.round(microSwipe.x - microSwipe.x0), Math.round(microSwipe.y - microSwipe.y0));
|
||
}
|
||
|
||
// 背景 + 地形场为静态缓存;逐帧只贴图(+ 滑动切换过渡,带方向性平移)
|
||
if (microState.transition) {
|
||
const tr = microState.transition;
|
||
const p = Math.min(1, (performance.now() - tr.t0) / tr.dur);
|
||
const e = 1 - Math.pow(1 - p, 3); // easeOutCubic 0→1
|
||
const D = mW * 0.6; // 切换滑动位移距离:旧图滑出、新图滑入有方向感
|
||
// 旧图:从松手预览偏移继续朝滑动方向滑出(消除"弹回原位再滑"的跳变)
|
||
if (tr.from) {
|
||
microCtx.save();
|
||
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();
|
||
}
|
||
// 新图:从松手预览偏移的反方向滑入到原位
|
||
if (microGridCache) {
|
||
microCtx.save();
|
||
microCtx.globalAlpha = Math.min(1, e * 1.6);
|
||
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();
|
||
}
|
||
if (p >= 1) microState.transition = null;
|
||
} else if (microGridCache) {
|
||
microCtx.drawImage(microGridCache, 0, 0);
|
||
}
|
||
|
||
// 矩形网格叠加层(仅浮岛内部,clip 到圆角矩形)
|
||
if (microGridOn && microGridCache) drawMicroGrid(microCtx, mW, mH, microState.accent || '#4aafff');
|
||
|
||
// 建筑延迟浮现:过渡期间隐藏,过渡结束后等待约 0.2s 再淡入
|
||
let microFade = 1;
|
||
if (microState.transition) {
|
||
microFade = 0; // 背景滑入时完全隐藏建筑
|
||
} else if (microBuildRevealAt > 0) {
|
||
const now = performance.now();
|
||
const fadeIn = 150; // 淡入持续时间 150ms(过渡结束后 200ms 时间段内完成)
|
||
const elapsed = Math.max(0, fadeIn - (microBuildRevealAt - now));
|
||
const progress = Math.min(1, elapsed / fadeIn);
|
||
microFade = progress >= 1 ? 1 : 1 - Math.pow(1 - progress, 3); // easeOutCubic: 0→1
|
||
}
|
||
|
||
// 建造模式:绘制已放置素材(始终渲染,含草地图模式),落在变换内随 zoom/pan 移动
|
||
const microBL = microBuildings[microState.tile.q + ',' + microState.tile.r];
|
||
if (microBL && microBL.length) {
|
||
const g = microBuildGeom();
|
||
microCtx.textAlign = 'center'; microCtx.textBaseline = 'middle';
|
||
microCtx.save();
|
||
microCtx.globalAlpha = microFade; // 过渡中随地图一起淡入
|
||
// 等距深度排序:后方(i+j 小)先画、前方后画,保证遮挡正确
|
||
microBL.slice().sort((a, b) => {
|
||
const ca = microItemCells(a), cb = microItemCells(b);
|
||
const za = a.i + a.j + (ca.ew + ca.eh) / 2;
|
||
const zb = b.i + b.j + (cb.ew + cb.eh) / 2;
|
||
return za - zb || a.i - b.i;
|
||
}).forEach(b => {
|
||
const it = microBuildItemById(b.type);
|
||
let ew = b.w || it.w || 1, eh = b.h || it.h || 1;
|
||
if (((b.rot || 0) % 180) !== 0) { const t = ew; ew = eh; eh = t; } // 旋转 90/270 交换长宽(设施仍保留)
|
||
const center = microCellToScreen(b.i + ew / 2, b.j + eh / 2);
|
||
microCtx.save();
|
||
microCtx.translate(center.x, center.y);
|
||
// 已放置素材不再常驻 footprint 菱形外框,避免遮挡建筑底部、影响视觉干净度。
|
||
// 玩家仍可通过 hover 看到白+橙色高亮框,建造预览时也有绿/红合法性外框。
|
||
microDrawPlaced(microCtx, b, it, g.cell, ew, eh);
|
||
microCtx.restore();
|
||
});
|
||
|
||
// hover 高亮:把微高亮的建筑额外描一遍醒目 footprint(覆盖在所有建筑之上,不被遮挡)
|
||
if (microHoverB) {
|
||
const hb = microHoverB;
|
||
const hIt = microBuildItemById(hb.type);
|
||
if (hIt && hIt.cat !== 'road' && hIt.cat !== 'ground') {
|
||
let hw_ = hb.w || hIt.w || 1, hh_ = hb.h || hIt.h || 1;
|
||
if (((hb.rot || 0) % 180) !== 0) { const t = hw_; hw_ = hh_; hh_ = t; }
|
||
const hp = microCellToScreen(hb.i + hw_ / 2, hb.j + hh_ / 2);
|
||
microCtx.save();
|
||
microCtx.translate(hp.x, hp.y);
|
||
// 高对比醒目双层描边:外白 halo + 内金橙主体。
|
||
// 不再使用 microState.accent——平原/森林 accent 是浅绿,和绿草地背景撞色看不出。
|
||
microCtx.strokeStyle = 'rgba(255,255,255,0.95)';
|
||
microCtx.lineWidth = 5;
|
||
microIsoFootprintStroke(microCtx, hw_, hh_, g.cell);
|
||
microCtx.strokeStyle = 'rgba(255,180,40,1)';
|
||
microCtx.lineWidth = 2;
|
||
microIsoFootprintStroke(microCtx, hw_, hh_, g.cell);
|
||
microCtx.restore();
|
||
}
|
||
}
|
||
|
||
microCtx.restore();
|
||
}
|
||
|
||
// ===== 建造预览 ghost:浮空半透明 + 绿/红合法性着色(按具体格子) =====
|
||
const gh = microComputeGhost();
|
||
if (gh) {
|
||
const gb = microBuildGeom();
|
||
const gc = gb.cell;
|
||
const gew = gh.w, geh = gh.h; // gh.w/h 已是按旋转算好的有效占位尺寸
|
||
const list = microBuildList();
|
||
const { cols, rows } = microGridDims();
|
||
const ignoreRef = microEditRef || null;
|
||
const git = microBuildItemById(gh.type);
|
||
const terrainOk = microTerrainOk(git);
|
||
microCtx.save();
|
||
// 逐个格子判断:越界/被占/地形不符 = 红,其余 = 绿(等距菱形着色)
|
||
for (let dy = 0; dy < geh; dy++) {
|
||
for (let dx = 0; dx < gew; dx++) {
|
||
const ci = gh.i + dx, cj = gh.j + dy;
|
||
const inBounds = ci >= 0 && cj >= 0 && ci < cols && cj < rows;
|
||
const cellValid = terrainOk && inBounds && !microFootprintHit(list, ci, cj, 1, 1, ignoreRef);
|
||
const col = cellValid ? '76,217,100' : '255,80,80';
|
||
const corners = [[ci, cj], [ci + 1, cj], [ci + 1, cj + 1], [ci, cj + 1]];
|
||
microCtx.beginPath();
|
||
corners.forEach(([x, y], k) => {
|
||
const p = microCellToScreen(x, y);
|
||
if (k === 0) microCtx.moveTo(p.x, p.y); else microCtx.lineTo(p.x, p.y);
|
||
});
|
||
microCtx.closePath();
|
||
microCtx.globalAlpha = 0.35;
|
||
microCtx.fillStyle = `rgba(${col},1)`;
|
||
microCtx.fill();
|
||
microCtx.globalAlpha = 1;
|
||
microCtx.lineWidth = 1.5;
|
||
microCtx.strokeStyle = `rgba(${col},0.95)`;
|
||
microCtx.stroke();
|
||
}
|
||
}
|
||
// 整体外框(颜色表示整体是否可建)
|
||
microCtx.globalAlpha = 1;
|
||
microCtx.lineWidth = 2;
|
||
microCtx.strokeStyle = `rgba(${gh.valid ? '76,217,100' : '255,80,80'},0.95)`;
|
||
microIsoFootprintAbs(microCtx, gh.i, gh.j, gew, geh);
|
||
microCtx.restore();
|
||
// 浮空半透明图标(跟随光标,似"拖着"的建造物)
|
||
// 自动道路刷子用绿/红格子即可,不需要额外浮空图标,避免与已铺道路重叠
|
||
if (gh.type !== 'road_auto') {
|
||
const center = microCellToScreen(gh.i + gew / 2, gh.j + geh / 2);
|
||
microCtx.save();
|
||
microCtx.translate(center.x, center.y);
|
||
microCtx.globalAlpha = 0.72;
|
||
microDrawPlaced(microCtx, { rot: gh.rot }, git, gc, gew, geh);
|
||
microCtx.restore();
|
||
}
|
||
}
|
||
|
||
// 建造模式浮动操作按钮(旋转/确认/取消),在屏幕坐标系绘制
|
||
if (microBuildMode) microDrawActionBtns();
|
||
|
||
const Rx = mW * 0.50, Ry = mH * 0.43;
|
||
const cx = mW / 2, cy = mH / 2;
|
||
|
||
// 过渡时居民随新视图一起柔和淡入(沿用上面的过渡进度,保持建筑/地图/居民同步浮现)
|
||
const unitAlpha = microFade;
|
||
|
||
// 居民:浮岛内确定性散点 + 中心避让 + 脉冲光环
|
||
// 使用背景图的 terrain 不画默认居民,保持纯净背景图
|
||
const microBgMapRender = {
|
||
plain: grassBgImg, forest: forestBgImg, mountain: mountainBgImg,
|
||
desert: desertBgImg, snow: snowBgImg, water: waterBgImg
|
||
};
|
||
const hasBgImage = microBgMapRender[microState.tile.terrain] && microBgMapRender[microState.tile.terrain].naturalWidth > 0;
|
||
microCtx.globalAlpha = unitAlpha;
|
||
const padX = Rx * 0.12, padY = Ry * 0.12, minD = Math.min(Rx, Ry) * 0.24;
|
||
const roadSet = microRoadKeySet();
|
||
const onRoad = roadSet.size > 0;
|
||
if (!hasBgImage) {
|
||
microState.units.forEach((u, k) => {
|
||
// 由建筑层负责渲染的种族角色不再在这里画 emoji,避免重影
|
||
if (u._buildingRender) return;
|
||
// 初始化/重置巡逻状态(无路时清空,重铺路后自动接管)
|
||
if (onRoad) { if (!u.patrol || u.patrol.roadSize !== roadSet.size) { u.patrol = microInitPatrol(u, roadSet); u.patrol.roadSize = roadSet.size; } }
|
||
else if (u.patrol) u.patrol = null;
|
||
let px, py, face = 1, moving = false;
|
||
if (u.patrol) {
|
||
const p = u.patrol;
|
||
// 推进插值进度(按格/秒),到目标格后选下一个方向
|
||
p.t += MICRO_PATROL_SPEED * 0.016;
|
||
while (p.t >= 1) { p.t -= 1; microPatrolArrive(p, roadSet, u); }
|
||
const a = microCellCenter(p.ci, p.cj), b = microCellCenter(p.nci, p.ncj);
|
||
const e = easeInOutCubic(p.t);
|
||
px = a.x + (b.x - a.x) * e; py = a.y + (b.y - a.y) * e;
|
||
const vx = b.x - a.x; if (Math.abs(vx) > 0.5) face = vx > 0 ? 1 : -1;
|
||
moving = (p.ci !== p.nci) || (p.cj !== p.ncj);
|
||
} else {
|
||
const h = microHash(u.id);
|
||
const rx1 = ((h >> 2) % 100) / 100;
|
||
const ry1 = ((h >> 9) % 100) / 100;
|
||
px = cx + (rx1 * 2 - 1) * (Rx - padX);
|
||
py = cy + (ry1 * 2 - 1) * (Ry - padY);
|
||
const dx_ = px - cx, dy_ = py - cy, d = Math.hypot(dx_, dy_);
|
||
if (d < minD && d > 0.001) { const kk = minD / d; px = cx + dx_ * kk; py = cy + dy_ * kk; }
|
||
}
|
||
// 行走 bob(缓动上下浮动)+ 呼吸脉冲(停步时)
|
||
const bob = moving ? Math.abs(Math.sin(microState.animTime * 6 + k)) * (Math.min(Rx, Ry) * 0.03) : 0;
|
||
const breath = 0.6 + 0.4 * Math.sin(microState.animTime * 3 + k);
|
||
const col = (u.status === 'working' ? '#8bc34a' : u.status === 'resting' ? '#ff9800' : '#4af');
|
||
// 脉冲光环
|
||
microCtx.save();
|
||
microCtx.globalAlpha = 0.22 + 0.22 * breath;
|
||
microCtx.fillStyle = col;
|
||
microCtx.beginPath(); microCtx.arc(px, py - bob, Math.max(11, Math.min(Rx,Ry) * 0.065) * (0.9 + 0.2 * breath), 0, Math.PI * 2); microCtx.fill();
|
||
microCtx.restore();
|
||
// 底圈
|
||
microCtx.fillStyle = 'rgba(18,26,38,0.92)';
|
||
microCtx.beginPath(); microCtx.arc(px, py - bob, Math.max(10, Math.min(Rx,Ry) * 0.058), 0, Math.PI * 2); microCtx.fill();
|
||
microCtx.lineWidth = 2.5; microCtx.strokeStyle = col; microCtx.stroke();
|
||
// 图标(按移动方向水平翻转,营造"脸朝前"的行走感)
|
||
microCtx.save();
|
||
microCtx.translate(px, py - bob);
|
||
microCtx.scale(face, 1);
|
||
microCtx.font = `${Math.max(15, Math.min(Rx,Ry) * 0.075)}px sans-serif`;
|
||
microCtx.textAlign = 'center'; microCtx.textBaseline = 'middle';
|
||
microCtx.fillText(u.icon, 0, 1);
|
||
microCtx.restore();
|
||
});
|
||
microCtx.globalAlpha = 1;
|
||
}
|
||
|
||
// ---- 渲染角色(精灵图动画)----
|
||
if (typeof renderChars === 'function') renderChars(microCtx, microState.animTime, mW, mH);
|
||
|
||
// 大气粒子层(背景图模式跳过,保持纯净背景图)
|
||
if (!hasBgImage) drawMicroAtmoParticles(microCtx, mW, mH, terrain);
|
||
|
||
// ---- 结束变换:恢复画布坐标系,绘制 UI 层不受 zoom 影响 ----
|
||
microCtx.restore();
|
||
|
||
// 注:大气粒子层已在上方变换内绘制(1641 行),随缩放/平移正确变换;
|
||
// 此处不再重复绘制,否则会出现“飘在另一层、不随放大缩放”的相对运动错觉。
|
||
}
|
||
loop();
|
||
}
|
||
|
||
// ============ 微观世界:滑动切换相邻地块 ============
|
||
// 在微观视图中朝某方向拖动/滑动 → 进入该方向相邻的沙盘地基块的微观视图(像平移显微镜)
|
||
let microSwipe = null; // {x0,y0,x,y,active}
|
||
const MICRO_SWIPE_MIN = 40; // 触发切换的最小拖动距离(px),小于则视为点击
|
||
let microInputBound = false;
|
||
|
||
function getMicroPoint(e) {
|
||
const r = microCanvas.getBoundingClientRect();
|
||
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
||
}
|
||
function bindMicroInput() {
|
||
const mc = document.getElementById('microCanvas');
|
||
mc.addEventListener('pointerdown', onMicroDown);
|
||
window.addEventListener('pointermove', onMicroMove);
|
||
window.addEventListener('pointerup', onMicroUp);
|
||
window.addEventListener('keydown', onMicroKey);
|
||
// 滚轮:以鼠标位置为中心缩放
|
||
mc.addEventListener('wheel', onMicroWheel, { passive: false });
|
||
// 屏蔽右键菜单(右键用于平移)
|
||
mc.addEventListener('contextmenu', e => e.preventDefault());
|
||
}
|
||
// 将屏幕坐标转换为世界坐标(反向 zoom/pan 变换)
|
||
function screenToWorld(sx, sy) {
|
||
const mW = microCanvas.width, mH = microCanvas.height;
|
||
const s = microZoom * microBuildScale;
|
||
return {
|
||
x: (sx - mW / 2 - microPanX) / s + mW / 2,
|
||
y: (sy - mH / 2 - microPanY + microBuildLift) / s + mH / 2
|
||
};
|
||
}
|
||
// 将世界坐标还原为屏幕坐标(screenToWorld 的逆),用于角色碰撞检测
|
||
function worldToScreen(wx, wy) {
|
||
const mW = microCanvas.width, mH = microCanvas.height;
|
||
const s = microZoom * microBuildScale;
|
||
return {
|
||
x: (wx - mW / 2) * s + mW / 2 + microPanX,
|
||
y: (wy - mH / 2) * s + mH / 2 + microPanY - microBuildLift,
|
||
};
|
||
}
|
||
// 平移限位:像缩放下限一样,浮岛不能拖出视野
|
||
// zoom=1 时限位为 0(浮岛居中、不可平移);zoom>1 时允许平移到「浮岛边缘抵达屏幕边」为止
|
||
function clampMicroPan() {
|
||
const mW = microCanvas.width, mH = microCanvas.height;
|
||
const Rx = mW * 0.50, Ry = mH * 0.43; // 与浮岛绘制半径一致
|
||
const s = microZoom * microBuildScale;
|
||
const maxX = Math.max(0, Rx * s - mW / 2);
|
||
const maxY = Math.max(0, Ry * s - mH / 2);
|
||
microPanX = Math.max(-maxX, Math.min(maxX, microPanX));
|
||
microPanY = Math.max(-maxY, Math.min(maxY, microPanY));
|
||
}
|
||
|
||
function onMicroWheel(e) {
|
||
if (!microState) return;
|
||
e.preventDefault();
|
||
const p = getMicroPoint(e);
|
||
const oldZoom = microZoom;
|
||
// 缩放步进:每滚轮刻度 ±15%,范围 1x(初始适配)~ 2.5x(放大)—— 略增步进让放大更跟手
|
||
const factor = e.deltaY < 0 ? 1.15 : 0.8696;
|
||
microZoom = Math.max(1, Math.min(2.5, microZoom * factor));
|
||
// 以鼠标位置为中心:调整 pan 使鼠标下的点保持不动
|
||
const worldBefore = screenToWorld(p.x, p.y);
|
||
const worldAfter = screenToWorld(p.x, p.y); // 用新 zoom 但旧 pan 计算
|
||
microPanX += (worldAfter.x - worldBefore.x) * microZoom;
|
||
microPanY += (worldAfter.y - worldBefore.y) * microZoom;
|
||
clampMicroPan();
|
||
// 回到初始大小(zoom=1)则复位平移,重新居中浮岛
|
||
if (microZoom <= 1.0001) { microZoom = 1; microPanX = 0; microPanY = 0; }
|
||
}
|
||
function onMicroDown(e) {
|
||
if (!microState) return;
|
||
// 建造模式:左键=放置/进入编辑/平移;右键=取消选择或删除
|
||
if (microBuildMode) {
|
||
const p = getMicroPoint(e);
|
||
if (e.button === 2) {
|
||
if (microBuildSel === 'road_auto') {
|
||
const cell = microCellAt(p);
|
||
if (cell) { microRoadErasing = true; microRoadLastCell = null; microEraseRoadCell(cell.i, cell.j); }
|
||
return;
|
||
}
|
||
microBuildRightClick(p); return; // 右键:删除或直接取消
|
||
}
|
||
if (e.button === 1) { // 中键:建造模式下平移视图(放大后常用)
|
||
e.preventDefault();
|
||
microPanning = true;
|
||
microPanStart = getMicroPoint(e);
|
||
microCanvas.style.cursor = 'move';
|
||
return;
|
||
}
|
||
if (e.button === 0) {
|
||
// 优先检测浮动按钮点击
|
||
const hitAct = microActionHit(p);
|
||
if (hitAct) { microDoAction(hitAct); return; }
|
||
// 自动道路/栅栏刷子:左键拖拽连续铺
|
||
if (microBuildSel === 'road_auto') {
|
||
const cell = microCellAt(p);
|
||
if (cell) {
|
||
microRoadPaintBefore = microBuildList().filter(b => microIsRoadAuto(b)).length;
|
||
microRoadPainting = true; microRoadLastCell = null; microPaintRoadCell(cell.i, cell.j);
|
||
}
|
||
return;
|
||
}
|
||
// 普通素材:点击 → 先冻结(静止住)在点击格;已冻结时点击空白则重新固定到该格
|
||
if (microBuildSel && microBuildSel !== 'road_auto' && !microEditRef) {
|
||
const cell = microCellAt(p);
|
||
// 命中已有建筑 → 不冻结(交给松手弹交互窗);道路 / 地面不算"建筑",落点和点普通格子一致
|
||
let hitExisting = null;
|
||
if (cell) {
|
||
const h = microBuildingAt(cell);
|
||
if (h) {
|
||
const hIt = microBuildItemById(h.type);
|
||
if (hIt && hIt.cat !== 'road' && hIt.cat !== 'ground') hitExisting = h;
|
||
}
|
||
}
|
||
if (cell && !hitExisting) {
|
||
const it = microBuildItemById(microBuildSel);
|
||
let ew = it.w || 1, eh = it.h || 1;
|
||
if ((microBuildRot % 180) !== 0) { const t = ew; ew = eh; eh = t; }
|
||
const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
|
||
microGhost.i = Math.max(iMin, Math.min(Math.round(cell.i - ew / 2), iMax - ew));
|
||
microGhost.j = Math.max(jMin, Math.min(Math.round(cell.j - eh / 2), jMax - eh));
|
||
microGhostFrozen = true;
|
||
}
|
||
return;
|
||
}
|
||
// 编辑态且按在正在编辑的建筑上 → 进入拖动移动
|
||
if (microEditRef && microHitBuilding(microEditRef, microCellAt(p))) {
|
||
microEditDrag = true; microCanvas.style.cursor = 'move'; return;
|
||
}
|
||
// 按在空白处且处于编辑态(未选中新素材)→ 退出编辑
|
||
if (microEditRef && !microBuildSel) microEditRef = null;
|
||
// 建造模式左键不平移视图(ghost 已随鼠标浮动),避免拖动时视图与 ghost 同时移动
|
||
}
|
||
return;
|
||
}
|
||
// 中键 → 纯平移;右键 → 单击取消选中角色,按住拖拽仍是平移
|
||
if (e.button === 1) {
|
||
e.preventDefault();
|
||
microPanning = true;
|
||
microPanStart = getMicroPoint(e);
|
||
microCanvas.style.cursor = 'move';
|
||
return; // 不启动 swipe
|
||
}
|
||
if (e.button === 2) {
|
||
e.preventDefault();
|
||
// 右键单击 = 取消选中角色(RTS 习惯)
|
||
if (typeof selectChar === 'function' && typeof selectedCharId !== 'undefined' && selectedCharId) {
|
||
selectChar(null);
|
||
}
|
||
microPanning = true; // 右键拖拽仍可平移视图
|
||
microPanStart = getMicroPoint(e);
|
||
microCanvas.style.cursor = 'move';
|
||
return;
|
||
}
|
||
const p = getMicroPoint(e);
|
||
// 角色交互优先(非建造模式、左键)
|
||
if (!microBuildMode && e.button === 0 && typeof hitChar === 'function') {
|
||
const w = screenToWorld(p.x, p.y);
|
||
const ch = hitChar(w.x, w.y);
|
||
if (ch) {
|
||
// 点击已选中的角色 → 取消选中;点击其他角色 → 切换选中
|
||
if (typeof selectedCharId !== 'undefined' && selectedCharId === ch.id) selectChar(null);
|
||
else selectChar(ch.id);
|
||
e.preventDefault(); return;
|
||
}
|
||
// 放置模式:点击地面放置角色(优先级高于移动)
|
||
if (typeof charPlaceMode !== 'undefined' && charPlaceMode && typeof createChar === 'function') {
|
||
createChar(charPlaceRace, w.x, w.y);
|
||
charPlaceMode = false;
|
||
if (typeof updateCharPlacePanel === 'function') updateCharPlacePanel();
|
||
return;
|
||
}
|
||
// 选中角色后:点击地面直接移动(无需再点按钮)
|
||
if (typeof selectedCharId !== 'undefined' && selectedCharId && typeof setCharTarget === 'function') {
|
||
setCharTarget(selectedCharId, w.x, w.y);
|
||
return;
|
||
}
|
||
}
|
||
// 放大状态下(zoom>1)左键拖拽 = 平移查看浮岛内部细节;
|
||
// 仅初始大小(zoom=1)时左键拖拽才是滑动切相邻地块
|
||
if (microZoom > 1.001) {
|
||
microPanning = true;
|
||
microPanStart = p;
|
||
microCanvas.style.cursor = 'move';
|
||
return;
|
||
}
|
||
microSwipe = { x0: p.x, y0: p.y, x: p.x, y: p.y, active: true };
|
||
microCanvas.style.cursor = 'grabbing';
|
||
}
|
||
function onMicroMove(e) {
|
||
if (!microState) return;
|
||
// 平移拖拽中(建造/非建造通用,优先于 ghost/编辑逻辑)
|
||
if (microPanning && microPanStart) {
|
||
const p = getMicroPoint(e);
|
||
microPanX += p.x - microPanStart.x;
|
||
microPanY += p.y - microPanStart.y;
|
||
microPanStart = p;
|
||
clampMicroPan();
|
||
return;
|
||
}
|
||
// 非建造模式:鼠标悬停建筑 → 显示名称/说明 tooltip(触摸设备无 hover,跳过,靠点按弹窗)
|
||
if (!microBuildMode && !microPanning && !microSwipe && !('ontouchstart' in window)) {
|
||
updateMicroHoverTip(e);
|
||
return;
|
||
}
|
||
// 建造模式:ghost 跟随鼠标浮动(编辑态则拖动已放置建筑)
|
||
if (microBuildMode) {
|
||
const rect = microCanvas.getBoundingClientRect();
|
||
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||
if (inside) {
|
||
const p = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||
// 自动道路拖拽:左键铺路 / 右键擦除(指针移出窗口松手时也能安全收尾)
|
||
if (microRoadPainting && !(e.buttons & 1)) { microRoadPainting = false; microRoadLastCell = null; scheduleBuildSave(); }
|
||
if (microRoadErasing && !(e.buttons & 2)) { microRoadErasing = false; microRoadLastCell = null; scheduleBuildSave(); }
|
||
if (microRoadPainting && (e.buttons & 1)) {
|
||
const cell = microCellAt(p);
|
||
if (cell) microPaintRoadCell(cell.i, cell.j);
|
||
return;
|
||
}
|
||
if (microRoadErasing && (e.buttons & 2)) {
|
||
const cell = microCellAt(p);
|
||
if (cell) microEraseRoadCell(cell.i, cell.j);
|
||
return;
|
||
}
|
||
// 浮动按钮悬停检测
|
||
const prevHover = microActionHover;
|
||
microActionHover = microActionHit(p);
|
||
if (prevHover !== microActionHover) microCanvas.style.cursor = microActionHover ? 'pointer' : 'crosshair';
|
||
if (microActionHover) return; // 悬停按钮时不触发 ghost 移动/放置
|
||
if (microEditRef && microEditDrag) {
|
||
const cell = microCellAt(p);
|
||
if (cell) {
|
||
const c = microItemCells(microEditRef);
|
||
const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
|
||
const ni = Math.max(iMin, Math.min(Math.round(cell.i - c.ew / 2), iMax - c.ew));
|
||
const nj = Math.max(jMin, Math.min(Math.round(cell.j - c.eh / 2), jMax - c.eh));
|
||
if (microGhostValidAt(ni, nj, c.ew, c.eh, microEditRef)) {
|
||
microEditRef.i = ni;
|
||
microEditRef.j = nj;
|
||
}
|
||
}
|
||
} else if (microBuildSel && !microGhostFrozen) {
|
||
const cell = microCellAt(p);
|
||
if (cell) {
|
||
const it = microBuildItemById(microBuildSel);
|
||
let ew = it.w || 1, eh = it.h || 1;
|
||
if ((microBuildRot % 180) !== 0) { const t = ew; ew = eh; eh = t; }
|
||
const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
|
||
microGhost.i = Math.max(iMin, Math.min(Math.round(cell.i - ew / 2), iMax - ew));
|
||
microGhost.j = Math.max(jMin, Math.min(Math.round(cell.j - eh / 2), jMax - eh));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (!microSwipe || !microSwipe.active) return;
|
||
const p = getMicroPoint(e);
|
||
// 跟手最大 ±320px(足以判断切地块方向),防止画面被拖太远
|
||
const SWIPE_MAX = 320;
|
||
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;
|
||
}
|
||
async function onMicroUp(e) {
|
||
if (!microState) return;
|
||
// 建造模式:松手判定——拖动移动已放置建筑 / 点击落地 / 点击选中编辑 / 平移
|
||
if (microBuildMode) {
|
||
const p = getMicroPoint(e);
|
||
const moved = microPanStart ? Math.hypot(p.x - microPanStart.x, p.y - microPanStart.y) : 0;
|
||
if (microRoadPainting) {
|
||
microRoadPainting = false; microRoadLastCell = null;
|
||
const list = microBuildList();
|
||
const after = list.filter(b => microIsRoadAuto(b)).length;
|
||
const added = after - microRoadPaintBefore;
|
||
if (added > 0) {
|
||
const res = await API.chargeBuild('road_auto', added);
|
||
if (!res || !res.ok) {
|
||
// 扣费失败:撤销本次新增的道路(从末尾移除 added 个 road_auto)
|
||
let toRemove = added;
|
||
for (let k = list.length - 1; k >= 0 && toRemove > 0; k--) {
|
||
if (microIsRoadAuto(list[k])) { list.splice(k, 1); toRemove--; }
|
||
}
|
||
microAutoRoadCells();
|
||
if (res && res.error === 'no_gold') microToast('金币不足,道路未铺设(需 🪙' + res.need + ')');
|
||
else if (res && res.error === 'auth') microToast('铺设需登录后扣费');
|
||
else microToast('扣费失败,道路未铺设');
|
||
microRoadPaintBefore = 0;
|
||
return;
|
||
}
|
||
if (microChar) microChar.gold = res.gold;
|
||
microRenderCharHud();
|
||
}
|
||
microRoadPaintBefore = 0;
|
||
scheduleBuildSave(); microToast('道路已铺设');
|
||
return;
|
||
}
|
||
else if (microRoadErasing) { microRoadErasing = false; microRoadLastCell = null; scheduleBuildSave(); }
|
||
else if (microEditDrag) {
|
||
microEditDrag = false; scheduleBuildSave(); // 已实时跟随,松手即落定
|
||
} else if (moved < MICRO_SWIPE_MIN) {
|
||
// 建造模式下点已有建筑 → 进入编辑态(显示旋转/确认/取消按钮),但不弹交互窗
|
||
if (!microBuildSel) {
|
||
const hit = microBuildingAt(microCellAt(p));
|
||
if (hit) {
|
||
const hitIt = microBuildItemById(hit.type);
|
||
// 道路 / 地面不允许点击交互,落点和点普通格子一致处理
|
||
if (hitIt && hitIt.cat !== 'road' && hitIt.cat !== 'ground') {
|
||
microEditRef = hit; hideMicroHoverTip(); microToast('编辑中:左✕删除 · 右✓保留 · 取消/确认正上方↺↻切换视角');
|
||
}
|
||
}
|
||
}
|
||
// 已选中新素材时:冻结/重定位于 onMicroDown 处理;此处不落地(需点 ✓ 确认才建造)
|
||
}
|
||
microPanning = false; microPanStart = null; microCanvas.style.cursor = 'crosshair';
|
||
return;
|
||
}
|
||
// 结束平移
|
||
if (microPanning) {
|
||
microPanning = false;
|
||
microPanStart = null;
|
||
microCanvas.style.cursor = '';
|
||
return;
|
||
}
|
||
// 左键:判断是切地块还是点击
|
||
if (!microSwipe) return;
|
||
const dx = microSwipe.x - microSwipe.x0, dy = microSwipe.y - microSwipe.y0;
|
||
microSwipe.active = false;
|
||
microCanvas.style.cursor = '';
|
||
if (Math.hypot(dx, dy) < MICRO_SWIPE_MIN) {
|
||
// 轻点:命中已放置建筑 → 弹出说明/交互弹窗(非建造模式)
|
||
const p = getMicroPoint(e);
|
||
const hit = microBuildingAt(microCellAt(p));
|
||
if (hit) {
|
||
hideMicroHoverTip();
|
||
const hitIt = microBuildItemById(hit.type);
|
||
if (hitIt && hitIt.isChar && hit._charCid) {
|
||
showUnitModal(hit._charCid); // 种族角色打开单位面板
|
||
} else {
|
||
showBuildModal(hit);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
// 超阈值:尝试切相邻地块;若边缘即墙(无相邻地块)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 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) {
|
||
microState.tile = tile;
|
||
applyMicroAccent(tile.terrain);
|
||
microState.units = tile.units || [];
|
||
// 把服务端已存在的居民标记为"已知",避免后续保存时把旧居民误判为新入驻
|
||
_knownUnitCids.clear();
|
||
(microState.units || []).forEach(u => { if (u.id) _knownUnitCids.add(u.id); });
|
||
microState.buildings = tile.hasBuilding
|
||
? [{ ...BUILDINGS[tile.buildingType], type: tile.buildingType, level: tile.buildingLevel, hp: 80 + tile.buildingLevel * 10 }]
|
||
: [];
|
||
microState.resources = calcResources(tile);
|
||
document.getElementById('microTitle').textContent = microTitleText(tile);
|
||
document.getElementById('microSubtitle').textContent = `坐标(${tile.q}, ${tile.r}) · ${microState.units.length}位居民`;
|
||
updateSidebar();
|
||
microAutoRoadCells(); // 切地块时重新计算自动道路贴图
|
||
microSanitizeBuildings(); // 切地块时清理越界建筑
|
||
}
|
||
// 快照旧视图 → 切换地块 → 重建缓存 → 启动滑入过渡
|
||
function transitionMicroTo(tile, dx, dy) {
|
||
const from = document.createElement('canvas');
|
||
from.width = microCanvas.width; from.height = microCanvas.height;
|
||
if (microGridCache) from.getContext('2d').drawImage(microGridCache, 0, 0);
|
||
setMicroTile(tile);
|
||
buildMicroGridCache(); // 用新 tile 重建离屏缓存
|
||
const sl = Math.hypot(dx, dy) || 1;
|
||
// sx/sy = 松手时的拖拽偏移(画布像素),作为过渡起点,消除"弹回原位再滑"的视觉跳变
|
||
microState.transition = { from, ux: dx / sl, uy: dy / sl, sx: dx, sy: dy, t0: performance.now(), dur: 340 };
|
||
microBuildRevealAt = performance.now() + 540; // 背景过渡 340ms 后延迟 200ms 显示建筑
|
||
}
|
||
|
||
|
||
// ============ 微观世界:圆形浮岛视图(参考浮岛参考图:清晰 + 内容丰富) ============
|
||
|
||
// 离屏缓存:暗背景 + 圆形浮岛(基地渐变 + 地形装饰 + 建筑)+ 边缘柔化,进入时构建一次
|
||
function buildMicroGridCache() {
|
||
if (!microState) return;
|
||
const mW = microCanvas.width, mH = microCanvas.height;
|
||
const tile = microState.tile;
|
||
const terrain = TERRAIN[tile.terrain];
|
||
const og = document.createElement('canvas');
|
||
og.width = mW; og.height = mH;
|
||
const c = og.getContext('2d');
|
||
|
||
// 暗色背景(与 #microView 一致),浮岛悬浮其上
|
||
c.fillStyle = '#0a0e17';
|
||
c.fillRect(0, 0, mW, mH);
|
||
drawMicroStars(c, mW, mH, tile.layer);
|
||
drawMicroAtmoStatic(c, mW, mH, terrain); // 方案A:地形主题大气氛围(光雾/天光/地平线雾)
|
||
|
||
const cx = mW / 2, cy = mH / 2;
|
||
const Rx = mW * 0.50, Ry = mH * 0.43; // 浮岛半宽/半高(已扩大视野范围)
|
||
const rRect = Math.min(Rx, Ry) * 0.14; // 圆角半径(四角小圆边,不要太大)
|
||
const X0 = cx - Rx, Y0 = cy - Ry, RW = Rx * 2, RH = Ry * 2;
|
||
// 圆角矩形路径
|
||
const rectPath = (ctx, x, y, w, h, r) => {
|
||
r = Math.min(r, w / 2, h / 2);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||
ctx.arcTo(x, y + h, x, y, r);
|
||
ctx.arcTo(x, y, x + w, y, r);
|
||
ctx.closePath();
|
||
};
|
||
|
||
// 浮岛投影(增强悬浮感)
|
||
c.save();
|
||
c.globalAlpha = 0.4;
|
||
c.fillStyle = '#000';
|
||
rectPath(c, X0 + RW * 0.03, Y0 + RH * 0.1, RW * 0.94, RH * 0.92, rRect * 0.9);
|
||
c.fill();
|
||
c.restore();
|
||
|
||
// ---- 岛屿本体(圆角矩形剪裁) ----
|
||
c.save();
|
||
rectPath(c, X0, Y0, RW, RH, rRect);
|
||
c.clip();
|
||
|
||
const microBgMap = {
|
||
plain: grassBgImg, forest: forestBgImg, mountain: mountainBgImg,
|
||
desert: desertBgImg, snow: snowBgImg, water: waterBgImg
|
||
};
|
||
const bgImg = microBgMap[tile.terrain];
|
||
if (bgImg && bgImg.naturalWidth > 0) {
|
||
// 微观视图:直接用对应地形背景图铺满浮岛,去掉默认树/石/建筑等装饰
|
||
const iw = bgImg.naturalWidth, ih = bgImg.naturalHeight;
|
||
const scale = Math.max(RW / iw, RH / ih);
|
||
const dw = iw * scale, dh = ih * scale;
|
||
c.drawImage(bgImg, 0, 0, iw, ih, X0 + (RW - dw) / 2, Y0 + (RH - dh) / 2, dw, dh);
|
||
} else {
|
||
// 基地:径向渐变,微凸起的泥土感
|
||
const base = c.createRadialGradient(cx, cy - Ry * 0.25, Math.min(Rx,Ry) * 0.08, cx, cy, Math.max(Rx,Ry));
|
||
base.addColorStop(0, shadeColor(terrain.groundColor, 14));
|
||
base.addColorStop(0.55, terrain.groundColor);
|
||
base.addColorStop(1, shadeColor(terrain.microBg, -18));
|
||
c.fillStyle = base;
|
||
c.fillRect(X0, Y0, RW, RH);
|
||
|
||
// 地形细斑(保持清晰,不糊)
|
||
drawTerrainGrain(c, cx, cy, Rx, Ry, terrain);
|
||
|
||
// 地形装饰物(确定性散布:树/石/仙人掌/雪堆/芦苇…)
|
||
drawTerrainDecor(c, cx, cy, Rx, Ry, tile, terrain);
|
||
|
||
// 建筑:清晰简笔 sprite(中心)
|
||
drawMicroBuilding(c, cx, cy, Rx, Ry, tile);
|
||
}
|
||
|
||
c.restore(); // 结束岛屿剪裁
|
||
|
||
// 岛屿边缘柔化:仅最外圈擦淡融入暗背景(集中在边缘,不侵蚀内容)
|
||
// 使用背景图的 terrain:跳过擦边,保证图片铺满到圆角边缘、内容不被遮挡
|
||
if (!bgImg) {
|
||
c.save();
|
||
c.globalCompositeOperation = 'destination-out';
|
||
const vig = c.createRadialGradient(cx, cy, Math.min(Rx,Ry) * 0.86, cx, cy, Math.max(Rx,Ry) * 1.0);
|
||
vig.addColorStop(0, 'rgba(0,0,0,0)');
|
||
vig.addColorStop(1, 'rgba(0,0,0,0.5)');
|
||
c.fillStyle = vig;
|
||
rectPath(c, X0, Y0, RW, RH, rRect);
|
||
c.fill();
|
||
c.restore();
|
||
}
|
||
|
||
|
||
// 岛屿外环微光(圆角矩形轮廓不死黑)
|
||
c.save();
|
||
c.globalCompositeOperation = 'lighter';
|
||
rectPath(c, X0, Y0, RW, RH, rRect);
|
||
c.lineWidth = 2;
|
||
c.strokeStyle = 'rgba(120,170,210,0.12)';
|
||
c.stroke();
|
||
c.restore();
|
||
|
||
buildMicroParticles(tile); // 方案A:按地块类型重建漂浮粒子
|
||
microGridCache = og;
|
||
}
|
||
|
||
// 暗背景星点/火星,呼应主世界氛围
|
||
function drawMicroStars(c, w, h, layer) {
|
||
const bg = LAYER_BG[layer] || LAYER_BG.earth;
|
||
if (bg.stars && layer === 'star') { // 仅星空层保留白色星点;大地/地下等不画(否则像背景噪点)
|
||
c.fillStyle = 'rgba(255,255,255,0.5)';
|
||
for (let i = 0; i < 60; i++) {
|
||
const sx = frac(Math.sin(i * 12.9898) * 43758.5453) * w;
|
||
const sy = frac(Math.sin(i * 78.233) * 12543.123) * h * 0.7;
|
||
c.fillRect(sx, sy, 1.4, 1.4);
|
||
}
|
||
}
|
||
if (bg.embers) {
|
||
c.fillStyle = 'rgba(255,130,50,0.5)';
|
||
for (let i = 0; i < 30; i++) {
|
||
const ex = frac(Math.sin(i * 9.1) * 9999.7) * w;
|
||
const ey = frac(Math.cos(i * 4.7) * 7777.3) * h;
|
||
c.fillRect(ex, ey, 1.8, 1.8);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============ 方案A:按地块类型的微观世界大气氛围 ============
|
||
function hexA(hex, a) {
|
||
const n = parseInt(hex.slice(1), 16);
|
||
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
|
||
}
|
||
|
||
// 静态氛围层:地形主题光雾 + 远景天光(绘于浮岛之下,主要在浮岛外暗区显现)
|
||
function drawMicroAtmoStatic(c, mW, mH, terrain) {
|
||
const a = terrain.atmo;
|
||
const cx = mW / 2, cy = mH / 2;
|
||
c.save();
|
||
c.globalCompositeOperation = 'lighter';
|
||
// 1) 整体大气雾:以浮岛为中心向外扩散
|
||
const fog = c.createRadialGradient(cx, cy, Math.min(mW, mH) * 0.16, cx, cy, Math.max(mW, mH) * 0.62);
|
||
fog.addColorStop(0, hexA(a.haze, 0.32));
|
||
fog.addColorStop(0.5, hexA(a.haze, 0.15));
|
||
fog.addColorStop(1, hexA(a.haze, 0));
|
||
c.fillStyle = fog; c.fillRect(0, 0, mW, mH);
|
||
// 2) 远景天光:浮岛后上方柔光斑
|
||
const gx = cx, gy = cy - mH * 0.18;
|
||
const glow = c.createRadialGradient(gx, gy, 0, gx, gy, Math.max(mW, mH) * 0.5);
|
||
glow.addColorStop(0, hexA(a.glow, 0.28));
|
||
glow.addColorStop(0.4, hexA(a.glow, 0.09));
|
||
glow.addColorStop(1, hexA(a.glow, 0));
|
||
c.fillStyle = glow; c.fillRect(0, 0, mW, mH);
|
||
c.restore();
|
||
}
|
||
|
||
// 生成按地块类型的漂浮粒子(切换地块时重建)
|
||
function buildMicroParticles(tile) {
|
||
const terrain = TERRAIN[tile.terrain];
|
||
const type = terrain.atmo.particle;
|
||
const seed = microHash((tile.id || (tile.q + '_' + tile.r)) + '_p');
|
||
let s = seed;
|
||
const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
|
||
const N = 30;
|
||
const arr = [];
|
||
for (let i = 0; i < N; i++) {
|
||
arr.push({
|
||
x: rnd(), y: rnd(),
|
||
r: 1.0 + rnd() * 2.4,
|
||
sp: 0.04 + rnd() * 0.10,
|
||
ph: rnd() * Math.PI * 2,
|
||
amp: 0.015 + rnd() * 0.03,
|
||
type
|
||
});
|
||
}
|
||
microState.particles = arr;
|
||
}
|
||
|
||
// 每帧绘制动态粒子层(营造成对地块主题的大气氛围)
|
||
function drawMicroAtmoParticles(c, mW, mH, terrain) {
|
||
const ps = microState.particles;
|
||
if (!ps || !ps.length) return;
|
||
const a = terrain.atmo;
|
||
const t = microState.animTime;
|
||
c.save();
|
||
for (const p of ps) {
|
||
let nx = p.x, ny = p.y;
|
||
const sway = Math.sin(t * 0.6 + p.ph) * p.amp;
|
||
if (p.type === 'snow' || p.type === 'leaf') {
|
||
ny = (p.y + t * p.sp * 0.5) % 1; nx = p.x + sway;
|
||
} else if (p.type === 'bubble' || p.type === 'sparkle') {
|
||
ny = (p.y - t * p.sp * 0.6) % 1; nx = p.x + sway * 0.6;
|
||
} else {
|
||
nx = (p.x + t * p.sp * 0.3 + sway) % 1;
|
||
ny = (p.y + Math.cos(t * 0.4 + p.ph) * p.amp) % 1;
|
||
}
|
||
nx = (nx % 1 + 1) % 1; ny = (ny % 1 + 1) % 1;
|
||
const px = nx * mW, py = ny * mH;
|
||
const tw = 0.55 + 0.45 * Math.sin(t * 2.2 + p.ph);
|
||
drawParticle(c, px, py, p.r, p.type, a.pcol, tw, t * 0.8 + p.ph);
|
||
}
|
||
c.restore();
|
||
}
|
||
|
||
// 单粒子绘制
|
||
function drawParticle(c, px, py, r, type, col, tw, ph) {
|
||
c.save();
|
||
c.globalCompositeOperation = 'lighter';
|
||
if (type === 'firefly' || type === 'sparkle') {
|
||
const g = c.createRadialGradient(px, py, 0, px, py, r * 3.2);
|
||
g.addColorStop(0, hexA(col, 0.9 * tw));
|
||
g.addColorStop(1, hexA(col, 0));
|
||
c.fillStyle = g; c.beginPath(); c.arc(px, py, r * 3.2, 0, 7); c.fill();
|
||
} else if (type === 'snow') {
|
||
c.fillStyle = hexA('#eaf4ff', 0.45 * tw + 0.4); c.beginPath(); c.arc(px, py, r, 0, 7); c.fill();
|
||
} else if (type === 'bubble') {
|
||
c.strokeStyle = hexA(col, 0.4 * tw + 0.35); c.lineWidth = 1; c.beginPath(); c.arc(px, py, r * 1.4, 0, 7); c.stroke();
|
||
c.fillStyle = hexA('#ffffff', 0.5 * tw); c.beginPath(); c.arc(px - r * 0.4, py - r * 0.4, r * 0.35, 0, 7); c.fill();
|
||
} else if (type === 'leaf') {
|
||
c.translate(px, py); c.rotate(ph); c.fillStyle = hexA(col, 0.5 * tw + 0.35);
|
||
c.beginPath(); c.ellipse(0, 0, r * 1.7, r * 0.7, 0, 0, 7); c.fill(); c.rotate(-ph); c.translate(-px, -py);
|
||
} else { // dust
|
||
c.fillStyle = hexA(col, 0.3 * tw + 0.15); c.beginPath(); c.arc(px, py, r * 1.5, 0, 7); c.fill();
|
||
}
|
||
c.restore();
|
||
}
|
||
|
||
// 地形细斑:低透明小格,做出土质起伏但不糊(矩形范围内)
|
||
function drawTerrainGrain(c, cx, cy, Rx, Ry, terrain) {
|
||
// 无缝细斑:确定性散点柔光,替代原规则网格(去掉硬网格感,保留自然起伏)
|
||
const seed = microHash((terrain.scene || '') + '_grain');
|
||
let s = seed;
|
||
const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
|
||
c.save();
|
||
const count = 280;
|
||
for (let i = 0; i < count; i++) {
|
||
const x = cx + (rnd() * 2 - 1) * Rx * 0.98;
|
||
const y = cy + (rnd() * 2 - 1) * Ry * 0.98;
|
||
const rad = 16 + rnd() * 40;
|
||
const dark = rnd() < 0.5;
|
||
const base = parseColor(shadeColor(terrain.groundColor, dark ? -16 : 14));
|
||
const g = c.createRadialGradient(x, y, 0, x, y, rad);
|
||
g.addColorStop(0, `rgba(${base[0]},${base[1]},${base[2]},0.16)`);
|
||
g.addColorStop(1, `rgba(${base[0]},${base[1]},${base[2]},0)`);
|
||
c.fillStyle = g;
|
||
c.beginPath(); c.arc(x, y, rad, 0, Math.PI * 2); c.fill();
|
||
}
|
||
c.restore();
|
||
}
|
||
|
||
// 地形装饰物:矩形内确定性散布(种子来自地块 id),中心区域留给建筑
|
||
function drawTerrainDecor(c, cx, cy, Rx, Ry, tile, terrain) {
|
||
let seed = microHash((tile.id || (tile.q + '_' + tile.r)) + '');
|
||
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
|
||
const count = 30;
|
||
const padX = Rx * 0.12, padY = Ry * 0.12, minD = Math.min(Rx, Ry) * 0.24;
|
||
for (let n = 0; n < count; n++) {
|
||
let x = cx + (rnd() * 2 - 1) * (Rx - padX);
|
||
let y = cy + (rnd() * 2 - 1) * (Ry - padY);
|
||
const dx = x - cx, dy = y - cy, d = Math.hypot(dx, dy); // 中心避让
|
||
if (d < minD && d > 0.001) { const kk = minD / d; x = cx + dx * kk; y = cy + dy * kk; }
|
||
const s = 0.55 + rnd() * 0.8;
|
||
drawDecorByTerrain(c, x, y, s, terrain, rnd);
|
||
}
|
||
}
|
||
|
||
// 按地形挑选装饰类型
|
||
function drawDecorByTerrain(c, x, y, s, terrain, rnd) {
|
||
switch (terrain.scene) {
|
||
case 'forest': rnd() < 0.8 ? drawTree(c, x, y, s) : drawRock(c, x, y, s); break;
|
||
case 'village': { const t = rnd(); t < 0.4 ? drawTree(c, x, y, s) : t < 0.7 ? drawBush(c, x, y, s) : drawRock(c, x, y, s); break; }
|
||
case 'mountain': rnd() < 0.7 ? drawRock(c, x, y, s * 1.3) : drawTree(c, x, y, s * 0.8); break;
|
||
case 'desert': rnd() < 0.6 ? drawCactus(c, x, y, s) : drawDune(c, x, y, s); break;
|
||
case 'snow': rnd() < 0.5 ? drawPine(c, x, y, s) : drawSnowPile(c, x, y, s); break;
|
||
case 'water': rnd() < 0.5 ? drawLily(c, x, y, s) : drawWave(c, x, y, s); break;
|
||
default: drawBush(c, x, y, s);
|
||
}
|
||
}
|
||
|
||
// ---- 各类装饰物(纯矢量,清晰) ----
|
||
function drawTree(c, x, y, s) {
|
||
c.save(); c.translate(x, y);
|
||
c.fillStyle = '#6b4a2b';
|
||
c.fillRect(-2 * s, -2 * s, 4 * s, 11 * s);
|
||
c.fillStyle = '#2f7d32';
|
||
c.beginPath(); c.arc(0, -9 * s, 9 * s, 0, Math.PI * 2); c.fill();
|
||
c.fillStyle = '#43a047';
|
||
c.beginPath(); c.arc(-4 * s, -11 * s, 6 * s, 0, Math.PI * 2); c.fill();
|
||
c.beginPath(); c.arc(5 * s, -10 * s, 6 * s, 0, Math.PI * 2); c.fill();
|
||
c.restore();
|
||
}
|
||
function drawPine(c, x, y, s) {
|
||
c.save(); c.translate(x, y);
|
||
c.fillStyle = '#5b3a1e';
|
||
c.fillRect(-2 * s, -2 * s, 4 * s, 9 * s);
|
||
c.fillStyle = '#1f5e2f';
|
||
for (let k = 0; k < 3; k++) {
|
||
const yy = -3 * s - k * 6 * s, ww = (10 - k * 2.5) * s;
|
||
c.beginPath(); c.moveTo(-ww, yy); c.lineTo(0, yy - 9 * s); c.lineTo(ww, yy); c.closePath(); c.fill();
|
||
}
|
||
c.restore();
|
||
}
|
||
function drawRock(c, x, y, s) {
|
||
c.save(); c.translate(x, y);
|
||
c.fillStyle = '#7d7d7d';
|
||
c.beginPath(); c.ellipse(0, 0, 8 * s, 6 * s, 0, 0, Math.PI * 2); c.fill();
|
||
c.fillStyle = '#9a9a9a';
|
||
c.beginPath(); c.ellipse(-2 * s, -2 * s, 4 * s, 3 * s, 0, 0, Math.PI * 2); c.fill();
|
||
c.restore();
|
||
}
|
||
function drawBush(c, x, y, s) {
|
||
c.save(); c.translate(x, y);
|
||
c.fillStyle = '#3c8c3c';
|
||
c.beginPath(); c.arc(-3 * s, 0, 5 * s, 0, Math.PI * 2); c.fill();
|
||
c.beginPath(); c.arc(3 * s, 0, 5 * s, 0, Math.PI * 2); c.fill();
|
||
c.beginPath(); c.arc(0, -3 * s, 5 * s, 0, Math.PI * 2); c.fill();
|
||
c.restore();
|
||
}
|
||
function drawCactus(c, x, y, s) {
|
||
c.save(); c.translate(x, y);
|
||
c.fillStyle = '#3f8f4f';
|
||
roundRect(c, -3 * s, -12 * s, 6 * s, 18 * s, 3 * s); c.fill();
|
||
roundRect(c, -9 * s, -6 * s, 5 * s, 4 * s, 2 * s); c.fill();
|
||
roundRect(c, -9 * s, -10 * s, 4 * s, 8 * s, 2 * s); c.fill();
|
||
roundRect(c, 4 * s, -8 * s, 5 * s, 4 * s, 2 * s); c.fill();
|
||
roundRect(c, 5 * s, -12 * s, 4 * s, 8 * s, 2 * s); c.fill();
|
||
c.restore();
|
||
}
|
||
function drawDune(c, x, y, s) {
|
||
c.save(); c.translate(x, y);
|
||
c.fillStyle = 'rgba(200,168,90,0.5)';
|
||
c.beginPath(); c.ellipse(0, 0, 11 * s, 4 * s, 0, 0, Math.PI); c.fill();
|
||
c.restore();
|
||
}
|
||
function drawSnowPile(c, x, y, s) {
|
||
c.save(); c.translate(x, y);
|
||
c.fillStyle = '#eef4fb';
|
||
c.beginPath(); c.ellipse(0, 0, 9 * s, 5 * s, 0, 0, Math.PI * 2); c.fill();
|
||
c.fillStyle = '#ffffff';
|
||
c.beginPath(); c.ellipse(-2 * s, -2 * s, 4 * s, 2.5 * s, 0, 0, Math.PI * 2); c.fill();
|
||
c.restore();
|
||
}
|
||
function drawWave(c, x, y, s) {
|
||
c.save(); c.translate(x, y);
|
||
c.strokeStyle = 'rgba(160,210,240,0.85)'; c.lineWidth = 2 * s;
|
||
c.beginPath(); c.arc(0, 0, 7 * s, Math.PI * 0.15, Math.PI * 0.85); c.stroke();
|
||
c.restore();
|
||
}
|
||
function drawLily(c, x, y, s) {
|
||
c.save(); c.translate(x, y);
|
||
c.fillStyle = '#3f8f6f';
|
||
c.beginPath(); c.ellipse(0, 0, 8 * s, 4 * s, 0, 0, Math.PI * 2); c.fill();
|
||
c.fillStyle = '#ff9ec4';
|
||
c.beginPath(); c.arc(0, -1 * s, 2.5 * s, 0, Math.PI * 2); c.fill();
|
||
c.restore();
|
||
}
|
||
|
||
// 建筑:中心清晰简笔 sprite;无建筑则不画任何东西(干净无干扰)
|
||
function drawMicroBuilding(c, cx, cy, Rx, Ry, tile) {
|
||
const x = cx, y = cy - Ry * 0.02;
|
||
if (tile.hasBuilding && microState.buildings[0]) {
|
||
const b = microState.buildings[0];
|
||
drawHouseSprite(c, x, y, Math.min(Rx,Ry) * 0.32, b.color, b.icon);
|
||
}
|
||
}
|
||
|
||
function drawHouseSprite(c, x, y, size, color, icon) {
|
||
c.save(); c.translate(x, y);
|
||
const w = size, h = size * 0.78;
|
||
c.fillStyle = 'rgba(0,0,0,0.28)';
|
||
c.beginPath(); c.ellipse(0, h * 0.55, w * 0.6, h * 0.16, 0, 0, Math.PI * 2); c.fill();
|
||
c.fillStyle = shadeColor(color, -14);
|
||
roundRect(c, -w / 2, -h * 0.08, w, h * 0.66, 4); c.fill();
|
||
c.fillStyle = color;
|
||
c.beginPath(); c.moveTo(-w * 0.64, -h * 0.08); c.lineTo(0, -h * 0.72); c.lineTo(w * 0.64, -h * 0.08); c.closePath(); c.fill();
|
||
c.fillStyle = 'rgba(0,0,0,0.35)';
|
||
roundRect(c, -w * 0.12, h * 0.12, w * 0.24, h * 0.46, 2); c.fill();
|
||
c.font = `${size * 0.42}px sans-serif`; c.textAlign = 'center'; c.textBaseline = 'middle';
|
||
c.fillText(icon, 0, -h * 0.36);
|
||
c.restore();
|
||
}
|
||
|
||
function roundRect(c, x, y, w, h, r) {
|
||
c.beginPath();
|
||
c.moveTo(x + r, y);
|
||
c.arcTo(x + w, y, x + w, y + h, r);
|
||
c.arcTo(x + w, y + h, x, y + h, r);
|
||
c.arcTo(x, y + h, x, y, r);
|
||
c.arcTo(x, y, x + w, y, r);
|
||
c.closePath();
|
||
}
|
||
|
||
function frac(x) { return x - Math.floor(x); }
|
||
function microHash(str) {
|
||
let h = 2166136261;
|
||
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||
return h >>> 0;
|
||
}
|
||
|
||
// ============ 单位详情弹窗 ============
|
||
function showUnitModal(unitId) {
|
||
if (!microState) return;
|
||
const unit = microState.units.find(u => u.id === unitId);
|
||
if (!unit) return;
|
||
if (!unit.equip) unit.equip = { weapon: null, armor: null, accessory: null };
|
||
const modal = document.getElementById('unitModal');
|
||
document.getElementById('modalTitle').textContent = `${unit.icon} ${unit.name} · Lv.${unit.level}`;
|
||
const hpColor = unit.hp > 70 ? '#8bc34a' : unit.hp > 40 ? '#ff9800' : '#f44336';
|
||
const enColor = unit.energy > 70 ? '#4af' : unit.energy > 40 ? '#ff9800' : '#f44336';
|
||
const need = unit.level * 100;
|
||
const expPct = Math.max(0, Math.min(100, Math.round((unit.exp % need) / need * 100)));
|
||
const slotHtml = ['weapon', 'armor', 'accessory'].map(slot => {
|
||
const map = { weapon: '武器', armor: '护甲', accessory: '饰品' };
|
||
const cur = unit.equip[slot];
|
||
const curInfo = cur ? (EQUIP_CATALOG[slot] || []).find(e => e.code === cur) : null;
|
||
let inner;
|
||
if (curInfo) {
|
||
inner = `<div class="equipCur">${curInfo.icon} ${curInfo.name} <span class="equipBonus">${curInfo.bonus}</span> <span class="equipX" onclick="microEquip('${unit.id}','${slot}',null,true)">✕卸下</span></div>`;
|
||
} else {
|
||
inner = (EQUIP_CATALOG[slot] || []).map(e =>
|
||
`<div class="equipOpt" onclick="microEquip('${unit.id}','${slot}','${e.code}',false)">${e.icon} ${e.name}<span class="equipBonus">${e.bonus}</span></div>`).join('');
|
||
}
|
||
return `<div class="equipSlot"><div class="equipSlotName">${map[slot]}</div>${inner}</div>`;
|
||
}).join('');
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<div class="statRow"><span class="statLabel">状态</span><span class="statValue" style="color:${unit.status==='working'?'#8bc34a':unit.status==='resting'?'#ff9800':'#4af'}">${unit.status==='working'?'🟢 工作中':unit.status==='resting'?'🟡 休息中':'🔵 移动中'}</span></div>
|
||
<div class="statRow"><span class="statLabel">生命</span><div class="statBar"><div class="statFill" style="width:${Math.round(unit.hp/(unit.hp_max||120)*100)}%;background:${hpColor};"></div></div><span class="statValue">${unit.hp}/${unit.hp_max||120}</span></div>
|
||
<div class="statRow"><span class="statLabel">能量</span><div class="statBar"><div class="statFill" style="width:${unit.energy}%;background:${enColor};"></div></div><span class="statValue">${unit.energy}</span></div>
|
||
<div class="statRow"><span class="statLabel">经验</span><div class="statBar"><div class="statFill" style="width:${expPct}%;background:#64b5f6;"></div></div><span class="statValue">${unit.exp}</span></div>
|
||
<div class="statRow"><span class="statLabel">职业</span><span class="statValue">${unit.job || '无'}</span></div>
|
||
<div class="statRow"><span class="statLabel">心情</span><span class="statValue">${unit.mood}</span></div>
|
||
<div style="margin-top:14px;"><div class="panel-label">装备(穿戴写入 Odoo 生物记录)</div><div class="equipWrap">${slotHtml}</div></div>
|
||
<div style="margin-top:14px;"><div class="panel-label">操作指令</div>
|
||
<div class="cmdBtn" onclick="microUnitTrain('${unit.id}')">⬆️ 训练升级</div>
|
||
<div class="cmdBtn" onclick="microUnitRest('${unit.id}')">😴 令其休息</div>
|
||
</div>
|
||
`;
|
||
modal.classList.add('active');
|
||
}
|
||
|
||
// 穿戴 / 卸下装备(联动角色背包,持久化到 Odoo 生物记录)
|
||
async function microEquip(unitId, slot, code, unequip) {
|
||
const unit = microState && microState.units.find(u => u.id === unitId);
|
||
if (!unit) return;
|
||
if (!unit.equip) unit.equip = { weapon: null, armor: null, accessory: null };
|
||
if (unequip) {
|
||
const prev = unit.equip[slot];
|
||
unit.equip[slot] = null;
|
||
if (prev && microChar && microChar.inventory) {
|
||
microChar.inventory[prev] = (microChar.inventory[prev] || 0) + 1;
|
||
}
|
||
} else {
|
||
const prev = unit.equip[slot];
|
||
if (prev && microChar && microChar.inventory) microChar.inventory[prev] = (microChar.inventory[prev] || 0) + 1;
|
||
unit.equip[slot] = code;
|
||
if (microChar && microChar.inventory) {
|
||
microChar.inventory[code] = (microChar.inventory[code] || 0) - 1;
|
||
if (microChar.inventory[code] <= 0) delete microChar.inventory[code];
|
||
}
|
||
}
|
||
microRenderCharHud();
|
||
await microSaveCreatures();
|
||
if (microChar) await updateChar();
|
||
showUnitModal(unitId);
|
||
}
|
||
|
||
// 训练升级:等级+1、经验清零、持久化
|
||
async function microUnitTrain(unitId) {
|
||
const unit = microState && microState.units.find(u => u.id === unitId);
|
||
if (!unit) return;
|
||
unit.level = (unit.level || 1) + 1;
|
||
unit.exp = 0;
|
||
unit.hp_max = (unit.hp_max || 120) + 10;
|
||
unit.hp = unit.hp_max;
|
||
microSaveCreatures();
|
||
showUnitModal(unitId);
|
||
}
|
||
// 令其休息:状态切 resting
|
||
async function microUnitRest(unitId) {
|
||
const unit = microState && microState.units.find(u => u.id === unitId);
|
||
if (!unit) return;
|
||
unit.status = 'resting';
|
||
microSaveCreatures();
|
||
showUnitModal(unitId);
|
||
}
|
||
// 保存角色(金币/背包/装备)到 Odoo
|
||
async function updateChar() {
|
||
if (!microChar) return;
|
||
const r = await API.saveChar({ gold: microChar.gold, inventory: microChar.inventory, equip: microChar.equip });
|
||
if (r && r.ok && r.char) microChar = r.char;
|
||
}
|
||
|
||
function closeUnitModal() { document.getElementById('unitModal').classList.remove('active'); }
|
||
|
||
// ===== 微观建筑:悬停 tooltip + 点击弹窗(与 Odoo 后台目录联动)=====
|
||
let microTipEl = null;
|
||
function ensureMicroTip() {
|
||
if (microTipEl) return microTipEl;
|
||
microTipEl = document.createElement('div');
|
||
microTipEl.id = 'microTip';
|
||
document.body.appendChild(microTipEl);
|
||
return microTipEl;
|
||
}
|
||
function hideMicroHoverTip() { if (microTipEl) microTipEl.style.display = 'none'; microHoverB = null; }
|
||
function updateMicroHoverTip(e) {
|
||
const tip = ensureMicroTip();
|
||
if (!microState || microBuildMode) { tip.style.display = 'none'; microHoverB = null; return; }
|
||
const cell = microCellAt(getMicroPoint(e));
|
||
const hit = microBuildingAt(cell);
|
||
if (!hit) { tip.style.display = 'none'; microHoverB = null; return; }
|
||
const it = microBuildItemById(hit.type);
|
||
// 道路 / 地面:铺满一格的纹理,没有"建筑边界"概念,按用户要求不响应 hover 高亮与点击交互
|
||
if (!it || it.cat === 'road' || it.cat === 'ground') { tip.style.display = 'none'; microHoverB = null; return; }
|
||
const img = (typeof it.icon === 'string') ? `<img src="${it.icon}" alt="">` : '';
|
||
tip.innerHTML = `<div class="tipName">${img}<span>${it.name || hit.type}</span></div>`
|
||
+ (it.desc ? `<div class="tipDesc">${it.desc}</div>` : '');
|
||
tip.style.display = 'block';
|
||
const pad = 14;
|
||
let x = e.clientX + pad, y = e.clientY + pad;
|
||
const r = tip.getBoundingClientRect();
|
||
if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
|
||
if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
|
||
tip.style.left = x + 'px';
|
||
tip.style.top = y + 'px';
|
||
microHoverB = hit; // 渲染层用:footprint 高亮
|
||
}
|
||
function microCatName(cat) {
|
||
const c = (typeof MICRO_BUILD_CATS !== 'undefined' && MICRO_BUILD_CATS.find(x => x.id === cat)) || null;
|
||
return c ? c.name : (cat || '其他');
|
||
}
|
||
// 点击已放置建筑 → 复用 unitModal 展示默认数据(demo 交互)
|
||
function showBuildModal(b) {
|
||
if (!b) return;
|
||
const it = microBuildItemById(b.type);
|
||
if (!it) return;
|
||
// 道路 / 地面:纹理铺满一格,没有"建筑"语义,按用户要求不响应点击交互
|
||
if (it.cat === 'road' || it.cat === 'ground') return;
|
||
// 建筑运行时状态(默认)
|
||
if (b.level == null) b.level = it.statLevel || 1;
|
||
if (b.exp == null) b.exp = 0;
|
||
if (b.hp == null) b.hp = it.statHp || 100;
|
||
microOpenBuilding = b;
|
||
// 优先用后端目录(micro_catalog)同步来的动作;没有则回退前端兜底 BUILD_INTERACT
|
||
const cfg = (it.actions && it.actions.length)
|
||
? { title: it.name, tagline: it.desc || '', actions: it.actions }
|
||
: (BUILD_INTERACT[b.type] || { title: it.name, tagline: it.desc || '', actions: [] });
|
||
const name = it.name || b.type;
|
||
const img = (typeof it.icon === 'string')
|
||
? `<img src="${it.icon}" alt="" style="width:64px;height:64px;object-fit:contain;margin:2px auto 10px;display:block;">` : '';
|
||
const yld = (it.statYield && typeof it.statYield === 'object' && Object.keys(it.statYield).length)
|
||
? Object.entries(it.statYield)
|
||
.map(([k, v]) => `<div class="statRow"><span class="statLabel">产出·${k}</span><span class="statValue">+${v}</span></div>`).join('') : '';
|
||
const need = b.level * 100;
|
||
const expPct = Math.max(0, Math.min(100, Math.round((b.exp % need) / need * 100)));
|
||
const hpPct = Math.max(0, Math.min(100, Math.round(b.hp / (it.statHp || 100) * 100)));
|
||
const hpColor = hpPct > 50 ? '#8bc34a' : hpPct > 25 ? '#ff9800' : '#f44336';
|
||
const acts = (cfg.actions || []).map(a =>
|
||
`<div class="cmdBtn" data-btype="${b.type}" data-baction="${a.action}" data-blabel="${a.label}">${a.icon} ${a.label}<div style="font-size:10px;opacity:.7;margin-top:2px;white-space:normal;">${a.desc}</div></div>`
|
||
).join('');
|
||
document.getElementById('modalTitle').textContent = `${name} · Lv.${b.level}`;
|
||
const body = document.getElementById('modalBody');
|
||
body.innerHTML = `
|
||
<div style="text-align:center">${img}</div>
|
||
${it.desc ? `<div style="color:rgba(234,242,255,0.78);font-size:12px;line-height:1.5;margin:0 0 12px;">${it.desc}</div>` : ''}
|
||
<div class="statRow"><span class="statLabel">类别</span><span class="statValue">${microCatName(it.cat)}</span></div>
|
||
<div class="statRow"><span class="statLabel">等级</span><span class="statValue">Lv.${b.level} ${'★'.repeat(Math.min(b.level, 5))}</span></div>
|
||
<div class="statRow"><span class="statLabel">经验</span><div class="statBar"><div class="statFill" style="width:${expPct}%;background:#64b5f6;"></div></div><span class="statValue">${b.exp}</span></div>
|
||
<div class="statRow"><span class="statLabel">耐久</span><div class="statBar"><div class="statFill" style="width:${hpPct}%;background:${hpColor};"></div></div><span class="statValue">${b.hp}</span></div>
|
||
${yld}
|
||
<div style="margin-top:16px;"><div class="panel-label">${cfg.tagline || '交互'}</div>
|
||
<div class="cmdGrid">${acts || '<div style="color:#8899aa;font-size:12px;">暂无可交互动作</div>'}</div>
|
||
</div>
|
||
`;
|
||
body.querySelectorAll('.cmdBtn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
microBuildingAct(btn.dataset.btype, btn.dataset.baction, btn.dataset.blabel);
|
||
});
|
||
});
|
||
document.getElementById('unitModal').classList.add('active');
|
||
}
|
||
|
||
// 执行建筑交互动作(数据驱动 → 后端 buildingAction)
|
||
async function microBuildingAct(type, action, label) {
|
||
const tile = microState && microState.tile;
|
||
if (!tile) return;
|
||
const bpos = microOpenBuilding || {};
|
||
const res = await API.buildingAction({ code: type, action, q: tile.q, r: tile.r, i: bpos.i, j: bpos.j });
|
||
if (!res || res.ok === false) {
|
||
const msg = res && res.error === 'no_gold' ? '金币不足'
|
||
: (res && res.error === 'no_res' ? ('资源不足:' + (res.res || '')) : '操作失败(未登录或网络异常)');
|
||
microToast(msg); return;
|
||
}
|
||
// 同步角色金币 / 背包
|
||
if (res.gold != null) { microChar = microChar || {}; microChar.gold = res.gold; }
|
||
if (res.inventory && microChar) microChar.inventory = res.inventory;
|
||
microRenderCharHud();
|
||
// 发射任务事件:建筑交互 + 资源产出
|
||
const _bkey = type + '_' + action;
|
||
emitTaskEvent('building_action', { key: _bkey });
|
||
const _res = TASK_RES[_bkey];
|
||
if (_res) emitTaskEvent('resource_collected', { res: _res });
|
||
// 升级:用后端落库后的权威等级/经验回写并持久化
|
||
if (action === 'upgrade' && microOpenBuilding && res.level != null) {
|
||
microOpenBuilding.level = res.level;
|
||
microOpenBuilding.exp = res.exp;
|
||
scheduleBuildSave();
|
||
}
|
||
// 治疗:回满本村所有生物生命
|
||
if (action === 'heal' && microState && microState.units) {
|
||
microState.units.forEach(u => { u.hp = u.hp_max || 120; });
|
||
microSaveCreatures();
|
||
}
|
||
microToast(res.message || '操作成功');
|
||
if (microOpenBuilding && document.getElementById('unitModal').classList.contains('active')) {
|
||
showBuildModal(microOpenBuilding);
|
||
}
|
||
}
|
||
|
||
// ============ 工具函数 ============
|
||
function parseColor(c) {
|
||
if (!c) return [0, 0, 0];
|
||
if (c[0] === '#') { const n = parseInt(c.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
|
||
const m = c.match(/\d+/g);
|
||
if (m && m.length >= 3) return [+m[0], +m[1], +m[2]];
|
||
return [0, 0, 0];
|
||
}
|
||
function shadeColor(color, percent) {
|
||
const [r, g, b] = parseColor(color);
|
||
return `rgb(${Math.min(255, Math.max(0, r + percent))},${Math.min(255, Math.max(0, g + percent))},${Math.min(255, Math.max(0, b + percent))})`;
|
||
}
|
||
|
||
// ============ 事件(修复版)============
|
||
canvas.addEventListener('wheel', e => {
|
||
// 建造 · 捏高度工具:滚轮微调悬停格
|
||
if (buildMode && buildTool === 'height' && currentLayer === 'earth' && hoveredTile) {
|
||
e.preventDefault();
|
||
applyHeight(hoveredTile, e.deltaY < 0 ? 1 : -1, true);
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1;
|
||
camera.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, camera.zoom * zoomDelta));
|
||
clampCamera(); // 缩放后重新钳制,避免世界跑出屏幕
|
||
// 缩放交互:激活快照快路径 + 关阴影模糊,缩放停 120ms 后恢复全量重绘
|
||
camInteracting = true;
|
||
worldBaseValid = false; // 相机变了,基座缓存失效,停手后重建
|
||
worldCacheValid = false; // 强制交互首帧重拍快照(避免用陈旧快照)
|
||
if (_camIntT) clearTimeout(_camIntT);
|
||
_camIntT = setTimeout(() => { camInteracting = false; }, 120);
|
||
}, { passive: false });
|
||
|
||
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
||
canvas.addEventListener('mousedown', e => {
|
||
// 建造 · 捏高度工具:左右键分别升/降,不平移相机
|
||
if (buildMode && buildTool === 'height' && currentLayer === 'earth') {
|
||
e.preventDefault();
|
||
isMouseDown = true;
|
||
editingHeight = true;
|
||
heightBrush = (e.button === 2) ? -1 : 1;
|
||
heightStroke.clear();
|
||
const rect = canvas.getBoundingClientRect();
|
||
const hex = pixelToHex(e.clientX - rect.left, e.clientY - rect.top);
|
||
const t = getTile(hex.q, hex.r);
|
||
if (t) applyHeight(t, heightBrush);
|
||
return;
|
||
}
|
||
if (e.button !== 0) return;
|
||
isMouseDown = true;
|
||
hasDragged = false;
|
||
mouseDownX = e.clientX;
|
||
mouseDownY = e.clientY;
|
||
dragCamStartX = camera.x;
|
||
dragCamStartY = camera.y;
|
||
camInteracting = true;
|
||
worldCacheValid = false; // 强制交互首帧重拍快照(避免用陈旧快照)
|
||
// 建造 · 移动工具:按住即进入"搬运交换",不平移相机
|
||
if (buildMode && buildTool === 'move' && currentLayer === 'earth') {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const hex = pixelToHex(e.clientX - rect.left, e.clientY - rect.top);
|
||
dragSwapFrom = getTile(hex.q, hex.r);
|
||
draggingSwap = !!dragSwapFrom;
|
||
}
|
||
});
|
||
|
||
canvas.addEventListener('mousemove', e => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const mx = e.clientX - rect.left;
|
||
const my = e.clientY - rect.top;
|
||
const hex = pixelToHex(mx, my);
|
||
const t = getTile(hex.q, hex.r);
|
||
const coordEl = document.getElementById('coords');
|
||
if (t) { coordEl.textContent = `坐标: (${hex.q}, ${hex.r})`; coordEl.style.display = ''; }
|
||
else { coordEl.style.display = 'none'; }
|
||
|
||
if (isMouseDown) {
|
||
if (editingHeight) {
|
||
const t = getTile(hex.q, hex.r);
|
||
if (t) applyHeight(t, heightBrush);
|
||
return; // 捏高度笔触中:只刷高度
|
||
}
|
||
if (draggingSwap) {
|
||
hoveredTile = getTile(hex.q, hex.r);
|
||
return; // 搬运中:只更新目标高亮
|
||
}
|
||
// 普通左键拖拽:平移相机(核心交互,之前未接上)
|
||
const dx = e.clientX - mouseDownX;
|
||
const dy = e.clientY - mouseDownY;
|
||
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) hasDragged = true;
|
||
camera.x = dragCamStartX + dx;
|
||
camera.y = dragCamStartY + dy;
|
||
clampCamera(); // 限制边界:世界不能拖出屏幕
|
||
// 到边界即"黏住":重置拖拽起点到当前边界位置,继续往外拖世界纹丝不动,
|
||
// 往回拖立即跟手 —— 消除越界积压量导致的死区/反弹(世界比视口小时边界反转,跳过)
|
||
if (_camBoundX.min <= _camBoundX.max && (camera.x <= _camBoundX.min || camera.x >= _camBoundX.max)) dragCamStartX = camera.x - dx;
|
||
if (_camBoundY.min <= _camBoundY.max && (camera.y <= _camBoundY.min || camera.y >= _camBoundY.max)) dragCamStartY = camera.y - dy;
|
||
worldBaseValid = false; // 相机平移中:基座缓存失效(交互快照路径接管显示)
|
||
} else {
|
||
// 只有没按下鼠标时才检测悬停
|
||
hoveredTile = getTile(hex.q, hex.r);
|
||
if (buildTool === 'height' && currentLayer === 'earth' && hoveredTile) {
|
||
const hv = hoveredTile.height != null ? hoveredTile.height : 0;
|
||
const sl = document.getElementById('bpHeightSlider');
|
||
const vl = document.getElementById('bpHeightVal');
|
||
if (sl) sl.value = String(hv);
|
||
if (vl) vl.textContent = heightLabel(hv);
|
||
}
|
||
}
|
||
});
|
||
|
||
canvas.addEventListener('mouseup', e => {
|
||
if (!isMouseDown) return;
|
||
isMouseDown = false;
|
||
camInteracting = false; // 松手:恢复全量重绘(render 会渐入阴影,无硬跳变)
|
||
|
||
// 建造模式 · 移动工具:拖拽/点击交换板块
|
||
if (draggingSwap) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const hex = pixelToHex(e.clientX - rect.left, e.clientY - rect.top);
|
||
const target = getTile(hex.q, hex.r);
|
||
if (target && swapFirst && target !== swapFirst) {
|
||
swapTiles(swapFirst, target); swapFirst = null;
|
||
} else if (target && !swapFirst && target !== dragSwapFrom) {
|
||
swapTiles(dragSwapFrom, target); swapFirst = null;
|
||
} else if (target && !swapFirst && target === dragSwapFrom) {
|
||
swapFirst = dragSwapFrom; // 单击选为待交换第一块
|
||
} else if (target && swapFirst && target === swapFirst) {
|
||
swapFirst = null; // 再次点击同一块 → 取消
|
||
}
|
||
draggingSwap = false; dragSwapFrom = null;
|
||
return;
|
||
}
|
||
|
||
if (editingHeight) { editingHeight = false; heightStroke.clear(); return; }
|
||
|
||
if (!hasDragged) {
|
||
// 这是点击,不是拖拽
|
||
const rect = canvas.getBoundingClientRect();
|
||
const mx = e.clientX - rect.left;
|
||
const my = e.clientY - rect.top;
|
||
const hex = pixelToHex(mx, my);
|
||
const tile = getTile(hex.q, hex.r);
|
||
if (tile) {
|
||
if (tile.locked) { tryActivateTile(tile); return; } // 锁定格:弹激活对话框
|
||
if (buildMode && currentLayer === 'earth') {
|
||
handleBuildClick(tile);
|
||
} else {
|
||
selectedTile = tile;
|
||
enterMicroWorld(tile);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
canvas.addEventListener('mouseleave', () => {
|
||
isMouseDown = false;
|
||
hasDragged = false;
|
||
camInteracting = false;
|
||
canvas.classList.remove('dragging');
|
||
hoveredTile = null;
|
||
document.getElementById('coords').style.display = 'none';
|
||
});
|
||
|
||
// ============ 移动端触摸支持(单指平移/点选 + 双指捏合缩放)============
|
||
(function setupTouch(){
|
||
if (!('ontouchstart' in window)) return; // 仅触摸设备启用,桌面不受影响
|
||
canvas.style.touchAction = 'none';
|
||
let mode = null; // 'pan' | 'pinch'
|
||
let panId = null;
|
||
let lastDist = 0;
|
||
const fire = (type, t) => canvas.dispatchEvent(new MouseEvent(type, {
|
||
clientX: t.clientX, clientY: t.clientY, button: 0, buttons: 1,
|
||
bubbles: true, cancelable: true
|
||
}));
|
||
canvas.addEventListener('touchstart', e => {
|
||
if (microViewOpen) return;
|
||
if (e.touches.length === 1) {
|
||
mode = 'pan'; panId = e.touches[0].identifier;
|
||
e.preventDefault();
|
||
fire('mousedown', e.touches[0]);
|
||
} else if (e.touches.length >= 2) {
|
||
if (mode === 'pan') { fire('mouseup', e.touches[0]); }
|
||
mode = 'pinch';
|
||
lastDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
|
||
e.preventDefault();
|
||
}
|
||
}, { passive: false });
|
||
canvas.addEventListener('touchmove', e => {
|
||
if (microViewOpen) return;
|
||
if (mode === 'pan' && e.touches.length === 1) {
|
||
const t = e.touches[0];
|
||
if (t.identifier === panId) { e.preventDefault(); fire('mousemove', t); }
|
||
} else if (mode === 'pinch' && e.touches.length >= 2) {
|
||
e.preventDefault();
|
||
const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
|
||
if (lastDist > 0) {
|
||
camera.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, camera.zoom * (d / lastDist)));
|
||
}
|
||
lastDist = d;
|
||
camInteracting = true; worldCacheValid = false; // 走交互快路径(缩放走全量重绘,无露缝)
|
||
if (_camIntT) clearTimeout(_camIntT);
|
||
_camIntT = setTimeout(() => { camInteracting = false; }, 120);
|
||
}
|
||
}, { passive: false });
|
||
const endTouch = e => {
|
||
if (mode === 'pan') { fire('mouseup', e.changedTouches[0]); }
|
||
else if (mode === 'pinch' && e.touches.length === 1) {
|
||
// 从捏合退回单指:重置为平移起点,避免相机跳变
|
||
mode = 'pan'; panId = e.touches[0].identifier;
|
||
fire('mousedown', e.touches[0]);
|
||
}
|
||
if (e.touches.length === 0) mode = null;
|
||
};
|
||
canvas.addEventListener('touchend', endTouch);
|
||
canvas.addEventListener('touchcancel', endTouch);
|
||
})();
|
||
|
||
// ============ 建造模式:地形 & 板块交换 & 高度 ============
|
||
// 地形笔:点击地块刷地形 + 选定素材变体(仅大地层)
|
||
function handleBuildClick(tile) {
|
||
if (buildTool === 'terrain') {
|
||
// ---- 地形工具:直接刷地形 ----
|
||
if (tile.terrain !== buildBrush) {
|
||
tile.terrain = buildBrush;
|
||
tile.height = terrainToHeight(buildBrush);
|
||
tile.tier = heightToTier(tile.height);
|
||
markDirty(tile.q, tile.r);
|
||
}
|
||
const arr = TERRAIN_ASSETS[buildBrush];
|
||
if (arr && arr.length) {
|
||
tile.variant = (buildVariant === 'random')
|
||
? pickVariantIndex(terrainTex[buildBrush] || [], tile.q, tile.r)
|
||
: Math.max(0, Math.min(arr.length - 1, buildVariant));
|
||
} else {
|
||
tile.variant = -1;
|
||
}
|
||
} else if (buildTool === 'height') {
|
||
// ---- 高度工具:单击地块 → 设为滑块目标高度 ----
|
||
setHeight(tile, heightTarget, true);
|
||
markDirty(tile.q, tile.r);
|
||
} else if (buildTool === 'move') {
|
||
// ---- 移动工具:单击选中/交换 ----
|
||
if (!swapFirst || swapFirst === tile) {
|
||
// 第一次点或重复点同一块:切换选中状态
|
||
swapFirst = (swapFirst === tile) ? null : tile;
|
||
} else {
|
||
// 已有一块选中,点另一块 → 交换
|
||
swapTiles(swapFirst, tile);
|
||
swapFirst = null;
|
||
}
|
||
}
|
||
// manage 工具不处理单块点击(全局比例调整)
|
||
}
|
||
|
||
// ============ 锁定领地激活(消耗激活点解锁) ============
|
||
// 前端默认激活点无限;后续接后端时改为有限值(activationPoints = <后端返回>)
|
||
let activationPoints = Infinity;
|
||
let pendingUnlockTile = null;
|
||
const unlockedRing = new Set(); // 已解锁环格坐标(q,r),跨"管理→应用"重新生成世界时保留
|
||
function hasActivationPoints() { return activationPoints === Infinity || activationPoints > 0; }
|
||
|
||
// 点击锁定格 → 弹对话框:有激活点=确认激活;无激活点=提示不足
|
||
function tryActivateTile(tile) {
|
||
if (!tile || !tile.locked) return;
|
||
if (!hasActivationPoints()) {
|
||
window.openAppModal('激活点不足',
|
||
'<div style="color:#cfd5ea;line-height:1.6;">需要激活点才能解锁边界领地。<br>当前激活点已用尽,去完成研究或任务获取更多激活点吧。</div>' +
|
||
'<div class="modalBtns"><button class="btn-cancel" onclick="closeAppModal()">知道了</button></div>');
|
||
return;
|
||
}
|
||
const tName = (TERRAIN[tile.terrain] && TERRAIN[tile.terrain].name) || tile.terrain;
|
||
pendingUnlockTile = tile;
|
||
window.openAppModal('激活领地',
|
||
'<div style="color:#cfd5ea;line-height:1.6;">确认消耗 <b style="color:#5eead4;">1 激活点</b> 解锁这块「<b>' + tName + '</b>」边界领地吗?<br>' +
|
||
'<span style="font-size:12px;color:#8899aa;">解锁后可正常建造与改造。</span></div>' +
|
||
'<div class="modalBtns">' +
|
||
'<button class="btn-cancel" onclick="closeAppModal()">取消</button>' +
|
||
'<button class="btn-confirm" onclick="doUnlockTile()">激活</button>' +
|
||
'</div>');
|
||
}
|
||
window.doUnlockTile = function() {
|
||
const tile = pendingUnlockTile;
|
||
if (!tile) { closeAppModal(); return; }
|
||
if (activationPoints !== Infinity && activationPoints > 0) activationPoints--; // 有限时扣减
|
||
tile.locked = false;
|
||
unlockedRing.add(tile.q + ',' + tile.r); // 跨重新生成世界保留解锁状态
|
||
markDirty(tile.q, tile.r);
|
||
pendingUnlockTile = null;
|
||
closeAppModal();
|
||
};
|
||
|
||
// 交换两板块的全部世界内容(位置 q,r 不变,仅内容互换)
|
||
function swapTiles(a, b) {
|
||
if (!a || !b || a.locked || b.locked) return; // 锁定边界环:未激活前不可移动/交换
|
||
const keys = ['terrain', 'hasBuilding', 'buildingType', 'buildingLevel', 'units', 'unitCount', 'explored', 'tier', 'variant', 'height'];
|
||
for (const k of keys) { const t = a[k]; a[k] = b[k]; b[k] = t; }
|
||
// 交换后 height 仍是唯一真值:让 terrain/tier 重新跟随各自 height,
|
||
// 否则会被旧 TERRAIN_TIER 覆盖 → 移完像被重置、地块属性看着没变
|
||
[a, b].forEach(tile => {
|
||
tile.terrain = heightToTerrain(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 });
|
||
}
|
||
|
||
// ===== 世界数据面板(演示 + 预留 /yt_world/api/data) =====
|
||
function computeWorldStats() {
|
||
const byTerrain = {};
|
||
let sumH = 0, n = 0, minH = Infinity, maxH = -Infinity;
|
||
for (const t of tiles) {
|
||
if (t.layer !== 'earth') continue;
|
||
byTerrain[t.terrain] = (byTerrain[t.terrain] || 0) + 1;
|
||
sumH += t.height; n++;
|
||
if (t.height < minH) minH = t.height;
|
||
if (t.height > maxH) maxH = t.height;
|
||
}
|
||
return { total: n, byTerrain, avgH: n ? sumH / n : 0, minH: n ? minH : 0, maxH: n ? maxH : 0 };
|
||
}
|
||
async function renderData() {
|
||
const body = document.getElementById('spDataBody');
|
||
const s = computeWorldStats();
|
||
let remote = null;
|
||
try { remote = await API.worldData(); } catch (e) {}
|
||
const usingRemote = !!(remote && remote.stats);
|
||
const terr = s.byTerrain;
|
||
const TICON = { water:'🔵', desert:'🟡', plain:'🟢', forest:'🌲', mountain:'🟤', snow:'❄' };
|
||
const terrRows = Object.keys(TERRAIN).map(id => {
|
||
const c = terr[id] || 0;
|
||
const pct = s.total ? Math.round(c / s.total * 100) : 0;
|
||
return `<div class="dp-row"><span>${TICON[id] || ''} ${TERRAIN[id].name}</span><span>${c} 块 (${pct}%)</span></div>`;
|
||
}).join('');
|
||
body.innerHTML = `
|
||
<div class="dp-section">
|
||
<div class="dp-h">概览</div>
|
||
<div class="dp-row"><span>地块总数</span><span>${s.total}</span></div>
|
||
<div class="dp-row"><span>平均高度</span><span>${s.avgH.toFixed(1)}</span></div>
|
||
<div class="dp-row"><span>高度区间</span><span>${s.minH} – ${s.maxH}</span></div>
|
||
</div>
|
||
<div class="dp-section">
|
||
<div class="dp-h">地形分布</div>
|
||
${terrRows}
|
||
</div>
|
||
<div class="dp-note">${usingRemote ? '已连接后端世界数据接口(/yt_world/api/data)' : '演示数据:前端实时从地块计算。后端接口 <code>/yt_world/api/data</code> 待接入。'}</div>
|
||
`;
|
||
}
|
||
|
||
// ===== 包裹面板(7列 × 行格 + 物资分类) =====
|
||
const INV_CATEGORIES = {
|
||
brush: [
|
||
{ ico:'🟢', nm:'平原', ct:'∞' }, { ico:'🔵', nm:'水域', ct:'∞' }, { ico:'🟤', nm:'山地', ct:'∞' },
|
||
{ ico:'🌲', nm:'森林', ct:'∞' }, { ico:'🟡', nm:'荒漠', ct:'∞' }, { ico:'❄', nm:'雪原', ct:'∞' },
|
||
{ ico:'⬜', nm:'空地', ct:'∞' },
|
||
],
|
||
template: [
|
||
{ ico:'🏠', nm:'村落', ct:3 }, { ico:'🏰', nm:'城寨', ct:2 }, { ico:'⛪', nm:'神殿', ct:1 },
|
||
{ ico:'🌾', nm:'农田', ct:5 }, { ico:'🪨', nm:'矿场', ct:4 }, { ico:'🛤', nm:'道路', ct:8 },
|
||
{ ico:'🌉', nm:'桥梁', ct:3 }, { ico:'🗼', nm:'高塔', ct:1 }, { ico:'⚱', nm:'雕像', ct:2 },
|
||
{ ico:'🏕', nm:'营地', ct:4 }, { ico:'🔭', nm:'观星台', ct:1 }, { ico:'📯', nm:'烽火', ct:6 },
|
||
{ ico:'💧', nm:'水井', ct:5 }, { ico:'🌳', nm:'灵树', ct:2 }, { ico:'🪨', nm:'石碑', ct:3 },
|
||
],
|
||
event: [
|
||
{ ico:'🌟', nm:'丰收', ct:2 }, { ico:'⚡', nm:'雷暴', ct:1 }, { ico:'❄️', nm:'冰封', ct:1 },
|
||
{ ico:'🌋', nm:'地动', ct:1 }, { ico:'🌈', nm:'虹息', ct:2 }, { ico:'💫', nm:'陨落', ct:1 },
|
||
{ ico:'🌀', nm:'风暴', ct:1 }, { ico:'☀️', nm:'大旱', ct:1 }, { ico:'🌙', nm:'月蚀', ct:1 },
|
||
{ ico:'✨', nm:'灵潮', ct:3 }, { ico:'🔥', nm:'野火', ct:1 }, { ico:'💎', nm:'矿脉', ct:2 },
|
||
{ ico:'🎋', nm:'竹生', ct:4 }, { ico:'🍂', nm:'落叶', ct:3 }, { ico:'🐦', nm:'迁徙', ct:2 },
|
||
],
|
||
};
|
||
|
||
function renderInvGrid(containerId, items) {
|
||
const el = document.getElementById(containerId);
|
||
// pad to multiple of 7 for clean grid
|
||
const padded = [...items];
|
||
while (padded.length % 7 !== 0) padded.push(null);
|
||
el.innerHTML = padded.map(it => {
|
||
if (!it) return '<div class="inv-slot"></div>';
|
||
const has = it.ct !== '∞';
|
||
return `<div class="inv-slot${has ? ' has-item' : ''}" title="${it.nm}${has ? ' ×'+it.ct : ' 无限'}">
|
||
${it.ico}<span class="inv-ct">${has ? it.ct : ''}</span></div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function renderInventory() {
|
||
let remote = null;
|
||
try { remote = await API.inventory(); } catch (e) {}
|
||
if (remote && Array.isArray(remote.items)) {
|
||
// TODO: 后端返回带 category 字段时按分类渲染
|
||
renderInvGrid('invGridBrush', remote.items.filter(i => i.cat === 'brush'));
|
||
renderInvGrid('invGridTemplate', remote.items.filter(i => i.cat === 'template'));
|
||
renderInvGrid('invGridEvent', remote.items.filter(i => i.cat === 'event'));
|
||
} else {
|
||
renderInvGrid('invGridBrush', INV_CATEGORIES.brush);
|
||
renderInvGrid('invGridTemplate', INV_CATEGORIES.template);
|
||
renderInvGrid('invGridEvent', INV_CATEGORIES.event);
|
||
}
|
||
}
|
||
|
||
// ===== 科技树(演示 + 预留 /yt_world/api/tech) =====
|
||
// 节点按 tier 分阶:前置满足且研究点充足才可研习;状态存 localStorage,预留后端 /yt_world/api/tech 同步。
|
||
const TECH_TREE = [
|
||
{ id:'gather', tier:0, ico:'🌿', name:'采集术', cost:1, pre:[], desc:'高效采集资源,建造笔刷消耗 −10%。' },
|
||
{ id:'fire', tier:0, ico:'🔥', name:'火耕', cost:1, pre:[], desc:'点燃荒原,解锁烧荒造地与基础冶炼。' },
|
||
{ id:'farm', tier:1, ico:'🌾', name:'农艺', cost:2, pre:['gather'], desc:'开垦农田产量 +30%,解锁轮作。' },
|
||
{ id:'path', tier:1, ico:'🛤', name:'道途', cost:2, pre:['gather'], desc:'道路连通使单位移动更快。' },
|
||
{ id:'masonry', tier:1, ico:'🧱', name:'砌筑', cost:2, pre:['fire'], desc:'解锁石质建筑(城寨 / 神殿)。' },
|
||
{ id:'irrig', tier:2, ico:'💧', name:'水利', cost:3, pre:['farm'], desc:'灌溉网络,干旱事件减伤。' },
|
||
{ id:'market', tier:2, ico:'🏪', name:'集市', cost:3, pre:['farm','path'], desc:'贸易节点,灵气积累加速。' },
|
||
{ id:'fort', tier:2, ico:'🏰', name:'城防', cost:3, pre:['masonry'], desc:'城墙与守卫,抵御地动 / 风暴。' },
|
||
{ id:'spirit', tier:3, ico:'🔮', name:'灵术', cost:4, pre:['irrig','market'], desc:'操控灵气,解锁灵树速生。' },
|
||
{ id:'astral', tier:3, ico:'🔭', name:'星图', cost:4, pre:['fort'], desc:'观星推演,预测事件与灾异。' },
|
||
{ id:'ascend', tier:4, ico:'🌟', name:'升灵', cost:5, pre:['spirit','astral'], desc:'世界飞升,解锁星空层建造。' },
|
||
];
|
||
const TECH_TIER_NAMES = { 0:'启明', 1:'匠作', 2:'兴业', 3:'通玄', 4:'飞升' };
|
||
const TECH_POINTS_START = 6; // [PLACEHOLDER] 演示起始研究点,待后端 / 平衡校准
|
||
const TECH_SAVE_KEY = 'world_tech_save';
|
||
|
||
let techState = loadTechState();
|
||
function loadTechState() {
|
||
try {
|
||
const raw = localStorage.getItem(TECH_SAVE_KEY);
|
||
if (raw) {
|
||
const o = JSON.parse(raw);
|
||
return { done: new Set(o.done || []), points: (o.points != null ? o.points : TECH_POINTS_START) };
|
||
}
|
||
} catch (e) {}
|
||
return { done: new Set(), points: TECH_POINTS_START };
|
||
}
|
||
function saveTechState() {
|
||
try { localStorage.setItem(TECH_SAVE_KEY, JSON.stringify({ done:[...techState.done], points: techState.points })); } catch (e) {}
|
||
}
|
||
function techPreMet(t) { return t.pre.every(p => techState.done.has(p)); }
|
||
|
||
function renderTech() {
|
||
const body = document.getElementById('spTechBody');
|
||
const byTier = {};
|
||
for (const t of TECH_TREE) (byTier[t.tier] = byTier[t.tier] || []).push(t);
|
||
const tiers = Object.keys(byTier).sort((a,b)=>a-b).map(tier => {
|
||
const nodes = byTier[tier].map(t => {
|
||
const done = techState.done.has(t.id);
|
||
const met = techPreMet(t);
|
||
const afford = techState.points >= t.cost;
|
||
const cls = ['tech-node'];
|
||
if (done) cls.push('done'); else if (!met) cls.push('locked'); else if (afford) cls.push('afford');
|
||
const preHtml = t.pre.length
|
||
? '前置:' + t.pre.map(p => {
|
||
const d = TECH_TREE.find(x => x.id === p);
|
||
const ok = techState.done.has(p);
|
||
return `<b class="${ok ? 'met' : ''}">${d ? d.name : p}</b>`;
|
||
}).join('、')
|
||
: '前置:无(启明科技)';
|
||
let btn;
|
||
if (done) btn = `<button class="tn-btn" disabled>✓ 已研习</button>`;
|
||
else if (!met) btn = `<button class="tn-btn" disabled>未解锁</button>`;
|
||
else btn = `<button class="tn-btn" data-tech="${t.id}">研究(${t.cost} 点)</button>`;
|
||
return `<div class="${cls.join(' ')}">
|
||
<div class="tn-top"><span class="tn-ico">${t.ico}</span><span class="tn-name">${t.name}</span></div>
|
||
<div class="tn-desc">${t.desc}</div>
|
||
<div class="tn-pre">${preHtml}</div>
|
||
<div class="tn-cost">花费 ${t.cost} 研究点</div>
|
||
${btn}
|
||
</div>`;
|
||
}).join('');
|
||
return `<div class="tech-tier"><div class="tech-tier-h">${TECH_TIER_NAMES[tier] || ('阶'+tier)} · 第 ${+tier+1} 阶</div><div class="tech-nodes">${nodes}</div></div>`;
|
||
}).join('');
|
||
|
||
body.innerHTML = `
|
||
<div class="tech-pool">
|
||
<span class="tp-label">研究点</span>
|
||
<span class="tp-val">${techState.points}</span>
|
||
<button class="tp-reset" id="techReset">重置</button>
|
||
</div>
|
||
${tiers}
|
||
<div class="tech-note">演示科技树:前置满足且研究点充足即可研习,状态存本地。后端接口 <code>/yt_world/api/tech</code> 待接入,将支持跨端同步与真正的增益结算。</div>
|
||
`;
|
||
body.querySelectorAll('.tn-btn[data-tech]').forEach(b => {
|
||
b.addEventListener('click', () => {
|
||
const id = b.dataset.tech;
|
||
const t = TECH_TREE.find(x => x.id === id);
|
||
if (!t || techState.done.has(id) || !techPreMet(t) || techState.points < t.cost) return;
|
||
techState.points -= t.cost;
|
||
techState.done.add(id);
|
||
saveTechState();
|
||
renderTech();
|
||
});
|
||
});
|
||
const reset = body.querySelector('#techReset');
|
||
if (reset) reset.onclick = () => {
|
||
if (!confirm('重置科技树?已研习进度与研究点将清空。')) return;
|
||
techState = { done: new Set(), points: TECH_POINTS_START };
|
||
saveTechState();
|
||
renderTech();
|
||
};
|
||
}
|
||
|
||
|
||
// ===== 建造模式 UI 绑定 =====
|
||
function setupBuildUI() {
|
||
const panel = document.getElementById('buildPanel');
|
||
if (!panel) return;
|
||
const bpBrush = document.getElementById('bpBrush');
|
||
const bpHint = document.getElementById('bpHint');
|
||
const bpHeightControl = document.getElementById('bpHeightControl');
|
||
const bpHeightSlider = document.getElementById('bpHeightSlider');
|
||
const bpHeightVal = document.getElementById('bpHeightVal');
|
||
const bpMgmt = document.getElementById('bpMgmt');
|
||
|
||
const renderBrush = () => {
|
||
bpBrush.innerHTML = '';
|
||
bpBrush.style.display = (buildTool === 'terrain') ? 'flex' : 'none';
|
||
bpHeightControl.style.display = 'none';
|
||
bpMgmt.style.display = (buildTool === 'manage') ? 'flex' : 'none';
|
||
if (buildTool === 'terrain') {
|
||
const BRUSH_ORDER = ['water', 'desert', 'plain', 'forest', 'mountain', 'snow'];
|
||
BRUSH_ORDER.forEach(id => {
|
||
const t = TERRAIN[id];
|
||
const b = document.createElement('button');
|
||
b.className = 'bp-chip' + (buildBrush === id ? ' active' : '');
|
||
b.style.background = t.color; b.textContent = t.name; b.title = t.name;
|
||
b.onclick = () => { buildBrush = id; buildVariant = 'random'; renderBrush(); };
|
||
bpBrush.appendChild(b);
|
||
});
|
||
bpHint.textContent = '先选地形,点地块应用(素材按坐标自动选)。';
|
||
} else if (buildTool === 'height') {
|
||
bpHeightControl.style.display = 'block';
|
||
bpHeightSlider.value = String(heightTarget);
|
||
bpHeightVal.textContent = heightLabel(heightTarget);
|
||
bpHint.textContent = '滑块设定高度:左键升高 / 右键降低 / 滚轮微调。每格独立,互不影响。';
|
||
} else if (buildTool === 'manage') {
|
||
bpHint.textContent = '调整地表比例,调好点应用即可重新生成世界。';
|
||
} else {
|
||
bpHint.textContent = '移动工具:点第一块再点第二块交换,或按住一块拖到另一块松手交换。';
|
||
}
|
||
};
|
||
|
||
// 高度滑块:拖动即把当前悬停格设为滑块绝对高度(每格独立,不联动周边)
|
||
const onHeightSlide = () => {
|
||
heightTarget = parseInt(bpHeightSlider.value, 10);
|
||
bpHeightVal.textContent = heightLabel(heightTarget);
|
||
if (buildMode && buildTool === 'height' && currentLayer === 'earth' && hoveredTile) {
|
||
setHeight(hoveredTile, heightTarget, true);
|
||
markDirty(hoveredTile.q, hoveredTile.r);
|
||
}
|
||
};
|
||
bpHeightSlider.addEventListener('input', onHeightSlide);
|
||
|
||
// 平铺按钮
|
||
const bpFlatten = document.getElementById('bpFlatten');
|
||
if (bpFlatten) bpFlatten.onclick = () => {
|
||
if (!confirm('平铺:把所有地块高度设为最低(1)?当前起伏将丢失。')) return;
|
||
for (const t of tiles) { setHeight(t, MIN_HEIGHT); markDirty(t.q, t.r); }
|
||
heightTarget = MIN_HEIGHT;
|
||
bpHeightSlider.value = String(MIN_HEIGHT);
|
||
bpHeightVal.textContent = heightLabel(MIN_HEIGHT);
|
||
};
|
||
|
||
// ===== 底部 7 按钮 → 统一左侧浮层面板(无顶部标签栏,切换时关闭上一个) =====
|
||
const BTN_TO_TAB = {
|
||
btnData: 'data',
|
||
btnInventory: 'inventory',
|
||
btnTerrain: 'terrain',
|
||
btnMove: 'move',
|
||
btnHeight: 'height',
|
||
// btnManage: 'manage', // 已移除管理功能
|
||
btnTech: 'tech',
|
||
};
|
||
const TAB_TO_BODY = {
|
||
data: 'spDataBody',
|
||
inventory: 'spInvBody',
|
||
terrain: 'spBuildBody',
|
||
move: 'spBuildBody',
|
||
height: 'spBuildBody',
|
||
manage: 'spBuildBody',
|
||
tech: 'spTechBody',
|
||
};
|
||
const BUILD_TABS = new Set(['terrain', 'move', 'height', 'manage']);
|
||
|
||
const sidePanel = document.getElementById('sidePanel');
|
||
const spBodies = document.querySelectorAll('.sp-body');
|
||
let currentSpTab = null;
|
||
let panelContentTimer = null; // 侧面板内容延迟渲染定时器(动画结束后填充,避免卡顿)
|
||
|
||
function openSidePanel(tab) {
|
||
// 离开上一个建造类 tab(地形/移动/高度/管理)→ 非建造类(数据/包裹/科技)时,强制退出建造模式
|
||
if (currentSpTab && BUILD_TABS.has(currentSpTab) && !BUILD_TABS.has(tab)) {
|
||
buildMode = false;
|
||
const bp = document.getElementById('buildPanel');
|
||
if (bp) bp.classList.remove('active');
|
||
swapFirst = null; draggingSwap = false; dragSwapFrom = null;
|
||
}
|
||
sidePanel.classList.add('open');
|
||
for (const [id, t] of Object.entries(BTN_TO_TAB)) {
|
||
const b = document.getElementById(id);
|
||
if (b) b.classList.toggle('active', t === tab);
|
||
}
|
||
const bodyId = TAB_TO_BODY[tab];
|
||
spBodies.forEach(b => b.classList.toggle('active', b.id === bodyId));
|
||
currentSpTab = tab;
|
||
// 轻量状态同步立即做(classList / buildMode),不阻塞动画
|
||
if (BUILD_TABS.has(tab)) {
|
||
buildMode = true;
|
||
buildTool = tab;
|
||
document.getElementById('buildPanel').classList.add('active');
|
||
if (currentLayer !== 'earth') switchWorld('earth');
|
||
renderBrush();
|
||
}
|
||
// 重 DOM 渲染(数据/包裹/科技树)延迟到滑入动画结束后执行,避免布局重计算卡住动画首帧
|
||
if (panelContentTimer) clearTimeout(panelContentTimer);
|
||
panelContentTimer = setTimeout(() => {
|
||
if (currentSpTab !== tab) return; // 期间已切换/关闭则不渲染
|
||
if (tab === 'data') renderData();
|
||
if (tab === 'inventory') renderInventory();
|
||
if (tab === 'tech') renderTech();
|
||
}, 200);
|
||
}
|
||
|
||
function closeSidePanel() {
|
||
sidePanel.classList.remove('open');
|
||
if (panelContentTimer) { clearTimeout(panelContentTimer); panelContentTimer = null; }
|
||
for (const id of Object.keys(BTN_TO_TAB)) {
|
||
const b = document.getElementById(id);
|
||
if (b) b.classList.remove('active');
|
||
}
|
||
if (currentSpTab && BUILD_TABS.has(currentSpTab)) {
|
||
buildMode = false;
|
||
document.getElementById('buildPanel').classList.remove('active');
|
||
swapFirst = null; draggingSwap = false; dragSwapFrom = null;
|
||
}
|
||
currentSpTab = null;
|
||
}
|
||
|
||
for (const [id, tab] of Object.entries(BTN_TO_TAB)) {
|
||
const b = document.getElementById(id);
|
||
if (b) b.addEventListener('click', () => {
|
||
if (currentSpTab === tab) { closeSidePanel(); return; }
|
||
openSidePanel(tab);
|
||
});
|
||
}
|
||
|
||
// 点击非地块区(空位/空白)时关闭左侧面板
|
||
canvas.addEventListener('mousedown', e => {
|
||
if (!currentSpTab) return;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const hex = pixelToHex(e.clientX - rect.left, e.clientY - rect.top);
|
||
const tile = getTile(hex.q, hex.r);
|
||
if (!tile) closeSidePanel(); // 点空位才关,点地块上不关
|
||
}, { capture: true });
|
||
|
||
renderBrush();
|
||
}
|
||
|
||
// 保存 / 恢复沙盘(地形与板块交换结果持久化到 localStorage)
|
||
// 后端记忆:保存按钮直接触发增量 flush(flushSave 内部自带 ✅/⚠ 反馈)
|
||
function saveWorld(btn) {
|
||
flushSave(btn);
|
||
}
|
||
function loadSavedWorld() {
|
||
try {
|
||
const raw = localStorage.getItem('world_sandbox_tiles');
|
||
if (!raw) return;
|
||
const saved = JSON.parse(raw);
|
||
if (!Array.isArray(saved) || !saved.length) return;
|
||
// 兼容旧存档:把已移除的地形(如 wetland)回退为平原,避免渲染崩溃
|
||
const validKeys = Object.keys(TERRAIN);
|
||
saved.forEach(t => {
|
||
if (!validKeys.includes(t.terrain)) t.terrain = 'plain';
|
||
// 高度迁移:旧存档可能没有 height 字段或值为 0(旧最低档),按 terrain 重算
|
||
if (t.height == null || t.height < MIN_HEIGHT) {
|
||
t.height = terrainToHeight(t.terrain);
|
||
}
|
||
t.tier = heightToTier(t.height);
|
||
});
|
||
// 缩圈后丢弃超出新 MAP_RADIUS 的地块,避免旧存档把世界撑回原尺寸
|
||
const kept = saved.filter(t => hexRing(t.q, t.r) <= MAP_RADIUS);
|
||
tiles = kept;
|
||
} 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);
|
||
worldBaseValid = false; // 地块改动 → 静态帧基础缓存失效,下帧重绘
|
||
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;
|
||
// 缩圈后:剔除已解锁集合中超出新边界环(>MAP_RADIUS+1)的残余 key
|
||
for (const key of unlockedRing) {
|
||
const p = key.split(','); const q = +p[0], r = +p[1];
|
||
if (hexRing(q, r) > MAP_RADIUS + 1) unlockedRing.delete(key);
|
||
}
|
||
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) {
|
||
if (hexRing(ov.q, ov.r) > MAP_RADIUS) continue; // 缩圈后丢弃界外地块
|
||
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;
|
||
if (ov.micro_name != null) microNames[ov.q + ',' + ov.r] = ov.micro_name; // 恢复微观世界命名记忆
|
||
}
|
||
// 微观建造布置:按 'q,r' 恢复(覆盖所有层,进入地块时即显示)
|
||
for (const ov of ws.tiles) {
|
||
if (hexRing(ov.q, ov.r) > MAP_RADIUS) continue; // 界外不再恢复建筑
|
||
if (ov.build_items && Array.isArray(ov.build_items) && ov.build_items.length) {
|
||
microBuildings[ov.q + ',' + ov.r] = ov.build_items.slice();
|
||
}
|
||
}
|
||
if (microState && microState.tile) { microAutoRoadCells(); microSanitizeBuildings(); } // 加载后重算道路并清理越界建筑
|
||
}
|
||
// 背景音乐本地偏好(localStorage)
|
||
function loadWorldBGMSetting() {
|
||
try { return localStorage.getItem('world_bgm_enabled') !== 'false'; }
|
||
catch (e) { return true; }
|
||
}
|
||
function saveWorldBGMSetting(enabled) {
|
||
try { localStorage.setItem('world_bgm_enabled', enabled ? 'true' : 'false'); } catch (e) {}
|
||
}
|
||
function applyBGMSetting(showToastOnError = false) {
|
||
const audio = document.getElementById('bgmAudio');
|
||
if (!audio) return;
|
||
const enabled = loadWorldBGMSetting();
|
||
if (enabled) {
|
||
if (audio.paused) {
|
||
const p = audio.play();
|
||
if (p && typeof p.catch === 'function') {
|
||
p.catch(err => {
|
||
console.warn('BGM 播放失败:', err.name || err);
|
||
// 自动播放被浏览器阻止时提示用户
|
||
if (showToastOnError) toast('音频尚未就绪,请稍后再试或点击屏幕任意位置');
|
||
});
|
||
}
|
||
}
|
||
} else {
|
||
audio.pause();
|
||
}
|
||
}
|
||
let bgmInteracted = false;
|
||
function tryPlayBGMOnce() {
|
||
if (bgmInteracted) return;
|
||
bgmInteracted = true;
|
||
const audio = document.getElementById('bgmAudio');
|
||
if (!audio) return;
|
||
// 必须在用户手势(首次 click/keydown/touchstart)内同步触发播放,
|
||
// 不可推迟到异步事件(canplaythrough 等已脱离手势上下文,会被浏览器拦截)。
|
||
if (loadWorldBGMSetting()) {
|
||
const p = audio.play();
|
||
if (p && typeof p.catch === 'function') {
|
||
p.catch(() => {
|
||
// 首次手势已解锁播放权限;若因资源尚未就绪被拒,资源就绪后自动开始
|
||
if (audio.readyState < 3) {
|
||
const onReady = () => {
|
||
audio.removeEventListener('canplay', onReady);
|
||
if (loadWorldBGMSetting() && audio.paused) audio.play().catch(() => {});
|
||
};
|
||
audio.addEventListener('canplay', onReady, { once: true });
|
||
audio.load();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
function initBGM() {
|
||
const audio = document.getElementById('bgmAudio');
|
||
if (!audio) return;
|
||
audio.volume = 0.35;
|
||
// 预加载音频
|
||
try { audio.load(); } catch (e) {}
|
||
// 进入页面立即尝试自动播放:浏览器若已对该域名授予自动播放许可(用户此前有交互),
|
||
// 这里会直接出声;无需等任何点击。
|
||
if (loadWorldBGMSetting()) {
|
||
applyBGMSetting();
|
||
}
|
||
// 兜底:若上述自动播放被浏览器拦截(如 Safari 严格策略),则在用户首次交互时补播。
|
||
['click', 'keydown', 'touchstart'].forEach(evt => {
|
||
document.addEventListener(evt, tryPlayBGMOnce, { once: true, passive: true });
|
||
});
|
||
}
|
||
|
||
// 相机/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 = Math.max(MIN_ZOOM, Math.min(MAX_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退出微观世界
|
||
document.addEventListener('keydown', e => { if (e.key === 'Escape') exitMicroWorld(); });
|
||
document.getElementById('backBtn').addEventListener('click', exitMicroWorld);
|
||
document.getElementById('unitModal').addEventListener('click', e => { if (e.target === document.getElementById('unitModal')) closeUnitModal(); });
|
||
|
||
// WASD 方向键:选中角色后控制移动(8 方向,相反键抵消)
|
||
const wasdKeys = { w: false, a: false, s: false, d: false };
|
||
window.addEventListener('keydown', e => {
|
||
const k = e.key.toLowerCase();
|
||
if (k === 'w' || k === 'a' || k === 's' || k === 'd') {
|
||
const tag = (e.target && e.target.tagName || '').toLowerCase();
|
||
if (tag === 'input' || tag === 'textarea') return; // 输入框聚焦时不响应
|
||
wasdKeys[k] = true;
|
||
e.preventDefault(); // 阻止页面滚动
|
||
}
|
||
});
|
||
window.addEventListener('keyup', e => {
|
||
const k = e.key.toLowerCase();
|
||
if (k in wasdKeys) wasdKeys[k] = false;
|
||
});
|
||
window.wasdKeys = wasdKeys;
|
||
|
||
function exitMicroWorld() {
|
||
document.getElementById('microView').classList.remove('active');
|
||
if (microAnimId) { cancelAnimationFrame(microAnimId); microAnimId = null; }
|
||
if (typeof saveMicroCharsNow === 'function') saveMicroCharsNow(); // 退出前落库角色(删除/移动)
|
||
microState = null;
|
||
microGridCache = null;
|
||
microBuildRevealAt = 0;
|
||
microZoom = 1; microPanX = 0; microPanY = 0;
|
||
microPanning = false; microPanStart = null;
|
||
microViewOpen = false; // 恢复主世界重绘
|
||
}
|
||
|
||
// 当前地块主题色 → 同步给上下边框(--mv-accent),让 UI 随场景/地块风格变化
|
||
function applyMicroAccent(terrainId) {
|
||
const t = TERRAIN[terrainId];
|
||
const a = (t && t.atmo && t.atmo.glow) || '#4aafff';
|
||
document.getElementById('microView').style.setProperty('--mv-accent', a);
|
||
if (microState) microState.accent = a;
|
||
}
|
||
|
||
// 平顶六边形路径
|
||
// ============ 矩形网格(仅浮岛内部,背景和圆角不画)============
|
||
// 网格单元格大小:50×50px(画布空间,随 zoom 自动缩放)
|
||
function drawMicroGrid(c, w, h, accent) {
|
||
if (!microState) return;
|
||
const g = microBuildGeom();
|
||
const N = g.N;
|
||
// 覆盖面积 = 矩形外边框(向内缩 1 格);内部为等距斜线,裁切到矩形内铺满
|
||
const rx = g.X0, ry = g.Y0, rw = g.RW, rh = g.RH;
|
||
c.save();
|
||
c.beginPath();
|
||
c.rect(rx, ry, rw, rh);
|
||
c.clip();
|
||
|
||
// 矩形在 (i,j) 连续坐标系下的覆盖区间,保证斜线铺满整个矩形
|
||
const invX = (x) => (x - g.ox) / g.hw;
|
||
const invY = (y) => (y - g.oy) / g.hh;
|
||
let gmin = Infinity, gmax = -Infinity;
|
||
for (const c2 of [[rx, ry], [rx + rw, ry], [rx, ry + rh], [rx + rw, ry + rh]]) {
|
||
const gi = (invX(c2[0]) + invY(c2[1])) / 2;
|
||
const gj = (invY(c2[1]) - invX(c2[0])) / 2;
|
||
if (gi < gmin) gmin = gi; if (gi > gmax) gmax = gi;
|
||
if (gj < gmin) gmin = gj; if (gj > gmax) gmax = gj;
|
||
}
|
||
const lo = Math.floor(gmin) - 1, hi = Math.ceil(gmax) + 1;
|
||
|
||
// 等距斜线(双描边:深色打底 + 浅色高光),铺满矩形
|
||
c.beginPath();
|
||
for (let j = lo; j <= hi; j++) {
|
||
let p = microCellToScreen(lo, j);
|
||
c.moveTo(p.x, p.y);
|
||
for (let i = lo + 1; i <= hi; i++) { p = microCellToScreen(i, j); c.lineTo(p.x, p.y); }
|
||
}
|
||
for (let i = lo; i <= hi; i++) {
|
||
let p = microCellToScreen(i, lo);
|
||
c.moveTo(p.x, p.y);
|
||
for (let j = lo + 1; j <= hi; j++) { p = microCellToScreen(i, j); c.lineTo(p.x, p.y); }
|
||
}
|
||
c.lineCap = 'round'; c.lineJoin = 'round'; c.globalAlpha = 1;
|
||
c.lineWidth = 2.4; c.strokeStyle = 'rgba(90,120,50,0.28)'; c.stroke();
|
||
// 线条改浅:与草地同色调、低不透明、稍细,避免深色网格压过地表
|
||
c.restore();
|
||
|
||
// 矩形外边框(不裁切,完整描出覆盖面积)
|
||
c.save();
|
||
c.beginPath();
|
||
c.rect(rx, ry, rw, rh);
|
||
c.lineWidth = 3.8;
|
||
c.strokeStyle = accent || 'rgba(74,175,255,0.95)';
|
||
c.stroke();
|
||
c.restore();
|
||
|
||
// 可建造区边界(内缩安全边距):淡黄虚线,明确提示“线外不可建造”
|
||
const br = microBuildableRect();
|
||
c.save();
|
||
c.setLineDash([10, 8]);
|
||
c.lineWidth = 2;
|
||
c.strokeStyle = 'rgba(255,213,79,0.55)';
|
||
c.strokeRect(br.X0, br.Y0, br.RW, br.RH);
|
||
c.restore();
|
||
}
|
||
|
||
// ===== 微观世界:建造模式 =====
|
||
function microBuildGeom() {
|
||
const mW = microCanvas.width, mH = microCanvas.height;
|
||
// 菱形网格覆盖矩形:矩形向内缩 1 格,iso 菱形自适应铺满该矩形
|
||
const cell = 60, padX = cell, padY = cell * 0.5;
|
||
const Rx = mW / 2, Ry = mH / 2;
|
||
const cx = mW / 2, cy = mH / 2;
|
||
const inset = cell * 1; // 矩形向内缩 1 个单位(格)
|
||
const X0 = inset, Y0 = inset, RW = mW - inset * 2, RH = mH - inset * 2;
|
||
// 等距(iso)菱形网格:菱形宽=cell、高=cell/2,居中铺满内缩后的矩形
|
||
const hw = cell / 2, hh = cell / 4;
|
||
// 让可建菱形 N 覆盖(略大于)可见矩形:矩形在 (i,j) 连续坐标下的跨度 = (RW+2RH)/cell,
|
||
// 取 ceil 再 +2 余量,保证整块矩形都在 [0,N] 内可铺设(矩形四角不再留白);
|
||
// 实际落点仍由 microFootprintInsideRect 约束在可见框内,菱形外圈不可见区域自然被裁掉。
|
||
const N = Math.max(4, Math.ceil((RW + 2 * RH) / cell) + 2);
|
||
const ox = X0 + RW / 2; // 菱形中心 X
|
||
const oy = Y0 + RH / 2 - N * cell / 4; // 菱形垂直居中(中心恒在矩形中心)
|
||
return { mW, mH, Rx, Ry, cell, padX, padY, cx, cy, X0, Y0, RW, RH, hw, hh, N, ox, oy };
|
||
}
|
||
// 网格连续坐标(i,j) → 屏幕(世界)坐标;cell(i,j) 中心 = microCellToScreen(i+0.5, j+0.5)
|
||
function microCellToScreen(i, j) {
|
||
const g = microBuildGeom();
|
||
return { x: g.ox + (i - j) * g.hw, y: g.oy + (i + j) * g.hh };
|
||
}
|
||
function microBuildList() {
|
||
if (!microState) return [];
|
||
const key = microState.tile.q + ',' + microState.tile.r;
|
||
if (!microBuildings[key]) microBuildings[key] = [];
|
||
return microBuildings[key];
|
||
}
|
||
function microCellAt(p) {
|
||
const g = microBuildGeom();
|
||
const w = screenToWorld(p.x, p.y);
|
||
const dx = w.x - g.ox, dy = w.y - g.oy;
|
||
const a = dx / g.hw; // = gi - gj
|
||
const b = dy / g.hh; // = gi + gj
|
||
const gi = (a + b) / 2, gj = (b - a) / 2;
|
||
const i = Math.floor(gi), j = Math.floor(gj);
|
||
const { cols, rows } = microGridDims();
|
||
if (i < 0 || j < 0 || i >= cols || j >= rows) return null;
|
||
return { i, j };
|
||
}
|
||
// 计算某已放置项的有效占位(考虑旋转交换长宽),返回 {ew,eh}
|
||
function microItemCells(b) {
|
||
const it = microBuildItemById(b.type);
|
||
let ew = b.w || it.w || 1, eh = b.h || it.h || 1;
|
||
if (((b.rot || 0) % 180) !== 0) { const t = ew; ew = eh; eh = t; }
|
||
return { ew, eh };
|
||
}
|
||
// 网格行列数(等距菱形:N×N)
|
||
function microGridDims() {
|
||
const g = microBuildGeom();
|
||
return { cols: g.N, rows: g.N };
|
||
}
|
||
// 可建造区域:以浮岛实际圆角矩形边界为基准,四周留安全边距(防止 3/4 立绘溢出岛外)
|
||
function microBuildableRect() {
|
||
const g = microBuildGeom();
|
||
const Rx = g.mW * 0.50, Ry = g.mH * 0.43;
|
||
const padX = g.cell * 2, padY = g.cell;
|
||
const X0 = g.mW / 2 - Rx + padX;
|
||
const Y0 = g.mH / 2 - Ry + padY;
|
||
const RW = Math.max(g.cell, Rx * 2 - padX * 2);
|
||
const RH = Math.max(g.cell, Ry * 2 - padY * 2);
|
||
return { X0, Y0, RW, RH };
|
||
}
|
||
// 可建造区域在 (i,j) 连续坐标下的整数边界,用于 ghost/编辑落点 clamp
|
||
function microBuildableCellBounds() {
|
||
const r = microBuildableRect();
|
||
const g = microBuildGeom();
|
||
const invX = (x) => (x - g.ox) / g.hw;
|
||
const invY = (y) => (y - g.oy) / g.hh;
|
||
let giMin = Infinity, gjMin = Infinity, giMax = -Infinity, gjMax = -Infinity;
|
||
for (const c of [[r.X0, r.Y0], [r.X0 + r.RW, r.Y0], [r.X0, r.Y0 + r.RH], [r.X0 + r.RW, r.Y0 + r.RH]]) {
|
||
const gi = (invX(c[0]) + invY(c[1])) / 2;
|
||
const gj = (invY(c[1]) - invX(c[0])) / 2;
|
||
if (gi < giMin) giMin = gi; if (gi > giMax) giMax = gi;
|
||
if (gj < gjMin) gjMin = gj; if (gj > gjMax) gjMax = gj;
|
||
}
|
||
return { iMin: Math.ceil(giMin), jMin: Math.ceil(gjMin), iMax: Math.floor(giMax), jMax: Math.floor(gjMax) };
|
||
}
|
||
// 矩形重叠检测:footprint(i,j,ew,eh) 是否与列表中其他项冲突
|
||
function microFootprintHit(list, i, j, ew, eh, ignore) {
|
||
for (const o of list) {
|
||
if (o === ignore) continue;
|
||
const c = microItemCells(o);
|
||
if (i < o.i + c.ew && i + ew > o.i && j < o.j + c.eh && j + eh > o.j) return true;
|
||
}
|
||
return false;
|
||
}
|
||
// 可见矩形(内缩后)在 (i,j) 连续坐标下的覆盖范围,用于约束建筑落点不出框
|
||
function microRectCellBounds() {
|
||
const g = microBuildGeom();
|
||
const r = microBuildableRect();
|
||
const rx = r.X0, ry = r.Y0, rw = r.RW, rh = r.RH;
|
||
const invX = (x) => (x - g.ox) / g.hw;
|
||
const invY = (y) => (y - g.oy) / g.hh;
|
||
let giMin = Infinity, gjMin = Infinity, giMax = -Infinity, gjMax = -Infinity;
|
||
for (const c of [[rx, ry], [rx + rw, ry], [rx, ry + rh], [rx + rw, ry + rh]]) {
|
||
const gi = (invX(c[0]) + invY(c[1])) / 2; // 连续 i
|
||
const gj = (invY(c[1]) - invX(c[0])) / 2; // 连续 j
|
||
if (gi < giMin) giMin = gi; if (gi > giMax) giMax = gi;
|
||
if (gj < gjMin) gjMin = gj; if (gj > gjMax) gjMax = gj;
|
||
}
|
||
return { giMin, gjMin, giMax, gjMax };
|
||
}
|
||
// 浮岛圆角矩形(与 drawMicroFrame 一致):用屏幕坐标判定某点是否落在岛内。
|
||
// 不再依赖 microBuildableRect 的近似矩形,避免菱形格子尖角从岛圆角处"挤出去"。
|
||
function microIslandRect() {
|
||
const mW = microCanvas.width, mH = microCanvas.height;
|
||
const Rx = mW * 0.50, Ry = mH * 0.43;
|
||
const rRect = Math.min(Rx, Ry) * 0.14;
|
||
return { X0: mW / 2 - Rx, Y0: mH / 2 - Ry, RW: Rx * 2, RH: Ry * 2, rRect };
|
||
}
|
||
function microPointInIsland(sx, sy) {
|
||
const ir = microIslandRect();
|
||
if (sx < ir.X0 || sx > ir.X0 + ir.RW || sy < ir.Y0 || sy > ir.Y0 + ir.RH) return false;
|
||
const r = ir.rRect;
|
||
if (sx >= ir.X0 + r && sx <= ir.X0 + ir.RW - r && sy >= ir.Y0 + r && sy <= ir.Y0 + ir.RH - r) return true;
|
||
const cx = sx < ir.X0 + ir.RW / 2 ? ir.X0 + r : ir.X0 + ir.RW - r;
|
||
const cy = sy < ir.Y0 + ir.RH / 2 ? ir.Y0 + r : ir.Y0 + ir.RH - r;
|
||
return Math.hypot(sx - cx, sy - cy) <= r;
|
||
}
|
||
// 建筑占位(i,j,ew,eh)是否完全落在岛圆角矩形内(防止边缘建筑超出岛边界 / 从圆角缝钻出去)
|
||
function microFootprintInsideRect(i, j, ew, eh) {
|
||
for (const [u, v] of [[i, j], [i + ew, j], [i, j + eh], [i + ew, j + eh]]) {
|
||
const sp = microCellToScreen(u, v);
|
||
if (!microPointInIsland(sp.x, sp.y)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
// 计算 ghost 当前合法性(边界 + 矩形框 + 与其它建筑重叠,忽略 ignoreRef 自身)
|
||
function microGhostValidAt(i, j, ew, eh, ignoreRef) {
|
||
const { cols, rows } = microGridDims();
|
||
if (i < 0 || j < 0 || i + ew > cols || j + eh > rows) return false;
|
||
if (!microFootprintInsideRect(i, j, ew, eh)) return false;
|
||
return !microFootprintHit(microBuildList(), i, j, ew, eh, ignoreRef);
|
||
}
|
||
// 清理当前地块中越界/非法的已放置建筑(加载旧存档或修正后兜底)
|
||
function microSanitizeBuildings() {
|
||
if (!microState || !microState.tile) return;
|
||
const key = microState.tile.q + ',' + microState.tile.r;
|
||
const list = microBuildings[key];
|
||
if (!list || !list.length) return;
|
||
const before = list.length;
|
||
const valid = list.filter(b => {
|
||
const c = microItemCells(b);
|
||
return microFootprintInsideRect(b.i, b.j, c.ew, c.eh);
|
||
});
|
||
if (valid.length !== before) {
|
||
microBuildings[key] = valid;
|
||
if (microEditRef && !valid.includes(microEditRef)) microEditRef = null;
|
||
scheduleBuildSave();
|
||
microToast('已清理 ' + (before - valid.length) + ' 个越界建筑');
|
||
}
|
||
}
|
||
// 取当前 ghost 描述(新放置 / 编辑已有),含锚点格与合法性
|
||
function microTerrainOk(it) {
|
||
const terrain = microState && microState.tile && microState.tile.terrain;
|
||
return !it.terrains || !terrain || it.terrains.includes(terrain);
|
||
}
|
||
function microComputeGhost() {
|
||
if (!microState) return null;
|
||
const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
|
||
if (microEditRef) {
|
||
const c = microItemCells(microEditRef);
|
||
const it = microBuildItemById(microEditRef.type);
|
||
const ci = Math.max(iMin, Math.min(microEditRef.i, iMax - c.ew));
|
||
const cj = Math.max(jMin, Math.min(microEditRef.j, jMax - c.eh));
|
||
const ok = microGhostValidAt(ci, cj, c.ew, c.eh, microEditRef) && microTerrainOk(it);
|
||
return { type: microEditRef.type, rot: microEditRef.rot || 0, w: c.ew, h: c.eh,
|
||
i: ci, j: cj,
|
||
valid: ok, editing: true };
|
||
}
|
||
if (microBuildSel) {
|
||
const it = microBuildItemById(microBuildSel);
|
||
let ew = it.w || 1, eh = it.h || 1;
|
||
if ((microBuildRot % 180) !== 0) { const t = ew; ew = eh; eh = t; }
|
||
const i = Math.max(iMin, Math.min(microGhost.i, iMax - ew));
|
||
const j = Math.max(jMin, Math.min(microGhost.j, jMax - eh));
|
||
const ok = microGhostValidAt(i, j, ew, eh, null) && microTerrainOk(it);
|
||
return { type: microBuildSel, rot: microBuildRot, w: ew, h: eh, i, j,
|
||
valid: ok, editing: false };
|
||
}
|
||
return null;
|
||
}
|
||
// 命中检测:cell 是否落在某已放置建筑占位内 → 返回该建筑对象
|
||
function microBuildingAt(cell) {
|
||
if (!cell) return null;
|
||
const list = microBuildList();
|
||
return list.find(b => { const c = microItemCells(b); return cell.i >= b.i && cell.i < b.i + c.ew && cell.j >= b.j && cell.j < b.j + c.eh; }) || null;
|
||
}
|
||
function microHitBuilding(b, cell) {
|
||
if (!b || !cell) return false;
|
||
const c = microItemCells(b);
|
||
return cell.i >= b.i && cell.i < b.i + c.ew && cell.j >= b.j && cell.j < b.j + c.eh;
|
||
}
|
||
// 左键点击落地:在 ghost 锚点处放置新副本(仅当合法)
|
||
async function microCommitPlace() {
|
||
if (!microBuildSel) return;
|
||
const g = microComputeGhost();
|
||
if (!g || !g.valid) { microToast('这里不能建造(红色区域)'); return; }
|
||
const it = microBuildItemById(microBuildSel);
|
||
if (it && it.isChar) {
|
||
// 种族角色:作为居民入驻(触发 char_recruited),同时保留建筑记录用于 PNG 渲染
|
||
const race = it.race || it.id;
|
||
const cid = race + '_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 5);
|
||
const raceEmoji = { human:'🧑', demon:'👹', dwarf:'🧔', elf:'🧝', dragon:'🐲', undead:'💀', orc:'👺', angel:'👼', werewolf:'🐺', golem:'🗿' };
|
||
microState.units.push({
|
||
id: cid, name: it.name, role: race, species: race, icon: raceEmoji[race] || '🧑',
|
||
level: 1, exp: 0, hp: 80, hp_max: 120,
|
||
energy: 80, mood: '😊', job: 'none',
|
||
equip: { weapon: null, armor: null, accessory: null },
|
||
status: 'resting', _buildingRender: true
|
||
});
|
||
microBuildList().push({ i: g.i, j: g.j, type: microBuildSel, rot: microBuildRot, w: it.w || 1, h: it.h || 1, level: 1, exp: 0, hp: (it.statHp || 100), _charCid: cid });
|
||
microSaveCreatures();
|
||
scheduleBuildSave();
|
||
return true;
|
||
}
|
||
// 非生物建筑:落库前先扣金币(后端校验余额,失败则不放置)
|
||
const cost = (it && (it.costGold || 0)) || 0;
|
||
if (cost > 0) {
|
||
const res = await API.chargeBuild(microBuildSel, 1);
|
||
if (!res || !res.ok) {
|
||
if (res && res.error === 'no_gold') {
|
||
microToast('金币不足(需 🪙' + res.need + ',现有 🪙' + res.have + ')');
|
||
} else if (res && res.error === 'auth') {
|
||
microToast('建造需登录后扣费');
|
||
} else {
|
||
microToast('扣费失败,无法建造');
|
||
}
|
||
return;
|
||
}
|
||
if (microChar) microChar.gold = res.gold;
|
||
microRenderCharHud();
|
||
}
|
||
microBuildList().push({ i: g.i, j: g.j, type: microBuildSel, rot: microBuildRot, w: it.w || 1, h: it.h || 1, level: 1, exp: 0, hp: (it.statHp || 100) });
|
||
emitTaskEvent('build_placed', { code: microBuildSel });
|
||
scheduleBuildSave();
|
||
return true;
|
||
}
|
||
// 右键:命中建筑→删除;否则取消当前选择/编辑
|
||
function microBuildRightClick(p) {
|
||
// 修复:建造中已选中待放置素材(尚未落地)时,右键只取消选择,绝不误删已放置建筑
|
||
if (microBuildSel) {
|
||
microBuildSel = null; clearBuildSelUI(); microRenderBuildPanel(); microToast('已取消选择');
|
||
return;
|
||
}
|
||
const hit = microBuildingAt(microCellAt(p));
|
||
if (hit) {
|
||
const list = microBuildList();
|
||
const idx = list.indexOf(hit);
|
||
if (idx >= 0) {
|
||
// 删除种族角色时同步移除对应居民记录
|
||
if (hit._charCid && microState.units) {
|
||
const ui = microState.units.findIndex(u => u.id === hit._charCid);
|
||
if (ui >= 0) microState.units.splice(ui, 1);
|
||
microSaveCreatures();
|
||
}
|
||
list.splice(idx, 1); if (microEditRef === hit) microEditRef = null; scheduleBuildSave();
|
||
}
|
||
return;
|
||
}
|
||
if (microEditRef) { microEditRef = null; microToast('退出编辑'); }
|
||
}
|
||
function microPaintRoadCell(i, j) {
|
||
const list = microBuildList();
|
||
const key = i + ',' + j;
|
||
if (microRoadLastCell === key) return;
|
||
microRoadLastCell = key;
|
||
const { cols, rows } = microGridDims();
|
||
if (i < 0 || j < 0 || i >= cols || j >= rows) return;
|
||
if (!microFootprintInsideRect(i, j, 1, 1)) return;
|
||
const it = microBuildItemById('road_auto');
|
||
if (!microTerrainOk(it)) return;
|
||
if (list.some(b => microIsRoadAuto(b) && b.i === i && b.j === j)) return; // 已有自动道路
|
||
if (list.some(b => {
|
||
if (microIsRoadAuto(b)) return false;
|
||
const c = microItemCells(b);
|
||
return i >= b.i && i < b.i + c.ew && j >= b.j && j < b.j + c.eh;
|
||
})) return; // 被非道路建筑占用
|
||
list.push({ i, j, type: 'road_auto', rot: 0, w: 1, h: 1 });
|
||
microAutoRoadCells();
|
||
}
|
||
function microEraseRoadCell(i, j) {
|
||
const list = microBuildList();
|
||
const key = i + ',' + j;
|
||
if (microRoadLastCell === key) return;
|
||
microRoadLastCell = key;
|
||
const idx = list.findIndex(b => microIsRoadAuto(b) && b.i === i && b.j === j);
|
||
if (idx >= 0) { list.splice(idx, 1); microAutoRoadCells(); }
|
||
}
|
||
// 旋转:编辑态旋转选中的建筑;否则旋转待放置朝向(Q=左/E=右)
|
||
// 3/4 立绘建筑只有两个对角视角(_r ↔ _l),旋转在两者间切换,不出现 180/270 的怪异镜像;
|
||
// 道路 / 平面件保留 0/90/180/270 全向旋转。
|
||
function microRotateGhost(deg) {
|
||
if (microEditRef) {
|
||
const it = microBuildItemById(microEditRef.type);
|
||
if (it && it.cat !== 'road' && !it.flat) {
|
||
microEditRef.rot = (microEditRef.rot === 90) ? 0 : 90;
|
||
} else {
|
||
microEditRef.rot = ((microEditRef.rot || 0) + deg + 360) % 360;
|
||
}
|
||
const c = microItemCells(microEditRef);
|
||
const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
|
||
microEditRef.i = Math.max(iMin, Math.min(microEditRef.i, iMax - c.ew));
|
||
microEditRef.j = Math.max(jMin, Math.min(microEditRef.j, jMax - c.eh));
|
||
scheduleBuildSave();
|
||
} else {
|
||
const it = microBuildSel ? microBuildItemById(microBuildSel) : null;
|
||
if (it && it.cat !== 'road' && !it.flat) {
|
||
microBuildRot = (microBuildRot === 90) ? 0 : 90;
|
||
} else {
|
||
microBuildRot = (microBuildRot + deg + 360) % 360;
|
||
}
|
||
}
|
||
}
|
||
// 把世界坐标 (wx,wy) 转成屏幕坐标
|
||
function microWorldToScreen(wx, wy) {
|
||
const mW = microCanvas.width, mH = microCanvas.height;
|
||
const s = microZoom * microBuildScale;
|
||
return {
|
||
x: mW / 2 + microPanX + (wx - mW / 2) * s,
|
||
y: mH / 2 + microPanY - microBuildLift + (wy - mH / 2) * s
|
||
};
|
||
}
|
||
// 计算并绘制建造浮动操作按钮(旋转 / 确认 / 取消),在屏幕坐标系绘制
|
||
function microUpdateActionBtns() {
|
||
microActionBtns = [];
|
||
if (!microBuildMode || (!microBuildSel && !microEditRef)) return;
|
||
const gh = microComputeGhost();
|
||
if (!gh) return;
|
||
const gc = (microBuildGeom()).cell;
|
||
const s = microZoom * microBuildScale;
|
||
// 以等距格心定位(与网格/已放置建筑同一坐标系),按钮围绕菱形包围盒排布
|
||
const centerW = microCellToScreen(gh.i + gh.w / 2, gh.j + gh.h / 2);
|
||
const cxS = microWorldToScreen(centerW.x, centerW.y);
|
||
// 按钮半径固定为屏幕像素,不随缩放改变,保证可点
|
||
const R = 18, gap = 10;
|
||
const halfW = gh.w * gc * s / 2; // 菱形半宽(世界宽 gh.w*cell)
|
||
const halfH = gh.h * gc * s / 4; // 菱形半高(世界高 gh.h*cell/2)
|
||
const btns = [];
|
||
const frozenNew = microGhostFrozen && microBuildSel && microBuildSel !== 'road_auto';
|
||
const editMode = !!microEditRef;
|
||
if (frozenNew || editMode) {
|
||
// 取消(✕)在左、确认(✓)在右;两个旋转按钮分别在它们「正上方」(同一 x,上移 2R+gap)
|
||
const sideY = cxS.y;
|
||
const sideX = cxS.x - halfW - R - gap; // 左侧:取消
|
||
const confirmX = cxS.x + halfW + R + gap; // 右侧:确认
|
||
const rotY = sideY - (2 * R + gap); // 取消/确认正上方一排
|
||
btns.push({ id: 'cancel', label: '✕', color: '255,90,90', x: sideX, y: sideY });
|
||
btns.push({ id: 'rotL', label: '↺', color: '100,200,255', x: sideX, y: rotY });
|
||
btns.push({ id: 'rotR', label: '↻', color: '100,200,255', x: confirmX, y: rotY });
|
||
btns.push({ id: 'confirm', label: '✓', color: '76,217,100', x: confirmX, y: sideY });
|
||
} else if (microBuildSel === 'road_auto') {
|
||
// 自动道路/栅栏刷子:只需取消按钮(左键拖拽即铺,右键拖拽即擦)
|
||
btns.push({ id: 'cancel', label: '✕', color: '255,90,90', x: cxS.x, y: cxS.y + halfH + R + gap });
|
||
}
|
||
// 非冻结、跟随鼠标的新素材不显示按钮(避免移动时按钮乱飘)
|
||
microActionBtns = btns;
|
||
}
|
||
function microDrawActionBtns() {
|
||
microUpdateActionBtns();
|
||
microActionBtns.forEach(btn => {
|
||
const r = 18;
|
||
const hover = microActionHover === btn.id;
|
||
microCtx.save();
|
||
microCtx.setTransform(1, 0, 0, 1, 0, 0); // 重置到屏幕坐标系
|
||
microCtx.beginPath();
|
||
microCtx.arc(btn.x, btn.y, r, 0, Math.PI * 2);
|
||
microCtx.fillStyle = `rgba(${btn.color},${hover ? 0.95 : 0.82})`;
|
||
microCtx.fill();
|
||
microCtx.lineWidth = 2;
|
||
microCtx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||
microCtx.stroke();
|
||
microCtx.fillStyle = '#fff';
|
||
microCtx.font = 'bold 18px sans-serif';
|
||
microCtx.textAlign = 'center';
|
||
microCtx.textBaseline = 'middle';
|
||
microCtx.fillText(btn.label, btn.x, btn.y + 1);
|
||
microCtx.restore();
|
||
});
|
||
}
|
||
// 检测屏幕点 p 是否命中浮动按钮
|
||
function microActionHit(p) {
|
||
for (const btn of microActionBtns) {
|
||
const dx = p.x - btn.x, dy = p.y - btn.y;
|
||
if (Math.hypot(dx, dy) <= 22) return btn.id;
|
||
}
|
||
return null;
|
||
}
|
||
// 执行浮动按钮动作
|
||
async function microDoAction(id) {
|
||
if (id === 'rotL') { microRotateGhost(-90); return; }
|
||
if (id === 'rotR') { microRotateGhost(90); return; }
|
||
if (id === 'move') { microGhostFrozen = false; microToast('移动模式:移动鼠标,点击再次固定'); return; }
|
||
if (id === 'confirm') {
|
||
if (microEditRef) { microEditRef = null; microToast('已确认'); }
|
||
else if (microBuildSel) {
|
||
// 放置后同样回到面板重选,不跟手(与取消一致);先扣费放置,成功才清选择
|
||
const ok = await microCommitPlace();
|
||
if (ok) {
|
||
microGhostFrozen = false;
|
||
microBuildSel = null; clearBuildSelUI(); microRenderBuildPanel();
|
||
microToast('已放置,重新选择建筑');
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (id === 'cancel') {
|
||
// 编辑已建建筑(microEditRef 存在)→ X 删除该建筑
|
||
if (microEditRef) {
|
||
const list = microBuildList();
|
||
const idx = list.indexOf(microEditRef);
|
||
if (idx >= 0) list.splice(idx, 1);
|
||
microEditRef = null; scheduleBuildSave();
|
||
microToast('已删除建筑');
|
||
return;
|
||
}
|
||
// 放置新建筑(跟手态)→ 取消重选,绝不删除已放置建筑
|
||
microGhostFrozen = false;
|
||
microEditRef = null;
|
||
if (microBuildSel) { microBuildSel = null; clearBuildSelUI(); microRenderBuildPanel(); }
|
||
microToast('已取消,重新选择建筑');
|
||
}
|
||
}
|
||
// 键盘快捷键:Q/E 旋转 · ESC 取消 · Del 删除(仅在建造模式)
|
||
function onMicroKey(e) {
|
||
if (!microState || !microBuildMode) return;
|
||
const k = (e.key || '').toLowerCase();
|
||
if (k === 'q' || k === 'e') {
|
||
if (microBuildSel === 'road_auto') { e.preventDefault(); return; } // 自动道路方向由邻接自动计算,禁用 Q/E 旋转
|
||
microRotateGhost(k === 'q' ? -90 : 90); e.preventDefault();
|
||
}
|
||
else if (k === 'escape') {
|
||
microGhostFrozen = false;
|
||
microEditRef = null;
|
||
if (microBuildSel) { microBuildSel = null; clearBuildSelUI(); microRenderBuildPanel(); microToast('已取消,重新选择建筑'); }
|
||
else microSetBuildMode(false);
|
||
e.preventDefault();
|
||
} else if (k === 'delete' || k === 'backspace') {
|
||
if (microEditRef) {
|
||
const list = microBuildList();
|
||
const idx = list.indexOf(microEditRef);
|
||
if (idx >= 0) list.splice(idx, 1);
|
||
microEditRef = null; scheduleBuildSave(); e.preventDefault();
|
||
}
|
||
}
|
||
}
|
||
function microToast(msg) {
|
||
let t = document.getElementById('microToast');
|
||
if (!t) {
|
||
t = document.createElement('div');
|
||
t.id = 'microToast';
|
||
t.style.cssText = 'position:fixed;left:50%;bottom:96px;transform:translateX(-50%);background:rgba(10,14,23,.92);color:#ffd54f;border:1px solid rgba(255,213,79,.4);padding:8px 16px;border-radius:10px;font-size:14px;z-index:300;pointer-events:none;transition:opacity .25s;opacity:0;';
|
||
document.body.appendChild(t);
|
||
}
|
||
t.textContent = msg;
|
||
t.style.opacity = '1';
|
||
clearTimeout(t._tid);
|
||
t._tid = setTimeout(() => { t.style.opacity = '0'; }, 1400);
|
||
}
|
||
// 建造面板:分类标签 + 九宫格素材选择器
|
||
function microRenderBuildPanel() {
|
||
const catsEl = document.getElementById('microCats');
|
||
const gridEl = document.getElementById('microGrid9');
|
||
if (!catsEl || !gridEl) return;
|
||
const terrain = microState && microState.tile && microState.tile.terrain;
|
||
const visibleInCat = (cat) => microAllBuildItems().filter(x =>
|
||
(cat === 'all' || x.cat === cat) && !x.hidden && (!x.terrains || !terrain || x.terrains.includes(terrain))
|
||
&& (!microSearchTerm || microItemSearchKey(x).includes(microSearchTerm.toLowerCase()))
|
||
);
|
||
// 全部 + 所有分类(含暂无素材的空分类,保持页签可见)
|
||
const cats = [{ id: 'all', name: '全部' }].concat(MICRO_BUILD_CATS);
|
||
catsEl.innerHTML = cats.map(c =>
|
||
`<button type="button" class="mv-cat${c.id === microBuildCat ? ' active' : ''}" data-cat="${c.id}">${c.name}</button>`
|
||
).join('');
|
||
catsEl.querySelectorAll('.mv-cat').forEach(el => {
|
||
el.addEventListener('click', () => { microBuildCat = el.dataset.cat; microRenderBuildPanel(); });
|
||
});
|
||
const items = visibleInCat(microBuildCat);
|
||
gridEl.innerHTML = items.length ? items.map(it => {
|
||
let ico;
|
||
if (it.isChar && it.sprite) {
|
||
ico = `<div class="mv-ico-char" style="background-image:url('${it.sprite}')"></div>`;
|
||
} else {
|
||
const isImg = typeof it.icon === 'string' && /\.(png|webp|jpg|jpeg)(\?.*)?$/i.test(it.icon);
|
||
ico = isImg ? `<img src="${it.icon}" class="mv-ico-img" alt="">` : `<span class="mv-ico">${it.icon}</span>`;
|
||
}
|
||
const cost = (!it.isChar) ? (it.costGold || 0) : null;
|
||
const costHtml = cost == null ? '' :
|
||
(cost > 0 ? `<span class="mv-cost">🪙 ${cost}</span>` : `<span class="mv-cost free">免费</span>`);
|
||
return `<button type="button" class="mv-cell${it.id === microBuildSel ? ' active' : ''}" data-build="${it.id}" title="${it.name}">
|
||
${ico}<span class="mv-nm">${it.name}</span>${costHtml}
|
||
</button>`;
|
||
}).join('') : '<div class="mv-empty-cat">当前地块不可用此类素材</div>';
|
||
gridEl.querySelectorAll('.mv-cell').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
const id = el.dataset.build;
|
||
const item = microAllBuildItems().find(x => x.id === id);
|
||
// 角色素材(生物栏):进入"点地面放置"模式,不进建筑模式
|
||
if (item && item.isChar) {
|
||
// characters.js 顶层 let charPlaceRace/charPlaceMode 在同全局词法环境可直接读写
|
||
charPlaceRace = item.race;
|
||
charPlaceMode = true;
|
||
// 退出建造模式(如果有)+ 清掉建筑选中态
|
||
microBuildSel = null;
|
||
microEditRef = null;
|
||
microGhost = null;
|
||
microGhostFrozen = false;
|
||
if (microBuildMode) microSetBuildMode(false);
|
||
clearBuildSelUI();
|
||
microRenderBuildPanel();
|
||
if (typeof updateCharPlacePanel === 'function') updateCharPlacePanel();
|
||
const raceName = (window.RACE_CONFIG && window.RACE_CONFIG[item.race]) ? window.RACE_CONFIG[item.race].name : item.name;
|
||
microToast('👤 点地面放置' + raceName);
|
||
return;
|
||
}
|
||
microBuildSel = (microBuildSel === id) ? null : id;
|
||
microEditRef = null; // 选新素材退出编辑
|
||
microGhostFrozen = false; // 进入跟随模式,移动鼠标,点一下才冻结
|
||
if (microBuildSel) {
|
||
const { cols, rows } = microGridDims();
|
||
microGhost = { i: Math.floor((cols - 1) / 2), j: Math.floor((rows - 1) / 2) };
|
||
}
|
||
microRenderBuildPanel();
|
||
if (microBuildSel && !microBuildMode) microSetBuildMode(true);
|
||
if (microBuildSel) {
|
||
const hint = (microBuildSel === 'road_auto')
|
||
? '左键拖拽铺设 · 右键拖拽擦除 · 自动拐角/入口判断 · 点 ✕ 取消'
|
||
: '移动鼠标 → 点一下冻结 · 周边按钮:左✕取消 / 右✓确认 / 取消·确认正上方↺↻切换视角';
|
||
microToast(hint);
|
||
}
|
||
});
|
||
});
|
||
// 异步目录/页签渲染完成后再测一次面板实际高度:首开建造时面板内容此时才就绪,
|
||
// 否则 microSetBuildMode 同步测到的会是空面板高度 → 世界缩放/上移量算错(首开遮挡微观)。
|
||
if (microBuildMode) {
|
||
const ph = document.getElementById('microBuildPanel');
|
||
microBuildPanelH = Math.min(300, Math.max(72, ph ? ph.scrollHeight : 150));
|
||
}
|
||
}
|
||
function clearBuildSelUI() {
|
||
document.querySelectorAll('#microGrid9 .mv-cell').forEach(x => x.classList.remove('active'));
|
||
}
|
||
// 横向拖动翻页(在素材条上按住拖动滚动;拖动后不误触选中)
|
||
(function microBuildDragScroll() {
|
||
const grid = document.getElementById('microGrid9');
|
||
if (!grid) return;
|
||
let down = false, startX = 0, startScroll = 0, moved = false;
|
||
grid.addEventListener('mousedown', e => {
|
||
if (e.button !== 0) return;
|
||
down = true; moved = false;
|
||
startX = e.pageX; startScroll = grid.scrollLeft;
|
||
grid.classList.add('dragging');
|
||
});
|
||
window.addEventListener('mouseup', () => { down = false; grid.classList.remove('dragging'); });
|
||
grid.addEventListener('mousemove', e => {
|
||
if (!down) return;
|
||
const dx = e.pageX - startX;
|
||
if (Math.abs(dx) > 4) moved = true;
|
||
grid.scrollLeft = startScroll - dx;
|
||
});
|
||
// 触摸支持:用原生滚动即可;这里只在拖动后拦截误触
|
||
grid.addEventListener('click', e => {
|
||
if (moved) { e.preventDefault(); e.stopPropagation(); moved = false; }
|
||
}, true);
|
||
})();
|
||
|
||
function microSetBuildMode(on) {
|
||
microBuildMode = on;
|
||
document.getElementById('microBuildBtn').classList.toggle('active', on);
|
||
document.getElementById('microBuildPanel').classList.toggle('open', on);
|
||
microGridOn = on; // 建造时默认开网格,方便对齐
|
||
microCanvas.style.cursor = on ? 'crosshair' : '';
|
||
if (on) {
|
||
microLoadIcons();
|
||
microLoadCatalog().then(microRenderBuildPanel); // 后台目录就绪后刷新页签(名称/说明/分类)
|
||
microShowBuildTips();
|
||
// 测量面板实际高度,供世界变换上移/缩小腾位(仅改变换,不resize画布 → 无闪烁)
|
||
const ph = document.getElementById('microBuildPanel');
|
||
microBuildPanelH = Math.min(300, Math.max(72, ph ? ph.scrollHeight : 150));
|
||
microBuildSel = null; clearBuildSelUI(); microEditRef = null; microGhostFrozen = false;
|
||
const { cols, rows } = microGridDims();
|
||
microGhost = { i: Math.floor((cols - 1) / 2), j: Math.floor((rows - 1) / 2) };
|
||
} else {
|
||
microBuildSel = null; microEditRef = null; clearBuildSelUI(); microGhostFrozen = false;
|
||
microHideBuildTips();
|
||
}
|
||
}
|
||
document.getElementById('microBuildBtn').addEventListener('click', () => {
|
||
microSetBuildMode(!microBuildMode);
|
||
});
|
||
// 清理当前/全部微观建造数据
|
||
function microClearCurrentBuilds() {
|
||
if (!microState) return;
|
||
const key = microState.tile.q + ',' + microState.tile.r;
|
||
const list = microBuildings[key];
|
||
if (!list || !list.length) { microToast('当前地块没有布置'); return; }
|
||
if (confirm('确定清空当前地块的所有建造布置?')) {
|
||
microBuildings[key] = [];
|
||
scheduleBuildSave();
|
||
microToast('已清空当前地块布置');
|
||
}
|
||
}
|
||
function microClearAllBuilds() {
|
||
if (confirm('确定清空所有地块的建造布置?此操作不可恢复。')) {
|
||
Object.keys(microBuildings).forEach(k => microBuildings[k] = []);
|
||
scheduleBuildSave();
|
||
microToast('已清空全部建造布置');
|
||
}
|
||
}
|
||
window.microClearCurrentBuilds = microClearCurrentBuilds;
|
||
window.microClearAllBuilds = microClearAllBuilds;
|
||
const microClearBtn = document.getElementById('microClearBuilds');
|
||
if (microClearBtn) microClearBtn.addEventListener('click', microClearCurrentBuilds);
|
||
// 素材搜索:输入即过滤当前页签素材(不重建输入框本身,避免重复绑定)
|
||
const microSearchInput = document.getElementById('microSearchBuild');
|
||
if (microSearchInput) {
|
||
microSearchInput.addEventListener('input', () => {
|
||
microSearchTerm = microSearchInput.value.trim();
|
||
microRenderBuildPanel();
|
||
});
|
||
}
|
||
|
||
// 建造模式顶部提示轮播
|
||
const MICRO_BUILD_TIPS = [
|
||
'普通素材:移动鼠标预览 → 点击冻结 → 左✕取消 / 右✓确认 / 取消·确认正上方↺↻切换视角',
|
||
'道路刷子:左键拖拽铺路 · 右键拖拽擦除 · 自动拐角 / 入口判断',
|
||
'点一下已放置建筑可进入编辑:拖动移动 · Del 删除 · 右键取消',
|
||
'快捷键:Q 左旋 · E 右旋 · ESC 取消 · Del 删除',
|
||
'绿色区域可建造,红色区域不可建造(越界 / 被占 / 地形不符)'
|
||
];
|
||
let microTipTimer = null;
|
||
let microTipIdx = 0;
|
||
function microShowBuildTips() {
|
||
const wrap = document.getElementById('microBuildTips');
|
||
const txt = document.getElementById('microBuildTipText');
|
||
if (!wrap || !txt) return;
|
||
microTipIdx = 0;
|
||
txt.textContent = MICRO_BUILD_TIPS[0];
|
||
txt.style.opacity = '1';
|
||
wrap.removeAttribute('hidden');
|
||
if (microTipTimer) clearInterval(microTipTimer);
|
||
microTipTimer = setInterval(() => {
|
||
microTipIdx = (microTipIdx + 1) % MICRO_BUILD_TIPS.length;
|
||
txt.style.opacity = '0';
|
||
setTimeout(() => {
|
||
txt.textContent = MICRO_BUILD_TIPS[microTipIdx];
|
||
txt.style.opacity = '1';
|
||
}, 250);
|
||
}, 4500);
|
||
}
|
||
function microHideBuildTips() {
|
||
const wrap = document.getElementById('microBuildTips');
|
||
if (wrap) wrap.setAttribute('hidden', '');
|
||
if (microTipTimer) { clearInterval(microTipTimer); microTipTimer = null; }
|
||
}
|
||
|
||
// 建造布置防抖保存(写回 Odoo,按地块记忆)
|
||
function scheduleBuildSave() {
|
||
if (buildSaveTimer) clearTimeout(buildSaveTimer);
|
||
buildSaveTimer = setTimeout(flushBuildSave, 800);
|
||
}
|
||
async function flushBuildSave() {
|
||
buildSaveTimer = null;
|
||
if (!microState) return;
|
||
const t = microState.tile;
|
||
const items = microBuildings[t.q + ',' + t.r] || [];
|
||
const res = await API.saveMicroBuild(t.q, t.r, items);
|
||
if (!res || !res.ok) {
|
||
if (res && res.error === 'auth') {
|
||
microToast('建造未保存:请先登录游戏(建造需登录 Odoo)');
|
||
} else {
|
||
microToast('建造未保存:连不上后端(确认 Odoo 已启动、经 localhost:8080 访问)');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 窗口尺寸变化:重建画布与静态缓存(微观视图内)
|
||
window.addEventListener('resize', () => {
|
||
if (microState && document.getElementById('microView').classList.contains('active')) {
|
||
const stage = document.getElementById('microStage');
|
||
microCanvas.width = stage.clientWidth - 280;
|
||
microCanvas.height = stage.clientHeight;
|
||
buildMicroGridCache();
|
||
}
|
||
});
|
||
|
||
// ============ 时间轴辅助 ============
|
||
function initAmbParticles() {
|
||
ambParticles = [];
|
||
for (let i = 0; i < 60; i++) {
|
||
ambParticles.push({
|
||
x: Math.random() * 2000, y: Math.random() * 2000,
|
||
vx: (Math.random() - 0.5) * 0.3, vy: -Math.random() * 0.25 - 0.05,
|
||
r: Math.random() * 1.6 + 0.6
|
||
});
|
||
}
|
||
}
|
||
|
||
// 调试控件与当前帧同步(自动推进时刷新滑块/文案)
|
||
// 顶栏时间文字(钟表已去除,只刷新两行文字)
|
||
function syncTimeUI() {
|
||
const now = performance.now();
|
||
// 节流:最多每 100ms 更新一次文字(原每帧 60fps 刷),减少 DOM 写入
|
||
if (now - lastClockUpdate < 100) return;
|
||
lastClockUpdate = now;
|
||
const hh = Math.floor(timeOfDay), mm = Math.floor((timeOfDay - hh) * 60);
|
||
const lab = (currentPalette && currentPalette.label) || '';
|
||
const label = document.getElementById('clockLabel');
|
||
if (label) label.textContent = `${lab} ${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
|
||
// 游戏日历:纪·年·季节
|
||
const cal = getCalendar();
|
||
const cl = document.getElementById('calendarLabel');
|
||
if (cl) cl.textContent = `${ERA_NAME} ${cal.year}年 · ${cal.seasonIcon}${cal.season}(第${cal.dayOfSeason}天)`;
|
||
}
|
||
|
||
// 场景背景图接入点:传入贴合场景的背景图片 URL,背景切到透明画布让其透出
|
||
let brokenImages = {}; // 加载失败的图片不再重试
|
||
let loadedImageUrl = null; // 当前已加载的图片 URL,避免每帧重载
|
||
function loadSceneImage(url) {
|
||
const img = document.getElementById('sceneImage');
|
||
if (!img || !url || brokenImages[url]) return;
|
||
img.onerror = () => { brokenImages[url] = true; clearSceneImage(); };
|
||
img.onload = () => { brokenImages[url] = false; };
|
||
img.src = url; img.style.display = 'block';
|
||
useImageBg = true;
|
||
}
|
||
function clearSceneImage() {
|
||
const img = document.getElementById('sceneImage');
|
||
if (img) { img.style.display = 'none'; img.removeAttribute('src'); }
|
||
useImageBg = false;
|
||
}
|
||
|
||
// 时间线不切换图片:每个世界固定一张背景图,时间只改变色调(render 里 tint 叠加)
|
||
function refreshWorldVideo() {
|
||
const w = WORLDS.find(x => x.id === currentLayer);
|
||
const url = w ? (w.image || null) : null;
|
||
if (url === loadedImageUrl) return; // 未变化不重载
|
||
loadedImageUrl = url;
|
||
if (url) loadSceneImage(url); else clearSceneImage();
|
||
}
|
||
|
||
// 切换世界(五层):星空 / 天空 / 大地 / 地下 / 地心,各一张背景图
|
||
function switchWorld(id) {
|
||
if (currentLayer === id) return;
|
||
currentLayer = id;
|
||
worldBaseValid = false; // 层切换:地块集合变了,基座缓存失效
|
||
_cachedSorted = null; _cachedSortedLayer = null; _tileGrid = null;
|
||
refreshWorldVideo();
|
||
syncWorldNav();
|
||
if (typeof clampCamera === 'function') clampCamera(); // 切层后重新钳制边界
|
||
}
|
||
function syncWorldNav() {
|
||
document.querySelectorAll('#worldNav .wn-item').forEach(el => {
|
||
el.classList.toggle('active', el.dataset.world === currentLayer);
|
||
});
|
||
// 浏览器标签随当前层更新;顶栏 #worldName 现在是世界名称(yt.world.name),不再覆盖
|
||
const nm = WORLD_NAMES[currentLayer] || '大地';
|
||
document.title = '宇森 · ' + nm;
|
||
}
|
||
|
||
// ============ 世界管理:地表比例面板(已并入 buildPanel 的"管理" tab) ============
|
||
function initWorldManager() {
|
||
const body = document.getElementById('bpMgmtBody');
|
||
const applyBtn = document.getElementById('bpMgmtApply');
|
||
if (!body || !applyBtn) return;
|
||
|
||
// 构建每种地形的滑杆行(注入到 buildPanel 内的 #bpMgmtBody)
|
||
const TERRAIN_ORDER = ['water', 'desert', 'plain', 'forest', 'mountain', 'snow'];
|
||
TERRAIN_ORDER.forEach(id => {
|
||
const t = TERRAIN[id];
|
||
const row = document.createElement('div');
|
||
row.className = 'mp-row';
|
||
const v = TERRAIN_RATIO[id] != null ? TERRAIN_RATIO[id] : 1;
|
||
row.innerHTML =
|
||
`<span class="mp-swatch" style="background:${t.color}"></span>` +
|
||
`<span class="mp-name">${t.name}</span>` +
|
||
`<input type="range" min="0" max="5" step="1" value="${v}" data-id="${id}">` +
|
||
`<span class="mp-val">${v}</span>`;
|
||
body.appendChild(row);
|
||
});
|
||
const sliders = Array.from(body.querySelectorAll('input[type=range]'));
|
||
sliders.forEach(s => s.addEventListener('input', () => {
|
||
s.parentElement.querySelector('.mp-val').textContent = s.value;
|
||
}));
|
||
|
||
// 预设(在 buildPanel 内的 .bp-mgmt-presets)
|
||
document.querySelectorAll('#buildPanel .bp-mgmt-presets button').forEach(b => b.addEventListener('click', () => {
|
||
const p = TERRAIN_RATIO_PRESETS[b.dataset.preset];
|
||
if (!p) return;
|
||
sliders.forEach(s => {
|
||
const v = p[s.dataset.id];
|
||
if (v != null) { s.value = v; s.parentElement.querySelector('.mp-val').textContent = v; }
|
||
});
|
||
}));
|
||
|
||
// 应用并重构地表
|
||
applyBtn.addEventListener('click', () => {
|
||
let sum = 0; sliders.forEach(s => sum += (+s.value));
|
||
if (sum <= 0) { alert('比例合计需大于 0'); return; }
|
||
sliders.forEach(s => { TERRAIN_RATIO[s.dataset.id] = +s.value; });
|
||
// 清理废弃 key
|
||
for (const k in TERRAIN_RATIO) if (!VALID_TERRAINS.includes(k)) delete TERRAIN_RATIO[k];
|
||
try { localStorage.setItem('terrainRatio', JSON.stringify(TERRAIN_RATIO)); } catch (e) {}
|
||
generateMap();
|
||
});
|
||
}
|
||
|
||
// ============ 初始化 ============
|
||
// 顶栏世界名称:点击进入编辑,回车/失焦保存到后端,Esc 取消
|
||
function setupWorldNameEdit() {
|
||
const wn = document.getElementById('worldName');
|
||
if (!wn) return;
|
||
|
||
wn.addEventListener('click', () => {
|
||
if (wn.querySelector('input')) return; // 已在编辑
|
||
const original = wn.textContent.trim();
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.value = original;
|
||
input.maxLength = 24;
|
||
input.className = 'worldName-input';
|
||
input.spellcheck = false;
|
||
wn.textContent = '';
|
||
wn.appendChild(input);
|
||
input.focus();
|
||
input.select();
|
||
|
||
let done = false;
|
||
const restore = (val) => {
|
||
if (done) return; done = true;
|
||
wn.textContent = val;
|
||
};
|
||
const commit = async () => {
|
||
if (done) return;
|
||
const next = input.value.trim();
|
||
if (!next || next === original) { restore(original); return; }
|
||
// 先本地生效(不回滚),再异步写后端
|
||
restore(next);
|
||
localStorage.setItem('world_name', next);
|
||
try {
|
||
const r = await API.renameWorld(next);
|
||
if (r && r.ok && r.name) { restore(r.name); localStorage.setItem('world_name', r.name); emitTaskEvent('world_named'); }
|
||
else console.warn('rename 后端未响应,已本地保存', r);
|
||
} catch (e) {
|
||
console.warn('rename 后端失败,已本地保存', e);
|
||
}
|
||
};
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||
else if (e.key === 'Escape') { e.preventDefault(); restore(original); }
|
||
});
|
||
input.addEventListener('blur', commit);
|
||
});
|
||
}
|
||
|
||
// ============ 微观世界标题:点击进入编辑,回车/失焦保存到后端,Esc 取消 ============
|
||
function setupMicroNameEdit() {
|
||
const mt = document.getElementById('microTitle');
|
||
if (!mt) return;
|
||
mt.classList.add('editable');
|
||
mt.title = '点击重命名此微观世界';
|
||
mt.addEventListener('click', () => {
|
||
if (!microState || !microState.tile) return; // 仅在微观视图内可编辑
|
||
if (mt.querySelector('input')) return; // 已在编辑
|
||
const tile = microState.tile;
|
||
const key = tile.q + ',' + tile.r;
|
||
const current = microNames[key] || '';
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.value = current;
|
||
input.placeholder = '输入此微观世界名称';
|
||
input.maxLength = 24;
|
||
input.className = 'worldName-input';
|
||
input.spellcheck = false;
|
||
mt.textContent = '';
|
||
mt.appendChild(input);
|
||
input.focus();
|
||
input.select();
|
||
let done = false;
|
||
const restore = () => { if (done) return; done = true; mt.textContent = microTitleText(tile); };
|
||
const commit = async () => {
|
||
if (done) return;
|
||
const next = input.value.trim();
|
||
if (!next || next === current) { restore(); return; }
|
||
restore(); // 先本地显示,再异步写后端
|
||
microNames[key] = next;
|
||
try {
|
||
const r = await API.renameMicro(tile.q, tile.r, next);
|
||
if (r && r.ok && r.name) { microNames[key] = r.name; }
|
||
else console.warn('微观世界重命名后端未响应,已本地保存', r);
|
||
} catch (e) { console.warn('微观世界重命名失败,已本地保存', e); }
|
||
};
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||
else if (e.key === 'Escape') { e.preventDefault(); restore(); }
|
||
});
|
||
input.addEventListener('blur', commit);
|
||
});
|
||
}
|
||
|
||
function init() {
|
||
initBGM();
|
||
W = canvas.width = window.innerWidth;
|
||
H = canvas.height = window.innerHeight - BOTTOM_BAR_H;
|
||
canvas.classList.add('canDrag');
|
||
|
||
initStars();
|
||
initEmbers();
|
||
initAmbParticles();
|
||
// 世界管理面板(地表比例自调)
|
||
initWorldManager();
|
||
// 建造模式面板(地形 / 板块交换)
|
||
setupBuildUI();
|
||
// 顶栏世界名称点击编辑
|
||
setupWorldNameEdit();
|
||
// 微观世界标题点击编辑(按地块独立命名,持久化到后端)
|
||
setupMicroNameEdit();
|
||
// 钟表已移走;时间文字由 syncTimeUI 每 100ms 节流更新
|
||
// 世界切换导航
|
||
document.querySelectorAll('#worldNav .wn-item').forEach(el => {
|
||
el.addEventListener('click', () => switchWorld(el.dataset.world));
|
||
});
|
||
syncWorldNav();
|
||
refreshWorldVideo(); // 初始世界(大地)加载对应背景视频
|
||
syncTimeUI();
|
||
// 加载微观地形背景图
|
||
function preloadMicroBg(src) {
|
||
const img = new Image();
|
||
img.src = src;
|
||
img.onload = () => {
|
||
worldBaseValid = false; // 重绘沙盘基座缓存
|
||
if (microState) buildMicroGridCache(); // 重建微观视图缓存
|
||
};
|
||
return img;
|
||
}
|
||
grassBgImg = preloadMicroBg('assets/backgrounds/grass_bg.png?v=20260803');
|
||
forestBgImg = preloadMicroBg('assets/backgrounds/forest_bg.png?v=20260803');
|
||
mountainBgImg = preloadMicroBg('assets/backgrounds/mountain_bg.png?v=20260803');
|
||
desertBgImg = preloadMicroBg('assets/backgrounds/desert_bg.png?v=20260803');
|
||
snowBgImg = preloadMicroBg('assets/backgrounds/snow_bg.png?v=20260803');
|
||
waterBgImg = preloadMicroBg('assets/backgrounds/water_bg.png?v=20260803');
|
||
// 注意:初始 clampCamera 不在本函数内调用——clampCamera 定义在后续 <script> 块中,
|
||
// 跨 script 块的函数声明不会提升,若在此调用会抛 ReferenceError 导致加载屏永不关闭(页面"卡住")。
|
||
// 初始钳制改在 block1 末尾(clampCamera 定义之后)统一执行。
|
||
// 加载动画:真实等待地形贴图全部解码完毕,再隐藏加载屏并启动渲染(预加载闸门,杜绝渐进 pop-in)
|
||
const loadFill = document.getElementById('loadFill');
|
||
const loadPct = document.getElementById('loadPct');
|
||
const loadTip = document.getElementById('loadTip');
|
||
const loadBg = document.getElementById('loadBg');
|
||
if (loadBg) loadBg.src = pickTransitionImage();
|
||
// 随机游戏小提示(兼作无声引导,循环轮换)
|
||
const TIPS = [
|
||
'💡 拖拽空白处可平移视角,滚轮缩放沙盘',
|
||
'💡 滚轮飞入地块,进入微观村落看看居民',
|
||
'💡 建造模式下可绘制地形,重塑世界地貌',
|
||
'💡 按住地块拖动,可与相邻地块交换地貌',
|
||
'💡 右上角时钟在自动流转昼夜,世界随时不同',
|
||
'💡 五层世界:星空 / 天空 / 大地 / 地下 / 地心',
|
||
'💡 你的建造会自动存档,下次回来世界仍在',
|
||
'💡 切换世界标签,俯瞰同一坐标的不同层级',
|
||
'💡 微观世界左键拖动可切换相邻地块',
|
||
'💡 微观世界滚轮缩放、右键拖拽平移'
|
||
];
|
||
let tipIdx = Math.floor(Math.random() * TIPS.length);
|
||
loadTip.textContent = TIPS[tipIdx];
|
||
const tipTimer = setInterval(() => {
|
||
tipIdx = (tipIdx + 1) % TIPS.length;
|
||
loadTip.style.opacity = '0';
|
||
setTimeout(() => { loadTip.textContent = TIPS[tipIdx]; loadTip.style.opacity = '1'; }, 350);
|
||
}, 2800);
|
||
const loadStart = Date.now();
|
||
loadTerrainAssets(p => {
|
||
const pct = Math.round(p * 100);
|
||
loadFill.style.width = pct + '%';
|
||
loadPct.textContent = pct + '%';
|
||
}).then(() => {
|
||
clearInterval(tipTimer);
|
||
loadFill.style.width = '100%';
|
||
loadPct.textContent = '100%';
|
||
// 加载屏至少停留 1 秒:确保主世界元素(地形贴图/居民/建筑)完全就绪再进入,快机也不闪屏
|
||
const MIN_LOAD_MS = 1500; // [PLACEHOLDER] 体感:1.5s 是否够?可下调至 1000
|
||
const hold = Math.max(0, MIN_LOAD_MS - (Date.now() - loadStart));
|
||
setTimeout(() => {
|
||
document.getElementById('loadingScreen').style.display = 'none';
|
||
requestAnimationFrame(render);
|
||
}, hold);
|
||
});
|
||
}
|
||
|
||
window.addEventListener('resize', () => {
|
||
W = canvas.width = window.innerWidth;
|
||
H = canvas.height = window.innerHeight - BOTTOM_BAR_H;
|
||
worldCacheValid = false; // 画布尺寸变化,交互快照失效,下次交互重新烘焙
|
||
worldBaseValid = false; // 基座缓存尺寸失效,下帧重建
|
||
if (typeof fitWorld === 'function') fitWorld(); // 窗口变化后重新铺满视口,不露灰棕底
|
||
});
|
||
|
||
loadSavedRatio(); // 应用上次保存的地表比例
|
||
generateMap();
|
||
// 注意:地表改动不再走 localStorage,统一由登录闸门拉取后端存档 applyWorldState 覆盖
|
||
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 = 20; // [PLACEHOLDER] 每步平移像素量(按钮单次 / 键盘单次)
|
||
const PAN_HOLD_MS = 55; // [PLACEHOLDER] 按钮长按重复间隔(ms)
|
||
const PAN_BOUND_K = 0.45; // [PLACEHOLDER] 平移上限 = 视口边长的比例(向内收缩,世界中心最多移到屏幕内约 45% 处即停)
|
||
let panTimer = null;
|
||
function stopPanHold() { if (panTimer) { clearInterval(panTimer); panTimer = null; } }
|
||
function panCamera(dx, dy) {
|
||
if (microViewOpen) return; // 微观视图打开时不平移主世界
|
||
const bx = W * PAN_BOUND_K, by = H * PAN_BOUND_K;
|
||
camera.x = Math.max(-bx, Math.min(bx, camera.x + dx));
|
||
camera.y = Math.max(-by, Math.min(by, camera.y + dy));
|
||
}
|
||
// 相机拖拽边界:世界全部地块必须保持在视口内,不能拖出屏幕
|
||
// 原理:计算所有地块的屏幕包围盒 → clamp camera 使包围盒不超出 canvas 边界
|
||
function clampCamera() {
|
||
if (microViewOpen) return;
|
||
// 遍历当前层所有地块,求世界包围盒(屏幕像素坐标,已含当前 camera)
|
||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||
for (const t of tiles) {
|
||
if (t.layer !== currentLayer) continue;
|
||
const { x, y } = hexToPixel(t.q, t.r);
|
||
const s = POLY_SIZE * camera.zoom;
|
||
const ez = heightToTier(t.height != null ? t.height : terrainToHeight(t.terrain)) * TIER_STEP * camera.zoom;
|
||
const cyTop = y - ez;
|
||
const wallH = Math.max(2, ez + SANDBOX_DEPTH * camera.zoom);
|
||
if (x - s < minX) minX = x - s;
|
||
if (x + s > maxX) maxX = x + s;
|
||
if (cyTop < minY) minY = cyTop;
|
||
if (cyTop + wallH > maxY) maxY = cyTop + wallH;
|
||
}
|
||
if (!isFinite(minX)) return;
|
||
const cx = camera.x, cy = camera.y; // 当前(未钳制)相机
|
||
// 各边独立留白:上/左翻倍(88),下/右保持(44)—— 用户要求上边距、左边距扩大两倍
|
||
const padL = 110, padR = 25, padT = 110, padB = 44;
|
||
// 绝对相机边界:使包围盒左 >= padL、右 <= W-padR、上 >= padT、下 <= H-padB
|
||
const loX = cx + (padL - minX);
|
||
const hiX = cx + ((W - padR) - maxX);
|
||
const loY = cy + (padT - minY);
|
||
const hiY = cy + ((H - padB) - maxY);
|
||
_camBoundX.min = loX; _camBoundX.max = hiX;
|
||
_camBoundY.min = loY; _camBoundY.max = hiY;
|
||
// loX<=hiX:世界比视口小 → 可在 [loX,hiX] 内自由平移(到边停住,留白仅 pad);
|
||
// loX>hiX :世界比视口大(放大)→ 钳制到 [hiX,loX],可平移、到边停住。
|
||
if (loX <= hiX) {
|
||
camera.x = Math.max(loX, Math.min(hiX, cx));
|
||
camera.y = Math.max(loY, Math.min(hiY, cy));
|
||
} else {
|
||
camera.x = Math.max(hiX, Math.min(loX, cx));
|
||
camera.y = Math.max(hiY, Math.min(loY, cy));
|
||
}
|
||
}
|
||
// 进入时让世界铺满视口(fit-to-view):小世界默认会被 clampCamera 居中锁死、四周露灰棕底 →
|
||
// 反推缩放使世界刚好充满视口(留少量可平移余量),既消除灰棕、又保留拖拽到边停住的手感。
|
||
function fitWorld() {
|
||
if (microViewOpen) return;
|
||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||
for (const t of tiles) {
|
||
if (t.layer !== currentLayer) continue;
|
||
const x = POLY_SIZE * (Math.sqrt(3) * t.q + Math.sqrt(3) / 2 * t.r);
|
||
const y = POLY_SIZE * (1.5 * t.r);
|
||
const s = POLY_SIZE;
|
||
const ez = heightToTier(t.height != null ? t.height : terrainToHeight(t.terrain)) * TIER_STEP;
|
||
const cyTop = y - ez;
|
||
const wallH = Math.max(2, ez + SANDBOX_DEPTH);
|
||
if (x - s < minX) minX = x - s;
|
||
if (x + s > maxX) maxX = x + s;
|
||
if (cyTop < minY) minY = cyTop;
|
||
if (cyTop + wallH > maxY) maxY = cyTop + wallH;
|
||
}
|
||
if (!isFinite(minX)) return;
|
||
const worldW = maxX - minX, worldH = maxY - minY;
|
||
const FIT_FILL = 0.7; // 世界铺满视口的 70%(首次进入默认大小,用户指定 0.7)
|
||
const z = Math.min((W * FIT_FILL) / worldW, (H * FIT_FILL) / worldH);
|
||
camera.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z));
|
||
camera.x = 0; camera.y = 0; // 世界对称,居中后交给 clampCamera 钳制
|
||
if (typeof clampCamera === 'function') clampCamera();
|
||
}
|
||
const PAN_DIRS = {
|
||
up:[0,-PAN_STEP], down:[0,PAN_STEP], left:[-PAN_STEP,0], right:[PAN_STEP,0],
|
||
nw:[-PAN_STEP,-PAN_STEP], ne:[PAN_STEP,-PAN_STEP], sw:[-PAN_STEP,PAN_STEP], se:[PAN_STEP,PAN_STEP],
|
||
};
|
||
const navPanel = document.getElementById('navControls');
|
||
navPanel.addEventListener('pointerdown', e => {
|
||
const btn = e.target.closest('button');
|
||
if (!btn) return;
|
||
e.preventDefault();
|
||
const d = PAN_DIRS[btn.dataset.cmd];
|
||
if (!d) return;
|
||
stopPanHold();
|
||
panCamera(d[0], d[1]); // 立即响应一次
|
||
panTimer = setInterval(() => panCamera(d[0], d[1]), PAN_HOLD_MS); // 长按连续平移
|
||
});
|
||
window.addEventListener('pointerup', stopPanHold);
|
||
window.addEventListener('pointercancel', stopPanHold);
|
||
// 键盘方向键 + WASD = 等效于方向按钮(按住时系统自动重复 keydown → 连续平移)
|
||
const KEY_PAN = {
|
||
ArrowUp: [0, -PAN_STEP], ArrowDown: [0, PAN_STEP],
|
||
ArrowLeft: [-PAN_STEP, 0], ArrowRight: [ PAN_STEP, 0],
|
||
w: [0, -PAN_STEP], s: [0, PAN_STEP],
|
||
a: [-PAN_STEP, 0], d: [ PAN_STEP, 0],
|
||
W: [0, -PAN_STEP], S: [0, PAN_STEP],
|
||
A: [-PAN_STEP, 0], D: [ PAN_STEP, 0],
|
||
};
|
||
window.addEventListener('keydown', e => {
|
||
if (!(e.key in KEY_PAN)) return;
|
||
if (microViewOpen) return; // 微观视图不平移主世界
|
||
const t = e.target;
|
||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||
e.preventDefault(); // 阻止方向键滚动页面
|
||
panCamera(KEY_PAN[e.key][0], KEY_PAN[e.key][1]);
|
||
});
|
||
|
||
// 初始钳制:clampCamera 在本 script 块内定义,init()(上一 script 块)无法在定义前调用它。
|
||
// 进入时一次性把世界铺满视口并钳制边界(fitWorld 内部已调 clampCamera)。
|
||
if (typeof fitWorld === 'function') fitWorld();
|
||
</script>
|
||
<script src="assets/game-api.js?v=20260825"></script>
|
||
<script src="assets/characters.js?v=20260825"></script>
|
||
<script>
|
||
// ===== 登录闸门:未登录跳登录页;已登录显示昵称+退出 =====
|
||
(async () => {
|
||
const splash = document.getElementById('authSplash');
|
||
const area = document.getElementById('userArea');
|
||
const trigger = document.getElementById('uaTrigger');
|
||
const menu = document.getElementById('uaMenu');
|
||
try {
|
||
const me = await API.me();
|
||
if (!me || !me.uid) { location.replace('login.html?next=world.html'); return; }
|
||
document.getElementById('uaName').textContent = me.name || me.login;
|
||
area.hidden = false;
|
||
// 拉取后端地表记忆(yt_world):确定性底图 + 玩家增量覆盖,叠加到当前 tiles
|
||
// 无论成功失败都更新世界名称,避免"加载中…"卡死
|
||
const wnEl = document.getElementById('worldName');
|
||
try {
|
||
const ws = await API.world();
|
||
applyWorldState(ws);
|
||
if (ws && ws.name && wnEl) wnEl.textContent = ws.name;
|
||
else {
|
||
// 后端无名称时,优先读本地缓存
|
||
const local = localStorage.getItem('world_name');
|
||
wnEl.textContent = local || '我的世界';
|
||
}
|
||
} catch (e) {
|
||
console.warn('加载世界存档失败,使用确定性底图', e);
|
||
const local = localStorage.getItem('world_name');
|
||
if (wnEl) wnEl.textContent = local || '我的世界';
|
||
}
|
||
fetchTasks(); // 登录即拉取新手主线任务(主世界初次进入也要触发,不只进地块时)
|
||
const backdrop = document.getElementById('uaBackdrop');
|
||
const closeBtn = document.getElementById('uaClose');
|
||
const setMenuOpen = (open) => {
|
||
menu.classList.toggle('open', open);
|
||
if (backdrop) backdrop.classList.toggle('open', open);
|
||
trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
};
|
||
|
||
// === 面板数据填充(从 me() 返回值动态渲染) ===
|
||
const cachedMe = me; // 保存供弹窗复用
|
||
function fillPanelData(d) {
|
||
const name = d.name || d.login || '';
|
||
const uaName2 = document.getElementById('uaName2');
|
||
if (uaName2) uaName2.textContent = name;
|
||
const uaAv = document.getElementById('uaAvatarLg');
|
||
if (uaAv) uaAv.textContent = (name || 'M').trim().charAt(0).toUpperCase();
|
||
const uaUid = document.getElementById('uaUid');
|
||
if (uaUid) uaUid.textContent = d.game_uid || d.uid || '--';
|
||
// 等级卡(个人面板内保留)
|
||
const uaLv = document.getElementById('uaLv');
|
||
if (uaLv) uaLv.textContent = d.level || 1;
|
||
const uaTitle = document.getElementById('uaTitle');
|
||
if (uaTitle) uaTitle.textContent = d.title || '萌新';
|
||
const expMax = d.exp_max || 100;
|
||
const expPct = expMax > 0 ? Math.min(100, (d.exp || 0) / expMax * 100) : 0;
|
||
const uaExpBar = document.getElementById('uaExpBar');
|
||
if (uaExpBar) uaExpBar.style.width = expPct + '%';
|
||
const uaExpTxt = document.getElementById('uaExpTxt');
|
||
if (uaExpTxt) uaExpTxt.textContent = 'EXP ' + (d.exp || 0) + ' / ' + expMax;
|
||
// 世界等级经验条(底部常驻)
|
||
const weLv = document.getElementById('weLv');
|
||
if (weLv) weLv.textContent = d.world_level || d.level || 1;
|
||
const weTitle = document.getElementById('weTitle');
|
||
if (weTitle) weTitle.textContent = d.world_title || d.title || '萌新';
|
||
const worldExp = d.world_exp != null ? d.world_exp : (d.exp || 0);
|
||
const worldExpMax = d.world_exp_max || d.exp_max || 100;
|
||
const worldExpPct = worldExpMax > 0 ? Math.min(100, worldExp / worldExpMax * 100) : 0;
|
||
const weExpFill = document.getElementById('weExpFill');
|
||
if (weExpFill) weExpFill.style.width = worldExpPct + '%';
|
||
const weExpTxt = document.getElementById('weExpTxt');
|
||
if (weExpTxt) weExpTxt.textContent = 'EXP ' + worldExp + ' / ' + worldExpMax;
|
||
const weExpPctEl = document.getElementById('weExpPct');
|
||
if (weExpPctEl) weExpPctEl.textContent = Math.floor(worldExpPct) + '%';
|
||
// 邮件未读角标(顶部图标栏)
|
||
const topMailBadge = document.getElementById('topMailBadge');
|
||
if (topMailBadge) {
|
||
const n = d.unread_mail || 0;
|
||
topMailBadge.textContent = n;
|
||
topMailBadge.style.display = n > 0 ? '' : 'none';
|
||
}
|
||
}
|
||
fillPanelData(me);
|
||
|
||
// === 异步加载邮件未读数 ===
|
||
(async () => {
|
||
try {
|
||
const ml = await API.mails();
|
||
if (ml && typeof ml.unread === 'number') {
|
||
const badge = document.getElementById('topMailBadge');
|
||
if (badge) {
|
||
badge.textContent = ml.unread;
|
||
badge.style.display = ml.unread > 0 ? '' : 'none';
|
||
}
|
||
}
|
||
} catch (e) { console.warn('加载邮件数失败', e); }
|
||
})();
|
||
|
||
// 顶部用户区现在只展示文字,不再下拉展开玩家面板;保留面板 DOM 但不再通过 trigger 打开。
|
||
document.addEventListener('click', (ev) => {
|
||
if (!menu.classList.contains('open')) return;
|
||
if (ev.target === backdrop) { setMenuOpen(false); return; }
|
||
if (menu.contains(ev.target) || trigger.contains(ev.target)) return;
|
||
setMenuOpen(false);
|
||
});
|
||
if (closeBtn) closeBtn.addEventListener('click', () => setMenuOpen(false));
|
||
if (backdrop) backdrop.addEventListener('click', () => setMenuOpen(false));
|
||
document.addEventListener('keydown', (ev) => {
|
||
if (ev.key === 'Escape' && menu.classList.contains('open')) setMenuOpen(false);
|
||
});
|
||
document.getElementById('uaLogout').addEventListener('click', async () => {
|
||
try { await API.logout(); } catch (e) {}
|
||
location.replace('login.html');
|
||
});
|
||
// 场景悬浮工具条:官网、任务、商城、公告、排行榜、日历、拍卖行
|
||
const btnWebsite = document.getElementById('btnWebsite');
|
||
if (btnWebsite) btnWebsite.addEventListener('click', () => { location.href = 'index.html'; });
|
||
const btnTask = document.getElementById('btnTask');
|
||
if (btnTask) btnTask.addEventListener('click', () => { openTaskPanel(); });
|
||
const btnMall = document.getElementById('btnMall');
|
||
if (btnMall) btnMall.addEventListener('click', () => {
|
||
openAppModal('🛒 商城', '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">商城系统即将开放<br><br>可购买皮肤、道具、加速器与会员服务。</div>');
|
||
});
|
||
const btnNotice = document.getElementById('btnNotice');
|
||
if (btnNotice) btnNotice.addEventListener('click', () => {
|
||
openAppModal('📢 公告', '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">公告系统即将开放<br><br>运营活动、版本更新与维护通知将在此集中展示,重要公告带红点提醒。</div>');
|
||
});
|
||
const btnRank = document.getElementById('btnRank');
|
||
if (btnRank) btnRank.addEventListener('click', () => {
|
||
openAppModal('🏅 排行榜', '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">排行榜即将开放<br><br>世界等级、财富与成就榜单,激励你冲榜。</div>');
|
||
});
|
||
const btnCalendar = document.getElementById('btnCalendar');
|
||
if (btnCalendar) btnCalendar.addEventListener('click', () => {
|
||
openAppModal('📅 日历', '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">世界日历即将开放<br><br>限时副本、公会战与世界事件时间一目了然。</div>');
|
||
});
|
||
const btnAuction = document.getElementById('btnAuction');
|
||
if (btnAuction) btnAuction.addEventListener('click', () => {
|
||
openAppModal('🔨 拍卖行', '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">拍卖行即将开放<br><br>玩家之间自由交易道具、材料与稀有素材。</div>');
|
||
});
|
||
|
||
// 通用弹窗
|
||
const appModal = document.getElementById('appModal');
|
||
const appModalTitle = document.getElementById('appModalTitle');
|
||
const appModalBody = document.getElementById('appModalBody');
|
||
window.openAppModal = function(title, html) {
|
||
if (appModalTitle) appModalTitle.textContent = title;
|
||
if (appModalBody) appModalBody.innerHTML = html;
|
||
if (appModal) appModal.classList.add('active');
|
||
setMenuOpen(false);
|
||
}
|
||
window.closeAppModal = function() {
|
||
if (appModal) appModal.classList.remove('active');
|
||
};
|
||
if (appModal) appModal.addEventListener('click', (e) => {
|
||
if (e.target === appModal) closeAppModal();
|
||
});
|
||
|
||
// 个人信息(从 cachedMe + 后端实时角色数据渲染)
|
||
async function openProfile() {
|
||
const d = cachedMe || {};
|
||
const name = d.name || d.login || '访客';
|
||
const avatarLetter = (name || 'M').trim().charAt(0).toUpperCase();
|
||
const gender = d.gender || '未设置';
|
||
const desc = d.desc || d.bio || '这位缔造者行事低调,尚未在宇森留下任何足迹。';
|
||
const frame = d.avatar_frame || d.frame || '初芒';
|
||
|
||
// 先弹出面板并显示加载中,避免点击后无反馈
|
||
openAppModal('👤 个人信息', '<div style="text-align:center;padding:24px;color:#9fb0c0;">加载中…</div>');
|
||
|
||
try {
|
||
// 强制从后端拉取最新角色数据,防止刷新后 microChar 为空导致经验清零
|
||
const fresh = await API.char();
|
||
if (fresh && fresh.ok) {
|
||
microChar = {
|
||
name: fresh.name, level: fresh.level, exp: fresh.exp,
|
||
exp_max: fresh.exp_max || 100, gold: fresh.gold || 0,
|
||
equip: fresh.equip || { weapon: null, armor: null, accessory: null },
|
||
inventory: fresh.inventory || {}
|
||
};
|
||
}
|
||
} catch (e) {
|
||
console.warn('加载角色信息失败,使用缓存数据', e);
|
||
}
|
||
|
||
const mv = (typeof microChar !== 'undefined' && microChar) ? microChar : null;
|
||
const mlv = mv ? (mv.level || 1) : (d.game_level || 1);
|
||
const mexp = mv ? (mv.exp || 0) : (d.game_exp || 0);
|
||
const mexpMax = mv ? (mv.exp_max || 100) : (d.game_exp_max || 100);
|
||
const mpct = mexpMax > 0 ? Math.min(100, Math.round(mexp / mexpMax * 100)) : 0;
|
||
openAppModal('👤 个人信息', `
|
||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:12px;">
|
||
<div style="position:relative;flex:0 0 auto;">
|
||
<div style="width:58px;height:58px;border-radius:50%;padding:3px;background:linear-gradient(135deg,#c77dff,#4af);box-shadow:0 0 14px rgba(199,125,255,.45);">
|
||
<div style="width:100%;height:100%;border-radius:50%;background:linear-gradient(135deg,#2a2348,#161b2e);display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:700;color:#fff;">${avatarLetter}</div>
|
||
</div>
|
||
<div style="position:absolute;right:-2px;bottom:-2px;min-width:22px;height:22px;padding:0 5px;border-radius:11px;background:linear-gradient(135deg,#3a7afe,#46e0c8);border:2px solid #16202c;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff;box-shadow:0 0 8px rgba(58,122,254,.5);">${mlv}</div>
|
||
</div>
|
||
<div style="min-width:0;">
|
||
<div style="font-size:16px;font-weight:700;color:#e0e0e0;">${name}</div>
|
||
<div style="font-size:12px;color:#8899aa;">UID: ${d.game_uid || d.uid || '--'} · 头像框:${frame}</div>
|
||
</div>
|
||
</div>
|
||
<div class="infoRow"><span class="infoLabel">等级</span><span class="infoValue">Lv.${mlv}</span></div>
|
||
<div style="display:flex;align-items:center;gap:6px;margin:4px 0 10px;">
|
||
<div style="position:relative;flex:1;height:7px;background:rgba(255,255,255,.12);border-radius:5px;overflow:hidden;">
|
||
<div style="position:absolute;left:0;top:0;bottom:0;width:${mpct}%;background:linear-gradient(90deg,#3a7afe,#46e0c8);border-radius:5px;transition:width .35s ease;"></div>
|
||
</div>
|
||
<span style="font-size:11px;color:#9fb0c0;white-space:nowrap;">EXP ${mexp}/${mexpMax}</span>
|
||
</div>
|
||
<div class="infoRow"><span class="infoLabel">性别</span><span class="infoValue">${gender}</span></div>
|
||
<div style="font-size:12px;color:#aab4c8;line-height:1.6;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:10px 12px;margin:10px 0;">${desc}</div>
|
||
<div class="infoRow"><span class="infoLabel">称号</span><span class="infoValue">${d.title || '萌新'}</span></div>
|
||
<div class="infoRow"><span class="infoLabel">注册</span><span class="infoValue">${d.reg_date || '--'}</span></div>
|
||
`);
|
||
}
|
||
if (trigger) trigger.addEventListener('click', () => { openProfile(); });
|
||
|
||
// 设置
|
||
const btnSettings = document.getElementById('btnSettings');
|
||
if (btnSettings) btnSettings.addEventListener('click', () => {
|
||
const bgmEnabled = loadWorldBGMSetting();
|
||
openAppModal('⚙️ 设置', `
|
||
<div class="settingRow"><span>背景音乐</span><span class="toggleSwitch${bgmEnabled ? ' active' : ''}" id="bgmToggle"></span></div>
|
||
<div class="settingRow"><span>音效</span><span class="toggleSwitch active" id="sfxToggle"></span></div>
|
||
<div class="settingRow"><span>通知推送</span><span class="toggleSwitch active" id="notifyToggle"></span></div>
|
||
<div class="settingRow"><span>画质</span><span style="color:#e0e0e0;">高</span></div>
|
||
<div class="settingRow"><span>语言</span><span style="color:#e0e0e0;">简体中文</span></div>
|
||
<div style="height:1px;background:rgba(255,255,255,.08);margin:12px 0;"></div>
|
||
<div style="font-size:13px;color:#8899aa;margin-bottom:6px;">兑换码</div>
|
||
<div style="display:flex;gap:8px;">
|
||
<input id="redeemInput" placeholder="输入兑换码" style="flex:1;background:rgba(8,12,26,.6);border:1px solid rgba(139,92,246,.4);color:#e0e0e0;padding:8px 10px;border-radius:8px;font-size:13px;outline:none;">
|
||
<button id="redeemBtn" style="background:linear-gradient(135deg,#8b5cf6,#4aafff);border:none;color:#fff;padding:8px 14px;border-radius:8px;font-size:13px;cursor:pointer;font-weight:600;">兑换</button>
|
||
</div>
|
||
<div id="redeemMsg" style="font-size:12px;color:#ffcf86;margin-top:6px;min-height:16px;"></div>
|
||
<div style="height:1px;background:rgba(255,255,255,.08);margin:12px 0;"></div>
|
||
<button id="clearCacheBtn" style="width:100%;padding:8px;background:rgba(255,80,80,.15);border:1px solid rgba(255,80,80,.3);color:#f88;border-radius:8px;cursor:pointer;font-size:13px;">清除本地缓存</button>
|
||
<div style="height:1px;background:rgba(255,255,255,.08);margin:12px 0;"></div>
|
||
<button id="uaWebsiteBtn" style="width:100%;padding:8px;background:rgba(139,92,246,.15);border:1px solid rgba(139,92,246,.35);color:#cbb4ff;border-radius:8px;cursor:pointer;font-size:13px;">🌐 官网 / 社区</button>
|
||
`);
|
||
const bgmToggle = document.getElementById('bgmToggle');
|
||
if (bgmToggle) bgmToggle.onclick = () => {
|
||
const now = bgmToggle.classList.toggle('active');
|
||
saveWorldBGMSetting(now);
|
||
const audio = document.getElementById('bgmAudio');
|
||
if (now && audio) {
|
||
// 开启时先确保音频已加载,再尝试播放
|
||
audio.load();
|
||
const p = audio.play();
|
||
if (p && typeof p.catch === 'function') {
|
||
p.catch(err => {
|
||
console.warn('BGM 开关播放失败:', err.name || err);
|
||
toast('音频加载中,请稍后再试');
|
||
});
|
||
}
|
||
} else if (audio) {
|
||
audio.pause();
|
||
}
|
||
};
|
||
const redeemBtn = document.getElementById('redeemBtn');
|
||
const redeemInput = document.getElementById('redeemInput');
|
||
const redeemMsg = document.getElementById('redeemMsg');
|
||
if (redeemBtn) redeemBtn.onclick = () => {
|
||
const code = redeemInput && redeemInput.value.trim();
|
||
if (!code) { if (redeemMsg) redeemMsg.textContent = '请输入兑换码'; return; }
|
||
if (redeemMsg) redeemMsg.textContent = '「' + code + '」兑换码无效或已使用';
|
||
};
|
||
const clearCacheBtn = document.getElementById('clearCacheBtn');
|
||
if (clearCacheBtn) clearCacheBtn.onclick = () => {
|
||
try { localStorage.clear(); if (redeemMsg) redeemMsg.textContent = '本地缓存已清除(下次刷新生效)'; } catch (e) {}
|
||
};
|
||
const uaWebsiteBtn = document.getElementById('uaWebsiteBtn');
|
||
if (uaWebsiteBtn) uaWebsiteBtn.onclick = () => { location.href = 'index.html'; };
|
||
});
|
||
|
||
// 邮件(从 API.mails() 动态取值)
|
||
const btnMail = document.getElementById('btnMail');
|
||
if (btnMail) btnMail.addEventListener('click', async () => {
|
||
openAppModal('✉️ 邮件', '<div style="text-align:center;padding:20px;color:#8899aa;font-size:13px;">加载中…</div>');
|
||
let mails = [];
|
||
try {
|
||
const resp = await API.mails();
|
||
mails = (resp && resp.items) || [];
|
||
} catch (e) {
|
||
if (appModalBody) appModalBody.innerHTML = '<div class="mailEmpty">邮件加载失败</div>';
|
||
return;
|
||
}
|
||
const cats = ['全部', ...new Set(mails.map(m => m.category_label).filter(Boolean))];
|
||
function renderMail(cat) {
|
||
const list = cat === '全部' ? mails : mails.filter(m => m.category_label === cat);
|
||
const tabs = cats.map(c => `<span class="mailTab${c === cat ? ' active' : ''}" data-cat="${c}">${c}</span>`).join('');
|
||
const items = list.length
|
||
? list.map(m => `<div class="mailItem" data-id="${m.id}" style="cursor:pointer;${!m.is_read ? 'border-left:3px solid #8b5cf6;' : ''}">
|
||
<div class="mailTitle">${!m.is_read ? '● ' : ''}${m.subject || '(无标题)'}</div>
|
||
<div class="mailMeta">${m.category_label || ''} · ${m.date || ''}${m.sender ? ' · ' + m.sender : ''}</div>
|
||
<div>${m.body || ''}</div>
|
||
</div>`).join('')
|
||
: '<div class="mailEmpty">暂无邮件</div>';
|
||
if (appModalBody) appModalBody.innerHTML = `<div class="mailTabs">${tabs}</div>${items}`;
|
||
appModalBody.querySelectorAll('.mailTab').forEach(t => { t.onclick = () => renderMail(t.dataset.cat); });
|
||
appModalBody.querySelectorAll('.mailItem').forEach(item => {
|
||
item.onclick = async () => {
|
||
const id = parseInt(item.dataset.id);
|
||
if (!id) return;
|
||
try { await API.mailRead(id); } catch (e) {}
|
||
const m = mails.find(x => x.id === id);
|
||
if (m) m.is_read = true;
|
||
const badge = document.getElementById('topMailBadge');
|
||
const unread = mails.filter(x => !x.is_read).length;
|
||
if (badge) { badge.textContent = unread; badge.style.display = unread > 0 ? '' : 'none'; }
|
||
const activeTab = appModalBody.querySelector('.mailTab.active');
|
||
renderMail(activeTab ? activeTab.dataset.cat : '全部');
|
||
};
|
||
});
|
||
}
|
||
renderMail('全部');
|
||
});
|
||
|
||
// ===== 成就(重构为可复用函数,供底部快捷栏「成就」按钮调用)=====
|
||
function updateAchBadge(items) {
|
||
const badge = document.getElementById('achBadge');
|
||
if (!badge) return;
|
||
let newCount = 0;
|
||
try {
|
||
const seen = JSON.parse(localStorage.getItem('yt_seen_ach') || '[]');
|
||
newCount = (items || []).filter(a => a.unlocked && !(seen.includes(a.id || a.name))).length;
|
||
} catch (e) {}
|
||
if (newCount > 0) { badge.textContent = newCount > 99 ? '99+' : String(newCount); badge.hidden = false; }
|
||
else { badge.hidden = true; }
|
||
}
|
||
window.openAchievements = async function() {
|
||
openAppModal('🏆 成就', '<div style="text-align:center;padding:20px;color:#8899aa;font-size:13px;">加载中…</div>');
|
||
let ach = { items: [], total_points: 0, unlocked_count: 0, total_count: 0 };
|
||
try {
|
||
ach = await API.achievements();
|
||
} catch (e) {
|
||
if (appModalBody) appModalBody.innerHTML = '<div style="text-align:center;padding:20px;color:#8899aa;">成就加载失败</div>';
|
||
return;
|
||
}
|
||
const items = ach.items || [];
|
||
const html = `
|
||
<div style="display:flex;justify-content:space-between;margin-bottom:14px;">
|
||
<div><div style="font-size:22px;font-weight:700;color:#e0e0e0;">${ach.total_points || 0}</div><div style="font-size:11px;color:#8899aa;">成就点数</div></div>
|
||
<div style="text-align:right;"><div style="font-size:22px;font-weight:700;color:#e0e0e0;">${ach.unlocked_count || 0} / ${ach.total_count || 0}</div><div style="font-size:11px;color:#8899aa;">已解锁</div></div>
|
||
</div>
|
||
${items.length ? items.map(a => {
|
||
const pct = a.target > 0 ? Math.min(100, (a.progress || 0) / a.target * 100) : (a.unlocked ? 100 : 0);
|
||
const icon = a.unlocked ? (a.icon || '🏆') : '🔒';
|
||
return `<div class="achItem${a.unlocked ? ' done' : ''}">
|
||
<div class="achIcon${a.unlocked ? '' : ' locked'}">${icon}</div>
|
||
<div class="achMain">
|
||
<div class="achName">${a.name}</div>
|
||
<div class="achDesc">${a.desc || ''}${a.tier_label ? ' [' + a.tier_label + ']' : ''} · ${a.points}点</div>
|
||
${a.unlocked ? '' : `<div class="achBar"><div class="achFill" style="width:${pct}%"></div></div>`}
|
||
</div>
|
||
<div class="achState">${a.unlocked ? '已达成' : Math.round(pct) + '%'}</div>
|
||
</div>`;
|
||
}).join('') : '<div style="text-align:center;padding:20px;color:#8899aa;font-size:13px;">暂无成就数据</div>'}
|
||
`;
|
||
if (appModalBody) appModalBody.innerHTML = html;
|
||
// 标记已读,清除红点
|
||
try {
|
||
const seen = JSON.parse(localStorage.getItem('yt_seen_ach') || '[]');
|
||
items.filter(a => a.unlocked).forEach(a => { const k = a.id || a.name; if (!seen.includes(k)) seen.push(k); });
|
||
localStorage.setItem('yt_seen_ach', JSON.stringify(seen));
|
||
} catch (e) {}
|
||
updateAchBadge(items);
|
||
};
|
||
window.refreshAchBadge = async function() {
|
||
try { const ach = await API.achievements(); updateAchBadge(ach.items || []); } catch (e) {}
|
||
};
|
||
const btnAch = document.getElementById('btnAchievements');
|
||
if (btnAch) btnAch.addEventListener('click', () => { window.openAchievements(); });
|
||
window.refreshAchBadge();
|
||
} catch (e) {
|
||
location.replace('login.html?next=world.html'); return;
|
||
} finally {
|
||
if (splash) splash.remove();
|
||
}
|
||
})();
|
||
</script>
|
||
<script src="assets/world-app.js?v=20260825"></script>
|
||
|
||
<!-- ===== 世界聊天框(左下角,可折叠展开,demo) ===== -->
|
||
<style>
|
||
#worldChat{position:fixed;left:18px;bottom:90px;z-index:25;
|
||
width:340px;max-height:620px;display:flex;flex-direction:column;
|
||
border-radius:16px;overflow:hidden;
|
||
background:rgba(10,16,30,.72);backdrop-filter:blur(14px);
|
||
border:1px solid rgba(120,200,255,.20);box-shadow:0 8px 32px rgba(0,0,0,.4);
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13.5px;color:#d8e2f0;
|
||
transition:max-height .3s ease,width .3s ease,opacity .3s ease,transform .3s ease;}
|
||
/* 折叠态:变成悬浮气泡(💬+未读数) */
|
||
#worldChat.collapsed{width:auto;height:auto;max-height:none;padding:0;
|
||
border-radius:50%;border-color:rgba(120,200,255,.28);background:rgba(10,16,30,.82);
|
||
box-shadow:0 6px 24px rgba(0,0,0,.5),0 0 0 2px rgba(100,160,255,.15);}
|
||
#worldChat.collapsed #wcPanel{display:none;}
|
||
/* 气泡:圆形按钮 + 未读角标 */
|
||
#wcBubble{display:none;position:relative;width:52px;height:52px;border-radius:50%;
|
||
align-items:center;justify-content:center;cursor:pointer;user-select:none;
|
||
font-size:22px;line-height:1;color:#9cf;transition:transform .22s ease,box-shadow .18s;}
|
||
#worldChat.collapsed #wcBubble{display:flex;}
|
||
#wcBubble:hover{transform:scale(1.1);box-shadow:0 0 16px rgba(139,92,246,.35);}
|
||
#wcBadge{position:absolute;top:-2px;right:-4px;min-width:18px;height:18px;border-radius:9px;
|
||
background:linear-gradient(135deg,#e74c3c,#c0392b);color:#fff;font-size:10px;font-weight:700;
|
||
display:flex;align-items:center;justify-content:center;padding:0 5px;line-height:1;
|
||
border:2px solid rgba(10,16,30,.85);pointer-events:none;opacity:0;transition:opacity .2s;}
|
||
#wcBadge.show{opacity:1;}
|
||
/* @提及高亮与自动补全下拉 */
|
||
.wc-mention{color:#ffd479;font-weight:600;}
|
||
.wc-mention-box{position:absolute;bottom:46px;left:10px;right:10px;max-height:160px;overflow-y:auto;
|
||
background:rgba(8,14,28,.96);border:1px solid rgba(139,92,246,.4);border-radius:10px;
|
||
box-shadow:0 8px 24px rgba(0,0,0,.5);padding:4px;z-index:30;}
|
||
.wc-mention-item{padding:6px 10px;border-radius:6px;cursor:pointer;font-size:13px;color:#d8e2f0;}
|
||
.wc-mention-item:hover,.wc-mention-item.active{background:rgba(139,92,246,.28);color:#fff;}
|
||
/* 表情按钮与选择器 */
|
||
.wc-emoji-btn{flex-shrink:0;width:36px;height:36px;border-radius:10px;border:1px solid rgba(120,200,255,.22);
|
||
background:rgba(8,14,26,.55);color:#cdd8e6;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;
|
||
transition:border-color .18s,background .18s;}
|
||
.wc-emoji-btn:hover{background:rgba(139,92,246,.22);border-color:rgba(139,92,246,.4);}
|
||
.wc-emoji-btn.active{background:rgba(139,92,246,.32);border-color:rgba(139,92,246,.5);}
|
||
.wc-emoji-box{position:absolute;bottom:46px;left:10px;right:10px;overflow:hidden;scrollbar-width:none;
|
||
display:grid;grid-template-columns:repeat(auto-fill,minmax(30px,1fr));gap:2px;padding:6px;
|
||
background:rgba(8,14,28,.97);border:1px solid rgba(139,92,246,.4);border-radius:10px;
|
||
box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:30;}
|
||
.wc-emoji-box::-webkit-scrollbar{display:none;width:0;height:0;}
|
||
.wc-emoji-item{aspect-ratio:1;display:flex;align-items:center;justify-content:center;
|
||
border-radius:6px;cursor:pointer;font-size:15px;background:rgba(255,255,255,.04);
|
||
border:1px solid rgba(139,92,246,.18);transition:background .14s,border-color .14s;}
|
||
.wc-emoji-item:hover{background:rgba(139,92,246,.30);border-color:rgba(139,92,246,.5);}
|
||
/* 底部快捷栏小红点(数字) */
|
||
.bb-badge{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;margin-left:4px;
|
||
padding:0 4px;border-radius:9px;background:linear-gradient(135deg,#e74c3c,#c0392b);color:#fff;
|
||
font-size:10px;font-weight:700;line-height:1;vertical-align:middle;}
|
||
.bb-badge[hidden]{display:none;}
|
||
|
||
#wcPanel{display:flex;flex-direction:column;max-height:600px;transition:none;}
|
||
#wcHeader{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;
|
||
background:rgba(12,20,38,.55);cursor:pointer;user-select:none;border-bottom:1px solid rgba(255,255,255,.08);
|
||
flex-shrink:0;}
|
||
#wcHeader .wc-title{font-weight:600;font-size:14px;color:#9cf;letter-spacing:.3px;}
|
||
#wcHeader .wc-toggle{width:28px;height:28px;display:flex;align-items:center;justify-content:center;
|
||
border-radius:6px;background:rgba(255,255,255,.06);color:#8aa0bd;font-size:20px;font-weight:300;cursor:pointer;
|
||
letter-spacing:2px;transition:background .18s,color .18s;}
|
||
#wcHeader .wc-toggle:hover{background:rgba(139,92,246,.2);color:#c8b6ff;}
|
||
#wcBody{flex:none;height:300px;max-height:300px;overflow-y:auto;padding:10px 14px;display:flex;flex-direction:column;gap:8px;}
|
||
#worldChat.collapsed #wcBody{max-height:150px;padding:7px 11px;gap:5px;}
|
||
.wc-msg{display:flex;gap:8px;align-items:flex-start;max-width:92%;align-self:flex-start;}
|
||
.wc-msg.wc-self{flex-direction:row-reverse;align-self:flex-end;}
|
||
.wc-avatar{width:30px;height:30px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;
|
||
font-size:13px;font-weight:600;color:#fff;background:linear-gradient(135deg,#6b5ce7,#4a90d9);overflow:hidden;user-select:none;}
|
||
.wc-avatar img{width:100%;height:100%;object-fit:cover;}
|
||
.wc-content{display:flex;flex-direction:column;max-width:240px;min-width:0;}
|
||
.wc-name{font-size:11px;color:#8aa0bd;margin:0 4px 4px;line-height:1;font-weight:500;}
|
||
.wc-msg.wc-self .wc-name{text-align:right;}
|
||
.wc-bubble{padding:8px 12px;border-radius:12px;line-height:1.5;font-size:13.5px;word-break:break-word;
|
||
width:fit-content;max-width:100%;background:rgba(60,80,110,.35);border:1px solid rgba(100,180,255,.14);}
|
||
.wc-msg.wc-self .wc-bubble{background:rgba(60,140,130,.40);border-color:rgba(80,220,180,.18);align-self:flex-end;}
|
||
.wc-time{font-size:10.5px;color:#788aa4;margin:3px 4px 0;line-height:1;}
|
||
.wc-msg.wc-self .wc-time{text-align:right;}
|
||
.wcInputArea{position:relative;padding:8px 14px 12px;border-top:1px solid rgba(255,255,255,.08);flex-shrink:0;
|
||
display:flex;gap:7px;align-items:flex-end;}
|
||
#worldChat.collapsed .wcInputArea{padding:6px 11px 8px;border-top:none;}
|
||
#wcInput{flex:1;min-height:34px;max-height:80px;padding:7px 11px;border-radius:10px;overflow:auto;scrollbar-width:none;
|
||
resize:none;border:1px solid rgba(120,200,255,.22);background:rgba(8,14,26,.55);color:#d8e2f0;
|
||
font-family:inherit;font-size:13px;line-height:1.45;outline:none;
|
||
transition:border-color .18s ease,box-shadow .18s ease;}
|
||
#wcInput::-webkit-scrollbar{display:none;width:0;height:0;}
|
||
#wcInput:focus{border-color:rgba(139,92,246,.55);box-shadow:0 0 0 1px rgba(139,92,246,.18);}
|
||
#wcSend{flex-shrink:0;height:36px;padding:0 16px;border-radius:10px;border:none;
|
||
background:linear-gradient(135deg,#6b5ce7,#4a90d9);color:#fff;font-size:13px;font-weight:600;cursor:pointer;
|
||
display:flex;align-items:center;justify-content:center;white-space:nowrap;letter-spacing:1px;
|
||
transition:opacity .18s,transform .15s;}
|
||
#wcSend:hover{opacity:.88;transform:scale(1.03);}
|
||
#wcSend:active{transform:scale(.96);}
|
||
/* 滚动条美化 */
|
||
#wcBody::-webkit-scrollbar{width:4px;}
|
||
#wcBody::-webkit-scrollbar-thumb{background:rgba(120,200,255,.22);border-radius:4px;}
|
||
/* 频道下拉选择器 */
|
||
.wc-channel-select{appearance:none;-webkit-appearance:none;flex:0 0 auto;min-width:0;max-width:120px;
|
||
background:rgba(8,14,26,.6);color:#9cf;border:1px solid rgba(120,200,255,.25);
|
||
border-radius:8px;padding:5px 26px 5px 10px;font-size:13px;font-weight:600;cursor:pointer;
|
||
outline:none;font-family:inherit;letter-spacing:.3px;
|
||
background-image:linear-gradient(45deg,transparent 50%,#9cf 50%),linear-gradient(135deg,#9cf 50%,transparent 50%);
|
||
background-position:calc(100% - 16px) 52%,calc(100% - 11px) 52%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;
|
||
transition:border-color .18s ease,box-shadow .18s ease;}
|
||
.wc-channel-select:focus{border-color:rgba(139,92,246,.55);box-shadow:0 0 0 1px rgba(139,92,246,.18);}
|
||
.wc-channel-select option{background:#0e1626;color:#d8e2f0;}
|
||
.wc-test-btn{
|
||
flex:0 0 auto; background:rgba(139,92,246,.18); border:1px solid rgba(139,92,246,.4);
|
||
color:#cbb4ff; border-radius:8px; padding:5px 12px; font-size:12px; font-weight:700;
|
||
cursor:pointer; transition:.15s;
|
||
}
|
||
.wc-test-btn:hover{background:rgba(139,92,246,.32); color:#fff;}
|
||
/* AI 游戏助手消息:紫色区分,与世界频道玩家消息隔离 */
|
||
.wc-msg.guide .wc-avatar{background:linear-gradient(135deg,#a78bfa,#7c3aed);}
|
||
.wc-msg.guide .wc-name{color:#c4b5fd;}
|
||
.wc-msg.guide .wc-bubble{background:linear-gradient(135deg,#2a2150,#3b2f63);border:1px solid #4c3a82;color:#ede9fe;}
|
||
</style>
|
||
<div id="worldChat">
|
||
<!-- 折叠态悬浮气泡 -->
|
||
<div id="wcBubble" onclick="toggleWorldChat()" title="展开聊天">
|
||
💬
|
||
<span class="wc-badge" id="wcBadge"></span>
|
||
</div>
|
||
<!-- 展开态面板 -->
|
||
<div id="wcPanel">
|
||
<div id="wcHeader">
|
||
<select id="wcChannelSel" class="wc-channel-select"></select>
|
||
<button id="wcTestBtn" class="wc-test-btn" type="button" onclick="wcSendTest()">测试</button>
|
||
<span class="wc-toggle" onclick="toggleWorldChat()">—</span>
|
||
</div>
|
||
<div id="wcBody">
|
||
<!-- demo messages injected by JS -->
|
||
</div>
|
||
<div class="wcInputArea">
|
||
<div id="wcMention" class="wc-mention-box" style="display:none;"></div>
|
||
<div id="wcEmoji" class="wc-emoji-box" style="display:none;"></div>
|
||
<button id="wcEmojiBtn" class="wc-emoji-btn" type="button" title="表情" aria-label="表情">😊</button>
|
||
<textarea id="wcInput" placeholder="" rows="1"></textarea>
|
||
<button id="wcSend" onclick="wcSendMsg()">发送</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
(function(){
|
||
const chat = document.getElementById('worldChat');
|
||
const body = document.getElementById('wcBody');
|
||
const input = document.getElementById('wcInput');
|
||
const sel = document.getElementById('wcChannelSel');
|
||
if (input) input.removeAttribute('onkeydown'); // 键盘逻辑统一由下方 addEventListener 处理
|
||
|
||
let channels = [];
|
||
let currentChannel = null;
|
||
const lastIdByChannel = {};
|
||
const unreadByChannel = {};
|
||
const renderedIds = new Set();
|
||
let pollTimer = null;
|
||
const badge = document.getElementById('wcBadge');
|
||
const mentionBox = document.getElementById('wcMention');
|
||
|
||
function totalUnread(){ let s = 0; for (const k in unreadByChannel) s += (unreadByChannel[k] || 0); return s; }
|
||
function updateBadge(){
|
||
const t = totalUnread();
|
||
badge.textContent = t > 99 ? '99+' : (t || '');
|
||
badge.className = 'wc-badge' + (t > 0 ? ' show' : '');
|
||
}
|
||
function updateChannelOptions(){
|
||
for (const opt of sel.options){
|
||
const id = opt.value;
|
||
const base = opt.dataset.name || opt.textContent.replace(/\s*\(\d+\)$/, '');
|
||
opt.dataset.name = base;
|
||
const n = unreadByChannel[id] || 0;
|
||
opt.textContent = n > 0 ? base + ' (' + n + ')' : base;
|
||
}
|
||
}
|
||
function shortTime(s){ if(!s) return ''; const m = String(s).match(/(\d{1,2}):(\d{2})/); return m ? m[0] : ''; }
|
||
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||
function highlightMentions(text){
|
||
return escapeHtml(text).replace(/@([^\s@]+)/g, '<span class="wc-mention">@$1</span>');
|
||
}
|
||
function renderMsg(m){
|
||
if(renderedIds.has(m.id)) return null;
|
||
renderedIds.add(m.id);
|
||
const d = document.createElement('div');
|
||
d.className = 'wc-msg' + (m.is_self ? ' wc-self' : '') + (m.is_guide ? ' guide' : '');
|
||
const av = document.createElement('div'); av.className = 'wc-avatar'; av.style.cursor='pointer';
|
||
if(m.is_guide){ av.textContent = '🤖'; }
|
||
else { av.textContent = (m.author_name || '?').slice(0, 1); }
|
||
av.addEventListener('click', () => showDemoProfile(m.author_name, m.is_guide));
|
||
const content = document.createElement('div'); content.className = 'wc-content';
|
||
const name = document.createElement('div'); name.className = 'wc-name';
|
||
name.textContent = m.author_name || (m.is_self ? '我' : '匿名');
|
||
const bubble = document.createElement('div'); bubble.className = 'wc-bubble'; bubble.innerHTML = highlightMentions(m.body || '');
|
||
const time = document.createElement('div'); time.className = 'wc-time'; time.textContent = shortTime(m.create_date);
|
||
content.appendChild(name); content.appendChild(bubble); content.appendChild(time);
|
||
d.appendChild(av); d.appendChild(content);
|
||
return d;
|
||
}
|
||
function appendMsg(m){
|
||
const el = renderMsg(m);
|
||
if(el) body.appendChild(el);
|
||
if(m.id > (lastIdByChannel[currentChannel] || 0)) lastIdByChannel[currentChannel] = m.id;
|
||
body.scrollTop = body.scrollHeight;
|
||
}
|
||
function appendLocal(name, text){
|
||
const d = document.createElement('div'); d.className = 'wc-msg';
|
||
const av = document.createElement('div'); av.className = 'wc-avatar'; av.textContent = '⚠'; av.style.cursor='pointer';
|
||
av.addEventListener('click', () => showDemoProfile('系统', true));
|
||
const content = document.createElement('div'); content.className = 'wc-content';
|
||
const nm = document.createElement('div'); nm.className = 'wc-name'; nm.textContent = name;
|
||
const bb = document.createElement('div'); bb.className = 'wc-bubble'; bb.textContent = text;
|
||
content.appendChild(nm); content.appendChild(bb); d.appendChild(av); d.appendChild(content);
|
||
body.appendChild(d); body.scrollTop = body.scrollHeight;
|
||
}
|
||
async function apiJSON(url, opts){
|
||
const r = await fetch(url, opts);
|
||
try { return await r.json(); } catch(e){ return {}; }
|
||
}
|
||
async function loadChannels(){
|
||
try{
|
||
const data = await apiJSON('/game/api/chat/channels');
|
||
channels = (data.items || []);
|
||
sel.innerHTML = '';
|
||
channels.forEach(c => {
|
||
const o = document.createElement('option');
|
||
o.value = c.id; o.textContent = c.name; o.dataset.name = c.name; sel.appendChild(o);
|
||
});
|
||
const def = channels.find(c => c.type === 'world') || channels[0];
|
||
if(def){ sel.value = def.id; currentChannel = def.id; }
|
||
await loadMessages(true);
|
||
startPoll();
|
||
updateChannelOptions(); updateBadge();
|
||
}catch(e){ console.warn('加载频道失败', e); }
|
||
}
|
||
async function loadMessages(reset){
|
||
if(!currentChannel) return;
|
||
let url = '/game/api/chat/messages?channel_id=' + encodeURIComponent(currentChannel) + '&limit=50';
|
||
if(!reset && lastIdByChannel[currentChannel]) url += '&after_id=' + lastIdByChannel[currentChannel];
|
||
try{
|
||
const data = await apiJSON(url);
|
||
const items = data.items || [];
|
||
if(reset){ body.innerHTML = ''; renderedIds.clear(); lastIdByChannel[currentChannel] = 0; }
|
||
items.forEach(m => {
|
||
const isNew = m.id > (lastIdByChannel[currentChannel] || 0);
|
||
appendMsg(m);
|
||
if(isNew && !m.is_self && chat.classList.contains('collapsed')){
|
||
unreadByChannel[currentChannel] = (unreadByChannel[currentChannel] || 0) + 1;
|
||
}
|
||
});
|
||
updateChannelOptions(); updateBadge();
|
||
}catch(e){ console.warn('加载消息失败', e); }
|
||
}
|
||
// 扫描未查看频道累计未读(首扫仅记录 lastId,不计未读)
|
||
async function scanOtherChannels(){
|
||
for(const c of channels){
|
||
if(c.id === currentChannel) continue;
|
||
try{
|
||
const url = '/game/api/chat/messages?channel_id=' + encodeURIComponent(c.id) + '&limit=50' + (lastIdByChannel[c.id] ? '&after_id=' + lastIdByChannel[c.id] : '');
|
||
const data = await apiJSON(url);
|
||
(data.items || []).forEach(m => {
|
||
if(m.id > (lastIdByChannel[c.id] || 0)) lastIdByChannel[c.id] = m.id;
|
||
if(lastIdByChannel[c.id] !== undefined && !m.is_self) unreadByChannel[c.id] = (unreadByChannel[c.id] || 0) + 1;
|
||
});
|
||
}catch(e){}
|
||
}
|
||
updateChannelOptions(); updateBadge();
|
||
}
|
||
function startPoll(){
|
||
if(pollTimer) clearInterval(pollTimer);
|
||
pollTimer = setInterval(() => { loadMessages(false); scanOtherChannels(); }, 2500);
|
||
}
|
||
sel.addEventListener('change', () => {
|
||
currentChannel = sel.value;
|
||
unreadByChannel[currentChannel] = 0;
|
||
loadMessages(true);
|
||
updateChannelOptions(); updateBadge();
|
||
});
|
||
window.toggleWorldChat = function(){
|
||
const wasCollapsed = chat.classList.contains('collapsed');
|
||
chat.classList.toggle('collapsed');
|
||
if(wasCollapsed){ unreadByChannel[currentChannel] = 0; updateChannelOptions(); updateBadge(); }
|
||
};
|
||
window.wcSendMsg = async function(){
|
||
const t = input.value.trim();
|
||
if(!t || !currentChannel) return;
|
||
input.value = ''; input.style.height = 'auto';
|
||
hideMention();
|
||
try{
|
||
const data = await apiJSON('/game/api/chat/send', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ channel_id: currentChannel, body: t })
|
||
});
|
||
if(data.ok && data.message){ appendMsg(data.message); }
|
||
else if(data.error){ appendLocal('系统', data.error); }
|
||
}catch(e){ console.warn('发送失败', e); }
|
||
};
|
||
window.wcSendTest = async function(){
|
||
if(!currentChannel){ appendLocal('系统', '请先选择一个聊天频道'); return; }
|
||
// 模拟“别的角色”发给我一条消息(收消息样式,is_self=false)
|
||
const senders = ['测试精灵', '星野', '阿土', '老炮', '系统巡检'];
|
||
const name = senders[Math.floor(Math.random()*senders.length)];
|
||
const samples = ['在吗?这是一条测试消息 🧪', '系统自检通过,频道连接正常 ✅', '给你发了个测试,收到回复一下~', '测试一下新版本聊天功能 💬', '世界频道已连通,消息收发正常。'];
|
||
const text = samples[Math.floor(Math.random()*samples.length)];
|
||
appendMsg({
|
||
id: Date.now(),
|
||
channel_id: currentChannel,
|
||
author_name: name,
|
||
body: text,
|
||
is_self: false,
|
||
is_guide: false,
|
||
create_date: new Date().toISOString()
|
||
});
|
||
};
|
||
function showDemoProfile(name, isGuide){
|
||
const letter = (name || '?').slice(0,1).toUpperCase();
|
||
const title = isGuide ? '🤖 AI 助手' : '👤 ' + (name || '玩家');
|
||
const uid = 10000000 + Math.floor(Math.random()*900000);
|
||
const level = Math.floor(Math.random()*50)+1;
|
||
const html = `
|
||
<div style="text-align:center;padding:10px 0;">
|
||
<div style="width:64px;height:64px;border-radius:50%;margin:0 auto 12px;display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:700;color:#fff;background:linear-gradient(135deg,#8b5cf6,#4af);box-shadow:0 0 18px rgba(139,92,246,.5);">${isGuide ? '🤖' : letter}</div>
|
||
<div style="font-size:16px;font-weight:700;color:#e6f0fb;margin-bottom:4px;">${name || '匿名玩家'}</div>
|
||
<div style="font-size:12px;color:#8899aa;margin-bottom:14px;">UID: ${uid} · ${isGuide ? 'AI 向导' : 'Lv.' + level}</div>
|
||
</div>
|
||
<div class="infoRow"><span class="infoLabel">称号</span><span class="infoValue">${isGuide ? '系统向导' : '探索者'}</span></div>
|
||
<div class="infoRow"><span class="infoLabel">世界</span><span class="infoValue">我的世界</span></div>
|
||
<div class="infoRow"><span class="infoLabel">加入</span><span class="infoValue">星元 1年 春</span></div>
|
||
${isGuide ? '' : '<div style="margin-top:12px;display:flex;gap:8px;"><button onclick="window.openAppModal(\'👥 加好友\', \'<div style=\\\'text-align:center;padding:24px;color:#8899aa;font-size:13px;\\\'>好友申请已发送(Demo)</div>\')" style="flex:1;padding:8px;background:rgba(139,92,246,.18);border:1px solid rgba(139,92,246,.4);color:#cbb4ff;border-radius:8px;cursor:pointer;font-size:13px;">加好友</button><button onclick="window.ChatAPI && window.ChatAPI.open(\'pm:' + uid + '\')" style="flex:1;padding:8px;background:rgba(74,175,255,.18);border:1px solid rgba(74,175,255,.4);color:#9cf;border-radius:8px;cursor:pointer;font-size:13px;">私聊</button></div>'}
|
||
`;
|
||
if(window.openAppModal) window.openAppModal(title, html);
|
||
}
|
||
// ===== @提及自动补全 =====
|
||
let mentionList = [], mentionIndex = -1, mentionStart = -1;
|
||
async function loadMentionCandidates(){
|
||
try{
|
||
const me = await apiJSON('/game/api/social/me');
|
||
const fr = await apiJSON('/game/api/social/friends');
|
||
const list = [];
|
||
if(me && me.user) list.push(me.user);
|
||
(fr.items || []).forEach(f => list.push({ uid: f.uid, name: f.name }));
|
||
mentionList = list;
|
||
}catch(e){ mentionList = []; }
|
||
}
|
||
function currentMentionFilter(){
|
||
const pos = input.selectionStart;
|
||
const m = input.value.slice(0, pos).match(/@([^\s@]*)$/);
|
||
return m ? { filter: m[1], start: pos - m[0].length } : null;
|
||
}
|
||
function showMention(filter){
|
||
if(!mentionBox) return;
|
||
if(emojiBox && emojiBox.style.display === 'block') toggleEmoji(false); // 避免与表情面板重叠
|
||
const matches = mentionList.filter(u => u.name && u.name.indexOf(filter) >= 0);
|
||
if(!matches.length){ hideMention(); return; }
|
||
if(mentionIndex >= matches.length) mentionIndex = 0;
|
||
mentionBox.innerHTML = matches.map((u, i) =>
|
||
`<div class="wc-mention-item${i === mentionIndex ? ' active' : ''}" data-name="${escapeHtml(u.name)}">@${escapeHtml(u.name)}</div>`).join('');
|
||
mentionBox.style.display = 'block';
|
||
mentionBox.querySelectorAll('.wc-mention-item').forEach(el => { el.onclick = () => insertMention(el.dataset.name); });
|
||
}
|
||
function hideMention(){ if(mentionBox){ mentionBox.style.display = 'none'; mentionIndex = -1; } }
|
||
function insertMention(name){
|
||
const pos = input.selectionStart;
|
||
const before = input.value.slice(0, mentionStart);
|
||
const after = input.value.slice(pos);
|
||
input.value = before + '@' + name + ' ' + after;
|
||
hideMention();
|
||
input.focus();
|
||
}
|
||
if(input){
|
||
input.addEventListener('input', () => {
|
||
const mm = currentMentionFilter();
|
||
if(mm){ mentionStart = mm.start; showMention(mm.filter); }
|
||
else hideMention();
|
||
});
|
||
input.addEventListener('keydown', (e) => {
|
||
if(emojiBox && emojiBox.style.display === 'block' && e.key === 'Escape'){ e.preventDefault(); toggleEmoji(false); return; }
|
||
if(mentionBox && mentionBox.style.display === 'block' && mentionList.length){
|
||
if(e.key === 'ArrowDown'){ e.preventDefault(); mentionIndex = (mentionIndex + 1) % mentionList.length; showMention(currentMentionFilter() ? currentMentionFilter().filter : ''); return; }
|
||
if(e.key === 'ArrowUp'){ e.preventDefault(); mentionIndex = (mentionIndex - 1 + mentionList.length) % mentionList.length; showMention(currentMentionFilter() ? currentMentionFilter().filter : ''); return; }
|
||
if(e.key === 'Tab' || e.key === 'Enter'){ e.preventDefault(); const item = mentionBox.querySelector('.wc-mention-item.active') || mentionBox.querySelector('.wc-mention-item'); if(item) insertMention(item.dataset.name); return; }
|
||
if(e.key === 'Escape'){ hideMention(); return; }
|
||
}
|
||
if(e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); wcSendMsg(); }
|
||
setTimeout(() => { input.style.height = Math.min(input.scrollHeight, 80) + 'px'; }, 0);
|
||
});
|
||
}
|
||
// 对外暴露:好友面板「私聊」调用
|
||
window.ChatAPI = {
|
||
open(channelId){
|
||
chat.classList.remove('collapsed');
|
||
if(channelId && channels.some(c => c.id === channelId)){
|
||
sel.value = channelId; currentChannel = channelId; unreadByChannel[channelId] = 0;
|
||
loadMessages(true); updateChannelOptions(); updateBadge();
|
||
}
|
||
},
|
||
refresh(){ loadChannels(); }
|
||
};
|
||
// ===== 表情选择器 =====
|
||
const emojiBtn = document.getElementById('wcEmojiBtn');
|
||
const emojiBox = document.getElementById('wcEmoji');
|
||
const EMOJIS = ['😀','😁','😂','🤣','😊','😍','😘','😎','🤔','😴','😭','😡','👍','👎','👏','🙏','💪','🔥','🌟','💡','❤️','💔','🎉','✨','🌈','☕','🍻','🐱','🐶','🌸','⚡','💰','🏆','✅','❌','📌','🚀','🌍','🌿','⭐','💬'];
|
||
function buildEmojiPicker(){
|
||
if(!emojiBox) return;
|
||
emojiBox.innerHTML = EMOJIS.map(e => `<div class="wc-emoji-item" data-emoji="${e}">${e}</div>`).join('');
|
||
emojiBox.querySelectorAll('.wc-emoji-item').forEach(el => { el.onclick = () => { insertText(el.dataset.emoji); toggleEmoji(false); }; });
|
||
}
|
||
function toggleEmoji(force){
|
||
if(!emojiBox || !emojiBtn) return;
|
||
const show = force !== undefined ? force : emojiBox.style.display !== 'block';
|
||
emojiBox.style.display = show ? 'block' : 'none';
|
||
emojiBtn.classList.toggle('active', show);
|
||
if(show) hideMention();
|
||
}
|
||
function insertText(text){
|
||
const pos = (input.selectionStart != null) ? input.selectionStart : input.value.length;
|
||
const before = input.value.slice(0, pos);
|
||
const after = input.value.slice(pos);
|
||
input.value = before + text + after;
|
||
const np = pos + text.length;
|
||
input.focus();
|
||
try { input.setSelectionRange(np, np); } catch(e){}
|
||
input.dispatchEvent(new Event('input'));
|
||
}
|
||
if(emojiBtn) emojiBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleEmoji(); });
|
||
if(emojiBox) emojiBox.addEventListener('click', (e) => { e.stopPropagation(); });
|
||
document.addEventListener('click', (e) => {
|
||
if(emojiBox && emojiBox.style.display === 'block' && !emojiBox.contains(e.target) && e.target !== emojiBtn && !emojiBtn.contains(e.target)) toggleEmoji(false);
|
||
});
|
||
buildEmojiPicker();
|
||
loadMentionCandidates();
|
||
loadChannels();
|
||
})();
|
||
</script>
|
||
<style id="friendPanelStyle">
|
||
.fpanel{position:fixed;right:18px;bottom:90px;z-index:26;width:330px;max-height:540px;display:none;flex-direction:column;
|
||
border-radius:16px;overflow:hidden;background:rgba(10,16,30,.82);backdrop-filter:blur(14px);
|
||
border:1px solid rgba(120,200,255,.2);box-shadow:0 8px 32px rgba(0,0,0,.45);
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13.5px;color:#d8e2f0;}
|
||
.fp-head{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:rgba(12,20,38,.6);border-bottom:1px solid rgba(255,255,255,.08);}
|
||
.fp-title{font-weight:700;font-size:15px;color:#cfe;}
|
||
.fp-close{background:rgba(255,255,255,.06);border:none;color:#9ab;width:28px;height:28px;border-radius:8px;cursor:pointer;font-size:14px;}
|
||
.fp-close:hover{background:rgba(239,68,68,.25);color:#fff;}
|
||
.fp-tabs{display:flex;gap:4px;padding:8px 10px;border-bottom:1px solid rgba(255,255,255,.06);}
|
||
.fp-tab{flex:1;padding:7px 4px;border:1px solid transparent;background:rgba(255,255,255,.05);color:#9ab;border-radius:8px;cursor:pointer;font-size:12.5px;position:relative;transition:.15s;white-space:nowrap;}
|
||
.fp-tab:hover{background:rgba(139,92,246,.18);color:#dcd;}
|
||
.fp-tab.active{background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;border-color:transparent;}
|
||
.fp-tab-badge{position:absolute;top:-6px;right:0;min-width:16px;height:16px;padding:0 4px;border-radius:9px;background:linear-gradient(135deg,#e74c3c,#c0392b);color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;}
|
||
.fp-tab-badge[hidden]{display:none;}
|
||
.fp-body{flex:1;overflow-y:auto;padding:10px;min-height:0;}
|
||
.fp-row{display:flex;align-items:center;gap:10px;padding:9px 8px;border-radius:10px;transition:background .15s;}
|
||
.fp-row:hover{background:rgba(255,255,255,.05);}
|
||
.fp-av{width:36px;height:36px;border-radius:50%;flex:0 0 auto;display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff;font-size:15px;background:linear-gradient(135deg,#6b5ce7,#4a90d9);}
|
||
.fp-av.off{background:linear-gradient(135deg,#555,#333);}
|
||
.fp-main{flex:1;min-width:0;}
|
||
.fp-name{font-weight:600;color:#e6edf6;font-size:13.5px;display:flex;align-items:center;gap:6px;}
|
||
.fp-dot{width:8px;height:8px;border-radius:50%;background:#3a4;box-shadow:0 0 6px #4c6;flex:0 0 auto;}
|
||
.fp-dot.off{background:#555;box-shadow:none;}
|
||
.fp-sub{font-size:11.5px;color:#8a99ad;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.fp-actions{display:flex;gap:5px;flex-shrink:0;}
|
||
.fp-act{padding:5px 8px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04);color:#bcd;border-radius:7px;cursor:pointer;font-size:11.5px;transition:.15s;white-space:nowrap;}
|
||
.fp-act:hover{background:rgba(139,92,246,.25);color:#fff;border-color:rgba(139,92,246,.4);}
|
||
.fp-act.danger:hover{background:rgba(239,68,68,.25);color:#fff;border-color:rgba(239,68,68,.4);}
|
||
.fp-empty{text-align:center;color:#7a8aa0;font-size:13px;padding:30px 10px;}
|
||
.fp-search{display:flex;gap:8px;margin-bottom:6px;}
|
||
.fp-search input{flex:1;background:rgba(8,12,26,.6);border:1px solid rgba(139,92,246,.35);color:#e0e0e0;padding:9px 11px;border-radius:9px;font-size:13px;outline:none;}
|
||
.fp-search button{background:linear-gradient(135deg,#8b5cf6,#4aafff);border:none;color:#fff;padding:0 14px;border-radius:9px;cursor:pointer;font-weight:600;font-size:13px;}
|
||
.fp-section-title{font-size:12px;color:#8a99ad;margin:10px 2px 6px;}
|
||
.fp-guild{display:flex;align-items:center;gap:10px;padding:10px 8px;border-radius:10px;background:rgba(255,255,255,.04);margin-bottom:8px;}
|
||
</style>
|
||
|
||
<!-- ===== 好友系统面板(右侧浮层) ===== -->
|
||
<div id="friendsPanel" class="fpanel">
|
||
<div class="fp-head">
|
||
<span class="fp-title">👥 好友</span>
|
||
<button class="fp-close" id="fpClose" type="button" title="收起">✕</button>
|
||
</div>
|
||
<div class="fp-tabs" id="fpTabs">
|
||
<button class="fp-tab active" data-tab="friends" type="button">好友</button>
|
||
<button class="fp-tab" data-tab="requests" type="button">申请<span class="fp-tab-badge" id="fpReqBadge" hidden>0</span></button>
|
||
<button class="fp-tab" data-tab="add" type="button">添加</button>
|
||
<button class="fp-tab" data-tab="guild" type="button">公会</button>
|
||
</div>
|
||
<div class="fp-body" id="fpBody"></div>
|
||
</div>
|
||
|
||
<script>
|
||
(function(){
|
||
const panel = document.getElementById('friendsPanel');
|
||
const fpBody = document.getElementById('fpBody');
|
||
const fpTabs = document.getElementById('fpTabs');
|
||
const btnFriends = document.getElementById('btnFriends');
|
||
let currentTab = 'friends';
|
||
|
||
function esc(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||
async function api(path, opts){ try { const r = await fetch(path, opts); return await r.json(); } catch(e){ return { ok:false }; } }
|
||
function post(path, body){ return api(path, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }); }
|
||
function setBadge(id, n){ const el = document.getElementById(id); if(!el) return; if(n>0){ el.textContent = n>99?'99+':String(n); el.hidden = false; } else { el.hidden = true; } }
|
||
function toast(msg){
|
||
let t = document.getElementById('fpToast');
|
||
if(!t){ t = document.createElement('div'); t.id='fpToast';
|
||
t.style.cssText='position:fixed;left:50%;bottom:130px;transform:translateX(-50%);background:rgba(10,16,30,.92);border:1px solid rgba(139,92,246,.4);color:#e6edf6;padding:9px 16px;border-radius:10px;font-size:13px;z-index:200;box-shadow:0 8px 24px rgba(0,0,0,.5);transition:opacity .3s;pointer-events:none;';
|
||
document.body.appendChild(t); }
|
||
t.textContent = msg; t.style.opacity = '1'; clearTimeout(t._tm); t._tm = setTimeout(()=>{ t.style.opacity='0'; }, 1800);
|
||
}
|
||
|
||
async function loadMe(){
|
||
const me = await api('/game/api/social/me');
|
||
if(me && me.ok){
|
||
setBadge('friendBadge', me.pending_requests || 0);
|
||
setBadge('fpReqBadge', me.pending_requests || 0);
|
||
}
|
||
}
|
||
async function refreshAll(){
|
||
await loadMe();
|
||
if(currentTab === 'friends') renderFriends();
|
||
else if(currentTab === 'requests') renderRequests();
|
||
else if(currentTab === 'guild') renderGuild();
|
||
if(window.ChatAPI) window.ChatAPI.refresh();
|
||
}
|
||
|
||
async function renderFriends(){
|
||
fpBody.innerHTML = '<div class="fp-empty">加载中…</div>';
|
||
const d = await api('/game/api/social/friends');
|
||
const items = (d && d.items) || [];
|
||
if(!items.length){ fpBody.innerHTML = '<div class="fp-empty">还没有好友,去「添加」找人吧</div>'; return; }
|
||
fpBody.innerHTML = '';
|
||
items.forEach(f => {
|
||
const row = document.createElement('div'); row.className = 'fp-row';
|
||
row.innerHTML = `
|
||
<div class="fp-av ${f.online?'':'off'}">${(f.name||'?').slice(0,1)}</div>
|
||
<div class="fp-main">
|
||
<div class="fp-name"><span class="fp-dot ${f.online?'':'off'}"></span>${esc(f.name)}${f.blocked?' <span style="color:#f88;font-size:11px;">已拉黑</span>':''}</div>
|
||
<div class="fp-sub">${esc(f.note||'')}${f.tags&&f.tags.length?(' · '+f.tags.map(esc).join('/')):''}</div>
|
||
</div>
|
||
<div class="fp-actions">
|
||
<button class="fp-act" data-act="chat" data-uid="${f.uid}">私聊</button>
|
||
<button class="fp-act" data-act="guild" data-uid="${f.uid}" data-name="${esc(f.name)}">公会</button>
|
||
<button class="fp-act" data-act="block" data-uid="${f.uid}" data-blocked="${f.blocked?1:0}">${f.blocked?'解除':'拉黑'}</button>
|
||
<button class="fp-act danger" data-act="del" data-uid="${f.uid}">删除</button>
|
||
</div>`;
|
||
fpBody.appendChild(row);
|
||
});
|
||
fpBody.querySelectorAll('[data-act]').forEach(b => {
|
||
b.onclick = async () => {
|
||
const uid = +b.dataset.uid; const act = b.dataset.act;
|
||
if(act === 'chat'){ if(window.ChatAPI) window.ChatAPI.open('pm:'+uid); closePanel(); }
|
||
else if(act === 'guild'){ await inviteToGuild(uid, b.dataset.name); }
|
||
else if(act === 'block'){ await post('/game/api/social/friend/block', { uid, unblock: b.dataset.blocked === '1' }); toast(b.dataset.blocked==='1'?'已解除拉黑':'已拉黑'); refreshAll(); }
|
||
else if(act === 'del'){ if(confirm('确定删除该好友?')){ await post('/game/api/social/friend/remove', { uid }); toast('已删除'); refreshAll(); } }
|
||
};
|
||
});
|
||
}
|
||
|
||
async function renderRequests(){
|
||
fpBody.innerHTML = '<div class="fp-empty">加载中…</div>';
|
||
const d = await api('/game/api/social/requests?dir=in');
|
||
const items = (d && d.items) || [];
|
||
if(!items.length){ fpBody.innerHTML = '<div class="fp-empty">暂无好友申请</div>'; return; }
|
||
fpBody.innerHTML = '';
|
||
items.forEach(r => {
|
||
const row = document.createElement('div'); row.className = 'fp-row';
|
||
row.innerHTML = `
|
||
<div class="fp-av">${(r.name||'?').slice(0,1)}</div>
|
||
<div class="fp-main">
|
||
<div class="fp-name">${esc(r.name)}</div>
|
||
<div class="fp-sub">${esc(r.message||'')}</div>
|
||
</div>
|
||
<div class="fp-actions">
|
||
<button class="fp-act" data-act="accept" data-id="${r.id}">同意</button>
|
||
<button class="fp-act danger" data-act="reject" data-id="${r.id}">拒绝</button>
|
||
<button class="fp-act" data-act="ignore" data-id="${r.id}">忽略</button>
|
||
</div>`;
|
||
fpBody.appendChild(row);
|
||
});
|
||
fpBody.querySelectorAll('[data-act]').forEach(b => {
|
||
b.onclick = async () => {
|
||
const id = +b.dataset.id; const act = b.dataset.act;
|
||
const action = act === 'accept' ? 'accept' : (act === 'reject' ? 'reject' : 'ignore');
|
||
await post('/game/api/social/friend/respond', { id, action });
|
||
toast(act === 'accept' ? '已添加好友' : '已处理');
|
||
refreshAll();
|
||
};
|
||
});
|
||
}
|
||
|
||
function renderAdd(){
|
||
fpBody.innerHTML = `
|
||
<div class="fp-search">
|
||
<input id="fpSearchInput" placeholder="输入 UID 或 昵称 搜索" />
|
||
<button id="fpSearchBtn">搜索</button>
|
||
</div>
|
||
<div id="fpSearchResult"></div>
|
||
<div class="fp-section-title">我发出的好友申请</div>
|
||
<div id="fpOutReq"></div>`;
|
||
const input = document.getElementById('fpSearchInput');
|
||
document.getElementById('fpSearchBtn').onclick = () => doSearch(input.value);
|
||
input.onkeydown = (e) => { if(e.key === 'Enter') doSearch(input.value); };
|
||
api('/game/api/social/requests?dir=out').then(d => {
|
||
const items = (d && d.items) || [];
|
||
const box = document.getElementById('fpOutReq');
|
||
if(!items.length){ box.innerHTML = '<div class="fp-empty">还没有发出申请</div>'; return; }
|
||
box.innerHTML = '';
|
||
items.forEach(r => {
|
||
const row = document.createElement('div'); row.className = 'fp-row';
|
||
row.innerHTML = `<div class="fp-av">${(r.name||'?').slice(0,1)}</div>
|
||
<div class="fp-main"><div class="fp-name">${esc(r.name)}</div><div class="fp-sub">等待对方通过…</div></div>
|
||
<div class="fp-actions"><button class="fp-act danger" data-act="cancel" data-id="${r.id}">撤销</button></div>`;
|
||
box.appendChild(row);
|
||
});
|
||
box.querySelectorAll('[data-act="cancel"]').forEach(b => { b.onclick = async () => { await post('/game/api/social/friend/respond', { id:+b.dataset.id, action:'ignore' }); toast('已撤销'); renderAdd(); }; });
|
||
});
|
||
}
|
||
async function doSearch(q){
|
||
const box = document.getElementById('fpSearchResult');
|
||
box.innerHTML = '<div class="fp-empty">搜索中…</div>';
|
||
const d = await api('/game/api/social/search?q=' + encodeURIComponent(q || ''));
|
||
const items = (d && d.items) || [];
|
||
if(!items.length){ box.innerHTML = '<div class="fp-empty">未找到用户</div>'; return; }
|
||
box.innerHTML = '';
|
||
items.forEach(u => {
|
||
const row = document.createElement('div'); row.className = 'fp-row';
|
||
row.innerHTML = `<div class="fp-av ${u.online?'':'off'}">${(u.name||'?').slice(0,1)}</div>
|
||
<div class="fp-main"><div class="fp-name"><span class="fp-dot ${u.online?'':'off'}"></span>${esc(u.name)}</div><div class="fp-sub">Lv.${u.level||1} · ${esc(u.title||'')}</div></div>
|
||
<div class="fp-actions"><button class="fp-act" data-act="add" data-uid="${u.uid}">加好友</button></div>`;
|
||
box.appendChild(row);
|
||
});
|
||
box.querySelectorAll('[data-act="add"]').forEach(b => { b.onclick = async () => {
|
||
const r = await post('/game/api/social/friend/add', { to_uid:+b.dataset.uid });
|
||
if(r && r.ok){ toast(r.status === 'friend' ? '已成为好友' : '申请已发送'); refreshAll(); }
|
||
else toast((r && r.error) || '操作失败');
|
||
}; });
|
||
}
|
||
|
||
async function renderGuild(){
|
||
fpBody.innerHTML = '<div class="fp-empty">加载中…</div>';
|
||
const d = await api('/game/api/social/guilds');
|
||
const items = (d && d.items) || [];
|
||
fpBody.innerHTML = '';
|
||
if(!items.length){ fpBody.innerHTML = '<div class="fp-empty">你还没有公会</div>'; }
|
||
items.forEach(g => {
|
||
const row = document.createElement('div'); row.className = 'fp-guild';
|
||
row.innerHTML = `<div class="fp-av">⚑</div>
|
||
<div class="fp-main"><div class="fp-name">${esc(g.name)}${g.owner?' <span style="color:#9cf;font-size:11px;">会长</span>':''}</div>
|
||
<div class="fp-sub">${esc(g.desc||'')} · ${g.members.length} 人</div></div>`;
|
||
fpBody.appendChild(row);
|
||
});
|
||
const create = document.createElement('div'); create.className = 'fp-row'; create.style.cursor = 'pointer';
|
||
create.innerHTML = `<div class="fp-av">+</div><div class="fp-main"><div class="fp-name">创建新公会</div></div>`;
|
||
create.onclick = async () => { const name = prompt('公会名称', '宇森开拓团'); if(!name) return; const r = await post('/game/api/social/guild/create', { name }); if(r && r.ok){ toast('公会已创建'); renderGuild(); } else toast((r && r.error) || '创建失败'); };
|
||
fpBody.appendChild(create);
|
||
}
|
||
|
||
async function inviteToGuild(uid, name){
|
||
const d = await api('/game/api/social/guilds');
|
||
const mine = (d && d.items) || [];
|
||
let guild = mine.find(g => g.owner);
|
||
if(!guild){ const r = await post('/game/api/social/guild/create', { name:'宇森开拓团' }); if(r && r.ok){ guild = { id: r.guild.id }; toast('已创建公会并邀请'); } else { toast('创建公会失败'); return; } }
|
||
const ir = await post('/game/api/social/guild/invite', { guild_id: guild.id, uid });
|
||
if(ir && ir.ok) toast('已邀请 ' + name + ' 加入公会'); else toast((ir && ir.error) || '邀请失败');
|
||
}
|
||
|
||
function openPanel(){ panel.style.display = 'flex'; btnFriends.classList.add('active'); switchTab(currentTab); }
|
||
function closePanel(){ panel.style.display = 'none'; btnFriends.classList.remove('active'); }
|
||
function switchTab(tab){
|
||
currentTab = tab;
|
||
if(tab === 'friends') renderFriends();
|
||
else if(tab === 'requests') renderRequests();
|
||
else if(tab === 'add') renderAdd();
|
||
else if(tab === 'guild') renderGuild();
|
||
}
|
||
if(btnFriends) btnFriends.onclick = () => { if(panel.style.display === 'flex') closePanel(); else { openPanel(); refreshAll(); } };
|
||
const fpClose = document.getElementById('fpClose'); if(fpClose) fpClose.onclick = closePanel;
|
||
fpTabs.querySelectorAll('.fp-tab').forEach(t => { t.onclick = () => {
|
||
fpTabs.querySelectorAll('.fp-tab').forEach(x => x.classList.remove('active'));
|
||
t.classList.add('active'); switchTab(t.dataset.tab);
|
||
}; });
|
||
|
||
loadMe();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html> |