Merge pull request '等级系统' (#45) from why_ga into main

Reviewed-on: #45
This commit was merged in pull request #45.
This commit is contained in:
wanghaoyang
2026-09-22 19:35:37 +08:00
6 changed files with 80 additions and 8 deletions
+1
View File
@@ -13,6 +13,7 @@
'data/codex_race.xml',
'data/codex_talent.xml',
'data/yt_world_build_item.xml',
'data/yt_world_build_item_updates.xml',
'data/yt_world_equip_item.xml',
'data/yt_world_race.xml',
'data/yt_world_task.xml',
+23 -1
View File
@@ -201,7 +201,7 @@ class YtWorldApi(http.Controller):
out = []
for r in items.read(['code', 'name', 'category', 'icon', 'footprint_w',
'footprint_h', 'cost_gold', 'terrains', 'desc', 'flat',
'stat_level', 'stat_hp', 'stat_yield', 'stat_yield_instant',
'stat_level', 'max_level', 'stat_hp', 'stat_yield', 'stat_yield_instant',
'stat_yield_tick', 'actions']):
if r['category'] not in seen:
seen.append(r['category'])
@@ -227,6 +227,7 @@ class YtWorldApi(http.Controller):
'desc': r['desc'] or '',
'flat': bool(r['flat']),
'statLevel': r['stat_level'] or 1,
'maxLevel': r['max_level'] or 1,
'statHp': r['stat_hp'] or 100,
'statYield': r['stat_yield'] or {},
'statYieldInstant': instant,
@@ -605,6 +606,19 @@ class YtWorldApi(http.Controller):
('market', 'upgrade'): {'cost': {'gold': 25}, 'msg': '集市升级申请已提交'},
}
@http.route('/yt_world/api/build/effects', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False)
def build_effects(self, **kw):
"""建筑动作效果表(费用/产出真源),供前端弹窗标注费用。
返回 {"cottage:upgrade": {"cost": {"gold": 50}, "gain": {}}, ...}"""
effects = {}
for (code, action), eff in self.BUILD_EFFECTS.items():
effects['%s:%s' % (code, action)] = {
'cost': eff.get('cost') or {},
'gain': eff.get('gain') or {},
}
return _json({'ok': True, 'effects': effects})
def _require_user(self):
"""auth='public' 时手动校验登录态;未登录返回统一错误字典。
Odoo auth='public' 不会把 session uid 同步到 request.env.user,
@@ -961,6 +975,12 @@ class YtWorldApi(http.Controller):
items = list(tile.micro_world_id.build_items or [])
for b in items:
if b.get('type') == code and b.get('i') == i and b.get('j') == j:
# 满级守卫:最大等级真源 = yt.world.build.item.max_level
item = request.env['yt.world.build.item'].sudo().search(
[('code', '=', code)], limit=1)
max_lv = (item.max_level or 1) if item else 1
if (b.get('level') or 1) >= max_lv:
return {'max_reached': True}
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})
@@ -1022,6 +1042,8 @@ class YtWorldApi(http.Controller):
if q is None or r is None or i is None or j is None:
return _json({'ok': False, 'error': 'bad_pos'})
upgrade_info = self._upgrade_building_instance(world, code, q, r, i, j)
if upgrade_info and upgrade_info.get('max_reached'):
return _json({'ok': False, 'error': 'max_level'})
if not upgrade_info:
return _json({'ok': False, 'error': 'building_not_found'})
@@ -68,6 +68,7 @@
<field name="cost_gold">20</field>
<field name="desc">村民起居的基本居所,提供人口上限与夜间休息。</field>
<field name="stat_level">1</field><field name="stat_hp">120</field>
<field name="max_level">2</field>
<field name="stat_yield" eval="{'population': 4}"/>
<field name="actions" eval="[
{'action': 'employ', 'label': '发布任职', 'icon': '📜', 'desc': '发布村长任职委托,吸引居民就任', 'cost': {}, 'gain': {}, 'msg': '已发布村长任职委托'},
+16 -1
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
from odoo import api, models, fields
class YtWorldBuildItem(models.Model):
@@ -44,6 +44,8 @@ class YtWorldBuildItem(models.Model):
flat = fields.Boolean(string='平面俯视', default=False,
help='true=顶视图,岛上压成菱形贴地;false=3/4 立绘,竖直站立')
stat_level = fields.Integer(string='默认等级', default=1)
max_level = fields.Integer(string='最大等级', default=1,
help='当前设计支持的最高等级;前端据此隐藏升级按钮,后端据此拒绝升级。')
stat_hp = fields.Integer(string='默认耐久', default=100)
stat_yield = fields.Json(string='默认产出(兼容)', default=dict,
help='兼容旧数据;新产出请使用 stat_yield_instant / stat_yield_tick')
@@ -57,3 +59,16 @@ class YtWorldBuildItem(models.Model):
_sql_constraints = [
('uniq_code', 'unique(code)', '素材编码必须唯一'),
]
# 设计最大等级(随素材制作进度迭代;由 data/yt_world_build_item_updates.xml 的
# <function> 在每次模块升级时刷新——noupdate 记录无法用普通 record 覆盖)
DESIGN_MAX_LEVELS = {
'cottage': 2, # L2 素材已上线
}
@api.model
def sync_max_levels(self):
for it in self.search([]):
lv = self.DESIGN_MAX_LEVELS.get(it.code)
if lv and it.max_level != lv:
it.max_level = lv
+10
View File
@@ -279,6 +279,16 @@ const API = {
return { ok: false, error: 'network', detail: String(e && e.message || e) };
}
},
// 建筑动作效果表(费用/产出真源在后端 BUILD_EFFECTS)
async buildEffects() {
try {
const r = await fetch(_worldUrl('/yt_world/api/build/effects'), {
method: 'GET', credentials: _wcred,
});
const j = await r.json();
return j && j.ok ? j : { ok: false, error: 'empty' };
} catch (e) { return { ok: false, error: 'network' }; }
},
// ---------- 微观世界角色(玩家放置的种族角色,绑定用户持久化) ----------
// 以下两个已重定向到世界生物表(yt.world.creature),保留旧名以兼容 characters.js
async microChars() {
+29 -6
View File
@@ -2626,6 +2626,15 @@ let microBuildRevealAt = 0; // 建筑显示时间戳:过渡结束 + 2
let microChar = null; // 玩家角色:{name,level,exp,gold,inventory,equip}
let microCreatureOverlay = {}; // cid -> 已保存生物状态(等级/装备/经验)
let microOpenBuilding = null; // 当前打开的建筑交互对象(用于升级等更新)
// 建筑动作效果表(费用/产出)——真源在后端 BUILD_EFFECTS,登录后拉取一次缓存
let MICRO_BUILD_EFFECTS = {};
const MICRO_RES_NAMES = { food:'🍖 食物', wood:'🪵 木材', metal:'⛏ 金属', gold:'💰 金币', culture:'🏛 文化', research:'🔬 科研', defense:'🛡 防御', military:'⚔ 军事', pop:'👥 人口', population:'👥 人口', water:'💧 水', meat:'🥩 肉', milk:'🥛 奶', coin:'🪙 硬币', flour:'🌾 面粉', stone:'🪨 石材', prestige:'⭐ 声望', rare:'💎 稀有' };
async function microLoadBuildEffects() {
try {
const r = await API.buildEffects();
if (r && r.ok && r.effects) MICRO_BUILD_EFFECTS = r.effects;
} catch (e) { /* 拉不到:弹窗不显示费用,交互仍走后端校验 */ }
}
let microCanvas, microCtx;
let microAnimId = null;
let microGridCache = null; // 微观世界:背景 + 20×20 离屏缓存(静态,逐帧只贴图)
@@ -2910,6 +2919,7 @@ async function microLoadCatalog() {
if (it.h != null && t.h == null) t.h = it.h;
if (it.flat != null) t.flat = it.flat;
if (it.statLevel) t.statLevel = it.statLevel;
if (it.maxLevel) t.maxLevel = it.maxLevel; // 最大等级真源(满级隐藏升级按钮)
if (it.statHp) t.statHp = it.statHp;
if (it.costGold != null) t.costGold = it.costGold; // 后端价格优先(单一数据源)
if (it.statYield && Object.keys(it.statYield).length) t.statYield = it.statYield;
@@ -4687,6 +4697,7 @@ function enterMicroWorld(tile, opts) {
updateSidebar();
microLoadIcons(); // 进入微观视图即预加载建造贴图,避免已放置建筑先显示文字
microLoadCatalog(); // 拉取 Odoo 后台建造目录(未登录时保留前端兜底常量),hover/弹窗即时生效
microLoadBuildEffects(); // 拉取建筑动作费用/产出真源(弹窗标注费用)
microLoadGame(); // 拉取角色 / 生物 / 订单(Odoo 后端),覆盖单位状态
if (!(opts && opts.boss)) {
microLoadChests(tile.q, tile.r); // 拉取本地块已生成的宝箱
@@ -4884,7 +4895,7 @@ function updateSidebar() {
const stocks = microStockCache || {};
const stockEntries = Object.entries(stocks).filter(([, s]) => (s.amount > 0.0001) || (s.rate_per_min > 0.0001));
if (stockEntries.length > 0) {
const resNames = { food:'🍖 食物', wood:'🪵 木材', metal:'⛏ 金属', gold:'💰 金币', culture:'🏛 文化', research:'🔬 科研', defense:'🛡 防御', military:'⚔ 军事', pop:'👥 人口', population:'👥 人口', water:'💧 水', meat:'🥩 肉', milk:'🥛 奶', coin:'🪙 硬币', flour:'🌾 面粉', prestige:'⭐ 声望', rare:'💎 稀有' };
const resNames = MICRO_RES_NAMES;
const order = ['food','water','wood','meat','milk','metal','gold','coin','culture','research','defense','military','pop','prestige','rare'];
stockEntries.sort((a, b) => {
const ia = order.indexOf(a[0]), ib = order.indexOf(b[0]);
@@ -6479,8 +6490,10 @@ function showBuildModal(b) {
? { title: it.name, tagline: it.desc || '', actions: it.actions }
: (BUILD_INTERACT[b.type] || { title: it.name, tagline: it.desc || '', actions: [] });
const name = it.name || b.type;
const img = (typeof it.icon === 'string')
? `<img src="${it.icon}" alt="" style="width:64px;height:64px;object-fit:contain;margin:2px auto 10px;display:block;">` : '';
// 面板大图跟随实际等级:有等级贴图用等级贴图,否则回退目录图标
const panelIcon = (it.levels && it.levels[b.level]) || it.icon;
const img = (typeof panelIcon === 'string')
? `<img src="${panelIcon}" alt="" style="width:64px;height:64px;object-fit:contain;margin:2px auto 10px;display:block;">` : '';
const yld = (it.statYield && typeof it.statYield === 'object' && Object.keys(it.statYield).length)
? Object.entries(it.statYield)
.map(([k, v]) => `<div class="statRow"><span class="statLabel">产出·${k}</span><span class="statValue">+${v}</span></div>`).join('') : '';
@@ -6488,9 +6501,19 @@ function showBuildModal(b) {
const expPct = Math.max(0, Math.min(100, Math.round((b.exp % need) / need * 100)));
const hpPct = Math.max(0, Math.min(100, Math.round(b.hp / (it.statHp || 100) * 100)));
const hpColor = hpPct > 50 ? '#8bc34a' : hpPct > 25 ? '#ff9800' : '#f44336';
const acts = (cfg.actions || []).map(a =>
`<div class="cmdBtn" data-btype="${b.type}" data-baction="${a.action}" data-blabel="${a.label}">${a.icon} ${a.label}<div style="font-size:10px;opacity:.7;margin-top:2px;white-space:normal;">${a.desc}</div></div>`
).join('');
const acts = (cfg.actions || []).filter(a =>
// 满级隐藏升级按钮:b.level 已达 maxLevel(后端真源)时不再渲染
!(a.action === 'upgrade' && b.level >= (it.maxLevel || 1))
).map(a => {
// 费用标注:优先用后端 catalog actions 自带的 cost;否则查效果表(key = "code:action")
const eff = MICRO_BUILD_EFFECTS[b.type + ':' + a.action] || {};
const costMap = (a.cost && Object.keys(a.cost).length) ? a.cost : (eff.cost || {});
const costEntries = Object.entries(costMap);
const costHtml = costEntries.length
? `<div style="font-size:11px;margin-top:3px;color:#ffd54f;">费用:${costEntries.map(([k, v]) => (MICRO_RES_NAMES[k] || k) + ' ×' + v).join(' ')}</div>`
: '';
return `<div class="cmdBtn" data-btype="${b.type}" data-baction="${a.action}" data-blabel="${a.label}">${a.icon} ${a.label}${costHtml}<div style="font-size:10px;opacity:.7;margin-top:2px;white-space:normal;">${a.desc}</div></div>`;
}).join('');
document.getElementById('modalTitle').textContent = `${name} · Lv.${b.level}`;
const body = document.getElementById('modalBody');
body.innerHTML = `