feat: 后台用户菜单加官网入口 + world.html世界名编辑/科技树/过渡背景 + 新增game_ai_npc模块

This commit is contained in:
李鹏宇 2026-07-26 12:31:12 +08:00
parent f78f37d677
commit 931b0dbe31
20 changed files with 1333 additions and 5 deletions

View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View 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',
}

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import npc_api

View 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})

View 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>

View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import npc
from . import npc_relationship
from . import npc_behavior
from . import npc_knowledge

View 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)}',
})
# ── 批量 TickCron 调用) ──
@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

View 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)

View 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)

View 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)

View 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 | 类型 | 性格 | 目标 |
|---|---|---|---|
| 张伯·老村长 | 领袖 | 严肃 | 维持村落安全 |
| 林铁柱·铁匠 | 工匠 | 刚毅 | 铸灵器级武器 |
| 柳絮·游方学者 | 学者 | 好奇 | 记录古遗迹 |
| 花娘·行商 | 商人 | 开朗 | 开辟跨层商路 |
| 阿猛·猎户 | 战士 | 大胆 | 猎杀三尾狐兽 |
| 老鬼·流浪药师 | 游荡者 | 神秘 | 寻忘忧草 |
| 小禾·农家女 | 居民 | 善良 | 学会识字记账 |
初始关系:张伯↔林铁柱(信赖)、张伯↔花娘(商业伙伴)、柳絮↔小禾(师生)、阿猛→柳絮(暗恋)。

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_game_npc game.npc user model_game_npc base.group_user 1 1 1 1
3 access_game_npc_relationship game.npc.relationship user model_game_npc_relationship base.group_user 1 1 1 1
4 access_game_npc_behavior_log game.npc.behavior.log user model_game_npc_behavior_log base.group_user 1 1 1 1
5 access_game_npc_knowledge game.npc.knowledge user model_game_npc_knowledge base.group_user 1 1 1 1

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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',
} }

View 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;
},
});

View File

@ -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++) {