From 96615c0c0dd414d671c6d63bb48ec778a3710e20 Mon Sep 17 00:00:00 2001 From: bridge Date: Thu, 28 Aug 2025 22:25:05 +0800 Subject: [PATCH] add new actions and AI --- README.md | 1 + src/classes/action.py | 62 ++++++++++++++++++++++++++++-- src/classes/ai.py | 72 +++++++++++++++++++++++++++++++++++ src/classes/avatar.py | 31 +++++++-------- src/classes/cultivation.py | 33 ++++++++++++---- src/{sim => classes}/event.py | 6 ++- src/classes/tile.py | 38 +++++++++++++++++- src/classes/world.py | 5 ++- src/front/front.py | 55 +++++++++----------------- src/sim/simulator.py | 19 +++++---- src/tools/run.py | 17 ++------- tests/test_basic.py | 2 +- tests/test_simulator.py | 8 ++-- 13 files changed, 253 insertions(+), 96 deletions(-) create mode 100644 src/classes/ai.py rename src/{sim => classes}/event.py (61%) diff --git a/README.md b/README.md index d471e89..8dc52ff 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ - ✅ 修炼境界体系 - ✅ 灵根系统 - ✅ 基础移动动作 +- [ ] 动态的突破成功概率 - [ ] 角色关系系统 - [ ] 性格系统设计 - [ ] 角色特殊能力 diff --git a/src/classes/action.py b/src/classes/action.py index 9907cc8..f3728ec 100644 --- a/src/classes/action.py +++ b/src/classes/action.py @@ -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 \ No newline at end of file + 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] \ No newline at end of file diff --git a/src/classes/ai.py b/src/classes/ai.py new file mode 100644 index 0000000..9ff5c47 --- /dev/null +++ b/src/classes/ai.py @@ -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 \ No newline at end of file diff --git a/src/classes/avatar.py b/src/classes/avatar.py index b13ce4d..8f68a67 100644 --- a/src/classes/avatar.py +++ b/src/classes/avatar.py @@ -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, - ) \ No newline at end of file + ) diff --git a/src/classes/cultivation.py b/src/classes/cultivation.py index 265143c..c792751 100644 --- a/src/classes/cultivation.py +++ b/src/classes/cultivation.py @@ -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() \ No newline at end of file diff --git a/src/sim/event.py b/src/classes/event.py similarity index 61% rename from src/sim/event.py rename to src/classes/event.py index 62127e9..31b3b33 100644 --- a/src/sim/event.py +++ b/src/classes/event.py @@ -12,4 +12,8 @@ class Event: content: str def __str__(self) -> str: - return f"{self.year}年{self.month}月: {self.content}" \ No newline at end of file + return f"{self.year}年{self.month}月: {self.content}" + +class NullEvent: + def __str__(self) -> str: + return "" \ No newline at end of file diff --git a/src/classes/tile.py b/src/classes/tile.py index 0029fb4..cb52970 100644 --- a/src/classes/tile.py +++ b/src/classes/tile.py @@ -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。 diff --git a/src/classes/world.py b/src/classes/world.py index 462c824..c01bb38 100644 --- a/src/classes/world.py +++ b/src/classes/world.py @@ -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 \ No newline at end of file + map: Map + year: Year + month: Month \ No newline at end of file diff --git a/src/front/front.py b/src/front/front.py index 506171a..4c19188 100644 --- a/src/front/front.py +++ b/src/front/front.py @@ -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)) diff --git a/src/sim/simulator.py b/src/sim/simulator.py index badcae2..ebdf3a1 100644 --- a/src/sim/simulator.py +++ b/src/sim/simulator.py @@ -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 diff --git a/src/tools/run.py b/src/tools/run.py index 8de23b7..02df15c 100644 --- a/src/tools/run.py +++ b/src/tools/run.py @@ -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, diff --git a/tests/test_basic.py b/tests/test_basic.py index 8bb93d4..0c4dbea 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -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, diff --git a/tests/test_simulator.py b/tests/test_simulator.py index 75db76e..e0531cd 100644 --- a/tests/test_simulator.py +++ b/tests/test_simulator.py @@ -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()