角色面板

This commit is contained in:
李鹏宇
2026-09-03 15:31:15 +08:00
parent 032d7f735e
commit 43e1ef7fce
14 changed files with 980 additions and 315 deletions
+2
View File
@@ -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 个模型视图 + 菜单改造
+93 -86
View File
@@ -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()})
# ============================================================
# 资源系统:库存 / 结算 / 离线囤积回收
+7 -7
View File
@@ -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>
+204
View File
@@ -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 -1
View File
@@ -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,
}
+148 -5
View File
@@ -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} 所在宏观地块坐标,角色按地块隔离')
+6 -3
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
37 access_yt_world_character_public yt.world.character public model_yt_world_character base.group_public 1 0 0 0
38 access_yt_world_creature_user yt.world.creature user model_yt_world_creature base.group_user 1 1 1 1
39 access_yt_world_creature_public yt.world.creature public model_yt_world_creature base.group_public 1 0 0 0
40 access_yt_world_equip_item_user yt.world.equip.item user model_yt_world_equip_item base.group_user 1 1 1 1
41 access_yt_world_equip_item_public yt.world.equip.item public model_yt_world_equip_item base.group_public 1 0 0 0
42 access_yt_world_equip_item_portal yt.world.equip.item portal model_yt_world_equip_item base.group_portal 1 1 1 1
43 access_yt_world_race_user yt.world.race user model_yt_world_race base.group_user 1 1 1 1
44 access_yt_world_race_public yt.world.race public model_yt_world_race base.group_public 1 1 1 1
45 access_yt_world_race_portal yt.world.race portal model_yt_world_race base.group_portal 1 1 1 1
46 access_game_npc game.npc user model_game_npc base.group_user 1 1 1 1
47 access_game_npc_relationship game.npc.relationship user model_game_npc_relationship base.group_user 1 1 1 1
48 access_game_npc_behavior_log game.npc.behavior.log user model_game_npc_behavior_log base.group_user 1 1 1 1
49 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
50 access_yt_world_build_instance_user yt.world.build.instance user model_yt_world_build_instance base.group_user 1 1 1 1
51 access_yt_world_build_instance_public yt.world.build.instance public model_yt_world_build_instance base.group_public 1 1 1 1
52 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>' : '') +