diff --git a/addons/yt_world/__manifest__.py b/addons/yt_world/__manifest__.py index 0884e80..398c279 100644 --- a/addons/yt_world/__manifest__.py +++ b/addons/yt_world/__manifest__.py @@ -1,6 +1,6 @@ { 'name': '宇森世界 - 沙盘与微观世界', - 'version': '0.7', + 'version': '0.8', 'summary': '地表存储(沙盘记忆) + 微观世界(1:1预留) + 资源(框架)', 'description': '每个玩家拥有一个确定性生成的世界;仅持久化玩家改过的地表地块(种子+增量覆盖)。微观世界独立为 1:1 模型仅预留,资源模型为框架。', 'category': 'Game', diff --git a/addons/yt_world/controllers/world_api.py b/addons/yt_world/controllers/world_api.py index c5bd3b5..7f22b77 100644 --- a/addons/yt_world/controllers/world_api.py +++ b/addons/yt_world/controllers/world_api.py @@ -916,18 +916,26 @@ class YtWorldApi(http.Controller): 'status': c.get('status') or 'idle', 'state': c.get('state') or 'idle', 'mood': c.get('mood', '😊'), 'job': c.get('job', 'none'), - 'equip': self._normalize_equip(c.get('equip')), 'tile_ref': c.get('tile_ref') or {}, } rec = model.search([('world_id', '=', world.id), ('cid', '=', cid)], limit=1) if cid else None if rec: + # 装备加成是"临时"的(来自实例,不存基础字段);前端回传的是有效属性, + # 落库时回扣装备加成,避免把临时加成固化进基础值导致下次加载重复叠加。 + bonus = rec._equip_bonus() + eff_hp_max = vals.get('hp_max', rec.hp_max) + for f in ('attack', 'defense', 'hp_max'): + if f in vals and isinstance(vals[f], (int, float)): + vals[f] = max(0, int(vals[f]) - bonus.get(f, 0)) + if 'hp' in vals: + vals['hp'] = min(vals['hp'], eff_hp_max) 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 {}}) + 'exp': rec.exp, 'hp': rec.hp}) # 删除指定 cid deleted = body.get('deleted') or [] if deleted: @@ -1085,20 +1093,37 @@ class YtWorldApi(http.Controller): if action == 'equip': slot = body.get('slot') code = body.get('code') - if slot not in ('weapon', 'armor', 'accessory'): + if slot not in ('weapon', 'armor', 'accessory', 'boots'): 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}) + if item.slot != slot: + return _json({'ok': False, 'error': 'slot_mismatch'}) + # 装备改为管理"实例":每只装备是独立实物,支撑未来耐久/强化/交易 + Inst = request.env['yt.world.equip.instance'].sudo() + # 1) 先卸下该槽现有装备(equipped_slot 置空 = 回背包) + old = Inst.search([('owner_creature_id', '=', rec.id), + ('equipped_slot', '=', slot)], limit=1) + if old: + old.write({'equipped_slot': False}) + # 2) 优先复用背包中同款未装备实例(避免每次点击都新建,造成实例堆积) + reuse = Inst.search([('owner_creature_id', '=', rec.id), + ('item_id', '=', item.id), + ('equipped_slot', '=', False)], limit=1) + if reuse: + reuse.write({'equipped_slot': slot}) + else: + Inst.create({'item_id': item.id, 'owner_creature_id': rec.id, + 'equipped_slot': slot, 'durability': 100, + 'max_durability': 100, 'enhance_level': 0}) elif action == 'unequip': slot = body.get('slot') - equip = dict(rec.equip or {}) - if slot in equip: - equip.pop(slot, None) - rec.write({'equip': equip}) + # 仅把该槽实例的 equipped_slot 置空(回背包),不删除实例 + inst = request.env['yt.world.equip.instance'].sudo().search( + [('owner_creature_id', '=', rec.id), ('equipped_slot', '=', slot)], limit=1) + if inst: + inst.write({'equipped_slot': False}) elif action == 'train': rec.write({ 'level': rec.level + 1, diff --git a/addons/yt_world/data/yt_world_equip_item.xml b/addons/yt_world/data/yt_world_equip_item.xml index fc9665c..6b3e047 100644 --- a/addons/yt_world/data/yt_world_equip_item.xml +++ b/addons/yt_world/data/yt_world_equip_item.xml @@ -6,7 +6,7 @@ iron_dagger 铁匕首 - 🗡️ + assets/icons/equips/iron_dagger.png weapon common {"attack": 3} @@ -16,7 +16,7 @@ steel_sword 精钢短剑 - ⚔️ + assets/icons/equips/steel_sword.png weapon rare {"attack": 7, "speed": 2} @@ -26,7 +26,7 @@ rune_blade 符文长剑 - 🗡️ + assets/icons/equips/rune_blade.png weapon epic {"attack": 12, "hp_max": 15} @@ -37,7 +37,7 @@ leather_vest 皮背心 - 🦺 + assets/icons/equips/leather_vest.png armor common {"defense": 2, "hp_max": 5} @@ -64,6 +64,37 @@ 传说用古龙鳞片打造,沉重但几乎刀枪不入。 + + + straw_sandals + 草鞋 + 🩴 + boots + common + {"speed": 1} + 轻便的草编鞋,适合远行。 + + + + leather_boots + 皮靴 + 🥾 + boots + rare + {"speed": 3, "defense": 1} + 结实的皮靴,能保护双脚并提升移动速度。 + + + + wind_boots + 疾风靴 + 👟 + boots + epic + {"speed": 6, "energy_max": 10} + 刻有风之符文的靴子,穿戴者仿佛能踏风而行。 + + lucky_charm diff --git a/addons/yt_world/models/__init__.py b/addons/yt_world/models/__init__.py index ae78fce..7f3a03b 100644 --- a/addons/yt_world/models/__init__.py +++ b/addons/yt_world/models/__init__.py @@ -3,6 +3,7 @@ from . import yt_world_tile from . import yt_world_tile_micro from . import yt_world_build_item from . import yt_world_equip_item +from . import yt_world_equip_instance 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_instance.py b/addons/yt_world/models/yt_world_equip_instance.py new file mode 100644 index 0000000..c41c313 --- /dev/null +++ b/addons/yt_world/models/yt_world_equip_instance.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""装备实例(玩家拥有的具体一件装备)。 + +与图鉴 yt.world.equip.item 的区别: +- item 是「模板」:定义「草鞋」长啥样(name/icon/槽位/基础加成)。 +- instance 是「实物」:A 生物的那只具体草鞋(耐久 80/100、强化 +2、主人是谁)。 + +这样设计是为了给未来的三大系统打地基(与主流 RPG 一致): +- 耐久:durability / max_durability 独立维护,每只装备各自掉耐久。 +- 强化:enhance_level 独立,强化不会污染其他同款装备。 +- 交易:owner_creature_id 改指另一个生物即可转让,顺着 + 生物→世界→账号自动归属到对应玩家。 +""" +from odoo import models, fields, api + + +class YtWorldEquipInstance(models.Model): + _name = 'yt.world.equip.instance' + _description = '装备实例(玩家拥有的具体一件装备)' + _order = 'id desc' + + item_id = fields.Many2one('yt.world.equip.item', string='模板', + required=True, ondelete='cascade', index=True) + owner_creature_id = fields.Many2one('yt.world.creature', string='拥有者生物', + ondelete='cascade', index=True) + # 已装备的部位;空值(False)表示在背包中未装备 + equipped_slot = fields.Selection( + [('weapon', '武器'), ('armor', '护甲'), ('accessory', '饰品'), ('boots', '鞋子')], + string='已装备部位', index=True) + durability = fields.Integer(string='当前耐久', default=100) + max_durability = fields.Integer(string='最大耐久', default=100) + enhance_level = fields.Integer(string='强化等级', default=0) + # 从模板继承的部位,便于按槽位查询(如"列出某生物所有武器") + slot = fields.Selection(related='item_id.slot', string='部位', + store=True, readonly=True) + + equipped = fields.Boolean(string='已装备', compute='_compute_equipped', + store=True, index=True) + + @api.depends('equipped_slot') + def _compute_equipped(self): + for r in self: + r.equipped = bool(r.equipped_slot) + + def to_dict(self): + self.ensure_one() + item = self.item_id + return { + 'id': self.id, + 'item_code': item.code, + 'name': item.name, + 'icon': item.icon, + 'slot': self.slot or item.slot, + 'rarity': item.rarity, + 'stats': item.stats or {}, + 'bonus': item._bonus_text(), + 'durability': self.durability, + 'max_durability': self.max_durability, + 'enhance_level': self.enhance_level, + } diff --git a/addons/yt_world/models/yt_world_equip_item.py b/addons/yt_world/models/yt_world_equip_item.py index 889d147..60fae33 100644 --- a/addons/yt_world/models/yt_world_equip_item.py +++ b/addons/yt_world/models/yt_world_equip_item.py @@ -22,7 +22,7 @@ class YtWorldEquipItem(models.Model): name = fields.Char(string='名称', required=True) icon = fields.Char(string='图标', default='⚔️') slot = fields.Selection( - [('weapon', '武器'), ('armor', '护甲'), ('accessory', '饰品')], + [('weapon', '武器'), ('armor', '护甲'), ('accessory', '饰品'), ('boots', '鞋子')], string='部位', required=True) rarity = fields.Selection( [('common', '普通'), ('rare', '稀有'), ('epic', '史诗'), ('legendary', '传说')], diff --git a/addons/yt_world/models/yt_world_game.py b/addons/yt_world/models/yt_world_game.py index bee0101..cdc9996 100644 --- a/addons/yt_world/models/yt_world_game.py +++ b/addons/yt_world/models/yt_world_game.py @@ -7,6 +7,7 @@ - yt.world.character : 玩家英雄(等级 / 经验 / 金币 / 背包 / 装备),每世界一条 - yt.world.creature : 世界生物(居民 / NPC),含等级 / 经验 / 生命 / 装备 / 职业 """ +import json from odoo import models, fields, api @@ -224,18 +225,16 @@ class YtWorldCreature(models.Model): 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') partner = fields.Json(string='相关对象', default=dict) partner_display = fields.Text(string='相关对象(可读)', compute='_compute_displays') + # 该生物拥有的全部装备实例(背包 + 已装备),与 账号→世界→生物 四级树末端直接关联 + equip_instance_ids = fields.One2many('yt.world.equip.instance', 'owner_creature_id', string='装备实例') - @api.depends('equip', 'tile_ref', 'partner') + @api.depends('tile_ref', 'partner') def _compute_displays(self): for r in self: - r.equip_display = _fmt_kv(r.equip) r.tile_ref_display = _fmt_kv(r.tile_ref) r.partner_display = _fmt_kv(r.partner) @@ -270,18 +269,48 @@ class YtWorldCreature(models.Model): vals.setdefault('energy', base['energy_max']) return super(YtWorldCreature, self).create(vals_list) + def _equip_bonus(self): + """装备提供的临时加成(仅攻击/防御/生命上限;其他数值类型暂不加)。 + + 汇总当前已装备(equipped_slot 非空)实例的模板 stats,不写回基础字段, + 因此加成天然"临时"——脱装备即消失,刷新/重载都从实例实时汇总。 + 这里直接查实例表,避免父记录 One2many 缓存未刷新导致加成丢失。""" + self.ensure_one() + bonus = {'attack': 0, 'defense': 0, 'hp_max': 0} + Inst = self.env['yt.world.equip.instance'].sudo() + for inst in Inst.search([('owner_creature_id', '=', self.id), ('equipped_slot', '!=', False)]): + stats = inst.item_id.stats or {} + if not isinstance(stats, dict): + try: + stats = json.loads(stats) + except Exception: + stats = {} + for k in bonus: + v = stats.get(k) + if isinstance(v, (int, float)): + bonus[k] += int(v) + return bonus + 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() + # 从装备实例表反查"当前已装备"的装备(实例 -> 模板对象), + # 保持前端 {slot: 对象} 结构不变,前端渲染零改动。 equip_resolved = {} - for slot, raw in (equip or {}).items(): - # 容错:equip 的值可能是装备 code 字符串,也可能是历史遗留的整件装备字典 - code = raw.get('code') if isinstance(raw, dict) else raw - if code and code in catalog: - equip_resolved[slot] = catalog[code] + for slot in ('weapon', 'armor', 'accessory', 'boots'): + inst = self.env['yt.world.equip.instance'].sudo().search( + [('owner_creature_id', '=', self.id), ('equipped_slot', '=', slot)], limit=1) + if inst: + equip_resolved[slot] = inst.item_id.to_dict() + # 装备临时加成:有效属性 = 基础字段 + 已装备实例的 stats 汇总 + bonus = self._equip_bonus() + hp = self.hp or 0 + hp_max = (self.hp_max or 0) + bonus['hp_max'] + # 当前生命不超过有效生命上限(如刚脱下 +生命装备导致上限回落) + if hp > hp_max: + hp = hp_max return { 'oid': self.id, 'cid': self.cid, @@ -290,12 +319,12 @@ class YtWorldCreature(models.Model): 'level': self.level, 'exp': self.exp, 'exp_max': self.exp_max, - 'hp': self.hp, - 'hp_max': self.hp_max, + 'hp': hp, + 'hp_max': hp_max, 'energy': self.energy, 'energy_max': self.energy_max, - 'attack': self.attack, - 'defense': self.defense, + 'attack': (self.attack or 0) + bonus['attack'], + 'defense': (self.defense or 0) + bonus['defense'], 'speed': self.speed, 'mood': self.mood, 'job': self.job, diff --git a/addons/yt_world/security/ir.model.access.csv b/addons/yt_world/security/ir.model.access.csv index 16fba14..52d1dee 100644 --- a/addons/yt_world/security/ir.model.access.csv +++ b/addons/yt_world/security/ir.model.access.csv @@ -40,6 +40,9 @@ access_yt_world_creature_public,yt.world.creature public,model_yt_world_creature 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_equip_instance_user,yt.world.equip.instance user,model_yt_world_equip_instance,base.group_user,1,1,1,1 +access_yt_world_equip_instance_public,yt.world.equip.instance public,model_yt_world_equip_instance,base.group_public,1,0,0,0 +access_yt_world_equip_instance_portal,yt.world.equip.instance portal,model_yt_world_equip_instance,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 diff --git a/addons/yt_world/views/yt_world_views.xml b/addons/yt_world/views/yt_world_views.xml index c21fdf2..e4f98ea 100644 --- a/addons/yt_world/views/yt_world_views.xml +++ b/addons/yt_world/views/yt_world_views.xml @@ -364,19 +364,28 @@ - - + - + + + + + + + + + + + + diff --git a/assets/characters.js b/assets/characters.js index 7fecdc1..b61ccfb 100644 --- a/assets/characters.js +++ b/assets/characters.js @@ -401,7 +401,8 @@ function applyAttackDamage(attacker) { if (other.id === attacker.id) continue; const d = Math.hypot(other.x - attacker.x, other.y - attacker.y); if (d <= range) { - let dmg = damage; + // 防御减免:实际伤害 = max(0, 攻击力 - 防御力);攻击 ≤ 防御则免伤(命中但不掉血) + let dmg = Math.max(0, damage - (other.defense || 0)); // 护盾优先吸收(矮人铁壁) if ((other.shield || 0) > 0 && dmg > 0) { const absorbed = Math.min(other.shield, dmg); @@ -412,13 +413,16 @@ function applyAttackDamage(attacker) { other.dmgPopups.push({ value: fmt(absorbed), t: 0, shield: true }); // 青色护盾吸收 } } - // 剩余伤害扣血 + // 剩余伤害扣血(钳制在 [0, 有效生命上限] 内,防止脱装备后上限回落导致越界) + if (!other.dmgPopups) other.dmgPopups = []; if (dmg > 0) { - other.hp = Math.max(0, (typeof other.hp === 'number' ? other.hp : (other.hpMax || CHAR_HP_MAX)) - dmg); + other.hp = Math.min(other.hpMax, Math.max(0, (typeof other.hp === 'number' ? other.hp : (other.hpMax || CHAR_HP_MAX)) - dmg)); other.hurtFlash = 0.4; // 受伤闪白 other.lastHurt = now; // 记录受伤时间(脱战恢复计时用) - if (!other.dmgPopups) other.dmgPopups = []; other.dmgPopups.push({ value: fmt(dmg), t: 0 }); // 红色扣血 + } else { + // 0 伤害也要显示:命中但被防御/护盾完全减免(免伤) + other.dmgPopups.push({ value: '0', t: 0, blocked: true }); // 灰色免伤飘字 } // 矮人铁壁:血量首次 ≤30% 时获得 10% 最大生命的护盾 if (other.enrage && !other.enrageTriggered && other.hp > 0 && other.hp <= other.hpMax * 0.3) { @@ -820,8 +824,8 @@ function renderChars(ctx, time, mW, mH) { const life = 1 - p.t / 0.9; if (life <= 0) continue; ctx.globalAlpha = Math.min(1, life); - ctx.fillStyle = p.shield ? '#37c7f0' : '#ff5040'; - ctx.fillText('-' + p.value, ch.x, ch.y - 46 - p.t * 26); + ctx.fillStyle = p.shield ? '#37c7f0' : p.blocked ? '#9aa3b2' : '#ff5040'; + ctx.fillText(p.blocked ? '0' : '-' + p.value, ch.x, ch.y - 46 - p.t * 26); } ctx.restore(); } @@ -927,6 +931,7 @@ function updateCharPanel() { ${shieldHtml}
👟 移速 ${stats.speed}
⚔ 攻击 ${fmt(curAtk)}${atkBonus}
+
🛡 防御 ${fmt(ch.defense)}
📖 ${rc.trait || '暂无介绍'}
${btns}
diff --git a/assets/icons/equips/iron_dagger.png b/assets/icons/equips/iron_dagger.png new file mode 100644 index 0000000..449de20 Binary files /dev/null and b/assets/icons/equips/iron_dagger.png differ diff --git a/assets/icons/equips/leather_vest.png b/assets/icons/equips/leather_vest.png new file mode 100644 index 0000000..12b55db Binary files /dev/null and b/assets/icons/equips/leather_vest.png differ diff --git a/assets/icons/equips/rune_blade.png b/assets/icons/equips/rune_blade.png new file mode 100644 index 0000000..0a408fe Binary files /dev/null and b/assets/icons/equips/rune_blade.png differ diff --git a/assets/icons/equips/steel_sword.png b/assets/icons/equips/steel_sword.png new file mode 100644 index 0000000..4018fb9 Binary files /dev/null and b/assets/icons/equips/steel_sword.png differ diff --git a/codex.html b/codex.html index a6404ae..584ba49 100644 --- a/codex.html +++ b/codex.html @@ -153,6 +153,11 @@ body::before{ .card.race .tags{display:flex;gap:4px;flex-wrap:wrap;justify-content:center;margin-top:2px;} .card.race .tag{font-size:9px;padding:1px 6px;border-radius:4px;background:rgba(255,255,255,.06);color:#8899aa;} +/* 装备/天赋/技能书等通用标签 */ +.card.equip .tags,.card.talent .tags,.card.skill_book .tags{display:flex;gap:4px;flex-wrap:wrap;justify-content:center;margin-top:2px;} +.card.equip .tag,.card.talent .tag,.card.skill_book .tag{font-size:9px;padding:1px 6px;border-radius:4px;background:rgba(255,255,255,.06);color:#8899aa;} +.card.equip{border-color:rgba(120,200,255,.12);} + /* 野怪等级角标 */ .card .tierbadge{position:absolute;top:8px;right:8px;font-size:9px;font-weight:700;padding:1px 6px;border-radius:6px;letter-spacing:.5px;} .tierbadge.normal{background:rgba(156,163,175,.2);color:#9ca3af;border:1px solid rgba(156,163,175,.3);} @@ -278,6 +283,7 @@ body::before{ 🌟 天赋 📖 技能书 📚 书籍 + 🛡️ 装备 ✨ 特殊
@@ -333,7 +339,8 @@ body::before{ const QUALITIES = ['common','uncommon','rare','epic','legendary','mythic']; const QUALITY_NAMES = { common:'普通', uncommon:'优秀', rare:'精良', epic:'史诗', legendary:'传说', mythic:'神话' }; const TIER_NAMES = { normal:'普通野怪', elite:'精英野怪', boss:'Boss' }; -const CAT_ORDER = ['material','processed','creature','monster','race','talent','skill_book','book','special']; +const CAT_ORDER = ['material','processed','creature','monster','race','talent','skill_book','book','equip','special']; +const SLOT_NAMES = { weapon:'武器', armor:'护甲', accessory:'饰品', boots:'鞋子' }; // 当前玩家已拥有天赋(从后端加载){ code: { level, active } } let MY_TALENTS = {}; @@ -413,6 +420,13 @@ function normBook(b){ desc:b.description||b.desc||'' }; } +function normEquip(e){ + return { + type:'equip', id:e.code, cat:'equip', name:e.name, icon:e.icon||'⚔️', + qual:e.rarity||'common', slot:e.slot||'weapon', slotName:SLOT_NAMES[e.slot]||'装备', + stats:e.stats||{}, bonus:e.bonus||'', desc:e.description||e.desc||'' + }; +} let ALL_ENTRIES = []; // 归一化后的全部条目(来自后端) let currentCat = 'all'; @@ -487,8 +501,8 @@ async function loadCodexData(){ const grid = document.getElementById('grid'); grid.innerHTML = '
正在从服务器加载图鉴数据…
'; try{ - const [res, cre, mon, rac, talents] = await Promise.all([ - API.codexResources(), API.codexCreatures(), API.codexMonsters(), API.codexRaces(), API.codexTalents() + const [res, cre, mon, rac, talents, equips] = await Promise.all([ + API.codexResources(), API.codexCreatures(), API.codexMonsters(), API.codexRaces(), API.codexTalents(), API.equipCatalog() ]); // 同时尝试拉取当前角色天赋状态;未登录或无角色时忽略。 MY_TALENTS = {}; @@ -508,6 +522,7 @@ async function loadCodexData(){ ...(talents||[]).map(normTalent), ...SKILL_BOOK_DATA.map(normSkillBook), ...BOOK_DATA.map(normBook), + ...((equips && equips.ok && Array.isArray(equips.items) ? equips.items : equips) || []).map(normEquip), ]; renderGrid(); }catch(err){ @@ -525,11 +540,12 @@ function renderGrid(){ const hay = [e.name, e.desc, e.effect||'', e.trigger||'', e.skillType||'', e.bookType||'', e.author||'', e.cost||'', (e.products||[]).join(' '), (e.drops||[]).join(' '), - (e.traits||[]).join(' '), e.hab||'', e.layer||''].join(' '); + (e.traits||[]).join(' '), e.hab||'', e.layer||'', + e.slotName||'', e.bonus||''].join(' '); if(!hay.toLowerCase().includes(q)) return false; } if(currentQual!=='all'){ - if(e.type!=='resource' && e.type!=='talent' && e.type!=='skill_book') return false; + if(e.type!=='resource' && e.type!=='talent' && e.type!=='skill_book' && e.type!=='equip') return false; if(e.qual!==currentQual) return false; } return true; @@ -611,6 +627,17 @@ function renderGrid(){ Lv ${e.learnLevel||1}
`; + } else if(e.type==='equip'){ + div.classList.add('q-'+e.qual, 'equip'); + div.innerHTML = ` + ${QUALITY_NAMES[e.qual]} +
${iconHTML(e.icon)}
+
${e.name}
+
+ ${e.slotName||'装备'} + ${e.bonus||''} +
+ `; } else if(e.type==='book'){ div.classList.add('book'); div.innerHTML = ` @@ -662,11 +689,11 @@ function showDetail(e){ html += `
`; html += `
${iconHTML(e.icon)}
`; html += `
${e.name}
`; - if(e.type==='resource' || e.type==='talent' || e.type==='skill_book'){ + if(e.type==='resource' || e.type==='talent' || e.type==='skill_book' || e.type==='equip'){ 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'; const t = e.qual==='common'?'#9ca3af':e.qual==='uncommon'?'#4ade80':e.qual==='rare'?'#60a5fa':e.qual==='epic'?'#c084fc':e.qual==='legendary'?'#fbbf24':'#f87171'; html += `${QUALITY_NAMES[e.qual]}`; - const catLabel = e.type==='talent'?'🌟 天赋':e.type==='skill_book'?'📖 技能书':e.cat==='material'?'材料':e.cat==='processed'?'加工品':'特殊'; + const catLabel = e.type==='talent'?'🌟 天赋':e.type==='skill_book'?'📖 技能书':e.type==='equip'?'🛡️ 装备':e.cat==='material'?'材料':e.cat==='processed'?'加工品':'特殊'; html += `${catLabel}`; } else if(e.type==='creature'){ html += `🐾 生物${e.hab||''}${e.diet||''}${e.tame?'✅ 可驯养':'❌ 野性'}`; @@ -680,8 +707,8 @@ function showDetail(e){ } html += `
`; - // 品质等级条(资源 / 天赋 / 技能书) - if(e.type==='resource' || e.type==='talent' || e.type==='skill_book'){ + // 品质等级条(资源 / 天赋 / 技能书 / 装备) + if(e.type==='resource' || e.type==='talent' || e.type==='skill_book' || e.type==='equip'){ html += `
`; QUALITIES.forEach((q,i)=>{ html += `
`; }); html += `
`; @@ -779,6 +806,20 @@ function showDetail(e){ } } + // 装备详情 + if(e.type==='equip'){ + html += `

📋 属性

`; + html += `
部位${e.slotName||'—'}
`; + html += `
品质${QUALITY_NAMES[e.qual]||'—'}
`; + if(e.bonus){ + html += `
加成${e.bonus}
`; + } + html += `
`; + if(e.desc){ + html += `

📖 描述

${e.desc}
`; + } + } + // 书籍详情 if(e.type==='book'){ html += `

📋 属性

`; diff --git a/world.html b/world.html index 48d7ba6..45f9bd6 100644 --- a/world.html +++ b/world.html @@ -942,6 +942,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa border: 1px solid rgba(139,92,246,.35); border-radius: 14px; box-shadow: 0 14px 44px rgba(0,0,0,.55), 0 0 0 1px rgba(139,92,246,.12), inset 0 1px 0 rgba(255,255,255,.06); padding: 16px 18px; max-width: 480px; width: 94%; pointer-events: auto; + max-height: 90vh; overflow-y: auto; } /* 头部:标题 + 右上角关闭(MMO 风格) */ #unitModal .umHead { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; @@ -953,7 +954,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa #unitModal .umClose:hover { background:rgba(255,80,80,.28); color:#fff; } /* 主体两栏:左侧立绘+环绕装备槽,右侧数值 */ #unitModal .umGrid { display:grid; grid-template-columns: 200px 1fr; gap:14px; } -#unitModal .umLeft { position:relative; border-radius:12px; padding:14px 10px 10px; +#unitModal .umLeft { position:relative; border-radius:12px; padding:14px 10px 90px; display:flex; flex-direction:column; align-items:center; background: radial-gradient(ellipse at 50% 26%, rgba(139,92,246,.14), rgba(255,255,255,.03) 62%); border: 1px solid rgba(139,92,246,.28); @@ -964,16 +965,35 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa #unitModal .umPortraitFallback { font-size:72px; line-height:180px; text-align:center; } #unitModal .umEq { position:absolute; width:46px; height:46px; border-radius:9px; background:rgba(8,14,26,.78); border:1px solid rgba(120,200,255,.3); - display:flex; flex-direction:column; align-items:center; justify-content:center; + display:flex; align-items:center; justify-content:center; cursor:pointer; z-index:2; transition:border-color .15s, background .15s, box-shadow .15s; } #unitModal .umEq:hover { border-color:rgba(139,92,246,.75); background:rgba(139,92,246,.2); box-shadow:0 0 10px rgba(139,92,246,.35); } -#unitModal .umEq .umEqIcon { font-size:19px; line-height:1; } -#unitModal .umEq .umEqName { font-size:9px; color:#8899aa; margin-top:2px; max-width:44px; - overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +#unitModal .umEq .umEqIcon { font-size:19px; line-height:1; display:flex; align-items:center; justify-content:center; } +#unitModal .umEq .umEqIcon img { width:28px; height:28px; object-fit:contain; filter:drop-shadow(0 1px 1px rgba(0,0,0,.5)); } +/* 装备名称统一放在框正下方 */ +#unitModal .umEq .umEqName { + position:absolute; top:100%; left:50%; transform:translateX(-50%); + margin-top:3px; font-size:9px; color:#a8b8cc; white-space:nowrap; + text-shadow:0 1px 2px rgba(0,0,0,.7); pointer-events:none; +} #unitModal .umEq.accessory { top:-10px; left:50%; transform:translateX(-50%); } #unitModal .umEq.weapon { left:-12px; top:50%; transform:translateY(-50%); } #unitModal .umEq.armor { right:-12px; top:50%; transform:translateY(-50%); } -#unitModal .umEquipPicker { margin-top:10px; width:100%; } +#unitModal .umEq.boots { bottom:-24px; left:50%; transform:translateX(-50%); } +#unitModal .umEquipPicker { + margin-top:92px; width:100%; + max-height: 220px; + overflow-y: auto; + background: rgba(8,14,26,0.96); + border: 1px solid rgba(139,92,246,0.32); + border-radius: 10px; + padding: 7px 5px 7px 7px; + box-shadow: 0 8px 24px rgba(0,0,0,0.35); +} +/* 未展开时隐藏空容器,避免在鞋子槽下方留下黑色长条 */ +#unitModal .umEquipPicker[data-slot=""] { display:none; } +#unitModal .umEquipPicker::-webkit-scrollbar { width: 4px; } +#unitModal .umEquipPicker::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.45); border-radius: 3px; } #unitModal .umRight { min-width:0; } #unitModal .umStatGrid { display:grid; grid-template-columns:1fr 1fr; gap:2px 14px; margin-bottom:8px; } #unitModal .umStatGrid .statBar { display:none; } @@ -993,10 +1013,34 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa #unitModal .cmdGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } #unitModal .cmdGrid .cmdBtn { margin-top: 0; } /* 装备候选列表(立绘旁装备槽点击后展开) */ -#unitModal .equipSlotName { font-size: 11px; color: #8899aa; margin-bottom: 5px; } -#unitModal .equipOpt { font-size: 12px; color: #cfe; padding: 5px 6px; margin-top: 4px; background: rgba(100,200,255,0.1); border-radius: 6px; cursor: pointer; } -#unitModal .equipOpt:hover { background: rgba(100,200,255,0.22); } -#unitModal .equipBonus { color: #8bc34a; font-size: 10px; margin-left: 3px; } +#unitModal .equipSlotName { + font-size: 11px; color: #a8b8cc; + margin-bottom: 6px; + display: flex; align-items: center; justify-content: space-between; +} +#unitModal .equipSlotName .equipClose { + font-size: 15px; line-height: 1; color: #8899aa; cursor: pointer; + padding: 0 3px; border-radius: 3px; transition: color .15s, background .15s; +} +#unitModal .equipSlotName .equipClose:hover { color: #fff; background: rgba(255,80,80,0.25); } +#unitModal .equipOpt { + display: flex; align-items: center; gap: 7px; + font-size: 12px; color: #cfe; + padding: 5px 6px; margin-top: 3px; + background: rgba(100,200,255,0.08); border-radius: 6px; cursor: pointer; + line-height: 1.2; +} +#unitModal .equipOpt:hover { background: rgba(100,200,255,0.2); } +#unitModal .equipOpt.unequip { color: #ff9a9a; background: rgba(255,80,80,.1); } +#unitModal .equipOpt.unequip:hover { background: rgba(255,80,80,.2); } +#unitModal .equipOpt .umEqIcon { + font-size: 16px; display: flex; align-items: center; justify-content: center; + flex-shrink: 0; width: 22px; height: 22px; +} +#unitModal .equipOpt .umEqIcon img { width: 22px; height: 22px; object-fit: contain; filter: drop-shadow(0 1px 1px rgba(0,0,0,.5)); } +#unitModal .equipOpt .equipMeta { display: flex; flex-direction: column; min-width: 0; } +#unitModal .equipOpt .equipName { font-size: 12px; color: #cfe; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +#unitModal .equipOpt .equipBonus { color: #8bc34a; font-size: 10px; margin-left: 0; } /* 微观建筑悬停提示 tooltip */ #microTip { @@ -2597,6 +2641,11 @@ const EQUIP_CATALOG_FALLBACK = { { code: 'power_ring', name: '力量戒指', icon: '💍', bonus: '攻+4 防+2' }, { code: 'amulet_life', name: '生命护符', icon: '📿', bonus: '血+25 能+15' }, ], + boots: [ + { code: 'straw_sandals', name: '草鞋', icon: '🩴', bonus: '速+1' }, + { code: 'leather_boots', name: '皮靴', icon: '🥾', bonus: '速+3 防+1' }, + { code: 'wind_boots', name: '疾风靴', icon: '👟', bonus: '速+6 能+10' }, + ], }; let EQUIP_CATALOG_SERVER = null; @@ -2652,7 +2701,7 @@ async function microLoadEquipCatalog() { try { const r = await API.equipCatalog(); if (r && r.ok && Array.isArray(r.items)) { - const bySlot = { weapon: [], armor: [], accessory: [] }; + const bySlot = { weapon: [], armor: [], accessory: [], boots: [] }; r.items.forEach(it => { if (!bySlot[it.slot]) bySlot[it.slot] = []; bySlot[it.slot].push(it); @@ -2672,7 +2721,7 @@ async function microLoadGame() { const [c, cr] = await Promise.all([API.char(), API.creatures(), microLoadEquipCatalog()]); microChar = (c && c.ok !== false) ? c : (c || null); if (microChar) { - if (!microChar.equip) microChar.equip = { weapon: null, armor: null, accessory: null }; + if (!microChar.equip) microChar.equip = { weapon: null, armor: null, accessory: null, boots: null }; if (!microChar.inventory) microChar.inventory = {}; ['gold','copper','silver','diamond'].forEach(k => { if (microChar[k] == null) microChar[k] = 0; }); } @@ -2711,7 +2760,7 @@ function microMakeUnitFromCreature(x) { energy: (x.energy != null) ? x.energy : 80, energy_max: x.energy_max || 100, attack: x.attack || 5, defense: x.defense || 3, speed: x.speed || 90, mood: x.mood || '😊', job: x.job || 'none', - equip: x.equip || { weapon: null, armor: null, accessory: null }, + equip: x.equip || { weapon: null, armor: null, accessory: null, boots: null }, status: x.state || x.status || 'resting', _buildingRender: true }; } @@ -2785,8 +2834,8 @@ function microEnsureCharPlaced(unit) { // equip 的值可能是装备对象(后端 to_panel_dict 解析后的结果),回存时统一压缩为 code function _normalizeEquip(eq) { - const out = { weapon: null, armor: null, accessory: null }; - ['weapon', 'armor', 'accessory'].forEach(s => { + const out = { weapon: null, armor: null, accessory: null, boots: null }; + ['weapon', 'armor', 'accessory', 'boots'].forEach(s => { const v = eq && eq[s]; out[s] = (v && typeof v === 'object') ? (v.code || null) : (v || null); }); @@ -2805,12 +2854,15 @@ async function microSaveCreatures() { const list = microState.units .filter(u => u._buildingRender) .map(u => ({ - cid: u.id, name: u.name, species: u.role || 'none', - level: u.level, exp: u.exp, hp: u.hp, hp_max: u.hp_max || 120, - energy: u.energy, mood: u.mood, job: u.job || 'none', - equip: _normalizeEquip(u.equip), + cid: u.id, name: u.name, race: u.role || u.species || 'human', + level: u.level, exp: u.exp, exp_max: u.exp_max || 100, + hp: u.hp, hp_max: u.hp_max || 120, + energy: u.energy, energy_max: u.energy_max || 100, + attack: u.attack, defense: u.defense, speed: u.speed, + mood: u.mood, job: u.job || 'none', tile_ref: microState.tile ? { q: microState.tile.q, r: microState.tile.r } : {}, - state: u.status || 'resting', + status: u.status || 'idle', + state: u.state || u.status || 'idle', })); const res = await API.saveCreatures(list); if (res && res.ok && Array.isArray(res.creatures)) { @@ -5563,9 +5615,31 @@ function microSyncCharLocal(char) { 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; + if (idx >= 0) { Object.assign(microState.units[idx], synced); } + else { microState.units.push(synced); } + + // 同步 characters.js 的 microChars:装备/训练后后端返回的是有效属性, + // 必须让 microChars 也持有最新值,否则 characters.js 的自动保存会把旧值 + //(不含新装备加成)当基础值回写,导致再次打开面板时加成"消失"。 + if (typeof window !== 'undefined' && window.microChars) { + const cidx = window.microChars.findIndex(c => c.id === key || c.cid === key); + if (cidx >= 0) { + const ch = window.microChars[cidx]; + if (typeof char.attack === 'number') ch.attack = char.attack; + if (typeof char.defense === 'number') ch.defense = char.defense; + if (typeof char.speed === 'number') ch.speed = char.speed; + if (typeof char.hp === 'number') ch.hp = char.hp; + if (typeof char.hp_max === 'number') ch.hpMax = char.hp_max; + if (typeof char.energy === 'number') ch.energy = char.energy; + if (typeof char.energy_max === 'number') ch.energy_max = char.energy_max; + if (typeof char.level === 'number') ch.level = char.level; + if (typeof char.exp === 'number') ch.exp = char.exp; + if (typeof char.exp_max === 'number') ch.exp_max = char.exp_max; + if (char.equip) ch.equip = char.equip; + } + } + + return idx >= 0 ? microState.units[idx] : synced; } function _localCharFromMicroChars(unitId) { // 兼容 characters.js 精灵图角色:从 microChars 取本地运行时数据转成本地面板对象 @@ -5648,7 +5722,7 @@ async function showUnitModal(unitId) { const eqCell = (slot, label) => { const cur = unit.equip && unit.equip[slot]; return `
-
${cur ? (cur.icon || '⚔️') : '➕'}
+
${cur ? eqIconHTML(cur.icon) : '➕'}
${cur ? cur.name : label}
`; }; @@ -5663,6 +5737,7 @@ async function showUnitModal(unitId) { ${eqCell('weapon','武器')}
${portrait}
${eqCell('armor','护甲')} + ${eqCell('boots','鞋子')}
@@ -5719,6 +5794,15 @@ function _drawRacePortrait(src, cv) { img.src = src; } +// 装备图标渲染:图片路径→,否则 emoji +function eqIconHTML(ic){ + if(!ic) return '➕'; + if(/^(assets\/|\/|https?:\/\/)/i.test(ic) || /\.(png|webp|jpe?g|gif|svg)(\?.*)?$/i.test(ic)){ + return ``; + } + return ic || '⚔️'; +} + // 点击立绘旁装备槽:展开/收起该槽的候选装备列表(穿戴写 Odoo,与原 equipWrap 同源) window.umTogglePicker = function(slot){ const box = document.getElementById('umPicker'); @@ -5726,17 +5810,17 @@ window.umTogglePicker = function(slot){ if (!box || !unit) return; if (box.dataset.slot === slot) { box.innerHTML = ''; box.dataset.slot = ''; return; } box.dataset.slot = slot; - const map = { weapon: '武器', armor: '护甲', accessory: '饰品' }; + const map = { weapon: '武器', armor: '护甲', accessory: '饰品', boots: '鞋子' }; const cur = unit.equip && unit.equip[slot]; const cid = unit.cid || unit.id; - let html = `
${map[slot]} · 点击穿戴
`; + let html = `
${map[slot]} · 点击穿戴×
`; if (unit._localOnly) { html += `
等待服务器同步…
`; } else { - if (cur) html += `
✕ 卸下 ${cur.name}
`; + if (cur) html += `
✕卸下 ${cur.name}
`; const catalog = microEquipCatalog(); (catalog[slot] || []).forEach(e => { - html += `
${e.icon || '⚔️'} ${e.name}${e.bonus || ''}
`; + html += `
${eqIconHTML(e.icon)}${e.name}${e.bonus || ''}
`; }); } box.innerHTML = html; @@ -5790,7 +5874,7 @@ async function updateChar() { }); if (r && r.ok && r.char) { microChar = r.char; - if (!microChar.equip) microChar.equip = { weapon: null, armor: null, accessory: null }; + if (!microChar.equip) microChar.equip = { weapon: null, armor: null, accessory: null, boots: null }; if (!microChar.inventory) microChar.inventory = {}; ['gold','copper','silver','diamond'].forEach(k => { if (microChar[k] == null) microChar[k] = 0; }); }