任务系统,新手引导

This commit is contained in:
李鹏宇
2026-08-18 19:36:52 +08:00
parent c777db0657
commit efbf69580a
18 changed files with 802 additions and 29 deletions
+1
View File
@@ -12,6 +12,7 @@
'data/codex_monster.xml',
'data/codex_race.xml',
'data/yt_world_build_item.xml',
'data/yt_world_task.xml',
'views/yt_world_views.xml',
# AI NPC(自 game_ai_npc 合并):先数据(建模型记录)→ 子视图(含 action) → 主视图 → 菜单(引用 action 与父菜单)
'data/game_npc_data.xml',
+151 -3
View File
@@ -340,10 +340,157 @@ class YtWorldApi(http.Controller):
char = request.env['yt.world.character'].sudo().search(
[('world_id', '=', world.id)], limit=1)
if not char:
char = request.env['yt.world.character'].sudo().create(
{'world_id': world.id})
# 认领当前登录人的玩家档案(hr.employee),把注册昵称同步为角色名
vals = {'world_id': world.id}
uid = request.session.uid
if uid:
emp = request.env['hr.employee'].sudo().search(
[('user_id', '=', uid)], limit=1)
if emp:
vals['employee_id'] = emp.id
vals['name'] = emp.name or char.name
char = request.env['yt.world.character'].sudo().create(vals)
else:
# 兼容老数据:已存在角色但没关联 employee 时补上
if not char.employee_id:
uid = request.session.uid
if uid:
emp = request.env['hr.employee'].sudo().search(
[('user_id', '=', uid)], limit=1)
if emp:
char.sudo().write({'employee_id': emp.id,
'name': emp.name or char.name})
return char
# ========== 任务系统序列化(get_tasks / task_progress 共用) ==========
def _serialize_tasks(self, char):
"""把任务定义 + 角色进度汇总成前端面板可直接渲染的结构。"""
done_codes = [t['code'] for t in (char.tasks or []) if t.get('state') == 'done']
tasks = request.env['yt.world.task'].sudo().search(
[('active', '=', True)], order='chapter, sequence, id')
out = []
for t in tasks:
prereq = t.prereq or []
entry = None
for x in (char.tasks or []):
if x.get('code') == t.code:
entry = x
break
if entry and entry.get('state') == 'done':
state = 'done'
elif any(p not in done_codes for p in prereq):
state = 'locked'
elif entry and entry.get('state') == 'claimable':
state = 'claimable'
else:
state = 'in_progress'
progress = (entry or {}).get('progress') or {}
objs = []
for o in (t.objectives or []):
oid = o.get('id') or ('%s:%s' % (o.get('type'), o.get('key')))
need = o.get('count', 1) or 1
have = min(progress.get(oid, 0), need)
objs.append({
'type': o.get('type'), 'key': o.get('key'),
'label': o.get('label'), 'hint': o.get('hint'),
'have': have, 'need': need,
})
out.append({
'code': t.code, 'chapter': t.chapter, 'title': t.title,
'desc': t.desc or '', 'objectives': objs,
'reward_exp': t.reward_exp or 0,
'reward_res': t.reward_res or {},
'prereq': prereq, 'state': state,
})
chapters = sorted({t['chapter'] for t in out})
return chapters, out
@http.route('/yt_world/api/tasks', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False)
def get_tasks(self, **kw):
"""返回任务定义 + 当前角色进度,前端据此渲染面板。"""
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'})
char = self._get_char(world)
# 回溯校验:角色等级可能已满足 level_up 类型任务,补发事件
char.on_game_event('level_up', {'level': char.level})
chapters, out = self._serialize_tasks(char)
return _json({
'ok': True,
'chapters': chapters,
'tasks': out,
'char': {
'name': char.name, 'level': char.level, 'exp': char.exp,
'exp_max': char.exp_max, 'gold': char.gold,
},
})
@http.route('/yt_world/api/task/progress', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False)
def task_progress(self, **kw):
"""接收前端游戏事件并推进任务进度。body: {"event":"build_placed","payload":{"code":"farmland"}}
返回更新后的任务列表(与 /tasks 同结构),claimable 列出本次新可领取的任务。"""
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'})
body = _post_body()
event = (body.get('event') or '').strip()
payload = body.get('payload') or {}
if not event:
return _json({'ok': False, 'error': 'bad_params'})
char = self._get_char(world)
res = char.on_game_event(event, payload)
# 回溯校验:角色等级可能已满足 level_up 类型任务,补发事件
char.on_game_event('level_up', {'level': char.level})
chapters, out = self._serialize_tasks(char)
return _json({
'ok': True,
'chapters': chapters,
'tasks': out,
'char': {
'name': char.name, 'level': char.level, 'exp': char.exp,
'exp_max': char.exp_max, 'gold': char.gold,
},
'claimable': res.get('claimable', []),
})
@http.route('/yt_world/api/task/complete', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False)
def complete_task(self, **kw):
"""领取/完成任务。body: {"code": "ch1_welcome"}
校验前置→发奖→写进度,返回最新角色状态。"""
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'})
body = _post_body()
code = (body.get('code') or '').strip()
if not code:
return _json({'ok': False, 'error': 'bad_params'})
char = self._get_char(world)
res = char.complete_task(code)
if not res.get('ok'):
return _json(res)
return _json({
'ok': True,
'already': res.get('already', False),
'char': {
'name': char.name, 'level': char.level, 'exp': char.exp,
'exp_max': char.exp_max, 'gold': char.gold,
},
'reward_exp': res.get('reward_exp', 0),
'reward_res': res.get('reward_res', {}),
})
@http.route('/yt_world/api/char', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False)
def get_char(self, **kw):
@@ -358,7 +505,8 @@ class YtWorldApi(http.Controller):
return _json({
'ok': True,
'name': char.name, 'level': char.level, 'exp': char.exp,
'gold': char.gold, 'inventory': char.inventory or {},
'exp_max': char.exp_max or 100, 'gold': char.gold,
'inventory': char.inventory or {},
'equip': char.equip or {'weapon': None, 'armor': None, 'accessory': None},
})
+98
View File
@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="0">
<!-- ===== 第1章 初入世界 ===== -->
<record id="task_ch1_welcome" model="yt.world.task">
<field name="chapter">1</field>
<field name="code">ch1_welcome</field>
<field name="title">欢迎来到宇森世界</field>
<field name="desc">你正式成为这个世界的缔造者。打开任务面板,即可领取新手启程礼包。</field>
<field name="objectives" eval="[{'id':'o1','type':'panel_opened','count':1,'label':'打开任务面板','hint':'点击顶部 📜 任务按钮'}]"/>
<field name="reward_exp">50</field>
<field name="reward_res" eval="{'gold': 100}"/>
<field name="prereq" eval="[]"/>
<field name="sequence">10</field>
<field name="active">True</field>
</record>
<record id="task_ch1_name" model="yt.world.task">
<field name="chapter">1</field>
<field name="code">ch1_name</field>
<field name="title">为你的世界命名</field>
<field name="desc">每个世界都该有自己的名字。点击世界标题,为它取一个响亮的名号。</field>
<field name="objectives" eval="[{'id':'o1','type':'world_named','count':1,'label':'为世界命名','hint':'点击顶部世界标题后回车保存'}]"/>
<field name="reward_exp">30</field>
<field name="reward_res" eval="{'gold': 50}"/>
<field name="prereq" eval="['ch1_welcome']"/>
<field name="sequence">20</field>
<field name="active">True</field>
</record>
<!-- ===== 第2章 建造启蒙 ===== -->
<record id="task_ch2_build" model="yt.world.task">
<field name="chapter">2</field>
<field name="code">ch2_build</field>
<field name="title">奠基第一座建筑</field>
<field name="desc">进入任意地块,从建造面板放置你的第一座建筑,让世界开始运转。</field>
<field name="objectives" eval="[{'id':'o1','type':'build_placed','count':1,'label':'放置任意建筑','hint':'进入地块,选素材后左键落地'}]"/>
<field name="reward_exp">40</field>
<field name="reward_res" eval="{'gold': 80}"/>
<field name="prereq" eval="['ch1_name']"/>
<field name="sequence">10</field>
<field name="active">True</field>
</record>
<record id="task_ch2_harvest" model="yt.world.task">
<field name="chapter">2</field>
<field name="code">ch2_harvest</field>
<field name="title">第一次收获</field>
<field name="desc">在农田上使用「收获」动作,体验建筑产出资源的乐趣。</field>
<field name="objectives" eval="[{'id':'o1','type':'building_action','key':'farmland_harvest','count':1,'label':'收获一次农田','hint':'选中农田 → 收获'}]"/>
<field name="reward_exp">40</field>
<field name="reward_res" eval="{'gold': 60}"/>
<field name="prereq" eval="['ch2_build']"/>
<field name="sequence">20</field>
<field name="active">True</field>
</record>
<!-- ===== 第3章 资源流转 ===== -->
<record id="task_ch3_food" model="yt.world.task">
<field name="chapter">3</field>
<field name="code">ch3_food</field>
<field name="title">粮仓初盈</field>
<field name="desc">反复经营你的农田与作坊,累计收获 5 份粮食,支撑世界运转。</field>
<field name="objectives" eval="[{'id':'o1','type':'resource_collected','key':'food','count':5,'label':'累计收获粮食 ×5','hint':'多次使用农田「收获」/风车「加工」'}]"/>
<field name="reward_exp">60</field>
<field name="reward_res" eval="{'gold': 100}"/>
<field name="prereq" eval="['ch2_harvest']"/>
<field name="sequence">10</field>
<field name="active">True</field>
</record>
<!-- ===== 第4章 生灵入驻 ===== -->
<record id="task_ch4_char" model="yt.world.task">
<field name="chapter">4</field>
<field name="code">ch4_char</field>
<field name="title">首位居民</field>
<field name="desc">一个世界不能只有建筑。在地块中放置你的第一名种族角色,带来生机。</field>
<field name="objectives" eval="[{'id':'o1','type':'char_recruited','count':1,'label':'入驻一名种族角色','hint':'在地块中放置一个种族角色'}]"/>
<field name="reward_exp">80</field>
<field name="reward_res" eval="{'gold': 150}"/>
<field name="prereq" eval="['ch3_food']"/>
<field name="sequence">10</field>
<field name="active">True</field>
</record>
<!-- ===== 第5章 文明初成 ===== -->
<record id="task_ch5_level" model="yt.world.task">
<field name="chapter">5</field>
<field name="code">ch5_level</field>
<field name="title">文明初成</field>
<field name="desc">完成前面的历练,让你的角色积累经验、晋升至 2 级,开启更广阔的世界。</field>
<field name="objectives" eval="[{'id':'o1','type':'level_up','key':2,'count':1,'label':'角色升至 2 级','hint':'完成更多任务积累经验'}]"/>
<field name="reward_exp">100</field>
<field name="reward_res" eval="{'gold': 200}"/>
<field name="prereq" eval="['ch4_char']"/>
<field name="sequence">10</field>
<field name="active">True</field>
</record>
</odoo>
+1
View File
@@ -4,6 +4,7 @@ from . import yt_world_tile_micro
from . import yt_world_micro_char
from . import yt_world_build_item
from . import yt_world_game
from . import yt_world_task
from . import yt_resource
from . import yt_codex
from . import npc
+138
View File
@@ -29,14 +29,22 @@ class YtWorldCharacter(models.Model):
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)
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')
# 任务进度:[{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):
@@ -44,6 +52,136 @@ class YtWorldCharacter(models.Model):
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'
+32
View File
@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
"""宇森世界 · 任务定义模型。
任务定义放后端(数据驱动),前端只渲染。与 yt.world.build.item 同一套路:
- 一条 yt.world.task 记录 = 一个任务(含章节/目标/奖励/前置)
- 玩家进度挂在 yt.world.character.tasks(Json 列表),不另建表
"""
from odoo import models, fields
class YtWorldTask(models.Model):
_name = 'yt.world.task'
_description = '世界任务(新手主线)'
_order = 'chapter, sequence, id'
chapter = fields.Integer(string='章节', default=1)
code = fields.Char(string='任务编码', required=True, index=True)
title = fields.Char(string='标题', required=True)
desc = fields.Text(string='描述')
# 目标步骤:[{key, label, hint}],前端渲染用;后端仅做展示
objectives = fields.Json(string='目标步骤', default=list)
reward_exp = fields.Integer(string='经验奖励', default=0)
# 货币奖励:{gold: 100} 等;gold 走 character.gold,其余走 inventory
reward_res = fields.Json(string='货币奖励', default=dict)
# 前置任务编码列表,全部完成后本任务才解锁
prereq = fields.Json(string='前置任务', default=list)
sequence = fields.Integer(string='排序', default=10)
active = fields.Boolean(string='启用', default=True)
_sql_constraints = [
('uniq_code', 'unique(code)', '任务编码必须唯一'),
]
@@ -33,3 +33,6 @@ access_game_npc_knowledge,game.npc.knowledge user,model_game_npc_knowledge,base.
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_task_user,yt.world.task user,model_yt_world_task,base.group_user,1,1,1,1
access_yt_world_task_public,yt.world.task public,model_yt_world_task,base.group_public,1,0,0,0
access_yt_world_task_portal,yt.world.task portal,model_yt_world_task,base.group_portal,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
33 access_yt_world_micro_char_user yt.world.micro.char user model_yt_world_micro_char base.group_user 1 1 1 1
34 access_yt_world_micro_char_public yt.world.micro.char public model_yt_world_micro_char base.group_public 1 0 0 0
35 access_yt_world_micro_char_portal yt.world.micro.char portal model_yt_world_micro_char base.group_portal 1 0 0 0
36 access_yt_world_task_user yt.world.task user model_yt_world_task base.group_user 1 1 1 1
37 access_yt_world_task_public yt.world.task public model_yt_world_task base.group_public 1 0 0 0
38 access_yt_world_task_portal yt.world.task portal model_yt_world_task base.group_portal 1 0 0 0