Files
game/addons/yt_world/models/yt_world_game.py
T
2026-09-09 15:04:24 +08:00

399 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""宇森世界 · 游戏化系统模型(角色 / 生物)。
这些模型让前端「建筑交互 / 生物装备 / 经济」在 Odoo 后端拥有可记忆的真实记录,
与已存在的微观建造布置(yt.world.tile.micro.build_items JSON)互补:
- yt.world.character : 玩家英雄(等级 / 经验 / 金币 / 背包 / 装备),每世界一条
- yt.world.creature : 世界生物(居民 / NPC),含等级 / 经验 / 生命 / 装备 / 职业
"""
import json
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)
copper = fields.Integer(string='铜币', default=0)
silver = fields.Integer(string='银币', default=0)
diamond = fields.Integer(string='钻石', default=0)
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')
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')
# 该生物拥有的全部装备实例(背包 + 已装备),与 账号→世界→生物 四级树末端直接关联
equip_instance_ids = fields.One2many('yt.world.equip.instance', 'owner_creature_id', string='装备实例')
@api.depends('tile_ref', 'partner')
def _compute_displays(self):
for r in self:
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 _equip_bonus(self):
"""装备提供的临时加成(仅攻击/防御/生命上限;其他数值类型暂不加)。
汇总当前已装备(equipped_slot 非空)实例的模板 stats,不写回基础字段,
因此加成天然"临时"——脱装备即消失,刷新/重载都从实例实时汇总。
这里直接查实例表,避免父记录 One2many 缓存未刷新导致加成丢失。"""
self.ensure_one()
bonus = {'attack': 0, 'defense': 0, 'hp_max': 0}
Inst = self.env['yt.world.equip.instance'].sudo()
for inst in Inst.search([('owner_creature_id', '=', self.id), ('equipped_slot', '!=', False)]):
stats = inst.item_id.stats or {}
if not isinstance(stats, dict):
try:
stats = json.loads(stats)
except Exception:
stats = {}
for k in bonus:
v = stats.get(k)
if isinstance(v, (int, float)):
bonus[k] += int(v)
return bonus
def to_panel_dict(self):
self.ensure_one()
catalog = {}
for it in self.env['yt.world.equip.item'].sudo().search([]):
catalog[it.code] = it.to_dict()
# 从装备实例表反查"当前已装备"的装备(实例 -> 模板对象),
# 保持前端 {slot: 对象} 结构不变,前端渲染零改动。
equip_resolved = {}
for slot in ('weapon', 'armor', 'accessory', 'boots'):
inst = self.env['yt.world.equip.instance'].sudo().search(
[('owner_creature_id', '=', self.id), ('equipped_slot', '=', slot)], limit=1)
if inst:
equip_resolved[slot] = inst.item_id.to_dict()
# 装备临时加成:有效属性 = 基础字段 + 已装备实例的 stats 汇总
bonus = self._equip_bonus()
hp = self.hp or 0
hp_max = (self.hp_max or 0) + bonus['hp_max']
# 当前生命不超过有效生命上限(如刚脱下 +生命装备导致上限回落)
if hp > hp_max:
hp = hp_max
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': hp,
'hp_max': hp_max,
'energy': self.energy,
'energy_max': self.energy_max,
'attack': (self.attack or 0) + bonus['attack'],
'defense': (self.defense or 0) + bonus['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,
}