diff --git a/web/src/App.vue b/web/src/App.vue index 2928243..4d85988 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,32 +1,18 @@ diff --git a/web/src/components/game/composables/useSharedTicker.ts b/web/src/components/game/composables/useSharedTicker.ts new file mode 100644 index 0000000..e422472 --- /dev/null +++ b/web/src/components/game/composables/useSharedTicker.ts @@ -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() + } + }) +} + diff --git a/web/src/components/game/composables/useTextures.ts b/web/src/components/game/composables/useTextures.ts index 590bc4e..b1acc2f 100644 --- a/web/src/components/game/composables/useTextures.ts +++ b/web/src/components/game/composables/useTextures.ts @@ -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') diff --git a/web/src/components/layout/StatusBar.vue b/web/src/components/layout/StatusBar.vue new file mode 100644 index 0000000..3b016a5 --- /dev/null +++ b/web/src/components/layout/StatusBar.vue @@ -0,0 +1,53 @@ + + + + + + 修仙模拟器 + + + + {{ store.year }}年 {{ store.month }}月 + + + 修士: {{ store.avatarList.length }} + + + + + + diff --git a/web/src/components/panels/EventPanel.vue b/web/src/components/panels/EventPanel.vue new file mode 100644 index 0000000..5530b71 --- /dev/null +++ b/web/src/components/panels/EventPanel.vue @@ -0,0 +1,100 @@ + + + + + + 事件记录 + + + {{ emptyEventMessage }} + + + {{ event.content || event.text }} + + + + + + + diff --git a/web/src/composables/useMapData.ts b/web/src/composables/useMapData.ts new file mode 100644 index 0000000..c967403 --- /dev/null +++ b/web/src/composables/useMapData.ts @@ -0,0 +1,35 @@ +import { ref } from 'vue' +import type { MapMatrix, Region } from '../types/game' +import { gameApi } from '../services/gameApi' + +const mapTiles = ref([]) +const regions = ref([]) +const isMapLoaded = ref(false) +let inFlight: Promise | 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 + } +} + diff --git a/web/src/services/apiClient.ts b/web/src/services/apiClient.ts new file mode 100644 index 0000000..a61f69c --- /dev/null +++ b/web/src/services/apiClient.ts @@ -0,0 +1,21 @@ +const DEFAULT_TIMEOUT = 10000 + +export interface ApiRequestOptions extends RequestInit { + timeout?: number +} + +export async function apiGet(url: string, options: ApiRequestOptions = {}): Promise { + 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) + } +} diff --git a/web/src/services/gameApi.ts b/web/src/services/gameApi.ts new file mode 100644 index 0000000..bfc0956 --- /dev/null +++ b/web/src/services/gameApi.ts @@ -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('/api/state') + }, + + getHoverInfo(target: HoverTarget) { + return apiGet(buildHoverQuery(target)) + }, + + getMap() { + return apiGet('/api/map') + } +} + diff --git a/web/src/services/gameGateway.ts b/web/src/services/gameGateway.ts new file mode 100644 index 0000000..a23c034 --- /dev/null +++ b/web/src/services/gameGateway.ts @@ -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 + } + } +} diff --git a/web/src/stores/game.ts b/web/src/stores/game.ts index cb8a452..e8de324 100644 --- a/web/src/stores/game.ts +++ b/web/src/stores/game.ts @@ -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 - 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 - 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>({}) - const events = ref([]) // 添加事件列表状态 - const selectedTarget = ref(null) - const hoverInfo = ref([]) - const infoLoading = ref(false) - const infoError = ref(null) - const hoverCache = new Map() - - // 计算属性:转换为数组以便遍历 - const avatarList = computed(() => Object.values(avatars.value)) + const isConnected = ref(false) + const year = ref(0) + const month = ref(0) + const avatars = ref>({}) + const events = ref([]) + const selectedTarget = ref(null) + const hoverInfo = ref([]) + const infoLoading = ref(false) + const infoError = ref(null) + const hoverCache = new Map() - 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 = {} + 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 + } +}) \ No newline at end of file diff --git a/web/src/types/game.ts b/web/src/types/game.ts new file mode 100644 index 0000000..4726172 --- /dev/null +++ b/web/src/types/game.ts @@ -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[] +} + diff --git a/web/src/utils/normalizers.ts b/web/src/utils/normalizers.ts new file mode 100644 index 0000000..7f5af23 --- /dev/null +++ b/web/src/utils/normalizers.ts @@ -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 + 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 + 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: [] + } +}