生物面板
This commit is contained in:
@@ -832,7 +832,7 @@ class YtWorldApi(http.Controller):
|
||||
if 'inventory' in body and body['inventory'] is not None:
|
||||
vals['inventory'] = body['inventory']
|
||||
if 'equip' in body and body['equip'] is not None:
|
||||
vals['equip'] = body['equip']
|
||||
vals['equip'] = self._normalize_equip(body['equip'])
|
||||
char.sudo().write(vals)
|
||||
return _json({'ok': True, 'char': {
|
||||
'name': char.name, 'level': char.level, 'exp': char.exp,
|
||||
@@ -841,6 +841,19 @@ class YtWorldApi(http.Controller):
|
||||
'equip': char.equip or {},
|
||||
}})
|
||||
|
||||
@staticmethod
|
||||
def _normalize_equip(equip):
|
||||
"""equip 的值统一规范为装备 code 字符串。
|
||||
|
||||
读取接口返回的是解析后的装备对象(含 code/name/desc…),前端原样回存
|
||||
会把它写成字典,导致后续序列化异常。这里统一压回 code。"""
|
||||
if not isinstance(equip, dict):
|
||||
return {}
|
||||
out = {}
|
||||
for slot, raw in equip.items():
|
||||
out[slot] = (raw.get('code') if isinstance(raw, dict) else raw)
|
||||
return out
|
||||
|
||||
@http.route('/yt_world/api/creatures', type='http', auth='public',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
def get_creatures(self, **kw):
|
||||
@@ -853,7 +866,23 @@ class YtWorldApi(http.Controller):
|
||||
return _json({'ok': False, 'error': 'no_world'})
|
||||
recs = request.env['yt.world.creature'].sudo().search(
|
||||
[('world_id', '=', world.id)], order='id')
|
||||
out = [r.to_panel_dict() for r in recs]
|
||||
out = []
|
||||
for r in recs:
|
||||
try:
|
||||
out.append(r.to_panel_dict())
|
||||
except Exception:
|
||||
# 单条脏数据不得拖垮整个接口,否则玩家所有生物都读不出来
|
||||
_logger.exception('[yt_world] 生物 %s 序列化失败,已降级返回', r.cid)
|
||||
out.append({
|
||||
'oid': r.id, 'cid': r.cid, 'race': r.race, 'name': r.name,
|
||||
'level': r.level, 'exp': r.exp, 'exp_max': r.exp_max,
|
||||
'hp': r.hp, 'hp_max': r.hp_max,
|
||||
'energy': r.energy, 'energy_max': r.energy_max,
|
||||
'attack': r.attack, 'defense': r.defense, 'speed': r.speed,
|
||||
'mood': r.mood, 'job': r.job, 'status': r.status, 'state': r.state,
|
||||
'x': r.x, 'y': r.y, 'facing': r.facing,
|
||||
'tile_ref': r.tile_ref or {}, 'equip': {},
|
||||
})
|
||||
return _json({'ok': True, 'creatures': out})
|
||||
|
||||
@http.route('/yt_world/api/creatures/save', type='http', auth='public',
|
||||
@@ -887,7 +916,7 @@ 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': c.get('equip') or {},
|
||||
'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
|
||||
|
||||
@@ -277,7 +277,9 @@ class YtWorldCreature(models.Model):
|
||||
for it in self.env['yt.world.equip.item'].sudo().search([]):
|
||||
catalog[it.code] = it.to_dict()
|
||||
equip_resolved = {}
|
||||
for slot, code in equip.items():
|
||||
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]
|
||||
return {
|
||||
|
||||
+237
-34
@@ -731,9 +731,45 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
|
||||
}
|
||||
#unitModal.active { display: flex; }
|
||||
#unitModal .modalContent {
|
||||
background: #1a2030; border: 1px solid rgba(100,200,255,0.3); border-radius: 12px;
|
||||
padding: 24px; max-width: 340px; width: 90%; pointer-events: auto;
|
||||
background: linear-gradient(180deg, #1d2540 0%, #161d30 55%, #131a2b 100%);
|
||||
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;
|
||||
}
|
||||
/* 头部:标题 + 右上角关闭(MMO 风格) */
|
||||
#unitModal .umHead { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px;
|
||||
padding-bottom:10px; border-bottom:1px solid rgba(139,92,246,.22); }
|
||||
#unitModal .umStatGrid .statRow { margin:2px 0; }
|
||||
#unitModal .umClose { width:28px;height:28px;border-radius:8px;border:1px solid rgba(255,120,120,.35);
|
||||
background:rgba(255,80,80,.12); color:#ff9a9a; font-size:15px; cursor:pointer;
|
||||
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: 200px 1fr; gap:14px; }
|
||||
#unitModal .umLeft { position:relative; border-radius:12px; padding:14px 10px 10px;
|
||||
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);
|
||||
box-shadow: inset 0 0 28px rgba(139,92,246,.10); }
|
||||
#unitModal .umPortraitWrap { position:relative; width:160px; height:180px; margin:8px 0 2px; }
|
||||
#unitModal .umPortrait { width:100%; height:100%; display:flex; align-items:flex-end; justify-content:center; }
|
||||
#unitModal .umPortrait canvas { width:100%; height:100%; display:block; }
|
||||
#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;
|
||||
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.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 .umRight { min-width:0; }
|
||||
#unitModal .umStatGrid { display:grid; grid-template-columns:1fr 1fr; gap:2px 14px; margin-bottom:8px; }
|
||||
#unitModal .umStatGrid .statBar { display:none; }
|
||||
#unitModal .modalTitle { font-size: 16px; font-weight: bold; color: #e0e0e0; margin-bottom: 16px; }
|
||||
#unitModal .statRow { display: flex; justify-content: space-between; align-items: center; margin: 6px 0; font-size: 12px; }
|
||||
#unitModal .statLabel { color: #8899aa; }
|
||||
@@ -749,15 +785,11 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
|
||||
#unitModal .cmdBtn:hover { background: rgba(100,200,255,0.2); }
|
||||
#unitModal .cmdGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
#unitModal .cmdGrid .cmdBtn { margin-top: 0; }
|
||||
/* 装备槽 */
|
||||
#unitModal .equipWrap { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
|
||||
#unitModal .equipSlot { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; padding: 7px; }
|
||||
/* 装备候选列表(立绘旁装备槽点击后展开) */
|
||||
#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 .equipCur { font-size: 12px; color: #eaf2ff; padding: 5px 2px; }
|
||||
#unitModal .equipBonus { color: #8bc34a; font-size: 10px; margin-left: 3px; }
|
||||
#unitModal .equipX { color: #ff7b7b; cursor: pointer; margin-left: 4px; font-size: 11px; }
|
||||
|
||||
/* 微观建筑悬停提示 tooltip */
|
||||
#microTip {
|
||||
@@ -1569,9 +1601,11 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
|
||||
<!-- 单位详情弹窗 -->
|
||||
<div id="unitModal">
|
||||
<div class="modalContent">
|
||||
<div class="modalTitle" id="modalTitle">👤 单位详情</div>
|
||||
<div class="umHead">
|
||||
<div class="modalTitle" id="modalTitle" style="margin-bottom:0;">👤 单位详情</div>
|
||||
<button class="umClose" onclick="closeUnitModal()" title="关闭">✕</button>
|
||||
</div>
|
||||
<div id="modalBody"></div>
|
||||
<button class="closeBtn" onclick="closeUnitModal()">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2423,6 +2457,7 @@ async function microLoadGame() {
|
||||
microCreatureOverlay = {};
|
||||
if (cr && cr.ok && Array.isArray(cr.creatures)) {
|
||||
cr.creatures.forEach(x => { if (x.cid) microCreatureOverlay[x.cid] = x; });
|
||||
microRestoreCreatures(cr.creatures); // 玩家生物回注对应地块(刷新/重进不丢)
|
||||
}
|
||||
if (microState && microState.units) {
|
||||
microState.units.forEach(u => {
|
||||
@@ -2441,6 +2476,101 @@ async function microLoadGame() {
|
||||
} catch (e) { /* 离线兜底:不阻塞微观世界 */ }
|
||||
}
|
||||
|
||||
// ===== 玩家生物持久化恢复(生物属于玩家账号,存于 yt.world.creature,按 tile_ref 归属地块) =====
|
||||
// 服务端生物记录 → 地块居民 unit 对象
|
||||
function microMakeUnitFromCreature(x) {
|
||||
const race = x.race || x.species || 'human';
|
||||
return {
|
||||
id: x.cid, name: x.name || '居民',
|
||||
role: race, species: race,
|
||||
icon: x.icon || _raceIconFallback(race),
|
||||
level: x.level || 1, exp: x.exp || 0, exp_max: x.exp_max || 100,
|
||||
hp: x.hp || 80, hp_max: x.hp_max || 120,
|
||||
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 },
|
||||
status: x.state || x.status || 'resting', _buildingRender: true
|
||||
};
|
||||
}
|
||||
// 判定是否"建造放置的居民单位"(cid 形如 human_xxx)。
|
||||
// char_ 前缀属于 characters.js 动画角色系统,由 loadMicroChars 自行恢复,两套不可互相注入/回存
|
||||
// (否则居民系统会把它们按 unit 格式回存,覆盖掉 x/y 等角色专用字段)。
|
||||
function _isPlayerCreature(x) {
|
||||
if (!x || !x.cid) return false;
|
||||
const cid = String(x.cid);
|
||||
return cid.includes('_') && !cid.startsWith('char_');
|
||||
}
|
||||
|
||||
// 全量恢复:把服务端生物按 tile_ref 回注到内存中对应地块的 units
|
||||
function microRestoreCreatures(list) {
|
||||
if (!Array.isArray(list)) return;
|
||||
list.filter(_isPlayerCreature).forEach(x => {
|
||||
const ref = (x.tile_ref && typeof x.tile_ref === 'object') ? x.tile_ref : {};
|
||||
const tile = tiles.find(t => t.layer === 'earth' && t.q === ref.q && t.r === ref.r);
|
||||
if (!tile) return;
|
||||
if (!tile.units) tile.units = [];
|
||||
if (tile.units.some(u => u.id === x.cid)) return; // 已存在(本会话刚放置)
|
||||
const unit = microMakeUnitFromCreature(x);
|
||||
tile.units.push(unit);
|
||||
_knownUnitCids.add(x.cid); // 已入驻,避免保存时误判新居民重复发任务事件
|
||||
// 当前打开的地块:microState.units 是拷贝,需同步(首次进入时 tile.units 与其分离)
|
||||
if (microState && microState.tile && microState.tile.q === ref.q && microState.tile.r === ref.r
|
||||
&& microState.units && !microState.units.some(u => u.id === x.cid)) {
|
||||
microState.units.push({ ...unit, patrol: null });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 切地块时:确保该地块的玩家生物已注入 units(并同步到当前 microState)
|
||||
function microEnsureTileCreatures(tile) {
|
||||
if (!tile) return;
|
||||
Object.values(microCreatureOverlay || {}).forEach(x => {
|
||||
if (!_isPlayerCreature(x)) return;
|
||||
const ref = (x.tile_ref && typeof x.tile_ref === 'object') ? x.tile_ref : {};
|
||||
if (ref.q !== tile.q || ref.r !== tile.r) return;
|
||||
if ((tile.units || []).some(u => u.id === x.cid)) return;
|
||||
const unit = microMakeUnitFromCreature(x);
|
||||
tile.units.push(unit);
|
||||
_knownUnitCids.add(x.cid);
|
||||
if (microState && microState.units && !microState.units.some(u => u.id === x.cid)) {
|
||||
microState.units.push({ ...unit });
|
||||
}
|
||||
microEnsureCharPlaced(unit); // 占位自愈(保证微观视图可见可点)
|
||||
});
|
||||
}
|
||||
|
||||
// 占位自愈:当前地块缺少该生物的 build_items 占位(_charCid)时补一个
|
||||
function microEnsureCharPlaced(unit) {
|
||||
if (!microState || !microState.tile || !unit) return;
|
||||
const list = microBuildList();
|
||||
if (list.some(b => b._charCid === unit.id)) return;
|
||||
const type = 'race_' + (unit.role || unit.species || 'human');
|
||||
const it = microBuildItemById(type);
|
||||
if (!it) return;
|
||||
const ew = it.w || 1, eh = it.h || 1;
|
||||
const { iMin, jMin, iMax, jMax } = microBuildableCellBounds();
|
||||
for (let j = jMin; j <= jMax - eh + 1; j++) {
|
||||
for (let i = iMin; i <= iMax - ew + 1; i++) {
|
||||
if (microGhostValidAt(i, j, ew, eh, null) && microTerrainOk(it)) {
|
||||
list.push({ i, j, type, rot: 0, w: ew, h: eh, level: unit.level || 1, exp: unit.exp || 0, hp: 100, _charCid: unit.id });
|
||||
scheduleBuildSave();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// equip 的值可能是装备对象(后端 to_panel_dict 解析后的结果),回存时统一压缩为 code
|
||||
function _normalizeEquip(eq) {
|
||||
const out = { weapon: null, armor: null, accessory: null };
|
||||
['weapon', 'armor', 'accessory'].forEach(s => {
|
||||
const v = eq && eq[s];
|
||||
out[s] = (v && typeof v === 'object') ? (v.code || null) : (v || null);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// 持久化当前地块全部单位的角色化状态(等级/经验/装备/生命)到 Odoo
|
||||
async function microSaveCreatures() {
|
||||
if (!microState || !microState.units) return;
|
||||
@@ -2448,11 +2578,15 @@ async function microSaveCreatures() {
|
||||
// 不要在这里用 microState.units 做延迟种子,否则刚放置的新角色 cid
|
||||
// 会被提前标记为"已知",导致 char_recruited 事件漏发。
|
||||
if (!microState.units.length) return;
|
||||
const list = microState.units.map(u => ({
|
||||
// 只保存"建造放置的居民单位"(_buildingRender 标记);随机原住民与动画角色不入库,
|
||||
// 避免污染玩家账号数据、也避免覆盖角色系统的 x/y 等专用字段
|
||||
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: u.equip || { weapon: null, armor: null, accessory: null },
|
||||
equip: _normalizeEquip(u.equip),
|
||||
tile_ref: microState.tile ? { q: microState.tile.q, r: microState.tile.r } : {},
|
||||
state: u.status || 'resting',
|
||||
}));
|
||||
@@ -4169,6 +4303,7 @@ function startMicroRender() {
|
||||
return za - zb || a.i - b.i;
|
||||
}).forEach(b => {
|
||||
const it = microBuildItemById(b.type);
|
||||
if (!it) return; // 目录未含该类型(未加载完/数据异常),跳过防渲染中断
|
||||
let ew = b.w || it.w || 1, eh = b.h || it.h || 1;
|
||||
if (((b.rot || 0) % 180) !== 0) { const t = ew; ew = eh; eh = t; } // 旋转 90/270 交换长宽(设施仍保留)
|
||||
const center = microCellToScreen(b.i + ew / 2, b.j + eh / 2);
|
||||
@@ -4757,6 +4892,7 @@ function setMicroTile(tile) {
|
||||
// 把服务端已存在的居民标记为"已知",避免后续保存时把旧居民误判为新入驻
|
||||
_knownUnitCids.clear();
|
||||
(microState.units || []).forEach(u => { if (u.id) _knownUnitCids.add(u.id); });
|
||||
microEnsureTileCreatures(tile); // 恢复该地块的玩家生物(服务端 tile_ref 归属)
|
||||
microState.buildings = tile.hasBuilding
|
||||
? [{ ...BUILDINGS[tile.buildingType], type: tile.buildingType, level: tile.buildingLevel, hp: 80 + tile.buildingLevel * 10 }]
|
||||
: [];
|
||||
@@ -5268,44 +5404,111 @@ async function showUnitModal(unitId) {
|
||||
const hpColor = unit.hp > hpMax * 0.7 ? '#8bc34a' : unit.hp > hpMax * 0.4 ? '#ff9800' : '#f44336';
|
||||
const enColor = unit.energy > enMax * 0.7 ? '#4af' : unit.energy > enMax * 0.4 ? '#ff9800' : '#f44336';
|
||||
const expPct = Math.max(0, Math.min(100, Math.round((unit.exp || 0) / (unit.exp_max || 100) * 100)));
|
||||
const catalog = microEquipCatalog();
|
||||
const slotHtml = ['weapon', 'armor', 'accessory'].map(slot => {
|
||||
const map = { weapon: '武器', armor: '护甲', accessory: '饰品' };
|
||||
const cur = unit.equip && unit.equip[slot];
|
||||
let inner;
|
||||
if (cur) {
|
||||
inner = `<div class="equipCur">${cur.icon || '⚔️'} ${cur.name} <span class="equipBonus">${cur.bonus || ''}</span> <span class="equipX" onclick="microEquip('${unitId}','${slot}',null,true)">✕卸下</span></div>`;
|
||||
} else if (unit._localOnly) {
|
||||
inner = `<div style="color:#888;font-size:12px;padding:6px 0;">等待服务器同步…</div>`;
|
||||
} else {
|
||||
inner = (catalog[slot] || []).map(e =>
|
||||
`<div class="equipOpt" onclick="microEquip('${unitId}','${slot}','${e.code}',false)">${e.icon || '⚔️'} ${e.name}<span class="equipBonus">${e.bonus || ''}</span></div>`).join('');
|
||||
}
|
||||
return `<div class="equipSlot"><div class="equipSlotName">${map[slot]}</div>${inner}</div>`;
|
||||
}).join('');
|
||||
const cmdDisabled = unit._localOnly ? 'style="opacity:0.5;pointer-events:none;"' : '';
|
||||
|
||||
// 左栏:种族立绘(精灵图第一帧,无图则退化为 emoji)+ 环绕装备槽
|
||||
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>`
|
||||
: `<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 : '(空,点击穿戴)'}">
|
||||
<div class="umEqIcon">${cur ? (cur.icon || '⚔️') : '➕'}</div>
|
||||
<div class="umEqName">${cur ? cur.name : label}</div>
|
||||
</div>`;
|
||||
};
|
||||
const statusColor = unit.status==='working'?'#8bc34a':unit.status==='resting'?'#ff9800':'#4af';
|
||||
const statusText = unit.status==='working'?'🟢 工作中':unit.status==='resting'?'🟡 休息中':'🔵 空闲中';
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
${syncNote}
|
||||
<div class="statRow"><span class="statLabel">状态</span><span class="statValue" style="color:${unit.status==='working'?'#8bc34a':unit.status==='resting'?'#ff9800':'#4af'}">${unit.status==='working'?'🟢 工作中':unit.status==='resting'?'🟡 休息中':'🔵 空闲中'}</span></div>
|
||||
<div class="statRow"><span class="statLabel">生命</span><div class="statBar"><div class="statFill" style="width:${Math.round((unit.hp||0)/hpMax*100)}%;background:${hpColor};"></div></div><span class="statValue">${unit.hp||0}/${hpMax}</span></div>
|
||||
<div class="statRow"><span class="statLabel">能量</span><div class="statBar"><div class="statFill" style="width:${Math.round((unit.energy||0)/enMax*100)}%;background:${enColor};"></div></div><span class="statValue">${unit.energy||0}/${enMax}</span></div>
|
||||
<div class="statRow"><span class="statLabel">经验</span><div class="statBar"><div class="statFill" style="width:${expPct}%;background:#64b5f6;"></div></div><span class="statValue">${unit.exp||0}</span></div>
|
||||
<div class="umGrid">
|
||||
<div class="umLeft">
|
||||
<div class="umPortraitWrap">
|
||||
${eqCell('accessory','饰品')}
|
||||
${eqCell('weapon','武器')}
|
||||
<div class="umPortrait">${portrait}</div>
|
||||
${eqCell('armor','护甲')}
|
||||
</div>
|
||||
<div id="umPicker" class="umEquipPicker" data-slot=""></div>
|
||||
</div>
|
||||
<div class="umRight">
|
||||
<div class="umStatGrid">
|
||||
<div class="statRow"><span class="statLabel">状态</span><span class="statValue" style="color:${statusColor}">${statusText}</span></div>
|
||||
<div class="statRow"><span class="statLabel">职业</span><span class="statValue">${unit.job || '无'}</span></div>
|
||||
<div class="statRow"><span class="statLabel">心情</span><span class="statValue">${unit.mood || '😊'}</span></div>
|
||||
<div class="statRow"><span class="statLabel">攻击</span><span class="statValue">${unit.attack||0}</span></div>
|
||||
<div class="statRow"><span class="statLabel">防御</span><span class="statValue">${unit.defense||0}</span></div>
|
||||
<div class="statRow"><span class="statLabel">速度</span><span class="statValue">${unit.speed||0}</span></div>
|
||||
<div class="statRow"><span class="statLabel">职业</span><span class="statValue">${unit.job || '无'}</span></div>
|
||||
<div class="statRow"><span class="statLabel">心情</span><span class="statValue">${unit.mood || '😊'}</span></div>
|
||||
<div style="margin-top:14px;"><div class="panel-label">装备(穿戴写入 Odoo 生物记录)</div><div class="equipWrap">${slotHtml}</div></div>
|
||||
<div style="margin-top:14px;"><div class="panel-label">操作指令</div>
|
||||
</div>
|
||||
<div class="statRow"><span class="statLabel">生命</span><div class="statBar"><div class="statFill" style="width:${Math.round((unit.hp||0)/hpMax*100)}%;background:${hpColor};"></div></div><span class="statValue">${unit.hp||0}/${hpMax}</span></div>
|
||||
<div class="statRow"><span class="statLabel">能量</span><div class="statBar"><div class="statFill" style="width:${Math.round((unit.energy||0)/enMax*100)}%;background:${enColor};"></div></div><span class="statValue">${unit.energy||0}/${enMax}</span></div>
|
||||
<div class="statRow"><span class="statLabel">经验</span><div class="statBar"><div class="statFill" style="width:${expPct}%;background:#64b5f6;"></div></div><span class="statValue">${unit.exp||0}/${unit.exp_max||100}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:12px;"><div class="panel-label">操作指令</div>
|
||||
<div class="cmdGrid" ${cmdDisabled}>
|
||||
<div class="cmdBtn" onclick="microUnitTrain('${unitId}')">⬆️ 训练升级</div>
|
||||
<div class="cmdBtn" onclick="microUnitRest('${unitId}')">😴 令其休息</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
window._umUnit = unit;
|
||||
if (sprite) _drawRacePortrait(sprite, document.getElementById('umPortraitCv'));
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
// 精灵图第一帧 → 面板立绘 canvas(精确取 48:64 帧比例、居中、脚下光晕底座)
|
||||
function _drawRacePortrait(src, cv) {
|
||||
if (!cv) return;
|
||||
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);
|
||||
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);
|
||||
};
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
// 点击立绘旁装备槽:展开/收起该槽的候选装备列表(穿戴写 Odoo,与原 equipWrap 同源)
|
||||
window.umTogglePicker = function(slot){
|
||||
const box = document.getElementById('umPicker');
|
||||
const unit = window._umUnit;
|
||||
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 cur = unit.equip && unit.equip[slot];
|
||||
const cid = unit.cid || unit.id;
|
||||
let html = `<div class="equipSlotName">${map[slot]} · 点击穿戴</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>`;
|
||||
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>`;
|
||||
});
|
||||
}
|
||||
box.innerHTML = html;
|
||||
};
|
||||
|
||||
// 穿戴 / 卸下装备 → 调后端 action API,成功后刷新面板
|
||||
async function microEquip(unitId, slot, code, unequip) {
|
||||
const action = unequip ? 'unequip' : 'equip';
|
||||
|
||||
Reference in New Issue
Block a user