365 lines
16 KiB
Python
365 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""宇森世界 · 游戏化系统模型(角色 / 生物)。
|
||
|
||
这些模型让前端「建筑交互 / 生物装备 / 经济」在 Odoo 后端拥有可记忆的真实记录,
|
||
与已存在的微观建造布置(yt.world.tile.micro.build_items JSON)互补:
|
||
|
||
- yt.world.character : 玩家英雄(等级 / 经验 / 金币 / 背包 / 装备),每世界一条
|
||
- yt.world.creature : 世界生物(居民 / NPC),含等级 / 经验 / 生命 / 装备 / 职业
|
||
"""
|
||
from odoo import models, fields, api
|
||
|
||
|
||
def _fmt_kv(d):
|
||
"""把 dict 格式化成可读多行文本;空则返回占位。"""
|
||
d = d or {}
|
||
if not d:
|
||
return '(空)'
|
||
if not isinstance(d, dict):
|
||
return str(d)
|
||
return '\n'.join('%s: %s' % (k, v) for k, v in d.items())
|
||
|
||
|
||
class YtWorldCharacter(models.Model):
|
||
_name = 'yt.world.character'
|
||
_description = '玩家角色(英雄)'
|
||
_sql_constraints = [
|
||
('uniq_world', 'unique(world_id)', '一个世界只能有一个角色'),
|
||
]
|
||
|
||
world_id = fields.Many2one('yt.world', string='世界', required=True,
|
||
ondelete='cascade', index=True)
|
||
employee_id = fields.Many2one('hr.employee', string='所属玩家',
|
||
ondelete='cascade', index=True)
|
||
name = fields.Char(string='角色名', default='旅者')
|
||
level = fields.Integer(string='等级', default=1)
|
||
exp = fields.Integer(string='经验', default=0)
|
||
exp_max = fields.Integer(string='升级所需经验', default=100)
|
||
gold = fields.Integer(string='金币', default=100)
|
||
talent_points = fields.Integer(string='天赋点', default=0,
|
||
help='用于激活/升级天赋的专用点数')
|
||
inventory = fields.Json(string='背包', default=dict)
|
||
inventory_display = fields.Text(string='背包(可读)', compute='_compute_displays')
|
||
equip = fields.Json(string='装备', default=dict)
|
||
equip_display = fields.Text(string='装备(可读)', compute='_compute_displays')
|
||
talent_ids = fields.One2many(
|
||
'yt.world.character.talent', 'character_id', string='已拥有天赋'
|
||
)
|
||
# 任务进度:[{code, progress:{obj_id:have}, state, ts}]
|
||
# state ∈ in_progress / claimable / done(locked 为前端按前置派生,不落库)
|
||
tasks = fields.Json(string='任务进度', default=list)
|
||
tutorial_chapter = fields.Integer(string='当前章节', default=1)
|
||
tutorial_step = fields.Integer(string='章节内步骤', default=0)
|
||
|
||
@api.depends('inventory', 'equip')
|
||
def _compute_displays(self):
|
||
for r in self:
|
||
r.inventory_display = _fmt_kv(r.inventory)
|
||
r.equip_display = _fmt_kv(r.equip)
|
||
|
||
# ========== 任务系统 ==========
|
||
# 前端可发射的游戏事件类型(服务端事件总线):
|
||
# panel_opened / world_named / build_placed{code} /
|
||
# building_action{key:'code_action'} / resource_collected{res} /
|
||
# char_recruited{race} / creature_recruited{species} / level_up{level}
|
||
@staticmethod
|
||
def _obj_id(o):
|
||
return '%s:%s' % (o.get('type'), o.get('key'))
|
||
|
||
@staticmethod
|
||
def _match_event(o, event, payload):
|
||
"""单条 objective 是否被该事件命中。"""
|
||
if o.get('type') != event:
|
||
return False
|
||
key = o.get('key')
|
||
if key is None:
|
||
return True
|
||
if event == 'level_up':
|
||
try:
|
||
return int(payload.get('level', 0)) >= int(key)
|
||
except Exception:
|
||
return False
|
||
field = {
|
||
'build_placed': 'code',
|
||
'building_action': 'key',
|
||
'resource_collected': 'res',
|
||
'char_recruited': 'race',
|
||
'creature_recruited': 'species',
|
||
}.get(event)
|
||
if not field:
|
||
return True # 不带 key 的事件(panel_opened/world_named)命中即计数
|
||
val = payload.get(field)
|
||
if val is None:
|
||
return True
|
||
return str(val) == str(key)
|
||
|
||
def on_game_event(self, event, payload=None):
|
||
"""服务端事件总线:遍历未完成任务的 objectives,命中则推进进度,满额置 claimable。
|
||
返回 {ok, changed, claimable:[codes]} 供接口提示前端。"""
|
||
self.ensure_one()
|
||
payload = payload or {}
|
||
Task = self.env['yt.world.task'].sudo()
|
||
tasks = list(self.tasks or [])
|
||
done_codes = [t['code'] for t in tasks if t.get('state') == 'done']
|
||
newly_claimable = []
|
||
changed = False
|
||
for task in Task.search([('active', '=', True)], order='chapter, sequence, id'):
|
||
code = task.code
|
||
idx = next((i for i, t in enumerate(tasks) if t.get('code') == code), None)
|
||
entry = tasks[idx] if idx is not None else None
|
||
if entry and entry.get('state') in ('done', 'claimable'):
|
||
continue # 已结算/待领取,不再推进
|
||
prereq = task.prereq or []
|
||
if any(p not in done_codes for p in prereq):
|
||
continue # 前置未解锁
|
||
objectives = task.objectives or []
|
||
if not objectives:
|
||
continue
|
||
prog = dict((entry or {}).get('progress') or {})
|
||
was_claimable = bool(entry and entry.get('state') == 'claimable')
|
||
for o in objectives:
|
||
oid = o.get('id') or self._obj_id(o)
|
||
need = o.get('count', 1) or 1
|
||
have = prog.get(oid, 0)
|
||
if have >= need:
|
||
continue
|
||
if self._match_event(o, event, payload):
|
||
prog[oid] = have + 1
|
||
changed = True
|
||
all_done = all(
|
||
(prog.get(o.get('id') or self._obj_id(o), 0)) >= (o.get('count', 1) or 1)
|
||
for o in objectives)
|
||
new_state = 'claimable' if all_done else 'in_progress'
|
||
rec = {'code': code, 'progress': prog, 'state': new_state}
|
||
if idx is not None:
|
||
tasks[idx] = rec
|
||
else:
|
||
tasks.append(rec)
|
||
changed = True
|
||
if new_state == 'claimable' and not was_claimable:
|
||
newly_claimable.append(code)
|
||
if changed:
|
||
self.sudo().write({'tasks': tasks})
|
||
return {'ok': True, 'changed': changed, 'claimable': newly_claimable}
|
||
|
||
def complete_task(self, code, task_model=None):
|
||
"""领取任务奖励(仅 state==claimable 可领)。校验前置→发奖→置 done。"""
|
||
self.ensure_one()
|
||
Task = task_model or self.env['yt.world.task'].sudo()
|
||
task = Task.search([('code', '=', code), ('active', '=', True)], limit=1)
|
||
if not task:
|
||
return {'ok': False, 'error': 'task_not_found', 'code': code}
|
||
# 前置校验
|
||
prereq = task.prereq or []
|
||
done_codes = [t['code'] for t in (self.tasks or []) if t.get('state') == 'done']
|
||
for p in prereq:
|
||
if p not in done_codes:
|
||
return {'ok': False, 'error': 'prereq_unmet', 'code': p}
|
||
# 找到进度条目
|
||
tasks = list(self.tasks or [])
|
||
entry = next((t for t in tasks if t.get('code') == code), None)
|
||
if entry and entry.get('state') == 'done':
|
||
return {'ok': True, 'already': True, 'level': self.level,
|
||
'exp': self.exp, 'gold': self.gold}
|
||
if not entry or entry.get('state') != 'claimable':
|
||
return {'ok': False, 'error': 'not_claimable', 'code': code}
|
||
# 加经验(带越级)
|
||
exp = self.exp + (task.reward_exp or 0)
|
||
level = self.level
|
||
exp_max = self.exp_max
|
||
while exp >= exp_max:
|
||
exp -= exp_max
|
||
level += 1
|
||
exp_max = int(exp_max * 1.3) # 每级所需经验递增
|
||
# 加货币
|
||
gold = self.gold + int((task.reward_res or {}).get('gold', 0))
|
||
# 置已完成
|
||
for i, t in enumerate(tasks):
|
||
if t.get('code') == code:
|
||
tasks[i] = {'code': code, 'progress': t.get('progress') or {},
|
||
'state': 'done', 'ts': fields.Datetime.to_string(fields.Datetime.now())}
|
||
break
|
||
self.sudo().write({
|
||
'level': level, 'exp': exp, 'exp_max': exp_max,
|
||
'gold': gold, 'tasks': tasks,
|
||
})
|
||
return {'ok': True, 'level': level, 'exp': exp, 'exp_max': exp_max,
|
||
'gold': gold, 'reward_exp': task.reward_exp or 0,
|
||
'reward_res': task.reward_res or {}}
|
||
|
||
|
||
class YtWorldCreature(models.Model):
|
||
_name = 'yt.world.creature'
|
||
_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)
|
||
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')
|
||
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')
|
||
partner = fields.Json(string='相关对象', default=dict)
|
||
partner_display = fields.Text(string='相关对象(可读)', compute='_compute_displays')
|
||
|
||
@api.depends('equip', 'tile_ref', 'partner')
|
||
def _compute_displays(self):
|
||
for r in self:
|
||
r.equip_display = _fmt_kv(r.equip)
|
||
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,
|
||
}
|
||
|