feat: 后台用户菜单加官网入口 + world.html世界名编辑/科技树/过渡背景 + 新增game_ai_npc模块
This commit is contained in:
parent
f78f37d677
commit
931b0dbe31
3
addons/game_ai_npc/__init__.py
Normal file
3
addons/game_ai_npc/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
38
addons/game_ai_npc/__manifest__.py
Normal file
38
addons/game_ai_npc/__manifest__.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': 'AI NPC 系统',
|
||||||
|
'version': '1.0',
|
||||||
|
'category': 'Game',
|
||||||
|
'summary': 'NPC角色身份、行为模拟、关系演进',
|
||||||
|
'description': """
|
||||||
|
AI NPC 系统
|
||||||
|
===========
|
||||||
|
为每位NPC赋予身份、性格、目标和日程,在后台定时Tick推进行为模拟,
|
||||||
|
NPC之间关系自主演进,玩家可查看状态、日志、关系网。
|
||||||
|
|
||||||
|
核心能力:
|
||||||
|
- NPC 角色配置(身份/性格/目标/背景)
|
||||||
|
- 行为刻 Tick(按日程推进,影响状态)
|
||||||
|
- 关系演进(互动→好感变化→关系层级)
|
||||||
|
- 行为日志(完整行为历史)
|
||||||
|
- NPC 知识体系
|
||||||
|
- REST API(前端沙盘接入)
|
||||||
|
- Odoo Cron 定时批量 Tick
|
||||||
|
""",
|
||||||
|
'depends': ['game_base'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/game_npc_data.xml',
|
||||||
|
# 子视图(含 action)先加载
|
||||||
|
'views/npc_relationship_views.xml',
|
||||||
|
'views/npc_behavior_views.xml',
|
||||||
|
'views/npc_knowledge_views.xml',
|
||||||
|
# 主 NPC 视图(引用上述 action)后加载
|
||||||
|
'views/npc_views.xml',
|
||||||
|
'views/menu.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'application': True,
|
||||||
|
'auto_install': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
2
addons/game_ai_npc/controllers/__init__.py
Normal file
2
addons/game_ai_npc/controllers/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import npc_api
|
||||||
124
addons/game_ai_npc/controllers/npc_api.py
Normal file
124
addons/game_ai_npc/controllers/npc_api.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import json
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def _json(data):
|
||||||
|
return request.make_response(
|
||||||
|
json.dumps(data, ensure_ascii=False, default=str),
|
||||||
|
headers=[('Content-Type', 'application/json; charset=utf-8')]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GameNpcApi(http.Controller):
|
||||||
|
|
||||||
|
# ── NPC 列表 ──
|
||||||
|
@http.route('/game/api/npcs', type='http', auth='public', methods=['GET'], csrf=False)
|
||||||
|
def npc_list(self, **kw):
|
||||||
|
"""返回所有活跃 NPC(含当前活动和关键状态)。"""
|
||||||
|
npcs = request.env['game.npc'].sudo().search_read(
|
||||||
|
[('active', '=', True)],
|
||||||
|
['id', 'name', 'title', 'code', 'archetype', 'personality',
|
||||||
|
'current_activity', 'energy', 'hunger', 'safety', 'mood',
|
||||||
|
'mood_label', 'activity_label', 'goal_progress', 'current_goal',
|
||||||
|
'current_area', 'relationship_count', 'recent_log', 'tick_count',
|
||||||
|
'affiliation'],
|
||||||
|
order='sequence, id'
|
||||||
|
)
|
||||||
|
for n in npcs:
|
||||||
|
n['archetype_label'] = dict(
|
||||||
|
request.env['game.npc']._fields['archetype'].selection
|
||||||
|
).get(n['archetype'], n['archetype'])
|
||||||
|
|
||||||
|
return _json({'npcs': npcs})
|
||||||
|
|
||||||
|
# ── NPC 详情 ──
|
||||||
|
@http.route('/game/api/npc/<int:npc_id>', type='http', auth='public', methods=['GET'], csrf=False)
|
||||||
|
def npc_detail(self, npc_id, **kw):
|
||||||
|
"""NPC 详情(含关系网 + 最近行为 + 知识)。"""
|
||||||
|
Npc = request.env['game.npc'].sudo()
|
||||||
|
npc = Npc.browse(npc_id)
|
||||||
|
if not npc.exists():
|
||||||
|
return _json({'error': 'NPC 不存在'})
|
||||||
|
|
||||||
|
data = npc.read([
|
||||||
|
'id', 'name', 'title', 'code', 'archetype', 'personality',
|
||||||
|
'origin', 'bio', 'current_activity', 'energy', 'hunger', 'safety',
|
||||||
|
'mood', 'mood_label', 'activity_label', 'goal_progress', 'current_goal',
|
||||||
|
'goal_detail', 'current_area', 'tick_count', 'last_tick',
|
||||||
|
'affiliation',
|
||||||
|
])
|
||||||
|
if not data:
|
||||||
|
return _json({'error': 'NPC 不存在'})
|
||||||
|
data = data[0]
|
||||||
|
|
||||||
|
# 关系网
|
||||||
|
rels = request.env['game.npc.relationship'].sudo().search_read(
|
||||||
|
[('npc_id', '=', npc_id)],
|
||||||
|
['target_npc_id', 'affinity', 'status', 'last_interaction'],
|
||||||
|
order='affinity desc'
|
||||||
|
)
|
||||||
|
data['relationships'] = [{
|
||||||
|
'target_id': r['target_npc_id'][0] if r['target_npc_id'] else None,
|
||||||
|
'target_name': r['target_npc_id'][1] if r['target_npc_id'] else '',
|
||||||
|
'affinity': r['affinity'],
|
||||||
|
'status': r['status'],
|
||||||
|
} for r in rels]
|
||||||
|
|
||||||
|
# 最近行为(20 条)
|
||||||
|
logs = request.env['game.npc.behavior.log'].sudo().search_read(
|
||||||
|
[('npc_id', '=', npc_id)],
|
||||||
|
['tick_time', 'activity', 'activity_label', 'location', 'summary', 'target_npc_id'],
|
||||||
|
limit=20, order='tick_time desc'
|
||||||
|
)
|
||||||
|
data['recent_behaviors'] = [{
|
||||||
|
'time': str(l['tick_time']),
|
||||||
|
'activity': l['activity'],
|
||||||
|
'label': l['activity_label'],
|
||||||
|
'location': l['location'] or '',
|
||||||
|
'summary': l['summary'],
|
||||||
|
} for l in logs]
|
||||||
|
|
||||||
|
# 知识
|
||||||
|
knowledge = request.env['game.npc.knowledge'].sudo().search_read(
|
||||||
|
[('npc_id', '=', npc_id)],
|
||||||
|
['topic', 'content', 'accuracy', 'source'],
|
||||||
|
order='sequence, id'
|
||||||
|
)
|
||||||
|
data['knowledge'] = knowledge
|
||||||
|
|
||||||
|
return _json(data)
|
||||||
|
|
||||||
|
# ── NPC 关系网 ──
|
||||||
|
@http.route('/game/api/npc/<int:npc_id>/relationships', type='http', auth='public', methods=['GET'], csrf=False)
|
||||||
|
def npc_relationships(self, npc_id, **kw):
|
||||||
|
rels = request.env['game.npc.relationship'].sudo().search_read(
|
||||||
|
[('npc_id', '=', npc_id)],
|
||||||
|
['target_npc_id', 'affinity', 'status', 'last_interaction', 'interaction_count'],
|
||||||
|
order='affinity desc'
|
||||||
|
)
|
||||||
|
return _json({'relationships': [{
|
||||||
|
'target_id': r['target_npc_id'][0] if r['target_npc_id'] else None,
|
||||||
|
'target_name': r['target_npc_id'][1] if r['target_npc_id'] else '',
|
||||||
|
'affinity': r['affinity'],
|
||||||
|
'status': r['status'],
|
||||||
|
'last_interaction': str(r['last_interaction']),
|
||||||
|
'interaction_count': r['interaction_count'],
|
||||||
|
} for r in rels]})
|
||||||
|
|
||||||
|
# ── 主动触发单个 NPC Tick ──
|
||||||
|
@http.route('/game/api/npc/<int:npc_id>/tick', type='http', auth='user', methods=['POST'], csrf=False)
|
||||||
|
def npc_tick(self, npc_id, **kw):
|
||||||
|
npc = request.env['game.npc'].sudo().browse(npc_id)
|
||||||
|
if not npc.exists():
|
||||||
|
return _json({'error': 'NPC 不存在'})
|
||||||
|
npc.action_tick()
|
||||||
|
return _json({'ok': True, 'name': npc.name, 'tick_count': npc.tick_count,
|
||||||
|
'activity': npc.activity_label, 'mood': npc.mood_label})
|
||||||
|
|
||||||
|
# ── 批量 Tick ──
|
||||||
|
@http.route('/game/api/npc/tick-all', type='http', auth='user', methods=['POST'], csrf=False)
|
||||||
|
def npc_tick_all(self, **kw):
|
||||||
|
count = request.env['game.npc'].sudo().cron_batch_tick(batch_size=100)
|
||||||
|
return _json({'ok': True, 'ticked': count})
|
||||||
229
addons/game_ai_npc/data/game_npc_data.xml
Normal file
229
addons/game_ai_npc/data/game_npc_data.xml
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="0">
|
||||||
|
<!-- ============ NPC 角色 ============ -->
|
||||||
|
<!-- 1. 老村长·张伯 -->
|
||||||
|
<record id="npc_zhang" model="game.npc">
|
||||||
|
<field name="name">张伯</field>
|
||||||
|
<field name="title">老村长</field>
|
||||||
|
<field name="code">zhang_elder</field>
|
||||||
|
<field name="archetype">leader</field>
|
||||||
|
<field name="personality">serious</field>
|
||||||
|
<field name="origin">凡丘村三代定居</field>
|
||||||
|
<field name="current_goal">维持村落安全,度过干旱季</field>
|
||||||
|
<field name="goal_detail">灵气日益稀薄,村民人心惶惶。必须在入冬前说服大家修筑储水渠,并与附近势力保持友好。</field>
|
||||||
|
<field name="energy">70</field>
|
||||||
|
<field name="hunger">75</field>
|
||||||
|
<field name="safety">55</field>
|
||||||
|
<field name="bio"><![CDATA[<p>凡丘村第三任村长,年轻时曾游历五层世界的前三层,见识广博。妻子早逝,独子在外修行。为人严肃但不失公正,村中大事小事都要经他过目。</p><p>他坚信"人定胜天",但对灵气退潮的趋势也感到力不从心。最近常独自站在村口眺望远方的山脉,像是在等待什么。</p>]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- 2. 林铁柱 -->
|
||||||
|
<record id="npc_tiezu" model="game.npc">
|
||||||
|
<field name="name">林铁柱</field>
|
||||||
|
<field name="title">铁匠</field>
|
||||||
|
<field name="code">lin_blacksmith</field>
|
||||||
|
<field name="archetype">artisan</field>
|
||||||
|
<field name="personality">stoic</field>
|
||||||
|
<field name="origin">铁匠世家,三代传人</field>
|
||||||
|
<field name="current_goal">铸出一柄灵器级武器</field>
|
||||||
|
<field name="goal_detail">已收集足够的精铁矿,唯独缺少一缕灵气淬火。需要找到一处灵脉泉眼,或向修士换取灵引符。</field>
|
||||||
|
<field name="energy">65</field>
|
||||||
|
<field name="hunger">70</field>
|
||||||
|
<field name="safety">50</field>
|
||||||
|
<field name="bio"><![CDATA[<p>沉默寡言的大汉,打铁时全神贯注,不理会任何打扰。祖父曾为城主铸剑,家传手艺到这一代已有些失传。</p><p>其实内心渴望超越祖辈,铸出一件真正的灵器。只是嘴上从不提起,只在深夜反复打磨一把未完成的短剑。</p>]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- 3. 柳絮 -->
|
||||||
|
<record id="npc_liuxu" model="game.npc">
|
||||||
|
<field name="name">柳絮</field>
|
||||||
|
<field name="title">游方学者</field>
|
||||||
|
<field name="code">liu_scholar</field>
|
||||||
|
<field name="archetype">scholar</field>
|
||||||
|
<field name="personality">curious</field>
|
||||||
|
<field name="origin">远方灵都游历至此</field>
|
||||||
|
<field name="current_goal">记录凡丘村周遭的古遗迹</field>
|
||||||
|
<field name="goal_detail">听说村外三里处有一处坍塌的古代祭坛,碑文尚未被人破译。如果能解读出来,或许能揭开这片区域灵气退潮的秘密。</field>
|
||||||
|
<field name="energy">50</field>
|
||||||
|
<field name="hunger">60</field>
|
||||||
|
<field name="safety">40</field>
|
||||||
|
<field name="bio"><![CDATA[<p>一身青衫,背挂书箱,腰悬毛笔筒。对一切未知充满好奇,逮着谁都要问两句本地传说。说话快,思维跳跃,常常自言自语地记笔记。</p><p>其实她来自灵都的没落书香门第,因不满家族安排的婚事而离家出走,游历世界寻找"值得写的书"。凡丘村只是她旅途的一站,但古祭坛的发现让她滞留了三个月。</p>]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- 4. 花娘 -->
|
||||||
|
<record id="npc_hua" model="game.npc">
|
||||||
|
<field name="name">花娘</field>
|
||||||
|
<field name="title">行商</field>
|
||||||
|
<field name="code">hua_merchant</field>
|
||||||
|
<field name="archetype">merchant</field>
|
||||||
|
<field name="personality">cheerful</field>
|
||||||
|
<field name="origin">流动商队出身</field>
|
||||||
|
<field name="current_goal">开辟一条稳定的跨层商路</field>
|
||||||
|
<field name="goal_detail">手里有一些地下层出产的黑曜石和星空层的星尘砂,如果能在大地层的村落间建立定期贸易路线,就能赚到足够的钱在灵都盘下一间铺面。</field>
|
||||||
|
<field name="energy">75</field>
|
||||||
|
<field name="hunger">65</field>
|
||||||
|
<field name="safety">35</field>
|
||||||
|
<field name="bio"><![CDATA[<p>圆脸笑眼,见人三分熟,嘴甜会来事。商队出身,从小就跟着驼兽在各层之间往返。攒了几年钱后开始单干,货物五花八门——从地心灵芝到天空羽毛,什么都倒腾。</p><p>嘴上说想开店安定下来,但骨子里还是享受在路上的感觉。花娘不是真名,没人知道她本名叫什么。</p>]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- 5. 阿猛 -->
|
||||||
|
<record id="npc_ameng" model="game.npc">
|
||||||
|
<field name="name">阿猛</field>
|
||||||
|
<field name="title">猎户</field>
|
||||||
|
<field name="code">a_meng_hunter</field>
|
||||||
|
<field name="archetype">warrior</field>
|
||||||
|
<field name="personality">bold</field>
|
||||||
|
<field name="origin">凡丘村本土</field>
|
||||||
|
<field name="current_goal">猎杀袭击村落的妖兽</field>
|
||||||
|
<field name="goal_detail">最近村外出现了一只三尾狐兽,已叼走了几只家畜。阿猛立誓要在下个月前解决它,保护好村里的老人和孩子。</field>
|
||||||
|
<field name="energy">85</field>
|
||||||
|
<field name="hunger">80</field>
|
||||||
|
<field name="safety">60</field>
|
||||||
|
<field name="bio"><![CDATA[<p>二十出头,虎背熊腰,性格耿直得有点莽撞。从小在林子里长大,弓箭和陷阱手艺是跟过路的巡林客学的。虽然平时大大咧咧,但保护村民的事从不含糊。</p><p>暗地里对柳絮有点意思,但完全不知道怎么表达,每次见到她就只会挠头傻笑。</p>]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- 6. 老鬼 -->
|
||||||
|
<record id="npc_laogui" model="game.npc">
|
||||||
|
<field name="name">老鬼</field>
|
||||||
|
<field name="title">流浪药师</field>
|
||||||
|
<field name="code">lao_gui_herbalist</field>
|
||||||
|
<field name="archetype">wanderer</field>
|
||||||
|
<field name="personality">mysterious</field>
|
||||||
|
<field name="origin">无人知晓</field>
|
||||||
|
<field name="current_goal">寻一味叫"忘忧草"的药材</field>
|
||||||
|
<field name="goal_detail">听说只有在灵气汇聚的隐秘山谷里才长这种草。他走遍了大地层的大半区域,每到一处就待上三五天,采药、制药,然后继续上路。</field>
|
||||||
|
<field name="energy">45</field>
|
||||||
|
<field name="hunger">50</field>
|
||||||
|
<field name="safety">30</field>
|
||||||
|
<field name="bio"><![CDATA[<p>驼背,消瘦,总是戴着一顶破斗笠,遮住大半张脸。说话声音沙哑,从不多说一个字。医术极高,村民的小病小痛他几副药就能治好,但从不收钱,只接受食物和住宿。</p><p>没人知道他从哪里来,为什么要找忘忧草。有人说他年轻时失去过重要的人,也有人说他只是找个由头流浪。他自己从不解释。</p>]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- 7. 小禾 -->
|
||||||
|
<record id="npc_xiaohe" model="game.npc">
|
||||||
|
<field name="name">小禾</field>
|
||||||
|
<field name="title">农家女</field>
|
||||||
|
<field name="code">xiaohe_farmer</field>
|
||||||
|
<field name="archetype">villager</field>
|
||||||
|
<field name="personality">kind</field>
|
||||||
|
<field name="origin">凡丘村土生土长</field>
|
||||||
|
<field name="current_goal">学会识字和记账</field>
|
||||||
|
<field name="goal_detail">村里的账目一直是张伯在管,但张伯年纪大了,眼睛不行了。小禾想学会认字和算术,以后帮村里记账、写文书。</field>
|
||||||
|
<field name="energy">80</field>
|
||||||
|
<field name="hunger">75</field>
|
||||||
|
<field name="safety">65</field>
|
||||||
|
<field name="bio"><![CDATA[<p>十六七岁的姑娘,扎两条麻花辫,眼神清澈。父亲早逝,和母亲相依为命,种着几亩薄田。心地善良,谁家有事她都去帮忙,村里人都喜欢她。</p><p>柳絮来了之后,小禾一有空就跑去找她学字。柳絮也挺喜欢这个勤奋的姑娘,教得很认真。</p>]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ============ 初始关系 ============ -->
|
||||||
|
<!-- 张伯 → 林铁柱:信赖的晚辈 -->
|
||||||
|
<record id="rel_zhang_tiezu" model="game.npc.relationship">
|
||||||
|
<field name="npc_id" ref="npc_zhang"/>
|
||||||
|
<field name="target_npc_id" ref="npc_tiezu"/>
|
||||||
|
<field name="affinity">40</field>
|
||||||
|
<field name="interaction_count">0</field>
|
||||||
|
</record>
|
||||||
|
<!-- 林铁柱 → 张伯:敬重 -->
|
||||||
|
<record id="rel_tiezu_zhang" model="game.npc.relationship">
|
||||||
|
<field name="npc_id" ref="npc_tiezu"/>
|
||||||
|
<field name="target_npc_id" ref="npc_zhang"/>
|
||||||
|
<field name="affinity">35</field>
|
||||||
|
<field name="interaction_count">0</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- 柳絮 → 小禾:学生般的关照 -->
|
||||||
|
<record id="rel_liuxu_xiaohe" model="game.npc.relationship">
|
||||||
|
<field name="npc_id" ref="npc_liuxu"/>
|
||||||
|
<field name="target_npc_id" ref="npc_xiaohe"/>
|
||||||
|
<field name="affinity">45</field>
|
||||||
|
<field name="interaction_count">0</field>
|
||||||
|
</record>
|
||||||
|
<!-- 小禾 → 柳絮:敬仰 -->
|
||||||
|
<record id="rel_xiaohe_liuxu" model="game.npc.relationship">
|
||||||
|
<field name="npc_id" ref="npc_xiaohe"/>
|
||||||
|
<field name="target_npc_id" ref="npc_liuxu"/>
|
||||||
|
<field name="affinity">55</field>
|
||||||
|
<field name="interaction_count">0</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- 阿猛 → 柳絮:暗恋(单向高好感) -->
|
||||||
|
<record id="rel_ameng_liuxu" model="game.npc.relationship">
|
||||||
|
<field name="npc_id" ref="npc_ameng"/>
|
||||||
|
<field name="target_npc_id" ref="npc_liuxu"/>
|
||||||
|
<field name="affinity">60</field>
|
||||||
|
<field name="interaction_count">0</field>
|
||||||
|
</record>
|
||||||
|
<!-- 柳絮 → 阿猛:普通熟人 -->
|
||||||
|
<record id="rel_liuxu_ameng" model="game.npc.relationship">
|
||||||
|
<field name="npc_id" ref="npc_liuxu"/>
|
||||||
|
<field name="target_npc_id" ref="npc_ameng"/>
|
||||||
|
<field name="affinity">15</field>
|
||||||
|
<field name="interaction_count">0</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- 张伯 → 花娘:商业伙伴 -->
|
||||||
|
<record id="rel_zhang_hua" model="game.npc.relationship">
|
||||||
|
<field name="npc_id" ref="npc_zhang"/>
|
||||||
|
<field name="target_npc_id" ref="npc_hua"/>
|
||||||
|
<field name="affinity">25</field>
|
||||||
|
<field name="interaction_count">0</field>
|
||||||
|
</record>
|
||||||
|
<!-- 花娘 → 张伯:互信 -->
|
||||||
|
<record id="rel_hua_zhang" model="game.npc.relationship">
|
||||||
|
<field name="npc_id" ref="npc_hua"/>
|
||||||
|
<field name="target_npc_id" ref="npc_zhang"/>
|
||||||
|
<field name="affinity">30</field>
|
||||||
|
<field name="interaction_count">0</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- 老鬼 → 所有人:陌生人(不创建关系,让 Tick 自然生成) -->
|
||||||
|
|
||||||
|
<!-- ============ 初始知识 ============ -->
|
||||||
|
<record id="know_zhang_1" model="game.npc.knowledge">
|
||||||
|
<field name="npc_id" ref="npc_zhang"/>
|
||||||
|
<field name="topic">祭坛</field>
|
||||||
|
<field name="content">村外三里处的古祭坛是上一轮文明留下的,据说和灵气退潮有关。年轻时曾进去过一次,里面刻满了看不懂的符文。</field>
|
||||||
|
<field name="accuracy">0.8</field>
|
||||||
|
<field name="source">年轻时亲身探索</field>
|
||||||
|
</record>
|
||||||
|
<record id="know_liuxu_1" model="game.npc.knowledge">
|
||||||
|
<field name="npc_id" ref="npc_liuxu"/>
|
||||||
|
<field name="topic">祭坛符文</field>
|
||||||
|
<field name="content">祭坛主碑文是古灵篆的一种变体,开头一行破译了一半,大意是'灵气如潮,进退有时'。可能和世界的自然周期有关。</field>
|
||||||
|
<field name="accuracy">0.6</field>
|
||||||
|
<field name="source">三个月研读碑文拓片</field>
|
||||||
|
</record>
|
||||||
|
<record id="know_tiezu_1" model="game.npc.knowledge">
|
||||||
|
<field name="npc_id" ref="npc_tiezu"/>
|
||||||
|
<field name="topic">灵脉泉眼</field>
|
||||||
|
<field name="content">听祖父说过,村后山深处有一处废弃的灵脉泉眼。如果能找到并疏通,也许还能引出灵气。但那条路很危险,有妖兽盘踞。</field>
|
||||||
|
<field name="accuracy">0.7</field>
|
||||||
|
<field name="source">祖父口述</field>
|
||||||
|
</record>
|
||||||
|
<record id="know_hua_1" model="game.npc.knowledge">
|
||||||
|
<field name="npc_id" ref="npc_hua"/>
|
||||||
|
<field name="topic">跨层商路</field>
|
||||||
|
<field name="content">地下层出产黑曜石和夜光矿,星空层出产星尘砂和浮空石,大地层是中间枢纽。如果能打通一条安全的运输路线,利润至少翻三倍。</field>
|
||||||
|
<field name="accuracy">0.9</field>
|
||||||
|
<field name="source">多年经商经验</field>
|
||||||
|
</record>
|
||||||
|
<record id="know_ameng_1" model="game.npc.knowledge">
|
||||||
|
<field name="npc_id" ref="npc_ameng"/>
|
||||||
|
<field name="topic">三尾狐兽</field>
|
||||||
|
<field name="content">那只狐兽通常是夜晚出没,脚印往北山方向消失。体型比普通狐狸大三倍,尾巴有三条,行动极快。皮糙肉厚,普通箭矢射不透。</field>
|
||||||
|
<field name="accuracy">0.85</field>
|
||||||
|
<field name="source">追踪观察半个月</field>
|
||||||
|
</record>
|
||||||
|
<record id="know_laogui_1" model="game.npc.knowledge">
|
||||||
|
<field name="npc_id" ref="npc_laogui"/>
|
||||||
|
<field name="topic">忘忧草</field>
|
||||||
|
<field name="content">忘忧草只生长在灵气浓郁又常年有雾的山谷底部。凡丘村以东百里外有一片雾谷,或许会有。但那片区域是荒兽领地,很危险。</field>
|
||||||
|
<field name="accuracy">0.5</field>
|
||||||
|
<field name="source">多方打听拼凑</field>
|
||||||
|
</record>
|
||||||
|
<record id="know_xiaohe_1" model="game.npc.knowledge">
|
||||||
|
<field name="npc_id" ref="npc_xiaohe"/>
|
||||||
|
<field name="topic">柳絮老师</field>
|
||||||
|
<field name="content">柳老师懂得好多字和学问,她说世界上有五层,每层都有不同的风景。我以后也想走出去看看。</field>
|
||||||
|
<field name="accuracy">1.0</field>
|
||||||
|
<field name="source">柳絮亲自教课</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
5
addons/game_ai_npc/models/__init__.py
Normal file
5
addons/game_ai_npc/models/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import npc
|
||||||
|
from . import npc_relationship
|
||||||
|
from . import npc_behavior
|
||||||
|
from . import npc_knowledge
|
||||||
380
addons/game_ai_npc/models/npc.py
Normal file
380
addons/game_ai_npc/models/npc.py
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── 角色类型/日程/效果常量 ──
|
||||||
|
|
||||||
|
ARCHETYPE_SCHEDULES = {
|
||||||
|
'villager': {
|
||||||
|
0:'sleep', 1:'sleep', 2:'sleep', 3:'sleep', 4:'sleep', 5:'wake',
|
||||||
|
6:'rest', 7:'eat', 8:'work', 9:'work', 10:'work', 11:'work',
|
||||||
|
12:'eat', 13:'rest', 14:'work', 15:'work', 16:'work', 17:'work',
|
||||||
|
18:'rest', 19:'eat', 20:'social', 21:'social', 22:'social', 23:'sleep',
|
||||||
|
},
|
||||||
|
'merchant': {
|
||||||
|
0:'sleep', 1:'sleep', 2:'sleep', 3:'sleep', 4:'sleep', 5:'wake',
|
||||||
|
6:'rest', 7:'eat', 8:'trade', 9:'trade', 10:'trade', 11:'trade',
|
||||||
|
12:'eat', 13:'rest', 14:'trade', 15:'trade', 16:'trade', 17:'trade',
|
||||||
|
18:'rest', 19:'eat', 20:'social', 21:'social', 22:'social', 23:'sleep',
|
||||||
|
},
|
||||||
|
'warrior': {
|
||||||
|
0:'sleep', 1:'sleep', 2:'sleep', 3:'sleep', 4:'sleep', 5:'wake',
|
||||||
|
6:'train', 7:'eat', 8:'train', 9:'work', 10:'work', 11:'work',
|
||||||
|
12:'eat', 13:'rest', 14:'work', 15:'work', 16:'train', 17:'rest',
|
||||||
|
18:'eat', 19:'social', 20:'social', 21:'wander', 22:'wander', 23:'sleep',
|
||||||
|
},
|
||||||
|
'scholar': {
|
||||||
|
0:'sleep', 1:'sleep', 2:'sleep', 3:'sleep', 4:'sleep', 5:'wake',
|
||||||
|
6:'rest', 7:'eat', 8:'study', 9:'study', 10:'study', 11:'study',
|
||||||
|
12:'eat', 13:'rest', 14:'study', 15:'study', 16:'social', 17:'rest',
|
||||||
|
18:'eat', 19:'social', 20:'social', 21:'study', 22:'wander', 23:'sleep',
|
||||||
|
},
|
||||||
|
'artisan': {
|
||||||
|
0:'sleep', 1:'sleep', 2:'sleep', 3:'sleep', 4:'sleep', 5:'wake',
|
||||||
|
6:'rest', 7:'eat', 8:'craft', 9:'craft', 10:'craft', 11:'craft',
|
||||||
|
12:'eat', 13:'rest', 14:'craft', 15:'craft', 16:'craft', 17:'craft',
|
||||||
|
18:'rest', 19:'eat', 20:'social', 21:'social', 22:'social', 23:'sleep',
|
||||||
|
},
|
||||||
|
'leader': {
|
||||||
|
0:'sleep', 1:'sleep', 2:'sleep', 3:'sleep', 4:'sleep', 5:'wake',
|
||||||
|
6:'rest', 7:'eat', 8:'work', 9:'social', 10:'work', 11:'work',
|
||||||
|
12:'eat', 13:'rest', 14:'work', 15:'social', 16:'wander', 17:'rest',
|
||||||
|
18:'eat', 19:'social', 20:'social', 21:'social', 22:'work', 23:'sleep',
|
||||||
|
},
|
||||||
|
'wanderer': {
|
||||||
|
0:'sleep', 1:'sleep', 2:'sleep', 3:'sleep', 4:'wander', 5:'wake',
|
||||||
|
6:'wander', 7:'eat', 8:'wander', 9:'wander', 10:'explore', 11:'explore',
|
||||||
|
12:'eat', 13:'rest', 14:'wander', 15:'wander', 16:'explore', 17:'rest',
|
||||||
|
18:'eat', 19:'social', 20:'social', 21:'wander', 22:'wander', 23:'sleep',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTIVITY_EFFECTS = {
|
||||||
|
'sleep': {'energy': 40, 'hunger': -2, 'safety': 5, 'goal': 0},
|
||||||
|
'wake': {'energy': 10, 'hunger': -3, 'safety': 2, 'goal': 0},
|
||||||
|
'eat': {'energy': 5, 'hunger': 35, 'safety': 0, 'goal': 0},
|
||||||
|
'work': {'energy': -15,'hunger': -8, 'safety': -2, 'goal': (1,5)},
|
||||||
|
'rest': {'energy': 15, 'hunger': -2, 'safety': 0, 'goal': 0},
|
||||||
|
'social': {'energy': -5, 'hunger': -4, 'safety': 5, 'goal': 0},
|
||||||
|
'wander': {'energy': -8, 'hunger': -5, 'safety': -3, 'goal': (0,2)},
|
||||||
|
'explore': {'energy': -12,'hunger': -7, 'safety': -5, 'goal': (1,4)},
|
||||||
|
'study': {'energy': -10,'hunger': -6, 'safety': 0, 'goal': (1,3)},
|
||||||
|
'craft': {'energy': -12,'hunger': -7, 'safety': 0, 'goal': (2,5)},
|
||||||
|
'trade': {'energy': -5, 'hunger': -4, 'safety': 2, 'goal': (1,4)},
|
||||||
|
'train': {'energy': -20,'hunger': -9, 'safety': 0, 'goal': (2,6)},
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTIVITY_LABELS = {
|
||||||
|
'sleep': '💤 睡眠', 'wake': '☀ 苏醒', 'eat': '🍽 用餐',
|
||||||
|
'work': '⚒ 劳作', 'rest': '🛋 休息', 'social': '💬 社交',
|
||||||
|
'wander': '🚶 游荡', 'explore': '🔍 探索', 'study': '📖 研习',
|
||||||
|
'craft': '🔨 制作', 'trade': '💰 交易', 'train': '⚔ 训练',
|
||||||
|
}
|
||||||
|
|
||||||
|
ARCHETYPE_LABELS = {
|
||||||
|
'villager': '居民', 'merchant': '商人', 'warrior': '战士',
|
||||||
|
'scholar': '学者', 'artisan': '工匠', 'leader': '领袖',
|
||||||
|
'wanderer': '游荡者',
|
||||||
|
}
|
||||||
|
|
||||||
|
MOOD_ORDER = ['rage', 'sad', 'low', 'fair', 'good', 'cheerful', 'ecstatic']
|
||||||
|
MOOD_LABELS = {
|
||||||
|
'rage': '🤬 暴怒', 'sad': '😢 悲伤', 'low': '😟 低落',
|
||||||
|
'fair': '😐 一般', 'good': '🙂 不错', 'cheerful': '😊 愉快',
|
||||||
|
'ecstatic': '🤩 极佳',
|
||||||
|
}
|
||||||
|
|
||||||
|
RELATION_ORDER = ['enemy', 'hostile', 'stranger', 'acquaintance', 'friend', 'close_friend', 'family']
|
||||||
|
RELATION_LABELS = {
|
||||||
|
'enemy': '死敌', 'hostile': '敌对', 'stranger': '陌生人',
|
||||||
|
'acquaintance': '熟人', 'friend': '朋友', 'close_friend': '挚友',
|
||||||
|
'family': '至亲',
|
||||||
|
}
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════
|
||||||
|
# NPC 主模型
|
||||||
|
# ════════════════════════════════════════════
|
||||||
|
|
||||||
|
class GameNpc(models.Model):
|
||||||
|
_name = 'game.npc'
|
||||||
|
_description = 'NPC 角色'
|
||||||
|
_rec_name = 'name'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
|
||||||
|
# ── 基本身份 ──
|
||||||
|
name = fields.Char(string='姓名', required=True, index=True)
|
||||||
|
title = fields.Char(string='称号', help='如:铁匠、老村长')
|
||||||
|
code = fields.Char(string='代号', help='NPC 唯一标识,用于前端引用')
|
||||||
|
active = fields.Boolean(string='激活', default=True)
|
||||||
|
sequence = fields.Integer(string='排序', default=10)
|
||||||
|
|
||||||
|
archetype = fields.Selection([
|
||||||
|
('villager', '居民'),
|
||||||
|
('merchant', '商人'),
|
||||||
|
('warrior', '战士'),
|
||||||
|
('scholar', '学者'),
|
||||||
|
('artisan', '工匠'),
|
||||||
|
('leader', '领袖'),
|
||||||
|
('wanderer', '游荡者'),
|
||||||
|
], string='角色类型', required=True, default='villager',
|
||||||
|
help='决定日程模板和行为倾向')
|
||||||
|
|
||||||
|
personality = fields.Selection([
|
||||||
|
('gentle', '温和'), ('cheerful', '开朗'), ('serious', '严肃'),
|
||||||
|
('cautious', '谨慎'), ('bold', '大胆'), ('mysterious', '神秘'),
|
||||||
|
('grumpy', '暴躁'), ('kind', '善良'), ('crafty', '狡黠'),
|
||||||
|
('stoic', '刚毅'), ('curious', '好奇'), ('lazy', '慵懒'),
|
||||||
|
], string='性格', required=True, default='gentle')
|
||||||
|
|
||||||
|
origin = fields.Char(string='出身')
|
||||||
|
bio = fields.Html(string='背景故事', help='NPC 的完整背景描述')
|
||||||
|
|
||||||
|
# ── 目标 ──
|
||||||
|
current_goal = fields.Char(string='当前目标',
|
||||||
|
help='如:收集 100 木材 / 修建防御工事 / 研究古代符文')
|
||||||
|
goal_progress = fields.Integer(string='目标进度', default=0)
|
||||||
|
goal_detail = fields.Text(string='目标说明')
|
||||||
|
|
||||||
|
# ── 状态数值(0-100) ──
|
||||||
|
energy = fields.Integer(string='精力', default=80, help='影响可执行的工作效率')
|
||||||
|
hunger = fields.Integer(string='饱腹', default=70)
|
||||||
|
safety = fields.Integer(string='安全感', default=60,
|
||||||
|
help='受所在区域和平度、关系网保护力影响')
|
||||||
|
mood = fields.Selection([
|
||||||
|
('rage', '暴怒'), ('sad', '悲伤'), ('low', '低落'),
|
||||||
|
('fair', '一般'), ('good', '不错'), ('cheerful', '愉快'),
|
||||||
|
('ecstatic', '极佳'),
|
||||||
|
], string='心情', default='fair', compute='_compute_mood', store=True)
|
||||||
|
|
||||||
|
# ── 当前活动 ──
|
||||||
|
current_activity = fields.Selection([
|
||||||
|
('sleep','睡眠'),('wake','苏醒'),('eat','用餐'),('work','劳作'),
|
||||||
|
('rest','休息'),('social','社交'),('wander','游荡'),('explore','探索'),
|
||||||
|
('study','研习'),('craft','制作'),('trade','交易'),('train','训练'),
|
||||||
|
], string='当前活动', default='wake')
|
||||||
|
|
||||||
|
current_area = fields.Char(string='当前区域',
|
||||||
|
help='前端位置标识:如在哪个地块/村落')
|
||||||
|
|
||||||
|
# ── Tick 状态 ──
|
||||||
|
last_tick = fields.Datetime(string='上次行为刻', readonly=True)
|
||||||
|
tick_count = fields.Integer(string='累计刻数', default=0, readonly=True)
|
||||||
|
tick_enabled = fields.Boolean(string='自动 Tick', default=True,
|
||||||
|
help='开启后由 Cron 定时推进行为')
|
||||||
|
|
||||||
|
# ── 关联 ──
|
||||||
|
affiliation = fields.Many2one('game.rule', string='所属势力',
|
||||||
|
domain=[('type', '=', 'faction')],
|
||||||
|
help='关联到 game.rule 中 type=faction 的势力等级记录')
|
||||||
|
|
||||||
|
relationship_ids = fields.One2many(
|
||||||
|
'game.npc.relationship', 'npc_id', string='关系网')
|
||||||
|
behavior_log_ids = fields.One2many(
|
||||||
|
'game.npc.behavior.log', 'npc_id', string='行为日志')
|
||||||
|
knowledge_ids = fields.One2many(
|
||||||
|
'game.npc.knowledge', 'npc_id', string='所知信息')
|
||||||
|
|
||||||
|
# ── 计算/统计 ──
|
||||||
|
relationship_count = fields.Integer(
|
||||||
|
string='关系数', compute='_count_relations', store=True)
|
||||||
|
recent_log = fields.Text(
|
||||||
|
string='最近行为', compute='_compute_recent_log')
|
||||||
|
|
||||||
|
# ── 虚拟字段 ──
|
||||||
|
mood_label = fields.Char(string='心情标签', compute='_compute_mood_label')
|
||||||
|
activity_label = fields.Char(string='活动标签', compute='_compute_activity_label')
|
||||||
|
|
||||||
|
# ── 约束 ──
|
||||||
|
_sql_constraints = [
|
||||||
|
('unique_code', 'UNIQUE(code)', 'NPC 代号已存在!'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.depends('relationship_ids')
|
||||||
|
def _count_relations(self):
|
||||||
|
for r in self:
|
||||||
|
r.relationship_count = len(r.relationship_ids)
|
||||||
|
|
||||||
|
@api.depends('behavior_log_ids')
|
||||||
|
def _compute_recent_log(self):
|
||||||
|
for r in self:
|
||||||
|
logs = r.behavior_log_ids.sorted('tick_time', reverse=True)[:3]
|
||||||
|
r.recent_log = '\n'.join(
|
||||||
|
f'[{l.tick_time.strftime("%m-%d %H:%M")}] {l.activity_label}'
|
||||||
|
for l in logs
|
||||||
|
) if logs else '尚无行为'
|
||||||
|
|
||||||
|
@api.depends('mood')
|
||||||
|
def _compute_mood_label(self):
|
||||||
|
for r in self:
|
||||||
|
r.mood_label = MOOD_LABELS.get(r.mood, '😐 一般')
|
||||||
|
|
||||||
|
@api.depends('current_activity')
|
||||||
|
def _compute_activity_label(self):
|
||||||
|
for r in self:
|
||||||
|
r.activity_label = ACTIVITY_LABELS.get(r.current_activity, '')
|
||||||
|
|
||||||
|
# ── 心情计算(基于状态合计) ──
|
||||||
|
@api.depends('energy', 'hunger', 'safety', 'goal_progress')
|
||||||
|
def _compute_mood(self):
|
||||||
|
for r in self:
|
||||||
|
score = (r.energy + r.hunger + r.safety + r.goal_progress) / 4
|
||||||
|
if score >= 85: r.mood = 'ecstatic'
|
||||||
|
elif score >= 70: r.mood = 'cheerful'
|
||||||
|
elif score >= 55: r.mood = 'good'
|
||||||
|
elif score >= 40: r.mood = 'fair'
|
||||||
|
elif score >= 25: r.mood = 'low'
|
||||||
|
elif score >= 15: r.mood = 'sad'
|
||||||
|
else: r.mood = 'rage'
|
||||||
|
|
||||||
|
# ════════════════════════════════════════
|
||||||
|
# Tick 行为模拟
|
||||||
|
# ════════════════════════════════════════
|
||||||
|
|
||||||
|
def _current_hour(self):
|
||||||
|
"""基于上次 Tick 时间推断当前『游戏小时』。"""
|
||||||
|
if not self.last_tick:
|
||||||
|
return 6 # 默认从早晨醒来到开始
|
||||||
|
elapsed_hours = int((fields.Datetime.now() - self.last_tick).total_seconds() // 3600)
|
||||||
|
# 每个刻推进 1 小时(如果长时间离线,只推进有限次,避免一次性耗尽资源)
|
||||||
|
return max(0, min(23, elapsed_hours))
|
||||||
|
|
||||||
|
def _get_activity(self, hour):
|
||||||
|
"""根据角色类型日程获取当前活动。"""
|
||||||
|
schedule = ARCHETYPE_SCHEDULES.get(self.archetype, ARCHETYPE_SCHEDULES['villager'])
|
||||||
|
return schedule.get(hour, 'wake')
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _clamp(self, val, lo=0, hi=100):
|
||||||
|
return max(lo, min(hi, val))
|
||||||
|
|
||||||
|
def action_tick(self):
|
||||||
|
"""执行一个行为刻(可由按钮或 Cron 调用)。"""
|
||||||
|
for npc in self:
|
||||||
|
if not npc.tick_enabled or not npc.active:
|
||||||
|
continue
|
||||||
|
|
||||||
|
hour = npc._current_hour()
|
||||||
|
activity = npc._get_activity(hour)
|
||||||
|
effects = ACTIVITY_EFFECTS.get(activity, ACTIVITY_EFFECTS['rest'])
|
||||||
|
rng = random.Random()
|
||||||
|
|
||||||
|
# ├ 消耗基础资源(每个刻都会消耗)
|
||||||
|
npc.energy = npc._clamp(npc.energy - rng.randint(2, 5))
|
||||||
|
npc.hunger = npc._clamp(npc.hunger - rng.randint(3, 6))
|
||||||
|
|
||||||
|
# ├ 应用活动效果
|
||||||
|
npc.energy = npc._clamp(npc.energy + effects.get('energy', 0))
|
||||||
|
npc.hunger = npc._clamp(npc.hunger + effects.get('hunger', 0))
|
||||||
|
npc.safety = npc._clamp(npc.safety + effects.get('safety', 0))
|
||||||
|
|
||||||
|
goal_range = effects.get('goal', 0)
|
||||||
|
if isinstance(goal_range, (list, tuple)):
|
||||||
|
npc.goal_progress = min(100, npc.goal_progress + rng.randint(goal_range[0], goal_range[1]))
|
||||||
|
elif goal_range:
|
||||||
|
npc.goal_progress = min(100, npc.goal_progress + goal_range)
|
||||||
|
|
||||||
|
# ├ 如果精力 ≤ 0,转为休息
|
||||||
|
if npc.energy <= 5 and activity not in ('sleep', 'wake', 'rest', 'eat'):
|
||||||
|
activity = 'rest'
|
||||||
|
|
||||||
|
# 更新当前活动
|
||||||
|
npc.current_activity = activity
|
||||||
|
|
||||||
|
# ├ 30% 概率触发社交
|
||||||
|
if effects.get('safety', 0) > 0 and rng.randint(1, 100) <= 30:
|
||||||
|
self._socialize(npc)
|
||||||
|
|
||||||
|
# └ 记录行为日志
|
||||||
|
self._log_behavior(npc, activity)
|
||||||
|
|
||||||
|
npc.last_tick = fields.Datetime.now()
|
||||||
|
npc.tick_count += 1
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── 社交逻辑 ──
|
||||||
|
def _socialize(self, npc):
|
||||||
|
"""与其他 NPC 随机社交互动。"""
|
||||||
|
others = self.search([
|
||||||
|
('id', '!=', npc.id),
|
||||||
|
('active', '=', True),
|
||||||
|
('tick_enabled', '=', True),
|
||||||
|
])
|
||||||
|
if not others:
|
||||||
|
return
|
||||||
|
|
||||||
|
target = others[random.randint(0, len(others) - 1)]
|
||||||
|
|
||||||
|
# 性格兼容性计算
|
||||||
|
compat_map = {
|
||||||
|
'gentle': {'gentle':15,'cheerful':10,'cautious':10,'kind':15},
|
||||||
|
'cheerful':{'cheerful':15,'kind':10,'bold':10,'curious':10},
|
||||||
|
'serious': {'serious':10,'cautious':10,'stoic':10,'gentle':5},
|
||||||
|
'cautious':{'cautious':5,'serious':5,'gentle':5,'grumpy':-10},
|
||||||
|
'bold': {'bold':10,'cheerful':10,'curious':10,'grumpy':-5},
|
||||||
|
'mysterious':{'mysterious':10,'curious':10,'serious':5,'lazy':5},
|
||||||
|
'grumpy': {'grumpy':-10,'serious':-5,'stoic':-5},
|
||||||
|
'kind': {'kind':15,'gentle':15,'cheerful':10,'cautious':10},
|
||||||
|
'crafty': {'crafty':10,'mysterious':5,'curious':5},
|
||||||
|
'stoic': {'stoic':10,'serious':10,'cautious':5,'mysterious':5},
|
||||||
|
'curious': {'curious':15,'cheerful':10,'mysterious':10,'crafty':5},
|
||||||
|
'lazy': {'lazy':5,'gentle':5},
|
||||||
|
}
|
||||||
|
affinity_delta = (compat_map.get(npc.personality, {}).get(target.personality, 0)
|
||||||
|
+ random.randint(-5, 5))
|
||||||
|
|
||||||
|
# 查找(或创建)关系记录
|
||||||
|
rel = self.env['game.npc.relationship'].search([
|
||||||
|
('npc_id', '=', npc.id),
|
||||||
|
('target_npc_id', '=', target.id),
|
||||||
|
], limit=1)
|
||||||
|
if not rel:
|
||||||
|
rel = self.env['game.npc.relationship'].create({
|
||||||
|
'npc_id': npc.id,
|
||||||
|
'target_npc_id': target.id,
|
||||||
|
'affinity': affinity_delta,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
rel.affinity = max(-100, min(100, rel.affinity + affinity_delta))
|
||||||
|
rel.last_interaction = fields.Datetime.now()
|
||||||
|
|
||||||
|
# 关系层级自动演进
|
||||||
|
rel._update_status()
|
||||||
|
|
||||||
|
def _log_behavior(self, npc, activity):
|
||||||
|
"""生成行为日志。"""
|
||||||
|
self.env['game.npc.behavior.log'].create({
|
||||||
|
'npc_id': npc.id,
|
||||||
|
'activity': activity,
|
||||||
|
'location': npc.current_area,
|
||||||
|
'summary': f'{npc.name} {ACTIVITY_LABELS.get(activity, activity)}',
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 批量 Tick(Cron 调用) ──
|
||||||
|
@api.model
|
||||||
|
def cron_batch_tick(self, batch_size=50):
|
||||||
|
"""定时 Cron:对所有活跃 NPC 执行一次 Tick。"""
|
||||||
|
npcs = self.search([
|
||||||
|
('active', '=', True),
|
||||||
|
('tick_enabled', '=', True),
|
||||||
|
], limit=batch_size)
|
||||||
|
count = len(npcs)
|
||||||
|
if count:
|
||||||
|
npcs.action_tick()
|
||||||
|
_logger.info(f'[NPC Tick] {count} 个角色完成一个行为刻')
|
||||||
|
return count
|
||||||
|
|
||||||
|
# ── Name Get ──
|
||||||
|
def name_get(self):
|
||||||
|
res = []
|
||||||
|
for r in self:
|
||||||
|
display = r.name
|
||||||
|
if r.title:
|
||||||
|
display = f'{r.name} · {r.title}'
|
||||||
|
res.append((r.id, display))
|
||||||
|
return res
|
||||||
42
addons/game_ai_npc/models/npc_behavior.py
Normal file
42
addons/game_ai_npc/models/npc_behavior.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
ACTIVITY_LABELS = {
|
||||||
|
'sleep':'💤 睡眠', 'wake':'☀ 苏醒', 'eat':'🍽 用餐', 'work':'⚒ 劳作',
|
||||||
|
'rest':'🛋 休息', 'social':'💬 社交', 'wander':'🚶 游荡', 'explore':'🔍 探索',
|
||||||
|
'study':'📖 研习', 'craft':'🔨 制作', 'trade':'💰 交易', 'train':'⚔ 训练',
|
||||||
|
}
|
||||||
|
|
||||||
|
class GameNpcBehaviorLog(models.Model):
|
||||||
|
_name = 'game.npc.behavior.log'
|
||||||
|
_description = 'NPC 行为日志'
|
||||||
|
_order = 'tick_time desc, id desc'
|
||||||
|
_rec_name = 'summary'
|
||||||
|
|
||||||
|
npc_id = fields.Many2one('game.npc', string='角色', required=True, ondelete='cascade', index=True)
|
||||||
|
tick_time = fields.Datetime(string='行为时间', default=fields.Datetime.now, index=True)
|
||||||
|
activity = fields.Selection([
|
||||||
|
('sleep','睡眠'),('wake','苏醒'),('eat','用餐'),('work','劳作'),
|
||||||
|
('rest','休息'),('social','社交'),('wander','游荡'),('explore','探索'),
|
||||||
|
('study','研习'),('craft','制作'),('trade','交易'),('train','训练'),
|
||||||
|
], string='活动类型', required=True)
|
||||||
|
location = fields.Char(string='地点')
|
||||||
|
target_npc_id = fields.Many2one('game.npc', string='互动对象')
|
||||||
|
summary = fields.Char(string='简述', required=True)
|
||||||
|
detail = fields.Text(string='详细描述')
|
||||||
|
world_context = fields.Text(string='世界上下文',
|
||||||
|
help='Tick 时的世界状态 JSON,供后续 LLM 对话使用')
|
||||||
|
|
||||||
|
activity_label = fields.Char(string='活动标签', compute='_compute_activity_label')
|
||||||
|
|
||||||
|
@api.depends('activity')
|
||||||
|
def _compute_activity_label(self):
|
||||||
|
for r in self:
|
||||||
|
r.activity_label = ACTIVITY_LABELS.get(r.activity, r.activity)
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
for vals in vals_list:
|
||||||
|
if not vals.get('tick_time'):
|
||||||
|
vals['tick_time'] = fields.Datetime.now()
|
||||||
|
return super().create(vals_list)
|
||||||
18
addons/game_ai_npc/models/npc_knowledge.py
Normal file
18
addons/game_ai_npc/models/npc_knowledge.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
class GameNpcKnowledge(models.Model):
|
||||||
|
_name = 'game.npc.knowledge'
|
||||||
|
_description = 'NPC 所知信息'
|
||||||
|
_rec_name = 'topic'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
|
||||||
|
npc_id = fields.Many2one('game.npc', string='角色', required=True, ondelete='cascade', index=True)
|
||||||
|
topic = fields.Char(string='知识主题', required=True, help='如:水源位置、人物传闻、古代遗迹')
|
||||||
|
content = fields.Text(string='所知内容', required=True)
|
||||||
|
accuracy = fields.Float(string='准确性', default=1.0,
|
||||||
|
help='0.0=完全错误, 1.0=完全准确。模拟谣言、误解、夸张。')
|
||||||
|
source = fields.Char(string='信息来源', help='如:亲眼所见 / 听某人说的 / 古书记载')
|
||||||
|
sequence = fields.Integer(string='排序', default=10)
|
||||||
|
|
||||||
|
discovered_date = fields.Datetime(string='得知时间', default=fields.Datetime.now)
|
||||||
64
addons/game_ai_npc/models/npc_relationship.py
Normal file
64
addons/game_ai_npc/models/npc_relationship.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
RELATION_STATUS_ORDER = [
|
||||||
|
'enemy', 'hostile', 'stranger', 'acquaintance', 'friend', 'close_friend', 'family',
|
||||||
|
]
|
||||||
|
RELATION_STATUS_LABELS = {
|
||||||
|
'enemy': '死敌', 'hostile': '敌对', 'stranger': '陌生人',
|
||||||
|
'acquaintance': '熟人', 'friend': '朋友', 'close_friend': '挚友', 'family': '至亲',
|
||||||
|
}
|
||||||
|
|
||||||
|
class GameNpcRelationship(models.Model):
|
||||||
|
_name = 'game.npc.relationship'
|
||||||
|
_description = 'NPC 关系'
|
||||||
|
_rec_name = 'display_name'
|
||||||
|
_order = 'affinity desc'
|
||||||
|
|
||||||
|
npc_id = fields.Many2one('game.npc', string='角色', required=True, ondelete='cascade', index=True)
|
||||||
|
target_npc_id = fields.Many2one('game.npc', string='对方', required=True, ondelete='cascade', index=True)
|
||||||
|
affinity = fields.Integer(string='好感度', default=0,
|
||||||
|
help='范围 -100(死敌)~ 100(至亲),0 = 陌生人')
|
||||||
|
status = fields.Selection([
|
||||||
|
('enemy', '死敌'), ('hostile', '敌对'), ('stranger', '陌生人'),
|
||||||
|
('acquaintance', '熟人'), ('friend', '朋友'),
|
||||||
|
('close_friend', '挚友'), ('family', '至亲'),
|
||||||
|
], string='关系层级', default='stranger', compute='_compute_status', store=True)
|
||||||
|
|
||||||
|
last_interaction = fields.Datetime(string='最近互动', default=fields.Datetime.now)
|
||||||
|
interaction_count = fields.Integer(string='互动次数', default=1)
|
||||||
|
notes = fields.Text(string='看法备注',
|
||||||
|
help='角色对对方的印象,未来可由 LLM 生成')
|
||||||
|
|
||||||
|
display_name = fields.Char(string='显示名称', compute='_compute_display_name')
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('unique_npc_pair', 'UNIQUE(npc_id, target_npc_id)',
|
||||||
|
'同一对 NPC 只能有一条关系记录!'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.depends('npc_id', 'target_npc_id')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for r in self:
|
||||||
|
source = r.npc_id.name or '?'
|
||||||
|
target = r.target_npc_id.name or '?'
|
||||||
|
r.display_name = f'{source} → {target}'
|
||||||
|
|
||||||
|
@api.depends('affinity')
|
||||||
|
def _compute_status(self):
|
||||||
|
for r in self:
|
||||||
|
r.status = r._calc_status(r.affinity)
|
||||||
|
|
||||||
|
def _calc_status(self, affinity):
|
||||||
|
if affinity <= -70: return 'enemy'
|
||||||
|
if affinity <= -30: return 'hostile'
|
||||||
|
if affinity <= 10: return 'stranger'
|
||||||
|
if affinity <= 30: return 'acquaintance'
|
||||||
|
if affinity <= 60: return 'friend'
|
||||||
|
if affinity <= 80: return 'close_friend'
|
||||||
|
return 'family'
|
||||||
|
|
||||||
|
def _update_status(self):
|
||||||
|
"""将当前好感度同步到关系层级。"""
|
||||||
|
for r in self:
|
||||||
|
r.status = r._calc_status(r.affinity)
|
||||||
74
addons/game_ai_npc/overview.md
Normal file
74
addons/game_ai_npc/overview.md
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# game_ai_npc 模块概览
|
||||||
|
|
||||||
|
## 已创建文件(16 个)
|
||||||
|
|
||||||
|
```
|
||||||
|
addons/game_ai_npc/
|
||||||
|
├── __init__.py
|
||||||
|
├── __manifest__.py
|
||||||
|
├── models/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── npc.py # NPC 主模型 + Tick 逻辑 + Cron 批处理
|
||||||
|
│ ├── npc_relationship.py # NPC 关系(好感度/关系层级)
|
||||||
|
│ ├── npc_behavior.py # 行为日志
|
||||||
|
│ └── npc_knowledge.py # NPC 所知信息(含谣言/准确性)
|
||||||
|
├── controllers/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── npc_api.py # REST API
|
||||||
|
├── security/
|
||||||
|
│ └── ir.model.access.csv
|
||||||
|
├── views/
|
||||||
|
│ ├── npc_views.xml # NPC 角色(列表+表单+ Cron)
|
||||||
|
│ ├── npc_relationship_views.xml
|
||||||
|
│ ├── npc_behavior_views.xml
|
||||||
|
│ ├── npc_knowledge_views.xml
|
||||||
|
│ └── menu.xml # 菜单:AI NPC → 角色/关系/日志/知识
|
||||||
|
├── data/
|
||||||
|
│ └── game_npc_data.xml # 7 个 NPC + 关系 + 知识 种子数据
|
||||||
|
└── overview.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 模型结构
|
||||||
|
|
||||||
|
| 模型 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `game.npc` | 角色(身份/性格/统计/目标/行为刻) |
|
||||||
|
| `game.npc.relationship` | 关系(好感度-100~100、自动推导层级) |
|
||||||
|
| `game.npc.behavior.log` | 行为日志(每次 Tick 自动记录) |
|
||||||
|
| `game.npc.knowledge` | 知识体系(含准确性、来源) |
|
||||||
|
|
||||||
|
## 核心设计
|
||||||
|
|
||||||
|
**NPC Tick 周期**(Odoo Cron 每 15 分钟推一次):
|
||||||
|
1. 按角色类型日程表确定当前活动(居民/商人/战士/学者/工匠/领袖/游荡者各有不同日程)
|
||||||
|
2. 消耗基础资源(精力↓、饱腹↓)
|
||||||
|
3. 应用活动效果(劳作涨目标进度、用餐涨饱腹、睡眠涨精力)
|
||||||
|
4. 30% 概率触发社交 → 性格兼容性计算 → 好感变化 → 关系层级自动推导
|
||||||
|
5. 综合状态更新心情
|
||||||
|
6. 记录行为日志
|
||||||
|
|
||||||
|
**关系系统**:好感度 -100~100,自动推到 7 个层级(死敌→敌对→陌生人→熟人→朋友→挚友→至亲),性格兼容性影响社交互动结果。
|
||||||
|
|
||||||
|
## API 端点
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/game/api/npcs` | GET | 所有活跃 NPC 列表 |
|
||||||
|
| `/game/api/npc/<id>` | GET | NPC 详情(含关系+行为+知识) |
|
||||||
|
| `/game/api/npc/<id>/relationships` | GET | NPC 关系网 |
|
||||||
|
| `/game/api/npc/<id>/tick` | POST | 手动触发单个 NPC Tick(需登录) |
|
||||||
|
| `/game/api/npc/tick-all` | POST | 批量 Tick 所有 NPC(需登录) |
|
||||||
|
|
||||||
|
## 种子数据(7 个角色)
|
||||||
|
|
||||||
|
| NPC | 类型 | 性格 | 目标 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 张伯·老村长 | 领袖 | 严肃 | 维持村落安全 |
|
||||||
|
| 林铁柱·铁匠 | 工匠 | 刚毅 | 铸灵器级武器 |
|
||||||
|
| 柳絮·游方学者 | 学者 | 好奇 | 记录古遗迹 |
|
||||||
|
| 花娘·行商 | 商人 | 开朗 | 开辟跨层商路 |
|
||||||
|
| 阿猛·猎户 | 战士 | 大胆 | 猎杀三尾狐兽 |
|
||||||
|
| 老鬼·流浪药师 | 游荡者 | 神秘 | 寻忘忧草 |
|
||||||
|
| 小禾·农家女 | 居民 | 善良 | 学会识字记账 |
|
||||||
|
|
||||||
|
初始关系:张伯↔林铁柱(信赖)、张伯↔花娘(商业伙伴)、柳絮↔小禾(师生)、阿猛→柳絮(暗恋)。
|
||||||
5
addons/game_ai_npc/security/ir.model.access.csv
Normal file
5
addons/game_ai_npc/security/ir.model.access.csv
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
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
|
||||||
|
22
addons/game_ai_npc/views/menu.xml
Normal file
22
addons/game_ai_npc/views/menu.xml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<menuitem id="menu_game_npc_root" name="AI NPC"
|
||||||
|
parent="game_base.menu_game_root" sequence="50"
|
||||||
|
groups="base.group_user"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_game_npc_characters" name="NPC 角色"
|
||||||
|
parent="menu_game_npc_root"
|
||||||
|
action="action_game_npc" sequence="10"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_game_npc_relationships" name="关系网"
|
||||||
|
parent="menu_game_npc_root"
|
||||||
|
action="action_game_npc_relationship" sequence="20"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_game_npc_behaviors" name="行为日志"
|
||||||
|
parent="menu_game_npc_root"
|
||||||
|
action="action_game_npc_behavior" sequence="30"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_game_npc_knowledge" name="NPC 知识"
|
||||||
|
parent="menu_game_npc_root"
|
||||||
|
action="action_game_npc_knowledge" sequence="40"/>
|
||||||
|
</odoo>
|
||||||
42
addons/game_ai_npc/views/npc_behavior_views.xml
Normal file
42
addons/game_ai_npc/views/npc_behavior_views.xml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="action_game_npc_behavior" model="ir.actions.act_window">
|
||||||
|
<field name="name">行为日志</field>
|
||||||
|
<field name="res_model">game.npc.behavior.log</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="context">{'search_default_group_by_activity': 1}</field>
|
||||||
|
</record>
|
||||||
|
<record id="view_game_npc_behavior_list" model="ir.ui.view">
|
||||||
|
<field name="name">game.npc.behavior.log.list</field>
|
||||||
|
<field name="model">game.npc.behavior.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="activity" column_invisible="1"/>
|
||||||
|
<field name="npc_id"/>
|
||||||
|
<field name="tick_time"/>
|
||||||
|
<field name="activity_label"/>
|
||||||
|
<field name="location"/>
|
||||||
|
<field name="summary"/>
|
||||||
|
<field name="target_npc_id"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="view_game_npc_behavior_form" model="ir.ui.view">
|
||||||
|
<field name="name">game.npc.behavior.log.form</field>
|
||||||
|
<field name="model">game.npc.behavior.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<group>
|
||||||
|
<field name="npc_id"/>
|
||||||
|
<field name="tick_time"/>
|
||||||
|
<field name="activity"/>
|
||||||
|
<field name="location"/>
|
||||||
|
<field name="target_npc_id"/>
|
||||||
|
</group>
|
||||||
|
<field name="summary"/>
|
||||||
|
<field name="detail"/>
|
||||||
|
<field name="world_context"/>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
36
addons/game_ai_npc/views/npc_knowledge_views.xml
Normal file
36
addons/game_ai_npc/views/npc_knowledge_views.xml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="action_game_npc_knowledge" model="ir.actions.act_window">
|
||||||
|
<field name="name">NPC 知识</field>
|
||||||
|
<field name="res_model">game.npc.knowledge</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
<record id="view_game_npc_knowledge_list" model="ir.ui.view">
|
||||||
|
<field name="name">game.npc.knowledge.list</field>
|
||||||
|
<field name="model">game.npc.knowledge</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="npc_id"/>
|
||||||
|
<field name="topic"/>
|
||||||
|
<field name="accuracy" widget="progressbar" options="{'max_value': 1.0}"/>
|
||||||
|
<field name="source"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="view_game_npc_knowledge_form" model="ir.ui.view">
|
||||||
|
<field name="name">game.npc.knowledge.form</field>
|
||||||
|
<field name="model">game.npc.knowledge</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<group>
|
||||||
|
<field name="npc_id"/>
|
||||||
|
<field name="topic"/>
|
||||||
|
<field name="accuracy"/>
|
||||||
|
<field name="source"/>
|
||||||
|
<field name="discovered_date"/>
|
||||||
|
</group>
|
||||||
|
<field name="content"/>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
40
addons/game_ai_npc/views/npc_relationship_views.xml
Normal file
40
addons/game_ai_npc/views/npc_relationship_views.xml
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="action_game_npc_relationship" model="ir.actions.act_window">
|
||||||
|
<field name="name">关系网</field>
|
||||||
|
<field name="res_model">game.npc.relationship</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="context">{'search_default_group_by_status': 1}</field>
|
||||||
|
</record>
|
||||||
|
<record id="view_game_npc_relationship_list" model="ir.ui.view">
|
||||||
|
<field name="name">game.npc.relationship.list</field>
|
||||||
|
<field name="model">game.npc.relationship</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="npc_id"/>
|
||||||
|
<field name="target_npc_id"/>
|
||||||
|
<field name="affinity" widget="progressbar" options="{'max_value': 100}"/>
|
||||||
|
<field name="status" widget="badge"/>
|
||||||
|
<field name="last_interaction"/>
|
||||||
|
<field name="interaction_count"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="view_game_npc_relationship_form" model="ir.ui.view">
|
||||||
|
<field name="name">game.npc.relationship.form</field>
|
||||||
|
<field name="model">game.npc.relationship</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<group>
|
||||||
|
<field name="npc_id"/>
|
||||||
|
<field name="target_npc_id"/>
|
||||||
|
<field name="affinity"/>
|
||||||
|
<field name="status" widget="badge"/>
|
||||||
|
<field name="last_interaction"/>
|
||||||
|
<field name="interaction_count"/>
|
||||||
|
</group>
|
||||||
|
<field name="notes"/>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
116
addons/game_ai_npc/views/npc_views.xml
Normal file
116
addons/game_ai_npc/views/npc_views.xml
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- ============ NPC 角色 ============ -->
|
||||||
|
<record id="action_game_npc" model="ir.actions.act_window">
|
||||||
|
<field name="name">NPC 角色</field>
|
||||||
|
<field name="res_model">game.npc</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="context">{'group_by': 'archetype'}</field>
|
||||||
|
</record>
|
||||||
|
<record id="view_game_npc_list" model="ir.ui.view">
|
||||||
|
<field name="name">game.npc.list</field>
|
||||||
|
<field name="model">game.npc</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="archetype" column_invisible="1"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="title"/>
|
||||||
|
<field name="current_activity"/>
|
||||||
|
<field name="mood" widget="badge"/>
|
||||||
|
<field name="energy" widget="progressbar"/>
|
||||||
|
<field name="hunger" widget="progressbar"/>
|
||||||
|
<field name="goal_progress" widget="progressbar"/>
|
||||||
|
<field name="tick_count"/>
|
||||||
|
<field name="tick_enabled"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="view_game_npc_form" model="ir.ui.view">
|
||||||
|
<field name="name">game.npc.form</field>
|
||||||
|
<field name="model">game.npc</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<header>
|
||||||
|
<button name="action_tick" type="object" string="手动 Tick" class="btn-primary"/>
|
||||||
|
<field name="tick_enabled" widget="boolean_toggle"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<field name="tick_enabled" widget="boolean_toggle"/>
|
||||||
|
<group>
|
||||||
|
<group string="身份">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="title"/>
|
||||||
|
<field name="code"/>
|
||||||
|
<field name="archetype"/>
|
||||||
|
<field name="personality"/>
|
||||||
|
<field name="origin"/>
|
||||||
|
<field name="affiliation"/>
|
||||||
|
</group>
|
||||||
|
<group string="状态">
|
||||||
|
<field name="current_activity" widget="badge" decoration-success="1"/>
|
||||||
|
<field name="mood" widget="badge"/>
|
||||||
|
<field name="activity_label"/>
|
||||||
|
<field name="current_area"/>
|
||||||
|
<field name="energy" widget="progressbar"/>
|
||||||
|
<field name="hunger" widget="progressbar"/>
|
||||||
|
<field name="safety" widget="progressbar"/>
|
||||||
|
<field name="goal_progress" widget="progressbar"/>
|
||||||
|
<field name="tick_count"/>
|
||||||
|
<field name="last_tick"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="目标">
|
||||||
|
<field name="current_goal"/>
|
||||||
|
<field name="goal_detail" placeholder="详细描述当前目标"/>
|
||||||
|
</group>
|
||||||
|
<group string="背景">
|
||||||
|
<field name="bio" placeholder="NPC 的完整故事……"/>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="行为日志 (最近 20 条)" name="logs">
|
||||||
|
<field name="behavior_log_ids" nolabel="1" readonly="1">
|
||||||
|
<list limit="20">
|
||||||
|
<field name="tick_time"/>
|
||||||
|
<field name="activity"/>
|
||||||
|
<field name="location"/>
|
||||||
|
<field name="summary"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="关系网" name="relations">
|
||||||
|
<field name="relationship_ids" nolabel="1">
|
||||||
|
<list>
|
||||||
|
<field name="target_npc_id"/>
|
||||||
|
<field name="affinity" widget="progressbar" options="{'max_value': 100}"/>
|
||||||
|
<field name="status"/>
|
||||||
|
<field name="last_interaction"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="所知信息" name="knowledge">
|
||||||
|
<field name="knowledge_ids" nolabel="1">
|
||||||
|
<list>
|
||||||
|
<field name="topic"/>
|
||||||
|
<field name="content"/>
|
||||||
|
<field name="accuracy" widget="progressbar" options="{'max_value': 1.0}"/>
|
||||||
|
<field name="source"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ============ Cron(每 15 分钟批量 Tick) ============ -->
|
||||||
|
<record id="cron_npc_tick" model="ir.cron">
|
||||||
|
<field name="name">NPC 定时行为刻</field>
|
||||||
|
<field name="model_id" ref="model_game_npc"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.cron_batch_tick()</field>
|
||||||
|
<field name="interval_number">15</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active" eval="True"/>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@ -4,11 +4,16 @@
|
|||||||
'summary': '地表存储(沙盘记忆) + 微观世界(1:1预留) + 资源(框架)',
|
'summary': '地表存储(沙盘记忆) + 微观世界(1:1预留) + 资源(框架)',
|
||||||
'description': '每个玩家拥有一个确定性生成的世界;仅持久化玩家改过的地表地块(种子+增量覆盖)。微观世界独立为 1:1 模型仅预留,资源模型为框架。',
|
'description': '每个玩家拥有一个确定性生成的世界;仅持久化玩家改过的地表地块(种子+增量覆盖)。微观世界独立为 1:1 模型仅预留,资源模型为框架。',
|
||||||
'category': 'Game',
|
'category': 'Game',
|
||||||
'depends': ['base'],
|
'depends': ['base', 'web'],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'views/yt_world_views.xml',
|
'views/yt_world_views.xml',
|
||||||
],
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'yt_world/static/src/js/user_menu.js',
|
||||||
|
],
|
||||||
|
},
|
||||||
'application': True,
|
'application': True,
|
||||||
'license': 'AGPL-3',
|
'license': 'AGPL-3',
|
||||||
}
|
}
|
||||||
|
|||||||
31
addons/yt_world/static/src/js/user_menu.js
Normal file
31
addons/yt_world/static/src/js/user_menu.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
/* @odoo-module */
|
||||||
|
import { UserMenu } from "@web/web/user_menu/user_menu";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
patch(UserMenu.prototype, "yt_world.UserMenu", {
|
||||||
|
setup() {
|
||||||
|
this._super(...arguments);
|
||||||
|
},
|
||||||
|
|
||||||
|
get menuItems() {
|
||||||
|
const items = this._super(...arguments) || [];
|
||||||
|
const websiteItem = {
|
||||||
|
id: "website_link",
|
||||||
|
description: "官网",
|
||||||
|
href: "/",
|
||||||
|
callback: () => {
|
||||||
|
window.location.href = "/";
|
||||||
|
},
|
||||||
|
sequence: 5,
|
||||||
|
};
|
||||||
|
// Insert before "设置" (settings) and after separator-ish area
|
||||||
|
const insertIndex = items.findIndex(i => i.id === "settings");
|
||||||
|
if (insertIndex > -1) {
|
||||||
|
items.splice(insertIndex, 0, websiteItem);
|
||||||
|
} else {
|
||||||
|
items.unshift(websiteItem);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
});
|
||||||
60
world.html
60
world.html
@ -36,6 +36,13 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
|
|||||||
font-size: 15px; font-weight: 600; color: #cfe1f3; -webkit-text-fill-color: #cfe1f3;
|
font-size: 15px; font-weight: 600; color: #cfe1f3; -webkit-text-fill-color: #cfe1f3;
|
||||||
margin-left: 10px; padding-left: 10px; border-left: 1px solid rgba(255,255,255,.28);
|
margin-left: 10px; padding-left: 10px; border-left: 1px solid rgba(255,255,255,.28);
|
||||||
}
|
}
|
||||||
|
#topBar .title .worldName.editable { cursor: pointer; padding: 2px 8px; border-radius: 5px; transition: background .15s; }
|
||||||
|
#topBar .title .worldName.editable:hover { background: rgba(255,255,255,.08); }
|
||||||
|
#topBar .title .worldName-input {
|
||||||
|
background: transparent; border: 1px solid rgba(124,199,255,.6); color: #cfe1f3;
|
||||||
|
font-size: 15px; font-weight: 600; padding: 2px 6px; border-radius: 5px; width: 180px;
|
||||||
|
font-family: inherit; outline: none; margin-left: 10px; padding-left: 10px;
|
||||||
|
}
|
||||||
#topBar .topRight { margin-left:auto; display:flex; align-items:center; gap:14px; }
|
#topBar .topRight { margin-left:auto; display:flex; align-items:center; gap:14px; }
|
||||||
#topBar .coords { font-size: 14px; color: #a8b8cc; }
|
#topBar .coords { font-size: 14px; color: #a8b8cc; }
|
||||||
|
|
||||||
@ -471,7 +478,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
|
|||||||
<canvas id="gameCanvas"></canvas>
|
<canvas id="gameCanvas"></canvas>
|
||||||
|
|
||||||
<div id="topBar">
|
<div id="topBar">
|
||||||
<span class="title"><img src="assets/ui/logo_planet_48.png" alt=""><span class="brand">宇森</span><span class="worldName" id="worldName">大地</span></span>
|
<span class="title"><span class="brand">宇森</span><span class="worldName editable" id="worldName" title="点击编辑世界名称">加载中…</span></span>
|
||||||
<div class="topRight">
|
<div class="topRight">
|
||||||
<span class="coords" id="coords">坐标: (0, 0)</span>
|
<span class="coords" id="coords">坐标: (0, 0)</span>
|
||||||
<div class="userArea" id="userArea" hidden>
|
<div class="userArea" id="userArea" hidden>
|
||||||
@ -3603,10 +3610,8 @@ function syncWorldNav() {
|
|||||||
document.querySelectorAll('#worldNav .wn-item').forEach(el => {
|
document.querySelectorAll('#worldNav .wn-item').forEach(el => {
|
||||||
el.classList.toggle('active', el.dataset.world === currentLayer);
|
el.classList.toggle('active', el.dataset.world === currentLayer);
|
||||||
});
|
});
|
||||||
// 顶栏页签 + 浏览器标签随当前层更新
|
// 浏览器标签随当前层更新;顶栏 #worldName 现在是世界名称(yt.world.name),不再覆盖
|
||||||
const nm = WORLD_NAMES[currentLayer] || '大地';
|
const nm = WORLD_NAMES[currentLayer] || '大地';
|
||||||
const wn = document.getElementById('worldName');
|
|
||||||
if (wn) wn.textContent = nm;
|
|
||||||
document.title = '宇森 · ' + nm;
|
document.title = '宇森 · ' + nm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3658,6 +3663,51 @@ function initWorldManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============ 初始化 ============
|
// ============ 初始化 ============
|
||||||
|
// 顶栏世界名称:点击进入编辑,回车/失焦保存到后端,Esc 取消
|
||||||
|
function setupWorldNameEdit() {
|
||||||
|
const wn = document.getElementById('worldName');
|
||||||
|
if (!wn) return;
|
||||||
|
|
||||||
|
wn.addEventListener('click', () => {
|
||||||
|
if (wn.querySelector('input')) return; // 已在编辑
|
||||||
|
const original = wn.textContent.trim();
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.value = original;
|
||||||
|
input.maxLength = 24;
|
||||||
|
input.className = 'worldName-input';
|
||||||
|
input.spellcheck = false;
|
||||||
|
wn.textContent = '';
|
||||||
|
wn.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
let done = false;
|
||||||
|
const restore = (val) => {
|
||||||
|
if (done) return; done = true;
|
||||||
|
wn.textContent = val;
|
||||||
|
};
|
||||||
|
const commit = async () => {
|
||||||
|
if (done) return;
|
||||||
|
const next = input.value.trim();
|
||||||
|
if (!next || next === original) { restore(original); return; }
|
||||||
|
try {
|
||||||
|
const r = await API.renameWorld(next);
|
||||||
|
if (r && r.ok) { restore(r.name || next); }
|
||||||
|
else { restore(original); console.warn('rename failed', r); }
|
||||||
|
} catch (e) {
|
||||||
|
restore(original);
|
||||||
|
console.warn('rename error', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||||||
|
else if (e.key === 'Escape') { e.preventDefault(); restore(original); }
|
||||||
|
});
|
||||||
|
input.addEventListener('blur', commit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
W = canvas.width = window.innerWidth;
|
W = canvas.width = window.innerWidth;
|
||||||
H = canvas.height = window.innerHeight - BOTTOM_BAR_H;
|
H = canvas.height = window.innerHeight - BOTTOM_BAR_H;
|
||||||
@ -3670,6 +3720,8 @@ function init() {
|
|||||||
initWorldManager();
|
initWorldManager();
|
||||||
// 建造模式面板(地形 / 板块交换)
|
// 建造模式面板(地形 / 板块交换)
|
||||||
setupBuildUI();
|
setupBuildUI();
|
||||||
|
// 顶栏世界名称点击编辑
|
||||||
|
setupWorldNameEdit();
|
||||||
// 右上角闹钟:刻度 + 点击播放暂停 + 拖动调时
|
// 右上角闹钟:刻度 + 点击播放暂停 + 拖动调时
|
||||||
const ticks = document.getElementById('clockTicks');
|
const ticks = document.getElementById('clockTicks');
|
||||||
if (ticks) for (let i = 0; i < 24; i++) {
|
if (ticks) for (let i = 0; i < 24; i++) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user