refactor web

This commit is contained in:
bridge
2025-11-21 01:38:41 +08:00
parent 5a51b6638d
commit 41d2103ffc
14 changed files with 863 additions and 559 deletions

View File

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

View File

@@ -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() {

View File

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

View 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()
}
})
}

View File

@@ -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')

View 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>

View 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>

View 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
}
}

View 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)
}
}

View 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')
}
}

View 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
}
}
}

View File

@@ -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
View 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[]
}

View 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: []
}
}