diff --git a/web/src/App.vue b/web/src/App.vue index 056db62..cb76b29 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -3,7 +3,7 @@ import { onMounted, onUnmounted, ref, watch } from 'vue' import { NConfigProvider, darkTheme, NMessageProvider } from 'naive-ui' import { useWorldStore } from './stores/world' import { useUiStore } from './stores/ui' -import { useGameSocket } from './composables/useGameSocket' +import { useSocketStore } from './stores/socket' import { gameApi } from './api/game' import GameCanvas from './components/game/GameCanvas.vue' @@ -12,20 +12,24 @@ import StatusBar from './components/layout/StatusBar.vue' import EventPanel from './components/panels/EventPanel.vue' import SystemMenu from './components/SystemMenu.vue' -// Composables -useGameSocket() - +// Stores const worldStore = useWorldStore() const uiStore = useUiStore() +const socketStore = useSocketStore() + const showMenu = ref(false) const isManualPaused = ref(false) onMounted(async () => { + // 初始化 Socket 连接 + socketStore.init() + // 初始化世界状态 await worldStore.initialize() window.addEventListener('keydown', handleKeydown) }) onUnmounted(() => { + socketStore.disconnect() window.removeEventListener('keydown', handleKeydown) }) diff --git a/web/src/api/http.ts b/web/src/api/http.ts index b584bca..530f2d1 100644 --- a/web/src/api/http.ts +++ b/web/src/api/http.ts @@ -3,7 +3,8 @@ * 封装基础的 fetch 请求 */ -const API_BASE = ''; // 相对路径 +// 使用环境变量作为 API 基础路径,如果没有配置则默认为空(相对路径) +const API_BASE = import.meta.env.VITE_API_TARGET || ''; export class ApiError extends Error { public status: number; diff --git a/web/src/components/game/MapLayer.vue b/web/src/components/game/MapLayer.vue index 8595baf..15678bb 100644 --- a/web/src/components/game/MapLayer.vue +++ b/web/src/components/game/MapLayer.vue @@ -3,13 +3,13 @@ import { onMounted, ref, watch } from 'vue' import { Container, Sprite } from 'pixi.js' import { useTextures } from './composables/useTextures' import { useWorldStore } from '../../stores/world' +import { getRegionTextStyle } from '../../utils/mapStyles' import type { RegionSummary } from '../../types/core' const TILE_SIZE = 64 const mapContainer = ref() const { textures, isLoaded, loadSectTexture, loadCityTexture } = useTextures() const worldStore = useWorldStore() -const regionStyleCache = new Map>() const emit = defineEmits<{ (e: 'mapLoaded', payload: { width: number, height: number }): void @@ -49,7 +49,8 @@ async function renderMap() { let tex = textures.value[type] if (type === 'SECT') { - tex = resolveSectTexture(x, y) ?? textures.value['CITY'] + // Legacy placeholder + tex = textures.value['CITY'] } if (!tex) { @@ -96,9 +97,9 @@ async function preloadRegionTextures() { // Cities const cityNames = Array.from( new Set( - regions - .filter(region => region.type === 'city') - .map(region => region.name) + regions + .filter(region => region.type === 'city') + .map(region => region.name) ) ) @@ -108,12 +109,6 @@ async function preloadRegionTextures() { ]) } -// Sect tile rendering is now handled in renderLargeRegions via slices -function resolveSectTexture(_x: number, _y: number) { - // Legacy function - sect rendering is now done via slices in renderLargeRegions - return null -} - function renderLargeRegions() { const regions = Array.from(worldStore.regions.values()); for (const region of regions) { @@ -159,28 +154,6 @@ function renderLargeRegions() { } } -function getRegionStyle(type: string) { - if (regionStyleCache.has(type)) { - return regionStyleCache.get(type) - } - const style = { - fontFamily: '"Microsoft YaHei", sans-serif', - fontSize: type === 'sect' ? 60 : 72, - fill: type === 'sect' ? '#ffcc00' : (type === 'city' ? '#ccffcc' : '#ffffff'), - stroke: { color: '#000000', width: 5, join: 'round' }, - align: 'center', - dropShadow: { - color: '#000000', - blur: 3, - angle: Math.PI / 6, - distance: 3, - alpha: 0.8 - } - } - regionStyleCache.set(type, style) - return style -} - function handleRegionSelect(region: RegionSummary) { emit('regionSelected', { type: 'region', @@ -205,7 +178,7 @@ function handleRegionSelect(region: RegionSummary) { :x="r.x * TILE_SIZE + TILE_SIZE / 2" :y="r.y * TILE_SIZE + TILE_SIZE * 1.5" :anchor="0.5" - :style="getRegionStyle(r.type)" + :style="getRegionTextStyle(r.type)" event-mode="static" cursor="pointer" @pointertap="handleRegionSelect(r)" diff --git a/web/src/components/game/panels/info/InfoPanelContainer.vue b/web/src/components/game/panels/info/InfoPanelContainer.vue index 22e57cb..9df3f4f 100644 --- a/web/src/components/game/panels/info/InfoPanelContainer.vue +++ b/web/src/components/game/panels/info/InfoPanelContainer.vue @@ -1,5 +1,6 @@ @@ -148,10 +127,10 @@ onUnmounted(() => { right: 20px; width: 320px; max-height: calc(100vh - 80px); - background: rgba(24, 24, 24, 0.96); - border: 1px solid #333; + background: var(--panel-bg); + border: 1px solid var(--color-border); border-radius: 8px; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.45); + box-shadow: var(--panel-shadow); color: #eee; display: flex; flex-direction: column; @@ -164,8 +143,8 @@ onUnmounted(() => { align-items: center; justify-content: space-between; padding: 12px 16px; - background: rgba(38, 38, 38, 0.95); - border-bottom: 1px solid #333; + background: var(--panel-header-bg); + border-bottom: 1px solid var(--color-border); border-top-left-radius: 8px; border-top-right-radius: 8px; flex-shrink: 0; @@ -180,7 +159,7 @@ onUnmounted(() => { .sub-title { font-size: 12px; - color: #888; + color: var(--color-text-secondary); } .sub-title.clickable { @@ -206,14 +185,13 @@ onUnmounted(() => { width: max-content; max-width: 260px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); - pointer-events: none; /* Prevents blocking if it overlaps something vital, though typically we want to select text */ pointer-events: auto; } .popover-arrow { position: absolute; top: -5px; - left: 20px; /* Approximate alignment under the nickname */ + left: 20px; width: 8px; height: 8px; background: rgba(50, 50, 50, 0.98); @@ -233,11 +211,6 @@ onUnmounted(() => { font-weight: bold; } -.sub-title { - font-size: 12px; - color: #888; -} - .close-btn { background: transparent; border: none; @@ -254,7 +227,7 @@ onUnmounted(() => { .panel-body { flex: 1; - overflow: hidden; /* Let children scroll */ + overflow: hidden; padding: 16px; display: flex; flex-direction: column; @@ -270,7 +243,7 @@ onUnmounted(() => { } .state-msg { - color: #888; + color: var(--color-text-secondary); font-size: 13px; text-align: center; padding: 20px 0; @@ -279,17 +252,4 @@ onUnmounted(() => { .state-msg.error { color: #ff7875; } - - -/* Legacy */ -.legacy-list { - font-size: 13px; - line-height: 1.5; -} - -.line { - margin-bottom: 4px; - white-space: pre-wrap; -} - diff --git a/web/src/composables/useGameSocket.ts b/web/src/stores/socket.ts similarity index 55% rename from web/src/composables/useGameSocket.ts rename to web/src/stores/socket.ts index d229df1..c4bfc7a 100644 --- a/web/src/composables/useGameSocket.ts +++ b/web/src/stores/socket.ts @@ -1,19 +1,30 @@ -import { onMounted, onUnmounted } from 'vue'; +import { defineStore } from 'pinia'; +import { ref } from 'vue'; import { gameSocket } from '../api/socket'; -import { useWorldStore } from '../stores/world'; -import { useUiStore } from '../stores/ui'; +import { useWorldStore } from './world'; +import { useUiStore } from './ui'; import type { TickPayloadDTO } from '../types/api'; -export function useGameSocket() { - const worldStore = useWorldStore(); - const uiStore = useUiStore(); - +export const useSocketStore = defineStore('socket', () => { + const isConnected = ref(false); + const lastError = ref(null); + let cleanupMessage: (() => void) | undefined; let cleanupStatus: (() => void) | undefined; - onMounted(() => { - // Connect socket - gameSocket.connect(); + function init() { + if (cleanupStatus) return; // Already initialized + + const worldStore = useWorldStore(); + const uiStore = useUiStore(); + + // Listen for status + cleanupStatus = gameSocket.onStatusChange((connected) => { + isConnected.value = connected; + if (connected) { + lastError.value = null; + } + }); // Listen for ticks cleanupMessage = gameSocket.on((data: any) => { @@ -27,25 +38,30 @@ export function useGameSocket() { uiStore.clearHoverCache(); // Refresh Detail if open (Silent update) - // 注意:这里可以选择是否每次 tick 都刷新详情,或者让用户手动刷新 - // 为了实时性,通常会尝试静默刷新 if (uiStore.selectedTarget) { uiStore.refreshDetail(); } } }); - // Listen for status - cleanupStatus = gameSocket.onStatusChange((connected) => { - console.log('Socket status:', connected ? 'Connected' : 'Disconnected'); - // Could update a connection status in a store if needed - }); - }); + // Connect socket + gameSocket.connect(); + } - onUnmounted(() => { + function disconnect() { if (cleanupMessage) cleanupMessage(); if (cleanupStatus) cleanupStatus(); + cleanupMessage = undefined; + cleanupStatus = undefined; gameSocket.disconnect(); - }); -} + isConnected.value = false; + } + + return { + isConnected, + lastError, + init, + disconnect + }; +}); diff --git a/web/src/stores/world.ts b/web/src/stores/world.ts index de27128..66645a4 100644 --- a/web/src/stores/world.ts +++ b/web/src/stores/world.ts @@ -3,6 +3,7 @@ import { ref, shallowRef, computed } from 'vue'; import type { AvatarSummary, GameEvent, MapMatrix, RegionSummary, CelestialPhenomenon } from '../types/core'; import type { TickPayloadDTO, InitialStateDTO } from '../types/api'; import { gameApi } from '../api/game'; +import { processNewEvents, mergeAndSortEvents } from '../utils/eventHelper'; export const useWorldStore = defineStore('world', () => { // --- State --- @@ -65,50 +66,8 @@ export const useWorldStore = defineStore('world', () => { function addEvents(rawEvents: any[]) { if (!rawEvents || rawEvents.length === 0) return; - // 转换 DTO -> Domain - // 增加临时索引 _seq 记录原始逻辑顺序,用于同时间戳事件的排序 - const newEvents: GameEvent[] = rawEvents.map((e, index) => ({ - id: e.id, - text: e.text, - content: e.content, - year: e.year ?? year.value, - month: e.month ?? month.value, - timestamp: (e.year ?? year.value) * 12 + (e.month ?? month.value), - relatedAvatarIds: e.related_avatar_ids || [], - isMajor: e.is_major, - isStory: e.is_story, - _seq: index - } as GameEvent & { _seq: number })); - - // 排序并保留最新的 N 条 - const MAX_EVENTS = 300; - const combined = [...newEvents, ...events.value]; - - combined.sort((a, b) => { - // 1. 先按时间戳升序(最旧的月在上面) - const ta = a.timestamp; - const tb = b.timestamp; - if (tb !== ta) { - return ta - tb; - } - - // 2. 时间相同时,按原始逻辑顺序升序(先发生的在上面) - // 旧事件通常没有 _seq (undefined),视为最旧 (-1) - const seqA = (a as any)._seq ?? -1; - const seqB = (b as any)._seq ?? -1; - - // 如果都是旧事件,保持相对顺序 (Stable) - if (seqA === -1 && seqB === -1) return 0; - - return seqA - seqB; - }); - - // 保留最新的 N 条 (因为是升序,最新的在最后,所以取最后 N 条) - if (combined.length > MAX_EVENTS) { - events.value = combined.slice(-MAX_EVENTS); - } else { - events.value = combined; - } + const newEvents = processNewEvents(rawEvents, year.value, month.value); + events.value = mergeAndSortEvents(events.value, newEvents); } function handleTick(payload: TickPayloadDTO) { @@ -116,19 +75,6 @@ export const useWorldStore = defineStore('world', () => { setTime(payload.year, payload.month); - // 检查并处理死亡事件,移除已死亡的角色 - // if (payload.events && Array.isArray(payload.events)) { - // const deathEvents = (payload.events as any[]).filter((e: any) => { - // const c = e.content || ''; - // return c.includes('身亡') || c.includes('老死'); - // }); - // - // if (deathEvents.length > 0) { - // // 旧逻辑:主动删除死人。现在改为软删除,后端会在 avatars 更新中推送 is_dead 状态, - // // 所以这里不再需要主动操作。前端展示层根据 is_dead 决定是否隐藏。 - // } - // } - if (payload.avatars) updateAvatars(payload.avatars); if (payload.events) addEvents(payload.events); if (payload.phenomenon !== undefined) { diff --git a/web/src/style.css b/web/src/style.css index 7844037..11cd4b1 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -3,8 +3,20 @@ -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.4; font-weight: 400; - color: #f6f6f6; - background-color: #050608; + + /* Colors */ + --color-bg-dark: #050608; + --color-text-main: #f6f6f6; + --color-text-secondary: #888; + --color-border: #333; + + /* Panels */ + --panel-bg: rgba(24, 24, 24, 0.96); + --panel-header-bg: rgba(38, 38, 38, 0.95); + --panel-shadow: 0 10px 25px rgba(0, 0, 0, 0.45); + + color: var(--color-text-main); + background-color: var(--color-bg-dark); font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; diff --git a/web/src/types/core.ts b/web/src/types/core.ts index 4ed3c16..6699f48 100644 --- a/web/src/types/core.ts +++ b/web/src/types/core.ts @@ -171,6 +171,9 @@ export interface GameEvent { relatedAvatarIds: string[]; isMajor: boolean; isStory: boolean; + + // 运行时辅助字段 + _seq?: number; } // --- 悬浮提示 (Hover) --- diff --git a/web/src/utils/eventHelper.ts b/web/src/utils/eventHelper.ts new file mode 100644 index 0000000..3150a5e --- /dev/null +++ b/web/src/utils/eventHelper.ts @@ -0,0 +1,60 @@ +import type { GameEvent } from '../types/core'; + +export const MAX_EVENTS = 300; + +/** + * 处理新事件列表,转换为 Domain 对象并分配序列号 + */ +export function processNewEvents(rawEvents: any[], currentYear: number, currentMonth: number): GameEvent[] { + if (!rawEvents || rawEvents.length === 0) return []; + + return rawEvents.map((e, index) => ({ + id: e.id, + text: e.text, + content: e.content, + year: e.year ?? currentYear, + month: e.month ?? currentMonth, + timestamp: (e.year ?? currentYear) * 12 + (e.month ?? currentMonth), + relatedAvatarIds: e.related_avatar_ids || [], + isMajor: e.is_major, + isStory: e.is_story, + _seq: index + })); +} + +/** + * 合并并排序事件列表 + * 1. 按时间戳升序 + * 2. 时间戳相同时,按序列号升序 + * 3. 保留最新的 MAX_EVENTS 条 + */ +export function mergeAndSortEvents(existingEvents: GameEvent[], newEvents: GameEvent[]): GameEvent[] { + const combined = [...newEvents, ...existingEvents]; + + combined.sort((a, b) => { + // 1. 先按时间戳升序(最旧的月在上面) + const ta = a.timestamp; + const tb = b.timestamp; + if (tb !== ta) { + return ta - tb; + } + + // 2. 时间相同时,按原始逻辑顺序升序(先发生的在上面) + // 旧事件通常没有 _seq (undefined),视为最旧 (-1) + const seqA = a._seq ?? -1; + const seqB = b._seq ?? -1; + + // 如果都是旧事件,保持相对顺序 (Stable) + if (seqA === -1 && seqB === -1) return 0; + + return seqA - seqB; + }); + + // 保留最新的 N 条 (因为是升序,最新的在最后,所以取最后 N 条) + if (combined.length > MAX_EVENTS) { + return combined.slice(-MAX_EVENTS); + } + + return combined; +} + diff --git a/web/src/utils/mapStyles.ts b/web/src/utils/mapStyles.ts new file mode 100644 index 0000000..2cb0bbd --- /dev/null +++ b/web/src/utils/mapStyles.ts @@ -0,0 +1,53 @@ +import type { TextStyleOptions } from 'pixi.js'; + +// 地图渲染相关的样式常量 + +export const REGION_STYLES: Record> = { + sect: { + fontFamily: '"Microsoft YaHei", sans-serif', + fontSize: 60, + fill: '#ffcc00', + stroke: { color: '#000000', width: 5, join: 'round' }, + align: 'center', + dropShadow: { + color: '#000000', + blur: 3, + angle: Math.PI / 6, + distance: 3, + alpha: 0.8 + } + }, + city: { + fontFamily: '"Microsoft YaHei", sans-serif', + fontSize: 72, + fill: '#ccffcc', + stroke: { color: '#000000', width: 5, join: 'round' }, + align: 'center', + dropShadow: { + color: '#000000', + blur: 3, + angle: Math.PI / 6, + distance: 3, + alpha: 0.8 + } + }, + default: { + fontFamily: '"Microsoft YaHei", sans-serif', + fontSize: 72, + fill: '#ffffff', + stroke: { color: '#000000', width: 5, join: 'round' }, + align: 'center', + dropShadow: { + color: '#000000', + blur: 3, + angle: Math.PI / 6, + distance: 3, + alpha: 0.8 + } + } +}; + +export function getRegionTextStyle(type: string): Partial { + return REGION_STYLES[type] || REGION_STYLES.default; +} + diff --git a/web/vite.config.ts b/web/vite.config.ts index 0316cea..21ae5ae 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,39 +1,45 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' import { compilerOptions } from 'vue3-pixi' import path from 'path' // https://vite.dev/config/ -export default defineConfig({ - plugins: [ - vue({ - template: { - compilerOptions, - }, - }), - ], - resolve: { - alias: { - '@': path.resolve(__dirname, './src') - } - }, - build: { - assetsDir: 'web_static', // 避免与游戏原本的 /assets 目录冲突 - }, - server: { - proxy: { - '/api': { - target: 'http://localhost:8002', - changeOrigin: true, - }, - '/ws': { - target: 'ws://localhost:8002', - ws: true, - changeOrigin: true, - }, - '/assets': { - target: 'http://localhost:8002', - changeOrigin: true, +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd()) + const API_TARGET = env.VITE_API_TARGET || 'http://localhost:8002' + const WS_TARGET = env.VITE_WS_TARGET || 'ws://localhost:8002' + + return { + plugins: [ + vue({ + template: { + compilerOptions, + }, + }), + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + }, + build: { + assetsDir: 'web_static', // 避免与游戏原本的 /assets 目录冲突 + }, + server: { + proxy: { + '/api': { + target: API_TARGET, + changeOrigin: true, + }, + '/ws': { + target: WS_TARGET, + ws: true, + changeOrigin: true, + }, + '/assets': { + target: API_TARGET, + changeOrigin: true, + } } } }