diff --git a/README.md b/README.md index 72b3330..2177445 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ - ✅ 角色交互范围 - ✅ 角色Buffs系统:增益/减益效果 - ✅ 法宝 +- [ ] 角色之间的相性 - [ ] 角色特殊能力 - [ ] 角色绰号 - [ ] 战斗相关 diff --git a/src/classes/avatar.py b/src/classes/avatar.py index 3a3d76d..2ff581a 100644 --- a/src/classes/avatar.py +++ b/src/classes/avatar.py @@ -171,7 +171,9 @@ class Avatar: 获取 avatar 的信息,返回 dict;根据 detailed 控制信息粒度。 """ region = self.tile.region if self.tile is not None else None - relations_info = self._get_relations_summary_str() + from src.classes.relation import get_relations_strs + relation_lines = get_relations_strs(self, max_lines=8) + relations_info = ";".join(relation_lines) if relation_lines else "无" magic_stone_info = str(self.magic_stone) if detailed: @@ -564,10 +566,11 @@ class Avatar: if self.spirit_animal is not None: add_kv(lines, "灵兽", self.spirit_animal.get_info()) - # 关系 - relations_list = [f"{other.name}({str(relation)})" for other, relation in getattr(self, "relations", {}).items()] - if relations_list: - add_section(lines, "关系", [f" {s}" for s in relations_list[:6]]) + # 关系(从自身视角分组展示) + from src.classes.relation import get_relations_strs + relation_lines = get_relations_strs(self, max_lines=6) + if relation_lines: + add_section(lines, "关系", [f" {s}" for s in relation_lines]) else: add_kv(lines, "关系", "无") diff --git a/src/classes/relation.py b/src/classes/relation.py index 981cfb7..bc1b020 100644 --- a/src/classes/relation.py +++ b/src/classes/relation.py @@ -2,6 +2,7 @@ from __future__ import annotations from enum import Enum from typing import TYPE_CHECKING, List +from collections import defaultdict class Relation(Enum): @@ -135,3 +136,35 @@ def get_possible_post_relations(from_avatar: "Avatar", to_avatar: "Avatar") -> L return candidates + +# ——— 悬浮提示:从“自身视角”格式化关系 ——— +def _label_from_self_perspective(relation: Relation) -> str: + # 以“我”为参照:有向关系需要取对偶后再显示(如 MASTER -> 徒弟)。 + counterpart = get_reciprocal(relation) + return relation_display_names.get(counterpart, str(counterpart)) + + +def get_relations_strs(avatar: "Avatar", max_lines: int = 6) -> list[str]: + """ + 以“我”的视角整理关系,输出若干行: + - 我的师傅:A,B + - 我的徒弟:C + - 兄弟姐妹:D,E + 等。 + """ + relations = getattr(avatar, "relations", None) + if not relations: + return [] + + grouped: dict[str, list[str]] = defaultdict(list) + for other, rel in relations.items(): + grouped[_label_from_self_perspective(rel)].append(other.name) + + lines: list[str] = [] + for key in sorted(grouped.keys()): + names = ",".join(grouped[key]) + lines.append(f"{key}为:{names}") + if len(lines) >= max_lines: + break + return lines + diff --git a/src/sim/new_avatar.py b/src/sim/new_avatar.py index 328c01d..e1b56eb 100644 --- a/src/sim/new_avatar.py +++ b/src/sim/new_avatar.py @@ -9,7 +9,7 @@ from src.classes.calendar import MonthStamp from src.classes.cultivation import CultivationProgress from src.classes.root import Root from src.classes.age import Age -from src.utils.names import get_random_name_for_sect +from src.utils.names import get_random_name_for_sect, pick_surname_for_sect, get_random_name_with_surname from src.utils.id_generator import get_avatar_id from src.classes.sect import Sect from src.classes.alignment import Alignment @@ -18,6 +18,38 @@ from src.classes.technique import get_technique_by_sect, attribute_to_root from src.classes.treasure import treasures_by_sect_id +# —— 参数常量(便于调参)—— +SECT_MEMBER_RATIO: float = 2 / 3 + +AGE_MIN: int = 16 +AGE_MAX: int = 150 +LEVEL_MIN: int = 0 +LEVEL_MAX: int = 120 + +FAMILY_PAIR_CAP_DIV: int = 6 # 家庭上限:n // 6 +FAMILY_TRIGGER_PROB: float = 0.35 # 生成家庭对概率 +FATHER_CHILD_PROB: float = 0.60 # 家庭为父子(同姓、父为男)的概率;否则母子(异姓、母为女) + +LOVERS_PAIR_CAP_DIV: int = 5 # 道侣两两预算:n // 5 +LOVERS_TRIGGER_PROB: float = 0.25 # 生成一对道侣的概率(强制异性) + +MASTER_PAIR_PROB: float = 0.30 # 同宗门内生成一对师徒的概率 + +FRIEND_PROB: float = 0.18 # 朋友概率 +ENEMY_PROB: float = 0.10 # 仇人概率(与朋友互斥) + +PARENT_MIN_DIFF: int = 16 # 父母与子女最小年龄差 +PARENT_MAX_DIFF: int = 80 # 父母与子女最大年龄差(用于生成目标差值) +PARENT_AGE_CAP: int = 120 # 父母年龄上限(修仙世界放宽) + +MASTER_LEVEL_MIN_DIFF: int = 20 # 师傅与徒弟最小等级差 +MASTER_LEVEL_EXTRA_MAX: int = 10 # 在最小等级差基础上的额外浮动 + +# 父母-子女等级差(修仙世界中通常父母更强) +PARENT_LEVEL_MIN_DIFF: int = 10 # 父母与子女最小等级差 +PARENT_LEVEL_EXTRA_MAX: int = 10 # 在最小等级差基础上的额外浮动 + + def random_gender() -> Gender: return Gender.MALE if random.random() < 0.5 else Gender.FEMALE @@ -45,49 +77,200 @@ def get_new_avatar_from_ordinary(world: World, current_month_stamp: MonthStamp, ) -def make_avatars( +def plan_sects_and_relations(n: int, existed_sects: Optional[List[Sect]]) -> tuple[list[Optional[Sect]], list[Optional[Gender]], list[Optional[str]], dict[tuple[int, int], Relation]]: + """ + 规划: + - 每个索引对应的宗门(可为空,表示散修); + - 性别(部分在后续阶段才确定); + - 姓氏(用于生成父子同姓/母子异姓等); + - 预设关系 (i,j)->Relation(方向遵循 set_relation 的方向)。 + """ + n = int(max(0, n)) + use_sects = bool(existed_sects) + planned_sect: list[Optional[Sect]] = [None] * n + if n == 0: + return planned_sect, [None]*0, [None]*0, {} + + # 宗门均衡分配(约 2/3 成为宗门弟子) + if use_sects and existed_sects: + sect_member_target = int(n * SECT_MEMBER_RATIO) # 目标配额:约2/3为宗门弟子;其余散修 + sect_member_counts_by_id: dict[int, int] = {s.id: 0 for s in existed_sects} + for i in range(sect_member_target): + min_count = min(sect_member_counts_by_id.values()) if sect_member_counts_by_id else 0 # 轮转均衡:优先填充人数最少的宗门 + candidates = [s for s in existed_sects if sect_member_counts_by_id.get(s.id, 0) == min_count] + s = random.choice(candidates) + sect_member_counts_by_id[s.id] += 1 + planned_sect[i] = s + paired = list(zip(planned_sect, list(range(n)))) + random.shuffle(paired) + planned_sect = [p[0] for p in paired] + + planned_gender: list[Optional[Gender]] = [None] * n + planned_surname: list[Optional[str]] = [None] * n + planned_relations: dict[tuple[int, int], Relation] = {} + + # — 家庭 — + unused_indices = list(range(n)) + random.shuffle(unused_indices) + + def _reserve_pair() -> tuple[int, int] | None: + if len(unused_indices) < 2: + return None + a = unused_indices.pop() + b = unused_indices.pop() + return (a, b) + + family_pairs_budget = max(0, n // FAMILY_PAIR_CAP_DIV) # 家庭上限:约每6人1对;触发概率见常量 + for _ in range(family_pairs_budget): + if random.random() < FAMILY_TRIGGER_PROB: + pair = _reserve_pair() + if pair is None: + break + a, b = pair + if random.random() < FATHER_CHILD_PROB: + # 父子:同姓;父为男 + surname = pick_surname_for_sect(planned_sect[a] or planned_sect[b]) + planned_surname[a] = surname + planned_surname[b] = surname + planned_gender[a] = Gender.MALE + planned_relations[(a, b)] = Relation.PARENT + else: + # 母子:异姓;母为女 + mother = a if random.random() < 0.5 else b + child = b if mother == a else a + planned_gender[mother] = Gender.FEMALE + mom_surname = pick_surname_for_sect(planned_sect[mother]) + planned_surname[mother] = mom_surname + for _ in range(5): + s = pick_surname_for_sect(planned_sect[child]) + if s != mom_surname: + planned_surname[child] = s + break + planned_relations[(mother, child)] = Relation.PARENT + + leftover = unused_indices[:] + + # — 道侣 — + random.shuffle(leftover) + lovers_budget = max(0, n // LOVERS_PAIR_CAP_DIV) # 道侣预算,两两配对,强制异性 + i = 0 + while i + 1 < len(leftover) and lovers_budget > 0: + if random.random() < LOVERS_TRIGGER_PROB: + a = leftover[i] + b = leftover[i + 1] + if (a, b) not in planned_relations and (b, a) not in planned_relations: + if planned_gender[a] is None and planned_gender[b] is None: + planned_gender[a] = Gender.MALE if random.random() < 0.5 else Gender.FEMALE + planned_gender[b] = Gender.FEMALE if planned_gender[a] is Gender.MALE else Gender.MALE + elif planned_gender[a] is None: + planned_gender[a] = Gender.MALE if planned_gender[b] is Gender.FEMALE else Gender.FEMALE + elif planned_gender[b] is None: + planned_gender[b] = Gender.MALE if planned_gender[a] is Gender.FEMALE else Gender.FEMALE + if planned_gender[a] != planned_gender[b]: + planned_relations[(a, b)] = Relation.LOVERS + lovers_budget -= 1 + i += 2 + + # — 师徒(同宗门)— + if use_sects and existed_sects: + members_by_sect: dict[int, list[int]] = {s.id: [] for s in existed_sects} + for idx, sect in enumerate(planned_sect): + if sect is not None: + members_by_sect.setdefault(sect.id, []).append(idx) + for _sect_id, members in members_by_sect.items(): + random.shuffle(members) + j = 0 + while j + 1 < len(members): + if random.random() < MASTER_PAIR_PROB: # 师徒:同宗门内指定概率,两两配对 + master, apprentice = members[j], members[j + 1] + if (master, apprentice) not in planned_relations and (apprentice, master) not in planned_relations: + planned_relations[(master, apprentice)] = Relation.MASTER + j += 2 + + # — 朋友/仇人 — + all_indices = list(range(n)) + random.shuffle(all_indices) + k = 0 + while k + 1 < len(all_indices): # 朋友/仇人互斥 + a, b = all_indices[k], all_indices[k + 1] + if (a, b) in planned_relations or (b, a) in planned_relations: + k += 2 + continue + r = random.random() + if r < FRIEND_PROB: + planned_relations[(a, b)] = Relation.FRIEND + elif r < FRIEND_PROB + ENEMY_PROB: + planned_relations[(a, b)] = Relation.ENEMY + k += 2 + + # 性别兜底 + for i in range(n): + if planned_gender[i] is None: + planned_gender[i] = random_gender() + + return planned_sect, planned_gender, planned_surname, planned_relations + + +def build_avatars_from_plan( world: World, - count: int = 12, - current_month_stamp: MonthStamp = MonthStamp(100 * 12), - existed_sects: Optional[List[Sect]] = None, + current_month_stamp: MonthStamp, + planned_sect: list[Optional[Sect]], + planned_gender: list[Optional[Gender]], + planned_surname: list[Optional[str]], + planned_relations: dict[tuple[int, int], Relation], ) -> dict[str, Avatar]: - avatars: dict[str, Avatar] = {} + """ + 根据规划生成实际 Avatar,并写入关系与宗门法宝/灵根映射。 + """ + n = len(planned_sect) width, height = world.map.width, world.map.height - num_total = int(count) - use_sects = bool(existed_sects) - # 约 2/3 为宗门弟子,1/3 为散修 - sect_member_target = int(num_total * 2 / 3) if use_sects else 0 + ages: list[int] = [random.randint(AGE_MIN, AGE_MAX) for _ in range(n)] + levels: list[int] = [random.randint(LEVEL_MIN, LEVEL_MAX) for _ in range(n)] - # 统计将要分配的宗门成员数量(用于均分) - sect_member_count = 0 - sect_member_counts_by_id: dict[int, int] = {s.id: 0 for s in existed_sects} if existed_sects else {} + # 调整父子年龄差(父母比子女至少大PARENT_MIN_DIFF,最大PARENT_AGE_CAP) + for (a, b), rel in list(planned_relations.items()): + if rel is Relation.PARENT: + if ages[a] <= ages[b] + (PARENT_MIN_DIFF - 1): + ages[a] = min(PARENT_AGE_CAP, ages[b] + random.randint(PARENT_MIN_DIFF, PARENT_MAX_DIFF)) + + # 调整父母-子女等级差(通常父母更强) + for (a, b), rel in list(planned_relations.items()): + if rel is Relation.PARENT: + # 至少略高于子女 + if levels[a] <= levels[b]: + levels[a] = min(LEVEL_MAX, levels[b] + 1) + # 满足最小差值要求 + if levels[a] < levels[b] + PARENT_LEVEL_MIN_DIFF: + levels[a] = min(LEVEL_MAX, levels[b] + PARENT_LEVEL_MIN_DIFF + random.randint(0, PARENT_LEVEL_EXTRA_MAX)) + + # 调整师徒级差(师傅≥徒弟 MASTER_LEVEL_MIN_DIFF) + for (a, b), rel in list(planned_relations.items()): + if rel is Relation.MASTER: + if levels[a] < levels[b] + MASTER_LEVEL_MIN_DIFF: + levels[a] = min(LEVEL_MAX, levels[b] + MASTER_LEVEL_MIN_DIFF + random.randint(0, MASTER_LEVEL_EXTRA_MAX)) + + avatars_by_index: list[Avatar] = [None] * n # type: ignore + avatars_by_id: dict[str, Avatar] = {} - # 本局中“已给出宗门法宝”的标记,确保每个宗门最多一件且仅首次分配 sect_treasure_assigned: Dict[int, bool] = {} - for i in range(count): - age_years = random.randint(16, 60) - birth_month_stamp = current_month_stamp - age_years * 12 + random.randint(0, 11) - gender = random_gender() + for i in range(n): + gender = planned_gender[i] or random_gender() + sect = planned_sect[i] - # 分配宗门或散修 - assigned_sect: Optional[Sect] = None - if use_sects and sect_member_count < sect_member_target and existed_sects: - min_count = min(sect_member_counts_by_id.values()) if sect_member_counts_by_id else 0 - candidates = [s for s in existed_sects if sect_member_counts_by_id.get(s.id, 0) == min_count] - assigned_sect = random.choice(candidates) - sect_member_counts_by_id[assigned_sect.id] = sect_member_counts_by_id.get(assigned_sect.id, 0) + 1 - sect_member_count += 1 + if planned_surname[i]: + name = get_random_name_with_surname(gender, planned_surname[i] or "", sect) + else: + name = get_random_name_for_sect(gender, sect) - name = get_random_name_for_sect(gender, assigned_sect) - - level = random.randint(0, 120) + level = levels[i] cultivation_progress = CultivationProgress(level) + age_years = ages[i] age = Age(age_years, cultivation_progress.realm) - # 出生点: x, y = random.randint(0, width - 1), random.randint(0, height - 1) + birth_month_stamp = current_month_stamp - age_years * 12 + random.randint(0, 11) avatar = Avatar( world=world, @@ -100,38 +283,48 @@ def make_avatars( pos_x=x, pos_y=y, root=random.choice(list(Root)), - sect=assigned_sect, + sect=sect, ) avatar.tile = world.map.get_tile(x, y) - if assigned_sect is not None: - avatar.alignment = assigned_sect.alignment - avatar.technique = get_technique_by_sect(assigned_sect) - - # 若该宗门有法宝,且本局尚未分配过,则给该宗门第一个生成的弟子分配法宝 - treasure = treasures_by_sect_id.get(assigned_sect.id) - if treasure is not None and not sect_treasure_assigned.get(assigned_sect.id, False): + if sect is not None: + avatar.alignment = sect.alignment + avatar.technique = get_technique_by_sect(sect) + treasure = treasures_by_sect_id.get(sect.id) + if treasure is not None and not sect_treasure_assigned.get(sect.id, False): # 每宗门仅发放一次所属法宝 avatar.treasure = treasure - sect_treasure_assigned[assigned_sect.id] = True + sect_treasure_assigned[sect.id] = True - mapped_root = attribute_to_root(avatar.technique.attribute) - if mapped_root is not None: - avatar.root = mapped_root + if avatar.technique is not None: + mapped = attribute_to_root(avatar.technique.attribute) + if mapped is not None: # 功法属性→默认灵根映射(邪不映射) + avatar.root = mapped - avatars[avatar.id] = avatar + avatars_by_index[i] = avatar + avatars_by_id[avatar.id] = avatar - # 简单关系样例 - avatar_list = list(avatars.values()) - # if len(avatar_list) >= 2: - # avatar_list[0].set_relation(avatar_list[1], Relation.ENEMY) - # if len(avatar_list) >= 4: - # avatar_list[2].set_relation(avatar_list[3], Relation.FRIEND) - # if len(avatar_list) >= 6: - # avatar_list[4].set_relation(avatar_list[5], Relation.MASTER) - # if len(avatar_list) >= 8: - # avatar_list[6].set_relation(avatar_list[7], Relation.LOVERS) + for (a, b), relation in planned_relations.items(): + av_a = avatars_by_index[a] + av_b = avatars_by_index[b] + if av_a is None or av_b is None or av_a is av_b: + continue + av_a.set_relation(av_b, relation) - return avatars + return avatars_by_id + + +def make_avatars( + world: World, + count: int = 12, + current_month_stamp: MonthStamp = MonthStamp(100 * 12), + existed_sects: Optional[List[Sect]] = None, +) -> dict[str, Avatar]: + n = int(max(0, count)) + if n == 0: + return {} + # 只负责编排:先规划,再生成 + planned_sect, planned_gender, planned_surname, planned_relations = plan_sects_and_relations(n, existed_sects) + return build_avatars_from_plan(world, current_month_stamp, planned_sect, planned_gender, planned_surname, planned_relations) diff --git a/src/utils/names.py b/src/utils/names.py index 7e575b4..78e1332 100644 --- a/src/utils/names.py +++ b/src/utils/names.py @@ -74,11 +74,37 @@ def get_random_name_for_sect(gender: Gender, sect: Optional[Sect]) -> str: """ 基于宗门生成姓名:优先使用宗门常见姓与性别对应名,若缺失则回退到全局库。 """ + surname = pick_surname_for_sect(sect) + given_pool = get_given_pool_for_sect(gender, sect) + return surname + random.choice(given_pool) + + +# —— 新增:基于指定姓氏与宗门风格生成姓名 —— +def pick_surname_for_sect(sect: Optional[Sect]) -> str: + """ + 从宗门常见姓或全局库中挑选一个姓氏。 + """ + if sect is not None and sect.sect_surnames: + return random.choice(sect.sect_surnames) + return random.choice(SURNAMES) + + +def get_given_pool_for_sect(gender: Gender, sect: Optional[Sect]) -> list[str]: + """ + 返回给定性别与宗门下的名库(回退到全局)。 + """ if sect is None: - return get_random_name(gender) - surnames = sect.sect_surnames or SURNAMES + return MALE_GIVEN_NAMES if gender == Gender.MALE else FEMALE_GIVEN_NAMES if gender == Gender.MALE: - given_pool = sect.male_sect_given_names or MALE_GIVEN_NAMES - else: - given_pool = sect.female_sect_given_names or FEMALE_GIVEN_NAMES - return random.choice(surnames) + random.choice(given_pool) \ No newline at end of file + return sect.male_sect_given_names or MALE_GIVEN_NAMES + return sect.female_sect_given_names or FEMALE_GIVEN_NAMES + + +def get_random_name_with_surname(gender: Gender, surname: str, sect: Optional[Sect]) -> str: + """ + 使用给定姓氏,结合宗门偏好名库生成姓名;若宗门未配置则回退全局。 + """ + if not surname: + return get_random_name_for_sect(gender, sect) + given_pool = get_given_pool_for_sect(gender, sect) + return surname + random.choice(given_pool) \ No newline at end of file