feat: add active domains serialization and UI integration

- Implemented `serialize_active_domains` function to gather and format active hidden domains from the game world.
- Updated `game_loop` to include active domains in the broadcast state.
- Enhanced `StatusBar` component to display active domains with a new `StatusWidget`, including dynamic labels and colors based on the number of active domains.
- Added localization support for hidden domain messages in both English and Chinese.
- Updated the world store to manage active domains state and synchronize with the backend.
This commit is contained in:
bridge
2026-02-01 12:20:27 +08:00
parent bc3ebc006c
commit d53d5885c0
8 changed files with 213 additions and 51 deletions

View File

@@ -172,6 +172,29 @@ class ConnectionManager:
manager = ConnectionManager()
def serialize_active_domains(world: World) -> List[dict]:
"""序列化当前开启的秘境列表"""
domains_data = []
if not world or not world.gathering_manager:
return []
for gathering in world.gathering_manager.gatherings:
# Check by class name to avoid circular imports
if gathering.__class__.__name__ == "HiddenDomain":
# Accessing _active_domains safely
active_domains = getattr(gathering, "_active_domains", [])
for d in active_domains:
domains_data.append({
"id": d.id,
"name": d.name,
"desc": d.desc,
# Use str() to trigger Realm.__str__ which returns translated text
"max_realm": str(d.max_realm),
"danger_prob": d.danger_prob,
"drop_prob": d.drop_prob
})
return domains_data
def serialize_events_for_client(events: List[Event]) -> List[dict]:
"""将事件转换为前端可用的结构。"""
serialized: List[dict] = []
@@ -553,7 +576,8 @@ async def game_loop():
"month": world.month_stamp.get_month().value,
"events": serialize_events_for_client(events),
"avatars": avatar_updates,
"phenomenon": serialize_phenomenon(world.current_phenomenon)
"phenomenon": serialize_phenomenon(world.current_phenomenon),
"active_domains": serialize_active_domains(world)
}
await manager.broadcast(state)
except Exception as e:

View File

@@ -4,6 +4,7 @@ import { useSocketStore } from '../../stores/socket'
import { ref, computed } from 'vue'
import { NPopover, NModal, NList, NListItem, NTag, NEmpty, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import StatusWidget from './StatusWidget.vue'
const { t } = useI18n()
const store = useWorldStore()
@@ -17,6 +18,17 @@ const phenomenonColor = computed(() => {
return getRarityColor(p.rarity);
})
const domainLabel = computed(() => {
const count = store.activeDomains.length;
return count > 0
? t('game.status_bar.hidden_domain.label_active', { count })
: t('game.status_bar.hidden_domain.label');
});
const domainColor = computed(() => {
return store.activeDomains.length > 0 ? '#fa8c16' : '#666'; // 有秘境时亮橙色,否则灰色
});
function getRarityColor(rarity: string) {
switch (rarity) {
case 'N': return '#ccc';
@@ -26,17 +38,8 @@ function getRarityColor(rarity: string) {
default: return '#ccc';
}
}
async function openPhenomenonSelector() {
showSelector.value = true;
await store.getPhenomenaList();
}
async function handleSelect(id: number, name: string) {
await store.changePhenomenon(id);
showSelector.value = false;
message.success(t('game.status_bar.change_success', { name }));
}
// ...
// ...
</script>
<template>
@@ -49,18 +52,14 @@ async function handleSelect(id: number, name: string) {
<span class="time">{{ store.year }}{{ t('common.year') }} {{ store.month }}{{ t('common.month') }}</span>
<!-- 天地灵机 -->
<div class="phenomenon" v-if="store.currentPhenomenon">
<span class="divider">|</span>
<n-popover trigger="hover" placement="bottom" style="max-width: 300px;">
<template #trigger>
<span
class="phenomenon-name"
:style="{ color: phenomenonColor }"
@click="openPhenomenonSelector"
>
[{{ store.currentPhenomenon.name }}]
</span>
</template>
<StatusWidget
v-if="store.currentPhenomenon"
:label="`[${store.currentPhenomenon.name}]`"
:color="phenomenonColor"
mode="single"
@trigger-click="openPhenomenonSelector"
>
<template #single>
<div class="phenomenon-card">
<div class="p-header" :style="{ color: phenomenonColor }">
<span class="p-title">{{ store.currentPhenomenon.name }}</span>
@@ -79,8 +78,18 @@ async function handleSelect(id: number, name: string) {
</div>
<div class="click-tip">{{ t('game.status_bar.click_to_change') }}</div>
</div>
</n-popover>
</div>
</template>
</StatusWidget>
<!-- 秘境 -->
<StatusWidget
:label="domainLabel"
:color="domainColor"
mode="list"
:title="t('game.status_bar.hidden_domain.title')"
:items="store.activeDomains"
:empty-text="t('game.status_bar.hidden_domain.empty')"
/>
</div>
<!-- 天象选择器 Modal -->
@@ -158,26 +167,7 @@ async function handleSelect(id: number, name: string) {
gap: 10px;
}
.phenomenon {
display: flex;
align-items: center;
gap: 10px;
}
.divider {
color: #444;
}
.phenomenon-name {
cursor: pointer;
font-weight: bold;
transition: opacity 0.2s;
}
.phenomenon-name:hover {
opacity: 0.8;
text-decoration: underline;
}
/* .phenomenon, .divider, .phenomenon-name REMOVED (moved to StatusWidget) */
.phenomenon-card {
padding: 4px 0;

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import { NPopover, NList, NListItem, NTag, NEmpty } from 'naive-ui'
import type { HiddenDomainInfo } from '../../types/core'
interface Props {
// 触发器显示
label: string
color?: string
// 弹窗内容
title?: string
items?: HiddenDomainInfo[] // 通用列表数据 (这里暂时专用于秘境,如果未来需要其他类型再泛型化)
emptyText?: string
// 模式: 'single' (天地灵机) 或 'list' (秘境)
mode?: 'single' | 'list'
}
const props = withDefaults(defineProps<Props>(), {
color: '#ccc',
items: () => [],
mode: 'list',
emptyText: '暂无数据'
})
// 发射点击事件(用于天地灵机的"更易天象"
const emit = defineEmits(['trigger-click'])
</script>
<template>
<div class="status-widget">
<span class="divider">|</span>
<n-popover trigger="click" placement="bottom" style="max-width: 350px;">
<template #trigger>
<span
class="widget-trigger"
:style="{ color: props.color }"
@click="emit('trigger-click')"
>
{{ props.label }}
</span>
</template>
<!-- 弹窗内容区 -->
<div class="widget-content">
<!-- 模式A: 单个详情 (复用天地灵机样式) -->
<slot name="single" v-if="mode === 'single'"></slot>
<!-- 模式B: 列表展示 (用于秘境) -->
<div v-else-if="mode === 'list'" class="list-container">
<div class="list-header" v-if="title">{{ title }}</div>
<n-list v-if="items.length > 0" hoverable clickable>
<n-list-item v-for="item in items" :key="item.id">
<div class="domain-item">
<div class="d-header">
<span class="d-name">{{ item.name }}</span>
<n-tag size="small" :bordered="false" type="warning" class="d-tag">
{{ item.max_realm }}
</n-tag>
</div>
<div class="d-desc">{{ item.desc }}</div>
<div class="d-stats">
<span>💀 {{ (item.danger_prob * 100).toFixed(0) }}%</span>
<span>🎁 {{ (item.drop_prob * 100).toFixed(0) }}%</span>
</div>
</div>
</n-list-item>
</n-list>
<n-empty v-else :description="emptyText" class="empty-state" />
</div>
</div>
</n-popover>
</div>
</template>
<style scoped>
.widget-trigger {
cursor: pointer;
font-weight: bold;
transition: opacity 0.2s;
}
.widget-trigger:hover { opacity: 0.8; }
.divider { color: #444; margin-right: 10px; }
.list-header {
font-weight: bold;
padding: 8px 12px;
border-bottom: 1px solid #333;
margin-bottom: 4px;
font-size: 14px;
}
.domain-item { padding: 4px 0; }
.d-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.d-name { font-weight: bold; color: #fadb14; font-size: 14px; }
.d-tag { font-size: 10px; height: 18px; line-height: 18px; }
.d-desc { font-size: 12px; color: #aaa; margin-bottom: 8px; line-height: 1.4; }
.d-stats { display: flex; gap: 12px; font-size: 12px; color: #888; }
.empty-state { padding: 20px; }
/* Naive UI List Override */
:deep(.n-list-item) {
padding: 8px 12px !important;
}
</style>

View File

@@ -230,7 +230,15 @@
"change_success": "Phenomenon changed to: {name}",
"click_to_change": "(Click to change phenomenon)",
"author_bilibili": "Bilibili",
"author_github": "Github"
"author_github": "Github",
"hidden_domain": {
"label": "[Hidden Domain]",
"label_active": "[Domain Opened: {count}]",
"title": "Opened Hidden Domains",
"empty": "No hidden domains currently open",
"danger": "Danger",
"drop": "Fortune"
}
},
"controls": {
"resume": "Resume Game",

View File

@@ -230,7 +230,15 @@
"change_success": "天象已更易为:{name}",
"click_to_change": "(点击可更易天象)",
"author_bilibili": "B站空间",
"author_github": "Github仓库"
"author_github": "Github仓库",
"hidden_domain": {
"label": "[秘境]",
"label_active": "[秘境开启: {count}]",
"title": "当前开启秘境",
"empty": "当前暂无秘境开启",
"danger": "凶险",
"drop": "机缘"
}
},
"controls": {
"resume": "继续游戏",

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia';
import { ref, shallowRef, computed } from 'vue';
import type { AvatarSummary, GameEvent, MapMatrix, RegionSummary, CelestialPhenomenon } from '../types/core';
import type { AvatarSummary, GameEvent, MapMatrix, RegionSummary, CelestialPhenomenon, HiddenDomainInfo } from '../types/core';
import type { TickPayloadDTO, InitialStateDTO } from '../types/api';
import type { FetchEventsParams } from '../types/api';
import { worldApi, eventApi } from '../api';
@@ -33,6 +33,9 @@ export const useWorldStore = defineStore('world', () => {
const currentPhenomenon = ref<CelestialPhenomenon | null>(null);
const phenomenaList = shallowRef<CelestialPhenomenon[]>([]);
// 秘境列表
const activeDomains = shallowRef<HiddenDomainInfo[]>([]);
// 请求计数器,用于处理 loadEvents 的竞态条件。
let eventsRequestId = 0;
// 请求计数器,用于处理 fetchState 的竞态条件。
@@ -110,6 +113,13 @@ export const useWorldStore = defineStore('world', () => {
if (payload.phenomenon !== undefined) {
currentPhenomenon.value = payload.phenomenon;
}
// 处理秘境同步
if (payload.active_domains !== undefined) {
activeDomains.value = payload.active_domains;
} else {
// 如果后端不传,说明本回合无秘境,清空
activeDomains.value = [];
}
}
function applyStateSnapshot(stateRes: InitialStateDTO) {
@@ -126,6 +136,7 @@ export const useWorldStore = defineStore('world', () => {
eventsFilter.value = {};
currentPhenomenon.value = stateRes.phenomenon || null;
isLoaded.value = true;
activeDomains.value = [];
}
// 提前加载地图数据(在 LLM 初始化期间可用)。
@@ -226,6 +237,7 @@ export const useWorldStore = defineStore('world', () => {
eventsFilter.value = {};
isLoaded.value = false;
currentPhenomenon.value = null;
activeDomains.value = [];
}
// --- 事件分页 ---
@@ -358,6 +370,7 @@ export const useWorldStore = defineStore('world', () => {
loadMoreEvents,
resetEvents,
getPhenomenaList,
changePhenomenon
changePhenomenon,
activeDomains
};
});

View File

@@ -3,7 +3,7 @@
* 这些类型严格对应后端接口返回的 JSON 结构。
*/
import type { MapMatrix, CelestialPhenomenon } from './core';
import type { MapMatrix, CelestialPhenomenon, HiddenDomainInfo } from './core';
// --- 通用响应 ---
@@ -39,6 +39,7 @@ export interface TickPayloadDTO {
avatars?: Array<Partial<InitialStateDTO['avatars'] extends (infer U)[] ? U : never>>;
events?: unknown[];
phenomenon?: CelestialPhenomenon | null;
active_domains?: HiddenDomainInfo[];
}
export interface MapResponseDTO {

View File

@@ -183,6 +183,18 @@ export interface CelestialPhenomenon {
effect_desc?: string;
}
// web/src/types/core.ts
// 新增秘境信息接口
export interface HiddenDomainInfo {
id: string;
name: string;
desc: string;
max_realm: string; // 限制境界
danger_prob: number; // 凶险度 (0.0 - 1.0)
drop_prob: number; // 机缘度 (0.0 - 1.0)
}
// --- 事件 (Events) ---
export interface GameEvent {