diff --git a/addons/yt_world/__init__.py b/addons/yt_world/__init__.py index f7209b1..6bde60f 100644 --- a/addons/yt_world/__init__.py +++ b/addons/yt_world/__init__.py @@ -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() diff --git a/addons/yt_world/__manifest__.py b/addons/yt_world/__manifest__.py index 1813702..aa5c792 100644 --- a/addons/yt_world/__manifest__.py +++ b/addons/yt_world/__manifest__.py @@ -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', } diff --git a/addons/yt_world/build_spec.py b/addons/yt_world/build_spec.py new file mode 100644 index 0000000..10e14af --- /dev/null +++ b/addons/yt_world/build_spec.py @@ -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 {}, + } diff --git a/addons/yt_world/controllers/world_api.py b/addons/yt_world/controllers/world_api.py index f1b04e0..f3816a3 100644 --- a/addons/yt_world/controllers/world_api.py +++ b/addons/yt_world/controllers/world_api.py @@ -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), + }) diff --git a/addons/yt_world/data/yt_world_build_item.xml b/addons/yt_world/data/yt_world_build_item.xml index 821f9d7..91f633f 100644 --- a/addons/yt_world/data/yt_world_build_item.xml +++ b/addons/yt_world/data/yt_world_build_item.xml @@ -158,6 +158,7 @@ 春季播种的耕地,定期产出粮食作物。 160 + fence栅栏 @@ -198,6 +199,7 @@ 汲取地下水的圆井,保障饮水与灌溉。_r/_l 为同一水井的两种朝向。 190 + school学堂 diff --git a/addons/yt_world/migrations/0.4/post.py b/addons/yt_world/migrations/0.4/post.py new file mode 100644 index 0000000..91bb0d5 --- /dev/null +++ b/addons/yt_world/migrations/0.4/post.py @@ -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) diff --git a/addons/yt_world/models/__init__.py b/addons/yt_world/models/__init__.py index 7916d3f..b234b27 100644 --- a/addons/yt_world/models/__init__.py +++ b/addons/yt_world/models/__init__.py @@ -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 diff --git a/addons/yt_world/models/yt_world.py b/addons/yt_world/models/yt_world.py index bc56972..118a68e 100644 --- a/addons/yt_world/models/yt_world.py +++ b/addons/yt_world/models/yt_world.py @@ -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') diff --git a/addons/yt_world/models/yt_world_build_instance.py b/addons/yt_world/models/yt_world_build_instance.py new file mode 100644 index 0000000..51649af --- /dev/null +++ b/addons/yt_world/models/yt_world_build_instance.py @@ -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 diff --git a/addons/yt_world/models/yt_world_build_item.py b/addons/yt_world/models/yt_world_build_item.py index 5111db6..6514bb0 100644 --- a/addons/yt_world/models/yt_world_build_item.py +++ b/addons/yt_world/models/yt_world_build_item.py @@ -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}}]') diff --git a/addons/yt_world/models/yt_world_stock.py b/addons/yt_world/models/yt_world_stock.py new file mode 100644 index 0000000..c3ca894 --- /dev/null +++ b/addons/yt_world/models/yt_world_stock.py @@ -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) diff --git a/addons/yt_world/models/yt_world_tile_micro.py b/addons/yt_world/models/yt_world_tile_micro.py index ae0c407..a837ff4 100644 --- a/addons/yt_world/models/yt_world_tile_micro.py +++ b/addons/yt_world/models/yt_world_tile_micro.py @@ -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)', '一个地块只能有一个微观世界(一对一)'), diff --git a/addons/yt_world/security/ir.model.access.csv b/addons/yt_world/security/ir.model.access.csv index 77e1a92..1e44a7f 100644 --- a/addons/yt_world/security/ir.model.access.csv +++ b/addons/yt_world/security/ir.model.access.csv @@ -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 diff --git a/addons/yt_world/views/codex_menu.xml b/addons/yt_world/views/codex_menu.xml new file mode 100644 index 0000000..dd17bdb --- /dev/null +++ b/addons/yt_world/views/codex_menu.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/yt_world/views/codex_views.xml b/addons/yt_world/views/codex_views.xml new file mode 100644 index 0000000..50655e2 --- /dev/null +++ b/addons/yt_world/views/codex_views.xml @@ -0,0 +1,334 @@ + + + + + + 资源 + yt.codex.resource + list,form + 材料、加工品与特殊资源图鉴。此处维护的数据会同步到 8888 前端图鉴的「材料 / 加工品 / 特殊」分类。 + + + + yt.codex.resource.list + yt.codex.resource + + + + + + + + + + + + + + + yt.codex.resource.search + yt.codex.resource + + + + + + + + + + + + + + + + + yt.codex.resource.form + yt.codex.resource + + + + + + + + + + + + + + + + + + + + + + + + + + + 生物 + yt.codex.creature + list,form + 可驯养或野生的生物图鉴。此处维护的数据会同步到 8888 前端图鉴的「生物」分类。 + + + + yt.codex.creature.list + yt.codex.creature + + + + + + + + + + + + + + + + yt.codex.creature.search + yt.codex.creature + + + + + + + + + + + + yt.codex.creature.form + yt.codex.creature + + + + + + + + + + + + + + + + + + + + + + + + 野怪 + yt.codex.monster + list,form + 野怪、精英与 Boss 图鉴。此处维护的数据会同步到 8888 前端图鉴的「野怪」分类。 + + + + yt.codex.monster.list + yt.codex.monster + + + + + + + + + + + + + + + + + yt.codex.monster.search + yt.codex.monster + + + + + + + + + + + + + + + + + yt.codex.monster.form + yt.codex.monster + + + + + + + + + + + + + + + + + + + + + + + + + 种族 + yt.codex.race + list,form + playable 种族图鉴。此处维护的数据会同步到 8888 前端图鉴的「种族」分类。 + + + + yt.codex.race.list + yt.codex.race + + + + + + + + + + + + + + yt.codex.race.search + yt.codex.race + + + + + + + + + + + yt.codex.race.form + yt.codex.race + + + + + + + + + + + + + + + + + + + + + + 天赋 + yt.codex.talent + list,form + 全局天赋配置图鉴。此处维护的数据会同步到 8888 前端图鉴的「天赋」分类。 + + + + yt.codex.talent.list + yt.codex.talent + + + + + + + + + + + + + + + + yt.codex.talent.search + yt.codex.talent + + + + + + + + + + + + + + + + + yt.codex.talent.form + yt.codex.talent + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/yusen_website/views/codex_views.xml b/addons/yusen_website/views/codex_views.xml index 9f75152..0342480 100644 --- a/addons/yusen_website/views/codex_views.xml +++ b/addons/yusen_website/views/codex_views.xml @@ -20,7 +20,7 @@ - diff --git a/assets/game-api.js b/assets/game-api.js index 58e8669..b7aeddf 100644 --- a/assets/game-api.js +++ b/assets/game-api.js @@ -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) }; + } + }, }; diff --git a/world.html b/world.html index ad323f8..efdca81 100644 --- a/world.html +++ b/world.html @@ -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 = '—'; } const resDiv = document.getElementById('detailResources'); - const res = microState.resources; - if (Object.keys(res).length > 0) { - const resNames = { food:'🍖 食物', wood:'🪵 木材', metal:'⛏ 金属', gold:'💰 金币', culture:'🏛 文化', research:'🔬 科研', defense:'🛡 防御', military:'⚔ 军事', pop:'👥 人口' }; - resDiv.innerHTML = Object.entries(res).map(([k,v]) => ` - ${resNames[k]||k} - - - +${v}`).join(''); - } else { resDiv.innerHTML = '无产出'; } + const 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]) => ` + ${resNames[k] || k} + + ${Math.floor(s.amount)} + +${fmtNum(s.rate_per_min)}/分 + + `).join(''); + } else { + resDiv.innerHTML = '暂无资源'; + } 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(); } }