refactor frontend

This commit is contained in:
bridge
2025-12-04 21:33:14 +08:00
parent 880e83c53e
commit ef0ff24783
11 changed files with 253 additions and 219 deletions

View File

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

View File

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

View File

@@ -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<Container>()
const { textures, isLoaded, loadSectTexture, loadCityTexture } = useTextures()
const worldStore = useWorldStore()
const regionStyleCache = new Map<string, Record<string, unknown>>()
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)"

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { onClickOutside } from '@vueuse/core';
import { useUiStore } from '../../../../stores/ui';
// Sub-components
@@ -11,15 +12,19 @@ const uiStore = useUiStore();
const panelRef = ref<HTMLElement | null>(null);
let lastOpenAt = 0;
// --- Computed ---
// --- Component Resolution ---
const currentComponent = computed(() => {
if (uiStore.selectedTarget?.type === 'avatar') return AvatarDetailView;
if (uiStore.selectedTarget?.type === 'region') return RegionDetailView;
if (uiStore.selectedTarget?.type === 'sect') return SectDetailView;
return null;
switch (uiStore.selectedTarget?.type) {
case 'avatar': return AvatarDetailView;
case 'region': return RegionDetailView;
case 'sect': return SectDetailView;
default: return null;
}
});
// --- Title & Subtitle Logic ---
const title = computed(() => {
if (uiStore.detailData) {
return uiStore.detailData.name;
@@ -53,35 +58,20 @@ function close() {
showNicknameReason.value = false;
}
// Click outside to close
function handleDocumentPointerDown(event: PointerEvent) {
if (!uiStore.selectedTarget || !panelRef.value) return;
const target = event.target as Node | null;
// 检查点击是否在面板内部,或者是在二级弹窗内部(如果二级弹窗是挂在 body 上的 portal 这里的逻辑会有问题,但目前 SecondaryPopup 是 AvatarDetail 的子组件,所以应该被 panelRef 包含)
// 但是要注意SecondaryPopup 使用了 absolute 定位到 left: -100%,仍然是 panelRef 的子节点
if (target && panelRef.value.contains(target)) return;
// 使用 vueuse 的 onClickOutside 替代原生监听
onClickOutside(panelRef, () => {
// Prevent closing immediately after opening if click propagated
const now = performance.now();
if (now - lastOpenAt < 100) return;
close();
});
// Prevent closing immediately after opening if click propagated
const now = performance.now();
if (now - lastOpenAt < 100) return;
close();
}
// Record open time
// Record open time to prevent immediate close
watch(() => uiStore.selectedTarget, (val) => {
if (val) lastOpenAt = performance.now();
showNicknameReason.value = false;
});
onMounted(() => {
document.addEventListener('pointerdown', handleDocumentPointerDown);
});
onUnmounted(() => {
document.removeEventListener('pointerdown', handleDocumentPointerDown);
});
</script>
<template>
@@ -126,17 +116,6 @@ onUnmounted(() => {
:data="uiStore.detailData"
/>
</div>
<!-- Fallback / Legacy Hover Info (If detail failed or not applicable) -->
<div v-else class="legacy-list">
<div v-for="(line, i) in uiStore.hoverInfo" :key="i" class="line">
<span
v-for="(seg, j) in line"
:key="j"
:style="{ color: seg.color }"
>{{ seg.text }}</span>
</div>
</div>
</div>
</div>
</template>
@@ -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;
}
</style>

View File

@@ -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<string | null>(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
};
});

View File

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

View File

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

View File

@@ -171,6 +171,9 @@ export interface GameEvent {
relatedAvatarIds: string[];
isMajor: boolean;
isStory: boolean;
// 运行时辅助字段
_seq?: number;
}
// --- 悬浮提示 (Hover) ---

View File

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

View File

@@ -0,0 +1,53 @@
import type { TextStyleOptions } from 'pixi.js';
// 地图渲染相关的样式常量
export const REGION_STYLES: Record<string, Partial<TextStyleOptions>> = {
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<TextStyleOptions> {
return REGION_STYLES[type] || REGION_STYLES.default;
}

View File

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