add new actions and AI

This commit is contained in:
bridge
2025-08-28 22:25:05 +08:00
parent f7176af935
commit 96615c0c0d
13 changed files with 253 additions and 96 deletions

View File

@@ -37,6 +37,7 @@
- ✅ 修炼境界体系
- ✅ 灵根系统
- ✅ 基础移动动作
- [ ] 动态的突破成功概率
- [ ] 角色关系系统
- [ ] 性格系统设计
- [ ] 角色特殊能力

View File

@@ -1,9 +1,12 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
import random
from src.classes.essence import Essence, EssenceType
from src.classes.root import Root, corres_essence_type
from src.classes.tile import Region
from src.classes.event import Event, NullEvent
if TYPE_CHECKING:
from src.classes.avatar import Avatar
@@ -24,7 +27,7 @@ class Action(ABC):
self.world = world
@abstractmethod
def execute(self):
def execute(self) -> Event|NullEvent:
pass
class DefineAction(Action):
@@ -45,7 +48,7 @@ class Move(DefineAction):
"""
最基础的移动动作在tile之间进行切换。
"""
def execute(self, delta_x: int, delta_y: int):
def execute(self, delta_x: int, delta_y: int) -> Event|NullEvent:
"""
移动到某个tile
"""
@@ -62,20 +65,42 @@ class Move(DefineAction):
else:
# 超出边界不改变位置与tile
pass
return NullEvent()
class MoveToRegion(DefineAction):
"""
移动到某个region
"""
def execute(self, region: Region) -> Event|NullEvent:
"""
移动到某个region
"""
cur_loc = (self.avatar.pos_x, self.avatar.pos_y)
region_center_loc = region.center_loc
delta_x = region_center_loc[0] - cur_loc[0]
delta_y = region_center_loc[1] - cur_loc[1]
# 横纵向一次最多移动一格(可以同时横纵移动)
delta_x = max(-1, min(1, delta_x))
delta_y = max(-1, min(1, delta_y))
Move(self.avatar, self.world).execute(delta_x, delta_y)
return Event(self.world.year, self.world.month, f"{self.avatar.name} 移动向 {region.name}")
class Cultivate(DefineAction):
"""
修炼动作,可以增加修仙进度。
"""
def execute(self, root: Root, essence: Essence):
def execute(self) -> Event|NullEvent:
"""
修炼
获得的exp增加取决于essence的对应灵根的大小。
"""
root = self.avatar.root
essence = self.avatar.tile.region.essence
essence_type = corres_essence_type[root]
essence_density = essence.get_density(essence_type)
exp = self.get_exp(essence_density)
self.avatar.cultivation_progress.add_exp(exp)
return Event(self.world.year, self.world.month, f"{self.avatar.name}{self.avatar.tile.region.name} 修炼")
def get_exp(self, essence_density: int) -> int:
"""
@@ -83,4 +108,33 @@ class Cultivate(DefineAction):
公式为base * essence_density
"""
base = 100
return base * essence_density
return base * essence_density
# 突破境界class
class Breakthrough(DefineAction):
"""
突破境界
"""
def calc_success_rate(self) -> float:
"""
计算突破境界的成功率
"""
return 0.5
def execute(self) -> Event|NullEvent:
"""
突破境界
"""
assert self.avatar.cultivation_progress.can_break_through()
success_rate = self.calc_success_rate()
if random.random() < success_rate:
self.avatar.cultivation_progress.break_through()
is_success = True
else:
is_success = False
res = "成功" if is_success else "失败"
return Event(self.world.year, self.world.month, f"{self.avatar.name} 突破境界{res}")
ALL_ACTION_CLASSES = [Move, Cultivate, Breakthrough, MoveToRegion]

72
src/classes/ai.py Normal file
View File

@@ -0,0 +1,72 @@
"""
NPC AI的类。
这里指的不是LLM或者Machine Learning而是NPC的决策机制
分为两类规则AI和LLM AI
"""
from abc import ABC, abstractmethod
from src.classes.world import World
from src.classes.tile import Region
from src.classes.root import corres_essence_type
class AI(ABC):
"""
AI的基类
"""
def __init__(self, avatar: 'Avatar'):
self.avatar = avatar
@abstractmethod
def decide(self, world: World) -> tuple[str, dict]:
"""
决定做什么
"""
pass
# def create_event(self, world: World, content: str) -> Event:
# """
# 创建事件
# """
# return Event(world.year, world.month, content)
class RuleAI(AI):
"""
规则AI
"""
def decide(self, world: World) -> tuple[str, dict]:
"""
决定做什么
先做一个简单的:
1. 找到自己灵根对应的最好的区域
2. 检测自己是否在最好的区域
3. 如果不在,则移动到最好的区域
4. 如果已经到达最好的区域,则进行修炼
5. 如果需要突破境界了,则突破境界
"""
best_region = self.get_best_region(list(world.map.regions.values()))
if self.avatar.is_in_region(best_region):
if self.avatar.cultivation_progress.can_break_through():
return "Breakthrough", {}
else:
return "Cultivate", {}
else:
return "MoveToRegion", {"region": best_region}
def get_best_region(self, regions: list[Region]) -> Region:
"""
根据avatar的灵根找到最适合的区域
"""
root = self.avatar.root
essence_type = corres_essence_type[root]
region_with_best_essence = max(regions, key=lambda region: region.essence.get_density(essence_type))
return region_with_best_essence
class LLMAI(AI):
"""
LLM AI
"""
def decide(self, world: World) -> tuple[str, dict]:
"""
决定做什么
"""
pass

View File

@@ -5,13 +5,14 @@ from enum import Enum
from typing import Optional
from src.classes.calendar import Month, Year
from src.classes.action import Action, Move, Cultivate
from src.classes.action import Action, ALL_ACTION_CLASSES
from src.classes.world import World
from src.classes.tile import Tile
from src.classes.tile import Tile, Region
from src.classes.cultivation import CultivationProgress, Realm
from src.classes.root import Root
from src.classes.age import Age
from src.utils.strings import to_snake_case
from src.classes.ai import AI, RuleAI
class Gender(Enum):
MALE = "male"
@@ -44,19 +45,22 @@ class Avatar:
tile: Optional[Tile] = None
actions: dict[str, Action] = field(default_factory=dict)
root: Root = field(default_factory=lambda: random.choice(list(Root)))
ai: AI = None
def __post_init__(self):
"""
在Avatar创建后自动绑定基础动作
在Avatar创建后自动绑定基础动作和AI
"""
self.tile = self.world.map.get_tile(self.pos_x, self.pos_y)
self.ai = RuleAI(self)
self._bind_basic_actions()
def _bind_basic_actions(self):
"""
绑定基础动作,如移动等
"""
self.bind_action(Move)
self.bind_action(Cultivate)
for action in ALL_ACTION_CLASSES:
self.bind_action(action)
def bind_action(self, action_class: type[Action]):
@@ -80,16 +84,10 @@ class Avatar:
角色执行动作。
实际上分为两步决定做什么decide和实习上去做do
"""
action_name, action_args = self.decide()
action_name, action_args = self.ai.decide(self.world)
action = self.actions[action_name]
action.execute(**action_args)
def decide(self):
"""
决定做什么。
"""
# 目前只做一个事情,就是随机移动。
return "Move", {"delta_x": random.randint(-1, 1), "delta_y": random.randint(-1, 1)}
event = action.execute(**action_args)
return event
def update_cultivation(self, new_level: int):
"""
@@ -136,6 +134,9 @@ class Avatar:
"realm": self.cultivation_progress.realm.value
}
def is_in_region(self, region: Region) -> bool:
return self.tile.region == region
def get_new_avatar_from_ordinary(world: World, current_year: Year, name: str, age: Age):
"""
从凡人中来的新修士
@@ -162,4 +163,4 @@ def get_new_avatar_from_ordinary(world: World, current_year: Year, name: str, ag
cultivation_progress=cultivation_progress,
pos_x=pos_x,
pos_y=pos_y,
)
)

View File

@@ -26,6 +26,12 @@ level_to_stage = {
20: Stage.Late_Stage,
}
level_to_break_through = {
30: Realm.Foundation_Establishment,
60: Realm.Core_Formation,
90: Realm.Nascent_Soul,
}
class CultivationProgress:
"""
修仙进度(包含等级、境界和经验值)
@@ -62,7 +68,7 @@ class CultivationProgress:
def __str__(self) -> str:
return f"{self.realm.value}{self.stage.value}({self.level}级)"
def get_exp_required(self, target_level: int) -> int:
def get_exp_required(self) -> int:
"""
计算升级到指定等级需要的经验值
使用简单的代数加法base_exp + (level - 1) * increment + realm_bonus
@@ -73,17 +79,16 @@ class CultivationProgress:
返回:
需要的经验值
"""
if target_level <= 0 or target_level > 120:
return 0
next_level = self.level + 1
base_exp = 100 # 基础经验值
increment = 50 # 每级增加50点经验值
# 基础经验值计算
exp_required = base_exp + (target_level - 1) * increment
exp_required = base_exp + (next_level - 1) * increment
# 境界加成每跨越一个境界额外增加1000点经验值
realm_bonus = (target_level // 30) * 1000
realm_bonus = (next_level // 30) * 1000
return exp_required + realm_bonus
@@ -94,7 +99,7 @@ class CultivationProgress:
返回:
如果经验值足够升级则返回True
"""
required_exp = self.get_exp_required(self.level + 1)
required_exp = self.get_exp_required()
return self.exp >= required_exp
def get_exp_progress(self) -> tuple[int, int]:
@@ -104,7 +109,7 @@ class CultivationProgress:
返回:
(当前经验值, 升级所需经验值)
"""
required_exp = self.get_exp_required(self.level + 1)
required_exp = self.get_exp_required()
return self.exp, required_exp
def add_exp(self, exp_amount: int) -> bool:
@@ -130,3 +135,17 @@ class CultivationProgress:
return True
return False
def break_through(self):
"""
突破境界
"""
self.level += 1
self.realm = self.get_realm(self.level)
self.stage = self.get_stage(self.level)
def can_break_through(self) -> bool:
"""
检查是否可以突破
"""
return self.level in level_to_break_through.keys()

View File

@@ -12,4 +12,8 @@ class Event:
content: str
def __str__(self) -> str:
return f"{self.year}{self.month}月: {self.content}"
return f"{self.year}{self.month}月: {self.content}"
class NullEvent:
def __str__(self) -> str:
return ""

View File

@@ -39,10 +39,13 @@ class Region():
description: str
essence: Essence
id: int = field(init=False)
center_loc: tuple[int, int] = field(init=False)
area: int = field(init=False)
def __post_init__(self):
self.id = next(region_id_counter)
def __hash__(self) -> int:
return hash(self.id)
@@ -55,6 +58,7 @@ class Region():
# 其他
default_region = Region(name="平原", description="最普通的平原,没有什么可说的", essence=Essence(density={EssenceType.GOLD: 1, EssenceType.WOOD: 1, EssenceType.WATER: 1, EssenceType.FIRE: 1, EssenceType.EARTH: 1}))
default_region.area = 1 # 默认区域面积为1
@dataclass
class Tile():
@@ -70,6 +74,7 @@ class Map():
"""
def __init__(self, width: int, height: int):
self.tiles = {}
self.regions = {}
self.width = width
self.height = height
@@ -90,10 +95,39 @@ class Map():
创建一个region。
"""
region = Region(name=name, description=description, essence=essence)
center_loc = self.get_center_locs(locs)
for loc in locs:
self.tiles[loc].region = region
region.center_loc = center_loc
region.area = len(locs)
self.regions[region.id] = region
return region
def get_center_locs(self, locs: list[tuple[int, int]]) -> tuple[int, int]:
"""
获取locs的中心位置。
如果几何中心恰好在位置列表中,返回几何中心;
否则返回距离几何中心最近的实际位置。
"""
if not locs:
return (0, 0)
# 分别计算x和y坐标的平均值
avg_x = sum(loc[0] for loc in locs) // len(locs)
avg_y = sum(loc[1] for loc in locs) // len(locs)
center = (avg_x, avg_y)
# 如果几何中心恰好在位置列表中,直接返回
if center in locs:
return center
# 否则找到距离几何中心最近的实际位置
def distance_squared(loc: tuple[int, int]) -> int:
"""计算到中心点的距离平方(避免开方运算)"""
return (loc[0] - avg_x) ** 2 + (loc[1] - avg_y) ** 2
return min(locs, key=distance_squared)
def get_region(self, x: int, y: int) -> Region | None:
"""
获取一个region。

View File

@@ -1,7 +1,10 @@
from dataclasses import dataclass
from src.classes.tile import Map
from src.classes.calendar import Year, Month
@dataclass
class World():
map: Map
map: Map
year: Year
month: Month

View File

@@ -6,7 +6,7 @@ from src.sim.simulator import Simulator
from src.classes.world import World
from src.classes.tile import TileType
from src.classes.avatar import Avatar, Gender
from src.sim.event import Event
from src.classes.event import Event
class Front:
@@ -206,7 +206,7 @@ class Front:
def _draw_year_month_info(self, y_pos: int, padding: int):
"""绘制年月信息"""
# 获取年月数据
year = int(self.simulator.year)
year = int(self.simulator.world.year)
month_num = self._get_month_number()
# 构建年月文本
@@ -222,7 +222,7 @@ class Front:
def _get_month_number(self) -> int:
"""获取月份数字"""
try:
month_num = list(type(self.simulator.month)).index(self.simulator.month) + 1
month_num = list(type(self.simulator.world.month)).index(self.simulator.world.month) + 1
return month_num
except Exception:
return 1
@@ -279,39 +279,31 @@ class Front:
m = self.margin
mouse_x, mouse_y = pygame.mouse.get_pos()
# 收集每个region的所有地块中心点
region_to_points = self._collect_region_points(map_obj, ts, m)
if not region_to_points:
return None
# 绘制每个region的标签
hovered_region = None
for region, points in region_to_points.items():
if not points:
continue
# 计算质心
avg_x = sum(p[0] for p in points) // len(points)
avg_y = sum(p[1] for p in points) // len(points)
for region in map_obj.regions.values():
name = getattr(region, "name", None)
if not name:
continue
# 计算字体大小
font_size = self._calculate_font_size(len(points))
# 使用region的center_loc计算屏幕位置
center_x, center_y = region.center_loc
screen_x = m + center_x * ts + ts // 2
screen_y = m + center_y * ts + ts // 2
# 计算字体大小基于region面积
font_size = self._calculate_font_size_by_area(region.area)
region_font = self._get_region_font(font_size)
# 渲染文字
text_surface = region_font.render(str(name), True, self.colors["text"])
shadow_surface = region_font.render(str(name), True, (0, 0, 0))
# 计算位置
# 计算位置(居中显示)
text_w = text_surface.get_width()
text_h = text_surface.get_height()
x = int(avg_x - text_w / 2)
y = int(avg_y - text_h / 2)
x = int(screen_x - text_w / 2)
y = int(screen_y - text_h / 2)
# 检测鼠标悬停
if (x <= mouse_x <= x + text_w and y <= mouse_y <= y + text_h):
@@ -323,23 +315,10 @@ class Front:
return hovered_region
def _collect_region_points(self, map_obj, ts, m):
"""收集region的点位信息"""
region_to_points = {}
for (x, y), tile in getattr(map_obj, "tiles", {}).items():
if getattr(tile, "region", None) is None:
continue
region_obj = tile.region
cx = m + x * ts + ts // 2
cy = m + y * ts + ts // 2
region_to_points.setdefault(region_obj, []).append((cx, cy))
return region_to_points
def _calculate_font_size(self, area):
"""根据区域大小计算字体大小"""
def _calculate_font_size_by_area(self, area):
"""根据区域面积计算字体大小"""
base = int(self.tile_size * 1.1)
growth = int(max(0, min(24, (area ** 0.5))))
return max(16, min(40, base + growth))

View File

@@ -3,15 +3,12 @@ import random
from src.classes.calendar import Month, Year, next_month
from src.classes.avatar import Avatar, get_new_avatar_from_ordinary
from src.classes.age import Age
from src.classes.avatar import Gender
from src.classes.world import World
from src.sim.event import Event
from src.classes.event import Event, NullEvent
class Simulator:
def __init__(self, world: World):
self.avatars = {} # dict of str -> Avatar
self.year = Year(1)
self.month = Month.JANUARY
self.world = world
self.brith_rate = 0.01
@@ -28,12 +25,14 @@ class Simulator:
# 结算角色行为
for avatar_id, avatar in self.avatars.items():
avatar.act()
event = avatar.act()
if event is not NullEvent:
events.append(event)
if avatar.death_by_old_age():
death_avatar_ids.append(avatar_id)
event = Event(self.year, self.month, f"{avatar.name} 老死了,时年{avatar.age.get_age()}")
event = Event(self.world.year, self.world.month, f"{avatar.name} 老死了,时年{avatar.age.get_age()}")
events.append(event)
avatar.update_age(self.month, self.year)
avatar.update_age(self.world.month, self.world.year)
# 删除死亡的角色
for avatar_id in death_avatar_ids:
@@ -43,12 +42,12 @@ class Simulator:
if random.random() < self.brith_rate:
name = f"无名"
age = random.randint(16, 60)
new_avatar = get_new_avatar_from_ordinary(self.world, self.year, name, Age(age))
new_avatar = get_new_avatar_from_ordinary(self.world, self.world.year, name, Age(age))
self.avatars[new_avatar.id] = new_avatar
event = Event(self.year, self.month, f"{new_avatar.name}晋升为修士了。")
event = Event(self.world.year, self.world.month, f"{new_avatar.name}晋升为修士了。")
events.append(event)
# 最后结算年月
self.month, self.year = next_month(self.month, self.year)
self.world.month, self.world.year = next_month(self.world.month, self.world.year)
return events

View File

@@ -1,14 +1,7 @@
import os
import sys
import random
import uuid
from typing import List, Tuple, Dict, Any
# 将项目根目录加入 Python 路径,确保可以导入 `src` 包
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
# 依赖项目内部模块
from src.front.front import Front
from src.sim.simulator import Simulator
@@ -21,7 +14,7 @@ from src.classes.essence import Essence, EssenceType
from src.classes.cultivation import CultivationProgress
from src.classes.root import Root
from src.classes.age import Age
from create_map import create_cultivation_world_map
from src.tools.create_map import create_cultivation_world_map
def clamp(value: int, lo: int, hi: int) -> int:
@@ -92,15 +85,13 @@ def main():
# 为了每次更丰富,使用随机种子;如需复现可将 seed 固定
game_map = create_cultivation_world_map()
world = World(map=game_map)
world = World(map=game_map, year=Year(100), month=Month.JANUARY)
# 设置模拟器从第100年开始
# 创建模拟器
sim = Simulator(world)
sim.year = Year(100) # 设置初始年份为100年
sim.month = Month.JANUARY # 设置初始月份为1月
# 创建角色,传入当前年份确保年龄与生日匹配
sim.avatars.update(make_avatars(world, count=14, current_year=sim.year))
sim.avatars.update(make_avatars(world, count=14, current_year=world.year))
front = Front(
simulator=sim,

View File

@@ -14,7 +14,7 @@ def test_basic():
for y in range(2):
map.create_tile(x, y, TileType.PLAIN)
world = World(map=map)
world = World(map=map, year=Year(1), month=Month.JANUARY)
avatar = Avatar(
world=world,

View File

@@ -18,13 +18,13 @@ def test_simulator_step_moves_avatar_and_sets_tile():
for y in range(3):
game_map.create_tile(x, y, TileType.PLAIN)
world = World(map=game_map)
world = World(map=game_map, year=Year(1), month=Month.JANUARY)
# 将角色放在地图中心,避免越界
avatar = Avatar(
world=world,
name="Tester",
id=1,
id="1",
birth_month=Month.JANUARY,
birth_year=Year(2000),
age=20,
@@ -34,8 +34,8 @@ def test_simulator_step_moves_avatar_and_sets_tile():
)
sim = Simulator()
sim.avatars.append(avatar)
sim = Simulator(world)
sim.avatars["1"] = avatar
# 执行一步模拟
sim.step()