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 ``;
- }).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.energy||0}/${enMax}
-
- 攻击${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.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';