角色面板
This commit is contained in:
@@ -13,6 +13,8 @@
|
||||
'data/codex_race.xml',
|
||||
'data/codex_talent.xml',
|
||||
'data/yt_world_build_item.xml',
|
||||
'data/yt_world_equip_item.xml',
|
||||
'data/yt_world_race.xml',
|
||||
'data/yt_world_task.xml',
|
||||
'views/yt_world_views.xml',
|
||||
# 图鉴:与 8888 前端图鉴对齐的 5 个模型视图 + 菜单改造
|
||||
|
||||
@@ -834,7 +834,7 @@ class YtWorldApi(http.Controller):
|
||||
@http.route('/yt_world/api/creatures', type='http', auth='public',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
def get_creatures(self, **kw):
|
||||
"""取当前世界全部生物。返回含服务器 id 与客户端 cid(partner.cid)。"""
|
||||
"""取当前世界全部生物(世界生物表)。返回完整面板数据。"""
|
||||
auth_err = self._require_user()
|
||||
if auth_err:
|
||||
return _json(auth_err)
|
||||
@@ -843,24 +843,14 @@ 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 = []
|
||||
for r in recs:
|
||||
out.append({
|
||||
'oid': r.id,
|
||||
'cid': (r.partner or {}).get('cid'),
|
||||
'name': r.name, 'species': r.species, 'level': r.level,
|
||||
'exp': r.exp, 'hp': r.hp, 'hp_max': r.hp_max,
|
||||
'energy': r.energy, 'mood': r.mood or '😊', 'job': r.job,
|
||||
'equip': r.equip or {'weapon': None, 'armor': None, 'accessory': None},
|
||||
'tile_ref': r.tile_ref or {}, 'state': r.state or 'resting',
|
||||
})
|
||||
out = [r.to_panel_dict() for r in recs]
|
||||
return _json({'ok': True, 'creatures': out})
|
||||
|
||||
@http.route('/yt_world/api/creatures/save', type='http', auth='public',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def save_creatures(self, **kw):
|
||||
"""upsert 生物列表。body: {"creatures":[{cid,name,species,level,exp,hp,hp_max,energy,mood,job,equip,tile_ref,state}]}
|
||||
按 partner.cid 匹配;存在则更新,否则新建。返回带 oid 的列表。"""
|
||||
"""upsert 生物列表。body: {"creatures":[{cid,race,name,level,exp,exp_max,hp,hp_max,energy,energy_max,attack,defense,speed,x,y,facing,status,state,mood,job,equip,tile_ref}]}
|
||||
按 cid 匹配:存在则更新,否则新建。返回带 oid 的列表。"""
|
||||
auth_err = self._require_user()
|
||||
if auth_err:
|
||||
return _json(auth_err)
|
||||
@@ -875,31 +865,35 @@ class YtWorldApi(http.Controller):
|
||||
cid = c.get('cid')
|
||||
vals = {
|
||||
'name': c.get('name') or '居民',
|
||||
'species': c.get('species'),
|
||||
'race': c.get('race') or 'human',
|
||||
'level': c.get('level', 1), 'exp': c.get('exp', 0),
|
||||
'hp': c.get('hp', 100), 'hp_max': c.get('hp_max', 100),
|
||||
'energy': c.get('energy', 100), 'mood': c.get('mood', '😊'),
|
||||
'job': c.get('job', 'none'),
|
||||
'equip': c.get('equip') or {'weapon': None, 'armor': None, 'accessory': None},
|
||||
'exp_max': c.get('exp_max', 100),
|
||||
'hp': c.get('hp', 50), 'hp_max': c.get('hp_max', 50),
|
||||
'energy': c.get('energy', 100), 'energy_max': c.get('energy_max', 100),
|
||||
'attack': c.get('attack', 5), 'defense': c.get('defense', 3),
|
||||
'speed': c.get('speed', 5),
|
||||
'x': float(c.get('x') or 0), 'y': float(c.get('y') or 0),
|
||||
'facing': int(c.get('facing') or 1),
|
||||
'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 {},
|
||||
'tile_ref': c.get('tile_ref') or {},
|
||||
'state': c.get('state', 'resting'),
|
||||
'partner': {'cid': cid},
|
||||
}
|
||||
# Json 字段不能用 ilike 检索,改为在 Python 侧按 partner.cid 匹配。
|
||||
rec = None
|
||||
if cid:
|
||||
for r in model.search([('world_id', '=', world.id)]):
|
||||
if (r.partner or {}).get('cid') == cid:
|
||||
rec = r
|
||||
break
|
||||
rec = model.search([('world_id', '=', world.id), ('cid', '=', cid)], limit=1) if cid else None
|
||||
if rec:
|
||||
rec.write(vals)
|
||||
else:
|
||||
vals['world_id'] = world.id
|
||||
vals['cid'] = cid
|
||||
rec = model.create(vals)
|
||||
out.append({'oid': rec.id, 'cid': cid, 'level': rec.level,
|
||||
'exp': rec.exp, 'hp': rec.hp, 'equip': rec.equip or {}})
|
||||
return _json({'ok': True, 'creatures': out})
|
||||
# 删除指定 cid
|
||||
deleted = body.get('deleted') or []
|
||||
if deleted:
|
||||
model.search([('world_id', '=', world.id), ('cid', 'in', list(deleted))]).unlink()
|
||||
return _json({'ok': True, 'creatures': out, 'deleted': len(deleted)})
|
||||
|
||||
def _find_build_action(self, code, action):
|
||||
"""优先从 yt.world.build.item 的 actions 字段查动作配置;没有则回退 BUILD_EFFECTS。"""
|
||||
@@ -998,34 +992,45 @@ class YtWorldApi(http.Controller):
|
||||
# 微观世界角色(玩家放置的种族角色,绑定用户持久化)
|
||||
# ============================================================
|
||||
|
||||
@http.route('/yt_world/api/micro/chars', type='http', auth='public',
|
||||
@http.route('/yt_world/api/races', type='http', auth='public',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
def micro_chars(self, **kw):
|
||||
"""取当前世界全部微观角色。返回 [{oid, cid, race, x, y, facing, state, tile_ref}]"""
|
||||
def get_races(self, **kw):
|
||||
"""种族模板(单一事实来源)。返回前端 characters.js 初始化所需全部字段。"""
|
||||
races = request.env['yt.world.race'].sudo().search([('active', '=', True)], order='sequence')
|
||||
return _json({'ok': True, 'races': [r.to_dict() for r in races]})
|
||||
|
||||
@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]})
|
||||
|
||||
def _creature_by_cid(self, world, cid):
|
||||
if not cid:
|
||||
return None
|
||||
return request.env['yt.world.creature'].sudo().search(
|
||||
[('world_id', '=', world.id), ('cid', '=', cid)], limit=1)
|
||||
|
||||
@http.route('/yt_world/api/creature/<string:cid>', type='http', auth='public',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
def creature_detail(self, cid, **kw):
|
||||
"""取单个生物详情(按 cid)。"""
|
||||
auth_err = self._require_user()
|
||||
if auth_err:
|
||||
return _json(auth_err)
|
||||
world = self._get_world()
|
||||
if not world:
|
||||
return _json({'ok': False, 'error': 'no_world'})
|
||||
recs = request.env['yt.world.micro.char'].sudo().search(
|
||||
[('world_id', '=', world.id)], order='id')
|
||||
out = []
|
||||
for r in recs:
|
||||
out.append({
|
||||
'oid': r.id, 'cid': r.cid,
|
||||
'race': r.race, 'x': r.x, 'y': r.y,
|
||||
'facing': r.facing, 'state': r.state,
|
||||
'hp': r.hp,
|
||||
'tile_ref': r.tile_ref or {},
|
||||
})
|
||||
return _json({'ok': True, 'chars': out})
|
||||
rec = self._creature_by_cid(world, cid)
|
||||
if not rec:
|
||||
return _json({'ok': False, 'error': 'char_not_found'})
|
||||
return _json({'ok': True, 'char': rec.to_panel_dict()})
|
||||
|
||||
@http.route('/yt_world/api/micro/chars/save', type='http', auth='public',
|
||||
@http.route('/yt_world/api/creature/action', type='http', auth='public',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def save_micro_chars(self, **kw):
|
||||
"""upsert 微观角色列表。body: {"chars":[{cid,race,x,y,facing,state,tile_ref}], "deleted":[cid...]}
|
||||
按 cid 匹配:存在则更新,否则新建;deleted 中的 cid 记录被删除。"""
|
||||
def creature_action(self, cid=None, **kw):
|
||||
"""生物操作:equip / unequip / train / rest。body: {cid, action, slot?, code?}"""
|
||||
auth_err = self._require_user()
|
||||
if auth_err:
|
||||
return _json(auth_err)
|
||||
@@ -1033,44 +1038,46 @@ class YtWorldApi(http.Controller):
|
||||
if not world:
|
||||
return _json({'ok': False, 'error': 'no_world'})
|
||||
body = _post_body()
|
||||
items = body.get('chars') or []
|
||||
deleted = body.get('deleted') or []
|
||||
model = request.env['yt.world.micro.char'].sudo()
|
||||
out = []
|
||||
for c in items:
|
||||
cid = c.get('cid')
|
||||
vals = {
|
||||
'race': c.get('race') or 'human',
|
||||
'x': float(c.get('x') or 0),
|
||||
'y': float(c.get('y') or 0),
|
||||
'facing': int(c.get('facing') or 1),
|
||||
'state': c.get('state') or 'idle',
|
||||
'tile_ref': c.get('tile_ref') or {},
|
||||
}
|
||||
# 生命值:传了才写(兼容旧版前端不传 hp)
|
||||
if c.get('hp') is not None:
|
||||
vals['hp'] = int(c.get('hp') or 0)
|
||||
rec = None
|
||||
if cid:
|
||||
for r in model.search([('world_id', '=', world.id)]):
|
||||
if r.cid == cid:
|
||||
rec = r
|
||||
break
|
||||
if rec:
|
||||
rec.write(vals)
|
||||
else:
|
||||
vals['world_id'] = world.id
|
||||
vals['cid'] = cid
|
||||
rec = model.create(vals)
|
||||
out.append({'oid': rec.id, 'cid': cid})
|
||||
# 删除指定 cid
|
||||
del_count = 0
|
||||
if deleted:
|
||||
for r in model.search([('world_id', '=', world.id)]):
|
||||
if r.cid in deleted:
|
||||
r.unlink()
|
||||
del_count += 1
|
||||
return _json({'ok': True, 'saved': len(out), 'deleted': del_count})
|
||||
cid = body.get('cid') or cid
|
||||
action = body.get('action')
|
||||
rec = self._creature_by_cid(world, cid)
|
||||
if not rec:
|
||||
return _json({'ok': False, 'error': 'char_not_found'})
|
||||
if action == 'equip':
|
||||
slot = body.get('slot')
|
||||
code = body.get('code')
|
||||
if slot not in ('weapon', 'armor', 'accessory'):
|
||||
return _json({'ok': False, 'error': 'bad_slot'})
|
||||
item = request.env['yt.world.equip.item'].sudo().search([('code', '=', code)], limit=1)
|
||||
if not item:
|
||||
return _json({'ok': False, 'error': 'item_not_found'})
|
||||
equip = dict(rec.equip or {})
|
||||
equip[slot] = code
|
||||
rec.write({'equip': equip})
|
||||
elif action == 'unequip':
|
||||
slot = body.get('slot')
|
||||
equip = dict(rec.equip or {})
|
||||
if slot in equip:
|
||||
equip.pop(slot, None)
|
||||
rec.write({'equip': equip})
|
||||
elif action == 'train':
|
||||
rec.write({
|
||||
'level': rec.level + 1,
|
||||
'exp': 0,
|
||||
'exp_max': rec.exp_max + 50,
|
||||
'hp_max': rec.hp_max + 10,
|
||||
'energy_max': rec.energy_max + 5,
|
||||
'attack': rec.attack + 2,
|
||||
'defense': rec.defense + 1,
|
||||
'speed': rec.speed + 1,
|
||||
'hp': rec.hp_max + 10,
|
||||
'energy': rec.energy_max + 5,
|
||||
})
|
||||
elif action == 'rest':
|
||||
rec.write({'status': 'resting', 'hp': rec.hp_max, 'energy': rec.energy_max})
|
||||
else:
|
||||
return _json({'ok': False, 'error': 'unknown_action'})
|
||||
return _json({'ok': True, 'char': rec.to_panel_dict()})
|
||||
|
||||
# ============================================================
|
||||
# 资源系统:库存 / 结算 / 离线囤积回收
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<record id="talent_ancient_blood" model="yt.codex.talent">
|
||||
<field name="code">talent_ancient_blood</field>
|
||||
<field name="name">先民血脉</field>
|
||||
<field name="icon">🩸</field>
|
||||
<field name="icon">assets/icons/talents/ancient_blood.png</field>
|
||||
<field name="quality">mythic</field>
|
||||
<field name="trigger">被动</field>
|
||||
<field name="effect">生命回复+20%;濒死时触发先民护盾,5秒内免伤</field>
|
||||
@@ -17,7 +17,7 @@
|
||||
<record id="talent_dragon_soul" model="yt.codex.talent">
|
||||
<field name="code">talent_dragon_soul</field>
|
||||
<field name="name">龙裔之魂</field>
|
||||
<field name="icon">🐉</field>
|
||||
<field name="icon">assets/icons/talents/dragon_soul.png</field>
|
||||
<field name="quality">mythic</field>
|
||||
<field name="trigger">被动</field>
|
||||
<field name="effect">火焰抗性+50%;攻击附带灼烧效果</field>
|
||||
@@ -31,7 +31,7 @@
|
||||
<record id="talent_star_resonance" model="yt.codex.talent">
|
||||
<field name="code">talent_star_resonance</field>
|
||||
<field name="name">星辰共鸣</field>
|
||||
<field name="icon">✨</field>
|
||||
<field name="icon">assets/icons/talents/star_resonance.png</field>
|
||||
<field name="quality">legendary</field>
|
||||
<field name="trigger">被动</field>
|
||||
<field name="effect">法力回复+15%;夜间额外+10%</field>
|
||||
@@ -45,7 +45,7 @@
|
||||
<record id="talent_beast_king" model="yt.codex.talent">
|
||||
<field name="code">talent_beast_king</field>
|
||||
<field name="name">兽王本能</field>
|
||||
<field name="icon">🐾</field>
|
||||
<field name="icon">assets/icons/talents/beast_king.png</field>
|
||||
<field name="quality">epic</field>
|
||||
<field name="trigger">被动</field>
|
||||
<field name="effect">驯养生物好感度+30%;生物产出+20%</field>
|
||||
@@ -59,7 +59,7 @@
|
||||
<record id="talent_smith_hand" model="yt.codex.talent">
|
||||
<field name="code">talent_smith_hand</field>
|
||||
<field name="name">铁匠之手</field>
|
||||
<field name="icon">🔨</field>
|
||||
<field name="icon">assets/icons/talents/smith_hand.png</field>
|
||||
<field name="quality">rare</field>
|
||||
<field name="trigger">被动</field>
|
||||
<field name="effect">加工品品质+1阶;加工成功率+10%</field>
|
||||
@@ -73,7 +73,7 @@
|
||||
<record id="talent_forest_walker" model="yt.codex.talent">
|
||||
<field name="code">talent_forest_walker</field>
|
||||
<field name="name">林间行者</field>
|
||||
<field name="icon">🌿</field>
|
||||
<field name="icon">assets/icons/talents/forest_walker.png</field>
|
||||
<field name="quality">rare</field>
|
||||
<field name="trigger">被动</field>
|
||||
<field name="effect">森林地形移速+25%;隐蔽+15%</field>
|
||||
@@ -87,7 +87,7 @@
|
||||
<record id="talent_ore_sense" model="yt.codex.talent">
|
||||
<field name="code">talent_ore_sense</field>
|
||||
<field name="name">矿脉感应</field>
|
||||
<field name="icon">⛏️</field>
|
||||
<field name="icon">assets/icons/talents/ore_sense.png</field>
|
||||
<field name="quality">uncommon</field>
|
||||
<field name="trigger">被动</field>
|
||||
<field name="effect">发现稀有矿脉概率+20%</field>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- 微观世界装备图鉴(单一真源):前端通过 /yt_world/api/micro/equip/catalog 拉取 -->
|
||||
|
||||
<!-- 武器 -->
|
||||
<record id="equip_iron_dagger" model="yt.world.equip.item">
|
||||
<field name="code">iron_dagger</field>
|
||||
<field name="name">铁匕首</field>
|
||||
<field name="icon">🗡️</field>
|
||||
<field name="slot">weapon</field>
|
||||
<field name="rarity">common</field>
|
||||
<field name="stats">{"attack": 3}</field>
|
||||
<field name="desc">朴实的铁刃,适合初学者的防身武器。</field>
|
||||
</record>
|
||||
|
||||
<record id="equip_steel_sword" model="yt.world.equip.item">
|
||||
<field name="code">steel_sword</field>
|
||||
<field name="name">精钢短剑</field>
|
||||
<field name="icon">⚔️</field>
|
||||
<field name="slot">weapon</field>
|
||||
<field name="rarity">rare</field>
|
||||
<field name="stats">{"attack": 7, "speed": 2}</field>
|
||||
<field name="desc">锻造精良的短剑,锋利且轻便。</field>
|
||||
</record>
|
||||
|
||||
<record id="equip_rune_blade" model="yt.world.equip.item">
|
||||
<field name="code">rune_blade</field>
|
||||
<field name="name">符文长剑</field>
|
||||
<field name="icon">🗡️</field>
|
||||
<field name="slot">weapon</field>
|
||||
<field name="rarity">epic</field>
|
||||
<field name="stats">{"attack": 12, "hp_max": 15}</field>
|
||||
<field name="desc">刻有古老符文的长剑,挥动时带有微光。</field>
|
||||
</record>
|
||||
|
||||
<!-- 护甲 -->
|
||||
<record id="equip_leather_vest" model="yt.world.equip.item">
|
||||
<field name="code">leather_vest</field>
|
||||
<field name="name">皮背心</field>
|
||||
<field name="icon">🦺</field>
|
||||
<field name="slot">armor</field>
|
||||
<field name="rarity">common</field>
|
||||
<field name="stats">{"defense": 2, "hp_max": 5}</field>
|
||||
<field name="desc">鞣制皮革制成的轻便护甲。</field>
|
||||
</record>
|
||||
|
||||
<record id="equip_iron_armor" model="yt.world.equip.item">
|
||||
<field name="code">iron_armor</field>
|
||||
<field name="name">铁鳞甲</field>
|
||||
<field name="icon">🛡️</field>
|
||||
<field name="slot">armor</field>
|
||||
<field name="rarity">rare</field>
|
||||
<field name="stats">{"defense": 6, "hp_max": 15}</field>
|
||||
<field name="desc">鳞状铁片编织的重甲,防护可靠。</field>
|
||||
</record>
|
||||
|
||||
<record id="equip_dragon_scale" model="yt.world.equip.item">
|
||||
<field name="code">dragon_scale</field>
|
||||
<field name="name">龙鳞甲</field>
|
||||
<field name="icon">🐉</field>
|
||||
<field name="slot">armor</field>
|
||||
<field name="rarity">legendary</field>
|
||||
<field name="stats">{"defense": 12, "hp_max": 30, "energy_max": 10}</field>
|
||||
<field name="desc">传说用古龙鳞片打造,沉重但几乎刀枪不入。</field>
|
||||
</record>
|
||||
|
||||
<!-- 饰品 -->
|
||||
<record id="equip_lucky_charm" model="yt.world.equip.item">
|
||||
<field name="code">lucky_charm</field>
|
||||
<field name="name">幸运符</field>
|
||||
<field name="icon">🍀</field>
|
||||
<field name="slot">accessory</field>
|
||||
<field name="rarity">common</field>
|
||||
<field name="stats">{"speed": 3}</field>
|
||||
<field name="desc">据说能带来好运的小饰物。</field>
|
||||
</record>
|
||||
|
||||
<record id="equip_power_ring" model="yt.world.equip.item">
|
||||
<field name="code">power_ring</field>
|
||||
<field name="name">力量戒指</field>
|
||||
<field name="icon">💍</field>
|
||||
<field name="slot">accessory</field>
|
||||
<field name="rarity">rare</field>
|
||||
<field name="stats">{"attack": 4, "defense": 2}</field>
|
||||
<field name="desc">镶嵌红色宝石的戒指,能感受到温热的力量。</field>
|
||||
</record>
|
||||
|
||||
<record id="equip_amulet_life" model="yt.world.equip.item">
|
||||
<field name="code">amulet_life</field>
|
||||
<field name="name">生命护符</field>
|
||||
<field name="icon">📿</field>
|
||||
<field name="slot">accessory</field>
|
||||
<field name="rarity">epic</field>
|
||||
<field name="stats">{"hp_max": 25, "energy_max": 15}</field>
|
||||
<field name="desc">蕴含生命能量的护符,佩戴者精力更充沛。</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,204 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- 世界生物种族模板:characters.js 与 yt.world.creature 的单一事实来源 -->
|
||||
<record id="world_race_human" model="yt.world.race">
|
||||
<field name="code">human</field>
|
||||
<field name="name">人族</field>
|
||||
<field name="icon">👤</field>
|
||||
<field name="sprite_walk">assets/characters/human/human_walk_1x6.png</field>
|
||||
<field name="sprite_idle">assets/characters/human/human_idle_attack_1x6.png</field>
|
||||
<field name="sprite_work">assets/characters/human/human_work_comm_1x6.png</field>
|
||||
<field name="hp_max">50</field>
|
||||
<field name="energy_max">100</field>
|
||||
<field name="attack">5</field>
|
||||
<field name="defense">3</field>
|
||||
<field name="speed">90</field>
|
||||
<field name="attack_range">60</field>
|
||||
<field name="regen">0</field>
|
||||
<field name="enrage">0</field>
|
||||
<field name="shield_ratio">0.0</field>
|
||||
<field name="trait">均衡型。各项能力标准,无特殊被动,适合新手。</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="active">1</field>
|
||||
</record>
|
||||
<record id="world_race_demon" model="yt.world.race">
|
||||
<field name="code">demon</field>
|
||||
<field name="name">妖族</field>
|
||||
<field name="icon">😈</field>
|
||||
<field name="sprite_walk">assets/characters/demon/demon_walk_1x6.png</field>
|
||||
<field name="sprite_idle">assets/characters/demon/demon_idle_attack_1x6.png</field>
|
||||
<field name="sprite_work">assets/characters/demon/demon_work_comm_1x6.png</field>
|
||||
<field name="hp_max">50</field>
|
||||
<field name="energy_max">100</field>
|
||||
<field name="attack">5</field>
|
||||
<field name="defense">3</field>
|
||||
<field name="speed">90</field>
|
||||
<field name="attack_range">60</field>
|
||||
<field name="regen">0</field>
|
||||
<field name="enrage">0</field>
|
||||
<field name="shield_ratio">0.0</field>
|
||||
<field name="trait">均衡型。无特殊被动,与人类齐平的基础战力。</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="active">1</field>
|
||||
</record>
|
||||
<record id="world_race_dwarf" model="yt.world.race">
|
||||
<field name="code">dwarf</field>
|
||||
<field name="name">矮人</field>
|
||||
<field name="icon">⛏️</field>
|
||||
<field name="sprite_walk">assets/characters/dwarf/dwarf_walk_1x6.png</field>
|
||||
<field name="sprite_idle">assets/characters/dwarf/dwarf_idle_attack_1x6.png</field>
|
||||
<field name="sprite_work">assets/characters/dwarf/dwarf_work_comm_1x6.png</field>
|
||||
<field name="hp_max">50</field>
|
||||
<field name="energy_max">100</field>
|
||||
<field name="attack">5</field>
|
||||
<field name="defense">5</field>
|
||||
<field name="speed">72</field>
|
||||
<field name="attack_range">60</field>
|
||||
<field name="regen">0</field>
|
||||
<field name="enrage">1</field>
|
||||
<field name="shield_ratio">0.10</field>
|
||||
<field name="trait">铁壁·狂暴:移速较慢(0.8倍);每损失10%生命攻击+5%;生命≤30%时获得10%最大生命护盾。</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="active">1</field>
|
||||
</record>
|
||||
<record id="world_race_elf" model="yt.world.race">
|
||||
<field name="code">elf</field>
|
||||
<field name="name">精灵</field>
|
||||
<field name="icon">🧝</field>
|
||||
<field name="sprite_walk">assets/characters/elf/elf_walk_1x6.png</field>
|
||||
<field name="sprite_idle">assets/characters/elf/elf_idle_attack_1x6.png</field>
|
||||
<field name="sprite_work">assets/characters/elf/elf_work_comm_1x6.png</field>
|
||||
<field name="hp_max">50</field>
|
||||
<field name="energy_max">100</field>
|
||||
<field name="attack">5</field>
|
||||
<field name="defense">2</field>
|
||||
<field name="speed">135</field>
|
||||
<field name="attack_range">60</field>
|
||||
<field name="regen">0</field>
|
||||
<field name="enrage">0</field>
|
||||
<field name="shield_ratio">0.0</field>
|
||||
<field name="trait">疾风:移速飞快(1.5倍),机动性最强的种族。</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="active">1</field>
|
||||
</record>
|
||||
<record id="world_race_dragon" model="yt.world.race">
|
||||
<field name="code">dragon</field>
|
||||
<field name="name">龙族</field>
|
||||
<field name="icon">🐉</field>
|
||||
<field name="sprite_walk">assets/characters/dragon/dragon_walk_1x6.png</field>
|
||||
<field name="sprite_idle">assets/characters/dragon/dragon_idle_attack_1x6.png</field>
|
||||
<field name="sprite_work">assets/characters/dragon/dragon_work_comm_1x6.png</field>
|
||||
<field name="hp_max">50</field>
|
||||
<field name="energy_max">100</field>
|
||||
<field name="attack">8</field>
|
||||
<field name="defense">6</field>
|
||||
<field name="speed">90</field>
|
||||
<field name="attack_range">90</field>
|
||||
<field name="regen">0</field>
|
||||
<field name="enrage">0</field>
|
||||
<field name="shield_ratio">0.0</field>
|
||||
<field name="trait">龙威:攻击力高(8),攻击范围大(1.5倍),爆发型输出种族。</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="active">1</field>
|
||||
</record>
|
||||
<record id="world_race_undead" model="yt.world.race">
|
||||
<field name="code">undead</field>
|
||||
<field name="name">不死族</field>
|
||||
<field name="icon">💀</field>
|
||||
<field name="sprite_walk">assets/characters/undead/undead_walk_1x6.png</field>
|
||||
<field name="sprite_idle">assets/characters/undead/undead_idle_attack_1x6.png</field>
|
||||
<field name="sprite_work">assets/characters/undead/undead_work_comm_1x6.png</field>
|
||||
<field name="hp_max">100</field>
|
||||
<field name="energy_max">100</field>
|
||||
<field name="attack">4</field>
|
||||
<field name="defense">3</field>
|
||||
<field name="speed">90</field>
|
||||
<field name="attack_range">60</field>
|
||||
<field name="regen">1</field>
|
||||
<field name="enrage">0</field>
|
||||
<field name="shield_ratio">0.0</field>
|
||||
<field name="trait">不死:生命极高(100);脱战10秒后每秒恢复5%最大生命。</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="active">1</field>
|
||||
</record>
|
||||
<record id="world_race_orc" model="yt.world.race">
|
||||
<field name="code">orc</field>
|
||||
<field name="name">兽人</field>
|
||||
<field name="icon">🐗</field>
|
||||
<field name="sprite_walk">assets/characters/orc/orc_walk_1x6.png</field>
|
||||
<field name="sprite_idle">assets/characters/orc/orc_idle_attack_1x6.png</field>
|
||||
<field name="sprite_work">assets/characters/orc/orc_work_comm_1x6.png</field>
|
||||
<field name="hp_max">50</field>
|
||||
<field name="energy_max">100</field>
|
||||
<field name="attack">5</field>
|
||||
<field name="defense">4</field>
|
||||
<field name="speed">90</field>
|
||||
<field name="attack_range">60</field>
|
||||
<field name="regen">0</field>
|
||||
<field name="enrage">0</field>
|
||||
<field name="shield_ratio">0.0</field>
|
||||
<field name="trait">均衡型。绿皮獠牙的战士,无特殊被动,基础战力与人类一致。</field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="active">1</field>
|
||||
</record>
|
||||
<record id="world_race_angel" model="yt.world.race">
|
||||
<field name="code">angel</field>
|
||||
<field name="name">天使</field>
|
||||
<field name="icon">✨</field>
|
||||
<field name="sprite_walk">assets/characters/angel/angel_walk_1x6.png</field>
|
||||
<field name="sprite_idle">assets/characters/angel/angel_idle_attack_1x6.png</field>
|
||||
<field name="sprite_work">assets/characters/angel/angel_work_comm_1x6.png</field>
|
||||
<field name="hp_max">50</field>
|
||||
<field name="energy_max">100</field>
|
||||
<field name="attack">5</field>
|
||||
<field name="defense">4</field>
|
||||
<field name="speed">90</field>
|
||||
<field name="attack_range">60</field>
|
||||
<field name="regen">0</field>
|
||||
<field name="enrage">0</field>
|
||||
<field name="shield_ratio">0.0</field>
|
||||
<field name="trait">均衡型。头顶光环的天界战士,无特殊被动,基础战力与人类一致。</field>
|
||||
<field name="sequence">80</field>
|
||||
<field name="active">1</field>
|
||||
</record>
|
||||
<record id="world_race_werewolf" model="yt.world.race">
|
||||
<field name="code">werewolf</field>
|
||||
<field name="name">狼人</field>
|
||||
<field name="icon">🐺</field>
|
||||
<field name="sprite_walk">assets/characters/werewolf/werewolf_walk_1x6.png</field>
|
||||
<field name="sprite_idle">assets/characters/werewolf/werewolf_idle_attack_1x6.png</field>
|
||||
<field name="sprite_work">assets/characters/werewolf/werewolf_work_comm_1x6.png</field>
|
||||
<field name="hp_max">50</field>
|
||||
<field name="energy_max">100</field>
|
||||
<field name="attack">5</field>
|
||||
<field name="defense">3</field>
|
||||
<field name="speed">90</field>
|
||||
<field name="attack_range">60</field>
|
||||
<field name="regen">0</field>
|
||||
<field name="enrage">0</field>
|
||||
<field name="shield_ratio">0.0</field>
|
||||
<field name="trait">均衡型。竖耳狼首的猎手,无特殊被动,基础战力与人类一致。</field>
|
||||
<field name="sequence">90</field>
|
||||
<field name="active">1</field>
|
||||
</record>
|
||||
<record id="world_race_golem" model="yt.world.race">
|
||||
<field name="code">golem</field>
|
||||
<field name="name">石傀儡</field>
|
||||
<field name="icon">🗿</field>
|
||||
<field name="sprite_walk">assets/characters/golem/golem_walk_1x6.png</field>
|
||||
<field name="sprite_idle">assets/characters/golem/golem_idle_attack_1x6.png</field>
|
||||
<field name="sprite_work">assets/characters/golem/golem_work_comm_1x6.png</field>
|
||||
<field name="hp_max">50</field>
|
||||
<field name="energy_max">100</field>
|
||||
<field name="attack">5</field>
|
||||
<field name="defense">8</field>
|
||||
<field name="speed">90</field>
|
||||
<field name="attack_range">60</field>
|
||||
<field name="regen">0</field>
|
||||
<field name="enrage">0</field>
|
||||
<field name="shield_ratio">0.0</field>
|
||||
<field name="trait">均衡型。眼中发光的岩石巨像,无特殊被动,基础战力与人类一致。</field>
|
||||
<field name="sequence">100</field>
|
||||
<field name="active">1</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,8 +1,8 @@
|
||||
from . import yt_world
|
||||
from . import yt_world_tile
|
||||
from . import yt_world_tile_micro
|
||||
from . import yt_world_micro_char
|
||||
from . import yt_world_build_item
|
||||
from . import yt_world_equip_item
|
||||
from . import yt_world_build_instance
|
||||
from . import yt_world_stock
|
||||
from . import yt_world_game
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""微观世界装备图鉴(单一真源)。前端通过 /yt_world/api/equip/catalog 拉取。
|
||||
|
||||
与 yt.world.creature 的 equip 字段({weapon,armor,accessory} = item.code)联动:
|
||||
creature.to_panel_dict() 会把装备 code 解析成这里 to_dict() 的完整对象返回前端。
|
||||
"""
|
||||
import json
|
||||
from odoo import models, fields, api
|
||||
|
||||
_STAT_LABELS = {
|
||||
'attack': '攻', 'defense': '防', 'hp_max': '生命',
|
||||
'energy_max': '能量', 'speed': '速',
|
||||
}
|
||||
|
||||
|
||||
class YtWorldEquipItem(models.Model):
|
||||
_name = 'yt.world.equip.item'
|
||||
_description = '世界生物装备'
|
||||
_order = 'slot, rarity, id'
|
||||
|
||||
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', '饰品')],
|
||||
string='部位', required=True)
|
||||
rarity = fields.Selection(
|
||||
[('common', '普通'), ('rare', '稀有'), ('epic', '史诗'), ('legendary', '传说')],
|
||||
string='稀有度', default='common')
|
||||
stats = fields.Json(string='属性加成', default=dict,
|
||||
help='{"attack":3,"hp_max":15} 等数值加成')
|
||||
desc = fields.Text(string='描述')
|
||||
active = fields.Boolean(string='启用', default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('uniq_code', 'unique(code)', '装备编码必须唯一'),
|
||||
]
|
||||
|
||||
def _bonus_text(self):
|
||||
stats = self.stats or {}
|
||||
if not isinstance(stats, dict):
|
||||
try:
|
||||
stats = json.loads(stats)
|
||||
except Exception:
|
||||
stats = {}
|
||||
return ' '.join('%s+%s' % (_STAT_LABELS.get(k, k), v)
|
||||
for k, v in stats.items())
|
||||
|
||||
def to_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'icon': self.icon or '⚔️',
|
||||
'slot': self.slot,
|
||||
'rarity': self.rarity,
|
||||
'stats': self.stats or {},
|
||||
'bonus': self._bonus_text(),
|
||||
'desc': self.desc or '',
|
||||
'active': self.active,
|
||||
}
|
||||
@@ -190,25 +190,42 @@ class YtWorldCharacter(models.Model):
|
||||
|
||||
class YtWorldCreature(models.Model):
|
||||
_name = 'yt.world.creature'
|
||||
_description = '世界生物(居民 / NPC)'
|
||||
_description = '世界生物'
|
||||
_order = 'id'
|
||||
|
||||
world_id = fields.Many2one('yt.world', string='世界', required=True,
|
||||
ondelete='cascade', index=True)
|
||||
name = fields.Char(string='名称', required=True)
|
||||
species = fields.Char(string='物种编码', index=True)
|
||||
# 与 characters.js 打通的核心字段
|
||||
cid = fields.Char(string='客户端ID', index=True,
|
||||
help='前端生成的生物唯一标识,用于 upsert 匹配(替代旧 partner.cid)')
|
||||
race = fields.Char(string='种族', default='human', index=True,
|
||||
help='human/demon/dwarf/elf/dragon/undead/orc/angel/werewolf/golem')
|
||||
x = fields.Float(string='X坐标', default=0)
|
||||
y = fields.Float(string='Y坐标', default=0)
|
||||
facing = fields.Integer(string='朝向', default=1, help='1=右 / -1=左')
|
||||
level = fields.Integer(string='等级', default=1)
|
||||
exp = fields.Integer(string='经验', default=0)
|
||||
hp = fields.Integer(string='当前生命', default=100)
|
||||
hp_max = fields.Integer(string='生命上限', default=100)
|
||||
exp_max = fields.Integer(string='升级所需经验', default=100)
|
||||
hp = fields.Integer(string='当前生命', default=50)
|
||||
hp_max = fields.Integer(string='生命上限', default=50)
|
||||
energy = fields.Integer(string='能量', default=100)
|
||||
energy_max = fields.Integer(string='能量上限', default=100)
|
||||
attack = fields.Integer(string='攻击', default=5)
|
||||
defense = fields.Integer(string='防御', default=3)
|
||||
speed = fields.Integer(string='速度', default=5)
|
||||
mood = fields.Char(string='心情', default='😊')
|
||||
job = fields.Char(string='职业', default='none')
|
||||
equip = fields.Json(string='装备', default=dict)
|
||||
status = fields.Char(string='工作状态', default='idle',
|
||||
help='idle/working/resting/moving')
|
||||
state = fields.Char(string='动作状态', default='idle',
|
||||
help='idle/walk/attack/work/communicate')
|
||||
equip = fields.Json(string='装备', default=dict,
|
||||
help='{weapon:code, armor:code, accessory:code}')
|
||||
equip_display = fields.Text(string='装备(可读)', compute='_compute_displays')
|
||||
tile_ref = fields.Json(string='所在地块', default=dict)
|
||||
tile_ref_display = fields.Text(string='所在地块(可读)', compute='_compute_displays')
|
||||
state = fields.Char(string='状态', default='resting')
|
||||
partner = fields.Json(string='相关对象', default=dict)
|
||||
partner_display = fields.Text(string='相关对象(可读)', compute='_compute_displays')
|
||||
|
||||
@@ -219,3 +236,129 @@ class YtWorldCreature(models.Model):
|
||||
r.tile_ref_display = _fmt_kv(r.tile_ref)
|
||||
r.partner_display = _fmt_kv(r.partner)
|
||||
|
||||
def _race_template(self):
|
||||
"""按 race 取 yt.world.race 模板;不存在则返回 None。"""
|
||||
self.ensure_one()
|
||||
if not self.race:
|
||||
return None
|
||||
return self.env['yt.world.race'].sudo().search(
|
||||
[('code', '=', self.race)], limit=1)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name') and vals.get('race'):
|
||||
rc = self.env['yt.world.race'].sudo().search(
|
||||
[('code', '=', vals['race'])], limit=1)
|
||||
vals['name'] = (rc.name + '居民') if rc else '居民'
|
||||
# 缺省数值从种族模板取,避免与前端/模板对不齐
|
||||
if 'hp_max' not in vals or not vals['hp_max']:
|
||||
rc = self.env['yt.world.race'].sudo().search(
|
||||
[('code', '=', vals.get('race', 'human'))], limit=1)
|
||||
base = rc and rc.to_base_dict() or {
|
||||
'hp_max': 50, 'energy_max': 100, 'attack': 5,
|
||||
'defense': 3, 'speed': 5}
|
||||
vals.setdefault('hp_max', base['hp_max'])
|
||||
vals.setdefault('energy_max', base['energy_max'])
|
||||
vals.setdefault('attack', base['attack'])
|
||||
vals.setdefault('defense', base['defense'])
|
||||
vals.setdefault('speed', base['speed'])
|
||||
vals.setdefault('hp', base['hp_max'])
|
||||
vals.setdefault('energy', base['energy_max'])
|
||||
return super(YtWorldCreature, self).create(vals_list)
|
||||
|
||||
def to_panel_dict(self):
|
||||
self.ensure_one()
|
||||
equip = self.equip or {}
|
||||
catalog = {}
|
||||
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():
|
||||
if code and code in catalog:
|
||||
equip_resolved[slot] = catalog[code]
|
||||
return {
|
||||
'oid': self.id,
|
||||
'cid': self.cid,
|
||||
'race': self.race,
|
||||
'name': self.name,
|
||||
'level': self.level,
|
||||
'exp': self.exp,
|
||||
'exp_max': self.exp_max,
|
||||
'hp': self.hp,
|
||||
'hp_max': self.hp_max,
|
||||
'energy': self.energy,
|
||||
'energy_max': self.energy_max,
|
||||
'attack': self.attack,
|
||||
'defense': self.defense,
|
||||
'speed': self.speed,
|
||||
'mood': self.mood,
|
||||
'job': self.job,
|
||||
'status': self.status,
|
||||
'state': self.state,
|
||||
'x': self.x,
|
||||
'y': self.y,
|
||||
'facing': self.facing,
|
||||
'tile_ref': self.tile_ref or {},
|
||||
'equip': equip_resolved,
|
||||
}
|
||||
|
||||
|
||||
class YtWorldRace(models.Model):
|
||||
_name = 'yt.world.race'
|
||||
_description = '世界生物种族模板'
|
||||
_order = 'sequence, id'
|
||||
|
||||
code = fields.Char(string='编码', required=True, index=True)
|
||||
name = fields.Char(string='名称', required=True)
|
||||
icon = fields.Char(string='图标', default='👤')
|
||||
sprite_walk = fields.Char(string='行走精灵图', default='')
|
||||
sprite_idle = fields.Char(string='待机/攻击精灵图', default='')
|
||||
sprite_work = fields.Char(string='工作精灵图', default='')
|
||||
hp_max = fields.Integer(string='生命上限', default=50)
|
||||
energy_max = fields.Integer(string='能量上限', default=100)
|
||||
attack = fields.Integer(string='攻击', default=5)
|
||||
defense = fields.Integer(string='防御', default=3)
|
||||
speed = fields.Integer(string='速度', default=5)
|
||||
attack_range = fields.Integer(string='攻击范围', default=60)
|
||||
regen = fields.Boolean(string='脱战回血', default=False)
|
||||
enrage = fields.Boolean(string='狂暴', default=False)
|
||||
shield_ratio = fields.Float(string='护盾比例', default=0.0)
|
||||
trait = fields.Text(string='特性文案')
|
||||
sequence = fields.Integer(string='排序', default=10)
|
||||
active = fields.Boolean(string='启用', default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('uniq_code', 'unique(code)', '种族编码必须唯一'),
|
||||
]
|
||||
|
||||
def to_base_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'hp_max': self.hp_max, 'energy_max': self.energy_max,
|
||||
'attack': self.attack, 'defense': self.defense,
|
||||
'speed': self.speed,
|
||||
}
|
||||
|
||||
def to_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'icon': self.icon,
|
||||
'sprite_walk': self.sprite_walk,
|
||||
'sprite_idle': self.sprite_idle,
|
||||
'sprite_work': self.sprite_work,
|
||||
'hp_max': self.hp_max,
|
||||
'energy_max': self.energy_max,
|
||||
'attack': self.attack,
|
||||
'defense': self.defense,
|
||||
'speed': self.speed,
|
||||
'attack_range': self.attack_range,
|
||||
'regen': self.regen,
|
||||
'enrage': self.enrage,
|
||||
'shield_ratio': self.shield_ratio,
|
||||
'trait': self.trait or '',
|
||||
'active': self.active,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class YtWorldMicroChar(models.Model):
|
||||
"""微观世界角色实例(玩家放置的种族角色,绑定用户世界)。
|
||||
|
||||
与建筑(build_items 存在 tile.micro JSON)不同,角色需要"绑定用户 +
|
||||
独立记录",故用独立模型:每条记录 = 一个放置在地图上的角色。
|
||||
通过 world_id 间接绑定 res.users(一个用户一个世界)。
|
||||
cid 为前端生成的角色唯一标识,用于 upsert 匹配(参照 yt.world.creature)。
|
||||
"""
|
||||
_name = 'yt.world.micro.char'
|
||||
_description = '微观世界角色'
|
||||
_order = 'id'
|
||||
|
||||
world_id = fields.Many2one('yt.world', string='所属世界',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
cid = fields.Char(string='客户端ID',
|
||||
help='前端生成的角色唯一标识,用于增量同步')
|
||||
race = fields.Char(string='种族', default='human',
|
||||
help='human/demon/dwarf/elf/dragon/undead')
|
||||
x = fields.Float(string='X坐标', default=0)
|
||||
y = fields.Float(string='Y坐标', default=0)
|
||||
facing = fields.Integer(string='朝向', default=1, help='1=右 / -1=左')
|
||||
state = fields.Char(string='动作状态', default='idle',
|
||||
help='idle/walk/attack/work/communicate')
|
||||
hp = fields.Integer(string='生命值', default=50,
|
||||
help='初始 50,被攻击扣 5,归零后角色消失')
|
||||
tile_ref = fields.Json(string='所在地块', default=dict,
|
||||
help='{q, r} 所在宏观地块坐标,角色按地块隔离')
|
||||
@@ -37,13 +37,16 @@ access_yt_world_character_user,yt.world.character user,model_yt_world_character,
|
||||
access_yt_world_character_public,yt.world.character public,model_yt_world_character,base.group_public,1,0,0,0
|
||||
access_yt_world_creature_user,yt.world.creature user,model_yt_world_creature,base.group_user,1,1,1,1
|
||||
access_yt_world_creature_public,yt.world.creature public,model_yt_world_creature,base.group_public,1,0,0,0
|
||||
access_yt_world_equip_item_user,yt.world.equip.item user,model_yt_world_equip_item,base.group_user,1,1,1,1
|
||||
access_yt_world_equip_item_public,yt.world.equip.item public,model_yt_world_equip_item,base.group_public,1,0,0,0
|
||||
access_yt_world_equip_item_portal,yt.world.equip.item portal,model_yt_world_equip_item,base.group_portal,1,1,1,1
|
||||
access_yt_world_race_user,yt.world.race user,model_yt_world_race,base.group_user,1,1,1,1
|
||||
access_yt_world_race_public,yt.world.race public,model_yt_world_race,base.group_public,1,1,1,1
|
||||
access_yt_world_race_portal,yt.world.race portal,model_yt_world_race,base.group_portal,1,1,1,1
|
||||
access_game_npc,game.npc user,model_game_npc,base.group_user,1,1,1,1
|
||||
access_game_npc_relationship,game.npc.relationship user,model_game_npc_relationship,base.group_user,1,1,1,1
|
||||
access_game_npc_behavior_log,game.npc.behavior.log user,model_game_npc_behavior_log,base.group_user,1,1,1,1
|
||||
access_game_npc_knowledge,game.npc.knowledge user,model_game_npc_knowledge,base.group_user,1,1,1,1
|
||||
access_yt_world_micro_char_user,yt.world.micro.char user,model_yt_world_micro_char,base.group_user,1,1,1,1
|
||||
access_yt_world_micro_char_public,yt.world.micro.char public,model_yt_world_micro_char,base.group_public,1,0,0,0
|
||||
access_yt_world_micro_char_portal,yt.world.micro.char portal,model_yt_world_micro_char,base.group_portal,1,0,0,0
|
||||
access_yt_world_build_instance_user,yt.world.build.instance user,model_yt_world_build_instance,base.group_user,1,1,1,1
|
||||
access_yt_world_build_instance_public,yt.world.build.instance public,model_yt_world_build_instance,base.group_public,1,1,1,1
|
||||
access_yt_world_build_instance_portal,yt.world.build.instance portal,model_yt_world_build_instance,base.group_portal,1,1,1,1
|
||||
|
||||
|
+99
-79
@@ -1,24 +1,56 @@
|
||||
// assets/characters.js — 微观世界角色动画系统(6种族×5动作)
|
||||
'use strict';
|
||||
|
||||
// ---- 种族精灵表配置(含种族数值:移速/攻击力/最大生命/恢复能力)----
|
||||
const RACE_CONFIG = {
|
||||
human: { name: '人族', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。各项能力标准,无特殊被动,适合新手。', walk: 'assets/characters/human/human_walk_1x6.png', idle_attack: 'assets/characters/human/human_idle_attack_1x6.png', work_comm: 'assets/characters/human/human_work_comm_1x6.png' },
|
||||
demon: { name: '妖族', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。无特殊被动,与人类齐平的基础战力。', walk: 'assets/characters/demon/demon_walk_1x6.png', idle_attack: 'assets/characters/demon/demon_idle_attack_1x6.png', work_comm: 'assets/characters/demon/demon_work_comm_1x6.png' },
|
||||
dwarf: { name: '矮人', speed: 72, attack: 5, hpMax: 50, regen: false, enrage: true, shieldRatio: 0.10, trait: '铁壁·狂暴:移速较慢(0.8倍);每损失10%生命攻击+5%;生命≤30%时获得10%最大生命护盾。', walk: 'assets/characters/dwarf/dwarf_walk_1x6.png', idle_attack: 'assets/characters/dwarf/dwarf_idle_attack_1x6.png', work_comm: 'assets/characters/dwarf/dwarf_work_comm_1x6.png' },
|
||||
elf: { name: '精灵', speed: 135, attack: 5, hpMax: 50, regen: false, trait: '疾风:移速飞快(1.5倍),机动性最强的种族。', walk: 'assets/characters/elf/elf_walk_1x6.png', idle_attack: 'assets/characters/elf/elf_idle_attack_1x6.png', work_comm: 'assets/characters/elf/elf_work_comm_1x6.png' },
|
||||
dragon: { name: '龙族', speed: 90, attack: 8, attackRange: 90, hpMax: 50, regen: false, trait: '龙威:攻击力高(8),攻击范围大(1.5倍),爆发型输出种族。', walk: 'assets/characters/dragon/dragon_walk_1x6.png', idle_attack: 'assets/characters/dragon/dragon_idle_attack_1x6.png', work_comm: 'assets/characters/dragon/dragon_work_comm_1x6.png' },
|
||||
undead: { name: '不死族', speed: 90, attack: 4, hpMax: 100, regen: true, trait: '不死:生命极高(100);脱战10秒后每秒恢复5%最大生命。', walk: 'assets/characters/undead/undead_walk_1x6.png', idle_attack: 'assets/characters/undead/undead_idle_attack_1x6.png', work_comm: 'assets/characters/undead/undead_work_comm_1x6.png' },
|
||||
orc: { name: '兽人', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。绿皮獠牙的战士,无特殊被动,基础战力与人类一致。', walk: 'assets/characters/orc/orc_walk_1x6.png', idle_attack: 'assets/characters/orc/orc_idle_attack_1x6.png', work_comm: 'assets/characters/orc/orc_work_comm_1x6.png' },
|
||||
angel: { name: '天使', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。头顶光环的天界战士,无特殊被动,基础战力与人类一致。', walk: 'assets/characters/angel/angel_walk_1x6.png', idle_attack: 'assets/characters/angel/angel_idle_attack_1x6.png', work_comm: 'assets/characters/angel/angel_work_comm_1x6.png' },
|
||||
werewolf:{ name: '狼人', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。竖耳狼首的猎手,无特殊被动,基础战力与人类一致。', walk: 'assets/characters/werewolf/werewolf_walk_1x6.png', idle_attack: 'assets/characters/werewolf/werewolf_idle_attack_1x6.png', work_comm: 'assets/characters/werewolf/werewolf_work_comm_1x6.png' },
|
||||
golem: { name: '石傀儡', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。眼中发光的岩石巨像,无特殊被动,基础战力与人类一致。', walk: 'assets/characters/golem/golem_walk_1x6.png', idle_attack: 'assets/characters/golem/golem_idle_attack_1x6.png', work_comm: 'assets/characters/golem/golem_work_comm_1x6.png' },
|
||||
};
|
||||
// ---- 种族精灵表与数值(单一事实来源 = Odoo yt.world.race,前端不再写死)----
|
||||
// RACE_CONFIG 初始为空,进入微观世界时由 loadRaces() 从 /api/races 填充。
|
||||
let RACE_CONFIG = {};
|
||||
|
||||
// 从 Odoo 拉取种族模板(含精灵图路径与全部数值),覆盖 RACE_CONFIG。
|
||||
// 失败则保持空表,raceStats 会退回安全默认值,绝不依赖前端写死数据。
|
||||
async function loadRaces() {
|
||||
if (typeof API === 'undefined' || !API.races) return false;
|
||||
try {
|
||||
const r = await API.races();
|
||||
if (!r || !r.ok || !Array.isArray(r.races)) return false;
|
||||
const map = {};
|
||||
r.races.forEach(rc => {
|
||||
map[rc.code] = {
|
||||
name: rc.name,
|
||||
icon: rc.icon || '👤',
|
||||
speed: rc.speed,
|
||||
attack: rc.attack,
|
||||
defense: rc.defense,
|
||||
hpMax: rc.hp_max,
|
||||
energyMax: rc.energy_max,
|
||||
attackRange: rc.attack_range,
|
||||
regen: !!rc.regen,
|
||||
enrage: !!rc.enrage,
|
||||
shieldRatio: rc.shield_ratio || 0,
|
||||
trait: rc.trait || '',
|
||||
walk: rc.sprite_walk,
|
||||
idle_attack: rc.sprite_idle,
|
||||
work_comm: rc.sprite_work,
|
||||
};
|
||||
});
|
||||
RACE_CONFIG = map;
|
||||
window.RACE_CONFIG = RACE_CONFIG;
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[loadRaces] 拉取种族模板失败,退回默认值', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function raceStats(race) {
|
||||
const rc = RACE_CONFIG[race] || RACE_CONFIG.human;
|
||||
if (!rc) {
|
||||
// 极端兜底:模板尚未加载时给通用安全值,不写死任何种族差异
|
||||
return { speed: 90, attack: 5, attackRange: CHAR_ATTACK_RANGE, hpMax: 50,
|
||||
regen: false, enrage: false, shieldRatio: 0, defense: 3 };
|
||||
}
|
||||
return {
|
||||
speed: rc.speed, attack: rc.attack, attackRange: rc.attackRange || CHAR_ATTACK_RANGE,
|
||||
speed: rc.speed, attack: rc.attack, defense: rc.defense,
|
||||
attackRange: rc.attackRange || CHAR_ATTACK_RANGE,
|
||||
hpMax: rc.hpMax, regen: !!rc.regen,
|
||||
enrage: !!rc.enrage, shieldRatio: rc.shieldRatio || 0,
|
||||
};
|
||||
@@ -178,8 +210,15 @@ function visibleMicroChars() {
|
||||
function _serializeChar(ch) {
|
||||
return {
|
||||
cid: ch.id, race: ch.race,
|
||||
name: ch.name,
|
||||
level: ch.level, exp: ch.exp, exp_max: ch.exp_max,
|
||||
hp: ch.hp, hp_max: ch.hpMax,
|
||||
energy: ch.energy, energy_max: ch.energy_max,
|
||||
attack: ch.attack, defense: ch.defense, speed: ch.speed,
|
||||
mood: ch.mood, job: ch.job, status: ch.status,
|
||||
x: Math.round(ch.x), y: Math.round(ch.y),
|
||||
facing: ch.facing, state: ch.state,
|
||||
equip: ch.equip || {},
|
||||
tile_ref: ch.tile || {},
|
||||
};
|
||||
}
|
||||
@@ -201,18 +240,22 @@ function scheduleSaveChars(delay) {
|
||||
charSaveTimer = setTimeout(saveMicroCharsNow, delay || 300);
|
||||
}
|
||||
|
||||
// 从后端拉取全部微观角色,填充 microChars(带 oid/tile 映射)
|
||||
// 从后端拉取全部微观角色(世界生物表),填充 microChars(带 oid/tile 映射)
|
||||
async function loadMicroChars() {
|
||||
if (typeof API === 'undefined' || !API.microChars) return;
|
||||
const res = await API.microChars();
|
||||
if (!res || !res.ok) return;
|
||||
const list = res.chars || [];
|
||||
const list = res.creatures || res.chars || [];
|
||||
// 本地仍存在的角色(未保存成功/临时态)优先保留,其余用服务器数据重建
|
||||
const localById = {};
|
||||
microChars.forEach(ch => { if (ch.oid) localById[ch.oid] = ch; });
|
||||
microChars = list.map(c => {
|
||||
const local = localById[c.oid];
|
||||
const stats = raceStats(c.race || 'human');
|
||||
const hpMax = (typeof c.hp_max === 'number') ? c.hp_max : (stats.hpMax || 50);
|
||||
const hp = (typeof c.hp === 'number' && c.hp > 0) ? c.hp : hpMax;
|
||||
const enMax = (typeof c.energy_max === 'number') ? c.energy_max : (stats.energyMax || 100);
|
||||
const en = (typeof c.energy === 'number') ? c.energy : enMax;
|
||||
return {
|
||||
id: c.cid || ('char_' + Math.random().toString(36).slice(2, 10)),
|
||||
oid: c.oid, race: c.race || 'human',
|
||||
@@ -221,13 +264,21 @@ async function loadMicroChars() {
|
||||
targetX: c.x, targetY: c.y,
|
||||
speed: stats.speed, selected: false, trail: [],
|
||||
tile: c.tile_ref || {},
|
||||
hp: (typeof c.hp === 'number' && c.hp > 0) ? c.hp : stats.hpMax,
|
||||
hpMax: stats.hpMax,
|
||||
attack: stats.attack,
|
||||
// 后端权威数值(训练/装备/战斗后的真实值)
|
||||
name: c.name || ((RACE_CONFIG[c.race] && RACE_CONFIG[c.race].name) || '居民') + '居民',
|
||||
level: (typeof c.level === 'number') ? c.level : 1,
|
||||
exp: (typeof c.exp === 'number') ? c.exp : 0,
|
||||
exp_max: (typeof c.exp_max === 'number') ? c.exp_max : 100,
|
||||
hp: hp,
|
||||
hpMax: hpMax,
|
||||
energy: en,
|
||||
energy_max: enMax,
|
||||
attack: (typeof c.attack === 'number') ? c.attack : (stats.attack || 5),
|
||||
defense: (typeof c.defense === 'number') ? c.defense : (stats.defense || 3),
|
||||
attackRange: stats.attackRange,
|
||||
regen: stats.regen,
|
||||
enrage: stats.enrage,
|
||||
shieldMax: stats.shieldRatio ? Math.max(1, Math.round(stats.hpMax * stats.shieldRatio)) : 0,
|
||||
shieldMax: stats.shieldRatio ? Math.max(1, Math.round(hpMax * stats.shieldRatio)) : 0,
|
||||
shield: 0,
|
||||
enrageTriggered: false,
|
||||
lastHurt: 0,
|
||||
@@ -238,18 +289,21 @@ async function loadMicroChars() {
|
||||
wanderInterval: 2 + Math.random() * 4,
|
||||
_userMove: false,
|
||||
freeMove: true, // 自由移动开关(默认开;面板按钮可切换)
|
||||
equip: c.equip || {},
|
||||
...(local ? { frame: local.frame, frameT: local.frameT } : {}),
|
||||
};
|
||||
});
|
||||
if (selectedCharId && !getChar(selectedCharId)) selectedCharId = null;
|
||||
window.microChars = microChars;
|
||||
updateCharPanel();
|
||||
updateCharPlacePanel();
|
||||
}
|
||||
|
||||
function createChar(race, x, y) {
|
||||
const id = 'char_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 5);
|
||||
const stats = raceStats(race);
|
||||
const rt = RACE_CONFIG[race] || {};
|
||||
const hpMax = stats.hpMax || 50;
|
||||
const enMax = stats.energyMax || 100;
|
||||
const ch = {
|
||||
id, race, x, y, targetX: x, targetY: y,
|
||||
state: 'idle', frame: 0, frameT: 0,
|
||||
@@ -258,24 +312,31 @@ function createChar(race, x, y) {
|
||||
trail: [],
|
||||
oid: null,
|
||||
tile: microCharCurrentTile() || {},
|
||||
hp: stats.hpMax, // 生命值(满血)
|
||||
hpMax: stats.hpMax, // 最大生命(种族差异)
|
||||
attack: stats.attack, // 攻击力(种族差异)
|
||||
attackRange: stats.attackRange, // 攻击范围(龙族 1.5 倍 = 90px)
|
||||
regen: stats.regen, // 是否有脱战恢复
|
||||
enrage: stats.enrage, // 是否狂暴(矮人:掉血加攻)
|
||||
shieldMax: stats.shieldRatio ? Math.max(1, Math.round(stats.hpMax * stats.shieldRatio)) : 0, // 护盾上限(矮人 10% 最大生命)
|
||||
shield: 0, // 当前护盾值
|
||||
enrageTriggered: false, // 护盾是否已触发(血量≤30%)
|
||||
lastHurt: 0, // 上次受伤时间(ms)
|
||||
hurtFlash: 0, // 受伤闪白计时
|
||||
dying: false, // 死亡动画中
|
||||
deathT: 0, // 死亡动画进度(秒)
|
||||
name: (rt.name || '居民') + '居民', // 名称(来自后端种族模板)
|
||||
level: 1, exp: 0, exp_max: 100,
|
||||
hp: hpMax, // 生命值(满血)
|
||||
hpMax: hpMax, // 最大生命(种族差异,来自模板)
|
||||
energy: enMax, // 能量(满)
|
||||
energy_max: enMax, // 能量上限(种族差异,来自模板)
|
||||
attack: stats.attack, // 攻击力(种族差异,来自模板)
|
||||
defense: stats.defense, // 防御力(种族差异,来自模板)
|
||||
attackRange: stats.attackRange, // 攻击范围(龙族 1.5 倍 = 90px)
|
||||
regen: stats.regen, // 是否有脱战恢复
|
||||
enrage: stats.enrage, // 是否狂暴(矮人:掉血加攻)
|
||||
shieldMax: stats.shieldRatio ? Math.max(1, Math.round(hpMax * stats.shieldRatio)) : 0, // 护盾上限(矮人 10% 最大生命)
|
||||
shield: 0, // 当前护盾值
|
||||
enrageTriggered: false, // 护盾是否已触发(血量≤30%)
|
||||
lastHurt: 0, // 上次受伤时间(ms)
|
||||
hurtFlash: 0, // 受伤闪白计时
|
||||
dying: false, // 死亡动画中
|
||||
deathT: 0, // 死亡动画进度(秒)
|
||||
mood: '😊', job: 'none', status: 'idle',
|
||||
equip: {},
|
||||
// 自主漫游状态
|
||||
wanderT: Math.random() * 3, // 首次漫游前随机等待
|
||||
wanderT: Math.random() * 3, // 首次漫游前随机等待
|
||||
wanderInterval: 2 + Math.random() * 4, // 2~6 秒
|
||||
_userMove: false, // 是否用户指令移动
|
||||
freeMove: charPlaceFreeMove, // 自由移动开关(默认按右侧面板"自由移动"按钮)
|
||||
_userMove: false, // 是否用户指令移动
|
||||
freeMove: charPlaceFreeMove, // 自由移动开关(默认开)
|
||||
};
|
||||
microChars.push(ch);
|
||||
selectChar(id);
|
||||
@@ -900,48 +961,8 @@ function updateCharPanel() {
|
||||
panel.querySelector('#charDelBtn').addEventListener('click', () => deleteChar(ch.id));
|
||||
}
|
||||
|
||||
// ---- 放置面板(右侧建筑面板"角色"分类下) ----
|
||||
// 新放置角色默认是否自由移动(面板上的自由移动按钮切换此值)
|
||||
// 新放置角色默认是否自由移动(由选中角色面板上的自由移动按钮间接控制)
|
||||
let charPlaceFreeMove = true;
|
||||
function updateCharPlacePanel() {
|
||||
const panel = document.getElementById('charPlacePanel');
|
||||
if (!panel) return;
|
||||
const races = Object.keys(RACE_CONFIG);
|
||||
const fmBtnStyle = charPlaceFreeMove
|
||||
? 'background:#1d4a2a;color:#7ef0a0;border:1px solid #3a8a5e;'
|
||||
: 'background:#3a3a4a;color:#aab;border:1px solid #5a5a7a;';
|
||||
panel.innerHTML = `
|
||||
<div style="font-size:12px;color:#8a9aaa;margin-bottom:6px;">放置角色</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||||
${races.map(r => {
|
||||
const active = charPlaceRace === r && charPlaceMode;
|
||||
const style = active
|
||||
? 'background:#2a4a6a;color:#cde;border:1px solid #5a9acc;'
|
||||
: 'background:#1d2b4a;color:#cde;border:1px solid #3a5a8e;';
|
||||
return `<button class="charRaceBtn" data-race="${r}" style="${style}padding:5px 10px;border-radius:5px;cursor:pointer;font-size:12px;">${RACE_CONFIG[r].name}</button>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<div style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap;">
|
||||
<button id="charFreeMoveToggle" style="${fmBtnStyle}padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px;">${charPlaceFreeMove ? '⏸ 停止自由移动' : '▶ 自由移动'}</button>
|
||||
<button id="charClearAll" style="background:#4a1e1e;color:#faa;border:1px solid #8a3a3a;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px;">取消全部</button>
|
||||
</div>
|
||||
`;
|
||||
panel.querySelectorAll('.charRaceBtn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
charPlaceRace = btn.dataset.race;
|
||||
charPlaceMode = true; // 点击种族直接进入放置模式(简化:无需再点"开始放置")
|
||||
updateCharPlacePanel();
|
||||
});
|
||||
});
|
||||
panel.querySelector('#charFreeMoveToggle').addEventListener('click', () => {
|
||||
charPlaceFreeMove = !charPlaceFreeMove;
|
||||
updateCharPlacePanel();
|
||||
});
|
||||
panel.querySelector('#charClearAll').addEventListener('click', () => {
|
||||
clearChars();
|
||||
updateCharPlacePanel();
|
||||
});
|
||||
}
|
||||
|
||||
// 导出到 window
|
||||
window.createChar = createChar;
|
||||
@@ -955,7 +976,6 @@ window.renderChars = renderChars;
|
||||
window.hitChar = hitChar;
|
||||
window.clampCharToIsland = clampCharToIsland;
|
||||
window.updateCharPanel = updateCharPanel;
|
||||
window.updateCharPlacePanel = updateCharPlacePanel;
|
||||
window.allSheetsLoaded = allSheetsLoaded;
|
||||
window.loadCharSheet = loadCharSheet;
|
||||
window.preloadAllSheets = preloadAllSheets;
|
||||
|
||||
+43
-10
@@ -230,6 +230,41 @@ const API = {
|
||||
return await r.json();
|
||||
} catch (e) { return null; }
|
||||
},
|
||||
async races() {
|
||||
try {
|
||||
const r = await fetch(_worldUrl('/yt_world/api/races'), { credentials: _wcred });
|
||||
if (!r.ok) return { ok: false, error: 'http_' + r.status };
|
||||
const j = await r.json();
|
||||
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
|
||||
} catch (e) { return { ok: false, error: 'network' }; }
|
||||
},
|
||||
async equipCatalog() {
|
||||
try {
|
||||
const r = await fetch(_worldUrl('/yt_world/api/equip/catalog'), { credentials: _wcred });
|
||||
if (!r.ok) return { ok: false, error: 'http_' + r.status };
|
||||
const j = await r.json();
|
||||
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
|
||||
} catch (e) { return { ok: false, error: 'network' }; }
|
||||
},
|
||||
async creature(cid) {
|
||||
try {
|
||||
const r = await fetch(_worldUrl('/yt_world/api/creature/' + encodeURIComponent(cid)), { credentials: _wcred });
|
||||
if (!r.ok) return { ok: false, error: 'http_' + r.status };
|
||||
const j = await r.json();
|
||||
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
|
||||
} catch (e) { return { ok: false, error: 'network' }; }
|
||||
},
|
||||
async creatureAction(payload) {
|
||||
try {
|
||||
const r = await fetch(_worldUrl('/yt_world/api/creature/action'), {
|
||||
method: 'POST', credentials: _wcred,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload || {}),
|
||||
});
|
||||
const j = await r.json();
|
||||
return j && j.ok === false ? j : (j || { ok: true });
|
||||
} catch (e) { return { ok: false, error: 'network' }; }
|
||||
},
|
||||
async buildingAction(payload) {
|
||||
try {
|
||||
const r = await fetch(_worldUrl('/yt_world/api/building/action'), {
|
||||
@@ -244,22 +279,16 @@ const API = {
|
||||
}
|
||||
},
|
||||
// ---------- 微观世界角色(玩家放置的种族角色,绑定用户持久化) ----------
|
||||
// 以下两个已重定向到世界生物表(yt.world.creature),保留旧名以兼容 characters.js
|
||||
async microChars() {
|
||||
try {
|
||||
const r = await fetch(_worldUrl('/yt_world/api/micro/chars'), { credentials: _wcred });
|
||||
if (!r.ok) return { ok: false, error: 'http_' + r.status };
|
||||
const j = await r.json();
|
||||
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
|
||||
} catch (e) {
|
||||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||||
}
|
||||
return this.creatures();
|
||||
},
|
||||
async saveMicroChars(chars, deleted) {
|
||||
try {
|
||||
const r = await fetch(_worldUrl('/yt_world/api/micro/chars/save'), {
|
||||
const r = await fetch(_worldUrl('/yt_world/api/creatures/save'), {
|
||||
method: 'POST', credentials: _wcred,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chars: chars || [], deleted: deleted || [] }),
|
||||
body: JSON.stringify({ creatures: chars || [], deleted: deleted || [] }),
|
||||
});
|
||||
const j = await r.json();
|
||||
return j && j.ok === false ? j : (j || { ok: true });
|
||||
@@ -267,6 +296,10 @@ const API = {
|
||||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||||
}
|
||||
},
|
||||
// 以下旧名重定向到世界生物表(yt.world.creature),保留以兼容历史调用
|
||||
async microEquipCatalog() { return this.equipCatalog(); },
|
||||
async microChar(cid) { return this.creature(cid); },
|
||||
async microCharAction(payload) { return this.creatureAction(payload); },
|
||||
// ---------- 图鉴(codex) ----------
|
||||
async codexResources() {
|
||||
return (await fetch(_worldUrl('/yt_world/api/codex/resources'), { credentials: _wcred })).json();
|
||||
|
||||
+20
-9
@@ -103,6 +103,7 @@ body::before{
|
||||
.card:hover{transform:translateY(-3px);box-shadow:0 8px 24px rgba(0,0,0,.4);}
|
||||
.card:hover::before{opacity:1;}
|
||||
.card .icon{font-size:36px;line-height:1.2;margin-bottom:2px;filter:drop-shadow(0 2px 4px rgba(0,0,0,.3));}
|
||||
.card .icon img.ic-img{width:46px;height:46px;object-fit:contain;display:block;margin:2px auto 0;filter:drop-shadow(0 2px 4px rgba(0,0,0,.3));}
|
||||
.card .name{font-size:14px;font-weight:700;color:#e6f0fb;line-height:1.3;}
|
||||
.card .sub{font-size:11px;color:#7b8ea8;line-height:1.3;}
|
||||
|
||||
@@ -190,7 +191,8 @@ body::before{
|
||||
.md-close:hover{background:rgba(239,68,68,.2);color:#ffb4b4;}
|
||||
|
||||
.md-header{display:flex;align-items:center;gap:16px;margin-bottom:20px;position:relative;}
|
||||
.md-icon{font-size:52px;line-height:1;}
|
||||
.md-icon{font-size:52px;line-height:1;display:flex;align-items:center;justify-content:center;}
|
||||
.md-icon img{width:52px;height:52px;object-fit:contain;filter:drop-shadow(0 2px 4px rgba(0,0,0,.3));}
|
||||
.md-info{flex:1;}
|
||||
.md-title{font-size:22px;font-weight:800;color:#eef2fb;}
|
||||
|
||||
@@ -552,13 +554,13 @@ function renderGrid(){
|
||||
div.classList.add('q-'+e.qual);
|
||||
div.innerHTML = `
|
||||
<span class="qbadge ${e.qual}">${QUALITY_NAMES[e.qual]}</span>
|
||||
<div class="icon">${e.icon}</div>
|
||||
<div class="icon">${iconHTML(e.icon)}</div>
|
||||
<div class="name">${e.name}</div>
|
||||
`;
|
||||
} else if(e.type==='creature'){
|
||||
div.classList.add('creature');
|
||||
div.innerHTML = `
|
||||
<div class="icon">${e.icon}</div>
|
||||
<div class="icon">${iconHTML(e.icon)}</div>
|
||||
<div class="name">${e.name}</div>
|
||||
<div class="tags">
|
||||
<span class="tag">${e.hab||''}</span>
|
||||
@@ -570,7 +572,7 @@ function renderGrid(){
|
||||
div.classList.add('monster','tier-'+e.tier);
|
||||
div.innerHTML = `
|
||||
<span class="tierbadge ${e.tier}">${TIER_NAMES[e.tier]}</span>
|
||||
<div class="icon">${e.icon}</div>
|
||||
<div class="icon">${iconHTML(e.icon)}</div>
|
||||
<div class="name">${e.name}</div>
|
||||
<div class="tags">
|
||||
<span class="tag">Lv ${e.levelMin}-${e.levelMax}</span>
|
||||
@@ -580,7 +582,7 @@ function renderGrid(){
|
||||
} else if(e.type==='race'){
|
||||
div.classList.add('race');
|
||||
div.innerHTML = `
|
||||
<div class="icon">${e.icon}</div>
|
||||
<div class="icon">${iconHTML(e.icon)}</div>
|
||||
<div class="name">${e.name}</div>
|
||||
<div class="tags">
|
||||
<span class="tag">${e.layer||''}</span>
|
||||
@@ -591,7 +593,7 @@ function renderGrid(){
|
||||
const mine = e.owned ? `<span class="tag" style="background:rgba(139,92,246,.25);color:#d0bcff;">Lv ${e.level}</span>` : '';
|
||||
div.innerHTML = `
|
||||
<span class="qbadge ${e.qual}">${QUALITY_NAMES[e.qual]}</span>
|
||||
<div class="icon">${e.icon}</div>
|
||||
<div class="icon">${iconHTML(e.icon)}</div>
|
||||
<div class="name">${e.name}</div>
|
||||
<div class="tags">
|
||||
<span class="tag">${e.trigger||'被动'}</span>
|
||||
@@ -602,7 +604,7 @@ function renderGrid(){
|
||||
div.classList.add('q-'+e.qual, 'skill_book');
|
||||
div.innerHTML = `
|
||||
<span class="qbadge ${e.qual}">${QUALITY_NAMES[e.qual]}</span>
|
||||
<div class="icon">${e.icon}</div>
|
||||
<div class="icon">${iconHTML(e.icon)}</div>
|
||||
<div class="name">${e.name}</div>
|
||||
<div class="tags">
|
||||
<span class="tag">${e.skillType||'主动'}</span>
|
||||
@@ -612,7 +614,7 @@ function renderGrid(){
|
||||
} else if(e.type==='book'){
|
||||
div.classList.add('book');
|
||||
div.innerHTML = `
|
||||
<div class="icon">${e.icon}</div>
|
||||
<div class="icon">${iconHTML(e.icon)}</div>
|
||||
<div class="name">${e.name}</div>
|
||||
<div class="tags">
|
||||
<span class="tag">${e.bookType||'其他'}</span>
|
||||
@@ -631,6 +633,15 @@ function renderGrid(){
|
||||
|
||||
function updateStats(){}
|
||||
|
||||
// 图标渲染:图片路径→<img>,否则原样(emoji)
|
||||
function iconHTML(ic){
|
||||
if(!ic) return '';
|
||||
if(/^(assets\/|\/)|\.(png|webp|jpe?g|gif|svg)(\?.*)?$/i.test(ic)){
|
||||
return '<img class="ic-img" src="'+ic+'" alt="">';
|
||||
}
|
||||
return ic;
|
||||
}
|
||||
|
||||
|
||||
// =====================================================================
|
||||
// 详 情 弹 窗
|
||||
@@ -649,7 +660,7 @@ function showDetail(e){
|
||||
|
||||
// 头部
|
||||
html += `<div class="md-header">`;
|
||||
html += `<div class="md-icon">${e.icon}</div>`;
|
||||
html += `<div class="md-icon">${iconHTML(e.icon)}</div>`;
|
||||
html += `<div class="md-info"><div class="md-title">${e.name}</div><div class="md-meta">`;
|
||||
if(e.type==='resource' || e.type==='talent' || e.type==='skill_book'){
|
||||
const c = e.qual==='common'?'156,163,175':e.qual==='uncommon'?'34,197,94':e.qual==='rare'?'59,130,246':e.qual==='epic'?'168,85,247':e.qual==='legendary'?'245,158,11':'220,38,38';
|
||||
|
||||
+199
-84
@@ -804,6 +804,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
|
||||
.tp-list{display:flex;flex-direction:column;gap:12px;}
|
||||
.tp-item{display:flex;gap:14px;padding:12px 14px;border-radius:12px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);}
|
||||
.tp-ico{font-size:34px;line-height:1;flex:0 0 auto;}
|
||||
.tp-ico img.ic-img{width:40px;height:40px;object-fit:contain;display:block;}
|
||||
.tp-body{flex:1;min-width:0;}
|
||||
.tp-name{font-size:16px;font-weight:700;color:#eef2fb;display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
|
||||
.tp-state{font-size:11px;padding:1px 8px;border-radius:6px;}
|
||||
@@ -845,6 +846,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
|
||||
.td-back:after{content:"";position:absolute;inset:10px;border:1px dashed rgba(167,139,250,.35);border-radius:10px;}
|
||||
.td-front{transform:rotateY(180deg);background:linear-gradient(180deg,rgba(20,26,46,.98),rgba(12,16,30,.98));border:1px solid rgba(255,255,255,.12);}
|
||||
.td-front .td-ico{font-size:40px;line-height:1;}
|
||||
.td-front .td-ico img.ic-img{width:46px;height:46px;object-fit:contain;display:block;margin:0 auto;}
|
||||
.td-front .td-q{font-size:11px;letter-spacing:1px;margin:7px 0 2px;font-weight:700;}
|
||||
.td-front .td-name{font-size:15px;font-weight:800;color:#fff;}
|
||||
.td-front .td-eff{font-size:11px;color:#cdd8e6;line-height:1.5;margin-top:6px;padding:0 4px;}
|
||||
@@ -1072,7 +1074,6 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
|
||||
</div>
|
||||
<div class="panel-section">
|
||||
<div class="panel-label">角色</div>
|
||||
<div id="charPlacePanel" style="margin-bottom:8px;"></div>
|
||||
<div id="charPanel" style="display:none;padding:8px;background:rgba(100,170,255,0.06);border:1px solid rgba(100,170,255,0.18);border-radius:6px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2045,25 +2046,26 @@ const BUILD_INTERACT = {
|
||||
] }
|
||||
};
|
||||
|
||||
// 装备目录:每个槽位的候选装备(图标 + 简短加成,仅作展示/穿戴)。
|
||||
// 穿戴写入生物 equip(持久化到 Odoo yt.world.creature),并联动角色背包。
|
||||
const EQUIP_CATALOG = {
|
||||
// 装备目录:后端 /yt_world/api/micro/equip/catalog 为单一真源;
|
||||
// 下面常量仅作极端兜底(未登录/网络不可达时显示)。
|
||||
const EQUIP_CATALOG_FALLBACK = {
|
||||
weapon: [
|
||||
{ code: 'iron_sword', name: '铁剑', icon: '🗡️', bonus: '攻+5' },
|
||||
{ code: 'wood_bow', name: '木弓', icon: '🏹', bonus: '攻+3' },
|
||||
{ code: 'stone_axe', name: '石斧', icon: '🪓', bonus: '攻+2' },
|
||||
{ code: 'iron_dagger', name: '铁匕首', icon: '🗡️', bonus: '攻+3' },
|
||||
{ code: 'steel_sword', name: '精钢短剑', icon: '⚔️', bonus: '攻+7 速+2' },
|
||||
{ code: 'rune_blade', name: '符文长剑', icon: '🗡️', bonus: '攻+12 血+15' },
|
||||
],
|
||||
armor: [
|
||||
{ code: 'leather_armor', name: '皮甲', icon: '🛡️', bonus: '防+4' },
|
||||
{ code: 'cloth_robe', name: '布袍', icon: '🥋', bonus: '防+2' },
|
||||
{ code: 'iron_plate', name: '铁甲', icon: '🪖', bonus: '防+6' },
|
||||
{ code: 'leather_vest', name: '皮背心', icon: '🦺', bonus: '防+2 血+5' },
|
||||
{ code: 'iron_armor', name: '铁鳞甲', icon: '🛡️', bonus: '防+6 血+15' },
|
||||
{ code: 'dragon_scale', name: '龙鳞甲', icon: '🐉', bonus: '防+12 血+30 能+10' },
|
||||
],
|
||||
accessory: [
|
||||
{ code: 'lucky_charm', name: '护符', icon: '🔮', bonus: '运+3' },
|
||||
{ code: 'speed_boots', name: '疾行靴', icon: '👢', bonus: '速+3' },
|
||||
{ code: 'power_ring', name: '力量戒', icon: '💍', bonus: '力+4' },
|
||||
{ code: 'lucky_charm', name: '幸运符', icon: '🍀', bonus: '速+3' },
|
||||
{ code: 'power_ring', name: '力量戒指', icon: '💍', bonus: '攻+4 防+2' },
|
||||
{ code: 'amulet_life', name: '生命护符', icon: '📿', bonus: '血+25 能+15' },
|
||||
],
|
||||
};
|
||||
let EQUIP_CATALOG_SERVER = null;
|
||||
|
||||
// 全部可建造项(道路/地面 + 建筑/设施),作为面板/渲染的单一来源
|
||||
function microAllBuildItems() { return MICRO_BUILD_ITEMS.concat(MICRO_BUILD_EXTRA); }
|
||||
@@ -2110,10 +2112,31 @@ async function microLoadCatalog() {
|
||||
} catch (e) { /* 未登录/网络不可达:保留兜底常量 */ }
|
||||
}
|
||||
|
||||
// 从 Odoo 拉取装备目录(单一真源)
|
||||
let microEquipCatalogLoaded = false;
|
||||
async function microLoadEquipCatalog() {
|
||||
if (microEquipCatalogLoaded) return;
|
||||
try {
|
||||
const r = await API.equipCatalog();
|
||||
if (r && r.ok && Array.isArray(r.items)) {
|
||||
const bySlot = { weapon: [], armor: [], accessory: [] };
|
||||
r.items.forEach(it => {
|
||||
if (!bySlot[it.slot]) bySlot[it.slot] = [];
|
||||
bySlot[it.slot].push(it);
|
||||
});
|
||||
EQUIP_CATALOG_SERVER = bySlot;
|
||||
microEquipCatalogLoaded = true;
|
||||
}
|
||||
} catch (e) { /* 保留兜底 */ }
|
||||
}
|
||||
function microEquipCatalog() {
|
||||
return EQUIP_CATALOG_SERVER || EQUIP_CATALOG_FALLBACK;
|
||||
}
|
||||
|
||||
// ============ 游戏化系统:角色 / 生物(与 Odoo 后端打通) ============
|
||||
async function microLoadGame() {
|
||||
try {
|
||||
const [c, cr] = await Promise.all([API.char(), API.creatures()]);
|
||||
const [c, cr] = await Promise.all([API.char(), API.creatures(), microLoadEquipCatalog()]);
|
||||
microChar = (c && c.ok !== false) ? c : (c || null);
|
||||
if (microChar && !microChar.equip) microChar.equip = { weapon: null, armor: null, accessory: null };
|
||||
if (microChar && !microChar.inventory) microChar.inventory = {};
|
||||
@@ -3519,11 +3542,17 @@ function enterMicroWorld(tile) {
|
||||
// 资源系统:登录时一次性结算离线产出并兜底回库,再启动在线自动进账
|
||||
microSettle(true, true).then(() => microRefreshResources());
|
||||
startResourceLoop();
|
||||
// 初始化角色动画系统
|
||||
if (typeof preloadAllSheets === 'function') preloadAllSheets();
|
||||
if (typeof updateCharPlacePanel === 'function') updateCharPlacePanel();
|
||||
// 初始化角色动画系统:先动态加载种族模板(后端单一事实来源),再预加载精灵图与角色
|
||||
if (typeof loadRaces === 'function') {
|
||||
loadRaces().then(() => {
|
||||
if (typeof preloadAllSheets === 'function') preloadAllSheets();
|
||||
if (typeof loadMicroChars === 'function') loadMicroChars(); // 从后端拉取该用户放置的角色(刷新/重进不丢)
|
||||
});
|
||||
} else {
|
||||
if (typeof preloadAllSheets === 'function') preloadAllSheets();
|
||||
if (typeof loadMicroChars === 'function') loadMicroChars();
|
||||
}
|
||||
if (typeof updateCharPanel === 'function') updateCharPanel();
|
||||
if (typeof loadMicroChars === 'function') loadMicroChars(); // 从后端拉取该用户放置的角色(刷新/重进不丢)
|
||||
// 注册角色障碍避让:随机游走 / 用户点击都被吸附到非建筑空地
|
||||
if (typeof setCharAvoid === 'function') {
|
||||
setCharAvoid({
|
||||
@@ -4184,16 +4213,16 @@ function onMicroDown(e) {
|
||||
const w = screenToWorld(p.x, p.y);
|
||||
const ch = hitChar(w.x, w.y);
|
||||
if (ch) {
|
||||
// 点击已选中的角色 → 取消选中;点击其他角色 → 切换选中
|
||||
if (typeof selectedCharId !== 'undefined' && selectedCharId === ch.id) selectChar(null);
|
||||
else selectChar(ch.id);
|
||||
// 点击角色:选中并打开功能面板(面板关闭后仍可点地面移动)
|
||||
console.log('[onMicroDown hitChar]', ch.id);
|
||||
selectChar(ch.id);
|
||||
showUnitModal(ch.id);
|
||||
e.preventDefault(); return;
|
||||
}
|
||||
// 放置模式:点击地面放置角色(优先级高于移动)
|
||||
if (typeof charPlaceMode !== 'undefined' && charPlaceMode && typeof createChar === 'function') {
|
||||
createChar(charPlaceRace, w.x, w.y);
|
||||
charPlaceMode = false;
|
||||
if (typeof updateCharPlacePanel === 'function') updateCharPlacePanel();
|
||||
return;
|
||||
}
|
||||
// 选中角色后:点击地面直接移动(无需再点按钮)
|
||||
@@ -4372,10 +4401,19 @@ async function onMicroUp(e) {
|
||||
hideMicroHoverTip();
|
||||
const hitIt = microBuildItemById(hit.type);
|
||||
if (hitIt && hitIt.isChar && hit._charCid) {
|
||||
console.log('[onMicroUp race building]', hit._charCid);
|
||||
showUnitModal(hit._charCid); // 种族角色打开单位面板
|
||||
} else {
|
||||
showBuildModal(hit);
|
||||
}
|
||||
} else if (typeof hitChar === 'function') {
|
||||
// 兜底:mousedown 未命中但 mouseup 命中的精灵图角色
|
||||
const ch = hitChar(wp.x, wp.y);
|
||||
if (ch) {
|
||||
console.log('[onMicroUp hitChar fallback]', ch.id);
|
||||
selectChar(ch.id);
|
||||
showUnitModal(ch.id);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -4843,90 +4881,168 @@ function microHash(str) {
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
// ============ 单位详情弹窗 ============
|
||||
function showUnitModal(unitId) {
|
||||
// ============ 单位详情弹窗(生物面板:数据来自 Odoo yt.world.micro.char) ============
|
||||
let microOpenUnitId = null;
|
||||
function _raceIconFallback(race) {
|
||||
const map = { human:'🧑', demon:'👹', dwarf:'🧔', elf:'🧝', dragon:'🐲', undead:'💀', orc:'👺', angel:'👼', werewolf:'🐺', golem:'🗿' };
|
||||
return map[race] || '👤';
|
||||
}
|
||||
function microSyncCharLocal(char) {
|
||||
if (!microState || !char) return null;
|
||||
const key = char.cid || char.id;
|
||||
if (!key) return null;
|
||||
const idx = microState.units.findIndex(u => u.id === key || u.cid === key);
|
||||
const icon = char.icon || _raceIconFallback(char.race);
|
||||
const synced = { ...char, icon, cid: key };
|
||||
if (idx >= 0) { Object.assign(microState.units[idx], synced); return microState.units[idx]; }
|
||||
microState.units.push(synced);
|
||||
return synced;
|
||||
}
|
||||
function _localCharFromMicroChars(unitId) {
|
||||
// 兼容 characters.js 精灵图角色:从 microChars 取本地运行时数据转成本地面板对象
|
||||
const chars = (typeof window !== 'undefined' && window.microChars) || [];
|
||||
const ch = chars.find(c => c.id === unitId);
|
||||
if (!ch) return null;
|
||||
const raceNameMap = { human:'人族', demon:'妖族', dwarf:'矮人', elf:'精灵', dragon:'龙族', undead:'不死', orc:'兽人', angel:'天使', werewolf:'狼人', golem:'石傀儡' };
|
||||
return {
|
||||
id: ch.id,
|
||||
cid: ch.id,
|
||||
name: (raceNameMap[ch.race] || ch.race || '未知') + '居民',
|
||||
race: ch.race || 'human',
|
||||
level: 1, exp: 0, exp_max: 100,
|
||||
hp: typeof ch.hp === 'number' ? ch.hp : (ch.hpMax || 50),
|
||||
hp_max: ch.hpMax || 50,
|
||||
energy: 100, energy_max: 100,
|
||||
attack: ch.attack || 0,
|
||||
defense: 0, speed: ch.speed || 0,
|
||||
job: '无', mood: '😊', status: ch.state || 'idle',
|
||||
equip: {},
|
||||
_localOnly: true
|
||||
};
|
||||
}
|
||||
async function showUnitModal(unitId) {
|
||||
console.log('[showUnitModal]', unitId);
|
||||
if (!microState) return;
|
||||
const unit = microState.units.find(u => u.id === unitId);
|
||||
if (!unit) return;
|
||||
if (!unit.equip) unit.equip = { weapon: null, armor: null, accessory: null };
|
||||
microOpenUnitId = unitId;
|
||||
const modal = document.getElementById('unitModal');
|
||||
document.getElementById('modalTitle').textContent = `${unit.icon} ${unit.name} · Lv.${unit.level}`;
|
||||
const hpColor = unit.hp > 70 ? '#8bc34a' : unit.hp > 40 ? '#ff9800' : '#f44336';
|
||||
const enColor = unit.energy > 70 ? '#4af' : unit.energy > 40 ? '#ff9800' : '#f44336';
|
||||
const need = unit.level * 100;
|
||||
const expPct = Math.max(0, Math.min(100, Math.round((unit.exp % need) / need * 100)));
|
||||
document.getElementById('modalTitle').textContent = '👤 加载中...';
|
||||
document.getElementById('modalBody').innerHTML = '<div style="text-align:center;padding:20px;color:#8899aa">正在从服务器读取生物数据...</div>';
|
||||
modal.classList.add('active');
|
||||
|
||||
// 优先从 Odoo 拉权威数据(世界生物表,按 cid 匹配)
|
||||
let r = null;
|
||||
try {
|
||||
r = await API.creature(unitId);
|
||||
console.log('[showUnitModal API.creature response]', r);
|
||||
} catch (err) {
|
||||
console.warn('[showUnitModal API.creature error]', err);
|
||||
}
|
||||
|
||||
let unit = null;
|
||||
let synced = false;
|
||||
if (r && r.ok && r.char) {
|
||||
unit = microSyncCharLocal(r.char);
|
||||
synced = true;
|
||||
}
|
||||
// 后端暂无记录:用 microState.units 或 characters.js 本地数据兜底,**不关闭弹窗**
|
||||
if (!unit) {
|
||||
unit = microState.units.find(u => u.id === unitId) || _localCharFromMicroChars(unitId);
|
||||
if (!unit) {
|
||||
console.warn('[showUnitModal] no local unit found for', unitId);
|
||||
microToast('未找到该生物数据');
|
||||
closeUnitModal();
|
||||
return;
|
||||
}
|
||||
if (!synced) {
|
||||
console.log('[showUnitModal] showing local-only panel for', unitId, unit);
|
||||
microToast('该生物尚未同步到服务器,显示本地数据');
|
||||
}
|
||||
}
|
||||
if (!unit.equip) unit.equip = {};
|
||||
|
||||
const raceIcon = (typeof unit.icon === 'string') ? unit.icon : _raceIconFallback(unit.race);
|
||||
const syncNote = unit._localOnly ? '<div style="color:#ff9800;font-size:12px;margin-bottom:8px;">⚠️ 数据未同步到服务器,装备/训练/休息暂不可用</div>' : '';
|
||||
document.getElementById('modalTitle').textContent = `${raceIcon} ${unit.name || '居民'} · Lv.${unit.level || 1}`;
|
||||
const hpMax = unit.hp_max || 50;
|
||||
const enMax = unit.energy_max || 100;
|
||||
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[slot];
|
||||
const curInfo = cur ? (EQUIP_CATALOG[slot] || []).find(e => e.code === cur) : null;
|
||||
const cur = unit.equip && unit.equip[slot];
|
||||
let inner;
|
||||
if (curInfo) {
|
||||
inner = `<div class="equipCur">${curInfo.icon} ${curInfo.name} <span class="equipBonus">${curInfo.bonus}</span> <span class="equipX" onclick="microEquip('${unit.id}','${slot}',null,true)">✕卸下</span></div>`;
|
||||
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 = (EQUIP_CATALOG[slot] || []).map(e =>
|
||||
`<div class="equipOpt" onclick="microEquip('${unit.id}','${slot}','${e.code}',false)">${e.icon} ${e.name}<span class="equipBonus">${e.bonus}</span></div>`).join('');
|
||||
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;"' : '';
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<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/(unit.hp_max||120)*100)}%;background:${hpColor};"></div></div><span class="statValue">${unit.hp}/${unit.hp_max||120}</span></div>
|
||||
<div class="statRow"><span class="statLabel">能量</span><div class="statBar"><div class="statFill" style="width:${unit.energy}%;background:${enColor};"></div></div><span class="statValue">${unit.energy}</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}</span></div>
|
||||
${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="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 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 class="cmdBtn" onclick="microUnitTrain('${unit.id}')">⬆️ 训练升级</div>
|
||||
<div class="cmdBtn" onclick="microUnitRest('${unit.id}')">😴 令其休息</div>
|
||||
<div class="cmdGrid" ${cmdDisabled}>
|
||||
<div class="cmdBtn" onclick="microUnitTrain('${unitId}')">⬆️ 训练升级</div>
|
||||
<div class="cmdBtn" onclick="microUnitRest('${unitId}')">😴 令其休息</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
// 穿戴 / 卸下装备(联动角色背包,持久化到 Odoo 生物记录)
|
||||
// 穿戴 / 卸下装备 → 调后端 action API,成功后刷新面板
|
||||
async function microEquip(unitId, slot, code, unequip) {
|
||||
const unit = microState && microState.units.find(u => u.id === unitId);
|
||||
if (!unit) return;
|
||||
if (!unit.equip) unit.equip = { weapon: null, armor: null, accessory: null };
|
||||
if (unequip) {
|
||||
const prev = unit.equip[slot];
|
||||
unit.equip[slot] = null;
|
||||
if (prev && microChar && microChar.inventory) {
|
||||
microChar.inventory[prev] = (microChar.inventory[prev] || 0) + 1;
|
||||
}
|
||||
} else {
|
||||
const prev = unit.equip[slot];
|
||||
if (prev && microChar && microChar.inventory) microChar.inventory[prev] = (microChar.inventory[prev] || 0) + 1;
|
||||
unit.equip[slot] = code;
|
||||
if (microChar && microChar.inventory) {
|
||||
microChar.inventory[code] = (microChar.inventory[code] || 0) - 1;
|
||||
if (microChar.inventory[code] <= 0) delete microChar.inventory[code];
|
||||
}
|
||||
const action = unequip ? 'unequip' : 'equip';
|
||||
const payload = { cid: unitId, action, slot };
|
||||
if (!unequip) payload.code = code;
|
||||
const r = await API.creatureAction(payload);
|
||||
if (!r || r.ok === false) {
|
||||
microToast('装备操作失败' + (r && r.error ? ' (' + r.error + ')' : ''));
|
||||
return;
|
||||
}
|
||||
microRenderCharHud();
|
||||
await microSaveCreatures();
|
||||
if (microChar) await updateChar();
|
||||
// 同步本地内存中的生物数据(若本地没有则追加)
|
||||
if (r.char) microSyncCharLocal(r.char);
|
||||
microToast(unequip ? '已卸下装备' : '装备穿戴成功');
|
||||
showUnitModal(unitId);
|
||||
}
|
||||
|
||||
// 训练升级:等级+1、经验清零、持久化
|
||||
// 训练升级 → 调后端 action API
|
||||
async function microUnitTrain(unitId) {
|
||||
const unit = microState && microState.units.find(u => u.id === unitId);
|
||||
if (!unit) return;
|
||||
unit.level = (unit.level || 1) + 1;
|
||||
unit.exp = 0;
|
||||
unit.hp_max = (unit.hp_max || 120) + 10;
|
||||
unit.hp = unit.hp_max;
|
||||
microSaveCreatures();
|
||||
const r = await API.creatureAction({ cid: unitId, action: 'train' });
|
||||
if (!r || r.ok === false) {
|
||||
microToast('训练失败' + (r && r.error ? ' (' + r.error + ')' : ''));
|
||||
return;
|
||||
}
|
||||
if (r.char) microSyncCharLocal(r.char);
|
||||
microToast(`升级成功!${r.char.name || '居民'} 升至 Lv.${r.char.level}`);
|
||||
showUnitModal(unitId);
|
||||
}
|
||||
// 令其休息:状态切 resting
|
||||
|
||||
// 令其休息 → 调后端 action API
|
||||
async function microUnitRest(unitId) {
|
||||
const unit = microState && microState.units.find(u => u.id === unitId);
|
||||
if (!unit) return;
|
||||
unit.status = 'resting';
|
||||
microSaveCreatures();
|
||||
const r = await API.creatureAction({ cid: unitId, action: 'rest' });
|
||||
if (!r || r.ok === false) {
|
||||
microToast('休息失败' + (r && r.error ? ' (' + r.error + ')' : ''));
|
||||
return;
|
||||
}
|
||||
if (r.char) microSyncCharLocal(r.char);
|
||||
microToast('已令该生物休息');
|
||||
showUnitModal(unitId);
|
||||
}
|
||||
// 保存角色(金币/背包/装备)到 Odoo
|
||||
@@ -4936,7 +5052,7 @@ async function updateChar() {
|
||||
if (r && r.ok && r.char) microChar = r.char;
|
||||
}
|
||||
|
||||
function closeUnitModal() { document.getElementById('unitModal').classList.remove('active'); }
|
||||
function closeUnitModal() { microOpenUnitId = null; document.getElementById('unitModal').classList.remove('active'); }
|
||||
|
||||
// ===== 微观建筑:悬停 tooltip + 点击弹窗(与 Odoo 后台目录联动)=====
|
||||
let microTipEl = null;
|
||||
@@ -6493,7 +6609,7 @@ function microRenderBuildPanel() {
|
||||
}
|
||||
const cost = (!it.isChar) ? (it.costGold || 0) : null;
|
||||
const costHtml = cost == null ? '' :
|
||||
(cost > 0 ? `<span class="mv-cost">🪙 ${cost}</span>` : `<span class="mv-cost free">免费</span>`);
|
||||
(cost > 0 ? `<span class="mv-cost">🪙 ${cost}</span>` : `<span class="mv-cost free"></span>`);
|
||||
return `<button type="button" class="mv-cell${it.id === microBuildSel ? ' active' : ''}" data-build="${it.id}" title="${it.name}">
|
||||
${ico}<span class="mv-nm">${it.name}</span>${costHtml}
|
||||
</button>`;
|
||||
@@ -6515,7 +6631,6 @@ function microRenderBuildPanel() {
|
||||
if (microBuildMode) microSetBuildMode(false);
|
||||
clearBuildSelUI();
|
||||
microRenderBuildPanel();
|
||||
if (typeof updateCharPlacePanel === 'function') updateCharPlacePanel();
|
||||
const raceName = (window.RACE_CONFIG && window.RACE_CONFIG[item.race]) ? window.RACE_CONFIG[item.race].name : item.name;
|
||||
microToast('👤 点地面放置' + raceName);
|
||||
return;
|
||||
@@ -7199,7 +7314,7 @@ if (typeof fitWorld === 'function') fitWorld();
|
||||
? '<div class="tp-max">已达最高等级</div>'
|
||||
: '<button class="tp-btn" data-code="' + t.code + '">升级至 Lv ' + ((t.level || 1) + 1) + '</button>';
|
||||
div.innerHTML =
|
||||
'<div class="tp-ico">' + (t.icon || '🌟') + '</div>' +
|
||||
'<div class="tp-ico">' + (typeof t.icon==='string' && /\.(png|webp|jpg|jpeg)(\?.*)?$/i.test(t.icon) ? '<img src="'+t.icon+'" class="ic-img" alt="">' : (t.icon || '🌟')) + '</div>' +
|
||||
'<div class="tp-body">' +
|
||||
'<div class="tp-name">' + t.name + ' ' + state + '</div>' +
|
||||
'<div class="tp-lv">等级 ' + (t.level || 1) + ' / ' + (t.max_level || 1) + '</div>' +
|
||||
@@ -7303,7 +7418,7 @@ if (typeof fitWorld === 'function') fitWorld();
|
||||
'<div class="td-face td-front">' +
|
||||
'<div class="td-ring"></div>' +
|
||||
'<div class="td-got" hidden>已获得</div>' +
|
||||
'<div class="td-ico">' + (t.icon || '🌟') + '</div>' +
|
||||
'<div class="td-ico">' + (typeof t.icon==='string' && /\.(png|webp|jpg|jpeg)(\?.*)?$/i.test(t.icon) ? '<img src="'+t.icon+'" class="ic-img" alt="">' : (t.icon || '🌟')) + '</div>' +
|
||||
'<div class="td-q ' + tdQualityClass(t.quality) + '">' + (QLABEL[t.quality] || '普通') + '</div>' +
|
||||
'<div class="td-name">' + t.name + '</div>' +
|
||||
(t.effect ? '<div class="td-eff">' + t.effect + '</div>' : '') +
|
||||
|
||||
Reference in New Issue
Block a user