diff --git a/src/classes/color.py b/src/classes/color.py index a0a9cb1..28eaf73 100644 --- a/src/classes/color.py +++ b/src/classes/color.py @@ -2,11 +2,15 @@ 颜色系统 统一管理游戏中各种等级、稀有度的颜色方案 """ +from __future__ import annotations + +import re from typing import Protocol class ColorGradable(Protocol): """支持颜色分级的协议""" + @property def color_rgb(self) -> tuple[int, int, int]: """返回RGB颜色值""" @@ -15,35 +19,43 @@ class ColorGradable(Protocol): # ==================== 通用颜色定义 ==================== + class Color: """预定义的颜色常量""" + # 基础颜色 WHITE = (255, 255, 255) BLACK = (0, 0, 0) - + # 品质颜色 - 从低到高 - COMMON_WHITE = (255, 255, 255) # 普通/白色 - UNCOMMON_GREEN = (50, 205, 50) # 不凡/绿色 - RARE_BLUE = (74, 144, 226) # 稀有/蓝色 - EPIC_PURPLE = (147, 112, 219) # 史诗/紫色 - LEGENDARY_GOLD = (255, 215, 0) # 传说/金色 + COMMON_WHITE = (255, 255, 255) # 普通/白色 + UNCOMMON_GREEN = (50, 205, 50) # 不凡/绿色 + RARE_BLUE = (74, 144, 226) # 稀有/蓝色 + EPIC_PURPLE = (147, 112, 219) # 史诗/紫色 + LEGENDARY_GOLD = (255, 215, 0) # 传说/金色 # ==================== 辅助函数 ==================== + +COLOR_TAG_PATTERN = re.compile( + r"(.*?)", re.DOTALL +) + + def get_color_from_mapping( grade_value: object, color_mapping: dict, - default_color: tuple[int, int, int] = Color.COMMON_WHITE + default_color: tuple[int, int, int] = Color.COMMON_WHITE, ) -> tuple[int, int, int]: """ 从映射字典中获取颜色 - + Args: grade_value: 等级对象 color_mapping: 等级到颜色的映射字典 default_color: 默认颜色 - + Returns: RGB颜色元组 """ @@ -54,11 +66,11 @@ def format_colored_text(text: str, color_rgb: tuple[int, int, int]) -> str: """ 将文本格式化为带颜色标记的字符串,供前端渲染使用 格式:text - + Args: text: 要着色的文本 color_rgb: RGB颜色元组 - + Returns: 带颜色标记的文本字符串 """ @@ -66,6 +78,51 @@ def format_colored_text(text: str, color_rgb: tuple[int, int, int]) -> str: return f"{text}" +def rgb_to_hex(color_rgb: tuple[int, int, int]) -> str: + """RGB 整数元组转 16 进制字符串(#rrggbb)""" + r, g, b = color_rgb + return f"#{r:02x}{g:02x}{b:02x}" + + +def split_colored_segments(text: str) -> list[dict[str, str]]: + """ + 将包含 标签的文本拆分为 segments, + 每个 segment 结构:{"text": "...", "color": "#rrggbb"}(color 可选)。 + """ + segments: list[dict[str, str]] = [] + last_index = 0 + + for match in COLOR_TAG_PATTERN.finditer(text): + start, end = match.span() + if start > last_index: + plain = text[last_index:start] + if plain: + segments.append({"text": plain}) + + r, g, b, content = match.groups() + color_hex = rgb_to_hex((int(r), int(g), int(b))) + segments.append({"text": content, "color": color_hex}) + last_index = end + + if last_index < len(text): + trailing = text[last_index:] + if trailing: + segments.append({"text": trailing}) + + if not segments: + segments.append({"text": text}) + + return segments + + +def serialize_hover_lines(lines: list[str]) -> list[list[dict[str, str]]]: + """将 hover 信息行转换为 segment 列表,供前端直接渲染颜色。""" + serialized: list[list[dict[str, str]]] = [] + for line in lines: + serialized.append(split_colored_segments(line or "")) + return serialized + + # ==================== 颜色方案映射 ==================== # 装备等级颜色方案(普通-宝物-法宝) diff --git a/src/server/main.py b/src/server/main.py index 4352eed..59a0f57 100644 --- a/src/server/main.py +++ b/src/server/main.py @@ -2,7 +2,8 @@ import sys import os import asyncio from contextlib import asynccontextmanager -from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from typing import List +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles import uvicorn @@ -17,6 +18,8 @@ from src.run.create_map import create_cultivation_world_map, add_sect_headquarte from src.sim.new_avatar import make_avatars as _new_make from src.utils.config import CONFIG from src.classes.sect import sects_by_id +from src.classes.color import serialize_hover_lines +from src.classes.event import Event import random # 全局游戏实例 @@ -48,6 +51,46 @@ class ConnectionManager: manager = ConnectionManager() + +def serialize_events_for_client(events: List[Event]) -> List[dict]: + """将事件转换为前端可用的结构。""" + serialized: List[dict] = [] + for idx, event in enumerate(events): + month_stamp = getattr(event, "month_stamp", None) + stamp_int = None + year = None + month = None + if month_stamp is not None: + try: + stamp_int = int(month_stamp) + except Exception: + stamp_int = None + try: + year = int(month_stamp.get_year()) + except Exception: + year = None + try: + month_obj = month_stamp.get_month() + month = int(getattr(month_obj, "value", month_obj)) + except Exception: + month = None + + related_raw = getattr(event, "related_avatars", None) or [] + related_ids = [str(a) for a in related_raw if a is not None] + + serialized.append({ + "id": getattr(event, "event_id", None) or f"{stamp_int or 'evt'}-{idx}", + "text": str(event), + "content": getattr(event, "content", ""), + "year": year, + "month": month, + "month_stamp": stamp_int, + "related_avatar_ids": related_ids, + "is_major": bool(getattr(event, "is_major", False)), + "is_story": bool(getattr(event, "is_story", False)), + }) + return serialized + def init_game(): """初始化游戏世界,逻辑复用自 src/run/run.py""" print("正在初始化游戏世界...") @@ -98,7 +141,7 @@ async def game_loop(): "type": "tick", "year": int(world.month_stamp.get_year()), "month": world.month_stamp.get_month().value, - "events": [str(e) for e in events], + "events": serialize_events_for_client(events), # 暂时只发前 50 个角色的位置更新,减少数据量 "avatars": [ { @@ -212,12 +255,21 @@ def get_state(): except Exception as e: return {"step": 3, "error": str(e)} + recent_events = [] + try: + event_manager = getattr(world, "event_manager", None) + if event_manager: + recent_events = serialize_events_for_client(event_manager.get_recent_events(limit=50)) + except Exception: + recent_events = [] + return { "status": "ok", "year": y, "month": m, "avatar_count": len(world.avatar_manager.avatars), - "avatars": av_list + "avatars": av_list, + "events": recent_events } except Exception as e: @@ -285,6 +337,43 @@ async def step_world(): "events_sample": [str(e) for e in events[:5]] } +@app.get("/api/hover") +def get_hover_info( + target_type: str = Query(alias="type"), + target_id: str = Query(alias="id") +): + world = game_instance.get("world") + if world is None: + raise HTTPException(status_code=503, detail="World not initialized") + + target = None + if target_type == "avatar": + target = world.avatar_manager.avatars.get(target_id) + elif target_type == "region": + if world.map and hasattr(world.map, "regions"): + regions = world.map.regions + target = regions.get(target_id) + if target is None: + try: + target = regions.get(int(target_id)) + except (ValueError, TypeError): + target = None + else: + raise HTTPException(status_code=400, detail="Unsupported target type") + + if target is None: + raise HTTPException(status_code=404, detail="Target not found") + if not hasattr(target, "get_hover_info"): + raise HTTPException(status_code=422, detail="Target has no hover info") + + lines = target.get_hover_info() or [] + return { + "id": target_id, + "type": target_type, + "name": getattr(target, "name", target_id), + "lines": serialize_hover_lines([str(line) for line in lines]), + } + def start(): """启动服务的入口函数""" # 改为 8002 端口 diff --git a/web/src/App.vue b/web/src/App.vue index 71ee0b7..2928243 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -3,6 +3,7 @@ import { onMounted, ref, computed } from 'vue' import { useGameStore } from './stores/game' import { NConfigProvider, darkTheme, NSelect } from 'naive-ui' import GameCanvas from './components/game/GameCanvas.vue' +import InfoPanel from './components/InfoPanel.vue' const store = useGameStore() const filterValue = ref('all') @@ -12,10 +13,26 @@ const filterOptions = computed(() => [ ...store.avatarList.map(a => ({ label: a.name, value: a.id })) ]) +const filteredEvents = computed(() => { + const allEvents = Array.isArray(store.events) ? store.events : store.events ?? [] + if (filterValue.value === 'all') { + return allEvents.slice() + } + return allEvents.filter(event => event.relatedAvatarIds.includes(filterValue.value)) +}) + +const emptyEventMessage = computed(() => { + return filterValue.value === 'all' ? '暂无事件' : '该修士暂无事件' +}) + onMounted(async () => { await store.fetchInitialState() store.connect() }) + +function handleSelection(target: { type: 'avatar' | 'region'; id: string; name?: string }) { + store.openInfoPanel(target) +}