update web, add clickable parts

This commit is contained in:
bridge
2025-11-22 15:07:09 +08:00
parent 8ae8b50e70
commit 99e4871a5d
18 changed files with 1085 additions and 13 deletions

View File

@@ -44,6 +44,16 @@ class Animal:
return " - ".join(info_parts)
def get_structured_info(self) -> dict:
items_info = [item.get_structured_info() for item in self.items]
return {
"name": self.name,
"desc": self.desc,
"grade": self.realm.value,
"drops": items_info,
"type": "animal"
}
def _load_animals() -> tuple[dict[int, Animal], dict[str, Animal]]:
"""从配表加载animal数据"""
animals_by_id: dict[int, Animal] = {}

View File

@@ -41,6 +41,16 @@ class Auxiliary:
r, g, b = self.grade.color_rgb
return f"<color:{r},{g},{b}>{self.get_info()}</color>"
def get_structured_info(self) -> dict:
from src.utils.effect_desc import format_effects_to_text
return {
"name": self.name,
"desc": self.desc,
"grade": self.grade.value,
"color": self.grade.color_rgb,
"effect_desc": format_effects_to_text(self.effects),
}
def _load_auxiliaries() -> tuple[Dict[int, Auxiliary], Dict[str, Auxiliary], Dict[int, Auxiliary]]:
"""从配表加载 auxiliary 数据。

View File

@@ -278,6 +278,119 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin):
info_dict["短期目标"] = self.short_term_objective
return info_dict
def get_structured_info(self) -> dict:
"""
获取结构化的角色信息,用于前端展示和交互。
"""
# 基础信息
info = {
"id": self.id,
"name": self.name,
"gender": str(self.gender),
"age": self.age.age,
"lifespan": self.age.max_lifespan,
"realm": self.cultivation_progress.realm.value,
"level": self.cultivation_progress.level,
"hp": {"cur": self.hp.cur, "max": self.hp.max},
"mp": {"cur": self.mp.cur, "max": self.mp.max},
"alignment": str(self.alignment) if self.alignment else "未知",
"magic_stone": self.magic_stone.value,
"thinking": self.thinking,
"short_term_objective": self.short_term_objective,
"long_term_objective": self.long_term_objective.content if self.long_term_objective else "",
"nickname": self.nickname,
}
# 复杂对象结构化
# 1. 特质 (Personas)
info["personas"] = [p.get_structured_info() for p in self.personas]
# 2. 功法 (Technique)
if self.technique:
info["technique"] = self.technique.get_structured_info()
else:
info["technique"] = None
# 3. 宗门 (Sect)
if self.sect:
sect_info = self.sect.get_structured_info()
# 补充职位信息
if self.sect_rank:
from src.classes.sect_ranks import get_rank_display_name
sect_info["rank"] = get_rank_display_name(self.sect_rank, self.sect)
else:
sect_info["rank"] = "弟子"
info["sect"] = sect_info
else:
info["sect"] = None
# 补充:阵营详情
from src.classes.alignment import alignment_infos, alignment_strs
# 保持 alignment 字段为 string (value) 兼容现有逻辑
info["alignment"] = str(self.alignment) if self.alignment else "未知"
if self.alignment:
cn_name = alignment_strs.get(self.alignment, self.alignment.value)
desc = alignment_infos.get(self.alignment, "")
info["alignment_detail"] = {
"name": cn_name,
"desc": desc,
}
# 4. 装备 (Weapon & Auxiliary)
if self.weapon:
w_info = self.weapon.get_structured_info()
w_info["proficiency"] = f"{self.weapon_proficiency:.1f}%"
info["weapon"] = w_info
else:
info["weapon"] = None
if self.auxiliary:
info["auxiliary"] = self.auxiliary.get_structured_info()
else:
info["auxiliary"] = None
# 5. 物品 (Items)
items_list = []
for item, count in self.items.items():
i_info = item.get_structured_info()
i_info["count"] = count
items_list.append(i_info)
info["items"] = items_list
# 6. 关系 (Relations)
relations_list = []
for other, relation in self.relations.items():
relations_list.append({
"target_id": other.id,
"name": other.name,
"relation": str(relation),
# 可以加更多 info比如境界用于列表中展示
"realm": other.cultivation_progress.realm.value,
"sect": other.sect.name if other.sect else "散修"
})
info["relations"] = relations_list
# 7. 外貌
info["appearance"] = self.appearance.get_info()
# 8. 灵根
from src.classes.root import format_root_cn
from src.utils.effect_desc import format_effects_to_text
root_str = format_root_cn(self.root)
info["root"] = root_str
info["root_detail"] = {
"name": root_str,
"desc": f"包含元素:{''.join(str(e) for e in self.root.elements)}",
"effect_desc": format_effects_to_text(self.root.effects)
}
# 9. 灵兽
if self.spirit_animal:
info["spirit_animal"] = self.spirit_animal.get_structured_info()
return info
def __str__(self) -> str:
return str(self.get_info(detailed=False))

View File

@@ -25,6 +25,14 @@ class Item:
def get_detailed_info(self) -> str:
return f"{self.name} - {self.desc}{self.realm.value}"
def get_structured_info(self) -> dict:
return {
"name": self.name,
"desc": self.desc,
"grade": self.realm.value,
"effect_desc": "" # 物品暂时没有效果字段
}
def _load_items() -> tuple[dict[int, Item], dict[str, Item]]:
"""从配表加载item数据"""
items_by_id: dict[int, Item] = {}

View File

@@ -41,6 +41,16 @@ class Persona:
r, g, b = self.rarity.color_rgb
return f"<color:{r},{g},{b}>{self.name}</color>"
def get_structured_info(self) -> dict:
from src.utils.effect_desc import format_effects_to_text
return {
"name": self.name,
"desc": self.desc,
"rarity": self.rarity.level.value,
"color": self.rarity.color_rgb,
"effect_desc": format_effects_to_text(self.effects),
}
def _load_personas() -> tuple[dict[int, Persona], dict[str, Persona]]:
"""从配表加载persona数据"""
personas_by_id: dict[int, Persona] = {}

View File

@@ -44,6 +44,16 @@ class Plant:
return " - ".join(info_parts)
def get_structured_info(self) -> dict:
items_info = [item.get_structured_info() for item in self.items]
return {
"name": self.name,
"desc": self.desc,
"grade": self.realm.value,
"drops": items_info,
"type": "plant"
}
def _load_plants() -> tuple[dict[int, Plant], dict[str, Plant]]:
"""从配表加载plant数据"""
plants_by_id: dict[int, Plant] = {}

View File

@@ -177,6 +177,15 @@ class Region(ABC):
# 基类暂无更多结构化信息,详细版返回名称+描述
return f"{self.name} - {self.desc}"
def get_structured_info(self) -> dict:
return {
"id": self.id,
"name": self.name,
"desc": self.desc,
"type": self.get_region_type(),
"type_name": "区域"
}
class Shape(Enum):
"""
@@ -301,6 +310,13 @@ class NormalRegion(Region):
# 如果该区域有植物,则可以采集
return len(self.plants) > 0
def get_structured_info(self) -> dict:
info = super().get_structured_info()
info["type_name"] = "普通区域"
info["animals"] = [a.get_structured_info() for a in self.animals]
info["plants"] = [p.get_structured_info() for p in self.plants]
return info
@dataclass
class CultivateRegion(Region):
@@ -338,6 +354,15 @@ class CultivateRegion(Region):
lines.append(f"主要灵气: {self.essence_type} {stars}")
return lines
def get_structured_info(self) -> dict:
info = super().get_structured_info()
info["type_name"] = "修炼区域"
info["essence"] = {
"type": self.essence_type.value,
"density": self.essence_density
}
return info
@dataclass
class CityRegion(Region):
@@ -361,6 +386,11 @@ class CityRegion(Region):
def get_detailed_info(self) -> str:
return f"{self.name} - {self.desc}"
def get_structured_info(self) -> dict:
info = super().get_structured_info()
info["type_name"] = "城市区域"
return info
def _normalize_region_name(name: str) -> str:
"""

View File

@@ -69,6 +69,19 @@ class Sect:
# 优先使用自定义名称,否则使用默认名称
return self.rank_names.get(rank.value, DEFAULT_RANK_NAMES.get(rank, "弟子"))
def get_structured_info(self) -> dict:
from src.utils.effect_desc import format_effects_to_text
hq = self.headquarter
return {
"name": self.name,
"desc": self.desc,
"alignment": self.alignment.value,
"style": self.member_act_style,
"hq_name": hq.name,
"hq_desc": hq.desc,
"effect_desc": format_effects_to_text(self.effects),
}
def _split_names(value: object) -> list[str]:
raw = "" if value is None or str(value) == "nan" else str(value)
sep = CONFIG.df.ids_separator

View File

@@ -24,4 +24,8 @@ class SectRegion(Region):
f"描述: {self.desc}",
]
def get_structured_info(self) -> dict:
info = super().get_structured_info()
info["type_name"] = "宗门驻地"
info["sect_name"] = self.sect_name
return info

View File

@@ -39,4 +39,13 @@ class SpiritAnimal:
pts = self.get_extra_strength_points()
return {"extra_battle_strength_points": pts} if pts else {}
def get_structured_info(self) -> dict:
from src.utils.effect_desc import format_effects_to_text
return {
"name": self.name,
"desc": f"境界:{self.realm.value}",
"grade": self.realm.value,
"effect_desc": format_effects_to_text(self.effects),
}

View File

@@ -83,6 +83,17 @@ class Technique:
r, g, b = self.grade.color_rgb
return f"<color:{r},{g},{b}>{self.name}{self.attribute}·{self.grade.value}</color>"
def get_structured_info(self) -> dict:
from src.utils.effect_desc import format_effects_to_text
return {
"name": self.name,
"desc": self.desc,
"grade": self.grade.value,
"color": self.grade.color_rgb,
"attribute": self.attribute.value,
"effect_desc": format_effects_to_text(self.effects),
}
# 五行与扩展属性的克制关系
# - 五行:金克木,木克土,土克水,水克火,火克金
# - 雷克邪;邪、冰、风、暗不克任何人

View File

@@ -51,6 +51,28 @@ class Weapon:
r, g, b = self.grade.color_rgb
return f"<color:{r},{g},{b}>{self.get_info()}</color>"
def get_structured_info(self) -> dict:
from src.utils.effect_desc import format_effects_to_text
# 基础描述
full_desc = self.desc
# 特殊数据处理
souls = 0
if self.name == "万魂幡":
souls = self.special_data.get("devoured_souls", 0)
if souls > 0:
full_desc = f"{full_desc} (已吞噬魂魄:{souls})"
return {
"name": self.name,
"desc": full_desc,
"grade": self.grade.value,
"color": self.grade.color_rgb,
"type": self.weapon_type.value,
"effect_desc": format_effects_to_text(self.effects),
}
def _load_weapons() -> tuple[Dict[int, Weapon], Dict[str, Weapon], Dict[int, Weapon]]:
"""从配表加载 weapon 数据。

View File

@@ -475,6 +475,45 @@ def get_hover_info(
"lines": serialize_hover_lines([str(line) for line in lines]),
}
@app.get("/api/detail")
def get_detail_info(
target_type: str = Query(alias="type"),
target_id: str = Query(alias="id")
):
"""获取结构化详情信息,替代/增强 hover info"""
world = game_instance.get("world")
if world is None:
raise HTTPException(status_code=503, detail="World not initialized")
target = None
if target_type == "avatar":
target = world.avatar_manager.avatars.get(target_id)
elif target_type == "region":
if world.map and hasattr(world.map, "regions"):
regions = world.map.regions
target = regions.get(target_id)
if target is None:
try:
target = regions.get(int(target_id))
except (ValueError, TypeError):
target = None
if target is None:
raise HTTPException(status_code=404, detail="Target not found")
if hasattr(target, "get_structured_info"):
return target.get_structured_info()
else:
# 回退到 hover info 如果没有结构化信息
if hasattr(target, "get_hover_info"):
lines = target.get_hover_info() or []
return {
"fallback": True,
"name": getattr(target, "name", target_id),
"lines": serialize_hover_lines([str(line) for line in lines])
}
return {"error": "No info available"}
class SetObjectiveRequest(BaseModel):
avatar_id: str
content: str

82
src/utils/effect_desc.py Normal file
View File

@@ -0,0 +1,82 @@
from typing import Any
EFFECT_DESC_MAP = {
"extra_hp_recovery_rate": "生命恢复速率",
"extra_max_hp": "最大生命值",
"extra_max_mp": "最大灵力值",
"extra_max_lifespan": "最大寿元",
"extra_weapon_proficiency_gain": "兵器熟练度获取",
"extra_dual_cultivation_exp": "双修经验",
"extra_breakthrough_success_rate": "突破成功率",
"extra_fortune_probability": "奇遇概率",
"extra_harvest_items": "采集获取物品",
"extra_hunt_items": "狩猎获取物品",
"extra_item_sell_price_multiplier": "物品出售价格",
"extra_weapon_upgrade_chance": "兵器升级概率",
"extra_plunder_multiplier": "掠夺收益",
"extra_catch_success_rate": "捕捉灵兽成功率",
"extra_cultivate_exp": "修炼经验",
"extra_battle_strength_points": "战力点数",
"extra_escape_success_rate": "逃跑成功率",
"extra_observation_radius": "感知范围",
"extra_move_step": "移动步长",
}
def format_value(key: str, value: Any) -> str:
"""
格式化效果数值
"""
if isinstance(value, (int, float)):
# 处理百分比类型的字段
if "rate" in key or "probability" in key or "chance" in key or "multiplier" in key or "gain" in key:
# 如果是小数,转为百分比。通常 0.1 表示 +10%
# 但有些可能是直接的倍率?代码里 1.0 + value所以 value 是增量
if isinstance(value, float):
percent = value * 100
sign = "+" if percent > 0 else ""
return f"{sign}{percent:.1f}%"
# 处理数值类型的字段
sign = "+" if value > 0 else ""
return f"{sign}{value}"
return str(value)
def format_effects_to_text(effects: dict[str, Any] | list[dict[str, Any]]) -> str:
"""
将 effects 字典转换为易读的文本描述。
例如:{"extra_max_hp": 100} -> "最大生命值 +100"
"""
if not effects:
return ""
if isinstance(effects, list):
parts = []
for eff in effects:
text = format_effects_to_text(eff)
if text:
if eff.get("when"):
parts.append(f"[条件触发] {text}")
else:
parts.append(text)
return "\n".join(parts)
desc_list = []
for k, v in effects.items():
if k == "when":
continue
# 跳过 eval 表达式或者无法解析的 key或者直接显示 key
name = EFFECT_DESC_MAP.get(k, k)
# 如果是 eval 表达式(字符串形式)
if isinstance(v, str) and v.startswith("eval("):
# 尝试提取简单的描述,或者显示"特殊效果"
val_str = "特殊效果"
else:
val_str = format_value(k, v)
desc_list.append(f"{name} {val_str}")
return "".join(desc_list)

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useGameStore } from '../stores/game'
import type { IEffectEntity } from '../types/game'
const store = useGameStore()
const panelRef = ref<HTMLElement | null>(null)
@@ -9,13 +10,22 @@ let lastOpenAt = 0
const showObjectiveModal = ref(false)
const objectiveContent = ref('')
const title = computed(() => store.selectedTarget?.name ?? '')
// Secondary info state
const secondaryItem = ref<IEffectEntity | null>(null)
const title = computed(() => {
if (store.detailInfo && !store.detailInfo.fallback) {
return store.detailInfo.name
}
return store.selectedTarget?.name ?? ''
})
watch(
() => store.selectedTarget,
(target) => {
showObjectiveModal.value = false
objectiveContent.value = ''
secondaryItem.value = null
if (target) {
lastOpenAt = performance.now()
}
@@ -48,6 +58,32 @@ async function handleClearObjective() {
}
}
function showSecondary(item: IEffectEntity | undefined) {
if (item) {
secondaryItem.value = item
}
}
function jumpToAvatar(id: string) {
store.openInfoPanel({ type: 'avatar', id })
}
// Helper for colors
function getRarityColor(item: any) {
if (!item) return undefined
if (item.color && Array.isArray(item.color) && item.color.length === 3) {
const [r, g, b] = item.color
return `rgb(${r},${g},${b})`
}
// Map grade string to color if no color prop
const grade = item.grade || item.rarity
if (!grade) return undefined
if (['上品', '宝物', 'SR', 'Upper'].some(s => grade.includes(s))) return '#c488fd' // Purple
if (['中品', 'R', 'Middle'].some(s => grade.includes(s))) return '#88fdc4' // Green? Usually blue
if (['法宝', 'SSR', 'Artifact'].some(s => grade.includes(s))) return '#fddc88' // Gold
return undefined
}
onMounted(() => {
document.addEventListener('pointerdown', handleDocumentPointerDown)
})
@@ -63,20 +99,252 @@ onUnmounted(() => {
class="info-panel"
ref="panelRef"
>
<!-- Secondary Panel (Popup) -->
<div v-if="secondaryItem" class="secondary-panel">
<div class="sec-header">
<span class="sec-title" :style="{ color: getRarityColor(secondaryItem) }">{{ secondaryItem.name }}</span>
<button class="close-btn-small" @click="secondaryItem = null">×</button>
</div>
<div class="sec-body">
<div class="sec-row" v-if="secondaryItem.grade || secondaryItem.rarity">
<span class="tag">{{ secondaryItem.grade || secondaryItem.rarity }}</span>
</div>
<div class="sec-desc">{{ secondaryItem.desc }}</div>
<div v-if="secondaryItem.effect_desc" class="sec-effect-box">
<div class="sec-label">效果</div>
<div class="sec-effect-text">{{ secondaryItem.effect_desc }}</div>
</div>
<!-- 特殊字段展示 -->
<div v-if="secondaryItem.hq_name" class="sec-extra">
<div><strong>驻地:</strong> {{ secondaryItem.hq_name }}</div>
<div class="sub-desc">{{ secondaryItem.hq_desc }}</div>
</div>
</div>
</div>
<div class="panel-actions" v-if="store.selectedTarget.type === 'avatar'">
<button class="action-btn primary" @click="showObjectiveModal = true">设定长期目标</button>
<button class="action-btn secondary" @click="handleClearObjective">清空长期目标</button>
</div>
<button class="close-btn-absolute" type="button" @click="store.closeInfoPanel()">×</button>
<div class="info-header">
<div class="info-title">{{ title || '详情' }}</div>
<button class="close-btn" type="button" @click="store.closeInfoPanel()">×</button>
<div class="header-right">
<span v-if="store.detailInfo && !store.detailInfo.fallback" class="header-subtitle">{{ store.detailInfo.nickname || '' }}</span>
</div>
</div>
<div class="info-body">
<div v-if="store.infoLoading" class="placeholder">加载中...</div>
<div v-else-if="store.infoError" class="placeholder error">
{{ store.infoError }}
</div>
<!-- 结构化数据展示 -->
<div v-else-if="store.detailInfo && !store.detailInfo.fallback && store.selectedTarget.type === 'avatar'" class="structured-content">
<!-- 基础属性 -->
<div class="stats-grid">
<div class="stat-item">
<label>境界</label>
<span>{{ store.detailInfo.realm }} ({{ store.detailInfo.level }})</span>
</div>
<div class="stat-item">
<label>年龄</label>
<span>{{ store.detailInfo.age }} / {{ store.detailInfo.lifespan }}</span>
</div>
<div class="stat-item">
<label>HP</label>
<span>{{ Math.floor(store.detailInfo.hp.cur) }}/{{ store.detailInfo.hp.max }}</span>
</div>
<div class="stat-item">
<label>灵石</label>
<span>{{ store.detailInfo.magic_stone }}</span>
</div>
<div
class="stat-item full clickable"
@click="showSecondary(store.detailInfo.alignment_detail)"
v-if="store.detailInfo.alignment_detail"
>
<label>阵营</label>
<span>{{ store.detailInfo.alignment }}</span>
</div>
<div class="stat-item full" v-else>
<label>阵营</label>
<span>{{ store.detailInfo.alignment }}</span>
</div>
<div
class="stat-item full clickable"
@click="showSecondary(store.detailInfo.root_detail)"
v-if="store.detailInfo.root_detail"
>
<label>灵根</label>
<span>{{ store.detailInfo.root }}</span>
</div>
<div class="stat-item full" v-else>
<label>灵根</label>
<span>{{ store.detailInfo.root }}</span>
</div>
</div>
<!-- 状态 -->
<div class="section" v-if="store.detailInfo.thinking">
<div class="section-title">当前思考</div>
<div class="text-content">{{ store.detailInfo.thinking }}</div>
</div>
<!-- 特质 -->
<div class="section" v-if="store.detailInfo.personas?.length">
<div class="section-title">特质</div>
<div class="tags-container">
<span
v-for="p in store.detailInfo.personas"
:key="p.name"
class="clickable-tag"
:style="{ borderColor: getRarityColor(p) }"
@click="showSecondary(p)"
>
{{ p.name }}
</span>
</div>
</div>
<!-- 功法 -->
<div class="section" v-if="store.detailInfo.technique">
<div class="section-title">功法</div>
<div class="clickable-item" @click="showSecondary(store.detailInfo.technique)">
<span :style="{ color: getRarityColor(store.detailInfo.technique) }">{{ store.detailInfo.technique.name }}</span>
<span class="item-meta">{{ store.detailInfo.technique.grade }}</span>
</div>
</div>
<!-- 宗门 -->
<div class="section" v-if="store.detailInfo.sect">
<div class="section-title">宗门</div>
<div class="clickable-item" @click="showSecondary(store.detailInfo.sect)">
<span>{{ store.detailInfo.sect.name }}</span>
<span class="item-meta">{{ store.detailInfo.sect.rank }}</span>
</div>
</div>
<!-- 装备 -->
<div class="section" v-if="store.detailInfo.weapon || store.detailInfo.auxiliary">
<div class="section-title">装备</div>
<div v-if="store.detailInfo.weapon" class="clickable-item" @click="showSecondary(store.detailInfo.weapon)">
<span :style="{ color: getRarityColor(store.detailInfo.weapon) }">{{ store.detailInfo.weapon.name }}</span>
<span class="item-meta">熟练度 {{ store.detailInfo.weapon.proficiency }}</span>
</div>
<div v-if="store.detailInfo.auxiliary" class="clickable-item" @click="showSecondary(store.detailInfo.auxiliary)">
<span :style="{ color: getRarityColor(store.detailInfo.auxiliary) }">{{ store.detailInfo.auxiliary.name }}</span>
</div>
</div>
<!-- 灵兽 -->
<div class="section" v-if="store.detailInfo.spirit_animal">
<div class="section-title">灵兽</div>
<div class="clickable-item" @click="showSecondary(store.detailInfo.spirit_animal)">
<span :style="{ color: getRarityColor(store.detailInfo.spirit_animal) }">{{ store.detailInfo.spirit_animal.name }}</span>
<span class="item-meta">{{ store.detailInfo.spirit_animal.grade }}</span>
</div>
</div>
<!-- 物品 -->
<div class="section" v-if="store.detailInfo.items?.length">
<div class="section-title">物品</div>
<div class="items-list">
<div
v-for="item in store.detailInfo.items"
:key="item.name"
class="clickable-item small"
@click="showSecondary(item)"
>
{{ item.name }} x{{ item.count }}
</div>
</div>
</div>
<!-- 关系 -->
<div class="section" v-if="store.detailInfo.relations?.length">
<div class="section-title">关系</div>
<div class="relations-list">
<div
v-for="rel in store.detailInfo.relations"
:key="rel.target_id"
class="relation-item"
@click="jumpToAvatar(rel.target_id)"
>
<div class="rel-name">{{ rel.name }}</div>
<div class="rel-desc">{{ rel.relation }}</div>
<div class="rel-meta">{{ rel.sect }} · {{ rel.realm }}</div>
</div>
</div>
</div>
<!-- 目标 -->
<div class="section">
<div class="section-title">长期目标</div>
<div class="text-content">{{ store.detailInfo.long_term_objective || '无' }}</div>
</div>
<div class="section">
<div class="section-title">短期目标</div>
<div class="text-content">{{ store.detailInfo.short_term_objective || '无' }}</div>
</div>
</div>
<!-- 结构化数据展示 (Region) -->
<div v-else-if="store.detailInfo && !store.detailInfo.fallback && store.selectedTarget.type === 'region'" class="structured-content">
<!-- Type & Desc -->
<div class="section">
<div class="section-title">{{ store.detailInfo.type_name }}</div>
<div class="text-content">{{ store.detailInfo.desc }}</div>
</div>
<!-- Cultivate Region: Essence -->
<div class="section" v-if="store.detailInfo.essence">
<div class="section-title">灵气环境</div>
<div class="text-content">
{{ store.detailInfo.essence.type }}行灵气 · 浓度 {{ store.detailInfo.essence.density }}
</div>
</div>
<!-- Normal Region: Animals -->
<div class="section" v-if="store.detailInfo.animals?.length">
<div class="section-title">动物分布</div>
<div class="items-list">
<div
v-for="animal in store.detailInfo.animals"
:key="animal.name"
class="clickable-item small"
@click="showSecondary(animal)"
>
<span :style="{ color: getRarityColor(animal) }">{{ animal.name }}</span>
<span class="item-meta">{{ animal.grade }}</span>
</div>
</div>
</div>
<!-- Normal Region: Plants -->
<div class="section" v-if="store.detailInfo.plants?.length">
<div class="section-title">植物分布</div>
<div class="items-list">
<div
v-for="plant in store.detailInfo.plants"
:key="plant.name"
class="clickable-item small"
@click="showSecondary(plant)"
>
<span :style="{ color: getRarityColor(plant) }">{{ plant.name }}</span>
<span class="item-meta">{{ plant.grade }}</span>
</div>
</div>
</div>
</div>
<!-- Legacy / Fallback 展示 -->
<ul v-else-if="store.hoverInfo.length" class="info-list">
<li v-for="(line, index) in store.hoverInfo" :key="index">
<template v-if="line.length">
@@ -114,7 +382,7 @@ onUnmounted(() => {
<style scoped>
.info-panel {
position: absolute;
top: 20px;
top: 60px;
right: 20px;
width: 320px;
max-height: calc(100vh - 40px);
@@ -126,7 +394,7 @@ onUnmounted(() => {
pointer-events: auto;
display: flex;
flex-direction: column;
overflow: visible; /* Allow modal to show outside */
overflow: visible;
}
.panel-actions {
@@ -187,6 +455,17 @@ onUnmounted(() => {
font-weight: bold;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.header-subtitle {
font-size: 12px;
color: #888;
}
.close-btn {
background: transparent;
border: none;
@@ -201,15 +480,41 @@ onUnmounted(() => {
color: #fff;
}
.close-btn-absolute {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
background: rgba(0,0,0,0.5);
border: 1px solid #555;
border-radius: 4px;
color: #ccc;
font-size: 18px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 20; /* Higher than panel-actions */
transition: all 0.2s;
}
.close-btn-absolute:hover {
background: rgba(255,255,255,0.2);
color: #fff;
border-color: #888;
}
.info-body {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
/* 确保body内容滚动时圆角 */
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
/* Legacy List Styles */
.info-list {
list-style: none;
padding: 0;
@@ -226,6 +531,259 @@ onUnmounted(() => {
white-space: pre-wrap;
}
/* Structured Content Styles */
.structured-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
background: rgba(255,255,255,0.03);
padding: 8px;
border-radius: 6px;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-item.full {
grid-column: span 2;
}
.stat-item.clickable {
cursor: pointer;
transition: background 0.2s;
border-radius: 4px;
padding: 4px;
margin: -4px;
border: 1px solid transparent;
}
.stat-item.clickable:hover {
background: rgba(255,255,255,0.08);
border-color: rgba(255,255,255,0.1);
}
.stat-item label {
font-size: 11px;
color: #888;
}
.stat-item span {
font-size: 13px;
color: #ddd;
font-weight: 500;
}
.section {
display: flex;
flex-direction: column;
gap: 6px;
}
.section-title {
font-size: 12px;
font-weight: bold;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #333;
padding-bottom: 4px;
}
.text-content {
font-size: 13px;
line-height: 1.5;
color: #ccc;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.clickable-tag {
font-size: 12px;
padding: 2px 8px;
background: rgba(255,255,255,0.05);
border: 1px solid #444;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
.clickable-tag:hover {
background: rgba(255,255,255,0.1);
border-color: #666;
}
.clickable-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
background: rgba(255,255,255,0.03);
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
}
.clickable-item:hover {
background: rgba(255,255,255,0.08);
}
.clickable-item.small {
padding: 4px 8px;
font-size: 12px;
}
.item-meta {
font-size: 11px;
color: #888;
}
.relations-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.relation-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px;
background: rgba(0,0,0,0.2);
border-left: 2px solid #333;
cursor: pointer;
transition: all 0.2s;
}
.relation-item:hover {
background: rgba(255,255,255,0.05);
border-left-color: #666;
}
.rel-name {
font-size: 13px;
font-weight: bold;
color: #eee;
}
.rel-desc {
font-size: 12px;
color: #aaa;
}
.rel-meta {
font-size: 11px;
color: #666;
}
/* Secondary Panel Styles */
.secondary-panel {
position: absolute;
top: 60px;
right: 100%;
margin-right: 12px;
width: 260px;
background: rgba(32, 32, 32, 0.98);
border: 1px solid #555;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 25px rgba(0, 0, 0, 0.8);
z-index: 90;
display: flex;
flex-direction: column;
gap: 12px;
}
.sec-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #444;
padding-bottom: 8px;
}
.sec-title {
font-size: 15px;
font-weight: bold;
}
.close-btn-small {
background: transparent;
border: none;
color: #888;
font-size: 16px;
cursor: pointer;
}
.close-btn-small:hover {
color: #fff;
}
.sec-body {
display: flex;
flex-direction: column;
gap: 10px;
font-size: 13px;
color: #ccc;
}
.tag {
display: inline-block;
padding: 2px 6px;
background: #444;
border-radius: 4px;
font-size: 11px;
color: #fff;
}
.sec-desc {
line-height: 1.5;
}
.sec-effect-box {
background: rgba(0,0,0,0.2);
padding: 8px;
border-radius: 4px;
border: 1px solid #444;
}
.sec-label {
font-size: 11px;
color: #888;
margin-bottom: 4px;
}
.sec-effect-text {
color: #ffd700; /* Gold for effects */
font-size: 12px;
line-height: 1.4;
}
.sec-extra {
font-size: 12px;
border-top: 1px solid #444;
padding-top: 8px;
}
.sub-desc {
color: #888;
margin-top: 2px;
}
.placeholder {
font-size: 13px;
color: #888;
@@ -239,7 +797,7 @@ onUnmounted(() => {
.objective-modal {
position: absolute;
top: 0;
right: 100%; /* Position to the left of the panel */
right: 100%;
margin-right: 12px;
width: 280px;
background: rgba(32, 32, 32, 0.98);

View File

@@ -2,7 +2,9 @@ import type {
HoverResponse,
HoverTarget,
InitialStateResponse,
MapResponse
MapResponse,
IAvatarDetail,
DetailInfo
} from '../types/game'
import { apiGet, apiPost } from './apiClient'
@@ -14,6 +16,14 @@ function buildHoverQuery(target: HoverTarget) {
return `/api/hover?${query.toString()}`
}
function buildDetailQuery(target: HoverTarget) {
const query = new URLSearchParams({
type: target.type,
id: target.id
})
return `/api/detail?${query.toString()}`
}
export interface SaveFile {
filename: string
save_time: string
@@ -30,6 +40,10 @@ export const gameApi = {
return apiGet<HoverResponse>(buildHoverQuery(target))
},
getDetailInfo(target: HoverTarget) {
return apiGet<DetailInfo>(buildDetailQuery(target))
},
getMap() {
return apiGet<MapResponse>('/api/map')
},

View File

@@ -5,7 +5,9 @@ import type {
GameEvent,
HoverLine,
HoverTarget,
TickPayload
TickPayload,
IAvatarDetail,
DetailInfo
} from '../types/game'
import { gameApi } from '../services/gameApi'
import { createGameGateway } from '../services/gameGateway'
@@ -41,6 +43,7 @@ export const useGameStore = defineStore('game', () => {
const events = ref<GameEvent[]>([])
const selectedTarget = ref<HoverTarget | null>(null)
const hoverInfo = ref<HoverLine[]>([])
const detailInfo = ref<DetailInfo | null>(null)
const infoLoading = ref(false)
const infoError = ref<string | null>(null)
const hoverCache = new Map<string, HoverLine[]>()
@@ -74,7 +77,11 @@ export const useGameStore = defineStore('game', () => {
// If panel is open, silently refresh content to show latest status
if (selectedTarget.value) {
fetchHoverInfo(selectedTarget.value, { force: true, silent: true })
if (selectedTarget.value.type === 'avatar') {
fetchDetailInfo(selectedTarget.value, { silent: true })
} else {
fetchHoverInfo(selectedTarget.value, { force: true, silent: true })
}
}
}
@@ -125,6 +132,38 @@ export const useGameStore = defineStore('game', () => {
}
}
async function fetchDetailInfo(target: HoverTarget, options: { silent?: boolean } = {}) {
const { silent = false } = options
if (!silent) {
infoLoading.value = true
infoError.value = null
detailInfo.value = null
}
try {
const data = await gameApi.getDetailInfo(target)
// Check if result matches current selection (race condition)
if (selectedTarget.value && selectedTarget.value.id === target.id) {
detailInfo.value = data
// If fallback is true, we might also want to populate hoverInfo for legacy support,
// but InfoPanel should handle detailInfo with lines fallback.
}
} catch (error) {
if (selectedTarget.value && selectedTarget.value.id === target.id) {
if (!silent) {
infoError.value = error instanceof Error ? error.message : String(error)
detailInfo.value = null
}
}
} finally {
if (selectedTarget.value && selectedTarget.value.id === target.id) {
if (!silent) {
infoLoading.value = false
}
}
}
}
async function fetchHoverInfo(target: HoverTarget, options: { force?: boolean, silent?: boolean } = {}) {
const { force = false, silent = false } = options
const key = cacheKey(target)
@@ -176,7 +215,7 @@ export const useGameStore = defineStore('game', () => {
await gameApi.setLongTermObjective(avatarId, content)
// 成功后刷新 info panel
if (selectedTarget.value && selectedTarget.value.id === avatarId && selectedTarget.value.type === 'avatar') {
await fetchHoverInfo(selectedTarget.value, { force: true })
await fetchDetailInfo(selectedTarget.value, { silent: true })
}
}
@@ -184,7 +223,7 @@ export const useGameStore = defineStore('game', () => {
await gameApi.clearLongTermObjective(avatarId)
// 成功后刷新 info panel
if (selectedTarget.value && selectedTarget.value.id === avatarId && selectedTarget.value.type === 'avatar') {
await fetchHoverInfo(selectedTarget.value, { force: true })
await fetchDetailInfo(selectedTarget.value, { silent: true })
}
}
@@ -218,13 +257,18 @@ export const useGameStore = defineStore('game', () => {
function openInfoPanel(target: HoverTarget) {
selectedTarget.value = target
fetchHoverInfo(target)
if (target.type === 'avatar' || target.type === 'region') {
fetchDetailInfo(target)
} else {
fetchHoverInfo(target)
}
}
function closeInfoPanel() {
selectedTarget.value = null
infoError.value = null
hoverInfo.value = []
detailInfo.value = null
infoLoading.value = false
}
@@ -237,6 +281,7 @@ export const useGameStore = defineStore('game', () => {
events,
selectedTarget,
hoverInfo,
detailInfo,
infoLoading,
infoError,
worldVersion, // 导出

View File

@@ -69,3 +69,87 @@ export interface MapResponse {
regions: Region[]
}
// --- Structured Info Types ---
export interface IEffectEntity {
name: string;
desc?: string;
effect_desc?: string;
grade?: string;
rarity?: string;
type?: string;
// some entities might have custom fields
[key: string]: any;
}
export interface ISectInfo extends IEffectEntity {
alignment: string;
style: string;
hq_name: string;
hq_desc: string;
rank: string;
}
export interface IItemInfo extends IEffectEntity {
count: number;
}
export interface IRelationInfo {
target_id: string;
name: string;
relation: string;
realm: string;
sect: string;
}
export interface IAvatarDetail {
id: string;
name: string;
gender: string;
age: number;
lifespan: number;
realm: string;
level: number;
hp: { cur: number; max: number };
mp: { cur: number; max: number };
alignment: string;
alignment_detail?: IEffectEntity;
magic_stone: number;
thinking: string;
short_term_objective: string;
long_term_objective: string;
nickname?: string;
personas: IEffectEntity[];
technique?: IEffectEntity;
sect?: ISectInfo;
weapon?: IEffectEntity & { proficiency: string };
auxiliary?: IEffectEntity;
items: IItemInfo[];
relations: IRelationInfo[];
appearance: string;
root: string;
root_detail?: IEffectEntity;
spirit_animal?: IEffectEntity;
// Fallback for non-avatar targets or legacy
fallback?: boolean;
lines?: unknown; // HoverLines
}
export interface IRegionDetail {
id: string | number;
name: string;
desc: string;
type: string;
type_name: string;
animals?: IEffectEntity[];
plants?: IEffectEntity[];
essence?: { type: string; density: number };
fallback?: boolean;
lines?: unknown;
}
export type DetailInfo = IAvatarDetail | IRegionDetail;