add map
This commit is contained in:
@@ -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
|
||||
@@ -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)}
|
||||
@@ -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
7
src/classes/world.py
Normal 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
5
src/front/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .front import Front
|
||||
|
||||
__all__ = ["Front"]
|
||||
|
||||
|
||||
281
src/front/front.py
Normal file
281
src/front/front.py
Normal 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"]
|
||||
|
||||
|
||||
13
src/run.py
13
src/run.py
@@ -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)
|
||||
@@ -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
3
src/utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""通用工具模块。"""
|
||||
|
||||
|
||||
10
src/utils/strings.py
Normal file
10
src/utils/strings.py
Normal 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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user