refactor web
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useTextures } from './composables/useTextures'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { Graphics, Ticker } from 'pixi.js'
|
||||
import { useApplication } from 'vue3-pixi'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Graphics } from 'pixi.js'
|
||||
import type { Avatar } from '../../types/game'
|
||||
import { useSharedTicker } from './composables/useSharedTicker'
|
||||
|
||||
const props = defineProps<{
|
||||
avatar: any
|
||||
avatar: Avatar
|
||||
tileSize: number
|
||||
}>()
|
||||
|
||||
@@ -14,7 +15,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { textures } = useTextures()
|
||||
const app = useApplication()
|
||||
|
||||
// Target position (grid coordinates)
|
||||
const targetX = ref(props.avatar.x)
|
||||
@@ -30,34 +30,23 @@ watch(() => [props.avatar.x, props.avatar.y], ([newX, newY]) => {
|
||||
targetY.value = newY
|
||||
})
|
||||
|
||||
// Animation Loop
|
||||
onMounted(() => {
|
||||
const ticker = new Ticker()
|
||||
ticker.add((delta) => {
|
||||
const destX = targetX.value * props.tileSize + props.tileSize / 2
|
||||
const destY = targetY.value * props.tileSize + props.tileSize / 2
|
||||
|
||||
// Simple Lerp for smoothness
|
||||
// Speed factor: 0.1 means it covers 10% of the remaining distance per frame
|
||||
const speed = 0.1 * delta.deltaTime
|
||||
|
||||
if (Math.abs(destX - currentX.value) > 1) {
|
||||
currentX.value += (destX - currentX.value) * speed
|
||||
} else {
|
||||
currentX.value = destX
|
||||
}
|
||||
|
||||
if (Math.abs(destY - currentY.value) > 1) {
|
||||
currentY.value += (destY - currentY.value) * speed
|
||||
} else {
|
||||
currentY.value = destY
|
||||
}
|
||||
})
|
||||
ticker.start()
|
||||
useSharedTicker((delta) => {
|
||||
const destX = targetX.value * props.tileSize + props.tileSize / 2
|
||||
const destY = targetY.value * props.tileSize + props.tileSize / 2
|
||||
|
||||
// Cleanup manually if needed, though Vue unmount should handle parent destruction
|
||||
// Ideally we should attach to app.ticker, but local ticker is easier for per-component logic without memory leaks if managed well.
|
||||
// Better approach: use onTick from vue3-pixi if available, or just requestAnimationFrame
|
||||
const speed = 0.1 * delta
|
||||
|
||||
if (Math.abs(destX - currentX.value) > 1) {
|
||||
currentX.value += (destX - currentX.value) * speed
|
||||
} else {
|
||||
currentX.value = destX
|
||||
}
|
||||
|
||||
if (Math.abs(destY - currentY.value) > 1) {
|
||||
currentY.value += (destY - currentY.value) * speed
|
||||
} else {
|
||||
currentY.value = destY
|
||||
}
|
||||
})
|
||||
|
||||
function getTexture() {
|
||||
|
||||
@@ -1,141 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { Container, Sprite } from 'pixi.js'
|
||||
import { useTextures } from './composables/useTextures'
|
||||
import { useMapData } from '../../composables/useMapData'
|
||||
import type { Region } from '../../types/game'
|
||||
|
||||
const TILE_SIZE = 64
|
||||
const mapContainer = ref<Container>()
|
||||
const { textures, isLoaded, loadSectTexture } = useTextures()
|
||||
const TILE_SIZE = 64
|
||||
const regions = ref<any[]>([])
|
||||
const { mapTiles, regions, isMapLoaded, loadMapData } = useMapData()
|
||||
const regionStyleCache = new Map<string, Record<string, unknown>>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'mapLoaded', payload: { width: number, height: number }): void
|
||||
(e: 'regionSelected', payload: { type: 'region'; id: string; name?: string }): void
|
||||
}>()
|
||||
|
||||
async function initMap() {
|
||||
if (!mapContainer.value || !isLoaded.value) return
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/map')
|
||||
const data = await res.json()
|
||||
const mapData = data.data
|
||||
regions.value = data.regions || []
|
||||
|
||||
// 1. 预加载所有宗门的纹理
|
||||
const loadPromises: Promise<void>[] = []
|
||||
for (const r of regions.value) {
|
||||
if (r.type === 'sect' && r.sect_name) {
|
||||
// 使用 sect_name(宗门名)而不是 name(总部名)来加载图片
|
||||
loadPromises.push(loadSectTexture(r.sect_name))
|
||||
}
|
||||
}
|
||||
await Promise.all(loadPromises)
|
||||
|
||||
if (!mapData) return
|
||||
|
||||
// Imperative Tile Rendering
|
||||
mapContainer.value.removeChildren()
|
||||
|
||||
const rows = mapData.length
|
||||
const cols = mapData[0].length
|
||||
|
||||
console.log(`Rendering Map: ${cols}x${rows}`)
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const type = mapData[y][x]
|
||||
|
||||
// 占位符直接跳过,不渲染任何东西(让背景透出来,或者就空着)
|
||||
if (type === 'PLACEHOLDER') continue
|
||||
|
||||
let tex = textures.value[type]
|
||||
|
||||
// 特殊处理 SECT 类型
|
||||
if (type === 'SECT') {
|
||||
const r = regions.value.find(r =>
|
||||
r.type === 'sect' && Math.abs(r.x - x) < 3 && Math.abs(r.y - y) < 3
|
||||
)
|
||||
|
||||
if (r && r.sect_name) {
|
||||
// 使用 sect_name(宗门名)而不是 name(总部名)来匹配图片
|
||||
const sectKey = `SECT_${r.sect_name}`
|
||||
if (textures.value[sectKey]) {
|
||||
tex = textures.value[sectKey]
|
||||
} else {
|
||||
tex = textures.value['CITY']
|
||||
}
|
||||
} else {
|
||||
tex = textures.value['CITY']
|
||||
}
|
||||
}
|
||||
|
||||
if (!tex) tex = textures.value['PLAIN']
|
||||
|
||||
if (tex) {
|
||||
const s = new Sprite(tex)
|
||||
s.x = x * TILE_SIZE
|
||||
s.y = y * TILE_SIZE
|
||||
|
||||
// 2x2 大地块渲染逻辑
|
||||
if (['SECT', 'CITY', 'CAVE', 'RUINS'].includes(type)) {
|
||||
s.width = TILE_SIZE * 2
|
||||
s.height = TILE_SIZE * 2
|
||||
// 确保层级正确,大建筑可以稍微调整 zIndex 如果有深度排序需求
|
||||
// 但在这里 tile 是平铺的,只要顺序对就行
|
||||
} else {
|
||||
s.width = TILE_SIZE
|
||||
s.height = TILE_SIZE
|
||||
}
|
||||
|
||||
s.eventMode = 'none'
|
||||
mapContainer.value.addChild(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit('mapLoaded', {
|
||||
width: cols * TILE_SIZE,
|
||||
height: rows * TILE_SIZE
|
||||
})
|
||||
|
||||
} catch (e) {
|
||||
console.error("Map load error", e)
|
||||
onMounted(() => {
|
||||
loadMapData().catch((error) => console.error('Map load error', error))
|
||||
if (isLoaded.value && isMapLoaded.value) {
|
||||
renderMap()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [isLoaded.value, isMapLoaded.value],
|
||||
([texturesReady, mapReady]) => {
|
||||
if (texturesReady && mapReady) {
|
||||
renderMap()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function renderMap() {
|
||||
if (!mapContainer.value || !mapTiles.value.length) return
|
||||
|
||||
await preloadSectTextures(regions.value)
|
||||
mapContainer.value.removeChildren()
|
||||
|
||||
const rows = mapTiles.value.length
|
||||
const cols = mapTiles.value[0]?.length ?? 0
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const type = mapTiles.value[y][x]
|
||||
if (type === 'PLACEHOLDER') continue
|
||||
|
||||
let tex = textures.value[type]
|
||||
|
||||
if (type === 'SECT') {
|
||||
tex = resolveSectTexture(x, y) ?? textures.value['CITY']
|
||||
}
|
||||
|
||||
if (!tex) {
|
||||
tex = textures.value['PLAIN']
|
||||
}
|
||||
|
||||
if (!tex) continue
|
||||
|
||||
const sprite = new Sprite(tex)
|
||||
sprite.x = x * TILE_SIZE
|
||||
sprite.y = y * TILE_SIZE
|
||||
|
||||
if (['SECT', 'CITY', 'CAVE', 'RUINS'].includes(type)) {
|
||||
sprite.width = TILE_SIZE * 2
|
||||
sprite.height = TILE_SIZE * 2
|
||||
} else {
|
||||
sprite.width = TILE_SIZE
|
||||
sprite.height = TILE_SIZE
|
||||
}
|
||||
|
||||
sprite.eventMode = 'none'
|
||||
mapContainer.value.addChild(sprite)
|
||||
}
|
||||
}
|
||||
|
||||
emit('mapLoaded', {
|
||||
width: cols * TILE_SIZE,
|
||||
height: rows * TILE_SIZE
|
||||
})
|
||||
}
|
||||
|
||||
watch(isLoaded, (val) => {
|
||||
if (val) initMap()
|
||||
})
|
||||
async function preloadSectTextures(regionList: Region[]) {
|
||||
const sectNames = Array.from(
|
||||
new Set(
|
||||
regionList
|
||||
.filter(region => region.type === 'sect' && region.sect_name)
|
||||
.map(region => region.sect_name as string)
|
||||
)
|
||||
)
|
||||
await Promise.all(sectNames.map(name => loadSectTexture(name)))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isLoaded.value) initMap()
|
||||
})
|
||||
function resolveSectTexture(x: number, y: number) {
|
||||
const region = regions.value.find(r =>
|
||||
r.type === 'sect' && Math.abs(r.x - x) < 3 && Math.abs(r.y - y) < 3
|
||||
)
|
||||
if (region?.sect_name) {
|
||||
const key = `SECT_${region.sect_name}`
|
||||
return textures.value[key] ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getRegionStyle(type: string) {
|
||||
const base = {
|
||||
fontFamily: '"Microsoft YaHei", sans-serif',
|
||||
fontSize: type === 'sect' ? 48 : 64,
|
||||
fill: type === 'sect' ? '#ffcc00' : (type === 'city' ? '#ccffcc' : '#ffffff'),
|
||||
stroke: { color: '#000000', width: 8, join: 'round' },
|
||||
align: 'center',
|
||||
dropShadow: {
|
||||
color: '#000000',
|
||||
blur: 4,
|
||||
angle: Math.PI / 6,
|
||||
distance: 4,
|
||||
alpha: 0.8
|
||||
}
|
||||
if (regionStyleCache.has(type)) {
|
||||
return regionStyleCache.get(type)
|
||||
}
|
||||
const style = {
|
||||
fontFamily: '"Microsoft YaHei", sans-serif',
|
||||
fontSize: type === 'sect' ? 48 : 64,
|
||||
fill: type === 'sect' ? '#ffcc00' : (type === 'city' ? '#ccffcc' : '#ffffff'),
|
||||
stroke: { color: '#000000', width: 8, join: 'round' },
|
||||
align: 'center',
|
||||
dropShadow: {
|
||||
color: '#000000',
|
||||
blur: 4,
|
||||
angle: Math.PI / 6,
|
||||
distance: 4,
|
||||
alpha: 0.8
|
||||
}
|
||||
return base
|
||||
}
|
||||
regionStyleCache.set(type, style)
|
||||
return style
|
||||
}
|
||||
|
||||
function handleRegionSelect(region: any) {
|
||||
emit('regionSelected', {
|
||||
type: 'region',
|
||||
id: String(region.id),
|
||||
name: region.name
|
||||
})
|
||||
function handleRegionSelect(region: Region) {
|
||||
emit('regionSelected', {
|
||||
type: 'region',
|
||||
id: String(region.id),
|
||||
name: region.name
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
28
web/src/components/game/composables/useSharedTicker.ts
Normal file
28
web/src/components/game/composables/useSharedTicker.ts
Normal file
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
53
web/src/components/layout/StatusBar.vue
Normal file
53
web/src/components/layout/StatusBar.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { useGameStore } from '../../stores/game'
|
||||
|
||||
const store = useGameStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="top-bar">
|
||||
<div class="left">
|
||||
<span class="title">修仙模拟器</span>
|
||||
<span class="status-dot" :class="{ connected: store.isConnected }"></span>
|
||||
</div>
|
||||
<div class="center">
|
||||
<span class="time">{{ store.year }}年 {{ store.month }}月</span>
|
||||
</div>
|
||||
<div class="right">
|
||||
<span>修士: {{ store.avatarList.length }}</span>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top-bar {
|
||||
height: 36px;
|
||||
background: #1f1f1f;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.top-bar .title {
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #ff4d4f;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
100
web/src/components/panels/EventPanel.vue
Normal file
100
web/src/components/panels/EventPanel.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useGameStore } from '../../stores/game'
|
||||
import { NSelect } from 'naive-ui'
|
||||
|
||||
const store = useGameStore()
|
||||
const filterValue = ref('all')
|
||||
|
||||
const filterOptions = computed(() => [
|
||||
{ label: '所有人', value: 'all' },
|
||||
...store.avatarList.map(avatar => ({ label: avatar.name ?? avatar.id, value: avatar.id }))
|
||||
])
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
const allEvents = Array.isArray(store.events) ? store.events : []
|
||||
if (filterValue.value === 'all') {
|
||||
return allEvents
|
||||
}
|
||||
return allEvents.filter(event => event.relatedAvatarIds.includes(filterValue.value))
|
||||
})
|
||||
|
||||
const emptyEventMessage = computed(() => (
|
||||
filterValue.value === 'all' ? '暂无事件' : '该修士暂无事件'
|
||||
))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="sidebar-section">
|
||||
<div class="sidebar-header">
|
||||
<h3>事件记录</h3>
|
||||
<n-select
|
||||
v-model:value="filterValue"
|
||||
:options="filterOptions"
|
||||
size="tiny"
|
||||
class="event-filter"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="filteredEvents.length === 0" class="empty">{{ emptyEventMessage }}</div>
|
||||
<div v-else class="event-list">
|
||||
<div v-for="event in filteredEvents" :key="event.id" class="event-item">
|
||||
{{ event.content || event.text }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #222;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.sidebar-header h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-filter {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #ddd;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.event-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user