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)
|
||||
|
||||
|
||||
9
tests/conftest.py
Normal file
9
tests/conftest.py
Normal 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
205
tests/run_front.py
Normal 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
28
tests/test_basic.py
Normal 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
53
tests/test_simulator.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user