diff --git a/addons/yt_world/__manifest__.py b/addons/yt_world/__manifest__.py index aa5c792..0884e80 100644 --- a/addons/yt_world/__manifest__.py +++ b/addons/yt_world/__manifest__.py @@ -13,6 +13,8 @@ 'data/codex_race.xml', 'data/codex_talent.xml', 'data/yt_world_build_item.xml', + 'data/yt_world_equip_item.xml', + 'data/yt_world_race.xml', 'data/yt_world_task.xml', 'views/yt_world_views.xml', # 图鉴:与 8888 前端图鉴对齐的 5 个模型视图 + 菜单改造 diff --git a/addons/yt_world/controllers/world_api.py b/addons/yt_world/controllers/world_api.py index f3816a3..f12be93 100644 --- a/addons/yt_world/controllers/world_api.py +++ b/addons/yt_world/controllers/world_api.py @@ -834,7 +834,7 @@ class YtWorldApi(http.Controller): @http.route('/yt_world/api/creatures', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False) def get_creatures(self, **kw): - """取当前世界全部生物。返回含服务器 id 与客户端 cid(partner.cid)。""" + """取当前世界全部生物(世界生物表)。返回完整面板数据。""" auth_err = self._require_user() if auth_err: return _json(auth_err) @@ -843,24 +843,14 @@ class YtWorldApi(http.Controller): return _json({'ok': False, 'error': 'no_world'}) recs = request.env['yt.world.creature'].sudo().search( [('world_id', '=', world.id)], order='id') - out = [] - for r in recs: - out.append({ - 'oid': r.id, - 'cid': (r.partner or {}).get('cid'), - 'name': r.name, 'species': r.species, 'level': r.level, - 'exp': r.exp, 'hp': r.hp, 'hp_max': r.hp_max, - 'energy': r.energy, 'mood': r.mood or '😊', 'job': r.job, - 'equip': r.equip or {'weapon': None, 'armor': None, 'accessory': None}, - 'tile_ref': r.tile_ref or {}, 'state': r.state or 'resting', - }) + out = [r.to_panel_dict() for r in recs] return _json({'ok': True, 'creatures': out}) @http.route('/yt_world/api/creatures/save', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False) def save_creatures(self, **kw): - """upsert 生物列表。body: {"creatures":[{cid,name,species,level,exp,hp,hp_max,energy,mood,job,equip,tile_ref,state}]} - 按 partner.cid 匹配;存在则更新,否则新建。返回带 oid 的列表。""" + """upsert 生物列表。body: {"creatures":[{cid,race,name,level,exp,exp_max,hp,hp_max,energy,energy_max,attack,defense,speed,x,y,facing,status,state,mood,job,equip,tile_ref}]} + 按 cid 匹配:存在则更新,否则新建。返回带 oid 的列表。""" auth_err = self._require_user() if auth_err: return _json(auth_err) @@ -875,31 +865,35 @@ class YtWorldApi(http.Controller): cid = c.get('cid') vals = { 'name': c.get('name') or '居民', - 'species': c.get('species'), + 'race': c.get('race') or 'human', 'level': c.get('level', 1), 'exp': c.get('exp', 0), - 'hp': c.get('hp', 100), 'hp_max': c.get('hp_max', 100), - 'energy': c.get('energy', 100), 'mood': c.get('mood', '😊'), - 'job': c.get('job', 'none'), - 'equip': c.get('equip') or {'weapon': None, 'armor': None, 'accessory': None}, + 'exp_max': c.get('exp_max', 100), + 'hp': c.get('hp', 50), 'hp_max': c.get('hp_max', 50), + 'energy': c.get('energy', 100), 'energy_max': c.get('energy_max', 100), + 'attack': c.get('attack', 5), 'defense': c.get('defense', 3), + 'speed': c.get('speed', 5), + 'x': float(c.get('x') or 0), 'y': float(c.get('y') or 0), + 'facing': int(c.get('facing') or 1), + 'status': c.get('status') or 'idle', + 'state': c.get('state') or 'idle', + 'mood': c.get('mood', '😊'), 'job': c.get('job', 'none'), + 'equip': c.get('equip') or {}, 'tile_ref': c.get('tile_ref') or {}, - 'state': c.get('state', 'resting'), - 'partner': {'cid': cid}, } - # Json 字段不能用 ilike 检索,改为在 Python 侧按 partner.cid 匹配。 - rec = None - if cid: - for r in model.search([('world_id', '=', world.id)]): - if (r.partner or {}).get('cid') == cid: - rec = r - break + rec = model.search([('world_id', '=', world.id), ('cid', '=', cid)], limit=1) if cid else None if rec: rec.write(vals) else: vals['world_id'] = world.id + vals['cid'] = cid rec = model.create(vals) out.append({'oid': rec.id, 'cid': cid, 'level': rec.level, 'exp': rec.exp, 'hp': rec.hp, 'equip': rec.equip or {}}) - return _json({'ok': True, 'creatures': out}) + # 删除指定 cid + deleted = body.get('deleted') or [] + if deleted: + model.search([('world_id', '=', world.id), ('cid', 'in', list(deleted))]).unlink() + return _json({'ok': True, 'creatures': out, 'deleted': len(deleted)}) def _find_build_action(self, code, action): """优先从 yt.world.build.item 的 actions 字段查动作配置;没有则回退 BUILD_EFFECTS。""" @@ -998,34 +992,45 @@ class YtWorldApi(http.Controller): # 微观世界角色(玩家放置的种族角色,绑定用户持久化) # ============================================================ - @http.route('/yt_world/api/micro/chars', type='http', auth='public', + @http.route('/yt_world/api/races', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False) - def micro_chars(self, **kw): - """取当前世界全部微观角色。返回 [{oid, cid, race, x, y, facing, state, tile_ref}]""" + def get_races(self, **kw): + """种族模板(单一事实来源)。返回前端 characters.js 初始化所需全部字段。""" + races = request.env['yt.world.race'].sudo().search([('active', '=', True)], order='sequence') + return _json({'ok': True, 'races': [r.to_dict() for r in races]}) + + @http.route('/yt_world/api/equip/catalog', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False) + def equip_catalog(self, **kw): + """装备目录(单一事实来源)。""" + items = request.env['yt.world.equip.item'].sudo().search([('active', '=', True)]) + return _json({'ok': True, 'items': [it.to_dict() for it in items]}) + + def _creature_by_cid(self, world, cid): + if not cid: + return None + return request.env['yt.world.creature'].sudo().search( + [('world_id', '=', world.id), ('cid', '=', cid)], limit=1) + + @http.route('/yt_world/api/creature/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False) + def creature_detail(self, cid, **kw): + """取单个生物详情(按 cid)。""" 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'}) - recs = request.env['yt.world.micro.char'].sudo().search( - [('world_id', '=', world.id)], order='id') - out = [] - for r in recs: - out.append({ - 'oid': r.id, 'cid': r.cid, - 'race': r.race, 'x': r.x, 'y': r.y, - 'facing': r.facing, 'state': r.state, - 'hp': r.hp, - 'tile_ref': r.tile_ref or {}, - }) - return _json({'ok': True, 'chars': out}) + rec = self._creature_by_cid(world, cid) + if not rec: + return _json({'ok': False, 'error': 'char_not_found'}) + return _json({'ok': True, 'char': rec.to_panel_dict()}) - @http.route('/yt_world/api/micro/chars/save', type='http', auth='public', + @http.route('/yt_world/api/creature/action', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False) - def save_micro_chars(self, **kw): - """upsert 微观角色列表。body: {"chars":[{cid,race,x,y,facing,state,tile_ref}], "deleted":[cid...]} - 按 cid 匹配:存在则更新,否则新建;deleted 中的 cid 记录被删除。""" + def creature_action(self, cid=None, **kw): + """生物操作:equip / unequip / train / rest。body: {cid, action, slot?, code?}""" auth_err = self._require_user() if auth_err: return _json(auth_err) @@ -1033,44 +1038,46 @@ class YtWorldApi(http.Controller): if not world: return _json({'ok': False, 'error': 'no_world'}) body = _post_body() - items = body.get('chars') or [] - deleted = body.get('deleted') or [] - model = request.env['yt.world.micro.char'].sudo() - out = [] - for c in items: - cid = c.get('cid') - vals = { - 'race': c.get('race') or 'human', - 'x': float(c.get('x') or 0), - 'y': float(c.get('y') or 0), - 'facing': int(c.get('facing') or 1), - 'state': c.get('state') or 'idle', - 'tile_ref': c.get('tile_ref') or {}, - } - # 生命值:传了才写(兼容旧版前端不传 hp) - if c.get('hp') is not None: - vals['hp'] = int(c.get('hp') or 0) - rec = None - if cid: - for r in model.search([('world_id', '=', world.id)]): - if r.cid == cid: - rec = r - break - if rec: - rec.write(vals) - else: - vals['world_id'] = world.id - vals['cid'] = cid - rec = model.create(vals) - out.append({'oid': rec.id, 'cid': cid}) - # 删除指定 cid - del_count = 0 - if deleted: - for r in model.search([('world_id', '=', world.id)]): - if r.cid in deleted: - r.unlink() - del_count += 1 - return _json({'ok': True, 'saved': len(out), 'deleted': del_count}) + cid = body.get('cid') or cid + action = body.get('action') + rec = self._creature_by_cid(world, cid) + if not rec: + return _json({'ok': False, 'error': 'char_not_found'}) + if action == 'equip': + slot = body.get('slot') + code = body.get('code') + if slot not in ('weapon', 'armor', 'accessory'): + return _json({'ok': False, 'error': 'bad_slot'}) + item = request.env['yt.world.equip.item'].sudo().search([('code', '=', code)], limit=1) + if not item: + return _json({'ok': False, 'error': 'item_not_found'}) + equip = dict(rec.equip or {}) + equip[slot] = code + rec.write({'equip': equip}) + elif action == 'unequip': + slot = body.get('slot') + equip = dict(rec.equip or {}) + if slot in equip: + equip.pop(slot, None) + rec.write({'equip': equip}) + elif action == 'train': + rec.write({ + 'level': rec.level + 1, + 'exp': 0, + 'exp_max': rec.exp_max + 50, + 'hp_max': rec.hp_max + 10, + 'energy_max': rec.energy_max + 5, + 'attack': rec.attack + 2, + 'defense': rec.defense + 1, + 'speed': rec.speed + 1, + 'hp': rec.hp_max + 10, + 'energy': rec.energy_max + 5, + }) + elif action == 'rest': + rec.write({'status': 'resting', 'hp': rec.hp_max, 'energy': rec.energy_max}) + else: + return _json({'ok': False, 'error': 'unknown_action'}) + return _json({'ok': True, 'char': rec.to_panel_dict()}) # ============================================================ # 资源系统:库存 / 结算 / 离线囤积回收 diff --git a/addons/yt_world/data/codex_talent.xml b/addons/yt_world/data/codex_talent.xml index db08fbf..a3dd19f 100644 --- a/addons/yt_world/data/codex_talent.xml +++ b/addons/yt_world/data/codex_talent.xml @@ -3,7 +3,7 @@ talent_ancient_blood 先民血脉 - 🩸 + assets/icons/talents/ancient_blood.png mythic 被动 生命回复+20%;濒死时触发先民护盾,5秒内免伤 @@ -17,7 +17,7 @@ talent_dragon_soul 龙裔之魂 - 🐉 + assets/icons/talents/dragon_soul.png mythic 被动 火焰抗性+50%;攻击附带灼烧效果 @@ -31,7 +31,7 @@ talent_star_resonance 星辰共鸣 - ✨ + assets/icons/talents/star_resonance.png legendary 被动 法力回复+15%;夜间额外+10% @@ -45,7 +45,7 @@ talent_beast_king 兽王本能 - 🐾 + assets/icons/talents/beast_king.png epic 被动 驯养生物好感度+30%;生物产出+20% @@ -59,7 +59,7 @@ talent_smith_hand 铁匠之手 - 🔨 + assets/icons/talents/smith_hand.png rare 被动 加工品品质+1阶;加工成功率+10% @@ -73,7 +73,7 @@ talent_forest_walker 林间行者 - 🌿 + assets/icons/talents/forest_walker.png rare 被动 森林地形移速+25%;隐蔽+15% @@ -87,7 +87,7 @@ talent_ore_sense 矿脉感应 - ⛏️ + assets/icons/talents/ore_sense.png uncommon 被动 发现稀有矿脉概率+20% diff --git a/addons/yt_world/data/yt_world_equip_item.xml b/addons/yt_world/data/yt_world_equip_item.xml new file mode 100644 index 0000000..fc9665c --- /dev/null +++ b/addons/yt_world/data/yt_world_equip_item.xml @@ -0,0 +1,97 @@ + + + + + + + iron_dagger + 铁匕首 + 🗡️ + weapon + common + {"attack": 3} + 朴实的铁刃,适合初学者的防身武器。 + + + + steel_sword + 精钢短剑 + ⚔️ + weapon + rare + {"attack": 7, "speed": 2} + 锻造精良的短剑,锋利且轻便。 + + + + rune_blade + 符文长剑 + 🗡️ + weapon + epic + {"attack": 12, "hp_max": 15} + 刻有古老符文的长剑,挥动时带有微光。 + + + + + leather_vest + 皮背心 + 🦺 + armor + common + {"defense": 2, "hp_max": 5} + 鞣制皮革制成的轻便护甲。 + + + + iron_armor + 铁鳞甲 + 🛡️ + armor + rare + {"defense": 6, "hp_max": 15} + 鳞状铁片编织的重甲,防护可靠。 + + + + dragon_scale + 龙鳞甲 + 🐉 + armor + legendary + {"defense": 12, "hp_max": 30, "energy_max": 10} + 传说用古龙鳞片打造,沉重但几乎刀枪不入。 + + + + + lucky_charm + 幸运符 + 🍀 + accessory + common + {"speed": 3} + 据说能带来好运的小饰物。 + + + + power_ring + 力量戒指 + 💍 + accessory + rare + {"attack": 4, "defense": 2} + 镶嵌红色宝石的戒指,能感受到温热的力量。 + + + + amulet_life + 生命护符 + 📿 + accessory + epic + {"hp_max": 25, "energy_max": 15} + 蕴含生命能量的护符,佩戴者精力更充沛。 + + diff --git a/addons/yt_world/data/yt_world_race.xml b/addons/yt_world/data/yt_world_race.xml new file mode 100644 index 0000000..2945f24 --- /dev/null +++ b/addons/yt_world/data/yt_world_race.xml @@ -0,0 +1,204 @@ + + + + + human + 人族 + 👤 + assets/characters/human/human_walk_1x6.png + assets/characters/human/human_idle_attack_1x6.png + assets/characters/human/human_work_comm_1x6.png + 50 + 100 + 5 + 3 + 90 + 60 + 0 + 0 + 0.0 + 均衡型。各项能力标准,无特殊被动,适合新手。 + 10 + 1 + + + demon + 妖族 + 😈 + assets/characters/demon/demon_walk_1x6.png + assets/characters/demon/demon_idle_attack_1x6.png + assets/characters/demon/demon_work_comm_1x6.png + 50 + 100 + 5 + 3 + 90 + 60 + 0 + 0 + 0.0 + 均衡型。无特殊被动,与人类齐平的基础战力。 + 20 + 1 + + + dwarf + 矮人 + ⛏️ + assets/characters/dwarf/dwarf_walk_1x6.png + assets/characters/dwarf/dwarf_idle_attack_1x6.png + assets/characters/dwarf/dwarf_work_comm_1x6.png + 50 + 100 + 5 + 5 + 72 + 60 + 0 + 1 + 0.10 + 铁壁·狂暴:移速较慢(0.8倍);每损失10%生命攻击+5%;生命≤30%时获得10%最大生命护盾。 + 30 + 1 + + + elf + 精灵 + 🧝 + assets/characters/elf/elf_walk_1x6.png + assets/characters/elf/elf_idle_attack_1x6.png + assets/characters/elf/elf_work_comm_1x6.png + 50 + 100 + 5 + 2 + 135 + 60 + 0 + 0 + 0.0 + 疾风:移速飞快(1.5倍),机动性最强的种族。 + 40 + 1 + + + dragon + 龙族 + 🐉 + assets/characters/dragon/dragon_walk_1x6.png + assets/characters/dragon/dragon_idle_attack_1x6.png + assets/characters/dragon/dragon_work_comm_1x6.png + 50 + 100 + 8 + 6 + 90 + 90 + 0 + 0 + 0.0 + 龙威:攻击力高(8),攻击范围大(1.5倍),爆发型输出种族。 + 50 + 1 + + + undead + 不死族 + 💀 + assets/characters/undead/undead_walk_1x6.png + assets/characters/undead/undead_idle_attack_1x6.png + assets/characters/undead/undead_work_comm_1x6.png + 100 + 100 + 4 + 3 + 90 + 60 + 1 + 0 + 0.0 + 不死:生命极高(100);脱战10秒后每秒恢复5%最大生命。 + 60 + 1 + + + orc + 兽人 + 🐗 + assets/characters/orc/orc_walk_1x6.png + assets/characters/orc/orc_idle_attack_1x6.png + assets/characters/orc/orc_work_comm_1x6.png + 50 + 100 + 5 + 4 + 90 + 60 + 0 + 0 + 0.0 + 均衡型。绿皮獠牙的战士,无特殊被动,基础战力与人类一致。 + 70 + 1 + + + angel + 天使 + ✨ + assets/characters/angel/angel_walk_1x6.png + assets/characters/angel/angel_idle_attack_1x6.png + assets/characters/angel/angel_work_comm_1x6.png + 50 + 100 + 5 + 4 + 90 + 60 + 0 + 0 + 0.0 + 均衡型。头顶光环的天界战士,无特殊被动,基础战力与人类一致。 + 80 + 1 + + + werewolf + 狼人 + 🐺 + assets/characters/werewolf/werewolf_walk_1x6.png + assets/characters/werewolf/werewolf_idle_attack_1x6.png + assets/characters/werewolf/werewolf_work_comm_1x6.png + 50 + 100 + 5 + 3 + 90 + 60 + 0 + 0 + 0.0 + 均衡型。竖耳狼首的猎手,无特殊被动,基础战力与人类一致。 + 90 + 1 + + + golem + 石傀儡 + 🗿 + assets/characters/golem/golem_walk_1x6.png + assets/characters/golem/golem_idle_attack_1x6.png + assets/characters/golem/golem_work_comm_1x6.png + 50 + 100 + 5 + 8 + 90 + 60 + 0 + 0 + 0.0 + 均衡型。眼中发光的岩石巨像,无特殊被动,基础战力与人类一致。 + 100 + 1 + + diff --git a/addons/yt_world/models/__init__.py b/addons/yt_world/models/__init__.py index b234b27..ae78fce 100644 --- a/addons/yt_world/models/__init__.py +++ b/addons/yt_world/models/__init__.py @@ -1,8 +1,8 @@ from . import yt_world 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_equip_item from . import yt_world_build_instance from . import yt_world_stock from . import yt_world_game diff --git a/addons/yt_world/models/yt_world_equip_item.py b/addons/yt_world/models/yt_world_equip_item.py new file mode 100644 index 0000000..889d147 --- /dev/null +++ b/addons/yt_world/models/yt_world_equip_item.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""微观世界装备图鉴(单一真源)。前端通过 /yt_world/api/equip/catalog 拉取。 + +与 yt.world.creature 的 equip 字段({weapon,armor,accessory} = item.code)联动: +creature.to_panel_dict() 会把装备 code 解析成这里 to_dict() 的完整对象返回前端。 +""" +import json +from odoo import models, fields, api + +_STAT_LABELS = { + 'attack': '攻', 'defense': '防', 'hp_max': '生命', + 'energy_max': '能量', 'speed': '速', +} + + +class YtWorldEquipItem(models.Model): + _name = 'yt.world.equip.item' + _description = '世界生物装备' + _order = 'slot, rarity, id' + + code = fields.Char(string='编码', required=True, index=True) + name = fields.Char(string='名称', required=True) + icon = fields.Char(string='图标', default='⚔️') + slot = fields.Selection( + [('weapon', '武器'), ('armor', '护甲'), ('accessory', '饰品')], + string='部位', required=True) + rarity = fields.Selection( + [('common', '普通'), ('rare', '稀有'), ('epic', '史诗'), ('legendary', '传说')], + string='稀有度', default='common') + stats = fields.Json(string='属性加成', default=dict, + help='{"attack":3,"hp_max":15} 等数值加成') + desc = fields.Text(string='描述') + active = fields.Boolean(string='启用', default=True) + + _sql_constraints = [ + ('uniq_code', 'unique(code)', '装备编码必须唯一'), + ] + + def _bonus_text(self): + stats = self.stats or {} + if not isinstance(stats, dict): + try: + stats = json.loads(stats) + except Exception: + stats = {} + return ' '.join('%s+%s' % (_STAT_LABELS.get(k, k), v) + for k, v in stats.items()) + + def to_dict(self): + self.ensure_one() + return { + 'code': self.code, + 'name': self.name, + 'icon': self.icon or '⚔️', + 'slot': self.slot, + 'rarity': self.rarity, + 'stats': self.stats or {}, + 'bonus': self._bonus_text(), + 'desc': self.desc or '', + 'active': self.active, + } diff --git a/addons/yt_world/models/yt_world_game.py b/addons/yt_world/models/yt_world_game.py index 737986d..2eca6e8 100644 --- a/addons/yt_world/models/yt_world_game.py +++ b/addons/yt_world/models/yt_world_game.py @@ -190,25 +190,42 @@ class YtWorldCharacter(models.Model): class YtWorldCreature(models.Model): _name = 'yt.world.creature' - _description = '世界生物(居民 / NPC)' + _description = '世界生物' _order = 'id' world_id = fields.Many2one('yt.world', string='世界', required=True, ondelete='cascade', index=True) name = fields.Char(string='名称', required=True) species = fields.Char(string='物种编码', index=True) + # 与 characters.js 打通的核心字段 + cid = fields.Char(string='客户端ID', index=True, + help='前端生成的生物唯一标识,用于 upsert 匹配(替代旧 partner.cid)') + race = fields.Char(string='种族', default='human', index=True, + help='human/demon/dwarf/elf/dragon/undead/orc/angel/werewolf/golem') + x = fields.Float(string='X坐标', default=0) + y = fields.Float(string='Y坐标', default=0) + facing = fields.Integer(string='朝向', default=1, help='1=右 / -1=左') level = fields.Integer(string='等级', default=1) exp = fields.Integer(string='经验', default=0) - hp = fields.Integer(string='当前生命', default=100) - hp_max = fields.Integer(string='生命上限', default=100) + exp_max = fields.Integer(string='升级所需经验', default=100) + hp = fields.Integer(string='当前生命', default=50) + hp_max = fields.Integer(string='生命上限', default=50) energy = fields.Integer(string='能量', default=100) + energy_max = fields.Integer(string='能量上限', default=100) + attack = fields.Integer(string='攻击', default=5) + defense = fields.Integer(string='防御', default=3) + speed = fields.Integer(string='速度', default=5) mood = fields.Char(string='心情', default='😊') job = fields.Char(string='职业', default='none') - equip = fields.Json(string='装备', default=dict) + status = fields.Char(string='工作状态', default='idle', + help='idle/working/resting/moving') + state = fields.Char(string='动作状态', default='idle', + help='idle/walk/attack/work/communicate') + equip = fields.Json(string='装备', default=dict, + help='{weapon:code, armor:code, accessory:code}') equip_display = fields.Text(string='装备(可读)', compute='_compute_displays') tile_ref = fields.Json(string='所在地块', default=dict) tile_ref_display = fields.Text(string='所在地块(可读)', compute='_compute_displays') - state = fields.Char(string='状态', default='resting') partner = fields.Json(string='相关对象', default=dict) partner_display = fields.Text(string='相关对象(可读)', compute='_compute_displays') @@ -219,3 +236,129 @@ class YtWorldCreature(models.Model): r.tile_ref_display = _fmt_kv(r.tile_ref) r.partner_display = _fmt_kv(r.partner) + def _race_template(self): + """按 race 取 yt.world.race 模板;不存在则返回 None。""" + self.ensure_one() + if not self.race: + return None + return self.env['yt.world.race'].sudo().search( + [('code', '=', self.race)], limit=1) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if not vals.get('name') and vals.get('race'): + rc = self.env['yt.world.race'].sudo().search( + [('code', '=', vals['race'])], limit=1) + vals['name'] = (rc.name + '居民') if rc else '居民' + # 缺省数值从种族模板取,避免与前端/模板对不齐 + if 'hp_max' not in vals or not vals['hp_max']: + rc = self.env['yt.world.race'].sudo().search( + [('code', '=', vals.get('race', 'human'))], limit=1) + base = rc and rc.to_base_dict() or { + 'hp_max': 50, 'energy_max': 100, 'attack': 5, + 'defense': 3, 'speed': 5} + vals.setdefault('hp_max', base['hp_max']) + vals.setdefault('energy_max', base['energy_max']) + vals.setdefault('attack', base['attack']) + vals.setdefault('defense', base['defense']) + vals.setdefault('speed', base['speed']) + vals.setdefault('hp', base['hp_max']) + vals.setdefault('energy', base['energy_max']) + return super(YtWorldCreature, self).create(vals_list) + + def to_panel_dict(self): + self.ensure_one() + equip = self.equip or {} + catalog = {} + for it in self.env['yt.world.equip.item'].sudo().search([]): + catalog[it.code] = it.to_dict() + equip_resolved = {} + for slot, code in equip.items(): + if code and code in catalog: + equip_resolved[slot] = catalog[code] + return { + 'oid': self.id, + 'cid': self.cid, + 'race': self.race, + 'name': self.name, + 'level': self.level, + 'exp': self.exp, + 'exp_max': self.exp_max, + 'hp': self.hp, + 'hp_max': self.hp_max, + 'energy': self.energy, + 'energy_max': self.energy_max, + 'attack': self.attack, + 'defense': self.defense, + 'speed': self.speed, + 'mood': self.mood, + 'job': self.job, + 'status': self.status, + 'state': self.state, + 'x': self.x, + 'y': self.y, + 'facing': self.facing, + 'tile_ref': self.tile_ref or {}, + 'equip': equip_resolved, + } + + +class YtWorldRace(models.Model): + _name = 'yt.world.race' + _description = '世界生物种族模板' + _order = 'sequence, id' + + code = fields.Char(string='编码', required=True, index=True) + name = fields.Char(string='名称', required=True) + icon = fields.Char(string='图标', default='👤') + sprite_walk = fields.Char(string='行走精灵图', default='') + sprite_idle = fields.Char(string='待机/攻击精灵图', default='') + sprite_work = fields.Char(string='工作精灵图', default='') + hp_max = fields.Integer(string='生命上限', default=50) + energy_max = fields.Integer(string='能量上限', default=100) + attack = fields.Integer(string='攻击', default=5) + defense = fields.Integer(string='防御', default=3) + speed = fields.Integer(string='速度', default=5) + attack_range = fields.Integer(string='攻击范围', default=60) + regen = fields.Boolean(string='脱战回血', default=False) + enrage = fields.Boolean(string='狂暴', default=False) + shield_ratio = fields.Float(string='护盾比例', default=0.0) + trait = fields.Text(string='特性文案') + sequence = fields.Integer(string='排序', default=10) + active = fields.Boolean(string='启用', default=True) + + _sql_constraints = [ + ('uniq_code', 'unique(code)', '种族编码必须唯一'), + ] + + def to_base_dict(self): + self.ensure_one() + return { + 'hp_max': self.hp_max, 'energy_max': self.energy_max, + 'attack': self.attack, 'defense': self.defense, + 'speed': self.speed, + } + + def to_dict(self): + self.ensure_one() + return { + 'code': self.code, + 'name': self.name, + 'icon': self.icon, + 'sprite_walk': self.sprite_walk, + 'sprite_idle': self.sprite_idle, + 'sprite_work': self.sprite_work, + 'hp_max': self.hp_max, + 'energy_max': self.energy_max, + 'attack': self.attack, + 'defense': self.defense, + 'speed': self.speed, + 'attack_range': self.attack_range, + 'regen': self.regen, + 'enrage': self.enrage, + 'shield_ratio': self.shield_ratio, + 'trait': self.trait or '', + 'active': self.active, + } + diff --git a/addons/yt_world/models/yt_world_micro_char.py b/addons/yt_world/models/yt_world_micro_char.py deleted file mode 100644 index 8281eeb..0000000 --- a/addons/yt_world/models/yt_world_micro_char.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -from odoo import models, fields - - -class YtWorldMicroChar(models.Model): - """微观世界角色实例(玩家放置的种族角色,绑定用户世界)。 - - 与建筑(build_items 存在 tile.micro JSON)不同,角色需要"绑定用户 + - 独立记录",故用独立模型:每条记录 = 一个放置在地图上的角色。 - 通过 world_id 间接绑定 res.users(一个用户一个世界)。 - cid 为前端生成的角色唯一标识,用于 upsert 匹配(参照 yt.world.creature)。 - """ - _name = 'yt.world.micro.char' - _description = '微观世界角色' - _order = 'id' - - world_id = fields.Many2one('yt.world', string='所属世界', - required=True, ondelete='cascade', index=True) - cid = fields.Char(string='客户端ID', - help='前端生成的角色唯一标识,用于增量同步') - race = fields.Char(string='种族', default='human', - help='human/demon/dwarf/elf/dragon/undead') - x = fields.Float(string='X坐标', default=0) - y = fields.Float(string='Y坐标', default=0) - facing = fields.Integer(string='朝向', default=1, help='1=右 / -1=左') - state = fields.Char(string='动作状态', default='idle', - help='idle/walk/attack/work/communicate') - hp = fields.Integer(string='生命值', default=50, - help='初始 50,被攻击扣 5,归零后角色消失') - tile_ref = fields.Json(string='所在地块', default=dict, - help='{q, r} 所在宏观地块坐标,角色按地块隔离') diff --git a/addons/yt_world/security/ir.model.access.csv b/addons/yt_world/security/ir.model.access.csv index 1e44a7f..16fba14 100644 --- a/addons/yt_world/security/ir.model.access.csv +++ b/addons/yt_world/security/ir.model.access.csv @@ -37,13 +37,16 @@ access_yt_world_character_user,yt.world.character user,model_yt_world_character, 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 access_yt_world_creature_public,yt.world.creature public,model_yt_world_creature,base.group_public,1,0,0,0 +access_yt_world_equip_item_user,yt.world.equip.item user,model_yt_world_equip_item,base.group_user,1,1,1,1 +access_yt_world_equip_item_public,yt.world.equip.item public,model_yt_world_equip_item,base.group_public,1,0,0,0 +access_yt_world_equip_item_portal,yt.world.equip.item portal,model_yt_world_equip_item,base.group_portal,1,1,1,1 +access_yt_world_race_user,yt.world.race user,model_yt_world_race,base.group_user,1,1,1,1 +access_yt_world_race_public,yt.world.race public,model_yt_world_race,base.group_public,1,1,1,1 +access_yt_world_race_portal,yt.world.race portal,model_yt_world_race,base.group_portal,1,1,1,1 access_game_npc,game.npc user,model_game_npc,base.group_user,1,1,1,1 access_game_npc_relationship,game.npc.relationship user,model_game_npc_relationship,base.group_user,1,1,1,1 access_game_npc_behavior_log,game.npc.behavior.log user,model_game_npc_behavior_log,base.group_user,1,1,1,1 access_game_npc_knowledge,game.npc.knowledge user,model_game_npc_knowledge,base.group_user,1,1,1,1 -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 diff --git a/assets/characters.js b/assets/characters.js index 23f4978..7fecdc1 100644 --- a/assets/characters.js +++ b/assets/characters.js @@ -1,24 +1,56 @@ // assets/characters.js — 微观世界角色动画系统(6种族×5动作) 'use strict'; -// ---- 种族精灵表配置(含种族数值:移速/攻击力/最大生命/恢复能力)---- -const RACE_CONFIG = { - human: { name: '人族', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。各项能力标准,无特殊被动,适合新手。', walk: 'assets/characters/human/human_walk_1x6.png', idle_attack: 'assets/characters/human/human_idle_attack_1x6.png', work_comm: 'assets/characters/human/human_work_comm_1x6.png' }, - demon: { name: '妖族', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。无特殊被动,与人类齐平的基础战力。', walk: 'assets/characters/demon/demon_walk_1x6.png', idle_attack: 'assets/characters/demon/demon_idle_attack_1x6.png', work_comm: 'assets/characters/demon/demon_work_comm_1x6.png' }, - dwarf: { name: '矮人', speed: 72, attack: 5, hpMax: 50, regen: false, enrage: true, shieldRatio: 0.10, trait: '铁壁·狂暴:移速较慢(0.8倍);每损失10%生命攻击+5%;生命≤30%时获得10%最大生命护盾。', walk: 'assets/characters/dwarf/dwarf_walk_1x6.png', idle_attack: 'assets/characters/dwarf/dwarf_idle_attack_1x6.png', work_comm: 'assets/characters/dwarf/dwarf_work_comm_1x6.png' }, - elf: { name: '精灵', speed: 135, attack: 5, hpMax: 50, regen: false, trait: '疾风:移速飞快(1.5倍),机动性最强的种族。', walk: 'assets/characters/elf/elf_walk_1x6.png', idle_attack: 'assets/characters/elf/elf_idle_attack_1x6.png', work_comm: 'assets/characters/elf/elf_work_comm_1x6.png' }, - dragon: { name: '龙族', speed: 90, attack: 8, attackRange: 90, hpMax: 50, regen: false, trait: '龙威:攻击力高(8),攻击范围大(1.5倍),爆发型输出种族。', walk: 'assets/characters/dragon/dragon_walk_1x6.png', idle_attack: 'assets/characters/dragon/dragon_idle_attack_1x6.png', work_comm: 'assets/characters/dragon/dragon_work_comm_1x6.png' }, - undead: { name: '不死族', speed: 90, attack: 4, hpMax: 100, regen: true, trait: '不死:生命极高(100);脱战10秒后每秒恢复5%最大生命。', walk: 'assets/characters/undead/undead_walk_1x6.png', idle_attack: 'assets/characters/undead/undead_idle_attack_1x6.png', work_comm: 'assets/characters/undead/undead_work_comm_1x6.png' }, - orc: { name: '兽人', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。绿皮獠牙的战士,无特殊被动,基础战力与人类一致。', walk: 'assets/characters/orc/orc_walk_1x6.png', idle_attack: 'assets/characters/orc/orc_idle_attack_1x6.png', work_comm: 'assets/characters/orc/orc_work_comm_1x6.png' }, - angel: { name: '天使', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。头顶光环的天界战士,无特殊被动,基础战力与人类一致。', walk: 'assets/characters/angel/angel_walk_1x6.png', idle_attack: 'assets/characters/angel/angel_idle_attack_1x6.png', work_comm: 'assets/characters/angel/angel_work_comm_1x6.png' }, - werewolf:{ name: '狼人', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。竖耳狼首的猎手,无特殊被动,基础战力与人类一致。', walk: 'assets/characters/werewolf/werewolf_walk_1x6.png', idle_attack: 'assets/characters/werewolf/werewolf_idle_attack_1x6.png', work_comm: 'assets/characters/werewolf/werewolf_work_comm_1x6.png' }, - golem: { name: '石傀儡', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。眼中发光的岩石巨像,无特殊被动,基础战力与人类一致。', walk: 'assets/characters/golem/golem_walk_1x6.png', idle_attack: 'assets/characters/golem/golem_idle_attack_1x6.png', work_comm: 'assets/characters/golem/golem_work_comm_1x6.png' }, -}; +// ---- 种族精灵表与数值(单一事实来源 = Odoo yt.world.race,前端不再写死)---- +// RACE_CONFIG 初始为空,进入微观世界时由 loadRaces() 从 /api/races 填充。 +let RACE_CONFIG = {}; + +// 从 Odoo 拉取种族模板(含精灵图路径与全部数值),覆盖 RACE_CONFIG。 +// 失败则保持空表,raceStats 会退回安全默认值,绝不依赖前端写死数据。 +async function loadRaces() { + if (typeof API === 'undefined' || !API.races) return false; + try { + const r = await API.races(); + if (!r || !r.ok || !Array.isArray(r.races)) return false; + const map = {}; + r.races.forEach(rc => { + map[rc.code] = { + name: rc.name, + icon: rc.icon || '👤', + speed: rc.speed, + attack: rc.attack, + defense: rc.defense, + hpMax: rc.hp_max, + energyMax: rc.energy_max, + attackRange: rc.attack_range, + regen: !!rc.regen, + enrage: !!rc.enrage, + shieldRatio: rc.shield_ratio || 0, + trait: rc.trait || '', + walk: rc.sprite_walk, + idle_attack: rc.sprite_idle, + work_comm: rc.sprite_work, + }; + }); + RACE_CONFIG = map; + window.RACE_CONFIG = RACE_CONFIG; + return true; + } catch (e) { + console.warn('[loadRaces] 拉取种族模板失败,退回默认值', e); + return false; + } +} function raceStats(race) { const rc = RACE_CONFIG[race] || RACE_CONFIG.human; + if (!rc) { + // 极端兜底:模板尚未加载时给通用安全值,不写死任何种族差异 + return { speed: 90, attack: 5, attackRange: CHAR_ATTACK_RANGE, hpMax: 50, + regen: false, enrage: false, shieldRatio: 0, defense: 3 }; + } return { - speed: rc.speed, attack: rc.attack, attackRange: rc.attackRange || CHAR_ATTACK_RANGE, + speed: rc.speed, attack: rc.attack, defense: rc.defense, + attackRange: rc.attackRange || CHAR_ATTACK_RANGE, hpMax: rc.hpMax, regen: !!rc.regen, enrage: !!rc.enrage, shieldRatio: rc.shieldRatio || 0, }; @@ -178,8 +210,15 @@ function visibleMicroChars() { function _serializeChar(ch) { return { cid: ch.id, race: ch.race, + name: ch.name, + level: ch.level, exp: ch.exp, exp_max: ch.exp_max, + hp: ch.hp, hp_max: ch.hpMax, + energy: ch.energy, energy_max: ch.energy_max, + attack: ch.attack, defense: ch.defense, speed: ch.speed, + mood: ch.mood, job: ch.job, status: ch.status, x: Math.round(ch.x), y: Math.round(ch.y), facing: ch.facing, state: ch.state, + equip: ch.equip || {}, tile_ref: ch.tile || {}, }; } @@ -201,18 +240,22 @@ function scheduleSaveChars(delay) { charSaveTimer = setTimeout(saveMicroCharsNow, delay || 300); } -// 从后端拉取全部微观角色,填充 microChars(带 oid/tile 映射) +// 从后端拉取全部微观角色(世界生物表),填充 microChars(带 oid/tile 映射) async function loadMicroChars() { if (typeof API === 'undefined' || !API.microChars) return; const res = await API.microChars(); if (!res || !res.ok) return; - const list = res.chars || []; + const list = res.creatures || res.chars || []; // 本地仍存在的角色(未保存成功/临时态)优先保留,其余用服务器数据重建 const localById = {}; microChars.forEach(ch => { if (ch.oid) localById[ch.oid] = ch; }); microChars = list.map(c => { const local = localById[c.oid]; const stats = raceStats(c.race || 'human'); + const hpMax = (typeof c.hp_max === 'number') ? c.hp_max : (stats.hpMax || 50); + const hp = (typeof c.hp === 'number' && c.hp > 0) ? c.hp : hpMax; + const enMax = (typeof c.energy_max === 'number') ? c.energy_max : (stats.energyMax || 100); + const en = (typeof c.energy === 'number') ? c.energy : enMax; return { id: c.cid || ('char_' + Math.random().toString(36).slice(2, 10)), oid: c.oid, race: c.race || 'human', @@ -221,13 +264,21 @@ async function loadMicroChars() { targetX: c.x, targetY: c.y, speed: stats.speed, selected: false, trail: [], tile: c.tile_ref || {}, - hp: (typeof c.hp === 'number' && c.hp > 0) ? c.hp : stats.hpMax, - hpMax: stats.hpMax, - attack: stats.attack, + // 后端权威数值(训练/装备/战斗后的真实值) + name: c.name || ((RACE_CONFIG[c.race] && RACE_CONFIG[c.race].name) || '居民') + '居民', + level: (typeof c.level === 'number') ? c.level : 1, + exp: (typeof c.exp === 'number') ? c.exp : 0, + exp_max: (typeof c.exp_max === 'number') ? c.exp_max : 100, + hp: hp, + hpMax: hpMax, + energy: en, + energy_max: enMax, + attack: (typeof c.attack === 'number') ? c.attack : (stats.attack || 5), + defense: (typeof c.defense === 'number') ? c.defense : (stats.defense || 3), attackRange: stats.attackRange, regen: stats.regen, enrage: stats.enrage, - shieldMax: stats.shieldRatio ? Math.max(1, Math.round(stats.hpMax * stats.shieldRatio)) : 0, + shieldMax: stats.shieldRatio ? Math.max(1, Math.round(hpMax * stats.shieldRatio)) : 0, shield: 0, enrageTriggered: false, lastHurt: 0, @@ -238,18 +289,21 @@ async function loadMicroChars() { wanderInterval: 2 + Math.random() * 4, _userMove: false, freeMove: true, // 自由移动开关(默认开;面板按钮可切换) + equip: c.equip || {}, ...(local ? { frame: local.frame, frameT: local.frameT } : {}), }; }); if (selectedCharId && !getChar(selectedCharId)) selectedCharId = null; window.microChars = microChars; updateCharPanel(); - updateCharPlacePanel(); } function createChar(race, x, y) { const id = 'char_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 5); const stats = raceStats(race); + const rt = RACE_CONFIG[race] || {}; + const hpMax = stats.hpMax || 50; + const enMax = stats.energyMax || 100; const ch = { id, race, x, y, targetX: x, targetY: y, state: 'idle', frame: 0, frameT: 0, @@ -258,24 +312,31 @@ function createChar(race, x, y) { trail: [], oid: null, tile: microCharCurrentTile() || {}, - hp: stats.hpMax, // 生命值(满血) - hpMax: stats.hpMax, // 最大生命(种族差异) - attack: stats.attack, // 攻击力(种族差异) - attackRange: stats.attackRange, // 攻击范围(龙族 1.5 倍 = 90px) - regen: stats.regen, // 是否有脱战恢复 - enrage: stats.enrage, // 是否狂暴(矮人:掉血加攻) - shieldMax: stats.shieldRatio ? Math.max(1, Math.round(stats.hpMax * stats.shieldRatio)) : 0, // 护盾上限(矮人 10% 最大生命) - shield: 0, // 当前护盾值 - enrageTriggered: false, // 护盾是否已触发(血量≤30%) - lastHurt: 0, // 上次受伤时间(ms) - hurtFlash: 0, // 受伤闪白计时 - dying: false, // 死亡动画中 - deathT: 0, // 死亡动画进度(秒) + name: (rt.name || '居民') + '居民', // 名称(来自后端种族模板) + level: 1, exp: 0, exp_max: 100, + hp: hpMax, // 生命值(满血) + hpMax: hpMax, // 最大生命(种族差异,来自模板) + energy: enMax, // 能量(满) + energy_max: enMax, // 能量上限(种族差异,来自模板) + attack: stats.attack, // 攻击力(种族差异,来自模板) + defense: stats.defense, // 防御力(种族差异,来自模板) + attackRange: stats.attackRange, // 攻击范围(龙族 1.5 倍 = 90px) + regen: stats.regen, // 是否有脱战恢复 + enrage: stats.enrage, // 是否狂暴(矮人:掉血加攻) + shieldMax: stats.shieldRatio ? Math.max(1, Math.round(hpMax * stats.shieldRatio)) : 0, // 护盾上限(矮人 10% 最大生命) + shield: 0, // 当前护盾值 + enrageTriggered: false, // 护盾是否已触发(血量≤30%) + lastHurt: 0, // 上次受伤时间(ms) + hurtFlash: 0, // 受伤闪白计时 + dying: false, // 死亡动画中 + deathT: 0, // 死亡动画进度(秒) + mood: '😊', job: 'none', status: 'idle', + equip: {}, // 自主漫游状态 - wanderT: Math.random() * 3, // 首次漫游前随机等待 + wanderT: Math.random() * 3, // 首次漫游前随机等待 wanderInterval: 2 + Math.random() * 4, // 2~6 秒 - _userMove: false, // 是否用户指令移动 - freeMove: charPlaceFreeMove, // 自由移动开关(默认按右侧面板"自由移动"按钮) + _userMove: false, // 是否用户指令移动 + freeMove: charPlaceFreeMove, // 自由移动开关(默认开) }; microChars.push(ch); selectChar(id); @@ -900,48 +961,8 @@ function updateCharPanel() { panel.querySelector('#charDelBtn').addEventListener('click', () => deleteChar(ch.id)); } -// ---- 放置面板(右侧建筑面板"角色"分类下) ---- -// 新放置角色默认是否自由移动(面板上的自由移动按钮切换此值) +// 新放置角色默认是否自由移动(由选中角色面板上的自由移动按钮间接控制) let charPlaceFreeMove = true; -function updateCharPlacePanel() { - const panel = document.getElementById('charPlacePanel'); - if (!panel) return; - const races = Object.keys(RACE_CONFIG); - const fmBtnStyle = charPlaceFreeMove - ? 'background:#1d4a2a;color:#7ef0a0;border:1px solid #3a8a5e;' - : 'background:#3a3a4a;color:#aab;border:1px solid #5a5a7a;'; - panel.innerHTML = ` -
放置角色
-
- ${races.map(r => { - const active = charPlaceRace === r && charPlaceMode; - const style = active - ? 'background:#2a4a6a;color:#cde;border:1px solid #5a9acc;' - : 'background:#1d2b4a;color:#cde;border:1px solid #3a5a8e;'; - return ``; - }).join('')} -
-
- - -
- `; - panel.querySelectorAll('.charRaceBtn').forEach(btn => { - btn.addEventListener('click', () => { - charPlaceRace = btn.dataset.race; - charPlaceMode = true; // 点击种族直接进入放置模式(简化:无需再点"开始放置") - updateCharPlacePanel(); - }); - }); - panel.querySelector('#charFreeMoveToggle').addEventListener('click', () => { - charPlaceFreeMove = !charPlaceFreeMove; - updateCharPlacePanel(); - }); - panel.querySelector('#charClearAll').addEventListener('click', () => { - clearChars(); - updateCharPlacePanel(); - }); -} // 导出到 window window.createChar = createChar; @@ -955,7 +976,6 @@ window.renderChars = renderChars; window.hitChar = hitChar; window.clampCharToIsland = clampCharToIsland; window.updateCharPanel = updateCharPanel; -window.updateCharPlacePanel = updateCharPlacePanel; window.allSheetsLoaded = allSheetsLoaded; window.loadCharSheet = loadCharSheet; window.preloadAllSheets = preloadAllSheets; diff --git a/assets/game-api.js b/assets/game-api.js index b7aeddf..3e47020 100644 --- a/assets/game-api.js +++ b/assets/game-api.js @@ -230,6 +230,41 @@ const API = { return await r.json(); } catch (e) { return null; } }, + async races() { + try { + const r = await fetch(_worldUrl('/yt_world/api/races'), { credentials: _wcred }); + if (!r.ok) return { ok: false, error: 'http_' + r.status }; + const j = await r.json(); + return j && j.ok === false ? j : (j || { ok: false, error: 'empty' }); + } catch (e) { return { ok: false, error: 'network' }; } + }, + async equipCatalog() { + try { + const r = await fetch(_worldUrl('/yt_world/api/equip/catalog'), { credentials: _wcred }); + if (!r.ok) return { ok: false, error: 'http_' + r.status }; + const j = await r.json(); + return j && j.ok === false ? j : (j || { ok: false, error: 'empty' }); + } catch (e) { return { ok: false, error: 'network' }; } + }, + async creature(cid) { + try { + const r = await fetch(_worldUrl('/yt_world/api/creature/' + encodeURIComponent(cid)), { credentials: _wcred }); + if (!r.ok) return { ok: false, error: 'http_' + r.status }; + const j = await r.json(); + return j && j.ok === false ? j : (j || { ok: false, error: 'empty' }); + } catch (e) { return { ok: false, error: 'network' }; } + }, + async creatureAction(payload) { + try { + const r = await fetch(_worldUrl('/yt_world/api/creature/action'), { + method: 'POST', credentials: _wcred, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload || {}), + }); + const j = await r.json(); + return j && j.ok === false ? j : (j || { ok: true }); + } catch (e) { return { ok: false, error: 'network' }; } + }, async buildingAction(payload) { try { const r = await fetch(_worldUrl('/yt_world/api/building/action'), { @@ -244,22 +279,16 @@ const API = { } }, // ---------- 微观世界角色(玩家放置的种族角色,绑定用户持久化) ---------- + // 以下两个已重定向到世界生物表(yt.world.creature),保留旧名以兼容 characters.js async microChars() { - try { - const r = await fetch(_worldUrl('/yt_world/api/micro/chars'), { credentials: _wcred }); - if (!r.ok) return { ok: false, error: 'http_' + r.status }; - const j = await r.json(); - return j && j.ok === false ? j : (j || { ok: false, error: 'empty' }); - } catch (e) { - return { ok: false, error: 'network', detail: String(e && e.message || e) }; - } + return this.creatures(); }, async saveMicroChars(chars, deleted) { try { - const r = await fetch(_worldUrl('/yt_world/api/micro/chars/save'), { + const r = await fetch(_worldUrl('/yt_world/api/creatures/save'), { method: 'POST', credentials: _wcred, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chars: chars || [], deleted: deleted || [] }), + body: JSON.stringify({ creatures: chars || [], deleted: deleted || [] }), }); const j = await r.json(); return j && j.ok === false ? j : (j || { ok: true }); @@ -267,6 +296,10 @@ const API = { return { ok: false, error: 'network', detail: String(e && e.message || e) }; } }, + // 以下旧名重定向到世界生物表(yt.world.creature),保留以兼容历史调用 + async microEquipCatalog() { return this.equipCatalog(); }, + async microChar(cid) { return this.creature(cid); }, + async microCharAction(payload) { return this.creatureAction(payload); }, // ---------- 图鉴(codex) ---------- async codexResources() { return (await fetch(_worldUrl('/yt_world/api/codex/resources'), { credentials: _wcred })).json(); diff --git a/codex.html b/codex.html index 40ca4b0..a6404ae 100644 --- a/codex.html +++ b/codex.html @@ -103,6 +103,7 @@ body::before{ .card:hover{transform:translateY(-3px);box-shadow:0 8px 24px rgba(0,0,0,.4);} .card:hover::before{opacity:1;} .card .icon{font-size:36px;line-height:1.2;margin-bottom:2px;filter:drop-shadow(0 2px 4px rgba(0,0,0,.3));} +.card .icon img.ic-img{width:46px;height:46px;object-fit:contain;display:block;margin:2px auto 0;filter:drop-shadow(0 2px 4px rgba(0,0,0,.3));} .card .name{font-size:14px;font-weight:700;color:#e6f0fb;line-height:1.3;} .card .sub{font-size:11px;color:#7b8ea8;line-height:1.3;} @@ -190,7 +191,8 @@ body::before{ .md-close:hover{background:rgba(239,68,68,.2);color:#ffb4b4;} .md-header{display:flex;align-items:center;gap:16px;margin-bottom:20px;position:relative;} -.md-icon{font-size:52px;line-height:1;} +.md-icon{font-size:52px;line-height:1;display:flex;align-items:center;justify-content:center;} +.md-icon img{width:52px;height:52px;object-fit:contain;filter:drop-shadow(0 2px 4px rgba(0,0,0,.3));} .md-info{flex:1;} .md-title{font-size:22px;font-weight:800;color:#eef2fb;} @@ -552,13 +554,13 @@ function renderGrid(){ div.classList.add('q-'+e.qual); div.innerHTML = ` ${QUALITY_NAMES[e.qual]} -
${e.icon}
+
${iconHTML(e.icon)}
${e.name}
`; } else if(e.type==='creature'){ div.classList.add('creature'); div.innerHTML = ` -
${e.icon}
+
${iconHTML(e.icon)}
${e.name}
${e.hab||''} @@ -570,7 +572,7 @@ function renderGrid(){ div.classList.add('monster','tier-'+e.tier); div.innerHTML = ` ${TIER_NAMES[e.tier]} -
${e.icon}
+
${iconHTML(e.icon)}
${e.name}
Lv ${e.levelMin}-${e.levelMax} @@ -580,7 +582,7 @@ function renderGrid(){ } else if(e.type==='race'){ div.classList.add('race'); div.innerHTML = ` -
${e.icon}
+
${iconHTML(e.icon)}
${e.name}
${e.layer||''} @@ -591,7 +593,7 @@ function renderGrid(){ const mine = e.owned ? `Lv ${e.level}` : ''; div.innerHTML = ` ${QUALITY_NAMES[e.qual]} -
${e.icon}
+
${iconHTML(e.icon)}
${e.name}
${e.trigger||'被动'} @@ -602,7 +604,7 @@ function renderGrid(){ div.classList.add('q-'+e.qual, 'skill_book'); div.innerHTML = ` ${QUALITY_NAMES[e.qual]} -
${e.icon}
+
${iconHTML(e.icon)}
${e.name}
${e.skillType||'主动'} @@ -612,7 +614,7 @@ function renderGrid(){ } else if(e.type==='book'){ div.classList.add('book'); div.innerHTML = ` -
${e.icon}
+
${iconHTML(e.icon)}
${e.name}
${e.bookType||'其他'} @@ -631,6 +633,15 @@ function renderGrid(){ function updateStats(){} +// 图标渲染:图片路径→,否则原样(emoji) +function iconHTML(ic){ + if(!ic) return ''; + if(/^(assets\/|\/)|\.(png|webp|jpe?g|gif|svg)(\?.*)?$/i.test(ic)){ + return ''; + } + return ic; +} + // ===================================================================== // 详 情 弹 窗 @@ -649,7 +660,7 @@ function showDetail(e){ // 头部 html += `
`; - html += `
${e.icon}
`; + html += `
${iconHTML(e.icon)}
`; html += `
${e.name}
`; if(e.type==='resource' || e.type==='talent' || e.type==='skill_book'){ const c = e.qual==='common'?'156,163,175':e.qual==='uncommon'?'34,197,94':e.qual==='rare'?'59,130,246':e.qual==='epic'?'168,85,247':e.qual==='legendary'?'245,158,11':'220,38,38'; diff --git a/world.html b/world.html index b50672b..852af23 100644 --- a/world.html +++ b/world.html @@ -804,6 +804,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa .tp-list{display:flex;flex-direction:column;gap:12px;} .tp-item{display:flex;gap:14px;padding:12px 14px;border-radius:12px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);} .tp-ico{font-size:34px;line-height:1;flex:0 0 auto;} +.tp-ico img.ic-img{width:40px;height:40px;object-fit:contain;display:block;} .tp-body{flex:1;min-width:0;} .tp-name{font-size:16px;font-weight:700;color:#eef2fb;display:flex;align-items:center;gap:8px;flex-wrap:wrap;} .tp-state{font-size:11px;padding:1px 8px;border-radius:6px;} @@ -845,6 +846,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa .td-back:after{content:"";position:absolute;inset:10px;border:1px dashed rgba(167,139,250,.35);border-radius:10px;} .td-front{transform:rotateY(180deg);background:linear-gradient(180deg,rgba(20,26,46,.98),rgba(12,16,30,.98));border:1px solid rgba(255,255,255,.12);} .td-front .td-ico{font-size:40px;line-height:1;} +.td-front .td-ico img.ic-img{width:46px;height:46px;object-fit:contain;display:block;margin:0 auto;} .td-front .td-q{font-size:11px;letter-spacing:1px;margin:7px 0 2px;font-weight:700;} .td-front .td-name{font-size:15px;font-weight:800;color:#fff;} .td-front .td-eff{font-size:11px;color:#cdd8e6;line-height:1.5;margin-top:6px;padding:0 4px;} @@ -1072,7 +1074,6 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
角色
-
@@ -2045,25 +2046,26 @@ const BUILD_INTERACT = { ] } }; -// 装备目录:每个槽位的候选装备(图标 + 简短加成,仅作展示/穿戴)。 -// 穿戴写入生物 equip(持久化到 Odoo yt.world.creature),并联动角色背包。 -const EQUIP_CATALOG = { +// 装备目录:后端 /yt_world/api/micro/equip/catalog 为单一真源; +// 下面常量仅作极端兜底(未登录/网络不可达时显示)。 +const EQUIP_CATALOG_FALLBACK = { weapon: [ - { code: 'iron_sword', name: '铁剑', icon: '🗡️', bonus: '攻+5' }, - { code: 'wood_bow', name: '木弓', icon: '🏹', bonus: '攻+3' }, - { code: 'stone_axe', name: '石斧', icon: '🪓', bonus: '攻+2' }, + { code: 'iron_dagger', name: '铁匕首', icon: '🗡️', bonus: '攻+3' }, + { code: 'steel_sword', name: '精钢短剑', icon: '⚔️', bonus: '攻+7 速+2' }, + { code: 'rune_blade', name: '符文长剑', icon: '🗡️', bonus: '攻+12 血+15' }, ], armor: [ - { code: 'leather_armor', name: '皮甲', icon: '🛡️', bonus: '防+4' }, - { code: 'cloth_robe', name: '布袍', icon: '🥋', bonus: '防+2' }, - { code: 'iron_plate', name: '铁甲', icon: '🪖', bonus: '防+6' }, + { code: 'leather_vest', name: '皮背心', icon: '🦺', bonus: '防+2 血+5' }, + { code: 'iron_armor', name: '铁鳞甲', icon: '🛡️', bonus: '防+6 血+15' }, + { code: 'dragon_scale', name: '龙鳞甲', icon: '🐉', bonus: '防+12 血+30 能+10' }, ], accessory: [ - { code: 'lucky_charm', name: '护符', icon: '🔮', bonus: '运+3' }, - { code: 'speed_boots', name: '疾行靴', icon: '👢', bonus: '速+3' }, - { code: 'power_ring', name: '力量戒', icon: '💍', bonus: '力+4' }, + { code: 'lucky_charm', name: '幸运符', icon: '🍀', bonus: '速+3' }, + { code: 'power_ring', name: '力量戒指', icon: '💍', bonus: '攻+4 防+2' }, + { code: 'amulet_life', name: '生命护符', icon: '📿', bonus: '血+25 能+15' }, ], }; +let EQUIP_CATALOG_SERVER = null; // 全部可建造项(道路/地面 + 建筑/设施),作为面板/渲染的单一来源 function microAllBuildItems() { return MICRO_BUILD_ITEMS.concat(MICRO_BUILD_EXTRA); } @@ -2110,10 +2112,31 @@ async function microLoadCatalog() { } catch (e) { /* 未登录/网络不可达:保留兜底常量 */ } } +// 从 Odoo 拉取装备目录(单一真源) +let microEquipCatalogLoaded = false; +async function microLoadEquipCatalog() { + if (microEquipCatalogLoaded) return; + try { + const r = await API.equipCatalog(); + if (r && r.ok && Array.isArray(r.items)) { + const bySlot = { weapon: [], armor: [], accessory: [] }; + r.items.forEach(it => { + if (!bySlot[it.slot]) bySlot[it.slot] = []; + bySlot[it.slot].push(it); + }); + EQUIP_CATALOG_SERVER = bySlot; + microEquipCatalogLoaded = true; + } + } catch (e) { /* 保留兜底 */ } +} +function microEquipCatalog() { + return EQUIP_CATALOG_SERVER || EQUIP_CATALOG_FALLBACK; +} + // ============ 游戏化系统:角色 / 生物(与 Odoo 后端打通) ============ async function microLoadGame() { try { - const [c, cr] = await Promise.all([API.char(), API.creatures()]); + const [c, cr] = await Promise.all([API.char(), API.creatures(), microLoadEquipCatalog()]); microChar = (c && c.ok !== false) ? c : (c || null); if (microChar && !microChar.equip) microChar.equip = { weapon: null, armor: null, accessory: null }; if (microChar && !microChar.inventory) microChar.inventory = {}; @@ -3519,11 +3542,17 @@ function enterMicroWorld(tile) { // 资源系统:登录时一次性结算离线产出并兜底回库,再启动在线自动进账 microSettle(true, true).then(() => microRefreshResources()); startResourceLoop(); - // 初始化角色动画系统 - if (typeof preloadAllSheets === 'function') preloadAllSheets(); - if (typeof updateCharPlacePanel === 'function') updateCharPlacePanel(); + // 初始化角色动画系统:先动态加载种族模板(后端单一事实来源),再预加载精灵图与角色 + if (typeof loadRaces === 'function') { + loadRaces().then(() => { + if (typeof preloadAllSheets === 'function') preloadAllSheets(); + if (typeof loadMicroChars === 'function') loadMicroChars(); // 从后端拉取该用户放置的角色(刷新/重进不丢) + }); + } else { + if (typeof preloadAllSheets === 'function') preloadAllSheets(); + if (typeof loadMicroChars === 'function') loadMicroChars(); + } if (typeof updateCharPanel === 'function') updateCharPanel(); - if (typeof loadMicroChars === 'function') loadMicroChars(); // 从后端拉取该用户放置的角色(刷新/重进不丢) // 注册角色障碍避让:随机游走 / 用户点击都被吸附到非建筑空地 if (typeof setCharAvoid === 'function') { setCharAvoid({ @@ -4184,16 +4213,16 @@ function onMicroDown(e) { const w = screenToWorld(p.x, p.y); const ch = hitChar(w.x, w.y); if (ch) { - // 点击已选中的角色 → 取消选中;点击其他角色 → 切换选中 - if (typeof selectedCharId !== 'undefined' && selectedCharId === ch.id) selectChar(null); - else selectChar(ch.id); + // 点击角色:选中并打开功能面板(面板关闭后仍可点地面移动) + console.log('[onMicroDown hitChar]', ch.id); + selectChar(ch.id); + showUnitModal(ch.id); e.preventDefault(); return; } // 放置模式:点击地面放置角色(优先级高于移动) if (typeof charPlaceMode !== 'undefined' && charPlaceMode && typeof createChar === 'function') { createChar(charPlaceRace, w.x, w.y); charPlaceMode = false; - if (typeof updateCharPlacePanel === 'function') updateCharPlacePanel(); return; } // 选中角色后:点击地面直接移动(无需再点按钮) @@ -4372,10 +4401,19 @@ async function onMicroUp(e) { hideMicroHoverTip(); const hitIt = microBuildItemById(hit.type); if (hitIt && hitIt.isChar && hit._charCid) { + console.log('[onMicroUp race building]', hit._charCid); showUnitModal(hit._charCid); // 种族角色打开单位面板 } else { showBuildModal(hit); } + } else if (typeof hitChar === 'function') { + // 兜底:mousedown 未命中但 mouseup 命中的精灵图角色 + const ch = hitChar(wp.x, wp.y); + if (ch) { + console.log('[onMicroUp hitChar fallback]', ch.id); + selectChar(ch.id); + showUnitModal(ch.id); + } } return; } @@ -4843,90 +4881,168 @@ function microHash(str) { return h >>> 0; } -// ============ 单位详情弹窗 ============ -function showUnitModal(unitId) { +// ============ 单位详情弹窗(生物面板:数据来自 Odoo yt.world.micro.char) ============ +let microOpenUnitId = null; +function _raceIconFallback(race) { + const map = { human:'🧑', demon:'👹', dwarf:'🧔', elf:'🧝', dragon:'🐲', undead:'💀', orc:'👺', angel:'👼', werewolf:'🐺', golem:'🗿' }; + return map[race] || '👤'; +} +function microSyncCharLocal(char) { + if (!microState || !char) return null; + const key = char.cid || char.id; + if (!key) return null; + const idx = microState.units.findIndex(u => u.id === key || u.cid === key); + const icon = char.icon || _raceIconFallback(char.race); + const synced = { ...char, icon, cid: key }; + if (idx >= 0) { Object.assign(microState.units[idx], synced); return microState.units[idx]; } + microState.units.push(synced); + return synced; +} +function _localCharFromMicroChars(unitId) { + // 兼容 characters.js 精灵图角色:从 microChars 取本地运行时数据转成本地面板对象 + const chars = (typeof window !== 'undefined' && window.microChars) || []; + const ch = chars.find(c => c.id === unitId); + if (!ch) return null; + const raceNameMap = { human:'人族', demon:'妖族', dwarf:'矮人', elf:'精灵', dragon:'龙族', undead:'不死', orc:'兽人', angel:'天使', werewolf:'狼人', golem:'石傀儡' }; + return { + id: ch.id, + cid: ch.id, + name: (raceNameMap[ch.race] || ch.race || '未知') + '居民', + race: ch.race || 'human', + level: 1, exp: 0, exp_max: 100, + hp: typeof ch.hp === 'number' ? ch.hp : (ch.hpMax || 50), + hp_max: ch.hpMax || 50, + energy: 100, energy_max: 100, + attack: ch.attack || 0, + defense: 0, speed: ch.speed || 0, + job: '无', mood: '😊', status: ch.state || 'idle', + equip: {}, + _localOnly: true + }; +} +async function showUnitModal(unitId) { + console.log('[showUnitModal]', unitId); if (!microState) return; - const unit = microState.units.find(u => u.id === unitId); - if (!unit) return; - if (!unit.equip) unit.equip = { weapon: null, armor: null, accessory: null }; + microOpenUnitId = unitId; const modal = document.getElementById('unitModal'); - document.getElementById('modalTitle').textContent = `${unit.icon} ${unit.name} · Lv.${unit.level}`; - const hpColor = unit.hp > 70 ? '#8bc34a' : unit.hp > 40 ? '#ff9800' : '#f44336'; - const enColor = unit.energy > 70 ? '#4af' : unit.energy > 40 ? '#ff9800' : '#f44336'; - const need = unit.level * 100; - const expPct = Math.max(0, Math.min(100, Math.round((unit.exp % need) / need * 100))); + document.getElementById('modalTitle').textContent = '👤 加载中...'; + document.getElementById('modalBody').innerHTML = '
正在从服务器读取生物数据...
'; + modal.classList.add('active'); + + // 优先从 Odoo 拉权威数据(世界生物表,按 cid 匹配) + let r = null; + try { + r = await API.creature(unitId); + console.log('[showUnitModal API.creature response]', r); + } catch (err) { + console.warn('[showUnitModal API.creature error]', err); + } + + let unit = null; + let synced = false; + if (r && r.ok && r.char) { + unit = microSyncCharLocal(r.char); + synced = true; + } + // 后端暂无记录:用 microState.units 或 characters.js 本地数据兜底,**不关闭弹窗** + if (!unit) { + unit = microState.units.find(u => u.id === unitId) || _localCharFromMicroChars(unitId); + if (!unit) { + console.warn('[showUnitModal] no local unit found for', unitId); + microToast('未找到该生物数据'); + closeUnitModal(); + return; + } + if (!synced) { + console.log('[showUnitModal] showing local-only panel for', unitId, unit); + microToast('该生物尚未同步到服务器,显示本地数据'); + } + } + if (!unit.equip) unit.equip = {}; + + const raceIcon = (typeof unit.icon === 'string') ? unit.icon : _raceIconFallback(unit.race); + const syncNote = unit._localOnly ? '
⚠️ 数据未同步到服务器,装备/训练/休息暂不可用
' : ''; + document.getElementById('modalTitle').textContent = `${raceIcon} ${unit.name || '居民'} · Lv.${unit.level || 1}`; + const hpMax = unit.hp_max || 50; + const enMax = unit.energy_max || 100; + const hpColor = unit.hp > hpMax * 0.7 ? '#8bc34a' : unit.hp > hpMax * 0.4 ? '#ff9800' : '#f44336'; + const enColor = unit.energy > enMax * 0.7 ? '#4af' : unit.energy > enMax * 0.4 ? '#ff9800' : '#f44336'; + const expPct = Math.max(0, Math.min(100, Math.round((unit.exp || 0) / (unit.exp_max || 100) * 100))); + const catalog = microEquipCatalog(); const slotHtml = ['weapon', 'armor', 'accessory'].map(slot => { const map = { weapon: '武器', armor: '护甲', accessory: '饰品' }; - const cur = unit.equip[slot]; - const curInfo = cur ? (EQUIP_CATALOG[slot] || []).find(e => e.code === cur) : null; + const cur = unit.equip && unit.equip[slot]; let inner; - if (curInfo) { - inner = `
${curInfo.icon} ${curInfo.name} ${curInfo.bonus} ✕卸下
`; + if (cur) { + inner = `
${cur.icon || '⚔️'} ${cur.name} ${cur.bonus || ''} ✕卸下
`; + } else if (unit._localOnly) { + inner = `
等待服务器同步…
`; } else { - inner = (EQUIP_CATALOG[slot] || []).map(e => - `
${e.icon} ${e.name}${e.bonus}
`).join(''); + inner = (catalog[slot] || []).map(e => + `
${e.icon || '⚔️'} ${e.name}${e.bonus || ''}
`).join(''); } return `
${map[slot]}
${inner}
`; }).join(''); + const cmdDisabled = unit._localOnly ? 'style="opacity:0.5;pointer-events:none;"' : ''; document.getElementById('modalBody').innerHTML = ` -
状态${unit.status==='working'?'🟢 工作中':unit.status==='resting'?'🟡 休息中':'🔵 移动中'}
-
生命
${unit.hp}/${unit.hp_max||120}
-
能量
${unit.energy}
-
经验
${unit.exp}
+ ${syncNote} +
状态${unit.status==='working'?'🟢 工作中':unit.status==='resting'?'🟡 休息中':'🔵 空闲中'}
+
生命
${unit.hp||0}/${hpMax}
+
能量
${unit.energy||0}/${enMax}
+
经验
${unit.exp||0}
+
攻击${unit.attack||0}
+
防御${unit.defense||0}
+
速度${unit.speed||0}
职业${unit.job || '无'}
-
心情${unit.mood}
+
心情${unit.mood || '😊'}
装备(穿戴写入 Odoo 生物记录)
${slotHtml}
操作指令
-
⬆️ 训练升级
-
😴 令其休息
+
+
⬆️ 训练升级
+
😴 令其休息
+
`; modal.classList.add('active'); } -// 穿戴 / 卸下装备(联动角色背包,持久化到 Odoo 生物记录) +// 穿戴 / 卸下装备 → 调后端 action API,成功后刷新面板 async function microEquip(unitId, slot, code, unequip) { - const unit = microState && microState.units.find(u => u.id === unitId); - if (!unit) return; - if (!unit.equip) unit.equip = { weapon: null, armor: null, accessory: null }; - if (unequip) { - const prev = unit.equip[slot]; - unit.equip[slot] = null; - if (prev && microChar && microChar.inventory) { - microChar.inventory[prev] = (microChar.inventory[prev] || 0) + 1; - } - } else { - const prev = unit.equip[slot]; - if (prev && microChar && microChar.inventory) microChar.inventory[prev] = (microChar.inventory[prev] || 0) + 1; - unit.equip[slot] = code; - if (microChar && microChar.inventory) { - microChar.inventory[code] = (microChar.inventory[code] || 0) - 1; - if (microChar.inventory[code] <= 0) delete microChar.inventory[code]; - } + const action = unequip ? 'unequip' : 'equip'; + const payload = { cid: unitId, action, slot }; + if (!unequip) payload.code = code; + const r = await API.creatureAction(payload); + if (!r || r.ok === false) { + microToast('装备操作失败' + (r && r.error ? ' (' + r.error + ')' : '')); + return; } - microRenderCharHud(); - await microSaveCreatures(); - if (microChar) await updateChar(); + // 同步本地内存中的生物数据(若本地没有则追加) + if (r.char) microSyncCharLocal(r.char); + microToast(unequip ? '已卸下装备' : '装备穿戴成功'); showUnitModal(unitId); } -// 训练升级:等级+1、经验清零、持久化 +// 训练升级 → 调后端 action API async function microUnitTrain(unitId) { - const unit = microState && microState.units.find(u => u.id === unitId); - if (!unit) return; - unit.level = (unit.level || 1) + 1; - unit.exp = 0; - unit.hp_max = (unit.hp_max || 120) + 10; - unit.hp = unit.hp_max; - microSaveCreatures(); + const r = await API.creatureAction({ cid: unitId, action: 'train' }); + if (!r || r.ok === false) { + microToast('训练失败' + (r && r.error ? ' (' + r.error + ')' : '')); + return; + } + if (r.char) microSyncCharLocal(r.char); + microToast(`升级成功!${r.char.name || '居民'} 升至 Lv.${r.char.level}`); showUnitModal(unitId); } -// 令其休息:状态切 resting + +// 令其休息 → 调后端 action API async function microUnitRest(unitId) { - const unit = microState && microState.units.find(u => u.id === unitId); - if (!unit) return; - unit.status = 'resting'; - microSaveCreatures(); + const r = await API.creatureAction({ cid: unitId, action: 'rest' }); + if (!r || r.ok === false) { + microToast('休息失败' + (r && r.error ? ' (' + r.error + ')' : '')); + return; + } + if (r.char) microSyncCharLocal(r.char); + microToast('已令该生物休息'); showUnitModal(unitId); } // 保存角色(金币/背包/装备)到 Odoo @@ -4936,7 +5052,7 @@ async function updateChar() { if (r && r.ok && r.char) microChar = r.char; } -function closeUnitModal() { document.getElementById('unitModal').classList.remove('active'); } +function closeUnitModal() { microOpenUnitId = null; document.getElementById('unitModal').classList.remove('active'); } // ===== 微观建筑:悬停 tooltip + 点击弹窗(与 Odoo 后台目录联动)===== let microTipEl = null; @@ -6493,7 +6609,7 @@ function microRenderBuildPanel() { } const cost = (!it.isChar) ? (it.costGold || 0) : null; const costHtml = cost == null ? '' : - (cost > 0 ? `🪙 ${cost}` : `免费`); + (cost > 0 ? `🪙 ${cost}` : ``); return ``; @@ -6515,7 +6631,6 @@ function microRenderBuildPanel() { if (microBuildMode) microSetBuildMode(false); clearBuildSelUI(); microRenderBuildPanel(); - if (typeof updateCharPlacePanel === 'function') updateCharPlacePanel(); const raceName = (window.RACE_CONFIG && window.RACE_CONFIG[item.race]) ? window.RACE_CONFIG[item.race].name : item.name; microToast('👤 点地面放置' + raceName); return; @@ -7199,7 +7314,7 @@ if (typeof fitWorld === 'function') fitWorld(); ? '
已达最高等级
' : ''; div.innerHTML = - '
' + (t.icon || '🌟') + '
' + + '
' + (typeof t.icon==='string' && /\.(png|webp|jpg|jpeg)(\?.*)?$/i.test(t.icon) ? '' : (t.icon || '🌟')) + '
' + '
' + '
' + t.name + ' ' + state + '
' + '
等级 ' + (t.level || 1) + ' / ' + (t.max_level || 1) + '
' + @@ -7303,7 +7418,7 @@ if (typeof fitWorld === 'function') fitWorld(); '
' + '
' + '' + - '
' + (t.icon || '🌟') + '
' + + '
' + (typeof t.icon==='string' && /\.(png|webp|jpg|jpeg)(\?.*)?$/i.test(t.icon) ? '' : (t.icon || '🌟')) + '
' + '
' + (QLABEL[t.quality] || '普通') + '
' + '
' + t.name + '
' + (t.effect ? '
' + t.effect + '
' : '') +