diff --git a/addons/yt_world/controllers/world_api.py b/addons/yt_world/controllers/world_api.py index 75941b0..c5bd3b5 100644 --- a/addons/yt_world/controllers/world_api.py +++ b/addons/yt_world/controllers/world_api.py @@ -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 diff --git a/addons/yt_world/models/yt_world_game.py b/addons/yt_world/models/yt_world_game.py index d37f5b9..bee0101 100644 --- a/addons/yt_world/models/yt_world_game.py +++ b/addons/yt_world/models/yt_world_game.py @@ -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 { diff --git a/world.html b/world.html index 8c49899..596d225 100644 --- a/world.html +++ b/world.html @@ -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
-
👤 单位详情
+
+
👤 单位详情
+ +
-
@@ -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 = `
${cur.icon || '⚔️'} ${cur.name} ${cur.bonus || ''} ✕卸下
`; - } else if (unit._localOnly) { - inner = `
等待服务器同步…
`; - } else { - inner = (catalog[slot] || []).map(e => - `
${e.icon || '⚔️'} ${e.name}${e.bonus || ''}
`).join(''); - } - return `
${map[slot]}
${inner}
`; - }).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 + ? `` + : `
${raceIcon}
`; + const eqCell = (slot, label) => { + const cur = unit.equip && unit.equip[slot]; + return `
+
${cur ? (cur.icon || '⚔️') : '➕'}
+
${cur ? cur.name : label}
+
`; + }; + const statusColor = unit.status==='working'?'#8bc34a':unit.status==='resting'?'#ff9800':'#4af'; + const statusText = unit.status==='working'?'🟢 工作中':unit.status==='resting'?'🟡 休息中':'🔵 空闲中'; document.getElementById('modalBody').innerHTML = ` ${syncNote} -
状态${unit.status==='working'?'🟢 工作中':unit.status==='resting'?'🟡 休息中':'🔵 空闲中'}
-
生命
${unit.hp||0}/${hpMax}
-
能量
${unit.energy||0}/${enMax}
-
经验
${unit.exp||0}
-
攻击${unit.attack||0}
-
防御${unit.defense||0}
-
速度${unit.speed||0}
-
职业${unit.job || '无'}
-
心情${unit.mood || '😊'}
-
装备(穿戴写入 Odoo 生物记录)
${slotHtml}
-
操作指令
+
+
+
+ ${eqCell('accessory','饰品')} + ${eqCell('weapon','武器')} +
${portrait}
+ ${eqCell('armor','护甲')} +
+
+
+
+
+
状态${statusText}
+
职业${unit.job || '无'}
+
心情${unit.mood || '😊'}
+
攻击${unit.attack||0}
+
防御${unit.defense||0}
+
速度${unit.speed||0}
+
+
生命
${unit.hp||0}/${hpMax}
+
能量
${unit.energy||0}/${enMax}
+
经验
${unit.exp||0}/${unit.exp_max||100}
+
+
+
操作指令
⬆️ 训练升级
😴 令其休息
`; + 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 = `
${map[slot]} · 点击穿戴
`; + if (unit._localOnly) { + html += `
等待服务器同步…
`; + } else { + if (cur) html += `
✕ 卸下 ${cur.name}
`; + const catalog = microEquipCatalog(); + (catalog[slot] || []).forEach(e => { + html += `
${e.icon || '⚔️'} ${e.name}${e.bonus || ''}
`; + }); + } + box.innerHTML = html; +}; + // 穿戴 / 卸下装备 → 调后端 action API,成功后刷新面板 async function microEquip(unitId, slot, code, unequip) { const action = unequip ? 'unequip' : 'equip';