Merge pull request 'verify PR creation' (#33) from why_ga into main

Reviewed-on: #33
This commit was merged in pull request #33.
This commit is contained in:
wanghaoyang
2026-09-10 14:42:46 +08:00
16 changed files with 367 additions and 79 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
'name': '宇森世界 - 沙盘与微观世界',
'version': '0.7',
'version': '0.8',
'summary': '地表存储(沙盘记忆) + 微观世界(1:1预留) + 资源(框架)',
'description': '每个玩家拥有一个确定性生成的世界;仅持久化玩家改过的地表地块(种子+增量覆盖)。微观世界独立为 1:1 模型仅预留,资源模型为框架。',
'category': 'Game',
+35 -10
View File
@@ -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,
+35 -4
View File
@@ -6,7 +6,7 @@
<record id="equip_iron_dagger" model="yt.world.equip.item">
<field name="code">iron_dagger</field>
<field name="name">铁匕首</field>
<field name="icon">🗡️</field>
<field name="icon">assets/icons/equips/iron_dagger.png</field>
<field name="slot">weapon</field>
<field name="rarity">common</field>
<field name="stats">{"attack": 3}</field>
@@ -16,7 +16,7 @@
<record id="equip_steel_sword" model="yt.world.equip.item">
<field name="code">steel_sword</field>
<field name="name">精钢短剑</field>
<field name="icon">⚔️</field>
<field name="icon">assets/icons/equips/steel_sword.png</field>
<field name="slot">weapon</field>
<field name="rarity">rare</field>
<field name="stats">{"attack": 7, "speed": 2}</field>
@@ -26,7 +26,7 @@
<record id="equip_rune_blade" model="yt.world.equip.item">
<field name="code">rune_blade</field>
<field name="name">符文长剑</field>
<field name="icon">🗡️</field>
<field name="icon">assets/icons/equips/rune_blade.png</field>
<field name="slot">weapon</field>
<field name="rarity">epic</field>
<field name="stats">{"attack": 12, "hp_max": 15}</field>
@@ -37,7 +37,7 @@
<record id="equip_leather_vest" model="yt.world.equip.item">
<field name="code">leather_vest</field>
<field name="name">皮背心</field>
<field name="icon">🦺</field>
<field name="icon">assets/icons/equips/leather_vest.png</field>
<field name="slot">armor</field>
<field name="rarity">common</field>
<field name="stats">{"defense": 2, "hp_max": 5}</field>
@@ -64,6 +64,37 @@
<field name="desc">传说用古龙鳞片打造,沉重但几乎刀枪不入。</field>
</record>
<!-- 鞋子 -->
<record id="equip_straw_sandals" model="yt.world.equip.item">
<field name="code">straw_sandals</field>
<field name="name">草鞋</field>
<field name="icon">🩴</field>
<field name="slot">boots</field>
<field name="rarity">common</field>
<field name="stats">{"speed": 1}</field>
<field name="desc">轻便的草编鞋,适合远行。</field>
</record>
<record id="equip_leather_boots" model="yt.world.equip.item">
<field name="code">leather_boots</field>
<field name="name">皮靴</field>
<field name="icon">🥾</field>
<field name="slot">boots</field>
<field name="rarity">rare</field>
<field name="stats">{"speed": 3, "defense": 1}</field>
<field name="desc">结实的皮靴,能保护双脚并提升移动速度。</field>
</record>
<record id="equip_wind_boots" model="yt.world.equip.item">
<field name="code">wind_boots</field>
<field name="name">疾风靴</field>
<field name="icon">👟</field>
<field name="slot">boots</field>
<field name="rarity">epic</field>
<field name="stats">{"speed": 6, "energy_max": 10}</field>
<field name="desc">刻有风之符文的靴子,穿戴者仿佛能踏风而行。</field>
</record>
<!-- 饰品 -->
<record id="equip_lucky_charm" model="yt.world.equip.item">
<field name="code">lucky_charm</field>
+1
View File
@@ -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
@@ -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,
}
@@ -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', '传说')],
+44 -15
View File
@@ -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,
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
40 access_yt_world_equip_item_user yt.world.equip.item user model_yt_world_equip_item base.group_user 1 1 1 1
41 access_yt_world_equip_item_public yt.world.equip.item public model_yt_world_equip_item base.group_public 1 0 0 0
42 access_yt_world_equip_item_portal yt.world.equip.item portal model_yt_world_equip_item base.group_portal 1 1 1 1
43 access_yt_world_equip_instance_user yt.world.equip.instance user model_yt_world_equip_instance base.group_user 1 1 1 1
44 access_yt_world_equip_instance_public yt.world.equip.instance public model_yt_world_equip_instance base.group_public 1 0 0 0
45 access_yt_world_equip_instance_portal yt.world.equip.instance portal model_yt_world_equip_instance base.group_portal 1 1 1 1
46 access_yt_world_race_user yt.world.race user model_yt_world_race base.group_user 1 1 1 1
47 access_yt_world_race_public yt.world.race public model_yt_world_race base.group_public 1 1 1 1
48 access_yt_world_race_portal yt.world.race portal model_yt_world_race base.group_portal 1 1 1 1
+13 -4
View File
@@ -364,19 +364,28 @@
<field name="job"/>
<field name="state"/>
</group>
<group string="装备 / 所在地块 / 关联">
<field name="equip_display" colspan="4" nolabel="1"
widget="textarea" readonly="1" class="o_readonly_text_block"/>
<group string="所在地块 / 关联">
<field name="tile_ref_display" colspan="4" nolabel="1"
widget="textarea" readonly="1" class="o_readonly_text_block"/>
<field name="partner_display" colspan="4" nolabel="1"
widget="textarea" readonly="1" class="o_readonly_text_block"/>
</group>
<group>
<field name="equip" widget="json" colspan="2"/>
<field name="tile_ref" widget="json" colspan="2"/>
<field name="partner" widget="json" colspan="2"/>
</group>
<group string="装备实例(与生物关联)">
<field name="equip_instance_ids" nolabel="1">
<list>
<field name="item_id"/>
<field name="equipped_slot"/>
<field name="durability"/>
<field name="max_durability"/>
<field name="enhance_level"/>
<field name="equipped"/>
</list>
</field>
</group>
</sheet>
</form>
</field>
+11 -6
View File
@@ -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}
<div style="margin-top:6px;color:#8a9aaa;font-size:11px;">👟 移速 <b style="color:#e8eef7;">${stats.speed}</b></div>
<div style="margin-top:5px;color:#8a9aaa;font-size:11px;">⚔ 攻击 <b style="color:#e8eef7;">${fmt(curAtk)}</b>${atkBonus}</div>
<div style="margin-top:5px;color:#8a9aaa;font-size:11px;">🛡 防御 <b style="color:#e8eef7;">${fmt(ch.defense)}</b></div>
<div style="margin-top:8px;padding:6px 8px;background:#16213a;border-radius:5px;color:#9fb2cc;font-size:11px;line-height:1.5;">📖 ${rc.trait || '暂无介绍'}</div>
<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;">${btns}</div>
<div style="margin-top:8px;">
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+50 -9
View File
@@ -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{
<span class="rule-tab" data-cat="talent" onclick="setCat('talent')">🌟 天赋</span>
<span class="rule-tab" data-cat="skill_book" onclick="setCat('skill_book')">📖 技能书</span>
<span class="rule-tab" data-cat="book" onclick="setCat('book')">📚 书籍</span>
<span class="rule-tab" data-cat="equip" onclick="setCat('equip')">🛡️ 装备</span>
<span class="rule-tab" data-cat="special" onclick="setCat('special')">✨ 特殊</span>
</div>
<div class="filterRow">
@@ -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 = '<div class="empty show">正在从服务器加载图鉴数据…</div>';
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(){
<span class="tag">Lv ${e.learnLevel||1}</span>
</div>
`;
} else if(e.type==='equip'){
div.classList.add('q-'+e.qual, 'equip');
div.innerHTML = `
<span class="qbadge ${e.qual}">${QUALITY_NAMES[e.qual]}</span>
<div class="icon">${iconHTML(e.icon)}</div>
<div class="name">${e.name}</div>
<div class="tags">
<span class="tag">${e.slotName||'装备'}</span>
<span class="tag">${e.bonus||''}</span>
</div>
`;
} else if(e.type==='book'){
div.classList.add('book');
div.innerHTML = `
@@ -662,11 +689,11 @@ function showDetail(e){
html += `<div class="md-header">`;
html += `<div class="md-icon">${iconHTML(e.icon)}</div>`;
html += `<div class="md-info"><div class="md-title">${e.name}</div><div class="md-meta">`;
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 += `<span class="mt" style="background:rgba(${c},.15);color:${t}">${QUALITY_NAMES[e.qual]}</span>`;
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 += `<span class="mt">${catLabel}</span>`;
} else if(e.type==='creature'){
html += `<span class="mt">🐾 生物</span><span class="mt">${e.hab||''}</span><span class="mt">${e.diet||''}</span><span class="mt">${e.tame?'✅ 可驯养':'❌ 野性'}</span>`;
@@ -680,8 +707,8 @@ function showDetail(e){
}
html += `</div></div></div>`;
// 品质等级条(资源 / 天赋 / 技能书)
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 += `<div class="qualityBar">`;
QUALITIES.forEach((q,i)=>{ html += `<div class="qlv ${i<=qi?'on':''}" title="${QUALITY_NAMES[q]}"></div>`; });
html += `</div>`;
@@ -779,6 +806,20 @@ function showDetail(e){
}
}
// 装备详情
if(e.type==='equip'){
html += `<div class="md-section"><h3>📋 属性</h3><div class="md-grid">`;
html += `<div class="gi"><span class="gl">部位</span><span class="gv">${e.slotName||'—'}</span></div>`;
html += `<div class="gi"><span class="gl">品质</span><span class="gv">${QUALITY_NAMES[e.qual]||'—'}</span></div>`;
if(e.bonus){
html += `<div class="gi" style="grid-column:1/-1;"><span class="gl">加成</span><span class="gv">${e.bonus}</span></div>`;
}
html += `</div></div>`;
if(e.desc){
html += `<div class="md-section"><h3>📖 描述</h3><div class="md-desc">${e.desc}</div></div>`;
}
}
// 书籍详情
if(e.type==='book'){
html += `<div class="md-section"><h3>📋 属性</h3><div class="md-grid">`;
+113 -29
View File
@@ -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 `<div class="umEq ${slot}" onclick="umTogglePicker('${slot}')" title="${label}${cur ? ':' + cur.name : '(空,点击穿戴)'}">
<div class="umEqIcon">${cur ? (cur.icon || '⚔️') : '➕'}</div>
<div class="umEqIcon">${cur ? eqIconHTML(cur.icon) : '➕'}</div>
<div class="umEqName">${cur ? cur.name : label}</div>
</div>`;
};
@@ -5663,6 +5737,7 @@ async function showUnitModal(unitId) {
${eqCell('weapon','武器')}
<div class="umPortrait">${portrait}</div>
${eqCell('armor','护甲')}
${eqCell('boots','鞋子')}
</div>
<div id="umPicker" class="umEquipPicker" data-slot=""></div>
</div>
@@ -5719,6 +5794,15 @@ function _drawRacePortrait(src, cv) {
img.src = src;
}
// 装备图标渲染:图片路径→<img>,否则 emoji
function eqIconHTML(ic){
if(!ic) return '➕';
if(/^(assets\/|\/|https?:\/\/)/i.test(ic) || /\.(png|webp|jpe?g|gif|svg)(\?.*)?$/i.test(ic)){
return `<img src="${ic}" alt="">`;
}
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 = `<div class="equipSlotName">${map[slot]} · 点击穿戴</div>`;
let html = `<div class="equipSlotName"><span>${map[slot]} · 点击穿戴</span><span class="equipClose" onclick="umTogglePicker('${slot}');event.stopPropagation();">×</span></div>`;
if (unit._localOnly) {
html += `<div style="color:#888;font-size:12px;padding:4px 0;">等待服务器同步…</div>`;
} else {
if (cur) html += `<div class="equipOpt" onclick="microEquip('${cid}','${slot}',null,true)" style="color:#ff9a9a;background:rgba(255,80,80,.12);">✕ 卸下 ${cur.name}</div>`;
if (cur) html += `<div class="equipOpt unequip" onclick="microEquip('${cid}','${slot}',null,true)"><span class="umEqIcon">✕</span><span class="equipMeta"><span class="equipName">卸下 ${cur.name}</span></span></div>`;
const catalog = microEquipCatalog();
(catalog[slot] || []).forEach(e => {
html += `<div class="equipOpt" onclick="microEquip('${cid}','${slot}','${e.code}',false)">${e.icon || '⚔️'} ${e.name}<span class="equipBonus">${e.bonus || ''}</span></div>`;
html += `<div class="equipOpt" onclick="microEquip('${cid}','${slot}','${e.code}',false)"><span class="umEqIcon">${eqIconHTML(e.icon)}</span><span class="equipMeta"><span class="equipName">${e.name}</span><span class="equipBonus">${e.bonus || ''}</span></span></div>`;
});
}
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; });
}