diff --git a/addons/yt_world/controllers/world_api.py b/addons/yt_world/controllers/world_api.py
index 7f22b77..495c5f3 100644
--- a/addons/yt_world/controllers/world_api.py
+++ b/addons/yt_world/controllers/world_api.py
@@ -1049,9 +1049,12 @@ class YtWorldApi(http.Controller):
@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]})
+ """装备目录(单一事实来源)。同时返回 slots 槽位真源列表,
+ 前端面板据此动态渲染(含尚无装备的空槽位)。"""
+ Item = request.env['yt.world.equip.item'].sudo()
+ items = Item.search([('active', '=', True)])
+ slots = [{'value': v, 'label': l} for v, l in Item._fields['slot'].selection]
+ return _json({'ok': True, 'items': [it.to_dict() for it in items], 'slots': slots})
def _creature_by_cid(self, world, cid):
if not cid:
@@ -1093,7 +1096,8 @@ class YtWorldApi(http.Controller):
if action == 'equip':
slot = body.get('slot')
code = body.get('code')
- if slot not in ('weapon', 'armor', 'accessory', 'boots'):
+ valid_slots = [s[0] for s in request.env['yt.world.equip.item']._fields['slot'].selection]
+ if slot not in valid_slots:
return _json({'ok': False, 'error': 'bad_slot'})
item = request.env['yt.world.equip.item'].sudo().search([('code', '=', code)], limit=1)
if not item:
diff --git a/addons/yt_world/data/yt_world_equip_item.xml b/addons/yt_world/data/yt_world_equip_item.xml
index 30afe69..ecd7e49 100644
--- a/addons/yt_world/data/yt_world_equip_item.xml
+++ b/addons/yt_world/data/yt_world_equip_item.xml
@@ -125,4 +125,121 @@
{"hp_max": 25, "energy_max": 15}
蕴含生命能量的护符,佩戴者精力更充沛。
+
+
+
+ straw_hat
+ 草帽
+ assets/icons/equips/straw_hat.png
+ hat
+ common
+ {"defense": 1}
+ 编织草帽,遮阳挡雨,略增防护。
+
+
+ iron_helm
+ 铁盔
+ assets/icons/equips/iron_helm.png
+ hat
+ rare
+ {"defense": 3, "hp_max": 8}
+ 冷锻铁盔,关键时刻护住头脸。
+
+
+ gold_crown
+ 黄金王冠
+ assets/icons/equips/gold_crown.png
+ hat
+ legendary
+ {"defense": 5, "hp_max": 20, "attack": 5}
+ 象征权柄的黄金王冠,威仪凛然。
+
+
+
+
+ cloth_pants
+ 布裤
+ assets/icons/equips/cloth_pants.png
+ pants
+ common
+ {"defense": 1}
+ 轻便布裤,行动无碍。
+
+
+ iron_leggings
+ 铁护腿
+ assets/icons/equips/iron_leggings.png
+ pants
+ rare
+ {"defense": 3, "hp_max": 10}
+ 裹住小腿的铁片护腿,稳健可靠。
+
+
+ dragon_pants
+ 龙鳞战裤
+ assets/icons/equips/dragon_pants.png
+ pants
+ legendary
+ {"defense": 6, "hp_max": 25}
+ 龙鳞缀成的战裤,刀枪难入。
+
+
+
+
+ leather_bracer
+ 皮护腕
+ assets/icons/equips/leather_bracer.png
+ bracelet
+ common
+ {"attack": 2}
+ 皮质护腕,挥击更稳。
+
+
+
+ silver_bracer
+ 银手镯
+ assets/icons/equips/silver_bracer.png
+ accessory
+ rare
+ {"attack": 4, "defense": 1}
+ 嵌银手镯,攻防兼修。
+
+
+ dragon_bracelet
+ 龙纹手镯
+ assets/icons/equips/dragon_bracelet.png
+ accessory
+ epic
+ {"attack": 8, "hp_max": 10}
+ 刻龙纹的护腕,蓄力更猛。
+
+
+
+
+ rope_belt
+ 麻绳腰带
+ assets/icons/equips/rope_belt.png
+ belt
+ common
+ {"hp_max": 5}
+ 粗麻绳束腰,勉强管用。
+
+
+ iron_belt
+ 铁腰封
+ assets/icons/equips/iron_belt.png
+ belt
+ rare
+ {"hp_max": 12, "defense": 2}
+ 铁片腰封,护住躯干。
+
+
+ dragon_belt
+ 龙鳞腰带
+ assets/icons/equips/dragon_belt.png
+ belt
+ epic
+ {"hp_max": 25, "defense": 4}
+ 龙鳞束腰,根骨更稳。
+
diff --git a/addons/yt_world/models/yt_world_equip_instance.py b/addons/yt_world/models/yt_world_equip_instance.py
index c41c313..ea7174b 100644
--- a/addons/yt_world/models/yt_world_equip_instance.py
+++ b/addons/yt_world/models/yt_world_equip_instance.py
@@ -12,6 +12,7 @@
生物→世界→账号自动归属到对应玩家。
"""
from odoo import models, fields, api
+from odoo.addons.yt_world.models.yt_world_equip_item import YtWorldEquipItem
class YtWorldEquipInstance(models.Model):
@@ -23,10 +24,10 @@ class YtWorldEquipInstance(models.Model):
required=True, ondelete='cascade', index=True)
owner_creature_id = fields.Many2one('yt.world.creature', string='拥有者生物',
ondelete='cascade', index=True)
- # 已装备的部位;空值(False)表示在背包中未装备
+ # 已装备的部位;空值(False)表示在背包中未装备。
+ # 与 yt.world.equip.item 的 SLOT_SELECTION 共用同一份常量,避免两处各写一份槽位列表。
equipped_slot = fields.Selection(
- [('weapon', '武器'), ('armor', '护甲'), ('accessory', '饰品'), ('boots', '鞋子')],
- string='已装备部位', index=True)
+ YtWorldEquipItem.SLOT_SELECTION, string='已装备部位', index=True)
durability = fields.Integer(string='当前耐久', default=100)
max_durability = fields.Integer(string='最大耐久', default=100)
enhance_level = fields.Integer(string='强化等级', default=0)
diff --git a/addons/yt_world/models/yt_world_equip_item.py b/addons/yt_world/models/yt_world_equip_item.py
index 60fae33..0014b5e 100644
--- a/addons/yt_world/models/yt_world_equip_item.py
+++ b/addons/yt_world/models/yt_world_equip_item.py
@@ -21,9 +21,13 @@ class YtWorldEquipItem(models.Model):
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', '饰品'), ('boots', '鞋子')],
- string='部位', required=True)
+ # 部位槽位:单一真源。前端面板 / 实例表 / catalog 接口均以本选择项为准,
+ # 新增槽位只需在此追加(及 yt.world.equip.instance 的 equipped_slot),前端自动渲染。
+ SLOT_SELECTION = [
+ ('weapon', '武器'), ('armor', '护甲'), ('accessory', '饰品'), ('boots', '鞋子'),
+ ('hat', '帽子'), ('pants', '裤子'), ('bracelet', '护臂'), ('belt', '腰带'),
+ ]
+ slot = fields.Selection(SLOT_SELECTION, string='部位', required=True)
rarity = fields.Selection(
[('common', '普通'), ('rare', '稀有'), ('epic', '史诗'), ('legendary', '传说')],
string='稀有度', default='common')
diff --git a/addons/yt_world/models/yt_world_game.py b/addons/yt_world/models/yt_world_game.py
index ce2e121..b6ee781 100644
--- a/addons/yt_world/models/yt_world_game.py
+++ b/addons/yt_world/models/yt_world_game.py
@@ -298,8 +298,10 @@ class YtWorldCreature(models.Model):
catalog[it.code] = it.to_dict()
# 从装备实例表反查"当前已装备"的装备(实例 -> 模板对象),
# 保持前端 {slot: 对象} 结构不变,前端渲染零改动。
+ # 槽位列表直接取自装备图鉴模型的 SLOT_SELECTION(单一真源),新增槽位无需改此处。
equip_resolved = {}
- for slot in ('weapon', 'armor', 'accessory', 'boots'):
+ equip_slots = [s[0] for s in self.env['yt.world.equip.item']._fields['slot'].selection]
+ for slot in equip_slots:
inst = self.env['yt.world.equip.instance'].sudo().search(
[('owner_creature_id', '=', self.id), ('equipped_slot', '=', slot)], limit=1)
if inst:
diff --git a/assets/icons/equips/cloth_pants.png b/assets/icons/equips/cloth_pants.png
new file mode 100644
index 0000000..eb38bfd
Binary files /dev/null and b/assets/icons/equips/cloth_pants.png differ
diff --git a/assets/icons/equips/dragon_belt.png b/assets/icons/equips/dragon_belt.png
new file mode 100644
index 0000000..d3786dd
Binary files /dev/null and b/assets/icons/equips/dragon_belt.png differ
diff --git a/assets/icons/equips/dragon_bracelet.png b/assets/icons/equips/dragon_bracelet.png
new file mode 100644
index 0000000..a1b8fa1
Binary files /dev/null and b/assets/icons/equips/dragon_bracelet.png differ
diff --git a/assets/icons/equips/dragon_pants.png b/assets/icons/equips/dragon_pants.png
new file mode 100644
index 0000000..b955c0e
Binary files /dev/null and b/assets/icons/equips/dragon_pants.png differ
diff --git a/assets/icons/equips/gen/Hearthstone_game_icon__BIG_bol_2026-09-20T09-07-17.png b/assets/icons/equips/gen/Hearthstone_game_icon__BIG_bol_2026-09-20T09-07-17.png
new file mode 100644
index 0000000..6b76806
Binary files /dev/null and b/assets/icons/equips/gen/Hearthstone_game_icon__BIG_bol_2026-09-20T09-07-17.png differ
diff --git a/assets/icons/equips/gen/Hearthstone_game_icon__BIG_bol_2026-09-20T09-08-41.png b/assets/icons/equips/gen/Hearthstone_game_icon__BIG_bol_2026-09-20T09-08-41.png
new file mode 100644
index 0000000..73669a3
Binary files /dev/null and b/assets/icons/equips/gen/Hearthstone_game_icon__BIG_bol_2026-09-20T09-08-41.png differ
diff --git a/assets/icons/equips/gen/Hearthstone_game_icon__single__2026-09-20T08-55-08.png b/assets/icons/equips/gen/Hearthstone_game_icon__single__2026-09-20T08-55-08.png
new file mode 100644
index 0000000..84faedd
Binary files /dev/null and b/assets/icons/equips/gen/Hearthstone_game_icon__single__2026-09-20T08-55-08.png differ
diff --git a/assets/icons/equips/gen/cloth_pants_review.png b/assets/icons/equips/gen/cloth_pants_review.png
new file mode 100644
index 0000000..eb38bfd
Binary files /dev/null and b/assets/icons/equips/gen/cloth_pants_review.png differ
diff --git a/assets/icons/equips/gen/dragon_belt_review.png b/assets/icons/equips/gen/dragon_belt_review.png
new file mode 100644
index 0000000..d3786dd
Binary files /dev/null and b/assets/icons/equips/gen/dragon_belt_review.png differ
diff --git a/assets/icons/equips/gen/dragon_bracelet_review.png b/assets/icons/equips/gen/dragon_bracelet_review.png
new file mode 100644
index 0000000..a1b8fa1
Binary files /dev/null and b/assets/icons/equips/gen/dragon_bracelet_review.png differ
diff --git a/assets/icons/equips/gen/dragon_pants_review.png b/assets/icons/equips/gen/dragon_pants_review.png
new file mode 100644
index 0000000..b955c0e
Binary files /dev/null and b/assets/icons/equips/gen/dragon_pants_review.png differ
diff --git a/assets/icons/equips/gen/gold_crown_review.png b/assets/icons/equips/gen/gold_crown_review.png
new file mode 100644
index 0000000..3d01430
Binary files /dev/null and b/assets/icons/equips/gen/gold_crown_review.png differ
diff --git a/assets/icons/equips/gen/iron_belt_review.png b/assets/icons/equips/gen/iron_belt_review.png
new file mode 100644
index 0000000..799454e
Binary files /dev/null and b/assets/icons/equips/gen/iron_belt_review.png differ
diff --git a/assets/icons/equips/gen/iron_helm_review.png b/assets/icons/equips/gen/iron_helm_review.png
new file mode 100644
index 0000000..341653c
Binary files /dev/null and b/assets/icons/equips/gen/iron_helm_review.png differ
diff --git a/assets/icons/equips/gen/iron_leggings_review.png b/assets/icons/equips/gen/iron_leggings_review.png
new file mode 100644
index 0000000..a72452c
Binary files /dev/null and b/assets/icons/equips/gen/iron_leggings_review.png differ
diff --git a/assets/icons/equips/gen/leather_bracer_review.png b/assets/icons/equips/gen/leather_bracer_review.png
new file mode 100644
index 0000000..8db877b
Binary files /dev/null and b/assets/icons/equips/gen/leather_bracer_review.png differ
diff --git a/assets/icons/equips/gen/rope_belt_review.png b/assets/icons/equips/gen/rope_belt_review.png
new file mode 100644
index 0000000..ddf4038
Binary files /dev/null and b/assets/icons/equips/gen/rope_belt_review.png differ
diff --git a/assets/icons/equips/gen/silver_bracer_review.png b/assets/icons/equips/gen/silver_bracer_review.png
new file mode 100644
index 0000000..807deb5
Binary files /dev/null and b/assets/icons/equips/gen/silver_bracer_review.png differ
diff --git a/assets/icons/equips/gen/straw_hat_review.png b/assets/icons/equips/gen/straw_hat_review.png
new file mode 100644
index 0000000..c13e4d8
Binary files /dev/null and b/assets/icons/equips/gen/straw_hat_review.png differ
diff --git a/assets/icons/equips/gold_crown.png b/assets/icons/equips/gold_crown.png
new file mode 100644
index 0000000..3d01430
Binary files /dev/null and b/assets/icons/equips/gold_crown.png differ
diff --git a/assets/icons/equips/iron_belt.png b/assets/icons/equips/iron_belt.png
new file mode 100644
index 0000000..799454e
Binary files /dev/null and b/assets/icons/equips/iron_belt.png differ
diff --git a/assets/icons/equips/iron_helm.png b/assets/icons/equips/iron_helm.png
new file mode 100644
index 0000000..341653c
Binary files /dev/null and b/assets/icons/equips/iron_helm.png differ
diff --git a/assets/icons/equips/iron_leggings.png b/assets/icons/equips/iron_leggings.png
new file mode 100644
index 0000000..a72452c
Binary files /dev/null and b/assets/icons/equips/iron_leggings.png differ
diff --git a/assets/icons/equips/leather_bracer.png b/assets/icons/equips/leather_bracer.png
new file mode 100644
index 0000000..8db877b
Binary files /dev/null and b/assets/icons/equips/leather_bracer.png differ
diff --git a/assets/icons/equips/rope_belt.png b/assets/icons/equips/rope_belt.png
new file mode 100644
index 0000000..ddf4038
Binary files /dev/null and b/assets/icons/equips/rope_belt.png differ
diff --git a/assets/icons/equips/silver_bracer.png b/assets/icons/equips/silver_bracer.png
new file mode 100644
index 0000000..807deb5
Binary files /dev/null and b/assets/icons/equips/silver_bracer.png differ
diff --git a/assets/icons/equips/straw_hat.png b/assets/icons/equips/straw_hat.png
new file mode 100644
index 0000000..c13e4d8
Binary files /dev/null and b/assets/icons/equips/straw_hat.png differ
diff --git a/world.html b/world.html
index d5ae29c..8936f85 100644
--- a/world.html
+++ b/world.html
@@ -1094,21 +1094,31 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
display:flex;align-items:center;justify-content:center; transition:background .15s,color .15s; }
#unitModal .umClose:hover { background:rgba(255,80,80,.28); color:#fff; }
/* 主体两栏:左侧立绘+环绕装备槽,右侧数值 */
-#unitModal .umGrid { display:grid; grid-template-columns: 260px 1fr; gap:18px; }
-#unitModal .umLeft { position:relative; border-radius:12px; padding:18px 12px 110px;
+#unitModal .umGrid { display:grid; grid-template-columns: 286px 1fr; gap:18px; }
+#unitModal .umLeft { position:relative; border-radius:12px; padding:14px 10px 14px;
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%);
+ background: radial-gradient(ellipse at 50% 42%, rgba(139,92,246,.14), rgba(255,255,255,.03) 62%);
border: 1px solid rgba(139,92,246,.28);
box-shadow: inset 0 0 28px rgba(139,92,246,.10); }
-#unitModal .umPortraitWrap { position:relative; width:220px; height:250px; margin:10px 0 4px; }
-#unitModal .umPortrait { width:100%; height:100%; display:flex; align-items:flex-end; justify-content:center; }
+/* 环绕装备槽:上=帽子 / 下=鞋子 / 左=护臂·武器·饰品 / 右=护甲·腰带·裤子 */
+#unitModal .umPortraitWrap { position:relative; margin:6px 0 4px; }
+#unitModal .umEqRing { display:flex; flex-direction:column; align-items:center; gap:8px; }
+#unitModal .umEqRingTop, #unitModal .umEqRingBottom { display:flex; justify-content:center; }
+#unitModal .umEqRingMid { display:flex; align-items:center; justify-content:center; gap:10px; }
+#unitModal .umEqCol { display:flex; flex-direction:column; gap:18px; }
+/* 立绘区:与位图等比(132×200 = 0.66),避免 canvas 被 CSS 非等比拉伸致人像挤扁 */
+#unitModal .umPortrait { width:132px; height:200px; display:flex; align-items:flex-end; justify-content:center; }
#unitModal .umPortrait canvas { width:100%; height:100%; display:block; }
-#unitModal .umPortraitFallback { font-size:90px; line-height:250px; text-align:center; }
-#unitModal .umEq { position:absolute; width:54px; height:54px; border-radius:11px;
+#unitModal .umPortraitFallback { font-size:86px; line-height:200px; text-align:center; }
+/* 未落入环绕方位的槽位(后端将来新增)兜底网格,保持在下方,避免丢槽 */
+#unitModal .umEquipGrid { display:grid; grid-template-columns:repeat(4, 56px); gap:20px 14px; justify-content:center; margin-top:10px; }
+#unitModal .umEq { position:relative; width:56px; height:56px; border-radius:11px;
background:rgba(8,14,26,.78); border:1px solid rgba(120,200,255,.3);
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.umEqGhost { border-style:dashed; border-color:rgba(120,200,255,.12); background:rgba(8,14,26,.35); cursor:default; pointer-events:none; }
#unitModal .umEq .umEqIcon { font-size:22px; line-height:1; display:flex; align-items:center; justify-content:center; }
#unitModal .umEq .umEqIcon img { width:34px; height:34px; object-fit:contain; filter:drop-shadow(0 1px 1px rgba(0,0,0,.5)); }
/* 装备名称统一放在框正下方 */
@@ -1117,12 +1127,8 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
margin-top:3px; font-size:10px; color:#a8b8cc; white-space:nowrap;
text-shadow:0 1px 2px rgba(0,0,0,.7); pointer-events:none;
}
-#unitModal .umEq.accessory { top:-12px; left:50%; transform:translateX(-50%); }
-#unitModal .umEq.weapon { left:-16px; top:50%; transform:translateY(-50%); }
-#unitModal .umEq.armor { right:-16px; top:50%; transform:translateY(-50%); }
-#unitModal .umEq.boots { bottom:-28px; left:50%; transform:translateX(-50%); }
#unitModal .umEquipPicker {
- margin-top:120px; width:100%;
+ margin-top:14px; width:100%;
max-height: 260px;
overflow-y: auto;
background: rgba(8,14,26,0.96);
@@ -2803,8 +2809,8 @@ const BUILD_INTERACT = {
] }
};
-// 装备目录:后端 /yt_world/api/micro/equip/catalog 为单一真源;
-// 下面常量仅作极端兜底(未登录/网络不可达时显示)。
+// 装备目录:后端 /yt_world/api/equip/catalog 为单一真源(返回 items + slots 槽位真源列表);
+// 下面常量仅作极端兜底(未登录/网络不可达时显示),槽位与后端 SLOT_SELECTION 保持一致。
const EQUIP_CATALOG_FALLBACK = {
weapon: [
{ code: 'iron_dagger', name: '铁匕首', icon: '🗡️', bonus: '攻+3' },
@@ -2820,14 +2826,54 @@ const EQUIP_CATALOG_FALLBACK = {
{ code: 'lucky_charm', name: '幸运符', icon: '🍀', bonus: '速+3' },
{ code: 'power_ring', name: '力量戒指', icon: '💍', bonus: '攻+4 防+2' },
{ code: 'amulet_life', name: '生命护符', icon: '📿', bonus: '血+25 能+15' },
+ { code: 'silver_bracer', name: '银手镯', icon: '🔗', bonus: '攻+4 防+1' },
+ { code: 'dragon_bracelet', name: '龙纹手镯', icon: '🔗', bonus: '攻+8 血+10' },
],
boots: [
{ code: 'straw_sandals', name: '草鞋', icon: '🩴', bonus: '速+1' },
{ code: 'leather_boots', name: '皮靴', icon: '🥾', bonus: '速+3 防+1' },
{ code: 'wind_boots', name: '疾风靴', icon: '👟', bonus: '速+6 能+10' },
],
+ hat: [
+ { code: 'straw_hat', name: '草帽', icon: '🎩', bonus: '防+1' },
+ { code: 'iron_helm', name: '铁盔', icon: '🎩', bonus: '防+3 血+8' },
+ { code: 'gold_crown', name: '黄金王冠', icon: '👑', bonus: '防+5 血+20 攻+5' },
+ ],
+ pants: [
+ { code: 'cloth_pants', name: '布裤', icon: '👖', bonus: '防+1' },
+ { code: 'iron_leggings', name: '铁护腿', icon: '👖', bonus: '防+3 血+10' },
+ { code: 'dragon_pants', name: '龙鳞战裤', icon: '👖', bonus: '防+6 血+25' },
+ ],
+ bracelet: [
+ { code: 'leather_bracer', name: '皮护腕', icon: '🔗', bonus: '攻+2' },
+ ],
+ belt: [
+ { code: 'rope_belt', name: '麻绳腰带', icon: '🪢', bonus: '血+5' },
+ { code: 'iron_belt', name: '铁腰封', icon: '🪢', bonus: '血+12 防+2' },
+ { code: 'dragon_belt', name: '龙鳞腰带', icon: '🪢', bonus: '血+25 防+4' },
+ ],
+};
+// 环绕方位映射(纯布局信息;槽位数据本身仍以后端 catalog.slots 为真源):
+// 上=帽子 / 下=鞋子 / 左=护臂·武器·饰品 / 右=护甲·腰带·裤子
+const EQUIP_SLOT_POS = {
+ hat: 'top',
+ bracelet: 'left1', weapon: 'left2', accessory: 'left3',
+ armor: 'right1', belt: 'right2', pants: 'right3',
+ boots: 'bottom',
+};
+// 槽位真源:优先取后端 catalog.slots,离线兜底用此映射(与后端 SLOT_SELECTION 对齐)。
+const EQUIP_SLOT_FALLBACK_LABELS = {
+ weapon: '武器', armor: '护甲', accessory: '饰品', boots: '鞋子',
+ hat: '帽子', pants: '裤子', bracelet: '护臂', belt: '腰带',
};
let EQUIP_CATALOG_SERVER = null;
+let EQUIP_SLOTS = null; // [{value, label}] 来自后端 catalog.slots
+// 返回当前可用的槽位列表(部件渲染据此动态生成,不写死)
+function microEquipSlots() {
+ if (EQUIP_SLOTS && EQUIP_SLOTS.length) return EQUIP_SLOTS;
+ const cat = microEquipCatalog();
+ return Object.keys(cat).map(v => ({ value: v, label: EQUIP_SLOT_FALLBACK_LABELS[v] || v }));
+}
// 全部可建造项(道路/地面 + 建筑/设施),作为面板/渲染的单一来源
function microAllBuildItems() { return MICRO_BUILD_ITEMS.concat(MICRO_BUILD_EXTRA); }
@@ -2881,12 +2927,14 @@ async function microLoadEquipCatalog() {
try {
const r = await API.equipCatalog();
if (r && r.ok && Array.isArray(r.items)) {
- const bySlot = { weapon: [], armor: [], accessory: [], boots: [] };
+ const bySlot = {};
+ (r.slots || []).forEach(s => { bySlot[s.value] = []; });
r.items.forEach(it => {
if (!bySlot[it.slot]) bySlot[it.slot] = [];
bySlot[it.slot].push(it);
});
EQUIP_CATALOG_SERVER = bySlot;
+ if (Array.isArray(r.slots) && r.slots.length) EQUIP_SLOTS = r.slots;
microEquipCatalogLoaded = true;
}
} catch (e) { /* 保留兜底 */ }
@@ -2901,7 +2949,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, boots: null };
+ if (!microChar.equip) microChar.equip = {};
if (!microChar.inventory) microChar.inventory = {};
['gold','copper','silver','diamond'].forEach(k => { if (microChar[k] == null) microChar[k] = 0; });
}
@@ -6145,15 +6193,25 @@ async function showUnitModal(unitId) {
const raceCfg = (typeof window !== 'undefined' && window.RACE_CONFIG && window.RACE_CONFIG[unit.race]) || null;
const sprite = raceCfg ? (raceCfg.idle_attack || raceCfg.walk || raceCfg.work_comm) : '';
const portrait = sprite
- ? ``
+ ? ``
: `
${raceIcon}
`;
- const eqCell = (slot, label) => {
- const cur = unit.equip && unit.equip[slot];
- return `
+ // 装备槽:槽位列表来自后端真源(microEquipSlots),环绕方位由 EQUIP_SLOT_POS 决定;
+ // 未落入方位的槽位(后端将来新增)自动进入底部兜底网格 —— 不写死、不丢槽。
+ const slotList = microEquipSlots();
+ const bySlot = {};
+ slotList.forEach(s => { bySlot[s.value] = s; });
+ const eqCell = (value) => {
+ const s = bySlot[value];
+ if (!s) return `
`; // 槽位缺失 → 占位保持环绕对称
+ const cur = (unit.equip && unit.equip[value]) || null;
+ return `
${cur ? eqIconHTML(cur.icon) : '➕'}
-
${cur ? cur.name : label}
+
${cur ? cur.name : s.label}
`;
};
+ const extraSlots = slotList.filter(s => !EQUIP_SLOT_POS[s.value]);
+ const extraGridHTML = extraSlots.length
+ ? `
${extraSlots.map(s => eqCell(s.value)).join('')}
` : '';
const statusColor = unit.status==='working'?'#8bc34a':unit.status==='resting'?'#ff9800':'#4af';
const statusText = unit.status==='working'?'🟢 工作中':unit.status==='resting'?'🟡 休息中':'🔵 空闲中';
document.getElementById('modalBody').innerHTML = `
@@ -6161,12 +6219,17 @@ async function showUnitModal(unitId) {
- ${eqCell('accessory','饰品')}
- ${eqCell('weapon','武器')}
-
${portrait}
- ${eqCell('armor','护甲')}
- ${eqCell('boots','鞋子')}
+
+
${eqCell('hat')}
+
+
${eqCell('bracelet')}${eqCell('weapon')}${eqCell('accessory')}
+
${portrait}
+
${eqCell('armor')}${eqCell('belt')}${eqCell('pants')}
+
+
${eqCell('boots')}
+
+ ${extraGridHTML}
@@ -6185,33 +6248,67 @@ async function showUnitModal(unitId) {
`;
window._umUnit = unit;
- if (sprite) _drawRacePortrait(sprite, document.getElementById('umPortraitCv'));
+ if (sprite) _drawRacePortrait(sprite, document.getElementById('umPortraitCv'), (raceCfg && raceCfg.fw) || 0);
modal.classList.add('active');
}
-// 精灵图第一帧 → 面板立绘 canvas(精确取 48:64 帧比例、居中、脚下光晕底座)
-function _drawRacePortrait(src, cv) {
+// 精灵图第一帧 → 面板立绘 canvas
+// 要点:① 位图尺寸跟随 CSS 显示尺寸(×DPR),杜绝 canvas 被非等比拉伸致人像变形;
+// ② 按第一帧的"不透明像素包围盒"等比放大,人像不被帧内透明留白浪费,填满立绘区;
+// ③ 帧宽优先取种族配置 fw(宽帧表按 48/64 反推会算错)。
+function _drawRacePortrait(src, cv, frameW) {
if (!cv) return;
+ // 位图尺寸 = 实际显示尺寸 × DPR(拿不到布局时退回 CSS 声明值 132×200)
+ let rect = { width: 0, height: 0 };
+ try { rect = cv.getBoundingClientRect(); } catch (e) {}
+ const dpr = 2;
+ const cssW = rect.width > 8 ? rect.width : 132;
+ const cssH = rect.height > 8 ? rect.height : 200;
+ cv.width = Math.round(cssW * dpr);
+ cv.height = Math.round(cssH * dpr);
+
const ctx = cv.getContext('2d');
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const W = cv.width, H = cv.height;
ctx.clearRect(0, 0, W, H);
- // 脚下光晕底座
- const gy = H * 0.88;
- const grd = ctx.createRadialGradient(W / 2, gy, 2, W / 2, gy, W * 0.42);
+ ctx.imageSmoothingEnabled = false;
+ const fh = img.naturalHeight;
+ const fw = frameW || Math.max(1, Math.round(fh * 48 / 64));
+ // 量第一帧的不透明包围盒
+ let sx = 0, sy = 0, sw = fw, sh = fh;
+ try {
+ const off = document.createElement('canvas');
+ off.width = fw; off.height = fh;
+ const octx = off.getContext('2d');
+ octx.drawImage(img, 0, 0, fw, fh, 0, 0, fw, fh);
+ const d = octx.getImageData(0, 0, fw, fh).data;
+ let x0 = fw, y0 = fh, x1 = -1, y1 = -1;
+ for (let y = 0; y < fh; y++) {
+ for (let x = 0; x < fw; x++) {
+ if (d[(y * fw + x) * 4 + 3] > 12) {
+ if (x < x0) x0 = x;
+ if (x > x1) x1 = x;
+ if (y < y0) y0 = y;
+ if (y > y1) y1 = y;
+ }
+ }
+ }
+ if (x1 > x0 && y1 > y0) { sx = x0; sy = y0; sw = x1 - x0 + 1; sh = y1 - y0 + 1; }
+ } catch (e) { /* 取像素受限(跨域)→ 退回整帧比例 */ }
+ // 等比缩放:高度优先(立绘区偏瘦高),宽度不足时自动以宽度为准
+ const scale = Math.min((W * 0.94) / sw, (H * 0.80) / sh);
+ const dw = sw * scale, dh = sh * scale;
+ const gy = H * 0.90; // 脚底基线
+ // 脚下光晕底座(随人像宽度自适应)
+ const gx = Math.max(W * 0.40, dw * 0.62);
+ const grd = ctx.createRadialGradient(W / 2, gy, 2, W / 2, gy, gx);
grd.addColorStop(0, 'rgba(139,92,246,.5)');
grd.addColorStop(1, 'rgba(139,92,246,0)');
ctx.fillStyle = grd;
- ctx.beginPath(); ctx.ellipse(W / 2, gy, W * 0.42, H * 0.06, 0, 0, Math.PI * 2); ctx.fill();
- // 统一帧规格 48×64(characters.js CHAR_FW/CHAR_FH),按原图高反推帧宽,只画第一帧
- const fh = img.naturalHeight;
- const fw = Math.max(1, Math.round(fh * 48 / 64));
- const scale = Math.min((W * 0.86) / fw, (H * 0.84) / fh);
- const dw = fw * scale, dh = fh * scale;
- ctx.imageSmoothingEnabled = false;
- ctx.drawImage(img, 0, 0, fw, fh, (W - dw) / 2, gy - dh, dw, dh);
+ ctx.beginPath(); ctx.ellipse(W / 2, gy, gx, H * 0.055, 0, 0, Math.PI * 2); ctx.fill();
+ ctx.drawImage(img, sx, sy, sw, sh, (W - dw) / 2, gy - dh, dw, dh);
};
img.src = src;
}
@@ -6232,7 +6329,9 @@ 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: '饰品', boots: '鞋子' };
+ const labelMap = {};
+ microEquipSlots().forEach(s => { labelMap[s.value] = s.label; });
+ const map = labelMap;
const cur = unit.equip && unit.equip[slot];
const cid = unit.cid || unit.id;
let html = `
${map[slot]} · 点击穿戴×
`;
@@ -6296,7 +6395,7 @@ async function updateChar() {
});
if (r && r.ok && r.char) {
microChar = r.char;
- if (!microChar.equip) microChar.equip = { weapon: null, armor: null, accessory: null, boots: null };
+ if (!microChar.equip) microChar.equip = {};
if (!microChar.inventory) microChar.inventory = {};
['gold','copper','silver','diamond'].forEach(k => { if (microChar[k] == null) microChar[k] = 0; });
}