diff --git a/addons/game_ai_npc/__init__.py b/addons/game_ai_npc/__init__.py new file mode 100644 index 0000000..3b38916 --- /dev/null +++ b/addons/game_ai_npc/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import models +from . import controllers diff --git a/addons/game_ai_npc/__manifest__.py b/addons/game_ai_npc/__manifest__.py new file mode 100644 index 0000000..3d82c09 --- /dev/null +++ b/addons/game_ai_npc/__manifest__.py @@ -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', +} diff --git a/addons/game_ai_npc/controllers/__init__.py b/addons/game_ai_npc/controllers/__init__.py new file mode 100644 index 0000000..d2db9b3 --- /dev/null +++ b/addons/game_ai_npc/controllers/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import npc_api diff --git a/addons/game_ai_npc/controllers/npc_api.py b/addons/game_ai_npc/controllers/npc_api.py new file mode 100644 index 0000000..c7ddbca --- /dev/null +++ b/addons/game_ai_npc/controllers/npc_api.py @@ -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/', 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//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//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}) diff --git a/addons/game_ai_npc/data/game_npc_data.xml b/addons/game_ai_npc/data/game_npc_data.xml new file mode 100644 index 0000000..a116cb5 --- /dev/null +++ b/addons/game_ai_npc/data/game_npc_data.xml @@ -0,0 +1,229 @@ + + + + + + 张伯 + 老村长 + zhang_elder + leader + serious + 凡丘村三代定居 + 维持村落安全,度过干旱季 + 灵气日益稀薄,村民人心惶惶。必须在入冬前说服大家修筑储水渠,并与附近势力保持友好。 + 70 + 75 + 55 + 凡丘村第三任村长,年轻时曾游历五层世界的前三层,见识广博。妻子早逝,独子在外修行。为人严肃但不失公正,村中大事小事都要经他过目。

他坚信"人定胜天",但对灵气退潮的趋势也感到力不从心。最近常独自站在村口眺望远方的山脉,像是在等待什么。

]]>
+
+ + + + 林铁柱 + 铁匠 + lin_blacksmith + artisan + stoic + 铁匠世家,三代传人 + 铸出一柄灵器级武器 + 已收集足够的精铁矿,唯独缺少一缕灵气淬火。需要找到一处灵脉泉眼,或向修士换取灵引符。 + 65 + 70 + 50 + 沉默寡言的大汉,打铁时全神贯注,不理会任何打扰。祖父曾为城主铸剑,家传手艺到这一代已有些失传。

其实内心渴望超越祖辈,铸出一件真正的灵器。只是嘴上从不提起,只在深夜反复打磨一把未完成的短剑。

]]>
+
+ + + + 柳絮 + 游方学者 + liu_scholar + scholar + curious + 远方灵都游历至此 + 记录凡丘村周遭的古遗迹 + 听说村外三里处有一处坍塌的古代祭坛,碑文尚未被人破译。如果能解读出来,或许能揭开这片区域灵气退潮的秘密。 + 50 + 60 + 40 + 一身青衫,背挂书箱,腰悬毛笔筒。对一切未知充满好奇,逮着谁都要问两句本地传说。说话快,思维跳跃,常常自言自语地记笔记。

其实她来自灵都的没落书香门第,因不满家族安排的婚事而离家出走,游历世界寻找"值得写的书"。凡丘村只是她旅途的一站,但古祭坛的发现让她滞留了三个月。

]]>
+
+ + + + 花娘 + 行商 + hua_merchant + merchant + cheerful + 流动商队出身 + 开辟一条稳定的跨层商路 + 手里有一些地下层出产的黑曜石和星空层的星尘砂,如果能在大地层的村落间建立定期贸易路线,就能赚到足够的钱在灵都盘下一间铺面。 + 75 + 65 + 35 + 圆脸笑眼,见人三分熟,嘴甜会来事。商队出身,从小就跟着驼兽在各层之间往返。攒了几年钱后开始单干,货物五花八门——从地心灵芝到天空羽毛,什么都倒腾。

嘴上说想开店安定下来,但骨子里还是享受在路上的感觉。花娘不是真名,没人知道她本名叫什么。

]]>
+
+ + + + 阿猛 + 猎户 + a_meng_hunter + warrior + bold + 凡丘村本土 + 猎杀袭击村落的妖兽 + 最近村外出现了一只三尾狐兽,已叼走了几只家畜。阿猛立誓要在下个月前解决它,保护好村里的老人和孩子。 + 85 + 80 + 60 + 二十出头,虎背熊腰,性格耿直得有点莽撞。从小在林子里长大,弓箭和陷阱手艺是跟过路的巡林客学的。虽然平时大大咧咧,但保护村民的事从不含糊。

暗地里对柳絮有点意思,但完全不知道怎么表达,每次见到她就只会挠头傻笑。

]]>
+
+ + + + 老鬼 + 流浪药师 + lao_gui_herbalist + wanderer + mysterious + 无人知晓 + 寻一味叫"忘忧草"的药材 + 听说只有在灵气汇聚的隐秘山谷里才长这种草。他走遍了大地层的大半区域,每到一处就待上三五天,采药、制药,然后继续上路。 + 45 + 50 + 30 + 驼背,消瘦,总是戴着一顶破斗笠,遮住大半张脸。说话声音沙哑,从不多说一个字。医术极高,村民的小病小痛他几副药就能治好,但从不收钱,只接受食物和住宿。

没人知道他从哪里来,为什么要找忘忧草。有人说他年轻时失去过重要的人,也有人说他只是找个由头流浪。他自己从不解释。

]]>
+
+ + + + 小禾 + 农家女 + xiaohe_farmer + villager + kind + 凡丘村土生土长 + 学会识字和记账 + 村里的账目一直是张伯在管,但张伯年纪大了,眼睛不行了。小禾想学会认字和算术,以后帮村里记账、写文书。 + 80 + 75 + 65 + 十六七岁的姑娘,扎两条麻花辫,眼神清澈。父亲早逝,和母亲相依为命,种着几亩薄田。心地善良,谁家有事她都去帮忙,村里人都喜欢她。

柳絮来了之后,小禾一有空就跑去找她学字。柳絮也挺喜欢这个勤奋的姑娘,教得很认真。

]]>
+
+ + + + + + + 40 + 0 + + + + + + 35 + 0 + + + + + + + 45 + 0 + + + + + + 55 + 0 + + + + + + + 60 + 0 + + + + + + 15 + 0 + + + + + + + 25 + 0 + + + + + + 30 + 0 + + + + + + + + 祭坛 + 村外三里处的古祭坛是上一轮文明留下的,据说和灵气退潮有关。年轻时曾进去过一次,里面刻满了看不懂的符文。 + 0.8 + 年轻时亲身探索 + + + + 祭坛符文 + 祭坛主碑文是古灵篆的一种变体,开头一行破译了一半,大意是'灵气如潮,进退有时'。可能和世界的自然周期有关。 + 0.6 + 三个月研读碑文拓片 + + + + 灵脉泉眼 + 听祖父说过,村后山深处有一处废弃的灵脉泉眼。如果能找到并疏通,也许还能引出灵气。但那条路很危险,有妖兽盘踞。 + 0.7 + 祖父口述 + + + + 跨层商路 + 地下层出产黑曜石和夜光矿,星空层出产星尘砂和浮空石,大地层是中间枢纽。如果能打通一条安全的运输路线,利润至少翻三倍。 + 0.9 + 多年经商经验 + + + + 三尾狐兽 + 那只狐兽通常是夜晚出没,脚印往北山方向消失。体型比普通狐狸大三倍,尾巴有三条,行动极快。皮糙肉厚,普通箭矢射不透。 + 0.85 + 追踪观察半个月 + + + + 忘忧草 + 忘忧草只生长在灵气浓郁又常年有雾的山谷底部。凡丘村以东百里外有一片雾谷,或许会有。但那片区域是荒兽领地,很危险。 + 0.5 + 多方打听拼凑 + + + + 柳絮老师 + 柳老师懂得好多字和学问,她说世界上有五层,每层都有不同的风景。我以后也想走出去看看。 + 1.0 + 柳絮亲自教课 + +
diff --git a/addons/game_ai_npc/models/__init__.py b/addons/game_ai_npc/models/__init__.py new file mode 100644 index 0000000..55b28da --- /dev/null +++ b/addons/game_ai_npc/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +from . import npc +from . import npc_relationship +from . import npc_behavior +from . import npc_knowledge diff --git a/addons/game_ai_npc/models/npc.py b/addons/game_ai_npc/models/npc.py new file mode 100644 index 0000000..3d37819 --- /dev/null +++ b/addons/game_ai_npc/models/npc.py @@ -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 diff --git a/addons/game_ai_npc/models/npc_behavior.py b/addons/game_ai_npc/models/npc_behavior.py new file mode 100644 index 0000000..a81adac --- /dev/null +++ b/addons/game_ai_npc/models/npc_behavior.py @@ -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) diff --git a/addons/game_ai_npc/models/npc_knowledge.py b/addons/game_ai_npc/models/npc_knowledge.py new file mode 100644 index 0000000..24d757a --- /dev/null +++ b/addons/game_ai_npc/models/npc_knowledge.py @@ -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) diff --git a/addons/game_ai_npc/models/npc_relationship.py b/addons/game_ai_npc/models/npc_relationship.py new file mode 100644 index 0000000..5704653 --- /dev/null +++ b/addons/game_ai_npc/models/npc_relationship.py @@ -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) diff --git a/addons/game_ai_npc/overview.md b/addons/game_ai_npc/overview.md new file mode 100644 index 0000000..d4a043a --- /dev/null +++ b/addons/game_ai_npc/overview.md @@ -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/` | GET | NPC 详情(含关系+行为+知识) | +| `/game/api/npc//relationships` | GET | NPC 关系网 | +| `/game/api/npc//tick` | POST | 手动触发单个 NPC Tick(需登录) | +| `/game/api/npc/tick-all` | POST | 批量 Tick 所有 NPC(需登录) | + +## 种子数据(7 个角色) + +| NPC | 类型 | 性格 | 目标 | +|---|---|---|---| +| 张伯·老村长 | 领袖 | 严肃 | 维持村落安全 | +| 林铁柱·铁匠 | 工匠 | 刚毅 | 铸灵器级武器 | +| 柳絮·游方学者 | 学者 | 好奇 | 记录古遗迹 | +| 花娘·行商 | 商人 | 开朗 | 开辟跨层商路 | +| 阿猛·猎户 | 战士 | 大胆 | 猎杀三尾狐兽 | +| 老鬼·流浪药师 | 游荡者 | 神秘 | 寻忘忧草 | +| 小禾·农家女 | 居民 | 善良 | 学会识字记账 | + +初始关系:张伯↔林铁柱(信赖)、张伯↔花娘(商业伙伴)、柳絮↔小禾(师生)、阿猛→柳絮(暗恋)。 diff --git a/addons/game_ai_npc/security/ir.model.access.csv b/addons/game_ai_npc/security/ir.model.access.csv new file mode 100644 index 0000000..a93f78c --- /dev/null +++ b/addons/game_ai_npc/security/ir.model.access.csv @@ -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 diff --git a/addons/game_ai_npc/views/menu.xml b/addons/game_ai_npc/views/menu.xml new file mode 100644 index 0000000..c7cfa89 --- /dev/null +++ b/addons/game_ai_npc/views/menu.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/addons/game_ai_npc/views/npc_behavior_views.xml b/addons/game_ai_npc/views/npc_behavior_views.xml new file mode 100644 index 0000000..145e49b --- /dev/null +++ b/addons/game_ai_npc/views/npc_behavior_views.xml @@ -0,0 +1,42 @@ + + + + 行为日志 + game.npc.behavior.log + list,form + {'search_default_group_by_activity': 1} + + + game.npc.behavior.log.list + game.npc.behavior.log + + + + + + + + + + + + + + game.npc.behavior.log.form + game.npc.behavior.log + +
+ + + + + + + + + + + + +
+
diff --git a/addons/game_ai_npc/views/npc_knowledge_views.xml b/addons/game_ai_npc/views/npc_knowledge_views.xml new file mode 100644 index 0000000..2db9a4c --- /dev/null +++ b/addons/game_ai_npc/views/npc_knowledge_views.xml @@ -0,0 +1,36 @@ + + + + NPC 知识 + game.npc.knowledge + list,form + + + game.npc.knowledge.list + game.npc.knowledge + + + + + + + + + + + game.npc.knowledge.form + game.npc.knowledge + +
+ + + + + + + + + +
+
+
diff --git a/addons/game_ai_npc/views/npc_relationship_views.xml b/addons/game_ai_npc/views/npc_relationship_views.xml new file mode 100644 index 0000000..d735e8d --- /dev/null +++ b/addons/game_ai_npc/views/npc_relationship_views.xml @@ -0,0 +1,40 @@ + + + + 关系网 + game.npc.relationship + list,form + {'search_default_group_by_status': 1} + + + game.npc.relationship.list + game.npc.relationship + + + + + + + + + + + + + game.npc.relationship.form + game.npc.relationship + +
+ + + + + + + + + + +
+
+
diff --git a/addons/game_ai_npc/views/npc_views.xml b/addons/game_ai_npc/views/npc_views.xml new file mode 100644 index 0000000..b4c2a92 --- /dev/null +++ b/addons/game_ai_npc/views/npc_views.xml @@ -0,0 +1,116 @@ + + + + + NPC 角色 + game.npc + list,form + {'group_by': 'archetype'} + + + game.npc.list + game.npc + + + + + + + + + + + + + + + + + game.npc.form + game.npc + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + NPC 定时行为刻 + + code + model.cron_batch_tick() + 15 + minutes + + +
diff --git a/addons/yt_world/__manifest__.py b/addons/yt_world/__manifest__.py index 5844ba5..3468db0 100644 --- a/addons/yt_world/__manifest__.py +++ b/addons/yt_world/__manifest__.py @@ -4,11 +4,16 @@ 'summary': '地表存储(沙盘记忆) + 微观世界(1:1预留) + 资源(框架)', 'description': '每个玩家拥有一个确定性生成的世界;仅持久化玩家改过的地表地块(种子+增量覆盖)。微观世界独立为 1:1 模型仅预留,资源模型为框架。', 'category': 'Game', - 'depends': ['base'], + 'depends': ['base', 'web'], 'data': [ 'security/ir.model.access.csv', 'views/yt_world_views.xml', ], + 'assets': { + 'web.assets_backend': [ + 'yt_world/static/src/js/user_menu.js', + ], + }, 'application': True, 'license': 'AGPL-3', } diff --git a/addons/yt_world/static/src/js/user_menu.js b/addons/yt_world/static/src/js/user_menu.js new file mode 100644 index 0000000..dff6211 --- /dev/null +++ b/addons/yt_world/static/src/js/user_menu.js @@ -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; + }, +}); diff --git a/world.html b/world.html index effc9ad..13367ab 100644 --- a/world.html +++ b/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; 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 .coords { font-size: 14px; color: #a8b8cc; } @@ -471,7 +478,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
- 宇森大地 + 宇森加载中…
坐标: (0, 0)