fix bug
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
# ==================== 颜色方案映射 ====================
|
||||
|
||||
# 装备等级颜色方案(普通-宝物-法宝)
|
||||
|
||||
@@ -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 端口
|
||||
|
||||
@@ -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 {
|
||||
|
||||
121
web/src/components/InfoPanel.vue
Normal file
121
web/src/components/InfoPanel.vue
Normal 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"> </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>
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
8
web/src/env.d.ts
vendored
Normal 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user