diff --git a/src/classes/action.py b/src/classes/action.py index e69de29..a8157b6 100644 --- a/src/classes/action.py +++ b/src/classes/action.py @@ -0,0 +1,61 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from src.classes.avatar import Avatar + from src.classes.world import World + +class Action(ABC): + """ + 角色可以执行的动作。 + 比如,移动、攻击、采集、建造、etc。 + """ + def __init__(self, avatar: Avatar, world: World): + """ + 传一个avatar的ref + 这样子实际执行的时候,可以知道avatar的能力和状态 + 可选传入world;若不传,则尝试从avatar.world获取。 + """ + self.avatar = avatar + self.world = world + + @abstractmethod + def execute(self): + pass + +class DefineAction(Action): + pass + +class LLMAction(Action): + """ + 基于LLM的action,这种action一般是不需要实际的规则定义。 + 而是一种抽象的,仅有社会层面的后果的定义。 + 比如“折辱”“恶狠狠地盯着”“退婚”等 + 这种action会通过LLM生成并被执行,让NPC记忆并产生后果。 + 但是不需要规则侧做出反应来。 + """ + pass + + +class Move(DefineAction): + """ + 最基础的移动动作,在tile之间进行切换。 + """ + def execute(self, delta_x: int, delta_y: int): + """ + 移动到某个tile + """ + world = self.world + new_x = self.avatar.pos_x + delta_x + new_y = self.avatar.pos_y + delta_y + + # 边界检查:越界则不移动 + if world.map.is_in_bounds(new_x, new_y): + self.avatar.pos_x = new_x + self.avatar.pos_y = new_y + target_tile = world.map.get_tile(new_x, new_y) + self.avatar.tile = target_tile + else: + # 超出边界:不改变位置与tile + pass \ No newline at end of file diff --git a/src/classes/avatar.py b/src/classes/avatar.py index 79f15be..d18114a 100644 --- a/src/classes/avatar.py +++ b/src/classes/avatar.py @@ -1,21 +1,73 @@ -from dataclasses import dataclass +import random +from dataclasses import dataclass, field from enum import Enum +from typing import Optional from src.classes.calendar import Month, Year +from src.classes.action import Action +from src.classes.world import World +from src.classes.tile import Tile +from src.utils.strings import to_snake_case class Gender(Enum): MALE = "male" FEMALE = "female" + def __str__(self) -> str: + return gender_strs.get(self, self.value) + +gender_strs = { + Gender.MALE: "男", + Gender.FEMALE: "女", +} + @dataclass class Avatar: """ NPC的类。 包含了这个角色的一切信息。 """ + world: World name: str id: int birth_month: Month birth_year: Year age: int gender: Gender + pos_x: int = 0 + pos_y: int = 0 + tile: Optional[Tile] = None + actions: dict[str, Action] = field(default_factory=dict) + + + def bind_action(self, action_class: type[Action]): + """ + 绑定一个action到avatar + """ + # 以类名为键保存实例,保持可追踪性 + self.actions[action_class.__name__] = action_class(self, self.world) + + # 同时挂载一个便捷方法,名称为蛇形(MoveFast -> move_fast),并转发参数 + method_name = to_snake_case(action_class.__name__) + + def _wrapper(*args, **kwargs): + return self.actions[action_class.__name__].execute(*args, **kwargs) + + setattr(self, method_name, _wrapper) + + + def act(self): + """ + 角色执行动作。 + 实际上分为两步:决定做什么(decide)和实习上去做(do) + """ + action_name, action_args = self.decide() + 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)} \ No newline at end of file diff --git a/src/classes/tile.py b/src/classes/tile.py index e69de29..f65adf7 100644 --- a/src/classes/tile.py +++ b/src/classes/tile.py @@ -0,0 +1,62 @@ +from enum import Enum +from dataclasses import dataclass + +class TileType(Enum): + PLAIN = "plain" # 平原 + WATER = "water" # 水域 + SEA = "sea" # 海洋 + MOUNTAIN = "mountain" # 山脉 + FOREST = "forest" # 森林 + CITY = "city" # 城市 + DESERT = "desert" # 沙漠 + RAINFOREST = "rainforest" # 热带雨林 + GLACIER = "glacier" # 冰川/冰原 + SNOW_MOUNTAIN = "snow_mountain" # 雪山 + +@dataclass +class Region(): + """ + 理想中,一些地块应当在一起组成一个区域。 + 比如,某山;某湖、江、海;某森林;某平原;某城市; + 一些分布,比如物产,按照Region来分布。 + 再比如,灵气,应当也是按照region分布的。 + 默认,一个region内部的属性,是共通的。 + 同时,NPC应当对Region有观测和认知。 + """ + name: str + description: str + qi: int # 灵气,从0~255 + # 物产 + # 灵气 + # 其他 + +@dataclass +class Tile(): + # 实际的地块 + type: TileType + x: int + y: int + # region: Region + +class Map(): + """ + 通过dict记录position 到 tile。 + TODO: 记录region到position的映射。 + TODO: 有特色的地貌,比如西部大漠,东部平原,最东海洋和岛国。南边热带雨林,北边雪山和冰原。 + """ + def __init__(self, width: int, height: int): + self.tiles = {} + self.width = width + self.height = height + + def is_in_bounds(self, x: int, y: int) -> bool: + """ + 判断坐标是否在地图边界内。 + """ + return 0 <= x < self.width and 0 <= y < self.height + + def create_tile(self, x: int, y: int, tile_type: TileType): + self.tiles[(x, y)] = Tile(tile_type, x, y) + + def get_tile(self, x: int, y: int) -> Tile: + return self.tiles[(x, y)] \ No newline at end of file diff --git a/src/classes/world.py b/src/classes/world.py new file mode 100644 index 0000000..462c824 --- /dev/null +++ b/src/classes/world.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + +from src.classes.tile import Map + +@dataclass +class World(): + map: Map \ No newline at end of file diff --git a/src/front/__init__.py b/src/front/__init__.py new file mode 100644 index 0000000..dfe62a0 --- /dev/null +++ b/src/front/__init__.py @@ -0,0 +1,5 @@ +from .front import Front + +__all__ = ["Front"] + + diff --git a/src/front/front.py b/src/front/front.py new file mode 100644 index 0000000..f14f303 --- /dev/null +++ b/src/front/front.py @@ -0,0 +1,281 @@ +import math +from typing import Dict, List, Optional, Tuple + +# Front 只依赖项目内部类型定义与 pygame +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 + + +class Front: + """ + 基于 pygame 的前端展示。 + + - 渲染地图 `World.map` 与其中的 `Avatar` + - 以固定节奏调用 `simulator.step()`,画面随之更新 + - 鼠标悬停在 avatar 上时显示信息 + + 按键: + - A:切换自动步进(默认开启) + - 空格:手动执行一步(在自动关闭时有用) + - ESC / 关闭窗口:退出 + """ + + def __init__( + self, + world: World, + simulator: Simulator, + *, + tile_size: int = 32, + margin: int = 8, + step_interval_ms: int = 400, + window_title: str = "Cultivation World Simulator", + font_path: Optional[str] = None, + ): + self.world = world + self.simulator = simulator + self.tile_size = tile_size + self.margin = margin + self.step_interval_ms = step_interval_ms + self.window_title = window_title + self.font_path = font_path + + # 运行时状态 + self._auto_step = True + self._last_step_ms = 0 + + # 延迟导入 pygame:避免未安装 pygame 时影响非可视化运行/测试 + import pygame # type: ignore + + self.pygame = pygame + pygame.init() + pygame.font.init() + + # 计算窗口大小 + width_px = world.map.width * tile_size + margin * 2 + height_px = world.map.height * tile_size + margin * 2 + self.screen = pygame.display.set_mode((width_px, height_px)) + pygame.display.set_caption(window_title) + + # 字体(优先中文友好字体;可显式传入 TTF 路径) + self.font = self._create_font(16) + self.tooltip_font = self._create_font(14) + + # 配色 + self.colors: Dict[str, Tuple[int, int, int]] = { + "bg": (18, 18, 18), + "grid": (40, 40, 40), + "text": (230, 230, 230), + "tooltip_bg": (32, 32, 32), + "tooltip_bd": (90, 90, 90), + "avatar": (240, 220, 90), + } + + self.tile_colors: Dict[TileType, Tuple[int, int, int]] = { + TileType.PLAIN: (64, 120, 64), + TileType.FOREST: (24, 96, 48), + TileType.MOUNTAIN: (108, 108, 108), + TileType.WATER: (60, 120, 180), + TileType.SEA: (30, 90, 150), + TileType.CITY: (140, 120, 90), + TileType.DESERT: (210, 180, 60), + TileType.RAINFOREST: (12, 80, 36), + TileType.GLACIER: (210, 230, 240), + TileType.SNOW_MOUNTAIN: (200, 200, 200), + } + + self.clock = pygame.time.Clock() + + # --------------------------- 主循环 --------------------------- + def run(self): + pygame = self.pygame + running = True + while running: + dt_ms = self.clock.tick(60) + self._last_step_ms += dt_ms + + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + elif event.type == pygame.KEYDOWN: + if event.key in (pygame.K_ESCAPE,): + running = False + elif event.key == pygame.K_a: + self._auto_step = not self._auto_step + elif event.key == pygame.K_SPACE: + self._step_once() + + if self._auto_step and self._last_step_ms >= self.step_interval_ms: + self._step_once() + + self._render() + + pygame.quit() + + def _step_once(self): + self.simulator.step() + self._last_step_ms = 0 + + # --------------------------- 渲染 --------------------------- + def _render(self): + pygame = self.pygame + self.screen.fill(self.colors["bg"]) + + self._draw_map() + hovered = self._draw_avatars_and_pick_hover() + if hovered is not None: + self._draw_tooltip_for_avatar(hovered) + + # 状态条 + hint = f"A:自动步进({'开' if self._auto_step else '关'}) SPACE:单步 ESC:退出" + text_surf = self.font.render(hint, True, self.colors["text"]) + self.screen.blit(text_surf, (self.margin, 4)) + + pygame.display.flip() + + def _draw_map(self): + pygame = self.pygame + map_obj = self.world.map + ts = self.tile_size + m = self.margin + + # 先画网格背景块 + for y in range(map_obj.height): + for x in range(map_obj.width): + tile = map_obj.get_tile(x, y) + color = self.tile_colors.get(tile.type, (80, 80, 80)) + rect = pygame.Rect(m + x * ts, m + y * ts, ts, ts) + pygame.draw.rect(self.screen, color, rect) + + # 画网格线 + grid_color = self.colors["grid"] + for gx in range(map_obj.width + 1): + start_pos = (m + gx * ts, m) + end_pos = (m + gx * ts, m + map_obj.height * ts) + pygame.draw.line(self.screen, grid_color, start_pos, end_pos, 1) + for gy in range(map_obj.height + 1): + start_pos = (m, m + gy * ts) + end_pos = (m + map_obj.width * ts, m + gy * ts) + pygame.draw.line(self.screen, grid_color, start_pos, end_pos, 1) + + def _draw_avatars_and_pick_hover(self) -> Optional[Avatar]: + pygame = self.pygame + mouse_x, mouse_y = pygame.mouse.get_pos() + + hovered: Optional[Avatar] = None + min_dist = float("inf") + + for avatar in self.simulator.avatars: + cx, cy = self._avatar_center_pixel(avatar) + radius = max(8, self.tile_size // 3) + pygame.draw.circle(self.screen, self.colors["avatar"], (cx, cy), radius) + + # 简单的 hover:鼠标与圆心距离 + dist = math.hypot(mouse_x - cx, mouse_y - cy) + if dist <= radius and dist < min_dist: + hovered = avatar + min_dist = dist + + return hovered + + # --------------------------- 工具/辅助 --------------------------- + def _avatar_center_pixel(self, avatar: Avatar) -> Tuple[int, int]: + ts = self.tile_size + m = self.margin + px = m + avatar.pos_x * ts + ts // 2 + py = m + avatar.pos_y * ts + ts // 2 + return px, py + + def _avatar_tooltip_lines(self, avatar: Avatar) -> List[str]: + gender = str(avatar.gender) + + pos = f"({avatar.pos_x}, {avatar.pos_y})" + lines = [ + f"{avatar.name}#{avatar.id}", + f"性别: {gender}", + f"年龄: {avatar.age}", + f"位置: {pos}", + ] + return lines + + def _draw_tooltip_for_avatar(self, avatar: Avatar): + pygame = self.pygame + lines = self._avatar_tooltip_lines(avatar) + + # 计算尺寸 + padding = 6 + spacing = 2 + surf_lines = [self.tooltip_font.render(t, True, self.colors["text"]) for t in lines] + width = max(s.get_width() for s in surf_lines) + padding * 2 + height = sum(s.get_height() for s in surf_lines) + padding * 2 + spacing * (len(surf_lines) - 1) + + mx, my = pygame.mouse.get_pos() + x = mx + 12 + y = my + 12 + + # 边界修正:尽量不出屏幕 + screen_w, screen_h = self.screen.get_size() + if x + width > screen_w: + x = mx - width - 12 + if y + height > screen_h: + y = my - height - 12 + + bg_rect = pygame.Rect(x, y, width, height) + pygame.draw.rect(self.screen, self.colors["tooltip_bg"], bg_rect, border_radius=6) + pygame.draw.rect(self.screen, self.colors["tooltip_bd"], bg_rect, 1, border_radius=6) + + # 绘制文字 + cursor_y = y + padding + for s in surf_lines: + self.screen.blit(s, (x + padding, cursor_y)) + cursor_y += s.get_height() + spacing + + + def _create_font(self, size: int): + pygame = self.pygame + if self.font_path: + try: + return pygame.font.Font(self.font_path, size) + except Exception: + # 回退到自动匹配 + pass + return self._load_font_with_fallback(size) + + def _load_font_with_fallback(self, size: int): + """ + 在不同平台上尝试加载常见等宽或中文字体,避免中文渲染为方块。 + """ + pygame = self.pygame + candidates = [ + # Windows 常见中文字体 + "Microsoft YaHei UI", + "Microsoft YaHei", + "SimHei", + "SimSun", + # 常见等宽/通用字体 + "Consolas", + "DejaVu Sans", + "DejaVu Sans Mono", + "Arial Unicode MS", + "Noto Sans CJK SC", + "Noto Sans CJK", + ] + + for name in candidates: + try: + f = pygame.font.SysFont(name, size) + # 简单验证一下是否能渲染中文(有些字体返回成功但渲染为空) + test = f.render("测试中文AaBb123", True, (255, 255, 255)) + if test.get_width() > 0: + return f + except Exception: + pass + + # 退回默认字体 + return pygame.font.SysFont(None, size) + + +__all__ = ["Front"] + + diff --git a/src/run.py b/src/run.py deleted file mode 100644 index 65e359f..0000000 --- a/src/run.py +++ /dev/null @@ -1,13 +0,0 @@ -from src.classes.avatar import Avatar, Gender -from src.classes.calendar import Month, Year - -avatar = Avatar( - name="John Doe", - id=1, - birth_month=Month.JANUARY, - birth_year=Year(2000), - age=20, - gender=Gender.MALE -) - -print(avatar) \ No newline at end of file diff --git a/src/world/world.py b/src/sim/simulator.py similarity index 69% rename from src/world/world.py rename to src/sim/simulator.py index a652758..4ec8972 100644 --- a/src/world/world.py +++ b/src/sim/simulator.py @@ -1,8 +1,8 @@ -class World: +class Simulator: def __init__(self): - pass + self.avatars = [] # list[Avatar] def step(self): """ @@ -12,4 +12,6 @@ class World: 先结算多个角色间互相交互的事件。 再去结算单个角色的事件。 """ - pass \ No newline at end of file + # 结算角色行为 + for avatar in self.avatars: + avatar.act() \ No newline at end of file diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..95a865c --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,3 @@ +"""通用工具模块。""" + + diff --git a/src/utils/strings.py b/src/utils/strings.py new file mode 100644 index 0000000..b9ca169 --- /dev/null +++ b/src/utils/strings.py @@ -0,0 +1,10 @@ +def to_snake_case(name: str) -> str: + """将驼峰/帕斯卡命名转换为蛇形命名。""" + chars = [] + for i, ch in enumerate(name): + if ch.isupper() and i > 0: + chars.append('_') + chars.append(ch.lower()) + return ''.join(chars) + + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d6f4fee --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +import os +import sys + +# 将项目根目录加入 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) + + diff --git a/tests/run_front.py b/tests/run_front.py new file mode 100644 index 0000000..399db66 --- /dev/null +++ b/tests/run_front.py @@ -0,0 +1,205 @@ +import os +import sys +import random +from typing import List, Tuple + +# 将项目根目录加入 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 +from src.classes.world import World +from src.classes.tile import Map, TileType +from src.classes.avatar import Avatar, Gender +from src.classes.calendar import Month, Year +from src.classes.action import Move + + +def clamp(value: int, lo: int, hi: int) -> int: + return max(lo, min(hi, value)) + + +def circle_points(cx: int, cy: int, r: int, width: int, height: int) -> List[Tuple[int, int]]: + pts: List[Tuple[int, int]] = [] + r2 = r * r + for y in range(clamp(cy - r, 0, height - 1), clamp(cy + r, 0, height - 1) + 1): + for x in range(clamp(cx - r, 0, width - 1), clamp(cx + r, 0, width - 1) + 1): + if (x - cx) * (x - cx) + (y - cy) * (y - cy) <= r2: + pts.append((x, y)) + return pts + + +def build_rich_random_map(width: int = 30, height: int = 20, *, seed: int | None = None) -> Map: + if seed is not None: + random.seed(seed) + + game_map = Map(width=width, height=height) + + # 1) 底色:平原 + for y in range(height): + for x in range(width): + game_map.create_tile(x, y, TileType.PLAIN) + + # 2) 西部大漠(左侧宽带),先铺设便于后续北/南带覆盖 + desert_w = max(4, width // 5) + for y in range(height): + for x in range(0, desert_w): + game_map.create_tile(x, y, TileType.DESERT) + # 绿洲 + for _ in range(random.randint(2, 3)): + cx = random.randint(1, max(1, desert_w - 1)) + cy = random.randint(2, height - 3) + r = random.randint(1, 2) + for x, y in circle_points(cx, cy, r, width, height): + if x < desert_w: + game_map.create_tile(x, y, TileType.WATER) + + # 3) 北部雪山与冰原(顶部宽带,覆盖整宽度) + north_band = max(3, height // 5) + for y in range(0, north_band): + for x in range(width): + game_map.create_tile(x, y, TileType.SNOW_MOUNTAIN) + # 局部冰川簇 + for _ in range(random.randint(2, 3)): + cx = random.randint(1, width - 2) + cy = random.randint(0, north_band - 1) + r = random.randint(1, 2) + for x, y in circle_points(cx, cy, r, width, height): + if y < north_band: + game_map.create_tile(x, y, TileType.GLACIER) + + # 4) 南部热带雨林(底部宽带,覆盖整宽度) + south_band = max(3, height // 5) + for y in range(height - south_band, height): + for x in range(width): + game_map.create_tile(x, y, TileType.RAINFOREST) + + # 5) 最东海域(右侧宽带),最后铺海以覆盖前面的地形;随后在海中造岛 + sea_band_w = max(3, width // 6) + sea_x0 = width - sea_band_w + for y in range(height): + for x in range(sea_x0, width): + game_map.create_tile(x, y, TileType.SEA) + # 岛屿:在海域内生成若干小岛(平原/森林) + for _ in range(random.randint(3, 5)): + cx = random.randint(sea_x0, width - 2) + cy = random.randint(1, height - 2) + r = random.randint(1, 2) + kind = random.choice([TileType.PLAIN, TileType.FOREST]) + for x, y in circle_points(cx, cy, r, width, height): + if x >= sea_x0: + game_map.create_tile(x, y, kind) + + # 6) 若干湖泊(水域圆斑,限制在中部非海域) + for _ in range(random.randint(3, 5)): + cx = random.randint(max(2, desert_w + 1), sea_x0 - 2) + cy = random.randint(north_band + 1, height - south_band - 2) + r = random.randint(1, 3) + for x, y in circle_points(cx, cy, r, width, height): + if x < sea_x0: + game_map.create_tile(x, y, TileType.WATER) + + # 7) 中部山脉:几条短链(避开海域和上下带) + for _ in range(random.randint(2, 4)): + length = random.randint(6, 12) + x = random.randint(desert_w + 1, sea_x0 - 2) + y = random.randint(north_band + 1, height - south_band - 2) + dx, dy = random.choice([(1, 0), (1, 1), (1, -1)]) + for _ in range(length): + if 0 <= x < sea_x0 and north_band <= y < height - south_band: + game_map.create_tile(x, y, TileType.MOUNTAIN) + x += dx + y += dy + + # 8) 中部森林:几个圆斑 + for _ in range(random.randint(4, 7)): + cx = random.randint(desert_w + 1, sea_x0 - 2) + cy = random.randint(north_band + 1, height - south_band - 2) + r = random.randint(2, 4) + for x, y in circle_points(cx, cy, r, width, height): + game_map.create_tile(x, y, TileType.FOREST) + + # 9) 城市:2~4个,尽量落在非极端地形 + cities = 0 + attempts = 0 + while cities < random.randint(2, 4) and attempts < 200: + attempts += 1 + x = random.randint(0, width - 1) + y = random.randint(0, height - 1) + t = game_map.get_tile(x, y) + if t.type not in (TileType.WATER, TileType.SEA, TileType.MOUNTAIN, TileType.GLACIER, TileType.SNOW_MOUNTAIN, TileType.DESERT): + game_map.create_tile(x, y, TileType.CITY) + cities += 1 + + return game_map + + +def random_gender() -> Gender: + return Gender.MALE if random.random() < 0.5 else Gender.FEMALE + + +def make_avatars(world: World, count: int = 12) -> list[Avatar]: + avatars: list[Avatar] = [] + width, height = world.map.width, world.map.height + for i in range(count): + name = f"NPC{i+1:03d}" + birth_year = Year(random.randint(1990, 2010)) + birth_month = random.choice(list(Month)) + age = random.randint(16, 60) + gender = random_gender() + + # 找一个非海域的出生点 + for _ in range(200): + x = random.randint(0, width - 1) + y = random.randint(0, height - 1) + t = world.map.get_tile(x, y) + if t.type not in (TileType.WATER, TileType.SEA, TileType.MOUNTAIN): + break + else: + x, y = random.randint(0, width - 1), random.randint(0, height - 1) + + avatar = Avatar( + world=world, + name=name, + id=i + 1, + birth_month=birth_month, + birth_year=birth_year, + age=age, + gender=gender, + pos_x=x, + pos_y=y, + ) + avatar.tile = world.map.get_tile(x, y) + avatar.bind_action(Move) + avatars.append(avatar) + return avatars + + +def main(): + # 为了每次更丰富,使用随机种子;如需复现可将 seed 固定 + # random.seed(42) + + width, height = 36, 24 + game_map = build_rich_random_map(width=width, height=height) + world = World(map=game_map) + + sim = Simulator() + sim.avatars.extend(make_avatars(world, count=14)) + + front = Front( + world=world, + simulator=sim, + tile_size=28, + margin=8, + step_interval_ms=350, + window_title="Cultivation World — Front Demo", + ) + front.run() + + +if __name__ == "__main__": + main() + diff --git a/tests/test_basic.py b/tests/test_basic.py new file mode 100644 index 0000000..bfc8887 --- /dev/null +++ b/tests/test_basic.py @@ -0,0 +1,28 @@ +from src.classes.avatar import Avatar, Gender +from src.classes.calendar import Month, Year +from src.classes.world import World +from src.classes.tile import Map, TileType + +def test_basic(): + """ + 测试整个基础代码能不能run起来 + """ + map = Map(width=2, height=2) + for x in range(2): + for y in range(2): + map.create_tile(x, y, TileType.PLAIN) + + world = World(map=map) + + avatar = Avatar( + world=world, + name="John Doe", + id=1, + birth_month=Month.JANUARY, + birth_year=Year(2000), + age=20, + gender=Gender.MALE + ) + + + diff --git a/tests/test_simulator.py b/tests/test_simulator.py new file mode 100644 index 0000000..aa5bbb9 --- /dev/null +++ b/tests/test_simulator.py @@ -0,0 +1,53 @@ +import random + +from src.sim.simulator import Simulator +from src.classes.avatar import Avatar, Gender +from src.classes.calendar import Month, Year +from src.classes.world import World +from src.classes.tile import Map, TileType +from src.classes.action import Move + + +def test_simulator_step_moves_avatar_and_sets_tile(): + # 固定随机种子,确保决定的移动是可预测的 + random.seed(0) + + # 构建 3x3 地图并填充地块 + game_map = Map(width=3, height=3) + for x in range(3): + for y in range(3): + game_map.create_tile(x, y, TileType.PLAIN) + + world = World(map=game_map) + + # 将角色放在地图中心,避免越界 + avatar = Avatar( + world=world, + name="Tester", + id=1, + birth_month=Month.JANUARY, + birth_year=Year(2000), + age=20, + gender=Gender.MALE, + pos_x=1, + pos_y=1, + ) + + # 绑定移动动作 + avatar.bind_action(Move) + + sim = Simulator() + sim.avatars.append(avatar) + + # 执行一步模拟 + sim.step() + + # 断言位置在边界内 + assert 0 <= avatar.pos_x < game_map.width + assert 0 <= avatar.pos_y < game_map.height + + # 断言 tile 已正确设置且与位置一致 + assert avatar.tile is not None + assert avatar.tile.x == avatar.pos_x + assert avatar.tile.y == avatar.pos_y +