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)
@@ -3603,10 +3610,8 @@ function syncWorldNav() {
document.querySelectorAll('#worldNav .wn-item').forEach(el => {
el.classList.toggle('active', el.dataset.world === currentLayer);
});
- // 顶栏页签 + 浏览器标签随当前层更新
+ // 浏览器标签随当前层更新;顶栏 #worldName 现在是世界名称(yt.world.name),不再覆盖
const nm = WORLD_NAMES[currentLayer] || '大地';
- const wn = document.getElementById('worldName');
- if (wn) wn.textContent = 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() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight - BOTTOM_BAR_H;
@@ -3670,6 +3720,8 @@ function init() {
initWorldManager();
// 建造模式面板(地形 / 板块交换)
setupBuildUI();
+ // 顶栏世界名称点击编辑
+ setupWorldNameEdit();
// 右上角闹钟:刻度 + 点击播放暂停 + 拖动调时
const ticks = document.getElementById('clockTicks');
if (ticks) for (let i = 0; i < 24; i++) {