222 lines
9.9 KiB
Python
222 lines
9.9 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 = '世界生物(居民 / NPC)'
|
||
_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)
|
||
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)
|
||
energy = fields.Integer(string='能量', default=100)
|
||
mood = fields.Char(string='心情', default='😊')
|
||
job = fields.Char(string='职业', default='none')
|
||
equip = fields.Json(string='装备', default=dict)
|
||
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')
|
||
|
||
@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)
|
||
|