diff --git a/addons/yt_world/build_spec.py b/addons/yt_world/build_spec.py
index 10e14af..d6d558c 100644
--- a/addons/yt_world/build_spec.py
+++ b/addons/yt_world/build_spec.py
@@ -44,6 +44,24 @@ BUILD_SPECS = {
}
+def level_mult(level):
+ """建筑等级产出倍率:L1=1倍、L2=2倍……(线性,升级即等比提升)。
+ 所有结算/对账/速率计算都必须乘上此倍率,保证升级带来实际收益。"""
+ try:
+ lv = int(level or 1)
+ except (TypeError, ValueError):
+ lv = 1
+ return max(1, lv)
+
+
+def scaled(d, level):
+ """把基础产出字典按等级缩放:{k: v} → {k: v * level_mult(level)}。"""
+ if not d:
+ return {}
+ m = level_mult(level)
+ return {k: (v or 0) * m for k, v in d.items()}
+
+
def spec_defined(code):
"""某建筑编码是否在 BUILD_SPECS 中有明确定义。"""
return code in BUILD_SPECS
diff --git a/addons/yt_world/controllers/world_api.py b/addons/yt_world/controllers/world_api.py
index b6ca1b3..90eaa18 100644
--- a/addons/yt_world/controllers/world_api.py
+++ b/addons/yt_world/controllers/world_api.py
@@ -984,6 +984,10 @@ class YtWorldApi(http.Controller):
b['level'] = (b.get('level') or 1) + 1
b['exp'] = (b.get('exp') or 0) + 50
tile.micro_world_id.sudo().write({'build_items': items})
+ # 升级改变实际产出(人口/每分钟速率)→ 立即重算世界库存,
+ # 让前端侧栏"资源产出"马上体现提升(幂等 SET,不怕重复调用)
+ world.sudo()._recalc_rates()
+ world.sudo()._reconcile_instant()
return {'level': b['level'], 'exp': b['exp']}
return None
diff --git a/addons/yt_world/models/yt_world.py b/addons/yt_world/models/yt_world.py
index 118a68e..e75c4cc 100644
--- a/addons/yt_world/models/yt_world.py
+++ b/addons/yt_world/models/yt_world.py
@@ -110,8 +110,8 @@ class YtWorld(models.Model):
def _recalc_rates(self):
"""重新计算世界各资源总产出速率(每分钟),写回 stock.rate_per_min。
- 速率直接从代码规格 BUILD_SPECS 读取,不依赖 noupdate 的 XML 字段。"""
- from odoo.addons.yt_world.build_spec import get_spec
+ 速率直接从代码规格 BUILD_SPECS 读取并按建筑等级缩放,不依赖 noupdate 的 XML 字段。"""
+ from odoo.addons.yt_world.build_spec import get_spec, level_mult
Stock = self.env['yt.world.stock'].sudo()
Instance = self.env['yt.world.build.instance'].sudo()
for world in self:
@@ -124,7 +124,7 @@ class YtWorld(models.Model):
for micro in micros:
for inst in Instance.search([('micro_id', '=', micro.id)]):
for code, rate in (get_spec(inst.code).get('stat_yield_tick') or {}).items():
- rates[code] = rates.get(code, 0.0) + (rate or 0)
+ rates[code] = rates.get(code, 0.0) + (rate or 0) * level_mult(inst.level)
# 确保每种有产出的资源都有 stock 行
for code, rate in rates.items():
stock = Stock.get_or_create(world.id, code)
@@ -136,9 +136,9 @@ class YtWorld(models.Model):
return True
def _reconcile_instant(self):
- """对账即时属性(如 population):把世界库存 amount 设为当前建筑实例的合计。
+ """对账即时属性(如 population):把世界库存 amount 设为当前建筑实例的合计(按等级缩放)。
幂等 SET,避免与实例 create/unlink 钩子的增量 ± 叠加产生漂移。"""
- from odoo.addons.yt_world.build_spec import get_spec
+ from odoo.addons.yt_world.build_spec import get_spec, scaled
Stock = self.env['yt.world.stock'].sudo()
Instance = self.env['yt.world.build.instance'].sudo()
for world in self:
@@ -149,7 +149,7 @@ class YtWorld(models.Model):
micros._sync_build_instances()
for micro in micros:
for inst in Instance.search([('micro_id', '=', micro.id)]):
- for code, amt in (get_spec(inst.code).get('stat_yield_instant') or {}).items():
+ for code, amt in scaled(get_spec(inst.code).get('stat_yield_instant'), inst.level).items():
totals[code] = totals.get(code, 0.0) + (amt or 0)
for code, amt in totals.items():
stock = Stock.get_or_create(world.id, code)
diff --git a/addons/yt_world/models/yt_world_build_instance.py b/addons/yt_world/models/yt_world_build_instance.py
index 51649af..338b5d8 100644
--- a/addons/yt_world/models/yt_world_build_instance.py
+++ b/addons/yt_world/models/yt_world_build_instance.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
-from odoo.addons.yt_world.build_spec import get_spec
+from odoo.addons.yt_world.build_spec import get_spec, scaled
class YtWorldBuildInstance(models.Model):
@@ -47,32 +47,33 @@ class YtWorldBuildInstance(models.Model):
for rec in self:
world = rec.micro_id.tile_id.world_id
if world:
- captured.append((world.id, get_spec(rec.code)))
+ captured.append((world.id, get_spec(rec.code), rec.level))
res = super().unlink()
Stock = self.env['yt.world.stock'].sudo()
- for world_id, spec in captured:
- for code, rate in (spec.get('stat_yield_tick') or {}).items():
+ for world_id, spec, level in captured:
+ for code, rate in scaled(spec.get('stat_yield_tick'), level).items():
if rate:
stock = Stock.get_or_create(world_id, code)
stock.rate_per_min = max(0.0, (stock.rate_per_min or 0.0) - rate)
- for code, amt in (spec.get('stat_yield_instant') or {}).items():
+ for code, amt in scaled(spec.get('stat_yield_instant'), level).items():
if amt:
stock = Stock.get_or_create(world_id, code)
stock.amount = max(0.0, (stock.amount or 0.0) - amt)
return res
def _apply_spec(self, sign):
- """建造/恢复时 sign=+1 把产出加进世界库存;删除前 sign=-1 扣减。"""
+ """建造/恢复时 sign=+1 把产出加进世界库存;删除前 sign=-1 扣减。
+ 产出按建筑等级缩放(L1=1倍、L2=2倍……)。"""
world = self.micro_id.tile_id.world_id
if not world:
return
spec = get_spec(self.code)
Stock = self.env['yt.world.stock'].sudo()
- for code, rate in (spec.get('stat_yield_tick') or {}).items():
+ for code, rate in scaled(spec.get('stat_yield_tick'), self.level).items():
if rate:
stock = Stock.get_or_create(world.id, code)
stock.rate_per_min = (stock.rate_per_min or 0.0) + sign * rate
- for code, amt in (spec.get('stat_yield_instant') or {}).items():
+ for code, amt in scaled(spec.get('stat_yield_instant'), self.level).items():
if amt:
stock = Stock.get_or_create(world.id, code)
stock.amount = (stock.amount or 0.0) + sign * amt
diff --git a/addons/yt_world/models/yt_world_tile_micro.py b/addons/yt_world/models/yt_world_tile_micro.py
index a837ff4..d192a5a 100644
--- a/addons/yt_world/models/yt_world_tile_micro.py
+++ b/addons/yt_world/models/yt_world_tile_micro.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
-from odoo.addons.yt_world.build_spec import get_spec
+from odoo.addons.yt_world.build_spec import get_spec, level_mult
class YtWorldTileMicro(models.Model):
@@ -112,7 +112,7 @@ class YtWorldTileMicro(models.Model):
for micro in self:
instances = Instance.search([('micro_id', '=', micro.id)])
for inst in instances:
- # 直接从代码规格读取产出,永远最新,不依赖 noupdate 的 XML 字段
+ # 直接从代码规格读取产出(按建筑等级缩放),永远最新,不依赖 noupdate 的 XML 字段
tick = get_spec(inst.code).get('stat_yield_tick') or {}
if not tick:
continue
@@ -125,7 +125,7 @@ class YtWorldTileMicro(models.Model):
effective = capped * offline_efficiency
this_call = {} # 本轮新增量(在线增量)
for code, rate in tick.items():
- add = rate * effective
+ add = rate * level_mult(inst.level) * effective
if add <= 0:
continue
stored = dict(inst.stored or {})
diff --git a/bazi.html b/bazi.html
deleted file mode 100644
index 5263a5d..0000000
--- a/bazi.html
+++ /dev/null
@@ -1,556 +0,0 @@
-
-
-
-
-
-鹏宇 · 命盘
-
-
-
-
-
-
三 命 通 览
-
庚辰年 · 庚辰月 · 甲寅日 · 癸酉时
-
-
-
-
-
-
- 杀印相生格局
- · 庚金七杀生癸水正印 ·
- 癸水正印生甲木日主
-
-
-
-
-
💡 你说的"两个七杀一个正印"
-
-
- 完美对上。之前我按庚金日主算,七杀变丙、正印变己,自然对不上。
- 甲木日主,七杀=庚,正印=癸,一字不差。
-
-
-
-
-
-
杀印相生 · 格局评述
-
- 甲木参天生于辰月,暮春土旺木气渐衰。天干双透庚金七杀,克身之力极重,若无救应则身弱杀强、步步维艰。
-
-
-
- 妙在时柱见癸水正印贴身相生——
- 七杀的压力化作正印的学习动力,
- 正印再将养分输送给甲木日主。
- 压力→学识→成长,流通无阻。
-
-
- 时支酉金(辛正官)更为点睛——
- 杀印相生之后再生官,压力转化为权威,
- 代表最终走向管理岗、有实权有地位。
-
-
格局核心特质:
-
① 权威性格 · 不怒自威,天生有领导气场
-
② 以学促用 · 善于通过学习思考化解问题
-
③ 后发制人 · 前期压力大,但越走越稳
-
④ 管理才能 · 既有权威(杀)又有智慧(印)
-
-
-
-
-
十神全表
-
-
-
-
年支
-
辰 · 偏财
-
藏戊(偏财) 乙(劫财) 癸(正印)
-
-
-
-
月支
-
辰 · 偏财
-
藏戊(偏财) 乙(劫财) 癸(正印)
-
-
-
-
日支
-
寅 · 食神
-
藏甲(比肩) 丙(食神) 戊(偏财)
-
-
-
-
-
-
-
-
-
地支藏干
-
-
-
辰(年支/月支)
-
- 戊 偏财
- 乙 劫财
- 癸 正印
-
-
-
-
寅(日支)
-
- 甲 比肩
- 丙 食神
- 戊 偏财
-
-
-
-
-
-
-
-
-
纳音五行
-
-
-
4金
-
3木
-
3水
-
1火
-
3土
-
-
- 金旺为杀印之源,木有寅根得印生,
- 水火调候俱备,土为财星有制
-
-
-
-
-
-
大运 · 顺排
-
- 阳男顺排 · 约3岁起运 · 每10年一运
-
-
-
- | 03-12 |
- 辛巳 |
- 正官 |
- 2003-2012 |
-
-
- | 13-22 |
- 壬午 |
- 偏印 |
- 2013-2022 |
-
-
- | 23-32 |
- 癸未 |
- 正印 |
- 2023-2032 |
-
-
- | 33-42 |
- 甲申 |
- 比肩 |
- 2033-2042 |
-
-
- | 43-52 |
- 乙酉 |
- 劫财 |
- 2043-2052 |
-
-
- | 53-62 |
- 丙戌 |
- 食神 |
- 2053-2062 |
-
-
-
- 当前 · 癸未大运(正印运,增强印星力量)
-
-
-
-
-
-
2026丙午 · 流年
-
-
- 丙午流年,丙火食神透出,午火为伤官。食神泄秀生财,想法多、行动力强。
-
-
- 大运癸未与流年丙午:丙癸相见,正印配食神,以技术/知识创造价值的一年。食神制杀(丙制庚),压力感明显减轻。
-
-
- 午未合火,加强了全局的火力——食伤力量提升,适合技术产品落地、内容创作、方案输出。
-
-
- 注意:丙辛合(流年丙合时支辛),正官被合,职场/管理上可能有变动,或因新方向冲击原有岗位结构。食伤太旺耗身,注意精力分配。
-
-
-
-
-
-
-
格局总评
-
- 甲木参天 · 双庚为杀 · 癸水为印 · 杀印相生
-
-
- 三奇得一:年干庚(杀)→ 时干癸(印)→ 日主甲,链式流通。
- 日坐寅木(禄地),身不太弱,能扛住两庚克制。
- 时支酉官收尾,杀印生官——压力→智慧→地位,路数清晰。
-
-
-
- 庚辰年 · 庚辰月 · 甲寅日 · 癸酉时
-
-
-
- 山东东营广饶 · 真太阳时校正后仍为酉时
-
-
-
-
-
-
-
-
diff --git a/buildings_default_demo.html b/buildings_default_demo.html
deleted file mode 100644
index c052af3..0000000
--- a/buildings_default_demo.html
+++ /dev/null
@@ -1,256 +0,0 @@
-
-
-
-
-
-宇森 · 人族 18 帧状态机 Demo
-
-
-
-宇森 · 人族 18 帧状态机 Demo
-点击地面 放置角色 · 拖动或点击其它格子 让它走过去 · 动作按钮 / 键盘 A 攻击 · W 工作 · C 交流 · I 待机 · 也可用 window.triggerAction('attack') 控制 · 空格暂停 · R 重置
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/deploy_server.sh b/deploy_server.sh
deleted file mode 100644
index b94d330..0000000
--- a/deploy_server.sh
+++ /dev/null
@@ -1,40 +0,0 @@
-#!/usr/bin/env bash
-# 宇森游戏 · 线上服务器一键更新(请在 81.70.146.51 上执行)
-# 用法:sudo bash deploy_server.sh
-set -e
-
-# ===== 按实际情况修改这几个变量 =====
-REPO_DIR="/opt/yuthon/game" # git 仓库目录(含 world.html 与 addons/)
-ODOO_CONF="/etc/odoo/odoo.conf" # odoo.conf 路径
-ODOO_DB="yt_game" # 线上数据库名(game.pengyuthon.cn 对应 yt_game;勿改,避免影响 school)
-ODOO_SERVICE="odoo" # systemctl 服务名
-ODOO_BIN="/usr/bin/odoo" # odoo-bin 路径(或 which odoo)
-# ===================================
-
-echo "== [1/5] 拉取最新代码 =="
-cd "$REPO_DIR"
-git pull --ff-only origin main
-
-echo "== [2/5] 钉死 odoo.conf 数据库 =="
-if grep -q "^[[:space:]]*db_name" "$ODOO_CONF"; then
- sed -i "s/^[[:space:]]*db_name.*/db_name = $ODOO_DB/" "$ODOO_CONF"
-else
- echo "db_name = $ODOO_DB" >> "$ODOO_CONF"
-fi
-if ! grep -q "^[[:space:]]*dbfilter" "$ODOO_CONF"; then
- echo "dbfilter = ^$ODOO_DB\$" >> "$ODOO_CONF"
-fi
-echo "当前 db_name=$(grep '^db_name' "$ODOO_CONF")"
-
-echo "== [3/5] 升级模块(数据库 $ODOO_DB)=="
-"$ODOO_BIN" -c "$ODOO_CONF" -d "$ODOO_DB" -u game_base,yt_world --stop-after-init
-# 若模块从未安装过,把上行的 -u 改成 -i 执行一次即可
-
-echo "== [4/5] 重启 Odoo =="
-systemctl restart "$ODOO_SERVICE"
-sleep 3
-systemctl is-active "$ODOO_SERVICE" || { echo "Odoo 未起来,检查: journalctl -u $ODOO_SERVICE -n 50"; exit 1; }
-
-echo "== [5/5] 冒烟验证 =="
-curl -s -o /dev/null -w "codex 接口 HTTP %{http_code}\n" "https://game.pengyuthon.cn/game/api/codex?db=yt_game"
-echo "完成。前端为静态文件,nginx 指向 $REPO_DIR 即可,无需构建。"
diff --git a/world.html b/world.html
index bf80432..2e2e766 100644
--- a/world.html
+++ b/world.html
@@ -4905,7 +4905,7 @@ function updateSidebar() {
${resNames[k] || k}
${Math.floor(s.amount)}
- +${fmtNum(s.rate_per_min)}/分
+ ${(s.rate_per_min > 0.0001) ? `+${fmtNum(s.rate_per_min)}/分` : ''}
`).join('');
} else {
@@ -5150,6 +5150,8 @@ function startMicroRender() {
// 建造模式浮动操作按钮(旋转/确认/取消),在屏幕坐标系绘制
if (microBuildMode) microDrawActionBtns();
+ // 农舍升级特效反馈层(屏幕空间叠加,纯表现层)
+ microUpgradeFxDraw(microCtx, now);
const Rx = mW * 0.50, Ry = mH * 0.43;
const cx = mW / 2, cy = mH / 2;
@@ -6435,7 +6437,8 @@ async function updateChar() {
}
}
-function closeUnitModal() { microOpenUnitId = null; document.getElementById('unitModal').classList.remove('active'); }
+function closeUnitModalDom() { microOpenUnitId = null; document.getElementById('unitModal').classList.remove('active'); }
+function closeUnitModal() { closeUnitModalDom(); microUpgradeFxReset(); }
// ===== 微观建筑:悬停 tooltip + 点击弹窗(与 Odoo 后台目录联动)=====
let microTipEl = null;
@@ -6474,6 +6477,212 @@ function microCatName(cat) {
return c ? c.name : (cat || '其他');
}
// 点击已放置建筑 → 复用 unitModal 展示默认数据(demo 交互)
+// ================= 农舍升级特效反馈层(纯表现层;不改动升级数据逻辑/存档/接口签名) =================
+// 真实状态机入口:microBuildingAct。完成特效只在 res.level != null(后端落库成功)时触发;
+// 资源不足 / 网络异常等失败分支只 toast 并中断,绝不播放完成特效(满足"异常分支不误播")。
+const UPGRADE_FX = {
+ enabled: true, // 总开关(关闭 = 全部静默降级,不影响升级结果)
+ forceLowEnd: false, // 强制低端降级(无粒子,仅保留环/光/数字)
+ autoDetectLowEnd: true, // 移动端 / 低核数 / 低内存自动降级
+ durations: { confirm: 220, progress: 1100, complete: 760 }, // 各阶段时长(ms)
+ particleCount: 24, // 高端粒子数;低端自动为 0
+ haloRings: 3, // 扩散光环数量
+ colors: { core: '#ffd54f', spark: '#ffe9a8', halo: '#ffb74d', ring: '#4fc3f7', reward: '#7fdca0' },
+ sound: { enabled: true, completeFreq: 880, gain: 0.07 },
+};
+function _ufLowEnd() {
+ if (UPGRADE_FX.forceLowEnd) return true;
+ if (!UPGRADE_FX.autoDetectLowEnd) return false;
+ try {
+ const c = navigator.hardwareConcurrency || 4;
+ const mem = navigator.deviceMemory || 4;
+ const mob = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent || '');
+ return mob || c <= 2 || mem <= 2;
+ } catch (e) { return false; }
+}
+function _ufNow() { return performance.now(); }
+function _ufEaseOutCubic(p) { return 1 - Math.pow(1 - p, 3); }
+function _ufClamp01(p) { return p < 0 ? 0 : (p > 1 ? 1 : p); }
+
+let UF = null; // 升级特效运行态:null = 空闲
+
+function microUpgradeFxReset() {
+ UF = null;
+ document.querySelectorAll('#modalBody .cmdBtn[data-baction="upgrade"]').forEach(b => {
+ b.disabled = false; b.style.opacity = ''; b.style.pointerEvents = '';
+ });
+}
+function microUpgradeFxBtnDisable() {
+ // 仅作"确认反馈"视觉提示(变暗),保持可点击以支持"进行中再次点击 = 跳过/快速完成"
+ const btn = document.querySelector('#modalBody .cmdBtn[data-baction="upgrade"]');
+ if (btn) { btn.dataset.fx = '1'; btn.style.opacity = '0.6'; }
+}
+function microUpgradeFxCostOf(type) {
+ if (!UPGRADE_FX.enabled) return null;
+ const it = microBuildItemById(type);
+ const up = it && (it.actions || []).find(a => a.action === 'upgrade');
+ return ((up && up.cost) || (MICRO_BUILD_EFFECTS[(type || '') + ':upgrade'] || {}).cost || {});
+}
+function microUpgradeFxBuildingCenter(b) {
+ const it = microBuildItemById(b.type);
+ const ew = b.w || (it && it.w) || 1, eh = b.h || (it && it.h) || 1;
+ return microCellToScreen(b.i + ew / 2, b.j + eh / 2);
+}
+function _ufSpawnParticles(uf, n, rising) {
+ const c = microUpgradeFxBuildingCenter(uf.building);
+ uf.particles.length = 0; // 复用数组,避免每帧分配 → 低 GC
+ for (let i = 0; i < n; i++) {
+ const a = Math.random() * Math.PI * 2;
+ const sp = rising ? (18 + Math.random() * 36) : (70 + Math.random() * 130);
+ uf.particles.push({
+ x0: c.x, y0: c.y - 12,
+ vx: rising ? Math.cos(a) * sp * 0.35 : Math.cos(a) * sp,
+ vy: rising ? -(38 + Math.random() * 58) : Math.sin(a) * sp,
+ t0: _ufNow(), life: rising ? 680 : 560 + Math.random() * 320, size: 2 + Math.random() * 2.6,
+ });
+ }
+}
+function _ufPushRings(uf, n) {
+ for (let r = 0; r < n; r++) uf.rings.push({ t0: _ufNow() + r * 110, life: 640 });
+}
+function _ufEnterComplete(uf) {
+ uf.phase = 'complete'; uf.t0 = _ufNow();
+ uf.floats.push({ kind: 'level', text: 'Lv.' + uf.toLevel, color: '#ffffff', t0: _ufNow(), life: UPGRADE_FX.durations.complete, vy: -0.022 });
+ if (uf.reward) uf.floats.push({ kind: 'reward', text: uf.reward, color: UPGRADE_FX.colors.reward, t0: _ufNow(), life: UPGRADE_FX.durations.complete + 220, vy: -0.04 });
+ if (!_ufLowEnd()) _ufSpawnParticles(uf, UPGRADE_FX.particleCount, false);
+ _ufPushRings(uf, UPGRADE_FX.haloRings);
+ _ufPlaySound(UPGRADE_FX.sound.completeFreq);
+}
+// 点击升级:进入"确认"预演(按钮态 + 资源扣除飘字)。此时数据尚未落库,仅为表现层。
+function microUpgradeFxStart(building, cost) {
+ if (!UPGRADE_FX.enabled || !building) return;
+ microUpgradeFxReset(); // 重复触发先清掉旧的,避免残留/重复播放
+ UF = { phase: 'confirm', t0: _ufNow(), building, fromLevel: building.level, toLevel: building.level,
+ reward: '', particles: [], rings: [], floats: [] };
+ microUpgradeFxBtnDisable();
+ if (cost) {
+ const parts = Object.entries(cost).map(([k, v]) => (MICRO_RES_NAMES[k] || k) + ' ' + v);
+ if (parts.length) UF.floats.push({ kind: 'cost', text: '-' + parts.join(' '), color: '#ffd54f', t0: _ufNow(), life: 900, vy: -0.03 });
+ }
+}
+// 后端落库成功:进入"进行中"过程反馈(进度环 + 上升火花 + 光环)。
+function microUpgradeFxProgress() {
+ if (!UF) return;
+ UF.phase = 'progress'; UF.t0 = _ufNow();
+ if (!_ufLowEnd()) _ufSpawnParticles(UF, Math.round(UPGRADE_FX.particleCount * 0.6), true);
+ _ufPushRings(UF, UPGRADE_FX.haloRings);
+}
+// 失败 / 打断:中断进行中动画(绝不进入完成阶段)。
+function microUpgradeFxAbort() { microUpgradeFxReset(); }
+// 跳过 / 快速完成:进行中立即跳到完成结算。
+function microUpgradeFxSkip() {
+ if (!UF) return;
+ if (UF.phase === 'confirm' || UF.phase === 'progress') {
+ UF.toLevel = UF.toLevel || (UF.fromLevel + 1);
+ _ufEnterComplete(UF);
+ }
+}
+
+let _ufAudioCtx = null;
+function _ufPlaySound(freq) {
+ if (!UPGRADE_FX.enabled || !UPGRADE_FX.sound.enabled) return;
+ try {
+ if (!_ufAudioCtx) _ufAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ const ac = _ufAudioCtx;
+ if (ac.state === 'suspended') ac.resume();
+ const t = ac.currentTime;
+ const mk = (f, g0, dt) => {
+ const o = ac.createOscillator(), g = ac.createGain();
+ o.type = 'sine'; o.frequency.value = f; o.connect(g); g.connect(ac.destination);
+ g.gain.setValueAtTime(g0, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dt);
+ o.start(t); o.stop(t + dt + 0.02);
+ };
+ mk(freq, UPGRADE_FX.sound.gain, 0.18);
+ mk(freq * 1.25, UPGRADE_FX.sound.gain * 0.7, 0.26); // 上行小三度,强化"完成"感
+ } catch (e) { /* 资源 / 环境缺失:静默降级 */ }
+}
+// 每帧由微观渲染循环调用(屏幕空间叠加,不影响建筑数据绘制)。
+function microUpgradeFxDraw(ctx, now) {
+ if (!UPGRADE_FX.enabled || !UF) return;
+ const g = microBuildGeom();
+ const c = microUpgradeFxBuildingCenter(UF.building);
+ const baseR = g.cell * 0.95;
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0); // 强制屏幕空间(绝对坐标)
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+
+ const el = now - UF.t0;
+ // 阶段推进:进行中结束 → 自动进入完成结算
+ if (UF.phase === 'progress' && el >= UPGRADE_FX.durations.progress) {
+ UF.toLevel = UF.toLevel || (UF.fromLevel + 1);
+ _ufEnterComplete(UF);
+ }
+
+ // 光环(confirm/progress/complete 共用)
+ UF.rings = UF.rings.filter(r => now - r.t0 < r.life);
+ UF.rings.forEach(r => {
+ const age = _ufClamp01((now - r.t0) / r.life);
+ const rad = baseR * (0.6 + age * 1.4);
+ ctx.globalAlpha = (1 - age) * 0.5;
+ ctx.strokeStyle = UPGRADE_FX.colors.halo; ctx.lineWidth = 3 * (1 - age) + 1;
+ ctx.beginPath(); ctx.arc(c.x, c.y, rad, 0, Math.PI * 2); ctx.stroke();
+ });
+
+ // 粒子
+ if (UF.particles.length) {
+ UF.particles = UF.particles.filter(p => (now - p.t0) < p.life);
+ UF.particles.forEach(p => {
+ const age = _ufClamp01((now - p.t0) / p.life);
+ const x = p.x0 + p.vx * age, y = p.y0 + p.vy * age + 120 * age * age; // 轻微重力下坠
+ ctx.globalAlpha = (1 - age) * 0.9;
+ ctx.fillStyle = age < 0.5 ? UPGRADE_FX.colors.spark : UPGRADE_FX.colors.core;
+ ctx.beginPath(); ctx.arc(x, y, p.size * (1 - age * 0.4), 0, Math.PI * 2); ctx.fill();
+ });
+ }
+
+ // 进度环(仅 progress 阶段)
+ if (UF.phase === 'progress') {
+ const p = _ufClamp01(el / UPGRADE_FX.durations.progress);
+ ctx.globalAlpha = 0.95; ctx.strokeStyle = UPGRADE_FX.colors.ring; ctx.lineWidth = 5;
+ ctx.beginPath(); ctx.arc(c.x, c.y, baseR * 0.78, -Math.PI / 2, -Math.PI / 2 + p * Math.PI * 2); ctx.stroke();
+ }
+
+ // 完成:建筑弹跳 + 径向辉光(外观过渡)
+ if (UF.phase === 'complete') {
+ const p = _ufClamp01(el / UPGRADE_FX.durations.complete);
+ const pop = 1 + 0.18 * Math.sin(p * Math.PI);
+ const glow = 1 - p;
+ const grad = ctx.createRadialGradient(c.x, c.y, baseR * 0.2, c.x, c.y, baseR * (1.1 + p));
+ grad.addColorStop(0, 'rgba(255,213,79,' + (0.45 * glow).toFixed(3) + ')');
+ grad.addColorStop(1, 'rgba(255,183,77,0)');
+ ctx.globalAlpha = 1; ctx.fillStyle = grad;
+ ctx.beginPath(); ctx.arc(c.x, c.y, baseR * (1.1 + p), 0, Math.PI * 2); ctx.fill();
+ ctx.globalAlpha = glow; ctx.strokeStyle = UPGRADE_FX.colors.core; ctx.lineWidth = 2;
+ ctx.beginPath(); ctx.arc(c.x, c.y, baseR * 0.95 * pop, 0, Math.PI * 2); ctx.stroke();
+ }
+
+ // 飘字(成本 / 等级 / 奖励)
+ UF.floats = UF.floats.filter(f => (now - f.t0) < f.life);
+ UF.floats.forEach(f => {
+ const age = _ufClamp01((now - f.t0) / f.life);
+ const y = c.y - 30 + f.vy * age * 160;
+ let alpha = 1, scale = 1;
+ if (f.kind === 'level') { scale = 0.7 + 0.5 * _ufEaseOutCubic(Math.min(1, age * 3)); alpha = age > 0.7 ? (1 - (age - 0.7) / 0.3) : 1; }
+ else { alpha = age > 0.75 ? (1 - (age - 0.75) / 0.25) : 1; }
+ ctx.globalAlpha = _ufClamp01(alpha);
+ ctx.fillStyle = f.color;
+ ctx.font = (f.kind === 'level' ? 'bold ' : '') + Math.round((f.kind === 'level' ? 30 : 18) * scale) + 'px system-ui, sans-serif';
+ ctx.fillText(f.text, c.x, y);
+ });
+
+ ctx.globalAlpha = 1; ctx.restore();
+
+ // 结束:完成阶段播完即清理(按钮已由 showBuildModal 重渲染恢复)
+ if (UF && UF.phase === 'complete' && (now - UF.t0) >= UPGRADE_FX.durations.complete) {
+ microUpgradeFxReset();
+ }
+}
+
function showBuildModal(b) {
if (!b) return;
const it = microBuildItemById(b.type);
@@ -6494,16 +6703,22 @@ function showBuildModal(b) {
const panelIcon = (it.levels && it.levels[b.level]) || it.icon;
const img = (typeof panelIcon === 'string')
? `
` : '';
- const yld = (it.statYield && typeof it.statYield === 'object' && Object.keys(it.statYield).length)
- ? Object.entries(it.statYield)
- .map(([k, v]) => `产出·${k}+${v}
`).join('') : '';
- const need = b.level * 100;
- const expPct = Math.max(0, Math.min(100, Math.round((b.exp % need) / need * 100)));
- const hpPct = Math.max(0, Math.min(100, Math.round(b.hp / (it.statHp || 100) * 100)));
- const hpColor = hpPct > 50 ? '#8bc34a' : hpPct > 25 ? '#ff9800' : '#f44336';
+ // 产出按建筑等级缩放(与后端 level_mult 一致:L1=1倍、L2=2倍…)
+ const _lvlMult = (lv) => Math.max(1, parseInt(lv, 10) || 1);
+ const _baseInstant = (it.statYieldInstant && Object.keys(it.statYieldInstant).length)
+ ? it.statYieldInstant : (it.statYield || {});
+ const _baseTick = it.statYieldTick || {};
+ const _mNow = _lvlMult(b.level);
+ const _yRow = (label, v) => `${label}+${v * _mNow}
`;
+ const yld = [
+ ...Object.entries(_baseInstant).map(([k, v]) => _yRow(`产出·${MICRO_RES_NAMES[k] || k}`, v)),
+ ...Object.entries(_baseTick).map(([k, v]) => _yRow(`每分·${MICRO_RES_NAMES[k] || k}`, v)),
+ ].join('');
const acts = (cfg.actions || []).filter(a =>
+ // 只保留有真实后台逻辑的动作(升级);发布任职/招募村民等占位动作暂不上面板
+ a.action === 'upgrade' &&
// 满级隐藏升级按钮:b.level 已达 maxLevel(后端真源)时不再渲染
- !(a.action === 'upgrade' && b.level >= (it.maxLevel || 1))
+ !(b.level >= (it.maxLevel || 1))
).map(a => {
// 费用标注:优先用后端 catalog actions 自带的 cost;否则查效果表(key = "code:action")
const eff = MICRO_BUILD_EFFECTS[b.type + ':' + a.action] || {};
@@ -6512,21 +6727,33 @@ function showBuildModal(b) {
const costHtml = costEntries.length
? `费用:${costEntries.map(([k, v]) => (MICRO_RES_NAMES[k] || k) + ' ×' + v).join(' ')}
`
: '';
- return `${a.icon} ${a.label}${costHtml}
${a.desc}
`;
+ // 升级按钮体现实际提升:当前产出 → 升级后产出(与后端等级倍率一致)
+ let descHtml = a.desc;
+ if (a.action === 'upgrade') {
+ const _mNext = _lvlMult(b.level + 1);
+ const parts = [
+ ...Object.entries(_baseInstant).map(([k, v]) =>
+ `${MICRO_RES_NAMES[k] || k} +${v * _mNow} → +${v * _mNext}`),
+ ...Object.entries(_baseTick).map(([k, v]) =>
+ `${MICRO_RES_NAMES[k] || k}/分 +${v * _mNow} → +${v * _mNext}`),
+ ];
+ if (parts.length) {
+ descHtml = `提升至 Lv.${b.level + 1}:${parts.join(',')}`;
+ }
+ }
+ return `${a.icon} ${a.label}${costHtml}
${descHtml}
`;
}).join('');
document.getElementById('modalTitle').textContent = `${name} · Lv.${b.level}`;
const body = document.getElementById('modalBody');
+ // 经验/耐久暂无后台逻辑支撑,按用户要求不在面板显示;无动作时整个交互区块隐藏
body.innerHTML = `
${img}
${it.desc ? `${it.desc}
` : ''}
类别${microCatName(it.cat)}
等级Lv.${b.level} ${'★'.repeat(Math.min(b.level, 5))}
-
-
${yld}
- ${cfg.tagline || '交互'}
-
-
+ ${acts ? `` : ''}
`;
body.querySelectorAll('.cmdBtn').forEach(btn => {
btn.addEventListener('click', () => {
@@ -6541,8 +6768,19 @@ async function microBuildingAct(type, action, label) {
const tile = microState && microState.tile;
if (!tile) return;
const bpos = microOpenBuilding || {};
+ // 升级:点击即进入"确认"预演(表现层;数据尚未落库,仅按钮态 + 资源扣除动效)
+ if (action === 'upgrade' && microOpenBuilding) {
+ if (UF && (UF.phase === 'confirm' || UF.phase === 'progress')) {
+ microUpgradeFxSkip(); // 进行中再次点击 = 跳过 / 快速完成(不重复调用后端,杜绝重复扣费)
+ return;
+ }
+ microUpgradeFxStart(microOpenBuilding, microUpgradeFxCostOf(type));
+ // 面板会挡住画布上的特效 → 点升级即收起面板(只关 DOM,不 Reset 特效;失败时有 toast 提示)
+ closeUnitModalDom();
+ }
const res = await API.buildingAction({ code: type, action, q: tile.q, r: tile.r, i: bpos.i, j: bpos.j });
if (!res || res.ok === false) {
+ if (action === 'upgrade') microUpgradeFxAbort(); // 异常分支:中断进行中动画,绝不播放完成特效
const msg = res && res.error === 'no_gold' ? '金币不足'
: (res && res.error === 'no_res' ? ('资源不足:' + (res.res || '')) : '操作失败(未登录或网络异常)');
microToast(msg); return;
@@ -6561,6 +6799,7 @@ async function microBuildingAct(type, action, label) {
if (_res) emitTaskEvent('resource_collected', { res: _res });
// 升级:用后端落库后的权威等级/经验回写并持久化
if (action === 'upgrade' && microOpenBuilding && res.level != null) {
+ const _was = microOpenBuilding.level;
microOpenBuilding.level = res.level;
microOpenBuilding.exp = res.exp;
// 升级即时换肤:主动确保新等级贴图(含左右视角)已加载,不依赖进入微观时的预加载时机
@@ -6572,6 +6811,18 @@ async function microBuildingAct(type, action, label) {
else if (base.includes('_r.png')) microEnsureIcon(base.replace('_r.png', '_l.png'));
}
scheduleBuildSave();
+ // 升级改变实际产出(人口上限/每分钟速率)→ 立即刷新侧栏"资源产出"
+ microRefreshResources();
+ // 表现层反馈:仅后端落库成功才进入"进行中 → 完成"特效(升级数据逻辑本身不动)
+ if (UF) {
+ UF.toLevel = res.level;
+ const _itU = microBuildItemById(microOpenBuilding.type);
+ const _inst = (_itU && (_itU.statYieldInstant || _itU.statYield)) || {};
+ UF.reward = Object.entries(_inst).map(([k, v]) => '+' + (v * (res.level - _was)) + ' ' + (MICRO_RES_NAMES[k] || k)).join(' ');
+ // 面板会挡住画布上的特效 → 落库成功后自动收起面板(只关 DOM,不 Reset 特效)
+ closeUnitModalDom();
+ microUpgradeFxProgress();
+ }
}
// 治疗:回满本村所有生物生命
if (action === 'heal' && microState && microState.units) {