This commit is contained in:
bridge
2025-08-20 01:18:04 +08:00
parent b309b2749c
commit 7851cbba0d
14 changed files with 782 additions and 17 deletions

View File

@@ -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

View File

@@ -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)}

View File

@@ -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)]

7
src/classes/world.py Normal file
View File

@@ -0,0 +1,7 @@
from dataclasses import dataclass
from src.classes.tile import Map
@dataclass
class World():
map: Map

5
src/front/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .front import Front
__all__ = ["Front"]

281
src/front/front.py Normal file
View File

@@ -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"]

View File

@@ -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)

View File

@@ -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
# 结算角色行为
for avatar in self.avatars:
avatar.act()

3
src/utils/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""通用工具模块。"""

10
src/utils/strings.py Normal file
View File

@@ -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)

9
tests/conftest.py Normal file
View File

@@ -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)

205
tests/run_front.py Normal file
View File

@@ -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()

28
tests/test_basic.py Normal file
View File

@@ -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
)

53
tests/test_simulator.py Normal file
View File

@@ -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