game/addons/game_ai_npc/models/npc.py

381 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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