This commit is contained in:
bridge
2025-11-20 23:55:22 +08:00
parent 0713881b71
commit 5a51b6638d
10 changed files with 560 additions and 34 deletions

View File

@@ -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"<color:(\d{1,3}),(\d{1,3}),(\d{1,3})>(.*?)</color>", 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:
"""
将文本格式化为带颜色标记的字符串,供前端渲染使用
格式:<color:R,G,B>text</color>
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"<color:{r},{g},{b}>{text}</color>"
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]]:
"""
将包含 <color> 标签的文本拆分为 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
# ==================== 颜色方案映射 ====================
# 装备等级颜色方案(普通-宝物-法宝)

View File

@@ -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 端口

View File

@@ -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)
}
</script>
<template>
@@ -38,7 +55,11 @@ onMounted(async () => {
<div class="main-content">
<!-- 地图区域 (占据主要空间) -->
<div class="map-container">
<GameCanvas />
<GameCanvas
@avatarSelected="handleSelection"
@regionSelected="handleSelection"
/>
<InfoPanel />
</div>
<!-- 右侧侧边栏 (固定宽度) -->
@@ -53,10 +74,10 @@ onMounted(async () => {
class="event-filter"
/>
</div>
<div v-if="store.events.length === 0" class="empty">暂无事件</div>
<div v-if="filteredEvents.length === 0" class="empty">{{ emptyEventMessage }}</div>
<div v-else class="event-list">
<div v-for="(event, index) in store.events" :key="index" class="event-item">
{{ event }}
<div v-for="event in filteredEvents" :key="event.id" class="event-item">
{{ event.content || event.text }}
</div>
</div>
</div>
@@ -161,10 +182,12 @@ onMounted(async () => {
}
.event-item {
font-size: 12px;
color: #ccc;
padding: 4px 0;
border-bottom: 1px solid #333;
font-size: 14px;
line-height: 1.6;
color: #ddd;
padding: 6px 0;
border-bottom: 1px solid #2a2a2a;
white-space: pre-line;
}
.event-item:last-child {

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useGameStore } from '../stores/game'
const store = useGameStore()
const title = computed(() => store.selectedTarget?.name ?? '')
</script>
<template>
<div
v-if="store.selectedTarget"
class="info-panel"
>
<div class="info-header">
<div class="info-title">{{ title || '详情' }}</div>
<button class="close-btn" type="button" @click="store.closeInfoPanel()">×</button>
</div>
<div class="info-body">
<div v-if="store.infoLoading" class="placeholder">加载中...</div>
<div v-else-if="store.infoError" class="placeholder error">
{{ store.infoError }}
</div>
<ul v-else-if="store.hoverInfo.length" class="info-list">
<li v-for="(line, index) in store.hoverInfo" :key="index">
<template v-if="line.length">
<span
v-for="(segment, segIndex) in line"
:key="segIndex"
class="info-segment"
:style="segment.color ? { color: segment.color } : undefined"
>
{{ segment.text || ' ' }}
</span>
</template>
<span v-else class="info-segment">&nbsp;</span>
</li>
</ul>
<div v-else class="placeholder">暂无信息</div>
</div>
</div>
</template>
<style scoped>
.info-panel {
position: absolute;
top: 20px;
right: 20px;
width: 320px;
max-height: calc(100vh - 40px);
background: rgba(24, 24, 24, 0.96);
border: 1px solid #333;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.45);
color: #eee;
pointer-events: auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
.info-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(38, 38, 38, 0.95);
border-bottom: 1px solid #333;
}
.info-title {
font-size: 16px;
font-weight: bold;
}
.close-btn {
background: transparent;
border: none;
color: #999;
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 2px 4px;
}
.close-btn:hover {
color: #fff;
}
.info-body {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.info-list {
list-style: none;
padding: 0;
margin: 0;
font-size: 13px;
line-height: 1.5;
}
.info-list li + li {
margin-top: 6px;
}
.info-segment {
white-space: pre-wrap;
}
.placeholder {
font-size: 13px;
color: #888;
}
.placeholder.error {
color: #ff7875;
}
</style>

View File

@@ -9,6 +9,10 @@ const props = defineProps<{
tileSize: number
}>()
const emit = defineEmits<{
(e: 'select', payload: { type: 'avatar'; id: string; name?: string }): void
}>()
const { textures } = useTextures()
const app = useApplication()
@@ -90,6 +94,14 @@ const nameStyle = {
alpha: 0.8
}
}
function handlePointerTap() {
emit('select', {
type: 'avatar',
id: props.avatar.id,
name: props.avatar.name
})
}
</script>
<template>
@@ -97,6 +109,9 @@ const nameStyle = {
:x="currentX"
:y="currentY"
:z-index="Math.floor(currentY)"
event-mode="static"
cursor="pointer"
@pointertap="handlePointerTap"
>
<sprite
v-if="getTexture()"

View File

@@ -4,6 +4,14 @@ import AnimatedAvatar from './AnimatedAvatar.vue'
const store = useGameStore()
const TILE_SIZE = 64
const emit = defineEmits<{
(e: 'avatarSelected', payload: { type: 'avatar'; id: string; name?: string }): void
}>()
function handleAvatarSelect(payload: { type: 'avatar'; id: string; name?: string }) {
emit('avatarSelected', payload)
}
</script>
<template>
@@ -13,6 +21,7 @@ const TILE_SIZE = 64
:key="avatar.id"
:avatar="avatar"
:tile-size="TILE_SIZE"
@select="handleAvatarSelect"
/>
</container>
</template>

View File

@@ -13,10 +13,23 @@ const { loadBaseTextures, isLoaded } = useTextures()
const mapSize = ref({ width: 2000, height: 2000 })
const emit = defineEmits<{
(e: 'avatarSelected', payload: { type: 'avatar'; id: string; name?: string }): void
(e: 'regionSelected', payload: { type: 'region'; id: string; name?: string }): void
}>()
function onMapLoaded(size: { width: number, height: number }) {
mapSize.value = size
}
function handleAvatarSelected(payload: { type: 'avatar'; id: string; name?: string }) {
emit('avatarSelected', payload)
}
function handleRegionSelected(payload: { type: 'region'; id: string; name?: string }) {
emit('regionSelected', payload)
}
const devicePixelRatio = window.devicePixelRatio || 1
onMounted(() => {
@@ -46,8 +59,8 @@ onMounted(() => {
:world-width="mapSize.width"
:world-height="mapSize.height"
>
<MapLayer @mapLoaded="onMapLoaded" />
<EntityLayer />
<MapLayer @mapLoaded="onMapLoaded" @regionSelected="handleRegionSelected" />
<EntityLayer @avatarSelected="handleAvatarSelected" />
</Viewport>
</Application>
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, watch, inject } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { Container, Sprite } from 'pixi.js'
import { useTextures } from './composables/useTextures'
@@ -8,7 +8,10 @@ const { textures, isLoaded, loadSectTexture } = useTextures()
const TILE_SIZE = 64
const regions = ref<any[]>([])
const emit = defineEmits(['mapLoaded'])
const emit = defineEmits<{
(e: 'mapLoaded', payload: { width: number, height: number }): void
(e: 'regionSelected', payload: { type: 'region'; id: string; name?: string }): void
}>()
async function initMap() {
if (!mapContainer.value || !isLoaded.value) return
@@ -113,8 +116,8 @@ function getRegionStyle(type: string) {
const base = {
fontFamily: '"Microsoft YaHei", sans-serif',
fontSize: type === 'sect' ? 48 : 64,
fill: type === 'sect' ? 0xffcc00 : (type === 'city' ? 0xccffcc : 0xffffff),
stroke: { color: 0x000000, width: 8, join: 'round' },
fill: type === 'sect' ? '#ffcc00' : (type === 'city' ? '#ccffcc' : '#ffffff'),
stroke: { color: '#000000', width: 8, join: 'round' },
align: 'center',
dropShadow: {
color: '#000000',
@@ -126,6 +129,14 @@ function getRegionStyle(type: string) {
}
return base
}
function handleRegionSelect(region: any) {
emit('regionSelected', {
type: 'region',
id: String(region.id),
name: region.name
})
}
</script>
<template>
@@ -135,6 +146,7 @@ function getRegionStyle(type: string) {
<!-- Region Labels Layer (Above tiles) -->
<container :z-index="200">
<!-- @vue-ignore -->
<text
v-for="r in regions"
:key="r.name"
@@ -143,6 +155,9 @@ function getRegionStyle(type: string) {
:y="r.y * TILE_SIZE + TILE_SIZE / 2"
:anchor="0.5"
:style="getRegionStyle(r.type)"
event-mode="static"
cursor="pointer"
@pointertap="handleRegionSelect(r)"
/>
</container>
</container>

8
web/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>
export default component
}

View File

@@ -9,16 +9,136 @@ export interface Avatar {
action?: string
}
export type HoverTarget = {
type: 'avatar' | 'region'
id: string
name?: string
}
export interface HoverSegment {
text: string
color?: string
}
export type HoverLine = HoverSegment[]
function normalizeHoverLines(raw: unknown): HoverLine[] {
if (!Array.isArray(raw)) return []
return raw.map((line) => {
if (!Array.isArray(line)) {
return [{ text: line != null ? String(line) : '' }]
}
const segments = line.map((segment) => {
if (segment && typeof segment === 'object') {
const record = segment as Record<string, unknown>
const textValue = 'text' in record ? record.text : ''
const colorValue = 'color' in record ? record.color : undefined
const text = textValue != null ? String(textValue) : ''
const color = typeof colorValue === 'string' && colorValue ? colorValue : undefined
return { text, color }
}
return { text: segment != null ? String(segment) : '' }
})
return segments.length ? segments : [{ text: '' }]
})
}
export interface GameEvent {
id: string
text: string
content?: string
year?: number
month?: number
monthStamp?: number
relatedAvatarIds: string[]
isMajor?: boolean
isStory?: boolean
}
let localEventIdCounter = 0
function nextLocalEventId(prefix: string) {
localEventIdCounter += 1
return `${prefix}-${Date.now()}-${localEventIdCounter}`
}
function normalizeGameEvent(raw: unknown): GameEvent | null {
if (raw == null) {
return null
}
if (typeof raw === 'string') {
return {
id: nextLocalEventId('legacy'),
text: raw,
relatedAvatarIds: []
}
}
if (typeof raw === 'object') {
const record = raw as Record<string, unknown>
const textSource = record.text ?? record.content ?? ''
const text = typeof textSource === 'string' ? textSource : String(textSource ?? '')
const idSource = record.id
const id = typeof idSource === 'string' && idSource ? idSource : nextLocalEventId('evt')
const relatedSource = record.related_avatar_ids ?? record.relatedAvatarIds ?? []
const relatedAvatarIds = Array.isArray(relatedSource) ? relatedSource.map(val => String(val)) : []
const content = typeof record.content === 'string' ? record.content : undefined
const year = typeof record.year === 'number' ? record.year : undefined
const month = typeof record.month === 'number' ? record.month : undefined
const monthStampRaw = record.month_stamp ?? record.monthStamp
const monthStamp = typeof monthStampRaw === 'number' ? monthStampRaw : undefined
const isMajor = Boolean(record.is_major ?? record.isMajor ?? false)
const isStory = Boolean(record.is_story ?? record.isStory ?? false)
return {
id,
text: text || content || '',
content,
year,
month,
monthStamp,
relatedAvatarIds,
isMajor,
isStory
}
}
return {
id: nextLocalEventId('legacy'),
text: String(raw),
relatedAvatarIds: []
}
}
export const useGameStore = defineStore('game', () => {
const isConnected = ref(false)
const year = ref(0)
const month = ref(0)
const avatars = ref<Record<string, Avatar>>({})
const events = ref<string[]>([]) // 添加事件列表状态
const events = ref<GameEvent[]>([]) // 添加事件列表状态
const selectedTarget = ref<HoverTarget | null>(null)
const hoverInfo = ref<HoverLine[]>([])
const infoLoading = ref(false)
const infoError = ref<string | null>(null)
const hoverCache = new Map<string, HoverLine[]>()
// 计算属性:转换为数组以便遍历
const avatarList = computed(() => Object.values(avatars.value))
function cacheKey(target: HoverTarget) {
return `${target.type}:${target.id}`
}
function appendEvents(rawEvents: unknown) {
if (!Array.isArray(rawEvents)) return
const normalized = rawEvents
.map((item) => normalizeGameEvent(item))
.filter((evt): evt is GameEvent => !!evt)
if (normalized.length) {
events.value = [...normalized, ...events.value].slice(0, 100)
}
}
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
// 开发环境下 Vite 代理会处理 /ws生产环境直接连
@@ -38,10 +158,7 @@ export const useGameStore = defineStore('game', () => {
month.value = data.month
// 更新事件日志
if (data.events && Array.isArray(data.events)) {
// 将新事件追加到开头
events.value = [...data.events, ...events.value].slice(0, 100) // 只保留最近100条
}
appendEvents(data.events)
// 更新 Avatars增量更新逻辑这里后端暂发的是全量/部分列表,直接覆盖位置)
if (data.avatars && Array.isArray(data.avatars)) {
@@ -82,12 +199,65 @@ export const useGameStore = defineStore('game', () => {
avatars.value[av.id] = av
})
}
appendEvents(data.events)
}
} catch (e) {
console.error('Fetch State Error', e)
}
}
async function fetchHoverInfo(target: HoverTarget) {
const key = cacheKey(target)
const cached = hoverCache.get(key)
if (cached) {
if (selectedTarget.value && cacheKey(selectedTarget.value) === key) {
hoverInfo.value = cached
}
infoLoading.value = false
infoError.value = null
return
}
infoLoading.value = true
infoError.value = null
hoverInfo.value = []
try {
const query = new URLSearchParams({ type: target.type, id: target.id })
const res = await fetch(`/api/hover?${query.toString()}`)
if (!res.ok) {
throw new Error(`加载失败:${res.status}`)
}
const data = await res.json()
const lines = normalizeHoverLines(data.lines)
hoverCache.set(key, lines)
if (selectedTarget.value && cacheKey(selectedTarget.value) === key) {
hoverInfo.value = lines
}
} catch (e) {
if (selectedTarget.value && cacheKey(selectedTarget.value) === key) {
infoError.value = e instanceof Error ? e.message : String(e)
hoverInfo.value = []
}
} finally {
if (selectedTarget.value && cacheKey(selectedTarget.value) === key) {
infoLoading.value = false
}
}
}
function openInfoPanel(target: HoverTarget) {
selectedTarget.value = target
fetchHoverInfo(target)
}
function closeInfoPanel() {
selectedTarget.value = null
infoError.value = null
hoverInfo.value = []
infoLoading.value = false
}
return {
isConnected,
year,
@@ -95,8 +265,14 @@ export const useGameStore = defineStore('game', () => {
avatars,
avatarList,
events, // 导出 events
selectedTarget,
hoverInfo,
infoLoading,
infoError,
connect,
fetchInitialState
fetchInitialState,
openInfoPanel,
closeInfoPanel
}
})