diff --git a/addons/yt_world/controllers/world_api.py b/addons/yt_world/controllers/world_api.py index bcfaa0c..0b4b6e5 100644 --- a/addons/yt_world/controllers/world_api.py +++ b/addons/yt_world/controllers/world_api.py @@ -1,8 +1,12 @@ # -*- coding: utf-8 -*- import json +import logging +import re from odoo import http from odoo.http import request, Response +_logger = logging.getLogger(__name__) + def _json(data): """返回 UTF-8 JSON 响应(与 game_base 一致)。""" @@ -59,3 +63,26 @@ class YtWorldApi(http.Controller): if world and settings is not None: world.settings = settings return _json({'ok': True}) + + @http.route('/yt_world/api/world/rename', type='http', auth='user', + methods=['POST', 'OPTIONS'], csrf=False) + def rename_world(self, **kw): + """修改当前用户世界名称。body: {"name": "..."} + 校验:trim 后 1-24 字符,禁控制字符,空白正常允许。""" + user = request.env.user + world = request.env['yt.world'].sudo().search( + [('user_id', '=', user.id)], limit=1) + if not world: + return _json({'ok': False, 'error': 'no_world'}) + body = _post_body() + name = (body.get('name') or '').strip() + if not name: + return _json({'ok': False, 'error': 'empty'}) + if len(name) > 24: + return _json({'ok': False, 'error': 'too_long'}) + if re.search(r'[\x00-\x1f\x7f]', name): + return _json({'ok': False, 'error': 'invalid_char'}) + old = world.name + world.name = name + _logger.info('yt.world rename uid=%s %r -> %r', user.id, old, name) + return _json({'ok': True, 'name': name}) diff --git a/assets/game-api.js b/assets/game-api.js index 03fbe5a..9869dd8 100644 --- a/assets/game-api.js +++ b/assets/game-api.js @@ -87,6 +87,13 @@ const API = { body: JSON.stringify({ settings }), })).json(); }, + async renameWorld(name) { + return (await fetch('/yt_world/api/world/rename?db=' + WORLD_DB, { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + })).json(); + }, // ---------- 世界数据 / 包裹(后端接口预留:yt_world 暂未实现路由,前端 mock 兜底) ---------- async worldData() { try { diff --git a/assets/site-app.js b/assets/site-app.js index bbdd67a..c50403e 100644 --- a/assets/site-app.js +++ b/assets/site-app.js @@ -64,9 +64,11 @@ async function loadForum(){ // 分类筛选 const cats = []; list.forEach(t => { if (t.category && cats.indexOf(t.category) < 0) cats.push(t.category); }); + const catLabels = {}; + list.forEach(t => { if (t.category) catLabels[t.category] = t.category_label || t.category; }); if (catsEl){ catsEl.innerHTML = `
全部
` + - cats.map(c => `
${c}
`).join(''); + cats.map(c => `
${catLabels[c] || c}
`).join(''); catsEl.querySelectorAll('.ch').forEach(b => b.addEventListener('click', () => { catsEl.querySelectorAll('.ch').forEach(x => x.classList.remove('active')); b.classList.add('active'); @@ -90,9 +92,22 @@ async function loadAnnounce(){ try { const list = await API.announcements(); if (!list || !list.length){ el.innerHTML = '
暂无公告
'; return; } - el.innerHTML = list.map(n => - `
${n.date}
${n.body_html || ''}
` - ).join(''); + el.innerHTML = list.map((n, i) => { + // 封面图:后台返回 image 字段则铺满封面;否则渐变占位(图片位从后台取) + const cover = n.image + ? `
` + : `
`; + const title = n.title ? `
${n.title}
` : ''; + const feat = (i === 0) ? ' feat' : ''; + return `
+ ${cover} +
+
${n.date || ''}
+ ${title} +
${n.body_html || ''}
+
+
`; + }).join(''); } catch (e) { /* 保留静态兜底内容 */ } } diff --git a/assets/terrain/desert_1.png b/assets/terrain/desert/desert_1.png similarity index 100% rename from assets/terrain/desert_1.png rename to assets/terrain/desert/desert_1.png diff --git a/assets/terrain/desert_2.png b/assets/terrain/desert/desert_2.png similarity index 100% rename from assets/terrain/desert_2.png rename to assets/terrain/desert/desert_2.png diff --git a/assets/terrain/desert_3.png b/assets/terrain/desert/desert_3.png similarity index 100% rename from assets/terrain/desert_3.png rename to assets/terrain/desert/desert_3.png diff --git a/assets/terrain/desert_4.png b/assets/terrain/desert/desert_4.png similarity index 100% rename from assets/terrain/desert_4.png rename to assets/terrain/desert/desert_4.png diff --git a/assets/terrain/desert_5.png b/assets/terrain/desert/desert_5.png similarity index 100% rename from assets/terrain/desert_5.png rename to assets/terrain/desert/desert_5.png diff --git a/assets/terrain/desert_6.png b/assets/terrain/desert/desert_6.png similarity index 100% rename from assets/terrain/desert_6.png rename to assets/terrain/desert/desert_6.png diff --git a/assets/terrain/desert_7.png b/assets/terrain/desert/desert_7.png similarity index 100% rename from assets/terrain/desert_7.png rename to assets/terrain/desert/desert_7.png diff --git a/assets/terrain/forest_1.png b/assets/terrain/forest/forest_1.png similarity index 100% rename from assets/terrain/forest_1.png rename to assets/terrain/forest/forest_1.png diff --git a/assets/terrain/forest_2.png b/assets/terrain/forest/forest_2.png similarity index 100% rename from assets/terrain/forest_2.png rename to assets/terrain/forest/forest_2.png diff --git a/assets/terrain/forest_3.png b/assets/terrain/forest/forest_3.png similarity index 100% rename from assets/terrain/forest_3.png rename to assets/terrain/forest/forest_3.png diff --git a/assets/terrain/forest_5.png b/assets/terrain/forest/forest_5.png similarity index 100% rename from assets/terrain/forest_5.png rename to assets/terrain/forest/forest_5.png diff --git a/assets/terrain/forest_6.png b/assets/terrain/forest/forest_6.png similarity index 100% rename from assets/terrain/forest_6.png rename to assets/terrain/forest/forest_6.png diff --git a/assets/terrain/forest_7.png b/assets/terrain/forest/forest_7.png similarity index 100% rename from assets/terrain/forest_7.png rename to assets/terrain/forest/forest_7.png diff --git a/assets/terrain/forest_8.png b/assets/terrain/forest/forest_8.png similarity index 100% rename from assets/terrain/forest_8.png rename to assets/terrain/forest/forest_8.png diff --git a/assets/terrain/manifest.json b/assets/terrain/manifest.json index f0bb6d8..5a24ba2 100644 --- a/assets/terrain/manifest.json +++ b/assets/terrain/manifest.json @@ -1,60 +1,60 @@ { "desert": [ - "desert_1.png", - "desert_2.png", - "desert_3.png", - "desert_4.png", - "desert_5.png", - "desert_6.png", - "desert_7.png" - ], - "snow": [ - "snow_1.png", - "snow_2.png", - "snow_3.png", - "snow_4.png", - "snow_5.png", - "snow_6.png", - "snow_8.png" + "desert/desert_1.png", + "desert/desert_2.png", + "desert/desert_3.png", + "desert/desert_4.png", + "desert/desert_5.png", + "desert/desert_6.png", + "desert/desert_7.png" ], "forest": [ - "forest_1.png", - "forest_2.png", - "forest_3.png", - "forest_5.png", - "forest_6.png", - "forest_7.png", - "forest_8.png" - ], - "plain": [ - "plain_1.png", - "plain_2.png", - "plain_3.png", - "plain_4.png", - "plain_5.png", - "plain_6.png", - "plain_7.png", - "plain_8.png" + "forest/forest_1.png", + "forest/forest_2.png", + "forest/forest_3.png", + "forest/forest_5.png", + "forest/forest_6.png", + "forest/forest_7.png", + "forest/forest_8.png" ], "mountain": [ - "mountain_1.png", - "mountain_2.png", - "mountain_3.png", - "mountain_4.png", - "mountain_5.png", - "mountain_6.png", - "mountain_7.png", - "mountain_8.png" + "mountain/mountain_1.png", + "mountain/mountain_2.png", + "mountain/mountain_3.png", + "mountain/mountain_4.png", + "mountain/mountain_5.png", + "mountain/mountain_6.png", + "mountain/mountain_7.png", + "mountain/mountain_8.png" + ], + "plain": [ + "plain/plain_1.png", + "plain/plain_2.png", + "plain/plain_3.png", + "plain/plain_4.png", + "plain/plain_5.png", + "plain/plain_6.png", + "plain/plain_7.png", + "plain/plain_8.png" + ], + "snow": [ + "snow/snow_1.png", + "snow/snow_2.png", + "snow/snow_3.png", + "snow/snow_4.png", + "snow/snow_5.png", + "snow/snow_6.png", + "snow/snow_8.png" ], "water": [ - "water_1.png", - "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/water_1.png", + "water/water_2.png", + "water/water_3.png", + "water/water_4.png", + "water/water_5.png", + "water/water_6.png", + "water/water_7.png", + "water/water_8.png", + "water/water_9.png" ] -} \ No newline at end of file +} diff --git a/assets/terrain/mountain_1.png b/assets/terrain/mountain/mountain_1.png similarity index 100% rename from assets/terrain/mountain_1.png rename to assets/terrain/mountain/mountain_1.png diff --git a/assets/terrain/mountain_2.png b/assets/terrain/mountain/mountain_2.png similarity index 100% rename from assets/terrain/mountain_2.png rename to assets/terrain/mountain/mountain_2.png diff --git a/assets/terrain/mountain_3.png b/assets/terrain/mountain/mountain_3.png similarity index 100% rename from assets/terrain/mountain_3.png rename to assets/terrain/mountain/mountain_3.png diff --git a/assets/terrain/mountain_4.png b/assets/terrain/mountain/mountain_4.png similarity index 100% rename from assets/terrain/mountain_4.png rename to assets/terrain/mountain/mountain_4.png diff --git a/assets/terrain/mountain_5.png b/assets/terrain/mountain/mountain_5.png similarity index 100% rename from assets/terrain/mountain_5.png rename to assets/terrain/mountain/mountain_5.png diff --git a/assets/terrain/mountain_6.png b/assets/terrain/mountain/mountain_6.png similarity index 100% rename from assets/terrain/mountain_6.png rename to assets/terrain/mountain/mountain_6.png diff --git a/assets/terrain/mountain_7.png b/assets/terrain/mountain/mountain_7.png similarity index 100% rename from assets/terrain/mountain_7.png rename to assets/terrain/mountain/mountain_7.png diff --git a/assets/terrain/mountain_8.png b/assets/terrain/mountain/mountain_8.png similarity index 100% rename from assets/terrain/mountain_8.png rename to assets/terrain/mountain/mountain_8.png diff --git a/assets/terrain/plain_1.png b/assets/terrain/plain/plain_1.png similarity index 100% rename from assets/terrain/plain_1.png rename to assets/terrain/plain/plain_1.png diff --git a/assets/terrain/plain_2.png b/assets/terrain/plain/plain_2.png similarity index 100% rename from assets/terrain/plain_2.png rename to assets/terrain/plain/plain_2.png diff --git a/assets/terrain/plain_3.png b/assets/terrain/plain/plain_3.png similarity index 100% rename from assets/terrain/plain_3.png rename to assets/terrain/plain/plain_3.png diff --git a/assets/terrain/plain_4.png b/assets/terrain/plain/plain_4.png similarity index 100% rename from assets/terrain/plain_4.png rename to assets/terrain/plain/plain_4.png diff --git a/assets/terrain/plain_5.png b/assets/terrain/plain/plain_5.png similarity index 100% rename from assets/terrain/plain_5.png rename to assets/terrain/plain/plain_5.png diff --git a/assets/terrain/plain_6.png b/assets/terrain/plain/plain_6.png similarity index 100% rename from assets/terrain/plain_6.png rename to assets/terrain/plain/plain_6.png diff --git a/assets/terrain/plain_7.png b/assets/terrain/plain/plain_7.png similarity index 100% rename from assets/terrain/plain_7.png rename to assets/terrain/plain/plain_7.png diff --git a/assets/terrain/plain_8.png b/assets/terrain/plain/plain_8.png similarity index 100% rename from assets/terrain/plain_8.png rename to assets/terrain/plain/plain_8.png diff --git a/assets/terrain/snow_1.png b/assets/terrain/snow/snow_1.png similarity index 100% rename from assets/terrain/snow_1.png rename to assets/terrain/snow/snow_1.png diff --git a/assets/terrain/snow_2.png b/assets/terrain/snow/snow_2.png similarity index 100% rename from assets/terrain/snow_2.png rename to assets/terrain/snow/snow_2.png diff --git a/assets/terrain/snow_3.png b/assets/terrain/snow/snow_3.png similarity index 100% rename from assets/terrain/snow_3.png rename to assets/terrain/snow/snow_3.png diff --git a/assets/terrain/snow_4.png b/assets/terrain/snow/snow_4.png similarity index 100% rename from assets/terrain/snow_4.png rename to assets/terrain/snow/snow_4.png diff --git a/assets/terrain/snow_5.png b/assets/terrain/snow/snow_5.png similarity index 100% rename from assets/terrain/snow_5.png rename to assets/terrain/snow/snow_5.png diff --git a/assets/terrain/snow_6.png b/assets/terrain/snow/snow_6.png similarity index 100% rename from assets/terrain/snow_6.png rename to assets/terrain/snow/snow_6.png diff --git a/assets/terrain/snow_8.png b/assets/terrain/snow/snow_8.png similarity index 100% rename from assets/terrain/snow_8.png rename to assets/terrain/snow/snow_8.png diff --git a/assets/terrain/water_1.png b/assets/terrain/water/water_1.png similarity index 100% rename from assets/terrain/water_1.png rename to assets/terrain/water/water_1.png diff --git a/assets/terrain/water_2.png b/assets/terrain/water/water_2.png similarity index 100% rename from assets/terrain/water_2.png rename to assets/terrain/water/water_2.png diff --git a/assets/terrain/water_3.png b/assets/terrain/water/water_3.png similarity index 100% rename from assets/terrain/water_3.png rename to assets/terrain/water/water_3.png diff --git a/assets/terrain/water_4.png b/assets/terrain/water/water_4.png similarity index 100% rename from assets/terrain/water_4.png rename to assets/terrain/water/water_4.png diff --git a/assets/terrain/water_5.png b/assets/terrain/water/water_5.png similarity index 100% rename from assets/terrain/water_5.png rename to assets/terrain/water/water_5.png diff --git a/assets/terrain/water_6.png b/assets/terrain/water/water_6.png similarity index 100% rename from assets/terrain/water_6.png rename to assets/terrain/water/water_6.png diff --git a/assets/terrain/water_7.png b/assets/terrain/water/water_7.png similarity index 100% rename from assets/terrain/water_7.png rename to assets/terrain/water/water_7.png diff --git a/assets/terrain/water_8.png b/assets/terrain/water/water_8.png similarity index 100% rename from assets/terrain/water_8.png rename to assets/terrain/water/water_8.png diff --git a/assets/terrain/water_9.png b/assets/terrain/water/water_9.png similarity index 100% rename from assets/terrain/water_9.png rename to assets/terrain/water/water_9.png diff --git a/assets/world_core.mp4 b/assets/world_core.mp4 deleted file mode 100644 index 5db0e97..0000000 Binary files a/assets/world_core.mp4 and /dev/null differ diff --git a/assets/world_earth.mp4 b/assets/world_earth.mp4 deleted file mode 100644 index 6ff81a9..0000000 Binary files a/assets/world_earth.mp4 and /dev/null differ diff --git a/assets/world_sky.mp4 b/assets/world_sky.mp4 deleted file mode 100644 index dd81fc9..0000000 Binary files a/assets/world_sky.mp4 and /dev/null differ diff --git a/assets/world_star.mp4 b/assets/world_star.mp4 deleted file mode 100644 index 8441d06..0000000 Binary files a/assets/world_star.mp4 and /dev/null differ diff --git a/assets/world_under.mp4 b/assets/world_under.mp4 deleted file mode 100644 index 5f3bd87..0000000 Binary files a/assets/world_under.mp4 and /dev/null differ diff --git a/assets/yusen-trailer.mp4 b/assets/yusen-trailer.mp4 deleted file mode 100644 index ab0cd98..0000000 Binary files a/assets/yusen-trailer.mp4 and /dev/null differ diff --git a/index.html b/index.html index 3308486..f4f291a 100644 --- a/index.html +++ b/index.html @@ -57,8 +57,16 @@ .cta:hover{filter:brightness(1.08);} .content{flex:1;position:relative;overflow:hidden;} - .panel{position:absolute;inset:0;display:none;flex-direction:column;padding:30px 44px;overflow:hidden;} + .panel{position:absolute;inset:0;display:none;flex-direction:column;padding:28px 48px;overflow:hidden;} .panel.active{display:flex;} + .panel-scroll{flex:1;min-height:0;overflow-y:auto;padding-right:8px;} + /* 隐藏内部滚动条但保留滚轮/触控滑动(规则/世界数据/公告/论坛/图鉴/剧情等) */ + .rules-scroll,#worldDataContent,#announce .notes,.threads,.codex-grid,.story-reader,.panel-scroll,.toc-list{ + scrollbar-width:none; -ms-overflow-style:none; + } + .rules-scroll::-webkit-scrollbar,#worldDataContent::-webkit-scrollbar,#announce .notes::-webkit-scrollbar, + .threads::-webkit-scrollbar,.codex-grid::-webkit-scrollbar,.story-reader::-webkit-scrollbar, + .panel-scroll::-webkit-scrollbar,.toc-list::-webkit-scrollbar{display:none;width:0;height:0;} /* ===== 介绍(视频主页) ===== */ #intro{padding:0;} @@ -77,8 +85,6 @@ /* ===== 特色 ===== */ #features{align-items:center;justify-content:center;gap:22px;} - .sec-title{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:32px;letter-spacing:4px;color:#fff;text-shadow:0 0 18px rgba(139,92,246,.5);} - .sec-title small{display:block;font-family:"Press Start 2P",monospace;font-size:9px;color:var(--magenta);letter-spacing:1px;margin-top:7px;} .grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;width:min(1060px,100%);} .card{background:var(--card);border:1px solid var(--card-bd);border-radius:14px;padding:18px 20px;transition:.26s;min-height:132px;} .card:hover{transform:translateY(-4px);border-color:var(--violet);box-shadow:var(--glow);background:rgba(139,92,246,.12);} @@ -87,8 +93,8 @@ .card p{font-size:13px;color:var(--text-dim);line-height:1.6;} /* ===== 背景 ===== */ - #background{flex-direction:column;align-items:stretch;gap:0;overflow-y:auto;padding:48px 6% 90px;} - .bg-page{max-width:1060px;width:100%;margin:0 auto;padding:30px 0;border-top:1px solid var(--card-bd);} + #background{flex-direction:column;align-items:stretch;gap:0;overflow:hidden;padding:28px 48px;} + .bg-page{max-width:1080px;width:100%;margin:0 auto;padding:24px 0;border-top:1px solid var(--card-bd);} .bg-page:first-child{border-top:none;padding-top:0;} .bg-page-head{text-align:center;margin-bottom:28px;} .bg-page-title{font-family:"Noto Serif SC",serif;font-weight:800;font-size:40px;letter-spacing:3px;line-height:1.2;margin:0 0 10px; @@ -120,7 +126,7 @@ .bg-text p{font-size:14.5px;color:var(--text-dim);line-height:1.95;max-width:640px;} /* ===== 规则 ===== */ - #rules{flex-direction:column;gap:14px;} + #rules{flex-direction:column;gap:0;} .rules-scroll{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:22px;padding-right:8px;} .rule-block h3{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:22px;color:#fff;letter-spacing:2px;margin-bottom:6px;} .rule-intro{font-size:13.5px;color:var(--text-dim);margin-bottom:12px;line-height:1.7;max-width:780px;} @@ -132,8 +138,8 @@ .tier-row .tdesc{font-size:13px;color:var(--text-dim);line-height:1.5;} /* ===== 图鉴 ===== */ - #codex{padding:22px 44px;gap:0;} - .codex-wrap{display:flex;flex-direction:column;gap:13px;height:100%;min-height:0;} + #codex{padding:28px 48px;gap:0;} + .codex-wrap{display:flex;flex-direction:column;gap:13px;flex:1;min-height:0;} .codex-cats{display:flex;gap:6px;flex-wrap:wrap;} .codex-cats button{font-family:"Noto Sans SC",sans-serif;font-size:14px;color:var(--text-dim);background:var(--card);border:1px solid var(--card-bd);padding:8px 15px;border-radius:11px;cursor:pointer;transition:.2s;} .codex-cats button:hover{color:#fff;} @@ -146,7 +152,7 @@ .codex-search{background:rgba(8,12,26,.6);border:1px solid var(--card-bd);border-radius:18px;color:var(--text);padding:7px 14px;font-size:13px;outline:none;width:200px;flex:0 0 auto;} .codex-search:focus{border-color:var(--violet);} .codex-count{font-size:12px;color:var(--text-dim);white-space:nowrap;flex:0 0 auto;} - .codex-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(176px,1fr));gap:14px;overflow-y:auto;padding:4px 6px 4px 0;align-content:start;flex:1;min-height:0;} + .codex-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;overflow-y:auto;padding:4px 6px 4px 0;align-content:start;flex:1;min-height:0;} .cx{background:var(--card);border:1px solid var(--card-bd);border-radius:13px;overflow:hidden;transition:.24s;display:flex;flex-direction:column;cursor:default;} .cx:hover{transform:translateY(-3px);border-color:var(--violet);box-shadow:var(--glow);} .cx .thumb{width:100%;aspect-ratio:1/1;display:block;overflow:hidden;background:linear-gradient(135deg,rgba(139,92,246,.22),rgba(76,201,240,.10));} @@ -159,38 +165,49 @@ .cx .ds{font-size:11.5px;color:var(--text-dim);line-height:1.5;margin-top:6px;} /* ===== 论坛 ===== */ - #forum{flex-direction:row;gap:22px;} - .f-cats{flex:0 0 180px;display:flex;flex-direction:column;gap:8px;} - .f-cats .ch{font-size:14px;color:var(--text-dim);background:var(--card);border:1px solid var(--card-bd);padding:11px 14px;border-radius:10px;cursor:pointer;transition:.2s;} - .f-cats .ch:hover{color:#fff;} - .f-cats .ch.active{color:#fff;background:rgba(139,92,246,.22);border-color:var(--magenta);} + #forum{flex-direction:row;gap:28px;} + .f-cats{flex:0 0 200px;display:flex;flex-direction:column;gap:12px;} + .f-cats .ch{font-size:15.5px;color:var(--text-dim);background:var(--card);border:1px solid var(--card-bd);padding:13px 18px;border-radius:12px;cursor:pointer;transition:.22s;letter-spacing:.5px;} + .f-cats .ch:hover{color:#fff;background:rgba(139,92,246,.12);} + .f-cats .ch.active{color:#fff;background:linear-gradient(120deg,rgba(139,92,246,.28),rgba(199,125,255,.16));border-color:var(--magenta);box-shadow:var(--glow);} .f-main{flex:1;display:flex;flex-direction:column;min-width:0;} - .f-head{display:flex;align-items:center;margin-bottom:12px;gap:12px;} - .f-head h2{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:24px;letter-spacing:2px;color:#fff;} - .f-head .new{margin-left:auto;font-size:13px;color:#0b1020;background:linear-gradient(120deg,var(--gold),#f0d49a);padding:8px 15px;border-radius:9px;font-weight:700;cursor:pointer;} - .f-head .new:hover{filter:brightness(1.08);} - .threads{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:10px;padding-right:6px;} - .th{display:flex;align-items:center;gap:14px;background:var(--card);border:1px solid var(--card-bd);border-radius:11px;padding:13px 16px;transition:.2s;cursor:pointer;} - .th:hover{border-color:var(--violet);background:rgba(139,92,246,.10);} - .th .pin{font-size:11px;font-weight:700;letter-spacing:1px;color:var(--gold);background:rgba(231,184,92,.14);border:1px solid rgba(231,184,92,.42);border-radius:5px;padding:1px 7px;} - .th .tt{flex:1;font-size:15px;color:#fff;} - .th .meta{font-size:12px;color:var(--text-dim);white-space:nowrap;} - .th .rp{color:var(--cyan);font-weight:500;} - .post-form{display:none;flex-direction:column;gap:8px;background:var(--card);border:1px solid var(--magenta);border-radius:12px;padding:14px;margin-bottom:12px;} + .threads{flex:1;overflow-y:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px;padding-right:8px;align-content:start;} + .th{display:flex;align-items:center;gap:16px;background:var(--card);border:1px solid var(--card-bd);border-radius:14px;padding:16px 20px;transition:.22s;cursor:pointer;} + .th:hover{border-color:var(--violet);background:rgba(139,92,246,.12);transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.25);} + .th .pin{font-size:12px;font-weight:700;letter-spacing:1px;color:var(--gold);background:rgba(231,184,92,.16);border:1px solid rgba(231,184,92,.45);border-radius:6px;padding:2px 8px;} + .th .tt{flex:1;font-size:16.5px;color:#fff;letter-spacing:.3px;} + .th .meta{font-size:13px;color:var(--text-dim);white-space:nowrap;} + .th .rp{color:var(--cyan);font-weight:600;} + .post-form{display:none;flex-direction:column;gap:10px;background:var(--card);border:1px solid var(--magenta);border-radius:14px;padding:16px;margin-bottom:14px;} .post-form.show{display:flex;} - .post-form input,.post-form textarea{background:rgba(8,12,26,.6);border:1px solid var(--card-bd);border-radius:8px;color:var(--text);padding:9px 12px;font-family:inherit;font-size:13px;outline:none;} - .post-form textarea{resize:vertical;min-height:64px;} - .post-form .row{display:flex;gap:8px;justify-content:flex-end;} - .post-form .row button{font-size:13px;padding:7px 16px;border-radius:8px;cursor:pointer;border:1px solid var(--card-bd);background:var(--card);color:var(--text);} + .post-form input,.post-form textarea{background:rgba(8,12,26,.6);border:1px solid var(--card-bd);border-radius:10px;color:var(--text);padding:10px 14px;font-family:inherit;font-size:14px;outline:none;} + .post-form textarea{resize:vertical;min-height:72px;} + .post-form .row{display:flex;gap:10px;justify-content:flex-end;} + .post-form .row button{font-size:14px;padding:8px 18px;border-radius:10px;cursor:pointer;border:1px solid var(--card-bd);background:var(--card);color:var(--text);} .post-form .row .ok{background:linear-gradient(120deg,var(--gold),#f0d49a);color:#0b1020;font-weight:700;border:none;} /* ===== 公告 ===== */ - #announce{gap:18px;} - #announce .notes{flex:1;min-height:0;display:flex;flex-direction:column;gap:14px;overflow-y:auto;padding-right:6px;} - .note{border-left:3px solid var(--magenta);padding:12px 16px;background:var(--card);border:1px solid var(--card-bd);border-left:3px solid var(--magenta);border-radius:0 12px 12px 0;} - .note .date{font-family:"Press Start 2P",monospace;font-size:9px;color:var(--cyan);margin-bottom:8px;} - .note .body{font-size:14px;color:var(--text);line-height:1.7;} - .note.pin{box-shadow:var(--glow);} + #announce{gap:0;align-items:stretch;} + #announce .notes{flex:1;min-height:0;width:100%;display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:18px;overflow-y:auto;padding-right:8px;align-content:start;} + #announce .notes{grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:20px;} + .note{display:flex;flex-direction:column;overflow:hidden;background:linear-gradient(135deg,rgba(139,92,246,.10),rgba(76,201,240,.04));border:1px solid var(--card-bd);border-left:4px solid var(--magenta);border-radius:16px;transition:.24s;} + .note:hover{transform:translateY(-3px);border-color:var(--violet);background:linear-gradient(135deg,rgba(139,92,246,.16),rgba(76,201,240,.07));box-shadow:var(--glow);} + .note.pin{border-left-color:var(--gold);background:linear-gradient(135deg,rgba(231,184,92,.12),rgba(139,92,246,.06));} + .note.pin:hover{background:linear-gradient(135deg,rgba(231,184,92,.18),rgba(139,92,246,.08));} + .note.feat{grid-column:1 / -1;flex-direction:row;align-items:stretch;} + .note.feat .note-cover{flex:0 0 44%;width:44%;min-height:260px;} + .note.feat .note-body{flex:1;justify-content:center;} + .note.feat .note-title{font-size:27px;} + .note.feat .body{font-size:16px;} + .note-cover{height:168px;flex:0 0 auto;background-size:cover;background-position:center;background-repeat:no-repeat;border-bottom:1px solid var(--card-bd);position:relative;} + .note-cover.empty{background:linear-gradient(135deg,rgba(139,92,246,.24),rgba(76,201,240,.10));} + .note-cover.empty::after{content:'封面图 · 后台配置';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:12px;letter-spacing:2px;color:rgba(255,255,255,.5);} + .note-body{padding:18px 22px;display:flex;flex-direction:column;min-width:0;} + .note .date{font-size:13px;font-weight:600;color:var(--cyan);letter-spacing:1px;margin-bottom:8px;} + .note-title{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:21px;color:#fff;letter-spacing:1px;margin-bottom:10px;line-height:1.32;text-shadow:0 0 14px rgba(139,92,246,.4);} + .note .body{font-size:15px;color:var(--text);line-height:1.9;} + .note .body p{margin:0 0 11px;} + .note .body p:last-child{margin-bottom:0;} /* ===== 致玩家 ===== */ #letter{align-items:center;justify-content:center;} @@ -204,19 +221,18 @@ #letter .letter-wrap .lt-sign{text-align:right;margin-top:28px;font-size:14px;color:var(--magenta);font-style:italic;} /* ===== 论坛(竖排:顶部分类条 + 下方纵向帖子流) ===== */ - #forum{flex-direction:column;gap:16px;} - .f-cats{flex:0 0 auto;flex-direction:row;flex-wrap:wrap;gap:8px;} + #forum{flex-direction:column;gap:20px;} + .f-cats{flex:0 0 auto;flex-direction:row;flex-wrap:wrap;gap:10px;} .f-cats .ch{flex:0 0 auto;} .f-main{flex:1;min-height:0;} - .threads{max-width:900px;width:100%;} + .threads{width:100%;} - /* ===== 公告(居左竖排) ===== */ - #announce{align-items:stretch;} - #announce .notes{max-width:1080px;width:100%;margin:0;} + /* ===== 公告(内容居中) ===== */ + #announce .notes{margin:0;} /* ===== 剧情(小说阅读器:左目录 + 右正文,竖排阅读感) ===== */ - #story{padding:30px 44px;} - .story-wrap{display:flex;flex-direction:row;width:100%;height:100%;min-height:0;} + #story{padding:28px 48px;} + .story-wrap{display:flex;flex-direction:row;width:100%;flex:1;min-height:0;} .story-toc{flex:0 0 286px;display:flex;flex-direction:column;border-right:1px solid var(--card-bd);background:rgba(8,12,26,.35);min-height:0;} .toc-head{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:24px;letter-spacing:3px;color:#fff;padding:24px 0 12px;text-shadow:0 0 16px rgba(139,92,246,.5);} .toc-head small{display:block;font-family:"Press Start 2P",monospace;font-size:8px;color:var(--magenta);letter-spacing:1px;margin-top:6px;} @@ -230,28 +246,28 @@ .toc-ch.active{color:#fff;background:rgba(139,92,246,.20);border-left-color:var(--magenta);} .story-reader{flex:1;overflow-y:auto;min-width:0;background:linear-gradient(180deg, rgba(139,92,246,.04), transparent 240px);} .reader-loading{padding:80px 20px;text-align:center;color:var(--text-dim);font-size:15px;} - .reader-inner{max-width:760px;margin:0 auto;padding:44px 48px 90px;} + .reader-inner{max-width:840px;margin:0 auto;padding:48px 52px 100px;} .reader-inner .r-vol{font-size:12px;color:var(--cyan);letter-spacing:2px;margin-bottom:10px;} - .reader-inner h1{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:30px;letter-spacing:2px;color:#fff;text-shadow:0 0 16px rgba(139,92,246,.5);margin-bottom:18px;line-height:1.3;} - .reader-inner .body{font-size:15.5px;color:var(--text);line-height:2.15;} - .reader-inner .body p{margin-bottom:18px;text-indent:2em;} + .reader-inner h1{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:32px;letter-spacing:2px;color:#fff;text-shadow:0 0 16px rgba(139,92,246,.5);margin-bottom:18px;line-height:1.3;} + .reader-inner .body{font-size:17.5px;color:#eef2fb;line-height:2.0;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;} + .reader-inner .body p{margin-bottom:20px;text-indent:2em;} .reader-inner .body p:first-child{text-indent:0;} .reader-inner .body h2,.reader-inner .body h3{color:#fff;margin:22px 0 10px;font-size:18px;} /* ===== 世界数据报表 ===== */ - #worlddata{overflow-y:auto;padding:46px 6% 90px;} - #worldDataContent{max-width:1080px;width:100%;margin:0 auto;} + #worlddata{overflow:hidden;padding:28px 48px;} + #worldDataContent{flex:1;min-height:0;overflow-y:auto;width:100%;padding-right:8px;} .wd-tag{display:inline-block;font-size:12px;letter-spacing:2px;color:var(--gold);background:rgba(231,184,92,.12);border:1px solid rgba(231,184,92,.38);border-radius:6px;padding:3px 11px;margin-bottom:22px;} .wd-h{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:22px;letter-spacing:3px;color:#fff;margin:38px 0 18px;text-shadow:0 0 14px rgba(139,92,246,.45);display:flex;align-items:center;gap:10px;} .wd-h::before{content:"";width:4px;height:22px;border-radius:3px;background:linear-gradient(180deg,var(--violet),var(--cyan));box-shadow:0 0 10px rgba(139,92,246,.6);} .wd-h small{font-family:"Press Start 2P",monospace;font-size:8px;color:var(--magenta);letter-spacing:1px;} - .wd-kpi{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:16px;} - .wd-kpi-card{position:relative;overflow:hidden;background:linear-gradient(135deg,rgba(139,92,246,.12),rgba(76,201,240,.05));border:1px solid var(--card-bd);border-radius:16px;padding:18px 20px;transition:.26s;} + .wd-kpi{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:20px;} + .wd-kpi-card{position:relative;overflow:hidden;background:linear-gradient(135deg,rgba(139,92,246,.12),rgba(76,201,240,.05));border:1px solid var(--card-bd);border-radius:16px;padding:22px 26px;transition:.26s;} .wd-kpi-card:hover{transform:translateY(-4px);border-color:var(--violet);box-shadow:var(--glow);} .wd-kpi-card::after{content:"";position:absolute;right:-26px;top:-26px;width:88px;height:88px;border-radius:50%;background:radial-gradient(circle,rgba(199,125,255,.22),transparent 70%);} .wd-kpi-ic{font-size:22px;filter:drop-shadow(0 0 7px rgba(139,92,246,.55));} - .wd-kpi-num{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:34px;line-height:1.1;color:#fff;margin:8px 0 2px;} + .wd-kpi-num{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:40px;line-height:1.1;color:#fff;margin:10px 0 4px;} .wd-kpi-num .u{font-size:14px;color:var(--text-dim);margin-left:4px;letter-spacing:1px;} .wd-kpi-label{font-size:13px;color:var(--text-dim);letter-spacing:1px;} .wd-kpi-delta{position:absolute;right:14px;top:16px;font-size:11px;font-weight:700;padding:1px 7px;border-radius:5px;} @@ -320,7 +336,6 @@ .hero .enter-wrap{bottom:28px;} .hero .enter-btn{font-size:15px;padding:11px 30px;} .panel{padding:18px 18px;} - .sec-title{font-size:26px;letter-spacing:2px;} .bg-page-title{font-size:30px;} .bg-page-body{font-size:15.5px;line-height:1.9;} .grid{grid-template-columns:repeat(2,1fr);gap:12px;} @@ -333,6 +348,9 @@ .story-toc{flex-basis:auto;} #story{padding:18px 18px;} .tier-row{grid-template-columns:72px 92px 1fr;gap:8px;} + .note.feat{flex-direction:column;} + .note.feat .note-cover{flex:0 0 auto;width:100%;min-height:180px;} + #announce .notes{grid-template-columns:1fr;} } @media (max-width:480px){ .topbar{padding:8px 10px;gap:8px;} @@ -390,11 +408,12 @@ -
+
+
+
-
等级制度TIERS & RANK SYSTEM
@@ -479,15 +498,12 @@
全部
综合讨论
-
玩法攻略
-
同人创作
-
BUG 反馈
+
攻略
+
背景考据
+
作品秀
+
反馈
-
-

社区论坛

-
发新帖
-
@@ -495,24 +511,96 @@
置顶【公告】宇森官网第一阶段上线,欢迎园丁们入驻128 回复 · 官方
-
新手向:园丁视角的三种开局思路42 回复 · 行者
+
新手向:园丁视角的三种开局思路42 回复 · 行者
你们更喜欢哪种族?来投个票76 回复 · 雾岛
-
手绘了我的主世界浮空岛,求轻喷31 回复 · 青柠
+
手绘了我的主世界浮空岛,求轻喷31 回复 · 青柠
反馈:飞入微观时偶现卡顿9 回复 · K
-
地下矿脉怎么规划最省心?18 回复 · 老周
+
地下矿脉怎么规划最省心?18 回复 · 老周
-
公告ANNOUNCEMENTS
-
2026.07.09
宇森官网第一阶段上线:介绍 / 特色 / 背景 / 规则 / 图鉴 / 论坛 / 公告 / 致玩家 八块,单页切换呈现,介绍页即概念宣传片。
-
2026.07.09
图鉴系统升级:种族 204 条、系别/性格/天赋/神器/传奇事件全面扩量,装备槽位对齐 头/手/胸/裤/鞋/戒指(双)/项链/宝物;全部缩略图改为程序化矢量插画,零 AI 生图。
-
2026.07.08
概念宣传片《垂直生长》完成,呈现五层世界自上而下生长的镜头语言(天空层改为云朵地基块,星空层改为陨石科技带)。
-
2026.07.01
园丁视角核心玩法原型启动,首版六边形地图与活 NPC 验证中。
-
2026.06.20
图鉴系统规划确定:种族 / 建筑 / 生物 / 神器 四大类目,美术持续补充中。
+
+
+
+
2026.07.09 · 官方
+
宇森官网第一阶段上线,欢迎园丁们入驻
+
+

历经三个月打磨,宇森官网第一阶段正式上线。本次更新带来八大板块——介绍、特色、背景、规则、图鉴、论坛、公告、致玩家,全部以单页切换形式呈现,介绍页即一段概念宣传片,让你在三秒内读懂我们要做什么。

+

「园丁视角」是宇森的核心:你不是旁观者,而是世界的第一位居民。上传你的世界种子,观看五层世界自上而下生长——从星空陨石带到地心熔岩,每一寸地形、每一个种族,都由你的选择塑造。

+

我们同步开放了社区论坛与图鉴共建入口。欢迎在「论坛」留下你的构想,或在「图鉴」认领尚未完成的种族条目;被采纳的内容,会带上你的署名进入正式世界观。

+
+
+
+
+
+
+
2026.07.09
+
图鉴系统全面升级:204 种族 + 程序化矢量插画
+
+

图鉴系统完成一次大版本升级:种族条目扩充至 204 条,系别、性格、天赋、神器与传奇事件全面扩量,装备槽位对齐「头 / 手 / 胸 / 裤 / 鞋 / 戒指(双)/ 项链 / 宝物」九宫格。

+

所有缩略图改用程序化矢量插画生成,零 AI 生图、零版权风险,且在任意分辨率下都不糊。后续会逐步开放玩家投稿的「同人词条」分区。

+
+
+
+
+
+
+
2026.07.08
+
概念宣传片《垂直生长》完成
+
+

概念宣传片《垂直生长》正式杀青。片长 90 秒,以「自上而下」的镜头语言呈现五层世界的生长过程:天空层改为云朵地基块,星空层改为陨石科技带,地心则翻涌着熔岩血脉。

+

这支片子将成为官网介绍页的自动背景,也会在后续对外宣发中使用。如果你喜欢这种克制的科幻基调,记得在论坛给我们反馈。

+
+
+
+
+
+
+
2026.07.01
+
园丁视角核心玩法原型启动
+
+

园丁视角核心玩法原型进入验证阶段。首版采用六边形地图,配合「活 NPC」系统——NPC 拥有独立的日程、记忆与情绪,会对你的长期行为产生不同反应。

+

这一阶段我们重点关注「放手感」:玩家拖拽、缩放、种植、引导文明,每一步都要足够顺滑。手感达标后,再叠加叙事与冲突。

+
+
+
+
+
+
+
2026.06.25
+
五大世界层设定公开
+
+

我们公开了宇森的底层世界观——五大世界层:星空、天空、大地、地下、地心。每一层对应一种文明形态与资源循环,层与层之间通过「根系」与「星轨」双向连通。

+

这套设定不是装饰,而是玩法骨架:你在大地的每一次开采,都会改变地心的压力;地心的每一次喷发,又会重塑天空的气候。

+
+
+
+
+
+
+
2026.06.20
+
社区共建计划:你的名字可以写进世界观
+
+

图鉴系统规划正式确定,分为「种族 / 建筑 / 生物 / 神器」四大类目。我们决定把相当一部分条目开放给社区共建。

+

被采纳的投稿会标注作者署名,并永久保留在官方图鉴中。第一批评审将在论坛「共建」分区进行,欢迎有想法的园丁报名。

+
+
+
+
+
+
+
2026.06.12
+
限量内测招募开启
+
+

宇森首轮封闭内测招募正式开启,名额 500 人。我们将优先邀请在论坛持续输出高质量内容的园丁,以及愿意参与玩法共创的核心用户。

+

内测的重点不是「好不好玩」,而是「哪里别扭」——你的每一条吐槽,都会直接进入我们的周度复盘会。

+
+
+
@@ -531,7 +619,6 @@
-
世界数据WORLD DATA
@@ -737,7 +824,8 @@ // forum post (in-session demo) const postForm=document.getElementById('postForm'); - document.getElementById('newPost').addEventListener('click',()=>postForm.classList.toggle('show')); + const newPostBtn=document.getElementById('newPost'); + if(newPostBtn && postForm) newPostBtn.addEventListener('click',()=>postForm.classList.toggle('show')); document.getElementById('pfCancel').addEventListener('click',()=>postForm.classList.remove('show')); document.getElementById('pfOk').addEventListener('click',()=>{ const t=document.getElementById('pfTitle').value.trim(); @@ -791,7 +879,7 @@ // ============ 背景页:从 odoo 拉取页面板块渲染 ============ async function renderBackground(){ - const box = document.getElementById('background'); + const box = document.getElementById('bgBody'); if(!box) return; try{ const r = await fetch('/game/api/pages?db=game', {credentials:'same-origin'}); diff --git a/wc1.js b/wc1.js new file mode 100644 index 0000000..e910228 --- /dev/null +++ b/wc1.js @@ -0,0 +1,2963 @@ + +// ============ 配置 ============ +const MAP_RADIUS = 4; +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) { + 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) { + 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.jpg' }, + { id:'sky', name:'天空', icon:'🌤', image:'assets/bg/bg_sky.jpg' }, + { id:'earth', name:'大地', icon:'🌍', image:'assets/bg/bg_earth.jpg', sandbox:true }, + { id:'under', name:'地下', icon:'🕳', image:'assets/bg/bg_under.jpg' }, + { id:'core', name:'地心', icon:'🔥', image:'assets/bg/bg_core.jpg' }, +]; + + +// ============ 时间轴 / 昼夜系统(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_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.78; // [PLACEHOLDER] 夜部最亮保底 + 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.55; // [PLACEHOLDER] 染色强度 + 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 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 } }, + house: { name: '民居', icon: '🏠', color: '#64b5f6', yield: { pop: 4 } }, + 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'); +const ctx = canvas.getContext('2d'); +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 = 1; // 阴影投影强度淡入淡出:交互中→0(关),松手/停滚→1(渐入),避免明暗硬跳变 +let worldCache = null, worldCacheCtx = null; // 交互期离屏世界快照:静止帧把世界烘焙进此画布,拖动/缩放时仅 blit(O(1)),纹理不丢、不掉帧 +let worldCacheValid = false; +const _cachedCam = { x: 0, y: 0, zoom: 1 }; // 快照对应的相机状态,交互期按相机增量平移/缩放 blit +let releaseBlend = 0; // 松手过渡淡出:0=不混合,>0 时在静止帧上方叠快照(globalAlpha 衰减),平滑 blit→全量的亚像素跳 +const MIN_ZOOM = 0.7, MAX_ZOOM = 7; // 缩放下限抬高(别太小)+ 上限 +3 单位(原 0.4 / 4) + +let tiles = []; + +// ============ 建造模式状态 ============ +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 stars = []; +let embers = []; +let ambParticles = []; + +// 微观世界 +let microState = 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 + +// ============ 地图生成 ============ +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), + energy: 60 + Math.floor(Math.random() * 40), + mood: ['😊','😐','😄'][Math.floor(Math.random() * 3)], + level: Math.floor(Math.random() * 3) + 1 + }); + } + // 高程:地形决定基础档位 + 确定性噪声在带内微抖(+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 + }); +} + +function generateMap() { + tiles = []; + 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'); + } +} + +// ============ 坐标工具 ============ +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 + const dim = (L - 1) * 60; // 全局压暗量(夜晚约 -23) + 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); + 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(); + } + } +} + +// ============ 渲染 ============ +// 把当前主画布(已全细节渲染的世界)烘焙进离屏快照,供交互期 blit(O(1) 单图绘制,纹理不丢、不掉帧) +function snapshotWorld() { + if (!worldCache) { worldCache = document.createElement('canvas'); worldCacheCtx = worldCache.getContext('2d'); } + if (worldCache.width !== W || worldCache.height !== H) { worldCache.width = W; worldCache.height = H; } + worldCacheCtx.setTransform(1, 0, 0, 1, 0, 0); + worldCacheCtx.clearRect(0, 0, W, H); + worldCacheCtx.drawImage(canvas, 0, 0); + _cachedCam.x = camera.x; _cachedCam.y = camera.y; _cachedCam.zoom = camera.zoom; +} +function render() { + if (microViewOpen) { requestAnimationFrame(render); return; } // 微观视图打开时主世界暂停重绘,把 CPU 让给微观渲染循环 + const interacting = camInteracting; // 拖动/缩放进行中:复用世界快照,松手后自动恢复 + // 阴影投影常驻 1:交互期走快照 blit(不再每帧重绘带模糊卷积),固定 1,松手帧与拖动帧像素级一致。 + const targetShadow = 1; + shadowFade += (targetShadow - shadowFade) * 0.14; + if (shadowFade < 0.003) shadowFade = 0; + + // ---- 交互快速路径:只 blit 快照,跳过时间/UI/调色板/全量重绘 ---- + // 交互期 timeOfDay 已冻结,syncTimeUI/refreshWorldVideo/getSky 的结果不变,跑了纯空转; + // 鼠标快速移动时每帧只做一次 drawImage → 跟手不卡。 + if (interacting) { + if (!worldCacheValid) { snapshotWorld(); worldCacheValid = true; } + const sc = camera.zoom / (_cachedCam.zoom || 1); + const tx = (W / 2 + camera.x) - sc * (W / 2 + _cachedCam.x); + const ty = (H / 2 + camera.y) - sc * (H / 2 + _cachedCam.y); + // 拖动期保持画布透明(与静止帧完全一致),底层 CSS 场景图无缝透出、固定不跳; + // 非场景图层无 CSS 底图,补调色板渐变兜底(用上一次 currentPalette,时间冻结所以不变)。 + if (useImageBg) { + ctx.clearRect(0, 0, W, H); + } else { + const _pal = currentPalette; + const _g = ctx.createLinearGradient(0, 0, 0, H); + _g.addColorStop(0, _pal.top); _g.addColorStop(0.55, _pal.mid); _g.addColorStop(1, _pal.bot); + ctx.fillStyle = _g; ctx.fillRect(0, 0, W, H); + } + ctx.drawImage(worldCache, 0, 0, W, H, tx, ty, W * sc, H * sc); + requestAnimationFrame(render); + return; + } + + // ---- 静止帧:时间推进 + UI 刷新 + 调色板 + 全量重绘 ---- + animTime += 0.016; + if (timePlaying) { + const prevT = timeOfDay; + timeOfDay = nowHours(); // 静止帧同步系统真实时间 + if (timeOfDay < prevT) gameDay++; // 跨过午夜,游戏天数 +1(驱动日历/季节) + } + syncTimeUI(); // 每帧刷新时钟(即便暂停也刷);秒针由真实时间驱动持续扫动 + refreshWorldVideo(); // 时间节点变化则切换背景视频(其余帧直接跳过) + currentPalette = getSky(currentLayer, timeOfDay); + worldCacheValid = false; // 静止帧:下次交互重新烘焙快照(世界可能已变化) + + ctx.clearRect(0, 0, W, H); + drawBackground(currentLayer); + // 时间色调叠加:仅大地/天空随昼夜变色调(星空/地下/地心不染时间色),不切换视频本身 + if (useImageBg && (currentLayer === 'earth' || currentLayer === 'sky') && currentPalette.tintA > 0.001) { + const t = currentPalette.tint; + ctx.fillStyle = `rgba(${t[0]},${t[1]},${t[2]},${currentPalette.tintA})`; + ctx.fillRect(0, 0, W, H); + } + + const size = POLY_SIZE * camera.zoom; + // 地块绘制顺序:交互(拖动/缩放)期间复用上次缓存(tiles 集合与相邻关系不变),静止帧每帧重建以保证建造结果即时可见 + let sortedTiles; + if (interacting && _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 (!interacting || !_tileGrid) buildTileGrid(); // 拖动/缩放期间复用上次网格索引(相邻判断不变);首次进入交互时兜底重建一次 + + sortedTiles.forEach(tile => { + try { + const { x, y } = hexToPixel(tile.q, tile.r); + const ez = heightToTier(tile.height != null ? tile.height : terrainToHeight(tile.terrain)) * TIER_STEP * camera.zoom; // 高程由 height 派生(唯一真值),避免 tier 两套刻度不一致导致刷地形/移动时块高度不变 + 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 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)); + + let fillColor = terrain.color; + let strokeColor = 'rgba(255,255,255,0.12)'; + let lineWidth = 1; + + if (isSwapFirst || isSwapFrom) { strokeColor = '#ffd54f'; lineWidth = 2.5; } + else if (isSwapTarget) { strokeColor = '#ffb74d'; lineWidth = 2; } + else if (isSelected) { strokeColor = '#4af'; lineWidth = 2.5; } + else if (isHovered) { strokeColor = 'rgba(100,200,255,0.6)'; lineWidth = 1.5; } + + // 高程偏移:按档位轻微抬升顶面(cyTop 随 tier 上移),高度差清晰可读;阶差小不刺眼 + const cyTop = y - ez; // 顶面中心:高地貌抬升、低地貌下沉 + const wallH = Math.max(2, ez + SANDBOX_DEPTH * camera.zoom); + + // A. 六棱柱本体(柱体) + C. 顶面方向光(亮度保底 / 柔和 / 悬停轮廓) + drawHexPrism(x, cyTop, size * 1.0, wallH, fillColor, isHovered, tile); + + // 选中实线描边 + if (isSelected) drawPoly(x, cyTop, size * 1.0, null, '#4af', 2.5); + if (isSwapFirst || isSwapFrom) drawPoly(x, cyTop, size * 1.0, null, '#ffd54f', 2.5); + if (isSwapTarget) drawPoly(x, cyTop, size * 1.0, null, '#ffb74d', 2); + + // 悬停光晕(轮廓已由 drawHexPrism 绘制,这里只加辉光) + 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(); + } + } catch(e) { /* 单格渲染错误不中断其余格子 */ } + }); + + // 交换闪光:金色连线淡出 + if (swapFlashes.length) { + for (let i = swapFlashes.length - 1; i >= 0; i--) { + const f = swapFlashes[i]; f.life -= 0.04; + if (f.life <= 0) { swapFlashes.splice(i, 1); continue; } + const pa = hexToPixel(f.a.q, f.a.r), pb = hexToPixel(f.b.q, f.b.r); + ctx.save(); + ctx.globalAlpha = f.life; + ctx.strokeStyle = '#ffd54f'; ctx.lineWidth = 3 * camera.zoom; + ctx.setLineDash([6 * camera.zoom, 5 * camera.zoom]); + ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y); ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + } + } + + // 松手过渡淡出:在上方叠放拖动的快照(同 camera 变换 + 衰减 alpha),平滑 blit→全量 的亚像素/插值差异, + // 2-3 帧消隐,过渡无跳。仅场景图层(useImageBg=透明画布)做过渡,非场景图层直接切。 + if (releaseBlend > 0.003) { + ctx.save(); + ctx.globalAlpha = Math.min(releaseBlend, 1); + const sc = camera.zoom / (_cachedCam.zoom || 1); + const tx = (W / 2 + camera.x) - sc * (W / 2 + _cachedCam.x); + const ty = (H / 2 + camera.y) - sc * (H / 2 + _cachedCam.y); + ctx.drawImage(worldCache, 0, 0, W, H, tx, ty, W * sc, H * sc); + ctx.restore(); + releaseBlend *= 0.4; // 快速衰减:1→0.4→0.16→0.06→0.03(4 帧 ~67ms,基本不可感知) + if (releaseBlend < 0.004) releaseBlend = 0; + } + + requestAnimationFrame(render); +} + +// ============ 分层背景(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 drawBackground(layer) { + const pal = currentPalette; + // 场景背景图接入:透明画布,让底层 透出(图片层自行呈现背景) + if (useImageBg) { + ctx.clearRect(0, 0, W, H); + return; + } + 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); + if (pal.star > 0.05) drawStars(); + drawSilhouette(); + } 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.png', + 'assets/art/transitions/transition_adventure.png', + 'assets/art/transitions/transition_sky.png', + 'assets/art/transitions/transition_forest.png', + 'assets/art/transitions/transition_night.png', + 'assets/art/transitions/transition_dawn.png', + 'assets/art/transitions/transition_coast.png', + 'assets/art/transitions/transition_skycity.png', +]; +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 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 || [], + resources: calcResources(tile), + animTime: 0, + particles: [] + }; + // 重置缩放与平移 + microZoom = 1; microPanX = 0; microPanY = 0; + microViewOpen = true; // 暂停主世界重绘 + microView.classList.add('active'); // 入场动画(缩放+去模糊)在遮罩掩护下播放,揭幕时早已归位 + const layerLabel = { earth:'🌍 大地', under:'🕳 地下', core:'🔥 地心', sky:'🌤 天空', star:'✦ 星空' }[tile.layer] || ''; + document.getElementById('microTitle').textContent = `${layerLabel} · ${TERRAIN[tile.terrain].name}村落`; + document.getElementById('microSubtitle').textContent = `坐标(${tile.q}, ${tile.r}) · ${microState.units.length}位居民`; + applyMicroAccent(tile.terrain); + updateSidebar(); + initMicroCanvas(); + startMicroRender(); + // 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 = `
+ ${b.icon} +
${b.name}
+
Lv.${tile.buildingLevel}
`; + } else { buildingDiv.innerHTML = '
'; } + + 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]) => `
+ ${resNames[k]||k} +
+
+ +${v}
`).join(''); + } else { resDiv.innerHTML = '
无产出
'; } + + const popDiv = document.getElementById('detailPopulation'); + if (microState.units.length > 0) { + popDiv.innerHTML = microState.units.map(u => `
+
+ ${u.icon} +
${u.name}
${u.status === 'working' ? '🟢' : u.status === 'resting' ? '🟡' : '🔵'} ${u.status}
+
`).join(''); + } else { popDiv.innerHTML = '
暂无居民
'; } +} + +// ============ 微观世界 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(); // 构建背景 + 地形场离屏缓存(静态,逐帧只贴图) +} + +function startMicroRender() { + if (!microState) return; + if (microAnimId) cancelAnimationFrame(microAnimId); + + function loop() { + if (!microState) return; + microAnimId = requestAnimationFrame(loop); + microState.animTime += 0.016; + const mW = microCanvas.width, mH = microCanvas.height; + const terrain = TERRAIN[microState.tile.terrain]; + + // ---- 不透明兜底:避免 clearRect 透出 body 黑底造成"黑影闪" ---- + microCtx.fillStyle = '#0a0e17'; + microCtx.fillRect(0, 0, mW, mH); + + // ---- 套缩放+平移变换(场景内容随 zoom/pan 移动)---- + microCtx.save(); + microCtx.translate(mW / 2 + microPanX, mH / 2 + microPanY); + microCtx.scale(microZoom, microZoom); + 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'); + + const Rx = mW * 0.50, Ry = mH * 0.43; + const cx = mW / 2, cy = mH / 2; + + // 过渡时居民随新视图一起柔和淡入 + let unitAlpha = 1; + if (microState.transition) { + const tr2 = microState.transition; + const p2 = Math.min(1, (performance.now() - tr2.t0) / tr2.dur); + unitAlpha = 1 - Math.pow(1 - p2, 3); + } + + // 居民:浮岛内确定性散点 + 中心避让 + 脉冲光环 + microCtx.globalAlpha = unitAlpha; + const padX = Rx * 0.12, padY = Ry * 0.12, minD = Math.min(Rx, Ry) * 0.24; + microState.units.forEach((u, k) => { + const h = microHash(u.id); + const rx1 = ((h >> 2) % 100) / 100; + const ry1 = ((h >> 9) % 100) / 100; + let px = cx + (rx1 * 2 - 1) * (Rx - padX); + let 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; } + const pulse = 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 * pulse; + microCtx.fillStyle = col; + microCtx.beginPath(); microCtx.arc(px, py, Math.max(11, Math.min(Rx,Ry) * 0.065) * (0.9 + 0.2 * pulse), 0, Math.PI * 2); microCtx.fill(); + microCtx.restore(); + // 底圈 + microCtx.fillStyle = 'rgba(18,26,38,0.92)'; + microCtx.beginPath(); microCtx.arc(px, py, 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.font = `${Math.max(15, Math.min(Rx,Ry) * 0.075)}px sans-serif`; + microCtx.textAlign = 'center'; microCtx.textBaseline = 'middle'; + microCtx.fillText(u.icon, px, py + 1); + }); + microCtx.globalAlpha = 1; + + // 大气粒子层 + 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); + // 滚轮:以鼠标位置为中心缩放 + 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; + return { + x: (sx - mW / 2 - microPanX) / microZoom + mW / 2, + y: (sy - mH / 2 - microPanY) / microZoom + mH / 2 + }; +} +// 平移限位:像缩放下限一样,浮岛不能拖出视野 +// zoom=1 时限位为 0(浮岛居中、不可平移);zoom>1 时允许平移到「浮岛边缘抵达屏幕边」为止 +function clampMicroPan() { + const mW = microCanvas.width, mH = microCanvas.height; + const Rx = mW * 0.50, Ry = mH * 0.43; // 与浮岛绘制半径一致 + const maxX = Math.max(0, Rx * microZoom - mW / 2); + const maxY = Math.max(0, Ry * microZoom - 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 (e.button === 1 || e.button === 2) { // 中键 or 右键 + e.preventDefault(); + microPanning = true; + microPanStart = getMicroPoint(e); + microCanvas.style.cursor = 'move'; + return; // 不启动 swipe + } + const p = getMicroPoint(e); + // 放大状态下(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; + // 平移拖拽中 + if (microPanning && microPanStart) { + const p = getMicroPoint(e); + microPanX += p.x - microPanStart.x; + microPanY += p.y - microPanStart.y; + microPanStart = p; + clampMicroPan(); + return; + } + 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; +} +function onMicroUp(e) { + if (!microState) 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) { + // 未达阈值:保留 microSwipe,由 loop 弹性回弹,避免生硬瞬回 + 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 || []; + microState.buildings = tile.hasBuilding + ? [{ ...BUILDINGS[tile.buildingType], type: tile.buildingType, level: tile.buildingLevel, hp: 80 + tile.buildingLevel * 10 }] + : []; + microState.resources = calcResources(tile); + const layerLabel = { earth: '🌍 大地', under: '🕳 地下', core: '🔥 地心', sky: '🌤 天空', star: '✦ 星空' }[tile.layer] || ''; + document.getElementById('microTitle').textContent = `${layerLabel} · ${TERRAIN[tile.terrain].name}村落`; + document.getElementById('microSubtitle').textContent = `坐标(${tile.q}, ${tile.r}) · ${microState.units.length}位居民`; + updateSidebar(); +} +// 快照旧视图 → 切换地块 → 重建缓存 → 启动滑入过渡 +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 }; +} + +// ============ 微观世界:圆形浮岛视图(参考浮岛参考图:清晰 + 内容丰富) ============ + +// 离屏缓存:暗背景 + 圆形浮岛(基地渐变 + 地形装饰 + 建筑)+ 边缘柔化,进入时构建一次 +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 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(); // 结束岛屿剪裁 + + // 岛屿边缘柔化:仅最外圈擦淡融入暗背景(集中在边缘,不侵蚀内容) + 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; + const modal = document.getElementById('unitModal'); + document.getElementById('modalTitle').textContent = `${unit.icon} ${unit.name}`; + const hpColor = unit.hp > 70 ? '#8bc34a' : unit.hp > 40 ? '#ff9800' : '#f44336'; + const enColor = unit.energy > 70 ? '#4af' : unit.energy > 40 ? '#ff9800' : '#f44336'; + document.getElementById('modalBody').innerHTML = ` +
状态${unit.status==='working'?'🟢 工作中':unit.status==='resting'?'🟡 休息中':'🔵 移动中'}
+
生命值
${unit.hp}
+
能量
${unit.energy}
+
等级Lv.${unit.level} ${'★'.repeat(unit.level)}${'☆'.repeat(3-unit.level)}
+
心情${unit.mood}
+
操作指令
+
📋 派遣工作
+
😴 令其休息
+
🚶 移动到指定位置
+
⬆️ 训练升级
+
+ `; + modal.classList.add('active'); +} + +function closeUnitModal() { document.getElementById('unitModal').classList.remove('active'); } + +// ============ 工具函数 ============ +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)); +}, { 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; + // 建造 · 移动工具:按住即进入"搬运交换",不平移相机 + 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; // 搬运中:只更新目标高亮 + } + } 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; + + // 建造模式 · 移动工具:拖拽/点击交换板块 + 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 (buildMode && currentLayer === 'earth') { + handleBuildClick(tile); + } else { + selectedTile = tile; + enterMicroWorld(tile); + } + } + } +}); + +canvas.addEventListener('mouseleave', () => { + isMouseDown = false; + hasDragged = 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; + } + }, { 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') return; + if (tile.terrain !== buildBrush) { + tile.terrain = buildBrush; + tile.height = terrainToHeight(buildBrush); + tile.tier = heightToTier(tile.height); // tier 从 height 派生,与渲染一致 + markDirty(tile.q, tile.r); + } + // 素材变体:random=按坐标确定性取一张(固定到该格,刷新一致);指定=用玩家选的下标 + 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; + } +} + +// 交换两板块的全部世界内容(位置 q,r 不变,仅内容互换) +function swapTiles(a, b) { + 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 `
${TICON[id] || ''} ${TERRAIN[id].name}${c} 块 (${pct}%)
`; + }).join(''); + body.innerHTML = ` +
+
概览
+
地块总数${s.total}
+
平均高度${s.avgH.toFixed(1)}
+
高度区间${s.minH} – ${s.maxH}
+
+
+
地形分布
+ ${terrRows} +
+
${usingRemote ? '已连接后端世界数据接口(/yt_world/api/data)' : '演示数据:前端实时从地块计算。后端接口 /yt_world/api/data 待接入。'}
+ `; + } + + // ===== 包裹面板(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 '
'; + const has = it.ct !== '∞'; + return `
+ ${it.ico}${has ? it.ct : ''}
`; + }).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 `${d ? d.name : p}`; + }).join('、') + : '前置:无(启明科技)'; + let btn; + if (done) btn = ``; + else if (!met) btn = ``; + else btn = ``; + return `
+
${t.ico}${t.name}
+
${t.desc}
+
${preHtml}
+
花费 ${t.cost} 研究点
+ ${btn} +
`; + }).join(''); + return `
${TECH_TIER_NAMES[tier] || ('阶'+tier)} · 第 ${+tier+1} 阶
${nodes}
`; + }).join(''); + + body.innerHTML = ` +
+ 研究点 + ${techState.points} + +
+ ${tiers} +
演示科技树:前置满足且研究点充足即可研习,状态存本地。后端接口 /yt_world/api/tech 待接入,将支持跨端同步与真正的增益结算。
+ `; + 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 btnBuild = document.getElementById('btnBuild'); + const panel = document.getElementById('buildPanel'); + const bpClose = document.getElementById('bpClose'); + 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'); + if (!btnBuild || !panel) return; + + 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 = '移动工具:点第一块再点第二块交换,或按住一块拖到另一块松手交换。'; + } + }; + + document.querySelectorAll('.bp-tool').forEach(btn => { + btn.onclick = () => { + document.querySelectorAll('.bp-tool').forEach(x => x.classList.remove('active')); + btn.classList.add('active'); + buildTool = btn.dataset.tool; + renderBrush(); + }; + }); + + // 高度滑块:拖动即把当前悬停格设为滑块绝对高度(每格独立,不联动周边) + 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); + }; + + // ===== 底部 4 按钮 → 统一左侧浮层面板(无顶部标签栏) ===== + const btnData = document.getElementById('btnData'); + const btnInventory = document.getElementById('btnInventory'); + const btnTech = document.getElementById('btnTech'); + const sidePanel = document.getElementById('sidePanel'); + const spBodies = document.querySelectorAll('.sp-body'); + let currentSpTab = null; + const SP_BODY = { data:'spDataBody', inventory:'spInvBody', build:'spBuildBody', tech:'spTechBody' }; + + function openSidePanel(tab) { + sidePanel.classList.add('open'); + btnData.classList.toggle('active', tab === 'data'); + btnInventory.classList.toggle('active', tab === 'inventory'); + btnBuild.classList.toggle('active', tab === 'build'); + btnTech.classList.toggle('active', tab === 'tech'); + spBodies.forEach(b => b.classList.toggle('active', b.id === SP_BODY[tab])); + currentSpTab = tab; + if (tab === 'data') renderData(); + if (tab === 'inventory') renderInventory(); + if (tab === 'build') { + buildMode = true; + document.getElementById('buildPanel').classList.add('active'); + if (currentLayer !== 'earth') switchWorld('earth'); + renderBrush(); + } + if (tab === 'tech') renderTech(); + } + + function closeSidePanel() { + sidePanel.classList.remove('open'); + btnData.classList.remove('active'); btnInventory.classList.remove('active'); btnBuild.classList.remove('active'); btnTech.classList.remove('active'); + if (currentSpTab === 'build') { + buildMode = false; + document.getElementById('buildPanel').classList.remove('active'); + swapFirst = null; draggingSwap = false; dragSwapFrom = null; + } + currentSpTab = null; + } + + btnData.addEventListener('click', () => { + if (currentSpTab === 'data') { closeSidePanel(); return; } + openSidePanel('data'); + }); + btnInventory.addEventListener('click', () => { + if (currentSpTab === 'inventory') { closeSidePanel(); return; } + openSidePanel('inventory'); + }); + btnBuild.addEventListener('click', () => { + if (currentSpTab === 'build') { closeSidePanel(); return; } + openSidePanel('build'); + }); + btnTech.addEventListener('click', () => { + if (currentSpTab === 'tech') { closeSidePanel(); return; } + openSidePanel('tech'); + }); + + // 关闭按钮 → 关闭整个浮层面板 + if (bpClose) bpClose.onclick = () => { + buildMode = false; + document.getElementById('buildPanel').classList.remove('active'); + swapFirst = null; draggingSwap = false; dragSwapFrom = null; + closeSidePanel(); + }; + + 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); + }); + tiles = saved; + } catch(e) {} +} + +// ============ 后端记忆:地块增量持久化(yt_world 模块) ============ +// 底图由坐标确定性生成(generateMap),只把"玩家改动的地块"增量存后端。 +let dirtyTiles = new Set(); // "q,r" 集合:待保存的改动地块 +let saveTimer = null; +let settingsTimer = null; + +function markDirty(q, r) { + dirtyTiles.add(q + ',' + r); + scheduleSave(); +} +function scheduleSave() { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => flushSave(), 800); +} +async function flushSave(btn) { + if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } + if (!dirtyTiles.size) { + if (btn) { btn.textContent = '✅ 已保存'; setTimeout(() => { btn.textContent = '保存'; }, 1200); } + return; + } + // 收集改动地块(仅大地层) + const byKey = new Map(); + for (const t of tiles) if (t.layer === 'earth') byKey.set(t.q + ',' + t.r, t); + const batch = []; + for (const key of dirtyTiles) { + const t = byKey.get(key); + if (!t) continue; + batch.push({ q: t.q, r: t.r, terrain: t.terrain || null, height: t.height, flags: t.flags || {} }); + } + dirtyTiles.clear(); + const oldText = btn ? btn.textContent : null; + try { + await API.saveTiles(batch); + if (btn) { btn.textContent = '✅ 已保存'; setTimeout(() => { btn.textContent = '保存'; }, 1200); } + } catch (e) { + for (const b of batch) dirtyTiles.add(b.q + ',' + b.r); // 失败重试:重新标脏 + if (btn) { btn.textContent = '⚠ 失败'; setTimeout(() => { btn.textContent = oldText || '保存'; }, 1400); } + } +} +// 启动:用后端存档覆盖底图(地形/高度/flags 原地覆盖,不重跑建筑随机) +function applyWorldState(ws) { + if (!ws) return; + if (ws.settings) applyWorldSettings(ws.settings); + if (!Array.isArray(ws.tiles)) return; + const byKey = new Map(); + for (const t of tiles) if (t.layer === 'earth') byKey.set(t.q + ',' + t.r, t); + for (const ov of ws.tiles) { + const t = byKey.get(ov.q + ',' + ov.r); + if (!t) continue; + if (ov.terrain) t.terrain = ov.terrain; + if (ov.height != null) { t.height = ov.height; t.tier = heightToTier(ov.height); } + if (ov.flags) t.flags = ov.flags; + } +} +// 相机/UI 偏好收集 + 防抖保存 +function collectWorldSettings() { + return { camera: { x: camera.x, y: camera.y, zoom: camera.zoom } }; +} +function applyWorldSettings(s) { + if (!s || !s.camera) return; + const c = s.camera; + if (typeof c.x === 'number') camera.x = c.x; + if (typeof c.y === 'number') camera.y = c.y; + if (typeof c.zoom === 'number') camera.zoom = 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(); }); + +function exitMicroWorld() { + document.getElementById('microView').classList.remove('active'); + if (microAnimId) { cancelAnimationFrame(microAnimId); microAnimId = null; } + microState = null; + microGridCache = null; + 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 mW = microCanvas.width, mH = microCanvas.height; + const cx = mW / 2, cy = mH / 2; + const Rx = mW * 0.50, Ry = mH * 0.43; + const cell = 25; // 网格单元像素(密度翻倍) + + c.save(); + + // 裁剪区域:纯矩形,向内缩一格(25px),网格不贴浮岛边缘 + // 扩展 3px 给双描边留空间,防止右/下边线被 clip 截断 + const pad = cell; // 向内缩一个网格单位 + const clipPad = 3; // 描边溢出补偿 + const X0 = cx - Rx + pad, Y0 = cy - Ry + pad, RW = Rx * 2 - pad * 2, RH = Ry * 2 - pad * 2; + c.beginPath(); + c.rect(X0 - clipPad, Y0 - clipPad, RW + clipPad * 2, RH + clipPad * 2); + c.clip(); + + // 矩形网格线(双描边:深色打底 + 浅色高光,保证任意地块上都清晰,作为建造参考) + c.beginPath(); + for (let x = X0; x <= X0 + RW + cell; x += cell) { + c.moveTo(x, Y0); c.lineTo(x, Y0 + RH); + } + for (let y = Y0; y <= Y0 + RH + cell; y += cell) { + c.moveTo(X0, y); c.lineTo(X0 + RW, y); + } + // round 线帽/连接:补死四角 1~2px 缺口(竖线不到上、横线不到左的象限) + c.lineCap = 'round'; + c.lineJoin = 'round'; + c.globalAlpha = 1; + // 第一遍:深色描边(在亮色地块上可见) + c.lineWidth = 2.2; + c.strokeStyle = 'rgba(8,14,26,0.32)'; + c.stroke(); + // 第二遍:浅色高光(在暗色地块上可见) + c.lineWidth = 1.1; + c.strokeStyle = 'rgba(255,255,255,0.55)'; + c.stroke(); + c.restore(); +} + +// 顶部按钮组:网格 + 缩放 +document.getElementById('microGridBtn').addEventListener('click', e => { + microGridOn = !microGridOn; + e.currentTarget.classList.toggle('active', microGridOn); +}); + +// 窗口尺寸变化:重建画布与静态缓存(微观视图内) +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 + }); + } +} + +// 调试控件与当前帧同步(自动推进时刷新滑块/文案) +// 时间线 UI:右上角闹钟(24h 表盘,指针走动;点击播放/暂停;拖动调时) +function syncTimeUI() { + 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 hH = document.getElementById('hourHand'), mH = document.getElementById('minHand'); + if (hH) hH.setAttribute('transform', `rotate(${timeOfDay / 24 * 360} 50 50)`); + if (mH) mH.setAttribute('transform', `rotate(${(timeOfDay % 1) * 360} 50 50)`); + const sH = document.getElementById('secHand'); + if (sH) sH.setAttribute('transform', `rotate(${(performance.now() / 1000 % 60) * 6} 50 50)`); // 真实墙钟时间驱动:1现实秒=6°,与现实秒针 100% 一致,最稳 + // 背景色 + 外环色:随当前时间节点变化(清晨橙/正午蓝/黄昏红橙/黑夜深蓝) + const ni = currentNodeIndex(timeOfDay); + const col = NODE_RGB[ni]; + const cw = document.getElementById('clockWidget'); + if (cw) cw.style.background = `rgba(14,20,36,.85)`; // 统一深底,不额外着色;四段弧的颜色已足够区分阶段 + // 当前阶段的彩色弧高亮(不透明度 .55 → 1)、其他段压暗,其它 stage 名变淡 + const segIds = ['segDawn', 'segDay', 'segDusk', 'segNight']; + for (let i = 0; i < 4; i++) { + const seg = document.getElementById(segIds[i]); + if (seg) seg.setAttribute('opacity', i === ni ? '1' : '.32'); + } + document.querySelectorAll('#stageLabels text').forEach(t => { + const s = +t.getAttribute('data-stage'); + t.setAttribute('opacity', s === ni ? '1' : '.35'); + }); + // 游戏日历:纪·年·季节 + 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) { + currentLayer = id; + refreshWorldVideo(); + syncWorldNav(); +} +function syncWorldNav() { + document.querySelectorAll('#worldNav .wn-item').forEach(el => { + el.classList.toggle('active', el.dataset.world === currentLayer); + }); + // 顶栏页签 + 浏览器标签随当前层更新 + const nm = WORLD_NAMES[currentLayer] || '大地'; + const wn = document.getElementById('worldName'); + if (wn) wn.textContent = nm; + 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 = + `` + + `${t.name}` + + `` + + `${v}`; + 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(); + }); +} + +// ============ 初始化 ============ +function init() { + W = canvas.width = window.innerWidth; + H = canvas.height = window.innerHeight - BOTTOM_BAR_H; + canvas.classList.add('canDrag'); + + initStars(); + initEmbers(); + initAmbParticles(); + // 世界管理面板(地表比例自调) + initWorldManager(); + // 建造模式面板(地形 / 板块交换) + setupBuildUI(); + // 右上角闹钟:刻度 + 点击播放暂停 + 拖动调时 + const ticks = document.getElementById('clockTicks'); + if (ticks) for (let i = 0; i < 24; i++) { + const a = (i / 24) * Math.PI * 2 - Math.PI / 2; + const isMajor = (i % 6 === 0); + const r1 = 44, r2 = isMajor ? 36 : 40.5; + const x1 = 50 + r1 * Math.cos(a), y1 = 50 + r1 * Math.sin(a); + const x2 = 50 + r2 * Math.cos(a), y2 = 50 + r2 * Math.sin(a); + const ln = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + ln.setAttribute('x1', x1); ln.setAttribute('y1', y1); + ln.setAttribute('x2', x2); ln.setAttribute('y2', y2); + ln.setAttribute('stroke', isMajor ? 'rgba(255,255,255,.85)' : 'rgba(255,255,255,.45)'); + ln.setAttribute('stroke-width', isMajor ? 2.0 : 1.2); + ticks.appendChild(ln); + } + // 时钟外环:4 段填充扇形(铺满整圆,从圆心到外弧的 pie wedge) + (function buildClockRing() { + const segs = TIME_KEYFRAMES; // [{h:5},{h:12},{h:18},{h:21}] + const ids = ['segDawn', 'segDay', 'segDusk', 'segNight']; + const R = 44; // 外半径(铺满整个表盘区域) + const cx = 50, cy = 50; + const pt = (h) => { // 小时 → 圆周坐标(0h 在 12 点钟) + const rad = (h / 24) * Math.PI * 2 - Math.PI / 2; + return { x: cx + R * Math.cos(rad), y: cy + R * Math.sin(rad) }; + }; + for (let i = 0; i < 4; i++) { + const h0 = segs[i].h; + const h1 = (i === 3) ? 24 : segs[i + 1].h; + const p0 = pt(h0), p1 = pt(h1); + const span = h1 - h0; + const large = span > 12 ? 1 : 0; + // 扇形路径:圆心 → 弧起点 → 弧(顺时针) → 弧终点 → 回圆心闭合 + const d = `M ${cx} ${cy} L ${p0.x} ${p0.y} A ${R} ${R} 0 ${large} 1 ${p1.x} ${p1.y} Z`; + const el = document.getElementById(ids[i]); + if (el) el.setAttribute('d', d); + } + })(); + // 4 阶段名标签:定位到对应时刻的圆周内(半径 30,文字在弧内侧) + (function placeStageLabels() { + const hours = [5, 12, 18, 21]; + const stages = [0, 1, 2, 3]; + const R = 23; // 阶段名文字半径:环内缘 r=25,文字内移到 23 避开彩色环 + hours.forEach((h, i) => { + const rad = (h / 24) * Math.PI * 2 - Math.PI / 2; + const x = 50 + R * Math.cos(rad); + const y = 50 + R * Math.sin(rad); + const tx = document.querySelector(`#stageLabels text[data-stage="${stages[i]}"]`); + if (tx) { tx.setAttribute('x', x); tx.setAttribute('y', y); } + }); + })(); + // 右上角时钟仅作展示(自动推进),不绑定暂停/拖动交互 + // 世界切换导航 + document.querySelectorAll('#worldNav .wn-item').forEach(el => { + el.addEventListener('click', () => switchWorld(el.dataset.world)); + }); + syncWorldNav(); + refreshWorldVideo(); // 初始世界(大地)加载对应背景视频 + syncTimeUI(); + // 加载动画:真实等待地形贴图全部解码完毕,再隐藏加载屏并启动渲染(预加载闸门,杜绝渐进 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; // 画布尺寸变化,交互快照失效,下次交互重新烘焙 +}); + +loadSavedRatio(); // 应用上次保存的地表比例 +generateMap(); +// 注意:地表改动不再走 localStorage,统一由登录闸门拉取后端存档 applyWorldState 覆盖 +init(); diff --git a/wc2.js b/wc2.js new file mode 100644 index 0000000..bff1570 --- /dev/null +++ b/wc2.js @@ -0,0 +1,47 @@ +