refactor web
This commit is contained in:
146
web/src/App.vue
146
web/src/App.vue
@@ -1,32 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { useGameStore } from './stores/game'
|
||||
import { NConfigProvider, darkTheme, NSelect } from 'naive-ui'
|
||||
import { NConfigProvider, darkTheme } from 'naive-ui'
|
||||
import GameCanvas from './components/game/GameCanvas.vue'
|
||||
import InfoPanel from './components/InfoPanel.vue'
|
||||
import StatusBar from './components/layout/StatusBar.vue'
|
||||
import EventPanel from './components/panels/EventPanel.vue'
|
||||
|
||||
const store = useGameStore()
|
||||
const filterValue = ref('all')
|
||||
|
||||
const filterOptions = computed(() => [
|
||||
{ label: '所有人', value: 'all' },
|
||||
...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()
|
||||
await store.fetchInitialState().catch((error) => {
|
||||
console.error('初始化失败', error)
|
||||
})
|
||||
store.connect()
|
||||
})
|
||||
|
||||
@@ -38,22 +24,8 @@ function handleSelection(target: { type: 'avatar' | 'region'; id: string; name?:
|
||||
<template>
|
||||
<n-config-provider :theme="darkTheme">
|
||||
<div class="app-layout">
|
||||
<!-- 顶部状态栏 -->
|
||||
<header class="top-bar">
|
||||
<div class="left">
|
||||
<span class="title">修仙模拟器</span>
|
||||
<span class="status-dot" :class="{ connected: store.isConnected }"></span>
|
||||
</div>
|
||||
<div class="center">
|
||||
<span class="time">{{ store.year }}年 {{ store.month }}月</span>
|
||||
</div>
|
||||
<div class="right">
|
||||
<span>修士: {{ store.avatarList.length }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<StatusBar />
|
||||
<div class="main-content">
|
||||
<!-- 地图区域 (占据主要空间) -->
|
||||
<div class="map-container">
|
||||
<GameCanvas
|
||||
@avatarSelected="handleSelection"
|
||||
@@ -61,26 +33,8 @@ function handleSelection(target: { type: 'avatar' | 'region'; id: string; name?:
|
||||
/>
|
||||
<InfoPanel />
|
||||
</div>
|
||||
|
||||
<!-- 右侧侧边栏 (固定宽度) -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-header">
|
||||
<h3>事件记录</h3>
|
||||
<n-select
|
||||
v-model:value="filterValue"
|
||||
:options="filterOptions"
|
||||
size="tiny"
|
||||
class="event-filter"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="filteredEvents.length === 0" class="empty">{{ emptyEventMessage }}</div>
|
||||
<div v-else class="event-list">
|
||||
<div v-for="event in filteredEvents" :key="event.id" class="event-item">
|
||||
{{ event.content || event.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EventPanel />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,34 +52,6 @@ function handleSelection(target: { type: 'avatar' | 'region'; id: string; name?:
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
height: 36px;
|
||||
background: #1f1f1f;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.top-bar .title {
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #ff4d4f;
|
||||
}
|
||||
.status-dot.connected {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -141,63 +67,11 @@ function handleSelection(target: { type: 'avatar' | 'region'; id: string; name?:
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 400px; /* Increased width */
|
||||
width: 400px;
|
||||
background: #181818;
|
||||
border-left: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #222;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.sidebar-header h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-filter {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
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 {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useTextures } from './composables/useTextures'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { Graphics, Ticker } from 'pixi.js'
|
||||
import { useApplication } from 'vue3-pixi'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Graphics } from 'pixi.js'
|
||||
import type { Avatar } from '../../types/game'
|
||||
import { useSharedTicker } from './composables/useSharedTicker'
|
||||
|
||||
const props = defineProps<{
|
||||
avatar: any
|
||||
avatar: Avatar
|
||||
tileSize: number
|
||||
}>()
|
||||
|
||||
@@ -14,7 +15,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { textures } = useTextures()
|
||||
const app = useApplication()
|
||||
|
||||
// Target position (grid coordinates)
|
||||
const targetX = ref(props.avatar.x)
|
||||
@@ -30,34 +30,23 @@ watch(() => [props.avatar.x, props.avatar.y], ([newX, newY]) => {
|
||||
targetY.value = newY
|
||||
})
|
||||
|
||||
// Animation Loop
|
||||
onMounted(() => {
|
||||
const ticker = new Ticker()
|
||||
ticker.add((delta) => {
|
||||
const destX = targetX.value * props.tileSize + props.tileSize / 2
|
||||
const destY = targetY.value * props.tileSize + props.tileSize / 2
|
||||
|
||||
// Simple Lerp for smoothness
|
||||
// Speed factor: 0.1 means it covers 10% of the remaining distance per frame
|
||||
const speed = 0.1 * delta.deltaTime
|
||||
|
||||
if (Math.abs(destX - currentX.value) > 1) {
|
||||
currentX.value += (destX - currentX.value) * speed
|
||||
} else {
|
||||
currentX.value = destX
|
||||
}
|
||||
|
||||
if (Math.abs(destY - currentY.value) > 1) {
|
||||
currentY.value += (destY - currentY.value) * speed
|
||||
} else {
|
||||
currentY.value = destY
|
||||
}
|
||||
})
|
||||
ticker.start()
|
||||
useSharedTicker((delta) => {
|
||||
const destX = targetX.value * props.tileSize + props.tileSize / 2
|
||||
const destY = targetY.value * props.tileSize + props.tileSize / 2
|
||||
|
||||
// Cleanup manually if needed, though Vue unmount should handle parent destruction
|
||||
// Ideally we should attach to app.ticker, but local ticker is easier for per-component logic without memory leaks if managed well.
|
||||
// Better approach: use onTick from vue3-pixi if available, or just requestAnimationFrame
|
||||
const speed = 0.1 * delta
|
||||
|
||||
if (Math.abs(destX - currentX.value) > 1) {
|
||||
currentX.value += (destX - currentX.value) * speed
|
||||
} else {
|
||||
currentX.value = destX
|
||||
}
|
||||
|
||||
if (Math.abs(destY - currentY.value) > 1) {
|
||||
currentY.value += (destY - currentY.value) * speed
|
||||
} else {
|
||||
currentY.value = destY
|
||||
}
|
||||
})
|
||||
|
||||
function getTexture() {
|
||||
|
||||
@@ -1,141 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { Container, Sprite } from 'pixi.js'
|
||||
import { useTextures } from './composables/useTextures'
|
||||
import { useMapData } from '../../composables/useMapData'
|
||||
import type { Region } from '../../types/game'
|
||||
|
||||
const TILE_SIZE = 64
|
||||
const mapContainer = ref<Container>()
|
||||
const { textures, isLoaded, loadSectTexture } = useTextures()
|
||||
const TILE_SIZE = 64
|
||||
const regions = ref<any[]>([])
|
||||
const { mapTiles, regions, isMapLoaded, loadMapData } = useMapData()
|
||||
const regionStyleCache = new Map<string, Record<string, unknown>>()
|
||||
|
||||
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
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/map')
|
||||
const data = await res.json()
|
||||
const mapData = data.data
|
||||
regions.value = data.regions || []
|
||||
|
||||
// 1. 预加载所有宗门的纹理
|
||||
const loadPromises: Promise<void>[] = []
|
||||
for (const r of regions.value) {
|
||||
if (r.type === 'sect' && r.sect_name) {
|
||||
// 使用 sect_name(宗门名)而不是 name(总部名)来加载图片
|
||||
loadPromises.push(loadSectTexture(r.sect_name))
|
||||
}
|
||||
}
|
||||
await Promise.all(loadPromises)
|
||||
|
||||
if (!mapData) return
|
||||
|
||||
// Imperative Tile Rendering
|
||||
mapContainer.value.removeChildren()
|
||||
|
||||
const rows = mapData.length
|
||||
const cols = mapData[0].length
|
||||
|
||||
console.log(`Rendering Map: ${cols}x${rows}`)
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const type = mapData[y][x]
|
||||
|
||||
// 占位符直接跳过,不渲染任何东西(让背景透出来,或者就空着)
|
||||
if (type === 'PLACEHOLDER') continue
|
||||
|
||||
let tex = textures.value[type]
|
||||
|
||||
// 特殊处理 SECT 类型
|
||||
if (type === 'SECT') {
|
||||
const r = regions.value.find(r =>
|
||||
r.type === 'sect' && Math.abs(r.x - x) < 3 && Math.abs(r.y - y) < 3
|
||||
)
|
||||
|
||||
if (r && r.sect_name) {
|
||||
// 使用 sect_name(宗门名)而不是 name(总部名)来匹配图片
|
||||
const sectKey = `SECT_${r.sect_name}`
|
||||
if (textures.value[sectKey]) {
|
||||
tex = textures.value[sectKey]
|
||||
} else {
|
||||
tex = textures.value['CITY']
|
||||
}
|
||||
} else {
|
||||
tex = textures.value['CITY']
|
||||
}
|
||||
}
|
||||
|
||||
if (!tex) tex = textures.value['PLAIN']
|
||||
|
||||
if (tex) {
|
||||
const s = new Sprite(tex)
|
||||
s.x = x * TILE_SIZE
|
||||
s.y = y * TILE_SIZE
|
||||
|
||||
// 2x2 大地块渲染逻辑
|
||||
if (['SECT', 'CITY', 'CAVE', 'RUINS'].includes(type)) {
|
||||
s.width = TILE_SIZE * 2
|
||||
s.height = TILE_SIZE * 2
|
||||
// 确保层级正确,大建筑可以稍微调整 zIndex 如果有深度排序需求
|
||||
// 但在这里 tile 是平铺的,只要顺序对就行
|
||||
} else {
|
||||
s.width = TILE_SIZE
|
||||
s.height = TILE_SIZE
|
||||
}
|
||||
|
||||
s.eventMode = 'none'
|
||||
mapContainer.value.addChild(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit('mapLoaded', {
|
||||
width: cols * TILE_SIZE,
|
||||
height: rows * TILE_SIZE
|
||||
})
|
||||
|
||||
} catch (e) {
|
||||
console.error("Map load error", e)
|
||||
onMounted(() => {
|
||||
loadMapData().catch((error) => console.error('Map load error', error))
|
||||
if (isLoaded.value && isMapLoaded.value) {
|
||||
renderMap()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [isLoaded.value, isMapLoaded.value],
|
||||
([texturesReady, mapReady]) => {
|
||||
if (texturesReady && mapReady) {
|
||||
renderMap()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function renderMap() {
|
||||
if (!mapContainer.value || !mapTiles.value.length) return
|
||||
|
||||
await preloadSectTextures(regions.value)
|
||||
mapContainer.value.removeChildren()
|
||||
|
||||
const rows = mapTiles.value.length
|
||||
const cols = mapTiles.value[0]?.length ?? 0
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const type = mapTiles.value[y][x]
|
||||
if (type === 'PLACEHOLDER') continue
|
||||
|
||||
let tex = textures.value[type]
|
||||
|
||||
if (type === 'SECT') {
|
||||
tex = resolveSectTexture(x, y) ?? textures.value['CITY']
|
||||
}
|
||||
|
||||
if (!tex) {
|
||||
tex = textures.value['PLAIN']
|
||||
}
|
||||
|
||||
if (!tex) continue
|
||||
|
||||
const sprite = new Sprite(tex)
|
||||
sprite.x = x * TILE_SIZE
|
||||
sprite.y = y * TILE_SIZE
|
||||
|
||||
if (['SECT', 'CITY', 'CAVE', 'RUINS'].includes(type)) {
|
||||
sprite.width = TILE_SIZE * 2
|
||||
sprite.height = TILE_SIZE * 2
|
||||
} else {
|
||||
sprite.width = TILE_SIZE
|
||||
sprite.height = TILE_SIZE
|
||||
}
|
||||
|
||||
sprite.eventMode = 'none'
|
||||
mapContainer.value.addChild(sprite)
|
||||
}
|
||||
}
|
||||
|
||||
emit('mapLoaded', {
|
||||
width: cols * TILE_SIZE,
|
||||
height: rows * TILE_SIZE
|
||||
})
|
||||
}
|
||||
|
||||
watch(isLoaded, (val) => {
|
||||
if (val) initMap()
|
||||
})
|
||||
async function preloadSectTextures(regionList: Region[]) {
|
||||
const sectNames = Array.from(
|
||||
new Set(
|
||||
regionList
|
||||
.filter(region => region.type === 'sect' && region.sect_name)
|
||||
.map(region => region.sect_name as string)
|
||||
)
|
||||
)
|
||||
await Promise.all(sectNames.map(name => loadSectTexture(name)))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isLoaded.value) initMap()
|
||||
})
|
||||
function resolveSectTexture(x: number, y: number) {
|
||||
const region = regions.value.find(r =>
|
||||
r.type === 'sect' && Math.abs(r.x - x) < 3 && Math.abs(r.y - y) < 3
|
||||
)
|
||||
if (region?.sect_name) {
|
||||
const key = `SECT_${region.sect_name}`
|
||||
return textures.value[key] ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getRegionStyle(type: string) {
|
||||
const base = {
|
||||
fontFamily: '"Microsoft YaHei", sans-serif',
|
||||
fontSize: type === 'sect' ? 48 : 64,
|
||||
fill: type === 'sect' ? '#ffcc00' : (type === 'city' ? '#ccffcc' : '#ffffff'),
|
||||
stroke: { color: '#000000', width: 8, join: 'round' },
|
||||
align: 'center',
|
||||
dropShadow: {
|
||||
color: '#000000',
|
||||
blur: 4,
|
||||
angle: Math.PI / 6,
|
||||
distance: 4,
|
||||
alpha: 0.8
|
||||
}
|
||||
if (regionStyleCache.has(type)) {
|
||||
return regionStyleCache.get(type)
|
||||
}
|
||||
const style = {
|
||||
fontFamily: '"Microsoft YaHei", sans-serif',
|
||||
fontSize: type === 'sect' ? 48 : 64,
|
||||
fill: type === 'sect' ? '#ffcc00' : (type === 'city' ? '#ccffcc' : '#ffffff'),
|
||||
stroke: { color: '#000000', width: 8, join: 'round' },
|
||||
align: 'center',
|
||||
dropShadow: {
|
||||
color: '#000000',
|
||||
blur: 4,
|
||||
angle: Math.PI / 6,
|
||||
distance: 4,
|
||||
alpha: 0.8
|
||||
}
|
||||
return base
|
||||
}
|
||||
regionStyleCache.set(type, style)
|
||||
return style
|
||||
}
|
||||
|
||||
function handleRegionSelect(region: any) {
|
||||
emit('regionSelected', {
|
||||
type: 'region',
|
||||
id: String(region.id),
|
||||
name: region.name
|
||||
})
|
||||
function handleRegionSelect(region: Region) {
|
||||
emit('regionSelected', {
|
||||
type: 'region',
|
||||
id: String(region.id),
|
||||
name: region.name
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
28
web/src/components/game/composables/useSharedTicker.ts
Normal file
28
web/src/components/game/composables/useSharedTicker.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Ticker } from 'pixi.js'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const sharedTicker = new Ticker()
|
||||
let consumerCount = 0
|
||||
|
||||
export function useSharedTicker(callback: (delta: number) => void) {
|
||||
const runner = (ticker: Ticker) => {
|
||||
callback(ticker.deltaTime)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
consumerCount += 1
|
||||
sharedTicker.add(runner)
|
||||
if (!sharedTicker.started) {
|
||||
sharedTicker.start()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
sharedTicker.remove(runner)
|
||||
consumerCount = Math.max(consumerCount - 1, 0)
|
||||
if (consumerCount === 0) {
|
||||
sharedTicker.stop()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,28 +30,29 @@ export function useTextures() {
|
||||
'FARM': '/assets/tiles/farm.png'
|
||||
}
|
||||
|
||||
// 加载基础地图纹理
|
||||
for (const [key, url] of Object.entries(manifest)) {
|
||||
const tilePromises = Object.entries(manifest).map(async ([key, url]) => {
|
||||
try {
|
||||
textures.value[key] = await Assets.load(url)
|
||||
} catch (e) {
|
||||
console.error(`Failed to load texture: ${url}`, e)
|
||||
} catch (error) {
|
||||
console.error(`Failed to load texture: ${url}`, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 加载角色立绘 (1-16)
|
||||
for (let i = 1; i <= 16; i++) {
|
||||
const avatarPromises = Array.from({ length: 16 }, (_, index) => index + 1).map(async (i) => {
|
||||
const maleUrl = `/assets/males/${i}.png`
|
||||
const femaleUrl = `/assets/females/${i}.png`
|
||||
|
||||
try {
|
||||
textures.value[`male_${i}`] = await Assets.load(maleUrl)
|
||||
} catch (e) { /* ignore */ }
|
||||
await Promise.allSettled([
|
||||
Assets.load(maleUrl).then((tex) => {
|
||||
textures.value[`male_${i}`] = tex
|
||||
}),
|
||||
Assets.load(femaleUrl).then((tex) => {
|
||||
textures.value[`female_${i}`] = tex
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
try {
|
||||
textures.value[`female_${i}`] = await Assets.load(femaleUrl)
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
await Promise.all([...tilePromises, ...avatarPromises])
|
||||
|
||||
isLoaded.value = true
|
||||
console.log('Base textures loaded')
|
||||
|
||||
53
web/src/components/layout/StatusBar.vue
Normal file
53
web/src/components/layout/StatusBar.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { useGameStore } from '../../stores/game'
|
||||
|
||||
const store = useGameStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="top-bar">
|
||||
<div class="left">
|
||||
<span class="title">修仙模拟器</span>
|
||||
<span class="status-dot" :class="{ connected: store.isConnected }"></span>
|
||||
</div>
|
||||
<div class="center">
|
||||
<span class="time">{{ store.year }}年 {{ store.month }}月</span>
|
||||
</div>
|
||||
<div class="right">
|
||||
<span>修士: {{ store.avatarList.length }}</span>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top-bar {
|
||||
height: 36px;
|
||||
background: #1f1f1f;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.top-bar .title {
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #ff4d4f;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
100
web/src/components/panels/EventPanel.vue
Normal file
100
web/src/components/panels/EventPanel.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useGameStore } from '../../stores/game'
|
||||
import { NSelect } from 'naive-ui'
|
||||
|
||||
const store = useGameStore()
|
||||
const filterValue = ref('all')
|
||||
|
||||
const filterOptions = computed(() => [
|
||||
{ label: '所有人', value: 'all' },
|
||||
...store.avatarList.map(avatar => ({ label: avatar.name ?? avatar.id, value: avatar.id }))
|
||||
])
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
const allEvents = Array.isArray(store.events) ? store.events : []
|
||||
if (filterValue.value === 'all') {
|
||||
return allEvents
|
||||
}
|
||||
return allEvents.filter(event => event.relatedAvatarIds.includes(filterValue.value))
|
||||
})
|
||||
|
||||
const emptyEventMessage = computed(() => (
|
||||
filterValue.value === 'all' ? '暂无事件' : '该修士暂无事件'
|
||||
))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="sidebar-section">
|
||||
<div class="sidebar-header">
|
||||
<h3>事件记录</h3>
|
||||
<n-select
|
||||
v-model:value="filterValue"
|
||||
:options="filterOptions"
|
||||
size="tiny"
|
||||
class="event-filter"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="filteredEvents.length === 0" class="empty">{{ emptyEventMessage }}</div>
|
||||
<div v-else class="event-list">
|
||||
<div v-for="event in filteredEvents" :key="event.id" class="event-item">
|
||||
{{ event.content || event.text }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #222;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.sidebar-header h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-filter {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
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 {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
35
web/src/composables/useMapData.ts
Normal file
35
web/src/composables/useMapData.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ref } from 'vue'
|
||||
import type { MapMatrix, Region } from '../types/game'
|
||||
import { gameApi } from '../services/gameApi'
|
||||
|
||||
const mapTiles = ref<MapMatrix>([])
|
||||
const regions = ref<Region[]>([])
|
||||
const isMapLoaded = ref(false)
|
||||
let inFlight: Promise<void> | null = null
|
||||
|
||||
async function loadMapData() {
|
||||
if (isMapLoaded.value) return
|
||||
if (inFlight) return inFlight
|
||||
|
||||
inFlight = gameApi.getMap()
|
||||
.then((data) => {
|
||||
mapTiles.value = data.data ?? []
|
||||
regions.value = data.regions ?? []
|
||||
isMapLoaded.value = mapTiles.value.length > 0
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null
|
||||
})
|
||||
|
||||
return inFlight
|
||||
}
|
||||
|
||||
export function useMapData() {
|
||||
return {
|
||||
mapTiles,
|
||||
regions,
|
||||
isMapLoaded,
|
||||
loadMapData
|
||||
}
|
||||
}
|
||||
|
||||
21
web/src/services/apiClient.ts
Normal file
21
web/src/services/apiClient.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
const DEFAULT_TIMEOUT = 10000
|
||||
|
||||
export interface ApiRequestOptions extends RequestInit {
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export async function apiGet<T>(url: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||
const { timeout = DEFAULT_TIMEOUT, ...init } = options
|
||||
const controller = new AbortController()
|
||||
const timer = window.setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...init, signal: controller.signal })
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败:${response.status}`)
|
||||
}
|
||||
return (await response.json()) as T
|
||||
} finally {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
30
web/src/services/gameApi.ts
Normal file
30
web/src/services/gameApi.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
HoverResponse,
|
||||
HoverTarget,
|
||||
InitialStateResponse,
|
||||
MapResponse
|
||||
} from '../types/game'
|
||||
import { apiGet } from './apiClient'
|
||||
|
||||
function buildHoverQuery(target: HoverTarget) {
|
||||
const query = new URLSearchParams({
|
||||
type: target.type,
|
||||
id: target.id
|
||||
})
|
||||
return `/api/hover?${query.toString()}`
|
||||
}
|
||||
|
||||
export const gameApi = {
|
||||
getInitialState() {
|
||||
return apiGet<InitialStateResponse>('/api/state')
|
||||
},
|
||||
|
||||
getHoverInfo(target: HoverTarget) {
|
||||
return apiGet<HoverResponse>(buildHoverQuery(target))
|
||||
},
|
||||
|
||||
getMap() {
|
||||
return apiGet<MapResponse>('/api/map')
|
||||
}
|
||||
}
|
||||
|
||||
121
web/src/services/gameGateway.ts
Normal file
121
web/src/services/gameGateway.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { TickPayload } from '../types/game'
|
||||
|
||||
export interface GatewayHandlers {
|
||||
onTick?: (payload: TickPayload) => void
|
||||
onStatusChange?: (connected: boolean) => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export interface GatewayOptions {
|
||||
reconnect?: boolean
|
||||
url?: string
|
||||
baseDelay?: number
|
||||
maxDelay?: number
|
||||
}
|
||||
|
||||
export function createGameGateway(
|
||||
handlers: GatewayHandlers,
|
||||
options: GatewayOptions = {}
|
||||
) {
|
||||
const reconnectEnabled = options.reconnect !== false
|
||||
const baseDelay = options.baseDelay ?? 1000
|
||||
const maxDelay = options.maxDelay ?? 8000
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: number | null = null
|
||||
let reconnectAttempts = 0
|
||||
let manuallyClosed = false
|
||||
|
||||
function getUrl() {
|
||||
if (options.url) return options.url
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${window.location.host}/ws`
|
||||
}
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (reconnectTimer != null) {
|
||||
window.clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSocket() {
|
||||
if (!ws) return
|
||||
ws.onopen = null
|
||||
ws.onmessage = null
|
||||
ws.onerror = null
|
||||
ws.onclose = null
|
||||
ws = null
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!reconnectEnabled || manuallyClosed) return
|
||||
clearReconnectTimer()
|
||||
const delay = Math.min(maxDelay, baseDelay * 2 ** reconnectAttempts)
|
||||
reconnectAttempts += 1
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function handleMessage(event: MessageEvent) {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data?.type === 'tick') {
|
||||
handlers.onTick?.(data as TickPayload)
|
||||
}
|
||||
} catch (error) {
|
||||
handlers.onError?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
reconnectAttempts = 0
|
||||
handlers.onStatusChange?.(true)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
handlers.onStatusChange?.(false)
|
||||
cleanupSocket()
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
function handleError(error: Event) {
|
||||
handlers.onError?.(error)
|
||||
}
|
||||
|
||||
function connect() {
|
||||
manuallyClosed = false
|
||||
clearReconnectTimer()
|
||||
cleanupSocket()
|
||||
|
||||
try {
|
||||
ws = new WebSocket(getUrl())
|
||||
ws.onopen = handleOpen
|
||||
ws.onmessage = handleMessage
|
||||
ws.onerror = handleError
|
||||
ws.onclose = handleClose
|
||||
} catch (error) {
|
||||
handlers.onError?.(error)
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
manuallyClosed = true
|
||||
clearReconnectTimer()
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close()
|
||||
} else {
|
||||
cleanupSocket()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
get readyState() {
|
||||
return ws?.readyState ?? WebSocket.CLOSED
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,278 +1,186 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type {
|
||||
Avatar,
|
||||
GameEvent,
|
||||
HoverLine,
|
||||
HoverTarget,
|
||||
TickPayload
|
||||
} from '../types/game'
|
||||
import { gameApi } from '../services/gameApi'
|
||||
import { createGameGateway } from '../services/gameGateway'
|
||||
import { normalizeGameEvent, normalizeHoverLines } from '../utils/normalizers'
|
||||
|
||||
export interface Avatar {
|
||||
id: string
|
||||
name?: string
|
||||
x: number
|
||||
y: number
|
||||
action?: string
|
||||
const MAX_EVENTS = 200
|
||||
|
||||
function cacheKey(target: HoverTarget) {
|
||||
return `${target.type}:${target.id}`
|
||||
}
|
||||
|
||||
export type HoverTarget = {
|
||||
type: 'avatar' | 'region'
|
||||
id: string
|
||||
name?: string
|
||||
function eventRank(event: GameEvent) {
|
||||
if (typeof event.monthStamp === 'number') {
|
||||
return event.monthStamp
|
||||
}
|
||||
if (typeof event.year === 'number' && typeof event.month === 'number') {
|
||||
return event.year * 12 + event.month
|
||||
}
|
||||
return -Infinity
|
||||
}
|
||||
|
||||
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: []
|
||||
}
|
||||
function sortEventsDescending(a: GameEvent, b: GameEvent) {
|
||||
const diff = eventRank(b) - eventRank(a)
|
||||
if (diff !== 0) return diff
|
||||
return b.id.localeCompare(a.id)
|
||||
}
|
||||
|
||||
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<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))
|
||||
const isConnected = ref(false)
|
||||
const year = ref(0)
|
||||
const month = ref(0)
|
||||
const avatars = ref<Record<string, Avatar>>({})
|
||||
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[]>()
|
||||
|
||||
function cacheKey(target: HoverTarget) {
|
||||
return `${target.type}:${target.id}`
|
||||
const avatarList = computed(() => Object.values(avatars.value))
|
||||
|
||||
const gateway = createGameGateway({
|
||||
onTick: handleTickPayload,
|
||||
onStatusChange: (connected) => {
|
||||
isConnected.value = connected
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('WS Error', error)
|
||||
}
|
||||
})
|
||||
|
||||
function handleTickPayload(payload: TickPayload) {
|
||||
year.value = payload.year
|
||||
month.value = payload.month
|
||||
appendEvents(payload.events)
|
||||
|
||||
if (Array.isArray(payload.avatars)) {
|
||||
mergeAvatars(payload.avatars)
|
||||
}
|
||||
}
|
||||
|
||||
function mergeAvatars(list: Avatar[]) {
|
||||
list.forEach((av) => {
|
||||
const existing = avatars.value[av.id]
|
||||
avatars.value[av.id] = existing ? { ...existing, ...av } : { ...av }
|
||||
})
|
||||
}
|
||||
|
||||
function appendEvents(rawEvents: unknown) {
|
||||
if (!Array.isArray(rawEvents) || !rawEvents.length) return
|
||||
const bucket = new Map(events.value.map((evt) => [evt.id, evt]))
|
||||
let changed = false
|
||||
|
||||
rawEvents.forEach((item) => {
|
||||
const evt = normalizeGameEvent(item)
|
||||
if (!evt) return
|
||||
bucket.set(evt.id, evt)
|
||||
changed = true
|
||||
})
|
||||
|
||||
if (!changed) return
|
||||
|
||||
const nextEvents = [...bucket.values()].sort(sortEventsDescending).slice(0, MAX_EVENTS)
|
||||
events.value = nextEvents
|
||||
}
|
||||
|
||||
async function fetchInitialState() {
|
||||
try {
|
||||
const data = await gameApi.getInitialState()
|
||||
if (data.status !== 'ok') return
|
||||
year.value = data.year
|
||||
month.value = data.month
|
||||
if (Array.isArray(data.avatars)) {
|
||||
const nextAvatars: Record<string, Avatar> = {}
|
||||
data.avatars.forEach((av) => {
|
||||
nextAvatars[av.id] = av
|
||||
})
|
||||
avatars.value = nextAvatars
|
||||
}
|
||||
appendEvents(data.events)
|
||||
} catch (error) {
|
||||
console.error('Fetch State Error', error)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
infoLoading.value = true
|
||||
infoError.value = null
|
||||
hoverInfo.value = []
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
// 开发环境下 Vite 代理会处理 /ws,生产环境直接连
|
||||
const host = window.location.host
|
||||
const ws = new WebSocket(`${protocol}//${host}/ws`)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WS Connected')
|
||||
isConnected.value = true
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.type === 'tick') {
|
||||
year.value = data.year
|
||||
month.value = data.month
|
||||
|
||||
// 更新事件日志
|
||||
appendEvents(data.events)
|
||||
|
||||
// 更新 Avatars(增量更新逻辑:这里后端暂发的是全量/部分列表,直接覆盖位置)
|
||||
if (data.avatars && Array.isArray(data.avatars)) {
|
||||
data.avatars.forEach((av: Avatar) => {
|
||||
if (avatars.value[av.id]) {
|
||||
// 存在则更新
|
||||
Object.assign(avatars.value[av.id], av)
|
||||
} else {
|
||||
// 不存在则创建(新角色)
|
||||
avatars.value[av.id] = av
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('WS Parse Error', e)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WS Closed')
|
||||
isConnected.value = false
|
||||
// 简单的断线重连
|
||||
setTimeout(connect, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载(通过 HTTP 获取一次全量状态,因为 WS 只发增量或视口内)
|
||||
async function fetchInitialState() {
|
||||
try {
|
||||
const res = await fetch('/api/state')
|
||||
const data = await res.json()
|
||||
if (data.status === 'ok') {
|
||||
year.value = data.year
|
||||
month.value = data.month
|
||||
if (data.avatars) {
|
||||
data.avatars.forEach((av: Avatar) => {
|
||||
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
|
||||
try {
|
||||
const data = await gameApi.getHoverInfo(target)
|
||||
const lines = normalizeHoverLines(data.lines)
|
||||
hoverCache.set(key, lines)
|
||||
if (selectedTarget.value && cacheKey(selectedTarget.value) === key) {
|
||||
hoverInfo.value = lines
|
||||
}
|
||||
} catch (error) {
|
||||
if (selectedTarget.value && cacheKey(selectedTarget.value) === key) {
|
||||
infoError.value = error instanceof Error ? error.message : String(error)
|
||||
hoverInfo.value = []
|
||||
}
|
||||
} finally {
|
||||
if (selectedTarget.value && cacheKey(selectedTarget.value) === key) {
|
||||
infoLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
year,
|
||||
month,
|
||||
avatars,
|
||||
avatarList,
|
||||
events, // 导出 events
|
||||
selectedTarget,
|
||||
hoverInfo,
|
||||
infoLoading,
|
||||
infoError,
|
||||
connect,
|
||||
fetchInitialState,
|
||||
openInfoPanel,
|
||||
closeInfoPanel
|
||||
}
|
||||
})
|
||||
function connect() {
|
||||
gateway.connect()
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
gateway.disconnect()
|
||||
}
|
||||
|
||||
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,
|
||||
month,
|
||||
avatars,
|
||||
avatarList,
|
||||
events,
|
||||
selectedTarget,
|
||||
hoverInfo,
|
||||
infoLoading,
|
||||
infoError,
|
||||
connect,
|
||||
disconnect,
|
||||
fetchInitialState,
|
||||
openInfoPanel,
|
||||
closeInfoPanel
|
||||
}
|
||||
})
|
||||
71
web/src/types/game.ts
Normal file
71
web/src/types/game.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export type HoverSegment = {
|
||||
text: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export type HoverLine = HoverSegment[]
|
||||
|
||||
export interface HoverTarget {
|
||||
type: 'avatar' | 'region'
|
||||
id: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface Avatar {
|
||||
id: string
|
||||
name?: string
|
||||
x: number
|
||||
y: number
|
||||
action?: string
|
||||
gender?: string
|
||||
pic_id?: number
|
||||
}
|
||||
|
||||
export interface GameEvent {
|
||||
id: string
|
||||
text: string
|
||||
content?: string
|
||||
year?: number
|
||||
month?: number
|
||||
monthStamp?: number
|
||||
relatedAvatarIds: string[]
|
||||
isMajor?: boolean
|
||||
isStory?: boolean
|
||||
}
|
||||
|
||||
export interface TickPayload {
|
||||
type: 'tick'
|
||||
year: number
|
||||
month: number
|
||||
avatars?: Avatar[]
|
||||
events?: unknown[]
|
||||
}
|
||||
|
||||
export interface InitialStateResponse {
|
||||
status: 'ok' | 'error'
|
||||
year: number
|
||||
month: number
|
||||
avatars?: Avatar[]
|
||||
events?: unknown[]
|
||||
}
|
||||
|
||||
export interface HoverResponse {
|
||||
lines: unknown
|
||||
}
|
||||
|
||||
export type MapMatrix = string[][]
|
||||
|
||||
export interface Region {
|
||||
id: string | number
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
type: string
|
||||
sect_name?: string
|
||||
}
|
||||
|
||||
export interface MapResponse {
|
||||
data: MapMatrix
|
||||
regions: Region[]
|
||||
}
|
||||
|
||||
78
web/src/utils/normalizers.ts
Normal file
78
web/src/utils/normalizers.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { GameEvent, HoverLine } from '../types/game'
|
||||
|
||||
let localEventIdCounter = 0
|
||||
|
||||
function nextLocalEventId(prefix: string) {
|
||||
localEventIdCounter += 1
|
||||
return `${prefix}-${Date.now()}-${localEventIdCounter}`
|
||||
}
|
||||
|
||||
export 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 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: []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user