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)