381 lines
16 KiB
Python
381 lines
16 KiB
Python
# -*- 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
|