资源体系,图鉴报错修复
This commit is contained in:
@@ -1,2 +1,27 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
|
||||
|
||||
def sync_specs_from_code(cr, registry):
|
||||
"""模块安装/升级后:把代码里的建筑产出规格刷进 build.item 字段,
|
||||
并对所有世界做全量对账(速率 + 即时属性)。
|
||||
|
||||
彻底消除「noupdate XML 记录升级不重放 → 必须手动补写 DB」的坑:
|
||||
改任何建筑产出,只需改 build_spec.BUILD_SPECS + 升级模块。
|
||||
"""
|
||||
from odoo import api
|
||||
import odoo
|
||||
env = api.Environment(cr, odoo.SUPERUSER_ID, {})
|
||||
from odoo.addons.yt_world.build_spec import BUILD_SPECS
|
||||
Item = env['yt.world.build.item'].sudo()
|
||||
for code, spec in BUILD_SPECS.items():
|
||||
items = Item.search([('code', '=', code)])
|
||||
if items:
|
||||
items.write({
|
||||
'stat_yield_instant': spec.get('stat_yield_instant', {}) or {},
|
||||
'stat_yield_tick': spec.get('stat_yield_tick', {}) or {},
|
||||
})
|
||||
# 全量对账:速率(tick)与即时属性(instant)都从实例重新计算,幂等 SET
|
||||
for world in env['yt.world'].search([]):
|
||||
world._recalc_rates()
|
||||
world._reconcile_instant()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': '宇森世界 - 沙盘与微观世界',
|
||||
'version': '0.3',
|
||||
'version': '0.7',
|
||||
'summary': '地表存储(沙盘记忆) + 微观世界(1:1预留) + 资源(框架)',
|
||||
'description': '每个玩家拥有一个确定性生成的世界;仅持久化玩家改过的地表地块(种子+增量覆盖)。微观世界独立为 1:1 模型仅预留,资源模型为框架。',
|
||||
'category': 'Game',
|
||||
@@ -15,6 +15,9 @@
|
||||
'data/yt_world_build_item.xml',
|
||||
'data/yt_world_task.xml',
|
||||
'views/yt_world_views.xml',
|
||||
# 图鉴:与 8888 前端图鉴对齐的 5 个模型视图 + 菜单改造
|
||||
'views/codex_views.xml',
|
||||
'views/codex_menu.xml',
|
||||
# AI NPC(自 game_ai_npc 合并):先数据(建模型记录)→ 子视图(含 action) → 主视图 → 菜单(引用 action 与父菜单)
|
||||
'data/game_npc_data.xml',
|
||||
'views/npc_relationship_views.xml',
|
||||
@@ -30,4 +33,5 @@
|
||||
},
|
||||
'application': True,
|
||||
'license': 'AGPL-3',
|
||||
'post_init_hook': 'sync_specs_from_code',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""建筑产出规格的单一真源(Single Source of Truth)。
|
||||
|
||||
此前建筑产出写在 noupdate 的 XML 数据记录里,模块升级时 Odoo 不会重放
|
||||
已存在记录的字段,导致改了产出必须手动补写 DB —— 这是行业里典型的
|
||||
「可变平衡值被放进存档/活数据」反模式。
|
||||
|
||||
这里把产出参数全部收口到代码:
|
||||
- stat_yield_tick : 每分钟产出的资源,如 {'food': 3}
|
||||
- stat_yield_instant: 建造即给、拆除即收的即时属性,如 {'population': 4}
|
||||
- effects : 非库存效果(治疗/饱食/仓储上限),供建筑交互使用,
|
||||
不参与世界 stock 结算
|
||||
|
||||
改任何建筑的产出,只改这个字典 + 升级模块即可,全自动生效,永不再手动补写。
|
||||
"""
|
||||
|
||||
BUILD_SPECS = {
|
||||
# ===== 即时属性(建造 +,拆除 -)=====
|
||||
'cottage': {'stat_yield_instant': {'population': 4}, 'stat_yield_tick': {}},
|
||||
'village': {'stat_yield_instant': {'population': 6}, 'stat_yield_tick': {'food': 1}},
|
||||
|
||||
# ===== 每分钟资源(tick)=====
|
||||
'farmland': {'stat_yield_instant': {}, 'stat_yield_tick': {'food': 3}},
|
||||
'well': {'stat_yield_instant': {}, 'stat_yield_tick': {'water': 2}},
|
||||
'windmill': {'stat_yield_instant': {}, 'stat_yield_tick': {'flour': 2}},
|
||||
'blacksmith': {'stat_yield_instant': {}, 'stat_yield_tick': {'metal': 2}},
|
||||
'grocery': {'stat_yield_instant': {}, 'stat_yield_tick': {'coin': 2}},
|
||||
'temple': {'stat_yield_instant': {}, 'stat_yield_tick': {'prestige': 3}},
|
||||
'pasture': {'stat_yield_instant': {}, 'stat_yield_tick': {'meat': 2, 'milk': 1}},
|
||||
'school': {'stat_yield_instant': {}, 'stat_yield_tick': {'research': 3, 'prestige': 1}},
|
||||
'library': {'stat_yield_instant': {}, 'stat_yield_tick': {'research': 4}},
|
||||
'barracks': {'stat_yield_instant': {}, 'stat_yield_tick': {'military': 4}},
|
||||
'guild': {'stat_yield_instant': {}, 'stat_yield_tick': {'prestige': 2, 'coin': 1}},
|
||||
|
||||
# ===== 效果类(非库存,供建筑交互)=====
|
||||
'clinic': {'stat_yield_instant': {}, 'stat_yield_tick': {}, 'effects': {'heal': 2}},
|
||||
'restaurant': {'stat_yield_instant': {}, 'stat_yield_tick': {}, 'effects': {'satiety': 3}},
|
||||
'storage': {'stat_yield_instant': {}, 'stat_yield_tick': {}, 'effects': {'storage': 10}},
|
||||
|
||||
# ===== 无产出(功能性)=====
|
||||
'fence': {'stat_yield_instant': {}, 'stat_yield_tick': {}},
|
||||
'wall': {'stat_yield_instant': {}, 'stat_yield_tick': {}},
|
||||
'blackmarket':{'stat_yield_instant': {}, 'stat_yield_tick': {}}, # 功能性建筑,无库存产出
|
||||
}
|
||||
|
||||
|
||||
def spec_defined(code):
|
||||
"""某建筑编码是否在 BUILD_SPECS 中有明确定义。"""
|
||||
return code in BUILD_SPECS
|
||||
|
||||
|
||||
def get_spec(code):
|
||||
"""取某建筑编码的规格;不存在返回空规格。"""
|
||||
spec = BUILD_SPECS.get(code)
|
||||
if not spec:
|
||||
return {'stat_yield_instant': {}, 'stat_yield_tick': {}, 'effects': {}}
|
||||
return {
|
||||
'stat_yield_instant': spec.get('stat_yield_instant', {}) or {},
|
||||
'stat_yield_tick': spec.get('stat_yield_tick', {}) or {},
|
||||
'effects': spec.get('effects', {}) or {},
|
||||
}
|
||||
@@ -201,9 +201,24 @@ 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', 'actions']):
|
||||
'stat_level', 'stat_hp', 'stat_yield', 'stat_yield_instant',
|
||||
'stat_yield_tick', 'actions']):
|
||||
if r['category'] not in seen:
|
||||
seen.append(r['category'])
|
||||
# 产出以代码规格 BUILD_SPECS 为单一真源;代码里已定义的建筑不再回退到 item 字段
|
||||
from odoo.addons.yt_world.build_spec import get_spec, spec_defined
|
||||
spec = get_spec(r['code'])
|
||||
if spec_defined(r['code']):
|
||||
instant = spec.get('stat_yield_instant') or {}
|
||||
tick = spec.get('stat_yield_tick') or {}
|
||||
else:
|
||||
# 未在代码规格中定义的建筑,才回退到 item 字段/旧 stat_yield 做兼容
|
||||
instant = spec.get('stat_yield_instant') or r['stat_yield_instant'] or {}
|
||||
tick = spec.get('stat_yield_tick') or r['stat_yield_tick'] or {}
|
||||
legacy = r['stat_yield'] or {}
|
||||
if not instant and not tick:
|
||||
# 旧数据尚未拆分,全部视为一次性产出(保守处理)
|
||||
instant = legacy
|
||||
out.append({
|
||||
'code': r['code'], 'name': r['name'], 'category': r['category'],
|
||||
'icon': r['icon'], 'w': r['footprint_w'], 'h': r['footprint_h'],
|
||||
@@ -214,6 +229,8 @@ class YtWorldApi(http.Controller):
|
||||
'statLevel': r['stat_level'] or 1,
|
||||
'statHp': r['stat_hp'] or 100,
|
||||
'statYield': r['stat_yield'] or {},
|
||||
'statYieldInstant': instant,
|
||||
'statYieldTick': tick,
|
||||
'actions': r['actions'] or [],
|
||||
})
|
||||
cats = [{'id': c, 'name': CAT_LABELS.get(c, c)} for c in seen]
|
||||
@@ -1054,3 +1071,118 @@ class YtWorldApi(http.Controller):
|
||||
r.unlink()
|
||||
del_count += 1
|
||||
return _json({'ok': True, 'saved': len(out), 'deleted': del_count})
|
||||
|
||||
# ============================================================
|
||||
# 资源系统:库存 / 结算 / 离线囤积回收
|
||||
# ============================================================
|
||||
|
||||
def _serialize_build_instance(self, inst):
|
||||
"""把 yt.world.build.instance 序列化为前端可用结构。"""
|
||||
return {
|
||||
'id': inst.id,
|
||||
'code': inst.code,
|
||||
'i': inst.i, 'j': inst.j,
|
||||
'rot': inst.rot or 0,
|
||||
'level': inst.level or 1,
|
||||
'stored': inst.stored or {},
|
||||
'last_tick': fields.Datetime.to_string(inst.last_tick) if inst.last_tick else None,
|
||||
}
|
||||
|
||||
def _serialize_stock(self, world, resource_code=None):
|
||||
"""序列化世界库存。resource_code 为 None 返回全部。"""
|
||||
Stock = request.env['yt.world.stock'].sudo()
|
||||
dom = [('world_id', '=', world.id)]
|
||||
if resource_code:
|
||||
dom.append(('resource_code', '=', resource_code))
|
||||
return {s.resource_code: {
|
||||
'amount': s.amount or 0.0,
|
||||
'rate_per_min': s.rate_per_min or 0.0,
|
||||
'capacity': s.capacity or -1.0,
|
||||
'last_settle': fields.Datetime.to_string(s.last_settle) if s.last_settle else None,
|
||||
} for s in Stock.search(dom)}
|
||||
|
||||
@http.route('/yt_world/api/world/resources', type='http', auth='public',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
def world_resources(self, **kw):
|
||||
"""取当前世界资源状态。
|
||||
可选 query: q, r(当前微观地块),返回该地块内建筑实例的囤积状态。
|
||||
"""
|
||||
auth_err = self._require_user()
|
||||
if auth_err:
|
||||
return _json(auth_err)
|
||||
world = self._get_world()
|
||||
if not world:
|
||||
return _json({'ok': False, 'error': 'no_world'})
|
||||
world._recalc_rates()
|
||||
q, r = kw.get('q'), kw.get('r')
|
||||
instances = []
|
||||
if q is not None and r is not None:
|
||||
tile = request.env['yt.world.tile'].sudo().search([
|
||||
('world_id', '=', world.id), ('q', '=', int(q)), ('r', '=', int(r))], limit=1)
|
||||
if tile and tile.micro_world_id:
|
||||
instances = [
|
||||
self._serialize_build_instance(i)
|
||||
for i in request.env['yt.world.build.instance'].sudo().search(
|
||||
[('micro_id', '=', tile.micro_world_id.id)])
|
||||
]
|
||||
return _json({
|
||||
'ok': True,
|
||||
'stocks': self._serialize_stock(world),
|
||||
'instances': instances,
|
||||
})
|
||||
|
||||
@http.route('/yt_world/api/world/settle', type='http', auth='public',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def world_settle(self, **kw):
|
||||
"""手动/定时触发世界资源结算。body: {"auto_collect": true/false, "force_collect": true/false}
|
||||
在线自动进账时 auto_collect=true;离线登录后 force_collect=true 可把已有囤积也兜底回库。
|
||||
"""
|
||||
auth_err = self._require_user()
|
||||
if auth_err:
|
||||
return _json(auth_err)
|
||||
world = self._get_world()
|
||||
if not world:
|
||||
return _json({'ok': False, 'error': 'no_world'})
|
||||
body = _post_body()
|
||||
auto_collect = bool(body.get('auto_collect', False))
|
||||
force_collect = bool(body.get('force_collect', False))
|
||||
res = world._settle_all(auto_collect=auto_collect, force_collect=force_collect)
|
||||
world._recalc_rates()
|
||||
return _json({
|
||||
'ok': True,
|
||||
'auto_collect': auto_collect,
|
||||
'force_collect': force_collect,
|
||||
'produced': res.get('produced', {}),
|
||||
'collected': res.get('collected', {}),
|
||||
'stocks': self._serialize_stock(world),
|
||||
})
|
||||
|
||||
@http.route('/yt_world/api/building/collect', type='http', auth='public',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def building_collect(self, **kw):
|
||||
"""回收指定建筑实例的囤积资源。body: {"q": int, "r": int, "i": int, "j": int}
|
||||
返回回收到的资源数量与最新库存。
|
||||
"""
|
||||
auth_err = self._require_user()
|
||||
if auth_err:
|
||||
return _json(auth_err)
|
||||
world = self._get_world()
|
||||
if not world:
|
||||
return _json({'ok': False, 'error': 'no_world'})
|
||||
body = _post_body()
|
||||
q, r, i, j = body.get('q'), body.get('r'), body.get('i'), body.get('j')
|
||||
if q is None or r is None or i is None or j is None:
|
||||
return _json({'ok': False, 'error': 'bad_params'})
|
||||
tile = request.env['yt.world.tile'].sudo().search([
|
||||
('world_id', '=', world.id), ('q', '=', int(q)), ('r', '=', int(r))], limit=1)
|
||||
if not tile or not tile.micro_world_id:
|
||||
return _json({'ok': False, 'error': 'no_micro'})
|
||||
res = world._collect_building(tile.micro_world_id.id, int(i), int(j))
|
||||
if not res.get('ok'):
|
||||
return _json(res)
|
||||
world._recalc_rates()
|
||||
return _json({
|
||||
'ok': True,
|
||||
'collected': res.get('collected', {}),
|
||||
'stocks': self._serialize_stock(world),
|
||||
})
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
<field name="desc">春季播种的耕地,定期产出粮食作物。</field>
|
||||
<field name="stat_level">1</field><field name="stat_hp">60</field>
|
||||
<field name="stat_yield" eval="{'food': 3}"/>
|
||||
<field name="stat_yield_tick" eval="{'food': 3}"/>
|
||||
</record>
|
||||
<record id="bi_fence" model="yt.world.build.item">
|
||||
<field name="code">fence</field><field name="name">栅栏</field>
|
||||
@@ -198,6 +199,7 @@
|
||||
<field name="desc">汲取地下水的圆井,保障饮水与灌溉。_r/_l 为同一水井的两种朝向。</field>
|
||||
<field name="stat_level">1</field><field name="stat_hp">90</field>
|
||||
<field name="stat_yield" eval="{'water': 2}"/>
|
||||
<field name="stat_yield_tick" eval="{'water': 2}"/>
|
||||
</record>
|
||||
<record id="bi_school" model="yt.world.build.item">
|
||||
<field name="code">school</field><field name="name">学堂</field>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""升级到 0.4:建筑产出规格迁移到代码单一真源(BUILD_SPECS)。
|
||||
|
||||
此前产出写在 noupdate 的 XML 记录里,模块升级时 Odoo 不会重放已存在记录的字段,
|
||||
导致改了产出必须手动补写 DB。本次把单一真源移到 build_spec.py,
|
||||
本迁移在每次跨越 0.3→0.4 的升级时执行一次:把代码规格刷进 item 字段,
|
||||
并对所有世界做全量对账(速率 tick + 即时属性 instant)。
|
||||
|
||||
以后调任何建筑产出:改 build_spec.BUILD_SPECS + 升一次级(如需再次 sync 则 bump 版本)即可。
|
||||
运行时结算/算速率/前端目录均已直接读 BUILD_SPECS,即使不跑本迁移游戏也正确。
|
||||
"""
|
||||
def migrate(cr, version):
|
||||
from odoo.addons.yt_world import sync_specs_from_code
|
||||
sync_specs_from_code(cr, None)
|
||||
@@ -3,6 +3,8 @@ from . import yt_world_tile
|
||||
from . import yt_world_tile_micro
|
||||
from . import yt_world_micro_char
|
||||
from . import yt_world_build_item
|
||||
from . import yt_world_build_instance
|
||||
from . import yt_world_stock
|
||||
from . import yt_world_game
|
||||
from . import yt_world_task
|
||||
from . import yt_resource
|
||||
|
||||
@@ -29,6 +29,133 @@ class YtWorld(models.Model):
|
||||
created_at = fields.Datetime(string='创建时间', default=fields.Datetime.now)
|
||||
last_active = fields.Datetime(string='最后活跃')
|
||||
|
||||
# ========== 全局参数(可通过 ir.config_parameter 覆盖) ==========
|
||||
# 离线收益封顶(分钟):默认 24 小时
|
||||
# 离线效率系数:1.0 = 离线全给;0.5 = 离线只给一半
|
||||
|
||||
def _get_config(self, key, default):
|
||||
"""读 ir.config_parameter 中的 yt_world 全局配置。"""
|
||||
return self.env['ir.config_parameter'].sudo().get_param('yt_world.%s' % key, default)
|
||||
|
||||
def _settle_all(self, now=None, auto_collect=False, force_collect=False):
|
||||
"""结算整个世界所有 tick 建筑产出,并更新库存 last_settle。
|
||||
|
||||
auto_collect=True 时把本轮新增产出直接回收进世界库存(在线自动进账)。
|
||||
force_collect=True 时额外把建筑里已有的囤积全部回收进库存(用于登录/切回时兜底)。
|
||||
返回 {'produced': {code: amount}, 'collected': {code: amount}, 'stocks': {code: amount}}。
|
||||
"""
|
||||
if now is None:
|
||||
now = fields.Datetime.now()
|
||||
try:
|
||||
cap_minutes = float(self._get_config('offline_cap_minutes', '1440'))
|
||||
efficiency = float(self._get_config('offline_efficiency', '1.0'))
|
||||
except Exception:
|
||||
cap_minutes, efficiency = 1440.0, 1.0
|
||||
# 遍历世界下所有微观世界
|
||||
Stock = self.env['yt.world.stock'].sudo()
|
||||
Instance = self.env['yt.world.build.instance'].sudo()
|
||||
produced = {}
|
||||
collected = {}
|
||||
for world in self:
|
||||
micros = self.env['yt.world.tile.micro'].sudo().search([
|
||||
('tile_id.world_id', '=', world.id)
|
||||
])
|
||||
# 先把 build_items JSON 同步到建筑实例表(修复旧建筑没有实例的问题)
|
||||
micros._sync_build_instances()
|
||||
res = micros.settle_buildings(
|
||||
now=now, auto_collect=auto_collect,
|
||||
offline_cap_minutes=cap_minutes, offline_efficiency=efficiency
|
||||
)
|
||||
for code, amt in res.items():
|
||||
produced[code] = produced.get(code, 0.0) + amt
|
||||
# force_collect:把建筑里剩余囤积全部回收到库存(与 auto_collect 配合兜底离线 backlog)
|
||||
if force_collect:
|
||||
for micro in micros:
|
||||
for inst in Instance.search([('micro_id', '=', micro.id)]):
|
||||
if not inst.stored:
|
||||
continue
|
||||
for code, amt in inst.stored.items():
|
||||
if amt <= 0:
|
||||
continue
|
||||
stock = Stock.get_or_create(micro.tile_id.world_id.id, code)
|
||||
stock.amount = (stock.amount or 0.0) + amt
|
||||
collected[code] = collected.get(code, 0.0) + amt
|
||||
inst.stored = {}
|
||||
inst.last_tick = now
|
||||
# 更新这个世界的所有 stock last_settle
|
||||
for stock in Stock.search([('world_id', '=', world.id)]):
|
||||
stock.last_settle = now
|
||||
return {'produced': produced, 'collected': collected,
|
||||
'stocks': {s.resource_code: s.amount for s in Stock.search([('world_id', 'in', self.ids)])}}
|
||||
|
||||
def _collect_building(self, micro_id, i, j):
|
||||
"""回收指定建筑实例的囤积资源到世界库存。"""
|
||||
Instance = self.env['yt.world.build.instance'].sudo()
|
||||
Stock = self.env['yt.world.stock'].sudo()
|
||||
inst = Instance.search([('micro_id', '=', micro_id), ('i', '=', i), ('j', '=', j)], limit=1)
|
||||
if not inst or not inst.stored:
|
||||
return {'ok': True, 'collected': {}}
|
||||
micro = inst.micro_id
|
||||
world_id = micro.tile_id.world_id.id
|
||||
collected = {}
|
||||
for code, amt in (inst.stored or {}).items():
|
||||
if amt <= 0:
|
||||
continue
|
||||
stock = Stock.get_or_create(world_id, code)
|
||||
stock.amount = (stock.amount or 0.0) + amt
|
||||
collected[code] = amt
|
||||
inst.stored = {}
|
||||
inst.last_tick = fields.Datetime.now()
|
||||
return {'ok': True, 'collected': collected}
|
||||
|
||||
def _recalc_rates(self):
|
||||
"""重新计算世界各资源总产出速率(每分钟),写回 stock.rate_per_min。
|
||||
速率直接从代码规格 BUILD_SPECS 读取,不依赖 noupdate 的 XML 字段。"""
|
||||
from odoo.addons.yt_world.build_spec import get_spec
|
||||
Stock = self.env['yt.world.stock'].sudo()
|
||||
Instance = self.env['yt.world.build.instance'].sudo()
|
||||
for world in self:
|
||||
rates = {}
|
||||
micros = self.env['yt.world.tile.micro'].sudo().search([
|
||||
('tile_id.world_id', '=', world.id)
|
||||
])
|
||||
# 先把 build_items JSON 同步到建筑实例表(修复旧建筑没有实例的问题)
|
||||
micros._sync_build_instances()
|
||||
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)
|
||||
# 确保每种有产出的资源都有 stock 行
|
||||
for code, rate in rates.items():
|
||||
stock = Stock.get_or_create(world.id, code)
|
||||
stock.rate_per_min = rate
|
||||
# 把已经没有产出的资源速率归零
|
||||
for stock in Stock.search([('world_id', '=', world.id)]):
|
||||
if stock.resource_code not in rates:
|
||||
stock.rate_per_min = 0.0
|
||||
return True
|
||||
|
||||
def _reconcile_instant(self):
|
||||
"""对账即时属性(如 population):把世界库存 amount 设为当前建筑实例的合计。
|
||||
幂等 SET,避免与实例 create/unlink 钩子的增量 ± 叠加产生漂移。"""
|
||||
from odoo.addons.yt_world.build_spec import get_spec
|
||||
Stock = self.env['yt.world.stock'].sudo()
|
||||
Instance = self.env['yt.world.build.instance'].sudo()
|
||||
for world in self:
|
||||
totals = {}
|
||||
micros = self.env['yt.world.tile.micro'].sudo().search([
|
||||
('tile_id.world_id', '=', world.id)
|
||||
])
|
||||
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():
|
||||
totals[code] = totals.get(code, 0.0) + (amt or 0)
|
||||
for code, amt in totals.items():
|
||||
stock = Stock.get_or_create(world.id, code)
|
||||
stock.amount = amt
|
||||
return True
|
||||
|
||||
# ---------- 计算字段(仅展示,不影响任何 API 返回结构) ----------
|
||||
|
||||
@api.depends('settings')
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api
|
||||
from odoo.addons.yt_world.build_spec import get_spec
|
||||
|
||||
|
||||
class YtWorldBuildInstance(models.Model):
|
||||
"""微观世界建筑实例(按实例记录产出/囤积状态)。
|
||||
|
||||
与 yt.world.tile.micro 的 build_items JSON 数组同步:
|
||||
- 放置建筑时创建实例
|
||||
- 删除建筑时删除实例
|
||||
- 每个实例独立记录:stored(已囤积但未回收的资源) / last_tick(最后结算时间)
|
||||
"""
|
||||
_name = 'yt.world.build.instance'
|
||||
_description = '微观世界建筑实例'
|
||||
_sql_constraints = [
|
||||
('uniq_pos', 'unique(micro_id, i, j)',
|
||||
'同一微观世界同一格只能有一个建筑实例'),
|
||||
]
|
||||
|
||||
micro_id = fields.Many2one('yt.world.tile.micro', string='微观世界',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
code = fields.Char(string='素材编码', required=True, index=True)
|
||||
i = fields.Integer(string='网格X', required=True)
|
||||
j = fields.Integer(string='网格Y', required=True)
|
||||
rot = fields.Integer(string='旋转', default=0)
|
||||
level = fields.Integer(string='等级', default=1)
|
||||
stored = fields.Json(string='囤积资源', default=dict,
|
||||
help='离线/未回收期间累计的资源,如 {"food": 12.5}')
|
||||
last_tick = fields.Datetime(string='最后结算时间', default=fields.Datetime.now)
|
||||
created_at = fields.Datetime(string='创建时间', default=fields.Datetime.now)
|
||||
|
||||
def name_get(self):
|
||||
return [(r.id, '%s(%s,%s)' % (r.code, r.i, r.j)) for r in self]
|
||||
|
||||
# ===== 增量维护世界库存:建造加、删除减(行业标准的 production-graph 做法)=====
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
recs = super().create(vals_list)
|
||||
for rec in recs:
|
||||
rec._apply_spec(+1)
|
||||
return recs
|
||||
|
||||
def unlink(self):
|
||||
# 删除前先记录,删除后再从世界库存扣减,保证不丢 world 引用
|
||||
captured = []
|
||||
for rec in self:
|
||||
world = rec.micro_id.tile_id.world_id
|
||||
if world:
|
||||
captured.append((world.id, get_spec(rec.code)))
|
||||
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():
|
||||
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():
|
||||
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 扣减。"""
|
||||
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():
|
||||
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():
|
||||
if amt:
|
||||
stock = Stock.get_or_create(world.id, code)
|
||||
stock.amount = (stock.amount or 0.0) + sign * amt
|
||||
@@ -45,8 +45,12 @@ class YtWorldBuildItem(models.Model):
|
||||
help='true=顶视图,岛上压成菱形贴地;false=3/4 立绘,竖直站立')
|
||||
stat_level = fields.Integer(string='默认等级', default=1)
|
||||
stat_hp = fields.Integer(string='默认耐久', default=100)
|
||||
stat_yield = fields.Json(string='默认产出', default=dict,
|
||||
help='默认产出映射,如 {"food": 3, "wood": 1}')
|
||||
stat_yield = fields.Json(string='默认产出(兼容)', default=dict,
|
||||
help='兼容旧数据;新产出请使用 stat_yield_instant / stat_yield_tick')
|
||||
stat_yield_instant = fields.Json(string='一次性产出', default=dict,
|
||||
help='建造/存在期间一次性生效的属性,如 {"population": 4, "military": 4}')
|
||||
stat_yield_tick = fields.Json(string='按分钟产出', default=dict,
|
||||
help='按分钟累计的资源产出,如 {"food": 3, "meat": 2}')
|
||||
actions = fields.Json(string='交互动作', default=list,
|
||||
help='建筑点击弹窗中的动作列表,如 [{"action":"upgrade","label":"升级","cost":{"gold":50}}]')
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class YtWorldStock(models.Model):
|
||||
"""世界级别资源库存(单一来源)。
|
||||
|
||||
每个世界 + 每种资源 一条记录。
|
||||
amount: 当前可用数量
|
||||
rate_per_min: 当前世界该资源总产出速率(缓存,方便前端显示)
|
||||
capacity: 容量上限(-1 表示无上限,后续由 storage 建筑扩展)
|
||||
last_settle: 最后结算时间戳
|
||||
"""
|
||||
_name = 'yt.world.stock'
|
||||
_description = '世界资源库存'
|
||||
|
||||
world_id = fields.Many2one('yt.world', string='世界', required=True,
|
||||
ondelete='cascade', index=True)
|
||||
resource_code = fields.Char(string='资源编码', required=True, index=True)
|
||||
amount = fields.Float(string='数量', default=0.0)
|
||||
rate_per_min = fields.Float(string='每分钟产出', default=0.0)
|
||||
capacity = fields.Float(string='容量上限', default=-1.0)
|
||||
last_settle = fields.Datetime(string='最后结算时间', default=fields.Datetime.now)
|
||||
|
||||
_sql_constraints = [
|
||||
('uniq_world_res', 'unique(world_id, resource_code)',
|
||||
'同一世界同种资源只能有一条库存'),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def get_or_create(self, world_id, resource_code, defaults=None):
|
||||
defaults = defaults or {}
|
||||
rec = self.search([('world_id', '=', world_id),
|
||||
('resource_code', '=', resource_code)], limit=1)
|
||||
if rec:
|
||||
return rec
|
||||
vals = {'world_id': world_id, 'resource_code': resource_code}
|
||||
vals.update(defaults)
|
||||
return self.create(vals)
|
||||
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api
|
||||
from odoo.addons.yt_world.build_spec import get_spec
|
||||
|
||||
|
||||
class YtWorldTileMicro(models.Model):
|
||||
@@ -44,6 +45,109 @@ class YtWorldTileMicro(models.Model):
|
||||
# resources = fields.Json(string='资源') # {wood, food, stone, fish}
|
||||
# ===========================================
|
||||
|
||||
def write(self, vals):
|
||||
res = super(YtWorldTileMicro, self).write(vals)
|
||||
if 'build_items' in vals:
|
||||
self._sync_build_instances()
|
||||
return res
|
||||
|
||||
def _sync_build_instances(self):
|
||||
"""把 build_items JSON 数组同步到 yt.world.build.instance 表。
|
||||
|
||||
规则:
|
||||
- JSON 中每条 item 对应一个实例(按 i/j 唯一)
|
||||
- 实例的 stored/last_tick/created_at 只在新建时初始化,已存在时不被覆盖
|
||||
- JSON 中消失的建筑会被删除实例(连带囤积资源清空)
|
||||
"""
|
||||
Instance = self.env['yt.world.build.instance'].sudo()
|
||||
for micro in self:
|
||||
items = micro.build_items or []
|
||||
wanted = {(it.get('i'), it.get('j')) for it in items}
|
||||
# 删除已不存在的实例
|
||||
for inst in Instance.search([('micro_id', '=', micro.id)]):
|
||||
if (inst.i, inst.j) not in wanted:
|
||||
inst.unlink()
|
||||
# 新建或保留
|
||||
for it in items:
|
||||
i, j = it.get('i'), it.get('j')
|
||||
if i is None or j is None:
|
||||
continue
|
||||
existing = Instance.search([
|
||||
('micro_id', '=', micro.id),
|
||||
('i', '=', int(i)),
|
||||
('j', '=', int(j)),
|
||||
], limit=1)
|
||||
if existing:
|
||||
# 更新可变字段但不覆盖 stored/last_tick
|
||||
existing.write({
|
||||
'code': it.get('type') or existing.code,
|
||||
'rot': int(it.get('rot') or existing.rot or 0),
|
||||
'level': int(it.get('level') or existing.level or 1),
|
||||
})
|
||||
else:
|
||||
Instance.create({
|
||||
'micro_id': micro.id,
|
||||
'code': it.get('type') or 'unknown',
|
||||
'i': int(i), 'j': int(j),
|
||||
'rot': int(it.get('rot') or 0),
|
||||
'level': int(it.get('level') or 1),
|
||||
'stored': {},
|
||||
'last_tick': fields.Datetime.now(),
|
||||
'created_at': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
def settle_buildings(self, now=None, auto_collect=False, offline_cap_minutes=1440, offline_efficiency=1.0):
|
||||
"""结算该微观世界内所有 tick 型建筑的产出。
|
||||
|
||||
返回 {resource_code: produced_total}。
|
||||
auto_collect=True 时,只回收「本轮新增」的产出到世界库存;
|
||||
此前已囤积在 stored 里的(如登录时结算的离线 backlog)予以保留,
|
||||
仍以建筑徽标形式等待玩家手动回收。
|
||||
"""
|
||||
if now is None:
|
||||
now = fields.Datetime.now()
|
||||
Stock = self.env['yt.world.stock'].sudo()
|
||||
Instance = self.env['yt.world.build.instance'].sudo()
|
||||
produced = {}
|
||||
for micro in self:
|
||||
instances = Instance.search([('micro_id', '=', micro.id)])
|
||||
for inst in instances:
|
||||
# 直接从代码规格读取产出,永远最新,不依赖 noupdate 的 XML 字段
|
||||
tick = get_spec(inst.code).get('stat_yield_tick') or {}
|
||||
if not tick:
|
||||
continue
|
||||
last = inst.last_tick or now
|
||||
delta = (now - last).total_seconds() / 60.0
|
||||
if delta <= 0:
|
||||
continue
|
||||
# 离线封顶 + 效率系数
|
||||
capped = min(delta, offline_cap_minutes)
|
||||
effective = capped * offline_efficiency
|
||||
this_call = {} # 本轮新增量(在线增量)
|
||||
for code, rate in tick.items():
|
||||
add = rate * effective
|
||||
if add <= 0:
|
||||
continue
|
||||
stored = dict(inst.stored or {})
|
||||
stored[code] = stored.get(code, 0.0) + add
|
||||
inst.stored = stored
|
||||
this_call[code] = this_call.get(code, 0.0) + add
|
||||
produced[code] = produced.get(code, 0.0) + add
|
||||
inst.last_tick = now
|
||||
# auto_collect:只把本轮新增回收入库,保留既有囤积(离线 backlog)
|
||||
if auto_collect and this_call:
|
||||
for code, amt in this_call.items():
|
||||
stock = Stock.get_or_create(micro.tile_id.world_id.id, code)
|
||||
stock.amount = (stock.amount or 0.0) + amt
|
||||
remain = (inst.stored or {}).get(code, 0.0) - amt
|
||||
if remain <= 0.0001:
|
||||
inst.stored = {k: v for k, v in (inst.stored or {}).items() if k != code}
|
||||
else:
|
||||
ns = dict(inst.stored or {})
|
||||
ns[code] = remain
|
||||
inst.stored = ns
|
||||
return produced
|
||||
|
||||
_sql_constraints = [
|
||||
('uniq_micro', 'unique(tile_id)',
|
||||
'一个地块只能有一个微观世界(一对一)'),
|
||||
|
||||
@@ -2,6 +2,7 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_yt_codex_talent_user,yt.codex.talent user,model_yt_codex_talent,base.group_user,1,0,0,0
|
||||
access_yt_codex_talent_public,yt.codex.talent public,model_yt_codex_talent,base.group_public,1,0,0,0
|
||||
access_yt_codex_talent_portal,yt.codex.talent portal,model_yt_codex_talent,base.group_portal,1,0,0,0
|
||||
access_yt_codex_talent_system,yt.codex.talent system,model_yt_codex_talent,base.group_system,1,1,1,1
|
||||
access_yt_world_character_talent_user,yt.world.character.talent user,model_yt_world_character_talent,base.group_user,1,1,1,1
|
||||
access_yt_world_character_talent_public,yt.world.character.talent public,model_yt_world_character_talent,base.group_public,1,0,0,0
|
||||
access_yt_world_character_talent_portal,yt.world.character.talent portal,model_yt_world_character_talent,base.group_portal,1,1,1,1
|
||||
@@ -22,12 +23,16 @@ access_yt_resource_public,yt.resource public,model_yt_resource,base.group_public
|
||||
access_yt_resource_portal,yt.resource portal,model_yt_resource,base.group_portal,1,1,1,1
|
||||
access_yt_codex_resource_user,yt.codex.resource user,model_yt_codex_resource,base.group_user,1,0,0,0
|
||||
access_yt_codex_resource_public,yt.codex.resource public,model_yt_codex_resource,base.group_public,1,0,0,0
|
||||
access_yt_codex_resource_system,yt.codex.resource system,model_yt_codex_resource,base.group_system,1,1,1,1
|
||||
access_yt_codex_creature_user,yt.codex.creature user,model_yt_codex_creature,base.group_user,1,0,0,0
|
||||
access_yt_codex_creature_public,yt.codex.creature public,model_yt_codex_creature,base.group_public,1,0,0,0
|
||||
access_yt_codex_creature_system,yt.codex.creature system,model_yt_codex_creature,base.group_system,1,1,1,1
|
||||
access_yt_codex_monster_user,yt.codex.monster user,model_yt_codex_monster,base.group_user,1,0,0,0
|
||||
access_yt_codex_monster_public,yt.codex.monster public,model_yt_codex_monster,base.group_public,1,0,0,0
|
||||
access_yt_codex_monster_system,yt.codex.monster system,model_yt_codex_monster,base.group_system,1,1,1,1
|
||||
access_yt_codex_race_user,yt.codex.race user,model_yt_codex_race,base.group_user,1,0,0,0
|
||||
access_yt_codex_race_public,yt.codex.race public,model_yt_codex_race,base.group_public,1,0,0,0
|
||||
access_yt_codex_race_system,yt.codex.race system,model_yt_codex_race,base.group_system,1,1,1,1
|
||||
access_yt_world_character_user,yt.world.character user,model_yt_world_character,base.group_user,1,1,1,1
|
||||
access_yt_world_character_public,yt.world.character public,model_yt_world_character,base.group_public,1,0,0,0
|
||||
access_yt_world_creature_user,yt.world.creature user,model_yt_world_creature,base.group_user,1,1,1,1
|
||||
@@ -39,6 +44,12 @@ access_game_npc_knowledge,game.npc.knowledge user,model_game_npc_knowledge,base.
|
||||
access_yt_world_micro_char_user,yt.world.micro.char user,model_yt_world_micro_char,base.group_user,1,1,1,1
|
||||
access_yt_world_micro_char_public,yt.world.micro.char public,model_yt_world_micro_char,base.group_public,1,0,0,0
|
||||
access_yt_world_micro_char_portal,yt.world.micro.char portal,model_yt_world_micro_char,base.group_portal,1,0,0,0
|
||||
access_yt_world_build_instance_user,yt.world.build.instance user,model_yt_world_build_instance,base.group_user,1,1,1,1
|
||||
access_yt_world_build_instance_public,yt.world.build.instance public,model_yt_world_build_instance,base.group_public,1,1,1,1
|
||||
access_yt_world_build_instance_portal,yt.world.build.instance portal,model_yt_world_build_instance,base.group_portal,1,1,1,1
|
||||
access_yt_world_stock_user,yt.world.stock user,model_yt_world_stock,base.group_user,1,1,1,1
|
||||
access_yt_world_stock_public,yt.world.stock public,model_yt_world_stock,base.group_public,1,1,1,1
|
||||
access_yt_world_stock_portal,yt.world.stock portal,model_yt_world_stock,base.group_portal,1,1,1,1
|
||||
access_yt_world_task_user,yt.world.task user,model_yt_world_task,base.group_user,1,1,1,1
|
||||
access_yt_world_task_public,yt.world.task public,model_yt_world_task,base.group_public,1,0,0,0
|
||||
access_yt_world_task_portal,yt.world.task portal,model_yt_world_task,base.group_portal,1,0,0,0
|
||||
|
||||
|
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!--
|
||||
把「宇森游戏」顶部菜单里的「图鉴」从单一 action 菜单改成父菜单,
|
||||
子菜单分别指向 yt_world 的 5 个图鉴模型。
|
||||
这样 Odoo 后台图鉴看到的内容与 8888 前端图鉴保持一致。
|
||||
-->
|
||||
|
||||
<!-- 清空原「图鉴」菜单的 action,使其成为可展开的父菜单 -->
|
||||
<record model="ir.ui.menu" id="yusen_website.menu_website_codex">
|
||||
<field name="action" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- 子菜单:资源(材料 / 加工品 / 特殊) -->
|
||||
<menuitem id="menu_yt_codex_resource"
|
||||
name="资源"
|
||||
parent="yusen_website.menu_website_codex"
|
||||
action="yt_world.action_yt_codex_resource"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- 子菜单:生物 -->
|
||||
<menuitem id="menu_yt_codex_creature"
|
||||
name="生物"
|
||||
parent="yusen_website.menu_website_codex"
|
||||
action="yt_world.action_yt_codex_creature"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- 子菜单:野怪 -->
|
||||
<menuitem id="menu_yt_codex_monster"
|
||||
name="野怪"
|
||||
parent="yusen_website.menu_website_codex"
|
||||
action="yt_world.action_yt_codex_monster"
|
||||
sequence="30"/>
|
||||
|
||||
<!-- 子菜单:种族 -->
|
||||
<menuitem id="menu_yt_codex_race"
|
||||
name="种族"
|
||||
parent="yusen_website.menu_website_codex"
|
||||
action="yt_world.action_yt_codex_race"
|
||||
sequence="40"/>
|
||||
|
||||
<!-- 子菜单:天赋 -->
|
||||
<menuitem id="menu_yt_codex_talent"
|
||||
name="天赋"
|
||||
parent="yusen_website.menu_website_codex"
|
||||
action="yt_world.action_yt_codex_talent"
|
||||
sequence="50"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,334 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============ 图鉴 - 资源(材料 / 加工品 / 特殊) ============ -->
|
||||
<record id="action_yt_codex_resource" model="ir.actions.act_window">
|
||||
<field name="name">资源</field>
|
||||
<field name="res_model">yt.codex.resource</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help">材料、加工品与特殊资源图鉴。此处维护的数据会同步到 8888 前端图鉴的「材料 / 加工品 / 特殊」分类。</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_resource_list" model="ir.ui.view">
|
||||
<field name="name">yt.codex.resource.list</field>
|
||||
<field name="model">yt.codex.resource</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="icon"/>
|
||||
<field name="category"/>
|
||||
<field name="quality"/>
|
||||
<field name="description"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_resource_search" model="ir.ui.view">
|
||||
<field name="name">yt.codex.resource.search</field>
|
||||
<field name="model">yt.codex.resource</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<filter name="material" string="材料" domain="[('category','=','material')]"/>
|
||||
<filter name="processed" string="加工品" domain="[('category','=','processed')]"/>
|
||||
<filter name="special" string="特殊" domain="[('category','=','special')]"/>
|
||||
<group expand="0" string="分组">
|
||||
<filter name="group_category" string="分类" context="{'group_by':'category'}"/>
|
||||
<filter name="group_quality" string="品质" context="{'group_by':'quality'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_resource_form" model="ir.ui.view">
|
||||
<field name="name">yt.codex.resource.form</field>
|
||||
<field name="model">yt.codex.resource</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="icon"/>
|
||||
<field name="category"/>
|
||||
<field name="quality"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="source"/>
|
||||
<field name="uses"/>
|
||||
<field name="recipe" widget="json" colspan="2"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============ 图鉴 - 生物 ============ -->
|
||||
<record id="action_yt_codex_creature" model="ir.actions.act_window">
|
||||
<field name="name">生物</field>
|
||||
<field name="res_model">yt.codex.creature</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help">可驯养或野生的生物图鉴。此处维护的数据会同步到 8888 前端图鉴的「生物」分类。</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_creature_list" model="ir.ui.view">
|
||||
<field name="name">yt.codex.creature.list</field>
|
||||
<field name="model">yt.codex.creature</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="icon"/>
|
||||
<field name="habitat"/>
|
||||
<field name="tameable"/>
|
||||
<field name="diet"/>
|
||||
<field name="description"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_creature_search" model="ir.ui.view">
|
||||
<field name="name">yt.codex.creature.search</field>
|
||||
<field name="model">yt.codex.creature</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="habitat"/>
|
||||
<filter name="tameable" string="可驯养" domain="[('tameable','=',True)]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_creature_form" model="ir.ui.view">
|
||||
<field name="name">yt.codex.creature.form</field>
|
||||
<field name="model">yt.codex.creature</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="icon"/>
|
||||
<field name="habitat"/>
|
||||
<field name="tameable"/>
|
||||
<field name="diet"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="products" widget="json" colspan="2"/>
|
||||
<field name="description" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============ 图鉴 - 野怪 / 精英 / Boss ============ -->
|
||||
<record id="action_yt_codex_monster" model="ir.actions.act_window">
|
||||
<field name="name">野怪</field>
|
||||
<field name="res_model">yt.codex.monster</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help">野怪、精英与 Boss 图鉴。此处维护的数据会同步到 8888 前端图鉴的「野怪」分类。</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_monster_list" model="ir.ui.view">
|
||||
<field name="name">yt.codex.monster.list</field>
|
||||
<field name="model">yt.codex.monster</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="icon"/>
|
||||
<field name="tier"/>
|
||||
<field name="level_min"/>
|
||||
<field name="level_max"/>
|
||||
<field name="habitat"/>
|
||||
<field name="description"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_monster_search" model="ir.ui.view">
|
||||
<field name="name">yt.codex.monster.search</field>
|
||||
<field name="model">yt.codex.monster</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="habitat"/>
|
||||
<filter name="normal" string="普通野怪" domain="[('tier','=','normal')]"/>
|
||||
<filter name="elite" string="精英野怪" domain="[('tier','=','elite')]"/>
|
||||
<filter name="boss" string="Boss" domain="[('tier','=','boss')]"/>
|
||||
<group expand="0" string="分组">
|
||||
<filter name="group_tier" string="等级" context="{'group_by':'tier'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_monster_form" model="ir.ui.view">
|
||||
<field name="name">yt.codex.monster.form</field>
|
||||
<field name="model">yt.codex.monster</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="icon"/>
|
||||
<field name="tier"/>
|
||||
<field name="level_min"/>
|
||||
<field name="level_max"/>
|
||||
<field name="habitat"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="drops" widget="json" colspan="2"/>
|
||||
<field name="description" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============ 图鉴 - 种族 ============ -->
|
||||
<record id="action_yt_codex_race" model="ir.actions.act_window">
|
||||
<field name="name">种族</field>
|
||||
<field name="res_model">yt.codex.race</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help"> playable 种族图鉴。此处维护的数据会同步到 8888 前端图鉴的「种族」分类。</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_race_list" model="ir.ui.view">
|
||||
<field name="name">yt.codex.race.list</field>
|
||||
<field name="model">yt.codex.race</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="icon"/>
|
||||
<field name="layer"/>
|
||||
<field name="description"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_race_search" model="ir.ui.view">
|
||||
<field name="name">yt.codex.race.search</field>
|
||||
<field name="model">yt.codex.race</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="layer"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_race_form" model="ir.ui.view">
|
||||
<field name="name">yt.codex.race.form</field>
|
||||
<field name="model">yt.codex.race</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="icon"/>
|
||||
<field name="layer"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="traits" widget="json" colspan="2"/>
|
||||
<field name="description" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============ 图鉴 - 天赋 ============ -->
|
||||
<record id="action_yt_codex_talent" model="ir.actions.act_window">
|
||||
<field name="name">天赋</field>
|
||||
<field name="res_model">yt.codex.talent</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help">全局天赋配置图鉴。此处维护的数据会同步到 8888 前端图鉴的「天赋」分类。</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_talent_list" model="ir.ui.view">
|
||||
<field name="name">yt.codex.talent.list</field>
|
||||
<field name="model">yt.codex.talent</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="icon"/>
|
||||
<field name="quality"/>
|
||||
<field name="trigger"/>
|
||||
<field name="effect"/>
|
||||
<field name="max_level"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_talent_search" model="ir.ui.view">
|
||||
<field name="name">yt.codex.talent.search</field>
|
||||
<field name="model">yt.codex.talent</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="trigger"/>
|
||||
<filter name="common" string="普通" domain="[('quality','=','common')]"/>
|
||||
<filter name="rare" string="精良" domain="[('quality','=','rare')]"/>
|
||||
<filter name="legendary" string="传说" domain="[('quality','=','legendary')]"/>
|
||||
<group expand="0" string="分组">
|
||||
<filter name="group_quality" string="品质" context="{'group_by':'quality'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_codex_talent_form" model="ir.ui.view">
|
||||
<field name="name">yt.codex.talent.form</field>
|
||||
<field name="model">yt.codex.talent</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="icon"/>
|
||||
<field name="quality"/>
|
||||
<field name="trigger"/>
|
||||
<field name="effect"/>
|
||||
<field name="max_level"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="cost_type"/>
|
||||
<field name="cost_value"/>
|
||||
<field name="unlock_condition"/>
|
||||
<field name="prerequisite_talent_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -20,7 +20,7 @@
|
||||
<t t-name="card">
|
||||
<div class="oe_kanban_card oe_kanban_global_click">
|
||||
<div class="o_kanban_image">
|
||||
<img t-att-src="kanban_image('game.codex.entry', 'image', record.id.raw_value)" alt="Image"
|
||||
<img t-att-src="'/web/image/game.codex.entry/' + record.id.raw_value + '/image'" alt="Image"
|
||||
style="max-width:60px;max-height:60px;object-fit:cover;border-radius:4px;"
|
||||
t-if="record.image.raw_value"/>
|
||||
<t t-else="">
|
||||
|
||||
@@ -370,4 +370,46 @@ const API = {
|
||||
},
|
||||
// 暴露后端 base,供图片等静态资源 URL 拼接(本地跨域→8069;线上同域→'')
|
||||
base: API_CONFIG.base,
|
||||
|
||||
// ---------- 资源系统(库存 / 结算 / 离线囤积回收) ----------
|
||||
async worldResources(q, r) {
|
||||
try {
|
||||
const qs = (q !== undefined && r !== undefined) ? ('?q=' + q + '&r=' + r) : '';
|
||||
const r0 = await fetch(_worldUrl('/yt_world/api/world/resources') + qs, { credentials: _wcred });
|
||||
if (!r0.ok) return { ok: false, error: 'http_' + r0.status };
|
||||
return await r0.json();
|
||||
} catch (e) {
|
||||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||||
}
|
||||
},
|
||||
async worldSettle(auto_collect = false, force_collect = false) {
|
||||
try {
|
||||
const r0 = await fetch(_worldUrl('/yt_world/api/world/settle'), {
|
||||
method: 'POST', credentials: _wcred,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auto_collect, force_collect }),
|
||||
});
|
||||
if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) {
|
||||
return { ok: false, error: 'auth' };
|
||||
}
|
||||
return await r0.json();
|
||||
} catch (e) {
|
||||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||||
}
|
||||
},
|
||||
async collectBuilding(q, r, i, j) {
|
||||
try {
|
||||
const r0 = await fetch(_worldUrl('/yt_world/api/building/collect'), {
|
||||
method: 'POST', credentials: _wcred,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ q, r, i, j }),
|
||||
});
|
||||
if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) {
|
||||
return { ok: false, error: 'auth' };
|
||||
}
|
||||
return await r0.json();
|
||||
} catch (e) {
|
||||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
+145
-10
@@ -2585,6 +2585,12 @@ function microPatrolArrive(p, roadSet, u) {
|
||||
let microBuildMode = false; // 是否处于建造模式
|
||||
let microBuildSel = null; // 当前选中素材 id(用于放置新副本)
|
||||
let microBuildings = {}; // 按地块存:{ 'q,r': [{i,j,type,rot,w,h}] },避免跨地块串味
|
||||
// ===== 资源系统:世界库存 + 本地块建筑囤积(离线产出)缓存 =====
|
||||
let microStockCache = {}; // resource_code -> {amount, rate_per_min, capacity, last_settle}
|
||||
let microInstanceCache = {}; // 'i,j' -> {id, code, i, j, rot, level, stored, last_tick}
|
||||
let microCollectHints = []; // 每帧重建:[{x, y, i, j}] 世界坐标下的可点击回收徽标
|
||||
let microResourceTimer = null; // 在线定时结算计时器
|
||||
let microSettleLock = false; // 防止 settle 并发请求导致重复/覆盖
|
||||
let microGhost = { i: 0, j: 0 }; // 放置预览锚点格(左上角),随鼠标浮动
|
||||
let microEditRef = null; // 正在编辑(可移动/旋转/删除)的已放置建筑
|
||||
let microEditDrag = false; // 拖拽移动已放置建筑中?
|
||||
@@ -3413,6 +3419,9 @@ function enterMicroWorld(tile) {
|
||||
microLoadCatalog(); // 拉取 Odoo 后台建造目录(未登录时保留前端兜底常量),hover/弹窗即时生效
|
||||
microLoadGame(); // 拉取角色 / 生物 / 订单(Odoo 后端),覆盖单位状态
|
||||
fetchTasks(); // 拉取任务定义 + 角色进度(新手主线)
|
||||
// 资源系统:登录时一次性结算离线产出并兜底回库,再启动在线自动进账
|
||||
microSettle(true, true).then(() => microRefreshResources());
|
||||
startResourceLoop();
|
||||
// 初始化角色动画系统
|
||||
if (typeof preloadAllSheets === 'function') preloadAllSheets();
|
||||
if (typeof updateCharPlacePanel === 'function') updateCharPlacePanel();
|
||||
@@ -3458,6 +3467,103 @@ function enterMicroWorld(tile) {
|
||||
}, MICRO_ENTER_HOLD);
|
||||
}
|
||||
|
||||
// ============ 资源系统:库存拉取 / 结算 / 回收 ============
|
||||
function fmtNum(v) {
|
||||
v = Number(v) || 0;
|
||||
if (Math.abs(v) >= 100) return String(Math.round(v));
|
||||
return String(Math.round(v * 10) / 10);
|
||||
}
|
||||
// 拉取世界库存 + 当前地块建筑囤积,刷新缓存与右侧面板
|
||||
async function microRefreshResources() {
|
||||
if (!microState || !microState.tile) return;
|
||||
const { q, r } = microState.tile;
|
||||
const res = await API.worldResources(q, r);
|
||||
if (!res || !res.ok) return;
|
||||
microStockCache = res.stocks || {};
|
||||
microInstanceCache = {};
|
||||
(res.instances || []).forEach(inst => { microInstanceCache[inst.i + ',' + inst.j] = inst; });
|
||||
updateSidebar();
|
||||
}
|
||||
// 触发一次世界结算:auto_collect=true 在线增量自动进账;force_collect=true 额外回收建筑已有囤积
|
||||
async function microSettle(auto_collect, force_collect) {
|
||||
if (microSettleLock) return { ok: false, error: 'busy' };
|
||||
microSettleLock = true;
|
||||
try {
|
||||
console.log('[yt_world] settle start', { auto_collect, force_collect });
|
||||
const res = await API.worldSettle(auto_collect, force_collect);
|
||||
if (res && res.ok) {
|
||||
microStockCache = res.stocks || microStockCache;
|
||||
console.log('[yt_world] settle ok', { produced: res.produced, collected: res.collected });
|
||||
} else {
|
||||
console.warn('[yt_world] settle fail', res);
|
||||
}
|
||||
return res;
|
||||
} finally {
|
||||
microSettleLock = false;
|
||||
}
|
||||
}
|
||||
// 回收某建筑囤积到世界库存(点击徽标触发)
|
||||
async function microCollectAt(i, j) {
|
||||
if (!microState || !microState.tile) return;
|
||||
const { q, r } = microState.tile;
|
||||
const res = await API.collectBuilding(q, r, i, j);
|
||||
if (res && res.ok) {
|
||||
microStockCache = res.stocks || microStockCache;
|
||||
delete microInstanceCache[i + ',' + j];
|
||||
const parts = Object.entries(res.collected || {}).map(([k, v]) => k + ' +' + fmtNum(v));
|
||||
microToast(parts.length ? ('已回收:' + parts.join(',')) : '无囤积资源');
|
||||
updateSidebar();
|
||||
} else {
|
||||
microRefreshResources();
|
||||
}
|
||||
}
|
||||
// 在线定时:每 30s 结算一次在线增量自动进账;用递归 setTimeout 避免重叠请求
|
||||
function startResourceLoop() {
|
||||
if (microResourceTimer) clearTimeout(microResourceTimer);
|
||||
const loop = async () => {
|
||||
if (!microState || !microState.tile) {
|
||||
microResourceTimer = null;
|
||||
return;
|
||||
}
|
||||
await microSettle(true, false);
|
||||
microRefreshResources();
|
||||
microResourceTimer = setTimeout(loop, 30000);
|
||||
};
|
||||
microResourceTimer = setTimeout(loop, 30000);
|
||||
}
|
||||
|
||||
// 切回页面时立即兜底结算,避免浏览器节流/标签页休眠导致资源中断
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && microState && microState.tile) {
|
||||
console.log('[yt_world] visibility visible, catch up settle');
|
||||
microSettle(true, true).then(() => microRefreshResources());
|
||||
}
|
||||
});
|
||||
}
|
||||
// 在网格世界坐标 (wx,wy) 处绘制"可回收囤积"徽标(随 zoom 缩放)
|
||||
function microDrawCollectHint(ctx, wx, wy) {
|
||||
const t = (microState ? microState.animTime : 0);
|
||||
const pulse = 0.5 + 0.5 * Math.sin(t * 4);
|
||||
ctx.save();
|
||||
ctx.translate(wx, wy);
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 15 + pulse * 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(255,196,60,0.22)';
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 13, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#ffb300';
|
||||
ctx.fill();
|
||||
ctx.lineWidth = 2; ctx.strokeStyle = '#ffffff'; ctx.stroke();
|
||||
ctx.strokeStyle = '#5a3b00'; ctx.lineWidth = 2.5; ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -6); ctx.lineTo(0, 5);
|
||||
ctx.moveTo(-4, 1); ctx.lineTo(0, 5); ctx.lineTo(4, 1);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function calcResources(tile) {
|
||||
const res = {};
|
||||
if (tile.hasBuilding && BUILDINGS[tile.buildingType]) {
|
||||
@@ -3479,15 +3585,25 @@ function updateSidebar() {
|
||||
} else { buildingDiv.innerHTML = '<div style="color:#445566;font-size:12px;">—</div>'; }
|
||||
|
||||
const resDiv = document.getElementById('detailResources');
|
||||
const res = microState.resources;
|
||||
if (Object.keys(res).length > 0) {
|
||||
const resNames = { food:'🍖 食物', wood:'🪵 木材', metal:'⛏ 金属', gold:'💰 金币', culture:'🏛 文化', research:'🔬 科研', defense:'🛡 防御', military:'⚔ 军事', pop:'👥 人口' };
|
||||
resDiv.innerHTML = Object.entries(res).map(([k,v]) => `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0;">
|
||||
<span style="font-size:12px;color:#ccd;">${resNames[k]||k}</span>
|
||||
<div class="resource-bar" style="width:100px;">
|
||||
<div class="resource-fill" style="width:${Math.min(v*12,100)}%;background:linear-gradient(90deg,#4af,#a8e6cf);"></div></div>
|
||||
<span style="font-size:12px;color:#8bc34a;">+${v}</span></div>`).join('');
|
||||
} else { resDiv.innerHTML = '<div style="color:#667788;font-size:12px;">无产出</div>'; }
|
||||
const 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 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]);
|
||||
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
|
||||
});
|
||||
resDiv.innerHTML = stockEntries.map(([k, s]) => `<div style="display:flex;justify-content:space-between;align-items:center;margin:5px 0;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.06);">
|
||||
<span style="font-size:12px;color:#ccd;">${resNames[k] || k}</span>
|
||||
<div style="display:flex;align-items:baseline;gap:10px;">
|
||||
<span style="font-size:13px;color:#8bc34a;font-weight:bold;">${Math.floor(s.amount)}</span>
|
||||
<span style="font-size:10px;color:#7fdca0;white-space:nowrap;">+${fmtNum(s.rate_per_min)}/分</span>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
} else {
|
||||
resDiv.innerHTML = '<div style="color:#667788;font-size:12px;">暂无资源</div>';
|
||||
}
|
||||
|
||||
const popDiv = document.getElementById('detailPopulation');
|
||||
if (microState.units.length > 0) {
|
||||
@@ -3533,6 +3649,7 @@ function startMicroRender() {
|
||||
const dt = Math.min((now - (loop._last || now)) / 1000, 0.05);
|
||||
loop._last = now;
|
||||
microState.animTime += dt;
|
||||
microCollectHints.length = 0; // 每帧重建可回收徽标命中表
|
||||
const mW = microCanvas.width, mH = microCanvas.height;
|
||||
const terrain = TERRAIN[microState.tile.terrain];
|
||||
|
||||
@@ -3635,6 +3752,14 @@ function startMicroRender() {
|
||||
// 玩家仍可通过 hover 看到白+橙色高亮框,建造预览时也有绿/红合法性外框。
|
||||
microDrawPlaced(microCtx, b, it, g.cell, ew, eh);
|
||||
microCtx.restore();
|
||||
// 建筑囤积提示徽标(离线产出,可点击回收)
|
||||
const mInst = microInstanceCache[b.i + ',' + b.j];
|
||||
if (mInst && mInst.stored && Object.values(mInst.stored).some(v => v > 0.0001)) {
|
||||
const topV = microCellToScreen(b.i, b.j);
|
||||
const bx = topV.x, by = topV.y - 26;
|
||||
microDrawCollectHint(microCtx, bx, by);
|
||||
microCollectHints.push({ x: bx, y: by, i: b.i, j: b.j });
|
||||
}
|
||||
});
|
||||
|
||||
// hover 高亮:把微高亮的建筑额外描一遍醒目 footprint(覆盖在所有建筑之上,不被遮挡)
|
||||
@@ -4139,8 +4264,12 @@ async function onMicroUp(e) {
|
||||
microSwipe.active = false;
|
||||
microCanvas.style.cursor = '';
|
||||
if (Math.hypot(dx, dy) < MICRO_SWIPE_MIN) {
|
||||
// 轻点:命中已放置建筑 → 弹出说明/交互弹窗(非建造模式)
|
||||
// 轻点:先看是否命中"可回收囤积"徽标(优先级最高)
|
||||
const p = getMicroPoint(e);
|
||||
const wp = screenToWorld(p.x, p.y);
|
||||
const hitHint = microCollectHints.find(h => Math.hypot(wp.x - h.x, wp.y - h.y) <= 18);
|
||||
if (hitHint) { microCollectAt(hitHint.i, hitHint.j); return; }
|
||||
// 命中已放置建筑 → 弹出说明/交互弹窗(非建造模式)
|
||||
const hit = microBuildingAt(microCellAt(p));
|
||||
if (hit) {
|
||||
hideMicroHoverTip();
|
||||
@@ -4202,6 +4331,7 @@ function setMicroTile(tile) {
|
||||
updateSidebar();
|
||||
microAutoRoadCells(); // 切地块时重新计算自动道路贴图
|
||||
microSanitizeBuildings(); // 切地块时清理越界建筑
|
||||
microRefreshResources(); // 切地块时刷新建筑囤积徽标缓存
|
||||
}
|
||||
// 快照旧视图 → 切换地块 → 重建缓存 → 启动滑入过渡
|
||||
function transitionMicroTo(tile, dx, dy) {
|
||||
@@ -5695,6 +5825,8 @@ function exitMicroWorld() {
|
||||
document.getElementById('microView').classList.remove('active');
|
||||
if (microAnimId) { cancelAnimationFrame(microAnimId); microAnimId = null; }
|
||||
if (typeof saveMicroCharsNow === 'function') saveMicroCharsNow(); // 退出前落库角色(删除/移动)
|
||||
if (microResourceTimer) { clearInterval(microResourceTimer); microResourceTimer = null; }
|
||||
microStockCache = {}; microInstanceCache = {}; microCollectHints = [];
|
||||
microState = null;
|
||||
microGridCache = null;
|
||||
microBuildRevealAt = 0;
|
||||
@@ -6449,6 +6581,9 @@ async function flushBuildSave() {
|
||||
} else {
|
||||
microToast('建造未保存:连不上后端(确认 Odoo 已启动、经 localhost:8080 访问)');
|
||||
}
|
||||
} else {
|
||||
// 后端已同步建筑实例并更新速率,立即刷新右侧面板
|
||||
await microRefreshResources();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user