图鉴
@@ -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:
|
||||
|
||||
@@ -125,4 +125,121 @@
|
||||
<field name="stats">{"hp_max": 25, "energy_max": 15}</field>
|
||||
<field name="desc">蕴含生命能量的护符,佩戴者精力更充沛。</field>
|
||||
</record>
|
||||
|
||||
<!-- ================= 帽子 (hat) ================= -->
|
||||
<record id="equip_straw_hat" model="yt.world.equip.item">
|
||||
<field name="code">straw_hat</field>
|
||||
<field name="name">草帽</field>
|
||||
<field name="icon">assets/icons/equips/straw_hat.png</field>
|
||||
<field name="slot">hat</field>
|
||||
<field name="rarity">common</field>
|
||||
<field name="stats">{"defense": 1}</field>
|
||||
<field name="desc">编织草帽,遮阳挡雨,略增防护。</field>
|
||||
</record>
|
||||
<record id="equip_iron_helm" model="yt.world.equip.item">
|
||||
<field name="code">iron_helm</field>
|
||||
<field name="name">铁盔</field>
|
||||
<field name="icon">assets/icons/equips/iron_helm.png</field>
|
||||
<field name="slot">hat</field>
|
||||
<field name="rarity">rare</field>
|
||||
<field name="stats">{"defense": 3, "hp_max": 8}</field>
|
||||
<field name="desc">冷锻铁盔,关键时刻护住头脸。</field>
|
||||
</record>
|
||||
<record id="equip_gold_crown" model="yt.world.equip.item">
|
||||
<field name="code">gold_crown</field>
|
||||
<field name="name">黄金王冠</field>
|
||||
<field name="icon">assets/icons/equips/gold_crown.png</field>
|
||||
<field name="slot">hat</field>
|
||||
<field name="rarity">legendary</field>
|
||||
<field name="stats">{"defense": 5, "hp_max": 20, "attack": 5}</field>
|
||||
<field name="desc">象征权柄的黄金王冠,威仪凛然。</field>
|
||||
</record>
|
||||
|
||||
<!-- ================= 裤子 (pants) ================= -->
|
||||
<record id="equip_cloth_pants" model="yt.world.equip.item">
|
||||
<field name="code">cloth_pants</field>
|
||||
<field name="name">布裤</field>
|
||||
<field name="icon">assets/icons/equips/cloth_pants.png</field>
|
||||
<field name="slot">pants</field>
|
||||
<field name="rarity">common</field>
|
||||
<field name="stats">{"defense": 1}</field>
|
||||
<field name="desc">轻便布裤,行动无碍。</field>
|
||||
</record>
|
||||
<record id="equip_iron_leggings" model="yt.world.equip.item">
|
||||
<field name="code">iron_leggings</field>
|
||||
<field name="name">铁护腿</field>
|
||||
<field name="icon">assets/icons/equips/iron_leggings.png</field>
|
||||
<field name="slot">pants</field>
|
||||
<field name="rarity">rare</field>
|
||||
<field name="stats">{"defense": 3, "hp_max": 10}</field>
|
||||
<field name="desc">裹住小腿的铁片护腿,稳健可靠。</field>
|
||||
</record>
|
||||
<record id="equip_dragon_pants" model="yt.world.equip.item">
|
||||
<field name="code">dragon_pants</field>
|
||||
<field name="name">龙鳞战裤</field>
|
||||
<field name="icon">assets/icons/equips/dragon_pants.png</field>
|
||||
<field name="slot">pants</field>
|
||||
<field name="rarity">legendary</field>
|
||||
<field name="stats">{"defense": 6, "hp_max": 25}</field>
|
||||
<field name="desc">龙鳞缀成的战裤,刀枪难入。</field>
|
||||
</record>
|
||||
|
||||
<!-- ================= 护臂 (bracelet) ================= -->
|
||||
<record id="equip_leather_bracer" model="yt.world.equip.item">
|
||||
<field name="code">leather_bracer</field>
|
||||
<field name="name">皮护腕</field>
|
||||
<field name="icon">assets/icons/equips/leather_bracer.png</field>
|
||||
<field name="slot">bracelet</field>
|
||||
<field name="rarity">common</field>
|
||||
<field name="stats">{"attack": 2}</field>
|
||||
<field name="desc">皮质护腕,挥击更稳。</field>
|
||||
</record>
|
||||
<!-- 银手镯/龙纹手镯 归属饰品槽 (accessory) -->
|
||||
<record id="equip_silver_bracer" model="yt.world.equip.item">
|
||||
<field name="code">silver_bracer</field>
|
||||
<field name="name">银手镯</field>
|
||||
<field name="icon">assets/icons/equips/silver_bracer.png</field>
|
||||
<field name="slot">accessory</field>
|
||||
<field name="rarity">rare</field>
|
||||
<field name="stats">{"attack": 4, "defense": 1}</field>
|
||||
<field name="desc">嵌银手镯,攻防兼修。</field>
|
||||
</record>
|
||||
<record id="equip_dragon_bracelet" model="yt.world.equip.item">
|
||||
<field name="code">dragon_bracelet</field>
|
||||
<field name="name">龙纹手镯</field>
|
||||
<field name="icon">assets/icons/equips/dragon_bracelet.png</field>
|
||||
<field name="slot">accessory</field>
|
||||
<field name="rarity">epic</field>
|
||||
<field name="stats">{"attack": 8, "hp_max": 10}</field>
|
||||
<field name="desc">刻龙纹的护腕,蓄力更猛。</field>
|
||||
</record>
|
||||
|
||||
<!-- ================= 腰带 (belt) ================= -->
|
||||
<record id="equip_rope_belt" model="yt.world.equip.item">
|
||||
<field name="code">rope_belt</field>
|
||||
<field name="name">麻绳腰带</field>
|
||||
<field name="icon">assets/icons/equips/rope_belt.png</field>
|
||||
<field name="slot">belt</field>
|
||||
<field name="rarity">common</field>
|
||||
<field name="stats">{"hp_max": 5}</field>
|
||||
<field name="desc">粗麻绳束腰,勉强管用。</field>
|
||||
</record>
|
||||
<record id="equip_iron_belt" model="yt.world.equip.item">
|
||||
<field name="code">iron_belt</field>
|
||||
<field name="name">铁腰封</field>
|
||||
<field name="icon">assets/icons/equips/iron_belt.png</field>
|
||||
<field name="slot">belt</field>
|
||||
<field name="rarity">rare</field>
|
||||
<field name="stats">{"hp_max": 12, "defense": 2}</field>
|
||||
<field name="desc">铁片腰封,护住躯干。</field>
|
||||
</record>
|
||||
<record id="equip_dragon_belt" model="yt.world.equip.item">
|
||||
<field name="code">dragon_belt</field>
|
||||
<field name="name">龙鳞腰带</field>
|
||||
<field name="icon">assets/icons/equips/dragon_belt.png</field>
|
||||
<field name="slot">belt</field>
|
||||
<field name="rarity">epic</field>
|
||||
<field name="stats">{"hp_max": 25, "defense": 4}</field>
|
||||
<field name="desc">龙鳞束腰,根骨更稳。</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 805 KiB |
|
After Width: | Height: | Size: 686 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -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
|
||||
? `<canvas id="umPortraitCv" width="160" height="180"></canvas>`
|
||||
? `<canvas id="umPortraitCv" width="264" height="400"></canvas>`
|
||||
: `<div class="umPortraitFallback">${raceIcon}</div>`;
|
||||
const eqCell = (slot, label) => {
|
||||
const cur = unit.equip && unit.equip[slot];
|
||||
return `<div class="umEq ${slot}" onclick="umTogglePicker('${slot}')" title="${label}${cur ? ':' + cur.name : '(空,点击穿戴)'}">
|
||||
// 装备槽:槽位列表来自后端真源(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 `<div class="umEq umEqGhost"></div>`; // 槽位缺失 → 占位保持环绕对称
|
||||
const cur = (unit.equip && unit.equip[value]) || null;
|
||||
return `<div class="umEq" data-slot="${value}" onclick="umTogglePicker('${value}')" title="${s.label}${cur ? ':' + cur.name : '(空,点击穿戴)'}">
|
||||
<div class="umEqIcon">${cur ? eqIconHTML(cur.icon) : '➕'}</div>
|
||||
<div class="umEqName">${cur ? cur.name : label}</div>
|
||||
<div class="umEqName">${cur ? cur.name : s.label}</div>
|
||||
</div>`;
|
||||
};
|
||||
const extraSlots = slotList.filter(s => !EQUIP_SLOT_POS[s.value]);
|
||||
const extraGridHTML = extraSlots.length
|
||||
? `<div class="umEquipGrid">${extraSlots.map(s => eqCell(s.value)).join('')}</div>` : '';
|
||||
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) {
|
||||
<div class="umGrid">
|
||||
<div class="umLeft">
|
||||
<div class="umPortraitWrap">
|
||||
${eqCell('accessory','饰品')}
|
||||
${eqCell('weapon','武器')}
|
||||
<div class="umEqRing">
|
||||
<div class="umEqRingTop">${eqCell('hat')}</div>
|
||||
<div class="umEqRingMid">
|
||||
<div class="umEqCol">${eqCell('bracelet')}${eqCell('weapon')}${eqCell('accessory')}</div>
|
||||
<div class="umPortrait">${portrait}</div>
|
||||
${eqCell('armor','护甲')}
|
||||
${eqCell('boots','鞋子')}
|
||||
<div class="umEqCol">${eqCell('armor')}${eqCell('belt')}${eqCell('pants')}</div>
|
||||
</div>
|
||||
<div class="umEqRingBottom">${eqCell('boots')}</div>
|
||||
</div>
|
||||
</div>
|
||||
${extraGridHTML}
|
||||
<div id="umPicker" class="umEquipPicker" data-slot=""></div>
|
||||
</div>
|
||||
<div class="umRight">
|
||||
@@ -6185,33 +6248,67 @@ async function showUnitModal(unitId) {
|
||||
</div>
|
||||
`;
|
||||
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 = `<div class="equipSlotName"><span>${map[slot]} · 点击穿戴</span><span class="equipClose" onclick="umTogglePicker('${slot}');event.stopPropagation();">×</span></div>`;
|
||||
@@ -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; });
|
||||
}
|
||||
|
||||